From 569bc940401c723a1921f36f37bf88ae4fcb7865 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 22 Sep 2026 22:08:11 +0200 Subject: [PATCH 001/344] fix(validation): retain shared checks and nested transfer context --- src/Validation/CutsceneValidation.php | 13 +- src/Validation/ProjectValidator.php | 137 ++++++++++++------ .../Unit/Cutscenes/CutsceneValidationTest.php | 77 ++++++++++ 3 files changed, 175 insertions(+), 52 deletions(-) diff --git a/src/Validation/CutsceneValidation.php b/src/Validation/CutsceneValidation.php index c099638..7585748 100644 --- a/src/Validation/CutsceneValidation.php +++ b/src/Validation/CutsceneValidation.php @@ -204,6 +204,10 @@ protected function walkCinematicCommands( continue; // The Engine already refused the tree. } + $issues = [...$issues, ...$this->checkSharedCommandSemantics( + $command, $where, $context['npcIds'], $context['mapId'], cinematic: true, + )]; + $named = match ($type) { 'give_item' => ['inventory', 'item', 'item'], 'play_music' => ['bgm', 'music', 'track'], @@ -329,24 +333,25 @@ protected function walkCinematicCommonEvent( $commands = $this->commonEventScripts[$eventId]; $eventWhere = sprintf('%s common event %s', $where, $eventId); + $issues = []; try { CinematicScriptValidator::validate($commands, sprintf('%s:common_event:%s', $where, $eventId)); } catch (Throwable $throwable) { - return [Issue::error( + $issues[] = Issue::error( $eventWhere, $throwable->getMessage(), 'The Engine refuses this Common Event in a cinematic session; the message names the command path.', - )]; + ); } - return $this->walkCinematicCommands( + return [...$issues, ...$this->walkCinematicCommands( $commands, $eventWhere, $known, $context, [...$commonEventStack, $eventId], - ); + )]; } /** diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 12d2d33..beac5b8 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -3096,8 +3096,25 @@ protected function checkCommands( array $npcIdsByMap = [], array $commonEventStack = [], ): array + { + $context = ['npcIds' => $npcIds, 'mapId' => $mapId, 'npcIdsByMap' => $npcIdsByMap]; + + return $this->walkEventCommands($commands, $where, $known, $context, $commonEventStack); + } + + /** + * Nested sequences and Common Events share the caller's current map. + * Independent entry points receive a fresh context through checkCommands(). + * @param array $commands + * @param array $known + * @param array{npcIds: string[]|null, mapId: string|null, npcIdsByMap: array} $context + * @param string[] $commonEventStack + * @return Issue[] + */ + protected function walkEventCommands(array $commands, string $where, array $known, array &$context, array $commonEventStack = []): array { $issues = []; + $npcIdsByMap = $context['npcIdsByMap']; foreach ($commands as $command) { if (! is_array($command)) { @@ -3114,9 +3131,7 @@ protected function checkCommands( ); } - if ($type === 'knowledge') { - $issues = [...$issues, ...$this->checkKnowledgeCommand($command, $where)]; - } + $issues = [...$issues, ...$this->checkSharedCommandSemantics($command, $where, $context['npcIds'], $context['mapId'])]; $named = match ($type) { 'give_item' => ['inventory', 'item', 'item'], @@ -3136,13 +3151,6 @@ protected function checkCommands( ]; } - if ($type === 'move_route') { - $issues = [ - ...$issues, - ...$this->checkMovementRoute($command, $where, $npcIds, $mapId), - ]; - } - if ($type === 'common_event') { $eventId = trim(strval($command['id'] ?? '')); @@ -3164,13 +3172,11 @@ protected function checkCommands( if (in_array($eventId, $commonEventStack, true)) { $issues[] = $this->getCommonEventCycleIssue($where, [...$commonEventStack, $eventId]); } else { - $issues = [...$issues, ...$this->checkCommands( + $issues = [...$issues, ...$this->walkEventCommands( $this->commonEventScripts[$eventId], sprintf('%s common event %s', $where, $eventId), $known, - $npcIds, - $mapId, - $npcIdsByMap, + $context, [...$commonEventStack, $eventId], )]; } @@ -3178,23 +3184,14 @@ protected function checkCommands( } } - if ($type === 'start_battle') { - $issues = [ - ...$issues, - ...$this->checkBattleContinuation($command, $where), - ]; - } - $issues = [...$issues, ...$this->checkConditions((array) ($command['conditions'] ?? []), $where, $known)]; if ($type === 'sequence') { - $issues = [...$issues, ...$this->checkCommands( + $issues = [...$issues, ...$this->walkEventCommands( (array) ($command['commands'] ?? []), $where, $known, - $npcIds, - $mapId, - $npcIdsByMap, + $context, $commonEventStack, )]; } @@ -3206,54 +3203,66 @@ protected function checkCommands( } $laneCommands = array_is_list($lane) ? $lane : (array) ($lane['commands'] ?? []); - $issues = [...$issues, ...$this->checkCommands( + $issues = [...$issues, ...$this->walkEventCommands( $laneCommands, $where, $known, - $npcIds, - $mapId, - $npcIdsByMap, + $context, $commonEventStack, )]; } } - // A choice hides its commands one level down, per option. + // Alternative arms start on the same map, not at the end of a sibling arm. + $branchContexts = []; foreach ((array) ($command['options'] ?? []) as $option) { if (is_array($option)) { - $issues = [...$issues, ...$this->checkCommands( + $branchContext = $context; + $issues = [...$issues, ...$this->walkEventCommands( (array) ($option['then'] ?? []), $where, $known, - $npcIds, - $mapId, - $npcIdsByMap, + $branchContext, $commonEventStack, )]; + $branchContexts[] = $branchContext; } } foreach (['then', 'else', 'cancel'] as $arm) { - $issues = [...$issues, ...$this->checkCommands( + if (! array_key_exists($arm, $command)) { + continue; + } + + $branchContext = $context; + $issues = [...$issues, ...$this->walkEventCommands( (array) ($command[$arm] ?? []), $where, $known, - $npcIds, - $mapId, - $npcIdsByMap, + $branchContext, $commonEventStack, )]; + $branchContexts[] = $branchContext; + } + + if ($branchContexts !== []) { + // An omitted alternative can leave the current map unchanged. + if (($type === 'branch' && (! isset($command['then']) || ! isset($command['else']))) + || ($type === 'choice' && ! isset($command['cancel']))) { + $branchContexts[] = $context; + } + $this->mergeCommandMapContexts($context, $branchContexts); } if ($type === 'transfer') { $destination = trim(strval($command['map'] ?? '')); if ($destination !== '' && array_key_exists($destination, $npcIdsByMap)) { - $mapId = $destination; - $npcIds = $npcIdsByMap[$destination]; + $context['mapId'] = $destination; + $context['npcIds'] = $npcIdsByMap[$destination]; } else { - $mapId = $destination !== '' ? $destination : $mapId; - $npcIds = null; + $context['mapId'] = $destination !== '' ? $destination : $context['mapId']; + $context['npcIds'] = null; } } } @@ -3262,13 +3271,43 @@ protected function checkCommands( } /** - * Checks one deterministic, awaited movement route. + * A command after a branch can address only NPCs present on every possible map. + * Unknown map contents remain unknown rather than inventing an empty NPC list. + * @param array{mapId: string|null, npcIds: string[]|null, npcIdsByMap: array} $context + * @param array $branches + */ + protected function mergeCommandMapContexts(array &$context, array $branches): void + { + $mapIds = array_unique(array_column($branches, 'mapId')); + $context['mapId'] = in_array(null, $mapIds, true) ? null : implode(' or ', $mapIds); + $npcLists = array_column($branches, 'npcIds'); + $context['npcIds'] = in_array(null, $npcLists, true) ? null : array_values(array_intersect(...$npcLists)); + } + + /** + * Shared project/runtime checks used by both event ownership paths. + * @param array $command + * @param string[]|null $npcIds + * @return Issue[] + */ + protected function checkSharedCommandSemantics(array $command, string $where, ?array $npcIds, ?string $mapId, bool $cinematic = false): array + { + return match ($command['type'] ?? '') { + 'knowledge' => $this->checkKnowledgeCommand($command, $where), + 'start_battle' => $this->checkBattleContinuation($command, $where), + 'move_route' => $this->checkMovementRoute($command, $where, $npcIds, $mapId, $cinematic), + default => [], + }; + } + + /** + * Checks one deterministic, awaited movement route in its owning lifecycle. * * @param array $command The route command. * @param string[]|null $npcIds Current-map NPC ids, or null without map context. * @return Issue[] The issues found. */ - protected function checkMovementRoute(array $command, string $where, ?array $npcIds, ?string $mapId): array + protected function checkMovementRoute(array $command, string $where, ?array $npcIds, ?string $mapId, bool $cinematic = false): array { $issues = []; @@ -3284,15 +3323,17 @@ protected function checkMovementRoute(array $command, string $where, ?array $npc $subject = strtolower(trim(strval($command['subject'] ?? 'player'))); - if (! in_array($subject, ['player', 'npc'], true)) { + $subjects = $cinematic ? ['player', 'npc', 'staged_actor'] : ['player', 'npc']; + + if (! in_array($subject, $subjects, true)) { $issues[] = Issue::error( $where, sprintf('A movement route uses unsupported subject "%s".', $subject !== '' ? $subject : '(empty)'), - 'Choose player or npc.' + 'Choose ' . implode(', ', $subjects) . '.' ); } - if (array_key_exists('remember', $command) || array_key_exists('retrace', $command)) { + if (! $cinematic && (array_key_exists('remember', $command) || array_key_exists('retrace', $command))) { $issues[] = Issue::error( $where, 'Recorded movement routes require an active cinematic session.', @@ -3304,7 +3345,7 @@ protected function checkMovementRoute(array $command, string $where, ?array $npc if ($subject === 'npc' && $npcId === '') { $issues[] = Issue::error($where, 'An NPC movement route has no npcId.', 'Choose a stable current-map NPC id.'); - } elseif ($subject === 'npc' && $npcIds !== null && ! in_array($npcId, $npcIds, true)) { + } elseif (! $cinematic && $subject === 'npc' && $npcIds !== null && ! in_array($npcId, $npcIds, true)) { $issues[] = Issue::error( $where, sprintf('The route targets NPC id "%s", which is not on map "%s".', $npcId, $mapId ?? '(unknown)'), diff --git a/tests/Unit/Cutscenes/CutsceneValidationTest.php b/tests/Unit/Cutscenes/CutsceneValidationTest.php index 1e939ad..63e4077 100644 --- a/tests/Unit/Cutscenes/CutsceneValidationTest.php +++ b/tests/Unit/Cutscenes/CutsceneValidationTest.php @@ -360,6 +360,83 @@ function insertHarbourCommands(string $root, array $commands): void ->and($npcFailures[0])->toContain('not on map "empty-quay"'); }); +it('keeps knowledge battle continuation and movement checks for every common-event owner', function (string $owner) { + $root = cutsceneProject(); + writeValidationCommonEvent($root, 'owned-invalid', [ + ['type' => 'knowledge', 'operation' => 'teleport'], + ['type' => 'knowledge', 'operation' => 'observe', 'subject' => 'unknown-subject', 'confidence' => 1.4], + ['type' => 'start_battle', 'troop' => 'missing-troop', 'resultVariable' => '', 'escapePolicy' => 'sometimes'], + ['type' => 'move_route', 'subject' => 'player', 'steps' => [['direction' => 'right', 'count' => -1]]], + ]); + writeValidationCommonEvent($root, 'owned-wrapper', [ + ['type' => 'sequence', 'commands' => [['type' => 'common_event', 'id' => 'owned-invalid']]], + ]); + if ($owner !== 'legacy') { + forbidHarbourCinematicSkip($root); + insertHarbourCommands($root, [['type' => 'common_event', 'id' => 'owned-wrapper']]); + } + if ($owner !== 'cinematic') { + $path = $root . '/assets/Maps/harbour/harbour.data.php'; + $data = require $path; + $data['events']['L'] = [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => ['scriptId' => 'owned-wrapper', 'mode' => 'action', 'reusable' => false], + ]; + file_put_contents($path, " str_starts_with($message, $prefix))); + expect($owned)->toContain('knowledge operation "teleport"') + ->toContain('knowledge observe command names no observation') + ->toContain('knowledge confidence "1.4" is not between 0 and 1') + ->toContain('resultVariable is empty or malformed') + ->toContain('invalid escapePolicy') + ->toContain('invalid count'); + } +})->with(['cinematic', 'legacy', 'mixed']); + +it('returns nested legacy transfer context to the caller without leaking across entry points', function () { + $validator = new ProjectValidator(); + $transfer = ['type' => 'transfer', 'map' => 'destination', 'x' => 0, 'y' => 0]; + new ReflectionProperty($validator, 'commonEventScripts')->setValue($validator, [ + 'inner' => [$transfer], + 'outer' => [['type' => 'sequence', 'commands' => [['type' => 'common_event', 'id' => 'inner']]]], + ]); + $route = static fn(string $npc): array => [ + 'type' => 'move_route', 'subject' => 'npc', 'npcId' => $npc, 'steps' => [['direction' => 'right']], + ]; + $known = ['maps' => ['origin', 'destination'], 'common_events' => ['inner', 'outer']]; + $maps = ['origin' => ['origin-npc'], 'destination' => ['destination-npc']]; + $check = new ReflectionMethod($validator, 'checkCommands'); + $messages = issueMessages($check->invoke($validator, [ + ['type' => 'sequence', 'commands' => [['type' => 'common_event', 'id' => 'outer']]], + $route('destination-npc'), $route('origin-npc'), + ], 'nested legacy', $known, $maps['origin'], 'origin', $maps)); + expect($messages)->toHaveCount(1) + ->and($messages[0])->toContain('NPC id "origin-npc", which is not on map "destination"'); + expect($check->invoke($validator, [$route('origin-npc')], 'independent caller', $known, $maps['origin'], 'origin', $maps))->toBe([]); +}); + +it('validates legacy alternative arms from their incoming map and joins their possible destinations', function () { + $validator = new ProjectValidator(); + $route = static fn(string $npc): array => [ + 'type' => 'move_route', 'subject' => 'npc', 'npcId' => $npc, 'steps' => [['direction' => 'right']], + ]; + $maps = ['origin' => ['origin-npc', 'shared'], 'destination' => ['destination-npc', 'shared']]; + $issues = new ReflectionMethod($validator, 'checkCommands')->invoke($validator, [ + ['type' => 'branch', 'then' => [ + ['type' => 'transfer', 'map' => 'destination'], $route('destination-npc'), + ], 'else' => [$route('origin-npc')]], + $route('shared'), + $route('destination-npc'), + ], 'branch context', ['maps' => array_keys($maps)], $maps['origin'], 'origin', $maps); + expect($issues)->toHaveCount(1) + ->and($issues[0]->message)->toContain('NPC id "destination-npc"'); +}); + it('checks the map triggers that launch cinematics', function () { $root = cutsceneProject(); $mapData = <<<'PHP_SOURCE' From 90bef541fcd34c61f5ee59da9e70bbfed9ae9740 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 22 Sep 2026 22:08:11 +0200 Subject: [PATCH 002/344] fix(canvas): share viewport geometry across all consumers --- src/Editor.php | 52 ++++++++++++++---------- src/ProjectWorkspace.php | 4 +- tests/Unit/CanvasInputRegressionTest.php | 37 +++++++++++++++++ 3 files changed, 70 insertions(+), 23 deletions(-) diff --git a/src/Editor.php b/src/Editor.php index d397257..b71ca4e 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -149,6 +149,8 @@ final class Editor /** Rows or columns one wheel tick scrolls the canvas viewport. */ private const int WHEEL_SCROLL_ROWS = 3; private const int WINDOW_HORIZONTAL_PADDING = 1; + /** Top and bottom borders surrounding window content. */ + private const int WINDOW_BORDER_ROWS = 2; private const string GUARD_ACTION_QUIT = 'quit'; private const string GUARD_ACTION_RELOAD = 'reload'; /** @@ -4313,8 +4315,8 @@ private function handleCanvasWheelScroll(MouseEvent $event): bool return true; } - $viewportWidth = $bounds['right'] - $bounds['left'] + 1; - $viewportHeight = $bounds['bottom'] - $bounds['top'] + 1; + $viewportWidth = $bounds['width']; + $viewportHeight = $bounds['height']; $maxOffsetX = max(0, $selectedMap->getWidth() - $viewportWidth); $maxOffsetY = max(0, $selectedMap->getHeight() - $viewportHeight); $nextOffsetX = max(0, min($maxOffsetX, $this->canvasOffsetX + $deltaX)); @@ -4331,21 +4333,26 @@ private function handleCanvasWheelScroll(MouseEvent $event): bool return true; } - /** @return array{left: int, top: int, right: int, bottom: int} */ - private function getCanvasPreviewBounds(): array + /** + * Shared geometry for rendering, cursor visibility, hit testing and scrolling. + * @return array{left: int, top: int, right: int, bottom: int, width: int, height: int} + */ + private function getCanvasPreviewBounds(?array $layout = null): array { - $layout = $this->resolveLayout(); + $layout ??= $this->resolveLayout(); $contentWidth = $this->getWindowContentWidth($layout['centerWidth']); - $previewHeight = max(1, $layout['contentHeight'] - 4); + $previewHeight = max(1, $layout['contentHeight'] - self::WINDOW_BORDER_ROWS - ProjectWorkspace::CANVAS_HEADER_ROWS); $canvasLeft = 2 + $layout['leftWidth'] + $layout['gutter']; $canvasTop = 5; $mapLeft = $canvasLeft + 1 + self::WINDOW_HORIZONTAL_PADDING; - $mapTop = $canvasTop + 3; + $mapTop = $canvasTop + 1 + ProjectWorkspace::CANVAS_HEADER_ROWS; return [ 'left' => $mapLeft, 'top' => $mapTop, 'right' => $mapLeft + $contentWidth - 1, 'bottom' => $mapTop + $previewHeight - 1, + 'width' => $contentWidth, + 'height' => $previewHeight, ]; } @@ -13777,9 +13784,9 @@ private function clampCanvasOffsets(): void return; } - $layout = $this->resolveLayout(); - $viewportWidth = max(1, $layout['centerWidth'] - 2); - $viewportHeight = max(1, $layout['contentHeight'] - 4); + $bounds = $this->getCanvasPreviewBounds(); + $viewportWidth = $bounds['width']; + $viewportHeight = $bounds['height']; $this->canvasOffsetX = max(0, min(max(0, $selectedMap->getWidth() - $viewportWidth), $this->canvasOffsetX)); $this->canvasOffsetY = max(0, min(max(0, $selectedMap->getHeight() - $viewportHeight), $this->canvasOffsetY)); } @@ -13816,9 +13823,9 @@ private function clampCursor(): void */ private function syncViewportToCursor(): void { - $layout = $this->resolveLayout(); - $viewportWidth = max(1, $layout['centerWidth'] - 2); - $viewportHeight = max(1, $layout['contentHeight'] - 4); + $bounds = $this->getCanvasPreviewBounds(); + $viewportWidth = $bounds['width']; + $viewportHeight = $bounds['height']; if ($this->cursorX < $this->canvasOffsetX) { $this->canvasOffsetX = $this->cursorX; @@ -13966,20 +13973,19 @@ private function renderCanvasCursor(array $layout): void $previewRow = $this->cursorY - $this->canvasOffsetY; $previewColumn = $this->cursorX - $this->canvasOffsetX; - $previewHeight = max(1, $layout['contentHeight'] - 4); - $previewWidth = $this->getWindowContentWidth($layout['centerWidth']); + $bounds = $this->getCanvasPreviewBounds($layout); + $previewHeight = $bounds['height']; + $previewWidth = $bounds['width']; if ($previewRow < 0 || $previewRow >= $previewHeight || $previewColumn < 0 || $previewColumn >= $previewWidth) { Console::cursor()->hide(); return; } - $canvasLeft = 2 + $layout['leftWidth'] + $layout['gutter']; - $canvasTop = 5; Console::cursor()->show(); Console::cursor()->moveTo( - $canvasLeft + 1 + self::WINDOW_HORIZONTAL_PADDING + $previewColumn, - $canvasTop + 3 + $previewRow + $bounds['left'] + $previewColumn, + $bounds['top'] + $previewRow ); } @@ -17873,7 +17879,9 @@ private function createAssetWindow(): EditorWindow private function createCanvasWindow(): EditorWindow { $layout = $this->resolveLayout(); - $contentWidth = $this->getWindowContentWidth($layout['centerWidth']); + $bounds = $this->getCanvasPreviewBounds($layout); + $contentWidth = $bounds['width']; + $contentHeight = $bounds['height'] + ProjectWorkspace::CANVAS_HEADER_ROWS; return new EditorWindow( title: $this->focusedPane === self::FOCUS_CANVAS ? 'Canvas [Focus]' : 'Canvas', @@ -17909,7 +17917,7 @@ private function createCanvasWindow(): EditorWindow $this->workspace?->getCanvasLines( $this->selectedAssetIndex, $contentWidth, - max(1, $layout['contentHeight'] - 2), + $contentHeight, $this->canvasOffsetX, $this->canvasOffsetY, $this->showEventOverlay, @@ -17919,7 +17927,7 @@ private function createCanvasWindow(): EditorWindow ['x' => $this->cursorX, 'y' => $this->cursorY], ) ?? [], $contentWidth, - $layout['contentHeight'] - 2 + $contentHeight ), ); } diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index ef65ac5..48ea406 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -23,6 +23,8 @@ */ final readonly class ProjectWorkspace { + /** Preview title and map metadata lines preceding the rendered grid. */ + public const int CANVAS_HEADER_ROWS = 2; /** * @param ProjectMap[] $maps * @param array $recordDatabases Schema-driven categories, keyed by category key. @@ -300,7 +302,7 @@ public function getCanvasLines( ]; } - $previewHeight = max(0, $height - 2); + $previewHeight = max(0, $height - self::CANVAS_HEADER_ROWS); $previewLines = $selectedMap->renderPreview($width, $previewHeight, $offsetX, $offsetY, $showEventOverlay, $showNpcOverlay, $selectedNpcIndex, $selectedNpcSprite); return [ diff --git a/tests/Unit/CanvasInputRegressionTest.php b/tests/Unit/CanvasInputRegressionTest.php index b7b3e1f..a8b9aeb 100644 --- a/tests/Unit/CanvasInputRegressionTest.php +++ b/tests/Unit/CanvasInputRegressionTest.php @@ -121,3 +121,40 @@ function createCanvasRegressionEditor(): array callEditorMethod($editor, 'dispatchInput', sprintf("\033[<67;%d;%dM", $bounds['right'], $bounds['bottom'])); expect(getEditorProperty($editor, 'canvasOffsetX'))->toBeGreaterThan(10); })->with(['map', 'event']); + +it('uses the rendered viewport width through wheel undo tab cursor movement and resize', function (int $terminalWidth) { + [$editor, $map] = createCanvasRegressionEditor(); + setEditorProperty($editor, 'lastTerminalSize', ['width' => $terminalWidth, 'height' => 40]); + $map->resize(200, 100); + $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); + $lastOffset = $map->getWidth() - $bounds['width']; + setEditorProperty($editor, 'cursorX', $map->getWidth() - 1); + callEditorMethod($editor, 'syncViewportToCursor'); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe($lastOffset); + + callEditorMethod($editor, 'applyCanvasWrites', $map, [['x' => 199, 'y' => 0, 'symbol' => 'Q']], 'test stroke'); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<67;%d;%dM", $bounds['right'], $bounds['top'])); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe($lastOffset); + callEditorMethod($editor, 'dispatchInput', "\t"); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe($lastOffset); + callEditorMethod($editor, 'dispatchInput', "\x19"); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe($lastOffset); + + setEditorProperty($editor, 'canvasOffsetX', 0); + setEditorProperty($editor, 'cursorX', $bounds['width']); + callEditorMethod($editor, 'syncViewportToCursor'); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(1); + + setEditorProperty($editor, 'lastTerminalSize', ['width' => $terminalWidth + 20, 'height' => 45]); + setEditorProperty($editor, 'canvasOffsetX', 199); + setEditorProperty($editor, 'canvasOffsetY', 99); + callEditorMethod($editor, 'clampCanvasOffsets'); + $resized = callEditorMethod($editor, 'getCanvasPreviewBounds'); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(200 - $resized['width']) + ->and(getEditorProperty($editor, 'canvasOffsetY'))->toBe(100 - $resized['height']); + $map->resize(2, 2); + callEditorMethod($editor, 'clampCanvasOffsets'); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(0) + ->and(getEditorProperty($editor, 'canvasOffsetY'))->toBe(0); +})->with([100, 120, 160]); From a7d60ba02a2c1809fc521854c8c21f621962a649 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 22 Sep 2026 22:22:42 +0200 Subject: [PATCH 003/344] docs(release): record editor dependency prerequisite --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index a7c8c25..2d7d807 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,11 @@ Editor development currently tracks Engine `dev-develop` for the shared cinematic route-validation API. `composer.lock` pins the published Engine revision used by local tests and CI. +`dev-develop` is for unreleased integration. Before publishing an Editor +release, require a compatible released Engine version, refresh the lockfile, +and verify a clean Console installation with Composer's default stable +dependency settings. + Install and verify from the `editor` repo: ```bash From 28fddc3c98baf2dcf954c7471e8d790e0440a93e Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 12:34:29 +0200 Subject: [PATCH 004/344] feat(maps)!: refuse executable grids before loading or writing Use the shared Engine literal-nowdoc parser and revalidate changed-on-disk grid sources before save, duplicate and move. Remove executable-grid loading and moving; reject rather than flatten authored code, while retaining transactional data-source preservation. --- src/ProjectMap.php | 85 +++++++++++------- tests/Unit/MapSaveIntegrityTest.php | 130 +++++++++++++++------------- tests/Unit/ProjectValidatorTest.php | 6 +- 3 files changed, 124 insertions(+), 97 deletions(-) diff --git a/src/ProjectMap.php b/src/ProjectMap.php index ac24e75..a4c4a18 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -8,7 +8,6 @@ use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; use Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal; use Ichiloto\Editor\Cutscenes\Source\SourceUnreadable; -use Ichiloto\Editor\Database\IsolatedPhpEvaluationFailure; use Ichiloto\Editor\Database\PhpDataFile; use Ichiloto\Editor\Field\NpcCollection; use Ichiloto\Editor\Field\ProjectNpc; @@ -17,6 +16,8 @@ use Ichiloto\Editor\Storage\FileSetTransactionFailure; use Ichiloto\Editor\Storage\FilesystemFileSetOperations; use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Engine\Field\MapGridSource; +use InvalidArgumentException; use RuntimeException; /** @@ -144,18 +145,21 @@ public static function fromDirectory(string $mapsRoot, string $directory): self throw new RuntimeException("Map files are incomplete in {$directory}."); } + $relativeDirectory = substr($directory, strlen($mapsRoot) + 1); + $mapId = str_replace(DIRECTORY_SEPARATOR, '/', $relativeDirectory); + + // Grid PHP is data, not executable map-building code. Refuse it before + // evaluating even the separate data member of an incomplete map. + $mapText = self::readGridSource($mapPath, $mapId); + $eventText = self::readGridSource($eventPath, $mapId); $data = require $dataPath; - $mapText = require $mapPath; - $eventText = require $eventPath; - if (! is_array($data) || ! is_string($mapText) || ! is_string($eventText)) { + if (! is_array($data)) { throw new RuntimeException("Map files could not be parsed in {$directory}."); } - $relativeDirectory = substr($directory, strlen($mapsRoot) + 1); - return new self( - mapId: str_replace(DIRECTORY_SEPARATOR, '/', $relativeDirectory), + mapId: $mapId, directory: $directory, dataPath: $dataPath, mapPath: $mapPath, @@ -167,6 +171,32 @@ public static function fromDirectory(string $mapsRoot, string $directory): self )->withLoadedBaseline(); } + /** Reads one canonical grid, refusing executable or generated source. */ + private static function readGridSource(string $path, string $mapId): string + { + try { + return MapGridSource::readFile($path); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal(sprintf( + '%s: %s (%s) cannot be loaded or saved: %s Repair it as a literal nowdoc, then retry; nothing was changed.', + $mapId, + basename($path), + $path, + $error->getMessage(), + ), previous: $error); + } + } + + /** Refuses a source changed on disk after this map was opened. */ + private function assertGridSourcesCanonical(): void + { + foreach ([$this->mapPath, $this->eventPath] as $path) { + if (is_file($path)) { + self::readGridSource($path, $this->mapId); + } + } + } + /** * Adopts the just-loaded content as the saved baseline. * @@ -1234,6 +1264,7 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) { $target = $this->resolveSaveTarget(); $moving = $target['directory'] !== $this->directory; + $this->assertGridSourcesCanonical(); $tripletExists = is_file($this->dataPath) && is_file($this->mapPath) @@ -1249,8 +1280,8 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) // Which members actually changed. The tile and event files compare // against the grids as of the last save, never against the bytes on - // disk, so a file an author keeps in another form -- a tile map - // built by a helper class -- is rewritten only when its grid is. + // disk. Existing grid sources have already passed the literal-nowdoc + // check, so an edited grid cannot silently flatten authored PHP. // A data file the parser cannot hold never reaches the writer unless // the on-disk member disappeared: its edits were refused, so its // cached source bytes are its own and are restored unchanged. @@ -1461,6 +1492,7 @@ public static function createBlank(string $directory, string $baseName, string $ */ public function duplicateTo(string $directory, string $baseName, string $displayName, ?FileSetOperations $files = null): void { + $this->assertGridSourcesCanonical(); // The copy keeps everything the original authored -- comments, // expressions, formatting -- with only the display name rewritten. // A source the editor cannot rewrite reversibly refuses the @@ -1717,9 +1749,8 @@ private function buildPlainLine(array $cells): string /** * The tile file the current grid should be saved as. * - * The tile and event files are the editor's own format -- a heredoc - * grid -- so a *changed* grid is written canonically; an untouched grid - * is never written at all, whatever form its author kept it in. + * A changed grid is written as a nowdoc; an untouched canonical source + * keeps its exact bytes. */ private function buildMapPayload(): string { @@ -1778,6 +1809,8 @@ public function moveTo(string $newRelativeId): self return $this; } + $this->assertGridSourcesCanonical(); + $mapsRoot = $this->getMapsRoot(); $directory = $mapsRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $newRelativeId); @@ -1818,7 +1851,6 @@ public function moveTo(string $newRelativeId): self $transaction->remove($this->mapPath); $transaction->remove($this->eventPath); - $destinationPaths = [$movedDataPath, $movedMapPath, $movedEventPath]; $expectedGrids = [ $movedMapPath => array_map($this->buildStyledLine(...), $this->tileCells), $movedEventPath => array_map($this->buildPlainLine(...), $this->eventCells), @@ -1832,7 +1864,6 @@ public function moveTo(string $newRelativeId): self // the state the game will load: no preview copies, temporary // neighbours, or old member can make an invalid move look valid. $transaction->commit(validate: function () use ( - $destinationPaths, $movedDataPath, $movedMapPath, $movedEventPath, @@ -1852,29 +1883,19 @@ public function moveTo(string $newRelativeId): self try { try { - $evaluatedFingerprints = []; - [$evaluated, $evaluatedMap, $evaluatedEvent] = $this->evaluateFiles($destinationPaths, $evaluatedFingerprints); + $evaluatedFingerprint = null; + $evaluated = $this->evaluateFile($movedDataPath, $evaluatedFingerprint); } catch (\Throwable $evaluationFailure) { - $failedPath = $movedDataPath; - - if ($evaluationFailure instanceof IsolatedPhpEvaluationFailure) { - $failedIndex = array_search($evaluationFailure->path, $destinationPaths, true); - - if (is_int($failedIndex)) { - $failedPath = $destinationPaths[$failedIndex]; - } - } - throw new RuntimeException(sprintf( - '%s does not evaluate at %s (%s) — an expression written against the old folder depth, such as a relative require, must be adjusted in the file first.', - basename($failedPath), + '%s does not evaluate at %s (%s) - an expression written against the old folder depth, such as a relative require, must be adjusted in the file first.', + basename($movedDataPath), $newRelativeId, $evaluationFailure->getMessage(), ), previous: $evaluationFailure); } if (! is_array($evaluated) - || ($evaluatedFingerprints[0] ?? null) !== PhpDataFile::valueFingerprint($this->editableData) + || $evaluatedFingerprint !== PhpDataFile::valueFingerprint($this->editableData) ) { throw new RuntimeException(sprintf( '%s would not read back as this map at %s.', @@ -1884,14 +1905,14 @@ public function moveTo(string $newRelativeId): self } $evaluatedGrids = [ - $movedMapPath => $evaluatedMap, - $movedEventPath => $evaluatedEvent, + $movedMapPath => MapGridSource::readFile($movedMapPath), + $movedEventPath => MapGridSource::readFile($movedEventPath), ]; foreach ($expectedGrids as $path => $expectedLines) { $evaluatedGrid = $evaluatedGrids[$path]; - if (! is_string($evaluatedGrid) || self::splitMapText($evaluatedGrid) !== $expectedLines) { + if (self::splitMapText($evaluatedGrid) !== $expectedLines) { throw new RuntimeException(sprintf( '%s would not read back as this map at %s.', basename($path), diff --git a/tests/Unit/MapSaveIntegrityTest.php b/tests/Unit/MapSaveIntegrityTest.php index a5759e6..8246212 100644 --- a/tests/Unit/MapSaveIntegrityTest.php +++ b/tests/Unit/MapSaveIntegrityTest.php @@ -444,6 +444,45 @@ // -- First save, creation and move -------------------------------------------- +it('refuses executable grid source at load without running its side effect', function (string $member) { + $root = authoredMapProject(); + $sourcePath = $root . "/assets/Maps/test-map/test-map.{$member}.php"; + $marker = $root . '/grid-was-executed'; + file_put_contents($sourcePath, " authoredMap($root))->toThrow(MapSourceRefusal::class, "test-map.{$member}.php"); + expect(is_file($marker))->toBeFalse(); +})->with(['map', 'event']); + +it('refuses an externally replaced grid before save, duplicate or move changes files', function (string $member, string $action) { + $root = authoredMapProject(); + $map = authoredMap($root); + if ($action === 'dirty save') { + $map->setTileSymbol(1, 1, '~'); + } + + $sourcePath = $member === 'map' ? $map->mapPath : $map->eventPath; + file_put_contents($sourcePath, "isDirty(); + + $operation = static function () use ($map, $root, $action): void { + if ($action === 'duplicate') { + $map->duplicateTo($root . '/assets/Maps/copy', 'copy', 'Copy'); + } elseif ($action === 'move') { + $map->moveTo('district/harbour'); + } else { + $map->save(); + } + }; + + expect($operation)->toThrow(MapSourceRefusal::class, basename($sourcePath)); + expect(sourceHashTree($root))->toBe($before) + ->and($map->isDirty())->toBe($dirtyBefore) + ->and(is_dir($root . '/assets/Maps/copy'))->toBeFalse() + ->and(is_dir($root . '/assets/Maps/district'))->toBeFalse(); +})->with(['map', 'event'])->with(['clean save', 'dirty save', 'duplicate', 'move']); + it('leaves no partial folder when a first save fails', function () { $root = authoredMapProject(); ProjectMap::createBlank($root . '/assets/Maps/new-quarter', 'new-quarter', 'New Quarter'); @@ -533,7 +572,7 @@ ->and($movedData)->toContain("'station' => ['x' => 3, 'y' => 1],"); }); -it('refuses a move when a staged grid member no longer evaluates at its destination', function (string $member) { +it('removes executable grid sources from the move contract before a transaction starts', function (string $member) { $root = authoredMapProject(); $map = authoredMap($root); $portableData = str_replace( @@ -545,7 +584,6 @@ $portableData = str_replace("'script' => require dirname(__DIR__, 2) . '/Events/harbour-watch.php',", "'script' => [],", $portableData); file_put_contents($map->dataPath, $portableData); - $map = authoredMap($root); $sourcePath = $member === 'map' ? $map->mapPath : $map->eventPath; $sharedPath = $root . '/assets/Maps/shared-' . $member . '.php'; file_put_contents($sharedPath, " $map->moveTo('district/harbour')) - ->toThrow(RuntimeException::class, "harbour.{$member}.php does not evaluate at district/harbour"); + ->toThrow(MapSourceRefusal::class, "test-map.{$member}.php"); expect(is_dir($root . '/assets/Maps/district'))->toBeFalse() ->and(tripletState($map))->toBe($before); })->with(['map', 'event']); @@ -572,15 +609,12 @@ ); $portableData = str_replace("'script' => \$watchScript,", "'script' => [],", $portableData); $portableData = str_replace("'script' => require dirname(__DIR__, 2) . '/Events/harbour-watch.php',", "'script' => [],", $portableData); - file_put_contents($map->dataPath, $portableData); - - $mapText = require $map->mapPath; - file_put_contents( - $map->mapPath, - "eventPath, true) . ";\n\nreturn " - . var_export($mapText, true) - . ";\n", + $portableData = str_replace( + 'return [', + "if (basename(__DIR__) === 'harbour') { throw new \\RuntimeException('destination data refusal'); }\n\nreturn [", + $portableData, ); + file_put_contents($map->dataPath, $portableData); $map = authoredMap($root); if ($retainSourceDirectory) { @@ -601,7 +635,7 @@ $fileBefore = stat($map->dataPath); expect(fn() => $map->moveTo('district/harbour')) - ->toThrow(RuntimeException::class, 'harbour.map.php does not evaluate at district/harbour'); + ->toThrow(RuntimeException::class, 'harbour.data.php does not evaluate at district/harbour'); clearstatcache(true, $map->directory); $directoryAfter = stat($map->directory); $fileAfter = stat($map->dataPath); @@ -633,18 +667,14 @@ ); $portableData = str_replace("'script' => \$watchScript,", "'script' => [],", $portableData); $portableData = str_replace("'script' => require dirname(__DIR__, 2) . '/Events/harbour-watch.php',", "'script' => [],", $portableData); - file_put_contents($map->dataPath, $portableData); - - $mapText = require $map->mapPath; $sourceDirectory = $map->directory; - file_put_contents( - $map->mapPath, - "dataPath, $portableData); $map = authoredMap($root); $moved = $map->moveTo('district/harbour'); @@ -664,13 +694,10 @@ ); $portableData = str_replace("'script' => \$watchScript,", "'script' => [],", $portableData); $portableData = str_replace("'script' => require dirname(__DIR__, 2) . '/Events/harbour-watch.php',", "'script' => [],", $portableData); - file_put_contents($map->dataPath, $portableData); - - $mapText = require $map->mapPath; $mapsRoot = $root . '/assets/Maps'; - file_put_contents( - $map->mapPath, - "dataPath, $portableData); $map = authoredMap($root); $other = ProjectMap::fromDirectory($mapsRoot, $mapsRoot . '/other'); $mapBefore = tripletState($map); @@ -698,7 +725,7 @@ ->and(is_dir($mapsRoot . '/new'))->toBeFalse(); }); -it('moves a map whose authored grid declares a named function', function (string $member) { +it('removes named-function grid sources from Editor loading', function (string $member) { $root = authoredMapProject(); $dataPath = $root . '/assets/Maps/test-map/test-map.data.php'; $portableData = str_replace( @@ -719,17 +746,11 @@ ); file_put_contents($sourcePath, rtrim($source) . "\n}\n\nreturn {$function}();\n"); - $map = authoredMap($root); - $moved = $map->moveTo('district/harbour'); - - expect($moved->mapId)->toBe('district/harbour') - ->and(is_dir($root . '/assets/Maps/test-map'))->toBeFalse() - ->and((string) file_get_contents( - $member === 'map' ? $moved->mapPath : $moved->eventPath, - ))->toContain("function {$function}(): string"); + expect(fn() => authoredMap($root))->toThrow(MapSourceRefusal::class, "test-map.{$member}.php"); + expect(is_dir($root . '/assets/Maps/district'))->toBeFalse(); })->with(['map', 'event']); -it('evaluates a moved triplet in runtime order when members share declarations', function () { +it('removes grid calls into data-file declarations', function () { $root = authoredMapProject(); $dataPath = $root . '/assets/Maps/test-map/test-map.data.php'; $mapPath = $root . '/assets/Maps/test-map/test-map.map.php'; @@ -749,15 +770,11 @@ file_put_contents($dataPath, $portableData); file_put_contents($mapPath, "moveTo('district/harbour'); - - expect($moved->mapId)->toBe('district/harbour') - ->and($moved->getWidth())->toBe($map->getWidth()) - ->and((string) file_get_contents($moved->mapPath))->toContain('editorSharedMapGridFixture()'); + expect(fn() => authoredMap($root))->toThrow(MapSourceRefusal::class, 'test-map.map.php'); + expect(function_exists('editorSharedMapGridFixture'))->toBeFalse(); }); -it('evaluates staged map members under their final basenames', function () { +it('removes filename-dependent executable grid sources', function () { $root = authoredMapProject(); $dataPath = $root . '/assets/Maps/test-map/test-map.data.php'; $mapPath = $root . '/assets/Maps/test-map/test-map.map.php'; @@ -778,11 +795,8 @@ . var_export($mapText, true) . " : '';\n", ); - $map = authoredMap($root); - $moved = $map->moveTo('district/harbour'); - - expect($moved->mapId)->toBe('district/harbour') - ->and(basename($moved->mapPath))->toBe('harbour.map.php'); + expect(fn() => authoredMap($root))->toThrow(MapSourceRefusal::class, 'test-map.map.php'); + expect(is_dir($root . '/assets/Maps/district'))->toBeFalse(); }); it('moves preserved map data containing an object without reconstructing it in the editor process', function () { @@ -811,7 +825,7 @@ ->and($reloaded->getEditableData()['opaque']->label)->toBe('preserved'); }); -it('fingerprints the final shared object state after the complete map load unit', function () { +it('removes grid mutation of shared data-file objects', function () { $root = authoredMapProject(); $dataPath = $root . '/assets/Maps/test-map/test-map.data.php'; $mapPath = $root . '/assets/Maps/test-map/test-map.map.php'; @@ -834,11 +848,7 @@ "label = 'after-grid';\n\nreturn " . var_export($mapText, true) . ";\n", ); - $map = authoredMap($root); - $moved = $map->moveTo('district/harbour'); - - expect($moved->getEditableData()['opaque']->label)->toBe('after-grid') - ->and(ProjectMap::fromDirectory($root . '/assets/Maps', $moved->directory)->getEditableData()['opaque']->label)->toBe('after-grid'); + expect(fn() => authoredMap($root))->toThrow(MapSourceRefusal::class, 'test-map.map.php'); }); // -- Editor-level flows ------------------------------------------------------- diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index a0453ed..72cc306 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -409,11 +409,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $root = makeTemporaryProject(); $path = $root . '/assets/Maps/test-map/test-map.event.php'; - file_put_contents($path, <<<'PHP' - str_replace( "'events' => [", From 75ea8c60198add63d8c43345a3c373f60b2ab88d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 12:34:29 +0200 Subject: [PATCH 005/344] feat(animations): select stable references in skill and item forms Reuse resource pickers, undo and safe-save paths for optional numeric animation IDs. Follow explicit skill IDs on navigation and warn about stale IDs or deprecated name-based selection. Preserve legacy skill fallback and unset item behavior. --- .../guide/tooling-loop/the-database.md | 15 ++++ src/Database/RecordSchemaCatalog.php | 8 ++ src/Database/ReferenceCatalog.php | 15 +++- src/Database/ReferencePicker.php | 4 +- src/Editor.php | 39 +++++--- src/ProjectSkill.php | 11 +++ src/ProjectSkillDatabase.php | 3 + .../AnimationReferenceValidator.php | 63 +++++++++++++ src/Validation/ProjectValidator.php | 1 + tests/Unit/AnimationReferenceTest.php | 89 +++++++++++++++++++ 10 files changed, 234 insertions(+), 14 deletions(-) create mode 100644 src/Validation/AnimationReferenceValidator.php create mode 100644 tests/Unit/AnimationReferenceTest.php diff --git a/docs/website/guide/tooling-loop/the-database.md b/docs/website/guide/tooling-loop/the-database.md index 36ad0e6..234152b 100644 --- a/docs/website/guide/tooling-loop/the-database.md +++ b/docs/website/guide/tooling-loop/the-database.md @@ -10,6 +10,21 @@ readTime: 4 Every field edit, entry creation, and deletion is undoable. Undo is identity-pinned, so `Ctrl+Z` applies to the entry you edited even after you have moved on to another one. +## Animation references + +Skills and Items have an **Animation** resource picker. It shows each +animation's name and numeric id and stores the id, so renaming an animation +does not break the reference. Skill `Ctrl+G` follows that id to the animation. +The edit participates in the existing undo/redo and safe-save paths; an +unsupported source form is still refused rather than flattened. + +Choose **(Legacy fallback)** for a skill or **(None)** for an item to clear +the reference. Skills then retain their +deprecated name-based selection, which project validation reports as a +warning when it matches. Items without a reference retain their existing +no-animation behavior. A missing explicit id omits the animation, not the +gameplay effect, and does not silently substitute a name-matched animation. + ## Repeating fields Quest objectives, skit beats, troop members, and event commands are all *sub-lists*, flattened into the field pane as numbered rows. Two keys manage them everywhere: diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 9adcbbb..9c76762 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -291,6 +291,14 @@ private static function items(): RecordSchema // Only a plain item carries a stack limit: the engine's // Equipment constructor does not take one. new RecordField('maxQuantity', 'Max Quantity', InputControlType::INTEGER), + new RecordField( + 'animationId', + 'Animation', + InputControlType::INTEGER, + reference: 'animation_ids', + allowsNone: true, + displayDefault: '(None)', + ), ], labelKey: 'name', identityKey: 'id', diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 52000c9..cbeb17f 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -43,6 +43,7 @@ final class ReferenceCatalog 'troops', 'states', 'animations', + 'animation_ids', 'skits', 'common_events', 'inventory', @@ -167,6 +168,10 @@ public function valuesFor(string $category): array static fn(object $animation): string => $animation->name ?? '', $this->workspace->animationDatabase->getAnimations() ), + 'animation_ids' => array_map( + static fn(object $animation): string => (string) $animation->id, + $this->workspace->animationDatabase->getAnimations() + ), // Cutscenes are folders, so the stable id is the folder name. 'cinematics' => $this->workspace->cutscenes?->ids(CutsceneType::CINEMATIC) ?? [], 'summons' => $this->workspace->cutscenes?->ids(CutsceneType::SUMMON) ?? [], @@ -323,10 +328,18 @@ private function summonCueIds(): array * identity that will actually be written. * * @param string $category The kind of reference. - * @return array Labels keyed by stored value. + * @return array Labels keyed by stored value; PHP converts numeric ids to integer keys. */ public function labelsFor(string $category): array { + if ($category === 'animation_ids') { + $labels = []; + foreach ($this->workspace->animationDatabase->getAnimations() as $animation) { + $labels[(string) $animation->id] = sprintf('%s (%d)', $animation->name, $animation->id); + } + return $labels; + } + if ($category === 'elements_or_any') { return ['*' => '* (whichever element it was)']; } diff --git a/src/Database/ReferencePicker.php b/src/Database/ReferencePicker.php index eb2abbe..d5fe250 100644 --- a/src/Database/ReferencePicker.php +++ b/src/Database/ReferencePicker.php @@ -20,7 +20,7 @@ final class ReferencePicker */ private array $values = []; /** - * @var array How to show a value, when what is stored and + * @var array How to show a value, when what is stored and * what an author recognises are not the same string. An inventory * reference stores a stable id and reads as its display name. */ @@ -46,7 +46,7 @@ final class ReferencePicker * @param string $category The kind of reference. * @param string[] $values What it may be set to. * @param string $current What it is set to now, which is where the cursor starts. - * @param array $labels How to show a value, keyed by the value stored. + * @param array $labels How to show a value, keyed by the value stored. * @return bool True when there was something to choose from. */ public function open(string $fieldId, string $label, string $category, array $values, string $current = '', array $labels = []): bool diff --git a/src/Editor.php b/src/Editor.php index b71ca4e..db7c9eb 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -6430,11 +6430,7 @@ private function goToActorClass(): void } /** - * Jumps from the selected skill to the animation sharing its name. - * - * Skills carry no explicit animation id yet (the engine models a magic - * *effect type*, not a database reference), so the hop resolves by name: - * the skill name first, then its effect type. + * Jumps to the selected skill's animation, preferring its stable id. * * @return void */ @@ -6450,18 +6446,28 @@ private function goToSkillAnimation(): void $animationIndex = null; $matchedName = ''; - foreach ($candidates as $candidate) { - $animationIndex = $this->findDatabaseAnimationIndex($candidate); + if ($skill->animationId !== null) { + foreach ($this->workspace?->animationDatabase->getAnimations() ?? [] as $index => $animation) { + if ($animation->id === $skill->animationId) { + $animationIndex = $index; + $matchedName = $animation->name; + break; + } + } + } else { + foreach ($candidates as $candidate) { + $animationIndex = $this->findDatabaseAnimationIndex($candidate); - if ($animationIndex !== null) { - $matchedName = $candidate; - break; + if ($animationIndex !== null) { + $matchedName = $candidate; + break; + } } } if ($animationIndex === null) { $this->setStatus( - sprintf('No animation named "%s" - skills carry no animation reference yet.', $skill->getName()), + sprintf('No animation found for "%s". Choose its Animation in the settings picker.', $skill->getName()), StatusLevel::WARN, ); $this->renderFooter(); @@ -10719,6 +10725,14 @@ private function getDatabaseSkillSettingsFields(): array ['label' => 'Repeat', 'value' => (string) ($invocation['repeat'] ?? 1), 'control' => new InputControl(InputControlType::INTEGER, (string) ($invocation['repeat'] ?? 1)), 'field' => 'invocationRepeat'], ['label' => 'AP Gain', 'value' => (string) ($invocation['apGain'] ?? 10), 'control' => new InputControl(InputControlType::INTEGER, (string) ($invocation['apGain'] ?? 10)), 'field' => 'invocationApGain'], ['label' => 'Effect Type', 'value' => $skill->getEffectType() ?? MagicEffectType::DESTRUCTIVE->value, 'options' => array_map(static fn(MagicEffectType $effectType): string => $effectType->value, MagicEffectType::cases()), 'field' => 'effectType'], + [ + 'label' => 'Animation', + 'value' => $skill->animationId === null ? '(Legacy fallback)' : (string) $skill->animationId, + 'field' => 'animationId', + 'reference' => 'animation_ids', + 'allowsNone' => true, + 'noneLabel' => '(Legacy fallback)', + ], ]; } /** @@ -12221,6 +12235,9 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) $oldRawValue = $control instanceof InputControl ? $control->rawValue : (string) ($field['value'] ?? ''); + if (($field['allowsNone'] ?? false) === true && $oldRawValue === (string) ($field['noneLabel'] ?? '(None)')) { + $oldRawValue = ''; + } $identity = [ 'category' => $this->databaseCategoryIndex, 'actor' => $this->databaseSelectedActorIndex, diff --git a/src/ProjectSkill.php b/src/ProjectSkill.php index ff5f39a..58755a9 100644 --- a/src/ProjectSkill.php +++ b/src/ProjectSkill.php @@ -18,6 +18,10 @@ final class ProjectSkill { use TracksPersistedState; + public ?int $animationId { + get => isset($this->payload['animationId']) ? intval($this->payload['animationId']) : null; + } + public function __construct( public readonly int $id, private array $payload, @@ -66,6 +70,7 @@ public static function fromSkill(Skill $skill, int $id): self $skill->effects, ), "effectType" => $skill instanceof MagicSkill ? $skill->effectType?->value : null, + "animationId" => $skill->animationId, ]); } @@ -119,6 +124,12 @@ public function getEffectType(): ?string { $value = $this->payload["effectType"] public function setField(string $field, mixed $value): void { + if ($field === 'animationId') { + $this->payload[$field] = $value === null || trim(strval($value)) === '' ? null : intval($value); + $this->touchState(); + return; + } + if (in_array($field, ["name", "description", "icon", "type", "occasion", "effectType"], true)) { $this->payload[$field] = is_string($value) ? trim($value) : strval($value); $this->touchState(); diff --git a/src/ProjectSkillDatabase.php b/src/ProjectSkillDatabase.php index 52c8768..c1ef27d 100644 --- a/src/ProjectSkillDatabase.php +++ b/src/ProjectSkillDatabase.php @@ -201,6 +201,9 @@ private function exportSkill(ProjectSkill $skill): string $lines[] = " [],"; $lines[] = " " . $this->exportMagicEffectType($skill->getEffectType()) . ","; } + if ($skill->animationId !== null) { + $lines[] = ' animationId: ' . $skill->animationId . ','; + } $lines[] = " )"; return implode(PHP_EOL, $lines); } diff --git a/src/Validation/AnimationReferenceValidator.php b/src/Validation/AnimationReferenceValidator.php new file mode 100644 index 0000000..e1cd50f --- /dev/null +++ b/src/Validation/AnimationReferenceValidator.php @@ -0,0 +1,63 @@ +animationDatabase->getAnimations(); + $ids = array_map(static fn($animation): int => $animation->id, $animations); + $names = array_map(static fn($animation): string => $animation->name, $animations); + $issues = []; + + foreach ($workspace->skillDatabase->getSkills() as $skill) { + $where = 'assets/Data/skills.php: ' . $skill->getName(); + if ($skill->animationId !== null) { + array_push($issues, ...$this->checkId($skill->animationId, $ids, $where)); + continue; + } + + $fallback = $skill->getType() === 'magic' + && in_array($skill->getEffectType(), [MagicEffectType::RESTORATIVE->value, MagicEffectType::BUFF->value], true) + ? 'Healing Aura' : 'Hit Spark'; + if (in_array($skill->getName(), $names, true) || in_array($fallback, $names, true)) { + $issues[] = Issue::warning( + $where, + 'Animation selection by name is deprecated.', + 'Choose an Animation in the picker to store its stable id; name fallback remains supported.', + ); + } + } + + foreach ($workspace->getRecordDatabase('items')?->getRecords() ?? [] as $item) { + $id = $item->get('animationId'); + if ($id !== null) { + array_push($issues, ...$this->checkId($id, $ids, 'assets/Data/items.php: ' . strval($item->get('name')))); + } + } + + return $issues; + } + + /** @param int[] $ids @return Issue[] */ + private function checkId(mixed $id, array $ids, string $where): array + { + if (is_int($id) && in_array($id, $ids, true)) { + return []; + } + + return [Issue::warning( + $where, + 'Its animationId does not identify an authored animation.', + 'Choose a current Animation in the picker. Gameplay still works; an explicit missing id never falls back by name.', + )]; + } +} diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index beac5b8..546c30f 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -118,6 +118,7 @@ public function validate(ProjectWorkspace $workspace): array ...$this->checkSummons($workspace), ...$this->checkCutscenes($workspace), ...$this->checkReferences($workspace), + ...new AnimationReferenceValidator()->validate($workspace), ...$this->checkDefinitionIdentities($workspace), ...$this->checkActorDefinitions($workspace), ...$this->checkKnowledgeCatalog($workspace), diff --git a/tests/Unit/AnimationReferenceTest.php b/tests/Unit/AnimationReferenceTest.php new file mode 100644 index 0000000..1a5e4a9 --- /dev/null +++ b/tests/Unit/AnimationReferenceTest.php @@ -0,0 +1,89 @@ +valuesFor('animation_ids'))->toBe(['1']) + ->and($catalog->labelsFor('animation_ids'))->toBe([1 => 'Slash (1)']) + ->and($catalog->valuesFor('animations'))->toBe(['Slash']); + + $picker = new ReferencePicker(); + $picker->open('animationId', 'Animation', 'animation_ids', $catalog->valuesFor('animation_ids'), '1', $catalog->labelsFor('animation_ids')); + expect($picker->selected())->toBe('1')->and($picker->labelFor('1'))->toBe('Slash (1)'); +}); + +it('round trips explicit animation ids for every skill subtype and allows clearing them', function (string $class) { + $root = makeTemporaryProject(); + $skill = ProjectSkill::fromSkill(new $class('Renamed', '', '', 0, 0, animationId: 1), 1); + $database = new ProjectSkillDatabase($root . '/assets/Data/skills.php', [$skill], true); + $database->save(); + $loaded = ProjectSkillDatabase::fromProject($root); + expect($loaded->getSkillByIndex(0)->animationId)->toBe(1); + $loaded->setField(0, 'animationId', ''); + $loaded->save(); + expect(ProjectSkillDatabase::fromProject($root)->getSkillByIndex(0)->animationId)->toBeNull(); +})->with([BasicSkill::class, MagicSkill::class, SpecialSkill::class]); + +it('round trips item animation references through the existing typed resource picker schema', function () { + $root = makeTemporaryProject(); + $database = loadRecordDatabase($root, 'items'); + $database->setField(0, 'animationId', '1'); + expect($database->getRecords()[0]->get('animationId'))->toBe(1); + $database->save(); + $loaded = loadRecordDatabase($root, 'items'); + expect($loaded->getRecords()[0]->get('animationId'))->toBe(1); + $loaded->setField(0, 'animationId', '(None)'); + $loaded->save(); + expect(loadRecordDatabase($root, 'items')->getRecords()[0]->get('animationId'))->toBeNull(); +}); + +it('reports deprecated name fallback and stale ids without rejecting gameplay', function () { + $root = makeTemporaryProject(); + $database = new ProjectSkillDatabase($root . '/assets/Data/skills.php', [ + ProjectSkill::fromSkill(new SpecialSkill('Slash', '', '', 0, 0), 1), + ProjectSkill::fromSkill(new SpecialSkill('Renamed', '', '', 0, 0, animationId: 1), 2), + ProjectSkill::fromSkill(new SpecialSkill('Missing', '', '', 0, 0, animationId: 999), 3), + ], true); + $database->save(); + $issues = new AnimationReferenceValidator()->validate(ProjectWorkspace::fromProject($root)); + expect($issues)->toHaveCount(2) + ->and($issues[0]->severity)->toBe(Severity::WARNING) + ->and($issues[0]->message)->toContain('deprecated') + ->and($issues[1]->severity)->toBe(Severity::WARNING) + ->and($issues[1]->message)->toContain('animationId'); +}); + +it('routes skill animation edits through the existing picker and undo transaction', function () { + $root = makeTemporaryProject(); + $database = new ProjectSkillDatabase($root . '/assets/Data/skills.php', [ + ProjectSkill::fromSkill(new SpecialSkill('Skill', '', '', 0, 0), 1), + ], true); + $database->save(); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'skills'); + $fields = callEditorMethod($editor, 'getDatabaseSkillSettingsFields'); + $field = array_values(array_filter($fields, static fn(array $field): bool => $field['field'] === 'animationId'))[0]; + expect($field['reference'])->toBe('animation_ids')->and($field['allowsNone'])->toBeTrue() + ->and(isset($field['control']))->toBeFalse(); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field, '1'); + $workspace = getEditorProperty($editor, 'workspace'); + expect($workspace->skillDatabase->getSkillByIndex(0)->animationId)->toBe(1); + callEditorMethod($editor, 'performUndo'); + expect($workspace->skillDatabase->getSkillByIndex(0)->animationId)->toBeNull(); + callEditorMethod($editor, 'performRedo'); + expect($workspace->skillDatabase->getSkillByIndex(0)->animationId)->toBe(1); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field, '(Legacy fallback)'); + expect($workspace->skillDatabase->getSkillByIndex(0)->animationId)->toBeNull(); +}); From dfee94d1168a5b6f52d2e3f4fb14b0cf7ee226c1 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 12:34:29 +0200 Subject: [PATCH 006/344] test(inventory): preserve authored constructor spelling in fixtures Compare copied project source against its original imported or qualified spelling rather than hardcoding one style. Retain byte-level preservation assertions and explicit synthetic coverage for both constructor forms. --- tests/Unit/InventorySourcePreservationTest.php | 18 ++++++++++++++---- tests/Unit/SharedInventoryTransactionTest.php | 4 +++- .../Unit/StructuralSourcePreservationTest.php | 8 ++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/tests/Unit/InventorySourcePreservationTest.php b/tests/Unit/InventorySourcePreservationTest.php index d065aba..d31e128 100644 --- a/tests/Unit/InventorySourcePreservationTest.php +++ b/tests/Unit/InventorySourcePreservationTest.php @@ -10,7 +10,7 @@ * Copies the real project's authored items.php into a throwaway project. * * The point of these tests is the actual authored source -- named arguments, - * fully qualified classes, nested constructors, emoji icons -- not a + * imported or fully qualified classes, nested constructors, emoji icons -- not a * simplified stand-in, so the file is copied rather than written. * * @return array{0: string, 1: string}|null The project root and items path. @@ -63,15 +63,20 @@ function inventoryDatabase(string $root, string $category): ProjectRecordDatabas } } + preg_match('/\bnew[ \t]+[^\s(]+[ \t]*\(/', $before, $authoredItemConstructor); + preg_match('/equipmentType:[^\r\n]+/', $before, $authoredEquipmentType); + expect($authoredItemConstructor)->not->toBe([]) + ->and($authoredEquipmentType)->not->toBe([]); + // Exactly one line differs, and it is the one asked for. expect($afterLines)->toHaveCount(count($beforeLines)) ->and($changed)->toHaveCount(1) ->and(trim($changed[0]))->toBe('price: 55,') // The authored spelling of everything else survives. - ->and($after)->toContain("new \\Ichiloto\\Engine\\Entities\\Inventory\\Items\\Item(") + ->and($after)->toContain($authoredItemConstructor[0]) ->and($after)->toContain("id: 'item.s-potion',") ->and($after)->toContain('use Ichiloto\Engine\Entities\Effects\HPRecoveryEffect;') - ->and($after)->toContain('equipmentType: \Ichiloto\Engine\Entities\Enumerations\WeaponType::SWORD,') + ->and($after)->toContain($authoredEquipmentType[0]) // The file is still PHP, and still the same list of definitions. ->and(PhpSourceDocument::parse($after)->entryCount())->toBe(PhpSourceDocument::parse($before)->entryCount()); @@ -131,10 +136,13 @@ function inventoryDatabase(string $root, string $category): ProjectRecordDatabas static fn(?string $line): bool => $line !== null, )); + preg_match('/parameterChanges:[ \t]*new[ \t]+[^\s(]+[ \t]*\(/', $before, $authoredParameterConstructor); + expect($authoredParameterConstructor)->not->toBe([]); + expect($changed)->toHaveCount(1) ->and(trim($changed[0]))->toBe('attack: 9,') // The nested constructor itself is untouched, not rebuilt. - ->and($after)->toContain('parameterChanges: new \Ichiloto\Engine\Entities\ParameterChanges('); + ->and($after)->toContain($authoredParameterConstructor[0]); $reloaded = inventoryDatabase($root, 'weapons'); expect($reloaded->getRecordByIndex(0)?->get('parameterChanges.attack'))->toBe(9); @@ -223,6 +231,8 @@ function inventoryDatabase(string $root, string $category): ProjectRecordDatabas // A single-line call takes an argument inline. $inline = $document->withArgument(1, 'sellable', 'false'); expect($inline->argumentSource(1, 'sellable'))->toBe('false') + ->and($inline->source)->toContain('new Item(') + ->and($inline->source)->toContain('new \Ichiloto\Engine\Entities\Inventory\Items\Item(') ->and($inline->source)->toContain("price: 80, sellable: false)"); }); diff --git a/tests/Unit/SharedInventoryTransactionTest.php b/tests/Unit/SharedInventoryTransactionTest.php index f819928..40e5c11 100644 --- a/tests/Unit/SharedInventoryTransactionTest.php +++ b/tests/Unit/SharedInventoryTransactionTest.php @@ -150,10 +150,12 @@ function inventoryCategories(string $root): array } // Three entries gone, and everything else spelled as the author spelled it. + preg_match('/parameterChanges:[ \t]*new[ \t]+[^\s(]+[ \t]*\(/', $source, $authoredParameterConstructor); + expect($authoredParameterConstructor)->not->toBe([]); expect(PhpSourceDocument::parse((string) file_get_contents($path))->entryCount()) ->toBe(PhpSourceDocument::parse($source)->entryCount() - 3) ->and((string) file_get_contents($path))->toContain('use Ichiloto\Engine\Entities\Effects\HPRecoveryEffect;') - ->and((string) file_get_contents($path))->toContain('parameterChanges: new \Ichiloto\Engine\Entities\ParameterChanges('); + ->and((string) file_get_contents($path))->toContain($authoredParameterConstructor[0]); })->group('engine'); it('composes a field edit in one category with a structural edit in another', function () { diff --git a/tests/Unit/StructuralSourcePreservationTest.php b/tests/Unit/StructuralSourcePreservationTest.php index b34730c..50b9571 100644 --- a/tests/Unit/StructuralSourcePreservationTest.php +++ b/tests/Unit/StructuralSourcePreservationTest.php @@ -90,6 +90,10 @@ function rewrittenLines(string $before, string $after): array // bracket is still there, in order: the new entry was inserted, not // written by rebuilding the list around it. $upToClose = rtrim(substr($before, 0, (int) strrpos($before, '];'))); + preg_match('/equipmentType:[^\r\n]+/', $before, $authoredEquipmentType); + preg_match('/parameterChanges:[ \t]*new[ \t]+[^\s(]+[ \t]*\(/', $before, $authoredParameterConstructor); + expect($authoredEquipmentType)->not->toBe([]) + ->and($authoredParameterConstructor)->not->toBe([]); expect(PhpSourceDocument::parse($after)->entryCount())->toBe($entriesBefore + 1) ->and(str_starts_with($after, $upToClose))->toBeTrue() @@ -97,8 +101,8 @@ function rewrittenLines(string $before, string $after): array // Everything the surgical writer exists to protect. ->and($after)->toContain('use Ichiloto\Engine\Entities\Effects\HPRecoveryEffect;') ->and($after)->toContain("id: 'item.s-potion',") - ->and($after)->toContain('equipmentType: \Ichiloto\Engine\Entities\Enumerations\WeaponType::SWORD,') - ->and($after)->toContain('parameterChanges: new \Ichiloto\Engine\Entities\ParameterChanges('); + ->and($after)->toContain($authoredEquipmentType[0]) + ->and($after)->toContain($authoredParameterConstructor[0]); $reloaded = structuralDatabase($root, 'weapons'); expect($reloaded->getEntryLabels())->toContain($label); From a584a91411dd429453a076ad5e517dac22955a0c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 15:46:50 +0200 Subject: [PATCH 007/344] fix(editor): share battle animation fallback and summon timing defaults Read the legacy skill animation candidate order and summon timing options from Engine source so editor diagnostics and displayed defaults track runtime behavior. --- src/Database/CutsceneSchemas.php | 3 ++- .../AnimationReferenceValidator.php | 9 ++++---- tests/Unit/AnimationReferenceTest.php | 23 +++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index 4c4f956..791a544 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -7,6 +7,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; +use Ichiloto\Engine\Cutscenes\Summons\SummonEffectTiming; /** * How a cinematic and a summon are edited: the fields, sub-lists and @@ -141,7 +142,7 @@ public static function summons(): RecordSchema new RecordField('transitionOut.type', 'Transition Out', options: ['fadeFromBlack', 'fadeToBlack', 'none'], removeWhenEmpty: true, displayDefault: 'fadeFromBlack'), new RecordField('transitionOut.durationMs', 'Transition Out Ms', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), new RecordField('transitionOut.color', 'Transition Out Color', removeWhenEmpty: true), - new RecordField('effectTiming.mode', 'Effect Timing', options: ['end', 'cue', 'frame'], removeWhenEmpty: true, displayDefault: 'end'), + new RecordField('effectTiming.mode', 'Effect Timing', options: SummonEffectTiming::AUTHORING_MODES, removeWhenEmpty: true, displayDefault: SummonEffectTiming::DEFAULT_MODE), RecordField::reference('effectTiming.cueId', 'Effect Cue', 'summon_cues', allowsNone: true, noneLabel: '(none)'), new RecordField('effectTiming.frame', 'Effect Frame', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField('targetPresentation.mode', 'Target Presentation', options: ['full_screen', 'inline'], removeWhenEmpty: true, displayDefault: 'full_screen'), diff --git a/src/Validation/AnimationReferenceValidator.php b/src/Validation/AnimationReferenceValidator.php index e1cd50f..c3106ef 100644 --- a/src/Validation/AnimationReferenceValidator.php +++ b/src/Validation/AnimationReferenceValidator.php @@ -5,6 +5,7 @@ namespace Ichiloto\Editor\Validation; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Animations\ActionAnimationResolver; use Ichiloto\Engine\Entities\Magic\MagicEffectType; /** Checks optional presentation references without invalidating gameplay. */ @@ -25,10 +26,10 @@ public function validate(ProjectWorkspace $workspace): array continue; } - $fallback = $skill->getType() === 'magic' - && in_array($skill->getEffectType(), [MagicEffectType::RESTORATIVE->value, MagicEffectType::BUFF->value], true) - ? 'Healing Aura' : 'Hit Spark'; - if (in_array($skill->getName(), $names, true) || in_array($fallback, $names, true)) { + $magicEffectType = $skill->getType() === 'magic' + ? MagicEffectType::tryFrom($skill->getEffectType() ?? '') : null; + $candidates = ActionAnimationResolver::getSkillCandidateNames($skill->getName(), $magicEffectType); + if (array_intersect($candidates, $names) !== []) { $issues[] = Issue::warning( $where, 'Animation selection by name is deprecated.', diff --git a/tests/Unit/AnimationReferenceTest.php b/tests/Unit/AnimationReferenceTest.php index 1a5e4a9..5c79381 100644 --- a/tests/Unit/AnimationReferenceTest.php +++ b/tests/Unit/AnimationReferenceTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Ichiloto\Editor\Database\ReferenceCatalog; +use Ichiloto\Editor\Database\CutsceneSchemas; use Ichiloto\Editor\Database\ReferencePicker; use Ichiloto\Editor\ProjectSkill; use Ichiloto\Editor\ProjectSkillDatabase; @@ -12,6 +13,15 @@ use Ichiloto\Engine\Entities\Skills\BasicSkill; use Ichiloto\Engine\Entities\Skills\MagicSkill; use Ichiloto\Engine\Entities\Skills\SpecialSkill; +use Ichiloto\Engine\Entities\Magic\MagicEffectType; +use Ichiloto\Engine\Cutscenes\Summons\SummonEffectTiming; + +it('displays the engine default summon effect timing in the editor', function () { + $timing = array_values(array_filter(CutsceneSchemas::summons()->fields, + static fn($field): bool => $field->key === 'effectTiming.mode'))[0]; + expect($timing->displayDefault)->toBe(SummonEffectTiming::DEFAULT_MODE) + ->and($timing->options)->toBe(SummonEffectTiming::AUTHORING_MODES); +}); it('offers animation names while storing stable numeric ids', function () { $catalog = new ReferenceCatalog(ProjectWorkspace::fromProject(makeTemporaryProject())); @@ -65,6 +75,19 @@ ->and($issues[1]->message)->toContain('animationId'); }); +it('uses the engine fallback rules for magic animation diagnostics', function () { + $root = makeTemporaryProject(); + file_put_contents($root . '/assets/Data/animations.php', " 2, 'name' => 'Healing Aura']];"); + $database = new ProjectSkillDatabase($root . '/assets/Data/skills.php', [ + ProjectSkill::fromSkill(new MagicSkill('Cure', '', '', 0, 0, effectType: MagicEffectType::RESTORATIVE), 1), + ProjectSkill::fromSkill(new MagicSkill('Flare', '', '', 0, 0, effectType: MagicEffectType::DESTRUCTIVE), 2), + ], true); + $database->save(); + $issues = new AnimationReferenceValidator()->validate(ProjectWorkspace::fromProject($root)); + expect($issues)->toHaveCount(1) + ->and($issues[0]->where)->toContain('Cure'); +}); + it('routes skill animation edits through the existing picker and undo transaction', function () { $root = makeTemporaryProject(); $database = new ProjectSkillDatabase($root . '/assets/Data/skills.php', [ From ab4533cbd2d625c043839afc51829ac0116948fb Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 15:49:42 +0200 Subject: [PATCH 008/344] fix(maps): remove edits on invalid maps and isolate discovery Keep malformed maps visible as read-only entries while valid maps remain editable. Refuse unsafe cinematic event grids before evaluating map data and generate parseable nowdoc labels. --- src/Playtest/PlaytestOverlay.php | 9 +++- src/ProjectMap.php | 65 ++++++++++++++++++++++---- src/ProjectWorkspace.php | 37 +++++++++++++-- tests/Unit/MapIsolationTest.php | 79 ++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 tests/Unit/MapIsolationTest.php diff --git a/src/Playtest/PlaytestOverlay.php b/src/Playtest/PlaytestOverlay.php index ef7ac55..7910284 100644 --- a/src/Playtest/PlaytestOverlay.php +++ b/src/Playtest/PlaytestOverlay.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\Database\PhpDataFile; use Ichiloto\Editor\Database\PhpValueExporter; +use Ichiloto\Engine\Field\MapGridSource; use RuntimeException; use Throwable; @@ -182,8 +183,8 @@ private function installCinematicLaunch(string $projectRoot, string $cinematicId */ private function writeCinematicLaunchFiles(string $sourceDirectory, string $targetDirectory, string $leaf, string $cinematicId): void { + $eventText = MapGridSource::readFile($sourceDirectory . '/' . $leaf . '.event.php'); $data = require $sourceDirectory . '/' . $leaf . '.data.php'; - $eventText = require $sourceDirectory . '/' . $leaf . '.event.php'; if (! is_array($data) || ! is_string($eventText)) { throw new RuntimeException(sprintf('Map %s could not be read for the playtest.', $this->mapId)); @@ -229,7 +230,11 @@ private function writeCinematicLaunchFiles(string $sourceDirectory, string $targ ); PhpDataFile::writeTransactionally( $targetDirectory . '/' . $leaf . '.event.php', - "editableData = $data; $this->tileCells = self::parseStyledLines($tileLines); @@ -171,6 +172,37 @@ public static function fromDirectory(string $mapsRoot, string $directory): self )->withLoadedBaseline(); } + /** Keeps an invalid map discoverable without evaluating or rewriting its files. */ + public static function createReadOnlyFromDirectory(string $mapsRoot, string $directory, string $issue): self + { + $baseName = basename($directory); + $mapId = str_replace(DIRECTORY_SEPARATOR, '/', substr($directory, strlen($mapsRoot) + 1)); + + return new self( + mapId: $mapId, + directory: $directory, + dataPath: $directory . DIRECTORY_SEPARATOR . $baseName . '.data.php', + mapPath: $directory . DIRECTORY_SEPARATOR . $baseName . '.map.php', + eventPath: $directory . DIRECTORY_SEPARATOR . $baseName . '.event.php', + data: [], + tileLines: [], + eventLines: [], + gridSourceIssue: $issue, + )->withLoadedBaseline(); + } + + public function getGridSourceIssue(): ?string + { + return $this->gridSourceIssue; + } + + private function assertEditable(): void + { + if ($this->gridSourceIssue !== null) { + throw new MapSourceRefusal("{$this->mapId} is read-only: {$this->gridSourceIssue}"); + } + } + /** Reads one canonical grid, refusing executable or generated source. */ private static function readGridSource(string $path, string $mapId): string { @@ -505,6 +537,7 @@ public function getEventSymbol(int $x, int $y): string */ public function setTileSymbol(int $x, int $y, string $symbol): void { + $this->assertEditable(); if (! isset($this->tileCells[$y][$x])) { return; } @@ -563,6 +596,7 @@ public function getTileColor(int $x, int $y): ?string */ public function setTileCell(int $x, int $y, string $symbol, string $prefix, string $suffix): void { + $this->assertEditable(); if (! isset($this->tileCells[$y][$x])) { return; } @@ -583,6 +617,7 @@ public function setTileCell(int $x, int $y, string $symbol, string $prefix, stri */ public function setEventSymbol(int $x, int $y, string $symbol): void { + $this->assertEditable(); if (! isset($this->eventCells[$y][$x])) { return; } @@ -633,6 +668,7 @@ public function captureGridSnapshot(): array */ public function restoreGridSnapshot(array $snapshot): void { + $this->assertEditable(); $this->tileCells = $snapshot['tiles']; $this->eventCells = $snapshot['events']; $this->cachedWidth = null; @@ -729,6 +765,7 @@ public function getNpcs(): NpcCollection */ public function setNpcs(NpcCollection $npcs): void { + $this->assertEditable(); $entries = $npcs->toMapData(); if (($this->editableData['npcs'] ?? null) === $entries) { @@ -882,6 +919,7 @@ public function getMapDataField(array $path): mixed */ public function setMapDataField(array $path, mixed $value): void { + $this->assertEditable(); if ($path === []) { return; } @@ -1073,6 +1111,7 @@ public function isEventMarkerSolidRectangle(string $marker): bool */ public function setMapField(string $field, mixed $value): void { + $this->assertEditable(); $next = $this->editableData; $next[$field] = $value; $this->writeData($next); @@ -1087,6 +1126,7 @@ public function setMapField(string $field, mixed $value): void */ public function resize(int $width, int $height): void { + $this->assertEditable(); $width = max(1, $width); $height = max(1, $height); @@ -1135,6 +1175,7 @@ public function resize(int $width, int $height): void */ public function setEventField(string $marker, array $path, mixed $value): void { + $this->assertEditable(); if ($path === []) { return; } @@ -1171,6 +1212,7 @@ public function setEventField(string $marker, array $path, mixed $value): void */ public function setEventDefinition(string $marker, array $definition): void { + $this->assertEditable(); $next = $this->editableData; if (! isset($next['events']) || ! is_array($next['events'])) { @@ -1190,6 +1232,7 @@ public function setEventDefinition(string $marker, array $definition): void */ public function removeEventDefinition(string $marker): void { + $this->assertEditable(); if (! isset($this->editableData['events'][$marker])) { return; } @@ -1211,6 +1254,7 @@ public function removeEventDefinition(string $marker): void */ public function setEventBounds(string $marker, int $x, int $y, int $width, int $height): void { + $this->assertEditable(); foreach ($this->eventCells as $rowIndex => $row) { foreach ($row as $columnIndex => $symbol) { if ($symbol === $marker) { @@ -1262,6 +1306,7 @@ public function willMoveOnSave(): bool */ public function save(?callable $backup = null, ?FileSetOperations $files = null): string { + $this->assertEditable(); $target = $this->resolveSaveTarget(); $moving = $target['directory'] !== $this->directory; $this->assertGridSourcesCanonical(); @@ -1473,11 +1518,11 @@ public static function createBlank(string $directory, string $baseName, string $ ); $transaction->write( $directory . DIRECTORY_SEPARATOR . $baseName . '.map.php', - "write( $directory . DIRECTORY_SEPARATOR . $baseName . '.event.php', - "commit(); } @@ -1492,6 +1537,7 @@ public static function createBlank(string $directory, string $baseName, string $ */ public function duplicateTo(string $directory, string $baseName, string $displayName, ?FileSetOperations $files = null): void { + $this->assertEditable(); $this->assertGridSourcesCanonical(); // The copy keeps everything the original authored -- comments, // expressions, formatting -- with only the display name rewritten. @@ -1754,9 +1800,10 @@ private function buildPlainLine(array $cells): string */ private function buildMapPayload(): string { - return "buildStyledLine(...), $this->tileCells)) - . "\nICHILOTO_MAP;\n"; + return MapGridSource::buildSource( + implode(PHP_EOL, array_map($this->buildStyledLine(...), $this->tileCells)), + 'ICHILOTO_MAP', + ); } /** @@ -1764,9 +1811,10 @@ private function buildMapPayload(): string */ private function buildEventPayload(): string { - return "buildPlainLine(...), $this->eventCells)) - . "\nICHILOTO_EVENT_MAP;\n"; + return MapGridSource::buildSource( + implode(PHP_EOL, array_map($this->buildPlainLine(...), $this->eventCells)), + 'ICHILOTO_EVENT_MAP', + ); } /** @@ -1799,6 +1847,7 @@ protected function buildPersistedPayload(): string */ public function moveTo(string $newRelativeId): self { + $this->assertEditable(); $newRelativeId = trim(str_replace('\\', '/', $newRelativeId), '/ '); if ($newRelativeId === '') { diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index 48ea406..95143ff 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -157,6 +157,10 @@ public function getInspectorLines(int $selectedMapIndex = 0): array sprintf('Size: %d x %d', $selectedMap->getWidth(), $selectedMap->getHeight()), sprintf('Events: %d', $selectedMap->getEventDefinitionCount()), sprintf('Triggers: %d', $selectedMap->getTriggerCount()), + ...($selectedMap->getGridSourceIssue() === null ? [] : [ + 'Read-only: map source needs repair.', + $selectedMap->getGridSourceIssue(), + ]), '', basename($selectedMap->dataPath), basename($selectedMap->mapPath), @@ -199,7 +203,8 @@ public function getAssetLines(int $selectedMapIndex = 0, ?array $visibleIndexes $prefix = $index === $selectedMapIndex ? '> ' : ' '; $dirty = $this->maps[$index]->isDirty() ? ' *' : ''; - $lines[] = sprintf('%s%s%s', $prefix, $mapId, $dirty); + $readOnly = $this->maps[$index]->getGridSourceIssue() === null ? '' : ' [read-only]'; + $lines[] = sprintf('%s%s%s%s', $prefix, $mapId, $dirty, $readOnly); } return $lines; @@ -302,6 +307,14 @@ public function getCanvasLines( ]; } + if ($selectedMap->getGridSourceIssue() !== null) { + return [ + sprintf('Preview: %s', $selectedMap->mapId), + 'Read-only: repair this map before editing.', + $selectedMap->getGridSourceIssue(), + ]; + } + $previewHeight = max(0, $height - self::CANVAS_HEADER_ROWS); $previewLines = $selectedMap->renderPreview($width, $previewHeight, $offsetX, $offsetY, $showEventOverlay, $showNpcOverlay, $selectedNpcIndex, $selectedNpcSprite); @@ -362,6 +375,10 @@ public function duplicateMap(int $selectedMapIndex, ?FileSetOperations $files = return null; } + if ($selectedMap->getGridSourceIssue() !== null) { + throw new MapSourceRefusal("{$selectedMap->mapId} is read-only: {$selectedMap->getGridSourceIssue()}"); + } + $parentDirectory = dirname($selectedMap->directory); $originalBaseName = basename($selectedMap->directory); $baseName = $this->getNextAvailableSiblingBaseName($parentDirectory, $originalBaseName . '-copy'); @@ -388,6 +405,10 @@ public function deleteMap(int $selectedMapIndex, ?FileSetOperations $files = nul return null; } + if ($selectedMap->getGridSourceIssue() !== null) { + throw new MapSourceRefusal("{$selectedMap->mapId} is read-only: {$selectedMap->getGridSourceIssue()}"); + } + // Only the split triplet is the map's; deleting a map must not take // an author's own notes or assets in the same folder with it. The // three members go as one transaction -- a failure on any of them @@ -445,10 +466,16 @@ private static function discoverMaps(string $projectRoot): array ksort($mapDirectories); - return array_values(array_map( - static fn(string $directory): ProjectMap => ProjectMap::fromDirectory($mapsRoot, $directory), - $mapDirectories - )); + $maps = []; + foreach ($mapDirectories as $directory) { + try { + $maps[] = ProjectMap::fromDirectory($mapsRoot, $directory); + } catch (Throwable $error) { + $maps[] = ProjectMap::createReadOnlyFromDirectory($mapsRoot, $directory, $error->getMessage()); + } + } + + return $maps; } /** diff --git a/tests/Unit/MapIsolationTest.php b/tests/Unit/MapIsolationTest.php new file mode 100644 index 0000000..1da559d --- /dev/null +++ b/tests/Unit/MapIsolationTest.php @@ -0,0 +1,79 @@ +maps[array_search('bad-a', $workspace->mapIds, true)]; + $good = $workspace->maps[array_search('test-map', $workspace->mapIds, true)]; + $issues = (new ProjectValidator())->validate($workspace); + $badIssues = array_values(array_filter($issues, static fn ($issue): bool => str_contains($issue->message, 'Map source is read-only'))); + + expect(is_file($executed))->toBeFalse() + ->and($badA->getGridSourceIssue())->toContain('bad-a.map.php') + ->and($workspace->getCanvasLines(array_search('bad-a', $workspace->mapIds, true), 40, 10))->toContain('Read-only: repair this map before editing.') + ->and($good->getGridSourceIssue())->toBeNull() + ->and($badIssues)->toHaveCount(2); + + expect(fn () => $badA->setMapField('name', 'Changed'))->toThrow(MapSourceRefusal::class, 'read-only'); + expect(fn () => $badA->save())->toThrow(MapSourceRefusal::class, 'read-only'); + expect(fn () => $workspace->deleteMap(array_search('bad-a', $workspace->mapIds, true)))->toThrow(MapSourceRefusal::class, 'read-only'); + + $good->setTileSymbol(1, 1, 'x'); + expect($good->isDirty())->toBeTrue(); +}); + +it('refuses a cinematic playtest event grid before evaluating its map data', function (): void { + $root = makeTemporaryProject('ichiloto-map-playtest-'); + $directory = $root . '/assets/Maps/test-map'; + $executed = $root . '/executed'; + file_put_contents($root . '/assets/Data/system.php', ' PlaytestOverlay::createForCinematic($root, 'test-map', 1, 1, 'intro')) + ->toThrow(InvalidArgumentException::class, 'literal nowdoc'); + expect(is_file($executed))->toBeFalse(); +}); + +it('writes parseable map and event grids when authored rows resemble nowdoc closers', function (): void { + $root = makeTemporaryProject('ichiloto-map-markers-'); + $directory = $root . '/assets/Maps/marker-map'; + mkdir($directory); + $map = new ProjectMap( + 'marker-map', + $directory, + $directory . '/marker-map.data.php', + $directory . '/marker-map.map.php', + $directory . '/marker-map.event.php', + ['name' => 'Marker Map'], + ['ICHILOTO_MAP;'], + ['ICHILOTO_EVENT_MAP;'], + ); + + $map->save(); + + expect(MapGridSource::readFile($map->mapPath))->toBe('ICHILOTO_MAP;') + ->and(MapGridSource::readFile($map->eventPath))->toBe('ICHILOTO_EVENT_MAP;') + ->and(ProjectMap::fromDirectory($root . '/assets/Maps', $directory)->tileLines)->toBe(['ICHILOTO_MAP;']); +}); From 5ecaa7790270db76b08a2a564392cd3242787283 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 16:11:20 +0200 Subject: [PATCH 009/344] fix(editor): preserve skill source and remove unsafe effect regeneration --- .../guide/tooling-loop/the-database.md | 7 +- src/Database/PhpSourceDocument.php | 85 ++++++- src/ProjectSkillDatabase.php | 218 ++++++++++++------ tests/Unit/SkillSourcePreservationTest.php | 122 ++++++++++ 4 files changed, 349 insertions(+), 83 deletions(-) create mode 100644 tests/Unit/SkillSourcePreservationTest.php diff --git a/docs/website/guide/tooling-loop/the-database.md b/docs/website/guide/tooling-loop/the-database.md index 234152b..6c569ce 100644 --- a/docs/website/guide/tooling-loop/the-database.md +++ b/docs/website/guide/tooling-loop/the-database.md @@ -16,7 +16,12 @@ Skills and Items have an **Animation** resource picker. It shows each animation's name and numeric id and stores the id, so renaming an animation does not break the reference. Skill `Ctrl+G` follows that id to the animation. The edit participates in the existing undo/redo and safe-save paths; an -unsupported source form is still refused rather than flattened. +unsupported source form is refused rather than flattened. Skill saves now patch +only changed constructor arguments, preserving effects, weapon requirements, +comments and other authored expressions. This replaces the old skill exporter, +which incorrectly regenerated non-damage effects as damage effects. Changing a +skill's class or regenerating effects without their original source is refused +before writing; edit those expressions in source instead. Choose **(Legacy fallback)** for a skill or **(None)** for an item to clear the reference. Skills then retain their diff --git a/src/Database/PhpSourceDocument.php b/src/Database/PhpSourceDocument.php index 471fed3..7e1f4ca 100644 --- a/src/Database/PhpSourceDocument.php +++ b/src/Database/PhpSourceDocument.php @@ -187,6 +187,45 @@ public function withArgument(int $entryIndex, string $path, string $literal): se return self::parse($this->insertedArgument($entryIndex, $path, $literal)); } + /** + * Edits a constructor with an explicitly known signature, including positional arguments. + * Unknown calls and argument unpacking are refused rather than assigned guessed positions. + * + * @param list $parameters Constructor parameter names in declaration order. + */ + public function getWithConstructorArgument(int $entryIndex, string $name, string $literal, array $parameters): self + { + if (! $this->isConstructorEntry($entryIndex) || ! in_array($name, $parameters, true)) { + throw new RuntimeException('Cannot edit an unknown constructor or parameter.'); + } + + $entries = $this->entries; + $entry = $entries[$entryIndex]; + $tokens = self::tokenize($this->source); + $open = $close = null; + foreach ($tokens as $index => $token) { + if ($token['offset'] === $entry['open']) { $open = $index; } + if ($token['offset'] === $entry['close']) { $close = $index; } + } + if ($open === null || $close === null) { + throw new RuntimeException('Cannot locate the authored constructor.'); + } + + $arguments = self::readArguments($tokens, $open, $close); + foreach ($arguments['positions'] as $position => $span) { + $parameter = $parameters[$position] ?? null; + if ($parameter === null || isset($arguments['named'][$parameter]) || $span['unpacked']) { + throw new RuntimeException('Cannot safely map positional constructor arguments.'); + } + unset($span['unpacked']); + $entry['arguments'][$parameter] = $span; + } + $entry['positional'] = false; + $entries[$entryIndex] = $entry; + + return new self($this->source, $entries, $this->arrayClose)->withArgument($entryIndex, $name, $literal); + } + /** * @var string The literal an insertion is writing, while it is writing it. */ @@ -268,8 +307,23 @@ public function withNewEntry(string $class, array $arguments, ?int $before = nul $lines[] = sprintf('%s%s: %s,', $inner, $name, $literal); } - $lines[] = $indent . '),'; - $block = implode("\n", $lines) . "\n"; + $lines[] = $indent . ')'; + + return $this->getWithEntrySource(ltrim(implode("\n", $lines)), $before); + } + + /** Inserts a previously preserved or newly authored constructor without re-exporting its values. */ + public function getWithEntrySource(string $source, ?int $before = null): self + { + if ($this->arrayClose === null) { + throw new RuntimeException('This file does not return an array to add an entry to.'); + } + $candidate = self::parse("entryCount() !== 1 || ! $candidate->isConstructorEntry(0)) { + throw new RuntimeException('Only a complete constructor entry can be inserted.'); + } + $indent = $this->entryIndent(); + $block = $indent . $source . ",\n"; if ($before !== null) { if (! $this->isConstructorEntry($before)) { @@ -424,6 +478,16 @@ private function insertedArgument(int $entryIndex, string $path, string $literal $indent = $this->argumentIndent($entry); $before = substr($this->source, 0, $close); $after = substr($this->source, $close); + if ($entry['arguments'] !== []) { + $lastEnd = max(array_column($entry['arguments'], 'end')); + $hasComma = false; + foreach (self::tokenize('source, $lastEnd, $close - $lastEnd)) as $token) { + if ($token['text'] === ',') { $hasComma = true; } + } + if (! $hasComma) { + $before = substr($before, 0, $lastEnd) . ',' . substr($before, $lastEnd); + } + } $trimmed = rtrim($before); $isMultiline = str_contains(substr($this->source, $entry['open'], $close - $entry['open']), "\n"); @@ -433,13 +497,10 @@ private function insertedArgument(int $entryIndex, string $path, string $literal } if (! $isMultiline) { - return $trimmed . sprintf(', %s: %s', $name, $literal) . $after; + return $trimmed . sprintf(' %s: %s', $name, $literal) . $after; } - $needsComma = ! str_ends_with($trimmed, ',') && ! str_ends_with($trimmed, '('); - return $trimmed - . ($needsComma ? ',' : '') . "\n" . $indent . sprintf('%s: %s,', $name, $literal) . "\n" . $this->closingIndent($entry) . $after; @@ -776,12 +837,13 @@ private static function readEntry(array $tokens, int $position, int $limit): ?ar * @param array $tokens The tokens. * @param int $open The opening parenthesis token index. * @param int $close The closing parenthesis token index. - * @return array{named: array, positional: bool} + * @return array{named: array, positional: bool, positions: list} */ private static function readArguments(array $tokens, int $open, int $close): array { $named = []; $positional = false; + $positions = []; $index = $open + 1; while ($index < $close) { @@ -826,12 +888,19 @@ private static function readArguments(array $tokens, int $open, int $close): arr 'end' => self::trimmedEnd($tokens, $index, $end, $valueEnd), 'nameStart' => $tokens[$nameIndex]['offset'], ]; + } else { + $positions[] = [ + 'start' => $valueStart, + 'end' => self::trimmedEnd($tokens, $index, $end, $valueEnd), + 'nameStart' => $tokens[$nameIndex]['offset'], + 'unpacked' => $tokens[$index]['id'] === T_ELLIPSIS, + ]; } $index = $end; } - return ['named' => $named, 'positional' => $positional]; + return ['named' => $named, 'positional' => $positional, 'positions' => $positions]; } /** diff --git a/src/ProjectSkillDatabase.php b/src/ProjectSkillDatabase.php index c1ef27d..a43efcb 100644 --- a/src/ProjectSkillDatabase.php +++ b/src/ProjectSkillDatabase.php @@ -6,6 +6,11 @@ use Ichiloto\Editor\History\TracksPersistedState; use Ichiloto\Editor\IO\AtomicFile; +use Ichiloto\Editor\Database\PhpSourceDocument; +use Ichiloto\Engine\Entities\Skills\BasicSkill; +use Ichiloto\Engine\Entities\Skills\MagicSkill; +use Ichiloto\Engine\Entities\Skills\SpecialSkill; +use ReflectionClass; use Ichiloto\Engine\Entities\Enumerations\ItemScopeNumber; use Ichiloto\Engine\Entities\Enumerations\ItemScopeSide; @@ -17,6 +22,12 @@ final class ProjectSkillDatabase { use TracksPersistedState; + private ?string $source = null; + /** @var list */ + private array $sourceOrder = []; + /** @var array */ + private array $authored = []; + public function __construct( public readonly string $path, private array $skills = [], @@ -41,12 +52,27 @@ public static function fromProject(string $projectRoot): self throw new RuntimeException(sprintf("Unable to parse %s.", $path)); } $skills = []; + $classes = []; foreach (array_values($payload) as $index => $skill) { if ($skill instanceof \Ichiloto\Engine\Entities\Skills\Skill) { $skills[] = ProjectSkill::fromSkill($skill, $index + 1); + $classes[] = $skill::class; } } - return new self($path, $skills); + $database = new self($path, $skills); + $database->source = (string) file_get_contents($path); + $document = PhpSourceDocument::parse($database->source); + foreach ($skills as $index => $skill) { + $key = spl_object_id($skill); + $database->sourceOrder[] = $key; + $database->authored[$key] = [ + 'skill' => $skill, + 'payload' => $skill->toArray(), + 'source' => count($payload) === count($skills) ? $document->entrySource($index) : null, + 'class' => $classes[$index], + ]; + } + return $database; } public function getSkills(): array { return array_values($this->skills); } @@ -120,7 +146,29 @@ public function save(): void return; } - AtomicFile::write($this->path, $this->buildPersistedPayload()); + if ($this->source !== null && file_get_contents($this->path) !== $this->source) { + throw new RuntimeException('Refusing to overwrite skills.php changed outside the editor. Reload before saving.'); + } + $source = $this->getUpdatedSource(); + try { + \PhpToken::tokenize($source, TOKEN_PARSE); + } catch (\ParseError $error) { + throw new RuntimeException('Refusing to write invalid skill source: ' . $error->getMessage(), previous: $error); + } + AtomicFile::write($this->path, $source); + $this->source = $source; + $document = PhpSourceDocument::parse($source); + $this->sourceOrder = []; + foreach ($this->getSkills() as $index => $skill) { + $key = spl_object_id($skill); + $this->sourceOrder[] = $key; + $this->authored[$key] = [ + 'skill' => $skill, + 'payload' => $skill->toArray(), + 'source' => $document->entrySource($index), + 'class' => $this->authored[$key]['class'] ?? $this->getSkillClass($skill), + ]; + } $this->captureBaseline(); } @@ -143,48 +191,102 @@ protected function dependencyVersion(): string */ protected function buildPersistedPayload(): string { - $imports = $this->getImportLines(); - $definitions = array_map(fn(ProjectSkill $skill): string => $this->exportSkill($skill), $this->getSkills()); - $payload = [self::openTag(), self::emptyString()]; - foreach ($imports as $import) { $payload[] = $import; } - $payload[] = self::emptyString(); - $payload[] = self::returnArray(); - foreach ($definitions as $definition) { $payload[] = $definition . self::comma(); } - $payload[] = self::closingArray(); - $payload[] = self::emptyString(); - - return implode(PHP_EOL, $payload); + return serialize(array_map(static fn(ProjectSkill $skill): array => $skill->toArray(), $this->getSkills())); } - private function getImportLines(): array + private function getUpdatedSource(): string { - $imports = [ - "use Ichiloto\Engine\Entities\Enumerations\ItemScopeNumber;", - "use Ichiloto\Engine\Entities\Enumerations\ItemScopeSide;", - "use Ichiloto\Engine\Entities\Enumerations\ItemScopeStatus;", - "use Ichiloto\Engine\Entities\Enumerations\Occasion;", - "use Ichiloto\Engine\Entities\ItemScope;", - "use Ichiloto\Engine\Entities\Skills\BasicSkill;", - "use Ichiloto\Engine\Entities\Skills\MagicSkill;", - "use Ichiloto\Engine\Entities\Skills\SkillInvocation;", - "use Ichiloto\Engine\Entities\Skills\SpecialSkill;", - ]; - foreach ($this->getSkills() as $skill) { - if ($skill->getType() === "magic") { $imports[] = "use Ichiloto\Engine\Entities\Magic\MagicEffectType;"; } - foreach ($skill->getEffects() as $effect) { $imports[] = "use " . strval($effect["class"] ?? "") . ";"; } + if ($this->source === null) { + return $this->getNewSource(); + } + $document = PhpSourceDocument::parse($this->source); + $order = $this->sourceOrder; + if ($document->entryCount() !== count($order)) { + throw new RuntimeException('Refusing to flatten skills.php: its entries cannot be matched to the loaded skills.'); + } + foreach ($this->getSkills() as $index => $skill) { + $key = spl_object_id($skill); + $entrySource = $this->getUpdatedEntry($skill); + $position = array_search($key, $order, true); + if ($position === $index) { + if ($document->entrySource($index) !== $entrySource) { + $old = $document->entrySource($index); + if ($old === null) { throw new RuntimeException('Refusing to replace an unsupported skill expression.'); } + // Patch the entry's values through the same constructor-span writer. + $document = $this->applySkillChanges($document, $index, $skill); + } + continue; + } + if ($position !== false) { + $document = $document->withoutEntry($position); + array_splice($order, $position, 1); + } + $document = $document->getWithEntrySource($entrySource, $index < count($order) ? $index : null); + array_splice($order, $index, 0, [$key]); + } + for ($index = count($order) - 1; $index >= count($this->skills); $index--) { + $document = $document->withoutEntry($index); + } + return $document->source; + } + + private function getUpdatedEntry(ProjectSkill $skill): string + { + $authored = $this->authored[spl_object_id($skill)] ?? null; + if ($authored === null) { return ltrim($this->exportSkill($skill)); } + if ($authored['source'] === null) { + throw new RuntimeException('Refusing to flatten an unsupported skill expression. Edit its source directly.'); + } + $document = PhpSourceDocument::parse('applySkillChanges($document, 0, $skill)->entrySource(0) + ?? throw new RuntimeException('Cannot preserve the edited skill constructor.'); + } + + private function applySkillChanges(PhpSourceDocument $document, int $index, ProjectSkill $skill): PhpSourceDocument + { + $authored = $this->authored[spl_object_id($skill)]; + $parameters = array_map(static fn(\ReflectionParameter $parameter): string => $parameter->getName(), + (new ReflectionClass($authored['class']))->getConstructor()?->getParameters() ?? []); + foreach ($skill->toArray() as $field => $value) { + if ($value === ($authored['payload'][$field] ?? null)) { continue; } + if (in_array($field, ['type', 'effects'], true)) { + throw new RuntimeException('Refusing to regenerate skill type or effects; edit those expressions in source.'); + } + $literal = match ($field) { + 'scope' => $this->exportScope($skill), + 'invocation' => $this->exportInvocation($skill), + 'occasion' => $this->exportOccasion($skill->getOccasion()), + 'effectType' => $this->exportMagicEffectType($skill->getEffectType()), + default => var_export($value, true), + }; + $document = $document->getWithConstructorArgument($index, $field, $literal, $parameters); } - sort($imports); - return array_values(array_unique($imports)); + return $document; + } + + /** @return class-string */ + private function getSkillClass(ProjectSkill $skill): string + { + return match ($skill->getType()) { + 'basic' => BasicSkill::class, + 'magic' => MagicSkill::class, + default => SpecialSkill::class, + }; + } + + private function getNewSource(): string + { + $definitions = array_map(fn(ProjectSkill $skill): string => $this->exportSkill($skill), $this->getSkills()); + return " $definition . ',', $definitions)) . "\n];\n"; } private function exportSkill(ProjectSkill $skill): string { + if ($skill->getEffects() !== []) { + throw new RuntimeException('Refusing to regenerate skill effects without their authored source.'); + } $lines = [ - sprintf(" new %s(", match ($skill->getType()) { - "basic" => "BasicSkill", - "magic" => "MagicSkill", - default => "SpecialSkill", - }), + sprintf(" new \\%s(", $this->getSkillClass($skill)), " " . var_export($skill->getName(), true) . ",", " " . var_export($skill->getDescription(), true) . ",", " " . var_export($skill->getIcon(), true) . ",", @@ -195,7 +297,6 @@ private function exportSkill(ProjectSkill $skill): string " " . $this->exportInvocation($skill) . ",", " [", ]; - foreach ($skill->getEffects() as $effect) { $lines[] = " " . $this->exportEffect($effect) . ","; } $lines[] = " ],"; if ($skill->getType() === "magic") { $lines[] = " [],"; @@ -212,24 +313,24 @@ private function exportScope(ProjectSkill $skill): string { $scope = $skill->getScope(); $parts = [ - "ItemScopeSide::" . $this->enumCaseFromValue(ItemScopeSide::class, strval($scope["side"] ?? "Enemy"), "ENEMY"), - "ItemScopeNumber::" . $this->enumCaseFromValue(ItemScopeNumber::class, strval($scope["number"] ?? "One"), "ONE"), - "ItemScopeStatus::" . $this->enumCaseFromValue(ItemScopeStatus::class, strval($scope["status"] ?? "Alive"), "ALIVE"), + "\\" . ItemScopeSide::class . "::" . $this->enumCaseFromValue(ItemScopeSide::class, strval($scope["side"] ?? "Enemy"), "ENEMY"), + "\\" . ItemScopeNumber::class . "::" . $this->enumCaseFromValue(ItemScopeNumber::class, strval($scope["number"] ?? "One"), "ONE"), + "\\" . ItemScopeStatus::class . "::" . $this->enumCaseFromValue(ItemScopeStatus::class, strval($scope["status"] ?? "Alive"), "ALIVE"), ]; if (($scope["targetCount"] ?? null) !== null) { $parts[] = strval(max(0, intval($scope["targetCount"]))); } - return "new ItemScope(" . implode(", ", $parts) . ")"; + return "new \\Ichiloto\\Engine\\Entities\\ItemScope(" . implode(", ", $parts) . ")"; } private function exportOccasion(string $occasion): string { - return "Occasion::" . $this->enumCaseFromValue(Occasion::class, $occasion, "BATTLE_SCREEN"); + return "\\" . Occasion::class . "::" . $this->enumCaseFromValue(Occasion::class, $occasion, "BATTLE_SCREEN"); } private function exportInvocation(ProjectSkill $skill): string { $invocation = $skill->getInvocation(); return sprintf( - "new SkillInvocation(%s, %d, %d, %d, %d)", + "new \\Ichiloto\\Engine\\Entities\\Skills\\SkillInvocation(%s, %d, %d, %d, %d)", var_export(strval($invocation["message"] ?? ""), true), max(0, intval($invocation["speed"] ?? 0)), max(0, intval($invocation["accuracy"] ?? 0)), @@ -238,24 +339,11 @@ private function exportInvocation(ProjectSkill $skill): string ); } - private function exportEffect(array $effect): string - { - $class = basename(str_replace(chr(92), "/", strval($effect["class"] ?? "SkillEffect"))); - return sprintf( - "new %s(%s, %s, %s, %s)", - $class, - var_export(strval($effect["formula"] ?? "0"), true), - var_export($effect["element"] ?? null, true), - var_export(floatval($effect["variance"] ?? 0.2), true), - var_export(boolval($effect["isCriticalHit"] ?? false), true), - ); - } - private function exportMagicEffectType(?string $effectType): string { return $effectType === null ? "null" - : "MagicEffectType::" . $this->enumCaseFromValue(\Ichiloto\Engine\Entities\Magic\MagicEffectType::class, $effectType, "DESTRUCTIVE"); + : "\\Ichiloto\\Engine\\Entities\\Magic\\MagicEffectType::" . $this->enumCaseFromValue(\Ichiloto\Engine\Entities\Magic\MagicEffectType::class, $effectType, "DESTRUCTIVE"); } private function enumCaseFromValue(string $enumClass, string $value, string $fallback): string @@ -265,22 +353,4 @@ private function enumCaseFromValue(string $enumClass, string $value, string $fal } return $fallback; } - - - private static function openTag(): string { return chr(60) . chr(63) . chr(112) . chr(104) . chr(112); } - private static function emptyString(): string { return str_repeat(chr(32), 0); } - private static function returnArray(): string { return chr(114) . chr(101) . chr(116) . chr(117) . chr(114) . chr(110) . chr(32) . chr(91); } - private static function comma(): string { return chr(44); } - private static function closingArray(): string { return chr(93) . chr(59); } - private static function writeFileTransactionally(string $path, string $contents): void - { - $temporaryPath = $path . ".tmp"; - if (file_put_contents($temporaryPath, $contents) === false) { - throw new RuntimeException(sprintf("Unable to write temporary file for %s.", $path)); - } - if (rename($temporaryPath, $path) === false) { - @unlink($temporaryPath); - throw new RuntimeException(sprintf("Unable to replace %s.", $path)); - } - } } diff --git a/tests/Unit/SkillSourcePreservationTest.php b/tests/Unit/SkillSourcePreservationTest.php new file mode 100644 index 0000000..7d29648 --- /dev/null +++ b/tests/Unit/SkillSourcePreservationTest.php @@ -0,0 +1,122 @@ +markTestSkipped('ICHILOTO_GAME_SRC is required for the production source fixture.'); } + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/skills.php'; + $before = (string) file_get_contents($game . '/assets/Data/skills.php'); + file_put_contents($path, $before); + $original = require $path; + $database = ProjectSkillDatabase::fromProject($root); + $database->setField(2, 'animationId', 17); + $database->save(); + $after = (string) file_get_contents($path); + expect(str_replace(" animationId: 17,\n", '', $after))->toBe($before); + $saved = require $path; + expect($saved[2]->animationId)->toBe(17); + foreach ($original as $index => $skill) { + expect(serialize($saved[$index]->effects))->toBe(serialize($skill->effects)) + ->and(serialize($saved[$index]->requiredWeapons))->toBe(serialize($skill->requiredWeapons)); + } + $database->setField(2, 'animationId', 18); + $database->save(); + expect(file_get_contents($path))->toBe(str_replace('animationId: 17', 'animationId: 18', $after)); + $database->setField(2, 'animationId', null); + $database->save(); + expect((require $path)[2]->animationId)->toBeNull(); +}); + +it('preserves positional and aliased constructors with nested effects and trailing comments', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/skills.php'; + $source = <<<'PHP' +setField(0, 'cost', 8); + $database->setField(0, 'animationId', 4); + $database->save(); + expect(file_get_contents($path))->toBe(str_replace(["'', 4, 0", 'animationId: 3'], ["'', 8, 0", 'animationId: 4'], $source)); + expect((require $path)[0]->effects[0]->stateId)->toBe('poison'); +}); + +it('preserves skill removal and saved undo without regenerating effects', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/skills.php'; + $source = <<<'PHP' +removeSkill(0); + $database->save(); + expect(require $path)->toHaveCount(1); + $database->insertSkill(0, $removed); + $database->save(); + expect(file_get_contents($path))->toBe($source); + expect((require $path)[0]->effects[0]->stateIds)->toBe(['poison']); +}); + +it('adds a named animation after positional arguments without swallowing a trailing comment', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/skills.php'; + file_put_contents($path, <<<'PHP' +setField(0, 'animationId', 42); + $database->save(); + expect((require $path)[0]->animationId)->toBe(42) + ->and(file_get_contents($path))->toContain('0, // Keep the cooldown comment.'); +}); + +it('refuses changing skill type rather than dropping its authored effects', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/skills.php'; + $source = "setField(0, 'type', 'magic'); + expect(fn() => $database->save())->toThrow(RuntimeException::class, 'Refusing to regenerate') + ->and(file_get_contents($path))->toBe($source)->and($database->isDirty())->toBeTrue(); +}); + +it('refuses unsafe source and externally changed skills without writing or clearing dirty state', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/skills.php'; + $source = <<<'PHP' +setField(0, 'animationId', 1); + expect(fn() => $database->save())->toThrow(RuntimeException::class, 'Refusing to flatten') + ->and(file_get_contents($path))->toBe($source) + ->and($database->isDirty())->toBeTrue(); + file_put_contents($path, $source . "\n// External edit\n"); + expect(fn() => $database->save())->toThrow(RuntimeException::class, 'changed outside') + ->and(file_get_contents($path))->toBe($source . "\n// External edit\n"); +}); From 3d23ced760f0fe342dd307c8198186ba3de4a332 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 16:12:40 +0200 Subject: [PATCH 010/344] fix(editor): select stable skit actors and aggregate refused map diagnostics --- src/Database/ProjectRecord.php | 7 ++++ src/Database/ProjectRecordDatabase.php | 2 ++ src/Database/RecordSchemaCatalog.php | 4 ++- src/Database/RecordSubList.php | 15 ++++++++ src/Editor.php | 14 ++++++++ src/Validation/ProjectValidator.php | 32 +++++++++++++++++ tests/Unit/SkitAndEventEditorTest.php | 49 ++++++++++++++++++++++++++ 7 files changed, 122 insertions(+), 1 deletion(-) diff --git a/src/Database/ProjectRecord.php b/src/Database/ProjectRecord.php index 24a2f43..25745b2 100644 --- a/src/Database/ProjectRecord.php +++ b/src/Database/ProjectRecord.php @@ -420,6 +420,13 @@ public function toArray(): array|object return $this->payload; } + /** Restores an editor-owned snapshot, including keys removed by a compound field edit. */ + public function restorePayload(array|object $payload): void + { + $this->payload = $payload; + $this->touchState(); + } + /** * Renders any leaf value as a settings-pane string. * diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index e5b29d9..fe6fc3a 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -2358,6 +2358,7 @@ private function setSubField(ProjectRecord $record, RecordSubList $subList, int explode('.', $field->key), self::coerce($field, $rawValue), ); + $entries[$entryIndex] = $subList->removeConflictingFields($entries[$entryIndex], $field->key); $record->setSubList($subList->key, $entries); $this->touchState(); @@ -3686,6 +3687,7 @@ private function writeEntryFieldToken(RecordSubList $subList, array $entry, stri } $written = self::writeNested($entry, explode('.', $field->key), self::coerce($field, $rawValue)); + $written = $subList->removeConflictingFields($written, $field->key); if ($field->key === $subList->variantKey && $written !== null) { $written = self::withoutStaleVariantFields($subList, $entry, $written); diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 9c76762..2ddb1b0 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -863,10 +863,12 @@ private static function skits(): RecordSchema prefix: 'beat', singular: 'beat', fields: [ - RecordField::reference('speaker', 'Speaker', 'actors'), + RecordField::reference('actor', 'Actor', 'actor_ids', allowsNone: true, noneLabel: '(Non-actor speaker)'), + new RecordField('speaker', 'Non-actor Speaker', removeWhenEmpty: true), new RecordField('text', 'Text'), ], blank: ['speaker' => 'Speaker', 'text' => 'Say something.'], + exclusiveFields: [['actor', 'speaker']], ), ); } diff --git a/src/Database/RecordSubList.php b/src/Database/RecordSubList.php index 348ec3b..903b57b 100644 --- a/src/Database/RecordSubList.php +++ b/src/Database/RecordSubList.php @@ -35,6 +35,7 @@ * @param array> $variantArms Command lists * only some variants carry, variant => (key => label): a cinematic * `sequence` owns its `commands`, a `choice` its `cancel` arm. + * @param list> $exclusiveFields Field groups where authoring one removes the others. */ public function __construct( public string $key, @@ -47,9 +48,23 @@ public function __construct( public array $nestedLists = [], public array $commandArms = [], public array $variantArms = [], + public array $exclusiveFields = [], ) { } + /** @param array $entry @return array */ + public function removeConflictingFields(array $entry, string $editedField): array + { + if (! array_key_exists($editedField, $entry)) { return $entry; } + foreach ($this->exclusiveFields as $fields) { + if (! in_array($editedField, $fields, true)) { continue; } + foreach ($fields as $field) { + if ($field !== $editedField) { unset($entry[$field]); } + } + } + return $entry; + } + /** * Returns the command arms one entry carries: the arms every entry has, * and those its variant adds. diff --git a/src/Editor.php b/src/Editor.php index db7c9eb..46d288f 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -12249,8 +12249,22 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) 'records' => $this->databaseSelectedRecordIndexes, ]; + $record = $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()); + $before = $record?->toArray(); $this->applyDatabaseFieldValue($fieldId, $rawValue); + if ($record !== null && $before !== null) { + $after = $record->toArray(); + if ($before !== $after) { + $this->recordCommand(new GenericCommand( + sprintf('%s edit', $field['label'] ?? 'Database field'), + static fn() => $record->restorePayload($after), + static fn() => $record->restorePayload($before), + )); + } + return; + } + if (in_array($fieldId, ['brushSymbol', 'brushColor'], true) || $oldRawValue === $rawValue) { return; } diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 546c30f..718bc35 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -22,6 +22,8 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Core\WorldConditionType; +use Ichiloto\Engine\Field\SkitSpeaker; +use Ichiloto\Engine\Util\Stores\ActorStore; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; use Ichiloto\Engine\Events\Interpreter\MovementRouteRunner; use Ichiloto\Engine\IO\Console\TerminalText; @@ -1558,6 +1560,15 @@ protected function checkMaps(ProjectWorkspace $workspace): array $troops = $this->labelsOf($workspace, 'troops'); foreach ($workspace->maps as $map) { + if (($sourceIssue = $map->getGridSourceIssue()) !== null) { + $issues[] = Issue::error( + $map->mapId, + "Map source is read-only: {$sourceIssue}", + 'Repair the source by hand, then reopen the project. Other maps remain editable.', + ); + continue; + } + $issues = [ ...$issues, ...$this->checkLayers($map), @@ -3009,6 +3020,12 @@ protected function checkSkitReferences(ProjectWorkspace $workspace, array $known } $issues = []; + try { + $actors = new ActorStore($workspace->projectRoot . '/assets/Data/Actors'); + } catch (InvalidArgumentException|\RuntimeException $exception) { + $actors = null; + $issues[] = Issue::error('skit actors', $exception->getMessage(), 'Repair the actor registry before validating skit actor references.'); + } foreach ($database->getRecords() as $record) { $skit = (array) $record->toArray(); @@ -3024,6 +3041,21 @@ protected function checkSkitReferences(ProjectWorkspace $workspace, array $known } $issues = [...$issues, ...$this->checkConditions((array) ($skit['conditions'] ?? []), $where, $known)]; + foreach ($skit['beats'] ?? [] as $index => $beat) { + if (! is_array($beat)) { + $issues[] = Issue::error($where, 'Each skit beat must be a data record.'); + continue; + } + if ($actors === null) { continue; } + $speaker = SkitSpeaker::getFromBeat($beat, $actors); + $beatLocation = sprintf('%s beat %d', $where, $index + 1); + foreach ($speaker->notices as $notice) { + $issues[] = Issue::warning($beatLocation, $notice, 'Choose the Actor picker to persist the stable actor id.'); + } + foreach ($speaker->errors as $error) { + $issues[] = Issue::error($beatLocation, $error, 'Choose an Actor, or enter a non-actor speaker, not both.'); + } + } } return $issues; diff --git a/tests/Unit/SkitAndEventEditorTest.php b/tests/Unit/SkitAndEventEditorTest.php index db26da8..b1773f9 100644 --- a/tests/Unit/SkitAndEventEditorTest.php +++ b/tests/Unit/SkitAndEventEditorTest.php @@ -79,6 +79,55 @@ removeDirectoryRecursively($root); }); +it('selects stable skit actors and keeps non-actor speaker text mutually exclusive', function (): void { + $root = makeTemporaryProject(); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'skits'); + $workspace = getEditorProperty($editor, 'workspace'); + $database = $workspace->getRecordDatabase('skits'); + $fields = array_column($database->getSettingsFields(0), null, 'field'); + $actor = $workspace->actorDatabase->getActors()[0]; + expect($fields['beat0Actor']['reference'])->toBe('actor_ids') + ->and(isset($fields['beat0Actor']['control']))->toBeFalse() + ->and(isset($fields['beat0Speaker']['reference']))->toBeFalse(); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $fields['beat0Actor'], $actor->getDefinitionId()); + $database->save(); + $path = $root . '/assets/Data/Skits/breakfast-banter.php'; + $beat = (require $path)['beats'][0]; + expect($beat['actor'])->toBe($actor->getDefinitionId())->and($beat)->not->toHaveKey('speaker'); + callEditorMethod($editor, 'performUndo'); + expect($database->getRecords()[0]->toArray()['beats'][0]['speaker'])->toBe('Liora'); + callEditorMethod($editor, 'performRedo'); + expect($database->getRecords()[0]->toArray()['beats'][0]['actor'])->toBe($actor->getDefinitionId()); + $database->setField(0, 'beat0Speaker', 'Innkeeper'); + $database->save(); + $beat = (require $path)['beats'][0]; + expect($beat['speaker'])->toBe('Innkeeper')->and($beat)->not->toHaveKey('actor'); +}); + +it('validates skit actor identity with the shared runtime resolver and warns on legacy speaker ids', function (): void { + $root = makeTemporaryProject(); + $workspace = \Ichiloto\Editor\ProjectWorkspace::fromProject($root); + $actor = $workspace->actorDatabase->getActors()[0]; + $database = $workspace->getRecordDatabase('skits'); + $record = $database->getRecords()[0]; + $record->setSubList('beats', [ + ['actor' => $actor->getDefinitionId(), 'text' => 'Canonical'], + ['speaker' => $actor->getDefinitionId(), 'text' => 'Legacy'], + ['actor' => 'not-an-actor', 'text' => 'Invalid'], + ['actor' => $actor->getDefinitionId(), 'speaker' => 'Innkeeper', 'text' => 'Conflict'], + ['speaker' => 'Innkeeper', 'text' => 'Plain text'], + ]); + $validator = new \Ichiloto\Editor\Validation\ProjectValidator(); + $method = new ReflectionMethod($validator, 'checkSkitReferences'); + $issues = $method->invoke($validator, $workspace, ['maps' => ['happyville/town-center'], 'quests' => ['breakfast-duty']]); + $speakers = array_values(array_filter($issues, static fn($issue): bool => str_contains($issue->where, ' beat '))); + expect($speakers)->toHaveCount(3) + ->and($speakers[0]->message)->toContain('Deprecated') + ->and($speakers[1]->message)->toContain('stable actor id') + ->and($speakers[2]->message)->toContain('not both'); +}); + it('creates a new skit as its own file', function (): void { $root = makeTemporaryProject(); $database = loadRecordDatabase($root, 'skits'); From 7c3c98a0d99fbeb1e2c638ab76134a37db68c9fc Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 20:12:22 +0200 Subject: [PATCH 011/344] fix(skills): isolate unsupported entries without blocking other saves --- src/ProjectSkillDatabase.php | 28 ++++++++++++++++--- tests/Unit/SkillSourcePreservationTest.php | 31 +++++++++++++++++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/ProjectSkillDatabase.php b/src/ProjectSkillDatabase.php index a43efcb..192d560 100644 --- a/src/ProjectSkillDatabase.php +++ b/src/ProjectSkillDatabase.php @@ -94,7 +94,13 @@ public function addSkill(string $name = "New Skill"): int public function setField(int $index, string $field, mixed $value): void { $skill = $this->getSkillByIndex($index); - if ($skill instanceof ProjectSkill) { $skill->setField($field, $value); $this->touchState(); } + if (! $skill instanceof ProjectSkill) { return; } + $authored = $this->authored[spl_object_id($skill)] ?? null; + if ($authored !== null && $authored['source'] === null) { + throw $this->getSourceRefusal($skill); + } + $skill->setField($field, $value); + $this->touchState(); } /** @@ -204,10 +210,21 @@ private function getUpdatedSource(): string if ($document->entryCount() !== count($order)) { throw new RuntimeException('Refusing to flatten skills.php: its entries cannot be matched to the loaded skills.'); } + $retained = array_map(spl_object_id(...), $this->getSkills()); + foreach (array_reverse(array_keys($order)) as $index) { + if (in_array($order[$index], $retained, true)) { continue; } + $removed = $this->authored[$order[$index]]; + if ($removed['source'] === null) { throw $this->getSourceRefusal($removed['skill']); } + $document = $document->withoutEntry($index); + array_splice($order, $index, 1); + } foreach ($this->getSkills() as $index => $skill) { $key = spl_object_id($skill); - $entrySource = $this->getUpdatedEntry($skill); $position = array_search($key, $order, true); + if ($position === $index && $skill->toArray() === $this->authored[$key]['payload']) { + continue; + } + $entrySource = $this->getUpdatedEntry($skill); if ($position === $index) { if ($document->entrySource($index) !== $entrySource) { $old = $document->entrySource($index); @@ -235,13 +252,18 @@ private function getUpdatedEntry(ProjectSkill $skill): string $authored = $this->authored[spl_object_id($skill)] ?? null; if ($authored === null) { return ltrim($this->exportSkill($skill)); } if ($authored['source'] === null) { - throw new RuntimeException('Refusing to flatten an unsupported skill expression. Edit its source directly.'); + throw $this->getSourceRefusal($skill); } $document = PhpSourceDocument::parse('applySkillChanges($document, 0, $skill)->entrySource(0) ?? throw new RuntimeException('Cannot preserve the edited skill constructor.'); } + private function getSourceRefusal(ProjectSkill $skill): RuntimeException + { + return new RuntimeException(sprintf('Refusing to flatten skill "%s" (entry %d): its authored expression is read-only. Edit its source directly.', $skill->getName(), $skill->id)); + } + private function applySkillChanges(PhpSourceDocument $document, int $index, ProjectSkill $skill): PhpSourceDocument { $authored = $this->authored[spl_object_id($skill)]; diff --git a/tests/Unit/SkillSourcePreservationTest.php b/tests/Unit/SkillSourcePreservationTest.php index 7d29648..74e09a1 100644 --- a/tests/Unit/SkillSourcePreservationTest.php +++ b/tests/Unit/SkillSourcePreservationTest.php @@ -112,11 +112,36 @@ PHP; file_put_contents($path, $source); $database = ProjectSkillDatabase::fromProject($root); - $database->setField(0, 'animationId', 1); - expect(fn() => $database->save())->toThrow(RuntimeException::class, 'Refusing to flatten') + expect(fn() => $database->setField(0, 'animationId', 1))->toThrow(RuntimeException::class, 'skill "Computed" (entry 1)') ->and(file_get_contents($path))->toBe($source) - ->and($database->isDirty())->toBeTrue(); + ->and($database->isDirty())->toBeFalse(); file_put_contents($path, $source . "\n// External edit\n"); + $database->addSkill(); expect(fn() => $database->save())->toThrow(RuntimeException::class, 'changed outside') ->and(file_get_contents($path))->toBe($source . "\n// External edit\n"); }); + +it('keeps an unsupported skill untouched while saving and removing supported neighbours', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/skills.php'; + $source = <<<'PHP' + $database->setField(1, 'animationId', 3))->toThrow(RuntimeException::class, '"Computed"'); + $database->setField(2, 'animationId', 4); + $database->save(); + expect(file_get_contents($path))->toBe(str_replace("'After', '', '', 0, 0)", "'After', '', '', 0, 0, animationId: 4)", $source)); + $database->removeSkill(0); + $database->save(); + expect((require $path)[0]->name)->toBe('Computed') + ->and((require $path)[1]->animationId)->toBe(4); +}); From 35c07d590eec7e583ff7343c83263211079818bb Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 20:14:22 +0200 Subject: [PATCH 012/344] fix(skills): remove unsupported Type and non-magic Effect Type controls --- src/Editor.php | 25 +++++++++++++++++-- src/ProjectSkill.php | 2 +- src/ProjectSkillDatabase.php | 25 +++++++++++++++++-- tests/Unit/AnimationReferenceTest.php | 28 ++++++++++++++++++++++ tests/Unit/SkillSourcePreservationTest.php | 5 ++-- 5 files changed, 77 insertions(+), 8 deletions(-) diff --git a/src/Editor.php b/src/Editor.php index 46d288f..70e050f 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -941,6 +941,7 @@ private function boot(): void $this->isHelpOpen = false; $this->isCommandPaletteOpen = false; $this->commandPalette->close(); + $this->reportRefusedMapsAtBoot(); $this->terminal->enterRawMode(); $this->inputDecoder = new InputDecoder(); $this->inputDecoder->attach(); @@ -954,6 +955,25 @@ private function boot(): void $this->requestFullRender(); } + /** Warns about maps whose authored grids cannot be safely loaded. */ + private function reportRefusedMapsAtBoot(): void + { + $refusals = []; + foreach ($this->workspace->maps as $map) { + if (($issue = $map->getGridSourceIssue()) !== null) { + $refusals[] = $issue; + } + } + + if ($refusals !== []) { + $this->setStatus( + sprintf('%d map(s) are read-only (Ctrl+E for details).', count($refusals)), + StatusLevel::WARN, + $refusals, + ); + } + } + /** * Restores the terminal after the editor exits. * @@ -10707,10 +10727,9 @@ private function getDatabaseSkillSettingsFields(): array $scope = $skill->getScope(); $invocation = $skill->getInvocation(); - return [ + $fields = [ ['label' => 'Name', 'value' => $skill->getName(), 'control' => new InputControl(InputControlType::TEXT, $skill->getName()), 'field' => 'name'], ['label' => 'Description', 'value' => $skill->getDescription(), 'control' => new InputControl(InputControlType::TEXT, $skill->getDescription()), 'field' => 'description'], - ['label' => 'Type', 'value' => $skill->getType(), 'options' => ['basic', 'special', 'magic'], 'field' => 'type'], ['label' => 'Icon', 'value' => $skill->getIcon(), 'control' => new InputControl(InputControlType::TEXT, $skill->getIcon()), 'field' => 'icon'], ['label' => 'Cost', 'value' => (string) $skill->getCost(), 'control' => new InputControl(InputControlType::INTEGER, (string) $skill->getCost()), 'field' => 'cost'], ['label' => 'Cooldown', 'value' => (string) $skill->getCooldown(), 'control' => new InputControl(InputControlType::INTEGER, (string) $skill->getCooldown()), 'field' => 'cooldown'], @@ -10734,6 +10753,8 @@ private function getDatabaseSkillSettingsFields(): array 'noneLabel' => '(Legacy fallback)', ], ]; + return array_values(array_filter($fields, fn(array $field): bool => + $this->workspace->skillDatabase->canEditField($this->databaseSelectedSkillIndex, $field['field']))); } /** * Returns the editable settings fields for the selected quest. diff --git a/src/ProjectSkill.php b/src/ProjectSkill.php index 58755a9..0b76d18 100644 --- a/src/ProjectSkill.php +++ b/src/ProjectSkill.php @@ -98,7 +98,7 @@ public static function createBlank(int $id, string $name = "New Skill"): self "apGain" => 10, ], "effects" => [], - "effectType" => MagicEffectType::DESTRUCTIVE->value, + "effectType" => null, ], true); } diff --git a/src/ProjectSkillDatabase.php b/src/ProjectSkillDatabase.php index 192d560..194ed70 100644 --- a/src/ProjectSkillDatabase.php +++ b/src/ProjectSkillDatabase.php @@ -99,10 +99,32 @@ public function setField(int $index, string $field, mixed $value): void if ($authored !== null && $authored['source'] === null) { throw $this->getSourceRefusal($skill); } + if (! $this->canEditField($index, $field)) { + throw new RuntimeException(sprintf('Skill "%s" does not support editing %s.', $skill->getName(), $field)); + } $skill->setField($field, $value); $this->touchState(); } + public function canEditField(int $index, string $field): bool + { + $skill = $this->getSkillByIndex($index); + if ($skill === null || in_array($field, ['type', 'effects'], true)) { return false; } + $authored = $this->authored[spl_object_id($skill)] ?? null; + if ($authored !== null && $authored['source'] === null) { return false; } + $parameter = str_starts_with($field, 'scope') ? 'scope' + : (str_starts_with($field, 'invocation') ? 'invocation' : $field); + return in_array($parameter, $this->getConstructorParameters($skill), true); + } + + /** @return list */ + private function getConstructorParameters(ProjectSkill $skill): array + { + $class = $this->authored[spl_object_id($skill)]['class'] ?? $this->getSkillClass($skill); + return array_map(static fn(\ReflectionParameter $parameter): string => $parameter->getName(), + (new ReflectionClass($class))->getConstructor()?->getParameters() ?? []); + } + /** * Removes the skill at the given index (rewritten to disk on save, so * re-inserting at the same index is a complete undo). @@ -267,8 +289,7 @@ private function getSourceRefusal(ProjectSkill $skill): RuntimeException private function applySkillChanges(PhpSourceDocument $document, int $index, ProjectSkill $skill): PhpSourceDocument { $authored = $this->authored[spl_object_id($skill)]; - $parameters = array_map(static fn(\ReflectionParameter $parameter): string => $parameter->getName(), - (new ReflectionClass($authored['class']))->getConstructor()?->getParameters() ?? []); + $parameters = $this->getConstructorParameters($skill); foreach ($skill->toArray() as $field => $value) { if ($value === ($authored['payload'][$field] ?? null)) { continue; } if (in_array($field, ['type', 'effects'], true)) { diff --git a/tests/Unit/AnimationReferenceTest.php b/tests/Unit/AnimationReferenceTest.php index 5c79381..7308448 100644 --- a/tests/Unit/AnimationReferenceTest.php +++ b/tests/Unit/AnimationReferenceTest.php @@ -110,3 +110,31 @@ callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field, '(Legacy fallback)'); expect($workspace->skillDatabase->getSkillByIndex(0)->animationId)->toBeNull(); }); + +it('offers only supported skill fields and leaves a no-op pick unchanged', function (string $class) { + $root = makeTemporaryProject(); + $database = new ProjectSkillDatabase($root . '/assets/Data/skills.php', [ + ProjectSkill::fromSkill(new $class('Skill', '', '', 0, 0), 1), + ], true); + $database->save(); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'skills'); + $fields = callEditorMethod($editor, 'getDatabaseSkillSettingsFields'); + $byName = array_column($fields, null, 'field'); + expect($byName)->not->toHaveKey('type'); + $workspace = getEditorProperty($editor, 'workspace'); + $skill = $workspace->skillDatabase->getSkillByIndex(0); + $before = $skill->toArray(); + if ($class === MagicSkill::class) { + expect($byName)->toHaveKey('effectType'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $byName['effectType'], $byName['effectType']['value']); + } else { + expect($byName)->not->toHaveKey('effectType'); + expect(fn() => $workspace->skillDatabase->setField(0, 'effectType', 'Destructive')) + ->toThrow(RuntimeException::class, 'does not support editing effectType'); + } + expect($skill->toArray())->toBe($before)->and($workspace->skillDatabase->isDirty())->toBeFalse(); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $byName['animationId'], '1'); + $workspace->skillDatabase->save(); + expect((require $root . '/assets/Data/skills.php')[0]->animationId)->toBe(1); +})->with([BasicSkill::class, MagicSkill::class, SpecialSkill::class]); diff --git a/tests/Unit/SkillSourcePreservationTest.php b/tests/Unit/SkillSourcePreservationTest.php index 74e09a1..ed29f4d 100644 --- a/tests/Unit/SkillSourcePreservationTest.php +++ b/tests/Unit/SkillSourcePreservationTest.php @@ -97,9 +97,8 @@ $source = "setField(0, 'type', 'magic'); - expect(fn() => $database->save())->toThrow(RuntimeException::class, 'Refusing to regenerate') - ->and(file_get_contents($path))->toBe($source)->and($database->isDirty())->toBeTrue(); + expect(fn() => $database->setField(0, 'type', 'magic'))->toThrow(RuntimeException::class, 'does not support editing type') + ->and(file_get_contents($path))->toBe($source)->and($database->isDirty())->toBeFalse(); }); it('refuses unsafe source and externally changed skills without writing or clearing dirty state', function (): void { From dd4f794691ce5ff9960c75e461c32628c7ca5566 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 20:21:23 +0200 Subject: [PATCH 013/344] fix(source): keep skill comments attached and remove cleared animation arguments --- .../guide/tooling-loop/the-database.md | 9 +- src/Database/PhpSourceDocument.php | 105 ++++++++++++++---- src/ProjectSkillDatabase.php | 18 ++- tests/Unit/AnimationReferenceTest.php | 3 +- tests/Unit/SkillSourcePreservationTest.php | 20 +++- 5 files changed, 125 insertions(+), 30 deletions(-) diff --git a/docs/website/guide/tooling-loop/the-database.md b/docs/website/guide/tooling-loop/the-database.md index 6c569ce..6938265 100644 --- a/docs/website/guide/tooling-loop/the-database.md +++ b/docs/website/guide/tooling-loop/the-database.md @@ -23,8 +23,15 @@ which incorrectly regenerated non-damage effects as damage effects. Changing a skill's class or regenerating effects without their original source is refused before writing; edit those expressions in source instead. +An unsupported skill expression is read-only, not a reason to block other +skills in the same file. Refusals identify the skill. The form omits Type +and any fields the selected constructor cannot save, including Effect Type +on non-magic skills. Leading documentation and same-line trailing comments +travel with a skill when it is moved, deleted or restored. + Choose **(Legacy fallback)** for a skill or **(None)** for an item to clear -the reference. Skills then retain their +the reference, removing the authored argument rather than writing a null +placeholder. Skills then retain their deprecated name-based selection, which project validation reports as a warning when it matches. Items without a reference retain their existing no-animation behavior. A missing explicit id omits the animation, not the diff --git a/src/Database/PhpSourceDocument.php b/src/Database/PhpSourceDocument.php index 7e1f4ca..d1bd280 100644 --- a/src/Database/PhpSourceDocument.php +++ b/src/Database/PhpSourceDocument.php @@ -194,6 +194,27 @@ public function withArgument(int $entryIndex, string $path, string $literal): se * @param list $parameters Constructor parameter names in declaration order. */ public function getWithConstructorArgument(int $entryIndex, string $name, string $literal, array $parameters): self + { + return $this->getMappedConstructor($entryIndex, $name, $parameters)->withArgument($entryIndex, $name, $literal); + } + + /** Removes an optional argument without shifting any later positional arguments. @param list $parameters */ + public function getWithoutConstructorArgument(int $entryIndex, string $name, array $parameters): self + { + $document = $this->getMappedConstructor($entryIndex, $name, $parameters); + $entry = $document->entries[$entryIndex]; + $removed = $entry['arguments'][$name] ?? null; + if ($removed === null) { return $this; } + $source = $this->source; + foreach (array_reverse($entry['arguments'], true) as $parameter => $span) { + if ($span['start'] <= $removed['start'] || ($span['nameStart'] ?? null) !== $span['start']) { continue; } + $source = substr($source, 0, $span['start']) . $parameter . ': ' . substr($source, $span['start']); + } + return self::parse($source)->getMappedConstructor($entryIndex, $name, $parameters)->withoutArgument($entryIndex, $name); + } + + /** @param list $parameters */ + private function getMappedConstructor(int $entryIndex, string $name, array $parameters): self { if (! $this->isConstructorEntry($entryIndex) || ! in_array($name, $parameters, true)) { throw new RuntimeException('Cannot edit an unknown constructor or parameter.'); @@ -223,7 +244,7 @@ public function getWithConstructorArgument(int $entryIndex, string $name, string $entry['positional'] = false; $entries[$entryIndex] = $entry; - return new self($this->source, $entries, $this->arrayClose)->withArgument($entryIndex, $name, $literal); + return new self($this->source, $entries, $this->arrayClose); } /** @@ -314,26 +335,42 @@ public function withNewEntry(string $class, array $arguments, ?int $before = nul /** Inserts a previously preserved or newly authored constructor without re-exporting its values. */ public function getWithEntrySource(string $source, ?int $before = null): self + { + return $this->getWithEntryBlockSource($this->entryIndent() . $source . ",\n", $before); + } + + /** Inserts a preserved entry together with its leading and same-line trailing comments. */ + public function getWithEntryBlockSource(string $block, ?int $before = null): self { if ($this->arrayClose === null) { throw new RuntimeException('This file does not return an array to add an entry to.'); } - $candidate = self::parse("entryCount() !== 1 || ! $candidate->isConstructorEntry(0)) { throw new RuntimeException('Only a complete constructor entry can be inserted.'); } - $indent = $this->entryIndent(); - $block = $indent . $source . ",\n"; + $entry = $candidate->entries[0]; + $hasComma = false; + foreach (self::tokenize('source, $entry['close'] + 1)) as $token) { + if (self::isSkippable($token) || $token['id'] === T_OPEN_TAG) { continue; } + $hasComma = $token['text'] === ','; + break; + } + if (! $hasComma) { + $offset = $entry['close'] + 1 - strlen("isConstructorEntry($before)) { + if (! isset($this->entries[$before])) { throw new RuntimeException(sprintf( 'Cannot place an entry before %s: it is not a constructor call this editor can position against.', $this->describeEntry($before), )); } - $start = $this->entries[$before]['start']; + $start = $this->getEntryBounds($before)['start']; $lineBreak = strrpos(substr($this->source, 0, $start), "\n"); $lineStart = $lineBreak === false ? 0 : $lineBreak + 1; @@ -346,7 +383,7 @@ public function getWithEntrySource(string $source, ?int $before = null): self // The neighbour shares a line with something before it. The new // entry still goes ahead of it, and the neighbour continues on a // fresh line at the entries' indentation. - return self::parse(substr($this->source, 0, $start) . ltrim($block) . $indent . substr($this->source, $start)); + return self::parse(substr($this->source, 0, $start) . ltrim($block) . $this->entryIndent() . substr($this->source, $start)); } $beforeClose = substr($this->source, 0, $this->arrayClose); @@ -382,28 +419,48 @@ public function withoutEntry(int $entryIndex): self )); } - $entry = $this->entries[$entryIndex]; - $start = $entry['start']; - $end = $entry['close'] + 1; - $source = $this->source; + $bounds = $this->getEntryBounds($entryIndex); + return self::parse(substr($this->source, 0, $bounds['start']) . substr($this->source, $bounds['end'])); + } - while ($end < strlen($source) && ($source[$end] === ' ' || $source[$end] === "\t")) { - $end++; - } + public function getEntryBlockSource(int $entryIndex): ?string + { + if (! $this->isConstructorEntry($entryIndex)) { return null; } + $bounds = $this->getEntryBounds($entryIndex); + return substr($this->source, $bounds['start'], $bounds['end'] - $bounds['start']); + } - if ($end < strlen($source) && $source[$end] === ',') { - $end++; + /** @return array{start: int, end: int} */ + private function getEntryBounds(int $entryIndex): array + { + $entry = $this->entries[$entryIndex]; + $tokens = self::tokenize($this->source); + $start = $entry['start']; + foreach (array_reverse($tokens) as $token) { + if ($token['offset'] >= $entry['start']) { continue; } + if ($token['id'] === T_WHITESPACE) { continue; } + if (! in_array($token['id'], [T_COMMENT, T_DOC_COMMENT], true)) { break; } + $lineStart = strrpos(substr($this->source, 0, $token['offset']), "\n"); + $lineStart = $lineStart === false ? 0 : $lineStart + 1; + if (trim(substr($this->source, $lineStart, $token['offset'] - $lineStart)) !== '') { break; } + $start = $token['offset']; } + $lineStart = strrpos(substr($this->source, 0, $start), "\n"); + $lineStart = $lineStart === false ? 0 : $lineStart + 1; + if (trim(substr($this->source, $lineStart, $start - $lineStart)) === '') { $start = $lineStart; } - if ($end < strlen($source) && $source[$end] === "\n") { - $end++; - - while ($start > 0 && ($source[$start - 1] === ' ' || $source[$start - 1] === "\t")) { - $start--; - } + $end = $entry['close'] + 1; + foreach ($tokens as $token) { + if ($token['offset'] < $end) { continue; } + if ($token['id'] === T_WHITESPACE) { + $newline = strpos($token['text'], "\n"); + $end = $token['offset'] + ($newline === false ? strlen($token['text']) : $newline + 1); + if ($newline !== false) { break; } + } elseif ($token['text'] === ',' || in_array($token['id'], [T_COMMENT, T_DOC_COMMENT], true)) { + $end = $token['offset'] + strlen($token['text']); + } else { break; } } - - return self::parse(substr($source, 0, $start) . substr($source, $end)); + return ['start' => $start, 'end' => $end]; } /** diff --git a/src/ProjectSkillDatabase.php b/src/ProjectSkillDatabase.php index 194ed70..dd8b199 100644 --- a/src/ProjectSkillDatabase.php +++ b/src/ProjectSkillDatabase.php @@ -25,7 +25,7 @@ final class ProjectSkillDatabase private ?string $source = null; /** @var list */ private array $sourceOrder = []; - /** @var array */ + /** @var array */ private array $authored = []; public function __construct( @@ -69,6 +69,7 @@ public static function fromProject(string $projectRoot): self 'skill' => $skill, 'payload' => $skill->toArray(), 'source' => count($payload) === count($skills) ? $document->entrySource($index) : null, + 'block' => count($payload) === count($skills) ? $document->getEntryBlockSource($index) : null, 'class' => $classes[$index], ]; } @@ -194,6 +195,7 @@ public function save(): void 'skill' => $skill, 'payload' => $skill->toArray(), 'source' => $document->entrySource($index), + 'block' => $document->getEntryBlockSource($index), 'class' => $this->authored[$key]['class'] ?? $this->getSkillClass($skill), ]; } @@ -260,7 +262,15 @@ private function getUpdatedSource(): string $document = $document->withoutEntry($position); array_splice($order, $position, 1); } - $document = $document->getWithEntrySource($entrySource, $index < count($order) ? $index : null); + $before = $index < count($order) ? $index : null; + $block = $this->authored[$key]['block'] ?? null; + if ($block !== null) { + $preserved = PhpSourceDocument::parse("applySkillChanges($preserved, 0, $skill)->getEntryBlockSource(0); + $document = $document->getWithEntryBlockSource($block, $before); + } else { + $document = $document->getWithEntrySource($entrySource, $before); + } array_splice($order, $index, 0, [$key]); } for ($index = count($order) - 1; $index >= count($this->skills); $index--) { @@ -295,6 +305,10 @@ private function applySkillChanges(PhpSourceDocument $document, int $index, Proj if (in_array($field, ['type', 'effects'], true)) { throw new RuntimeException('Refusing to regenerate skill type or effects; edit those expressions in source.'); } + if ($field === 'animationId' && $value === null) { + $document = $document->getWithoutConstructorArgument($index, $field, $parameters); + continue; + } $literal = match ($field) { 'scope' => $this->exportScope($skill), 'invocation' => $this->exportInvocation($skill), diff --git a/tests/Unit/AnimationReferenceTest.php b/tests/Unit/AnimationReferenceTest.php index 7308448..36f2cc1 100644 --- a/tests/Unit/AnimationReferenceTest.php +++ b/tests/Unit/AnimationReferenceTest.php @@ -43,7 +43,8 @@ expect($loaded->getSkillByIndex(0)->animationId)->toBe(1); $loaded->setField(0, 'animationId', ''); $loaded->save(); - expect(ProjectSkillDatabase::fromProject($root)->getSkillByIndex(0)->animationId)->toBeNull(); + expect(ProjectSkillDatabase::fromProject($root)->getSkillByIndex(0)->animationId)->toBeNull() + ->and(file_get_contents($root . '/assets/Data/skills.php'))->not->toContain('animationId:'); })->with([BasicSkill::class, MagicSkill::class, SpecialSkill::class]); it('round trips item animation references through the existing typed resource picker schema', function () { diff --git a/tests/Unit/SkillSourcePreservationTest.php b/tests/Unit/SkillSourcePreservationTest.php index ed29f4d..0927abf 100644 --- a/tests/Unit/SkillSourcePreservationTest.php +++ b/tests/Unit/SkillSourcePreservationTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Ichiloto\Editor\ProjectSkillDatabase; +use Ichiloto\Editor\Database\PhpSourceDocument; it('changes only the animation field in real Last Legend skills and preserves every effect', function (): void { $game = gameSourceRoot(); @@ -28,7 +29,7 @@ expect(file_get_contents($path))->toBe(str_replace('animationId: 17', 'animationId: 18', $after)); $database->setField(2, 'animationId', null); $database->save(); - expect((require $path)[2]->animationId)->toBeNull(); + expect((require $path)[2]->animationId)->toBeNull()->and(file_get_contents($path))->toBe($before); }); it('preserves positional and aliased constructors with nested effects and trailing comments', function (): void { @@ -60,8 +61,10 @@ use Ichiloto\Engine\Entities\Skills\SpecialSkill; use Ichiloto\Engine\Entities\Effects\SkillEffects\RemoveStateSkillEffect; return [ + // Cleanse documentation. new SpecialSkill('Cleanse', '', '', 5, 0, effects: [new RemoveStateSkillEffect(['poison'])]), - new SpecialSkill('Other', '', '', 0, 0), + // Other documentation. + new SpecialSkill('Other', '', '', 0, 0), // Other tail. ]; PHP; file_put_contents($path, $source); @@ -69,10 +72,16 @@ $removed = $database->removeSkill(0); $database->save(); expect(require $path)->toHaveCount(1); + expect(file_get_contents($path))->not->toContain('Cleanse documentation.') + ->and(file_get_contents($path))->toContain("// Other documentation.\n new SpecialSkill('Other', '', '', 0, 0), // Other tail."); $database->insertSkill(0, $removed); $database->save(); expect(file_get_contents($path))->toBe($source); expect((require $path)[0]->effects[0]->stateIds)->toBe(['poison']); + $moved = $database->removeSkill(1); + $database->insertSkill(0, $moved); + $database->save(); + expect(file_get_contents($path))->toContain("// Other documentation.\n new SpecialSkill('Other', '', '', 0, 0), // Other tail.\n // Cleanse documentation."); }); it('adds a named animation after positional arguments without swallowing a trailing comment', function (): void { @@ -144,3 +153,10 @@ expect((require $path)[0]->name)->toBe('Computed') ->and((require $path)[1]->animationId)->toBe(4); }); + +it('removes optional positional arguments without shifting the arguments that follow', function (): void { + $source = "getWithoutConstructorArgument(0, 'animationId', ['name', 'animationId', 'note']); + expect($document->source)->toBe(" PhpToken::tokenize($document->source, TOKEN_PARSE))->not->toThrow(ParseError::class); +}); From ccc64dca880e56303214e6a83d1cc381b9715b35 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 20:23:15 +0200 Subject: [PATCH 014/344] fix(editor): clarify map refusals and return preview transfer success --- src/Cutscenes/Preview/PreviewGameScene.php | 3 ++- src/ProjectMap.php | 7 ++----- tests/Unit/MapIsolationTest.php | 13 ++++++++++++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/Cutscenes/Preview/PreviewGameScene.php b/src/Cutscenes/Preview/PreviewGameScene.php index b233a65..0608359 100644 --- a/src/Cutscenes/Preview/PreviewGameScene.php +++ b/src/Cutscenes/Preview/PreviewGameScene.php @@ -82,7 +82,7 @@ public function previewCamera(): PreviewCamera return $camera; } - public function transferPlayer(Location $location, bool $useConfiguredTransition = true): void + public function transferPlayer(Location $location, bool $useConfiguredTransition = true): bool { $this->transfers[] = [ 'map' => $location->mapFilename, @@ -118,6 +118,7 @@ public function transferPlayer(Location $location, bool $useConfiguredTransition $this->eventInterpreter?->resumeAfterTransfer(); $this->autoSave(); + return true; } public function onEventSessionStarted(EventExecutionSession $session): void diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 99d31b8..e85f419 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -207,13 +207,10 @@ private function assertEditable(): void private static function readGridSource(string $path, string $mapId): string { try { - return MapGridSource::readFile($path); + return MapGridSource::readFile($path, $mapId . '/' . basename($path)); } catch (InvalidArgumentException $error) { throw new MapSourceRefusal(sprintf( - '%s: %s (%s) cannot be loaded or saved: %s Repair it as a literal nowdoc, then retry; nothing was changed.', - $mapId, - basename($path), - $path, + '%s The map cannot be loaded or saved. Repair it as a literal nowdoc, then retry; nothing was changed.', $error->getMessage(), ), previous: $error); } diff --git a/tests/Unit/MapIsolationTest.php b/tests/Unit/MapIsolationTest.php index 1da559d..f479f1f 100644 --- a/tests/Unit/MapIsolationTest.php +++ b/tests/Unit/MapIsolationTest.php @@ -3,9 +3,11 @@ declare(strict_types=1); use Ichiloto\Editor\MapSourceRefusal; +use Ichiloto\Editor\Editor; use Ichiloto\Editor\Playtest\PlaytestOverlay; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Editor\Status\StatusLevel; use Ichiloto\Editor\Validation\ProjectValidator; use Ichiloto\Engine\Field\MapGridSource; @@ -30,11 +32,20 @@ $badIssues = array_values(array_filter($issues, static fn ($issue): bool => str_contains($issue->message, 'Map source is read-only'))); expect(is_file($executed))->toBeFalse() - ->and($badA->getGridSourceIssue())->toContain('bad-a.map.php') + ->and($badA->getGridSourceIssue())->toContain('bad-a/bad-a.map.php: found T_STRING at line 2') + ->and($badA->getGridSourceIssue())->not->toContain($root) ->and($workspace->getCanvasLines(array_search('bad-a', $workspace->mapIds, true), 40, 10))->toContain('Read-only: repair this map before editing.') ->and($good->getGridSourceIssue())->toBeNull() ->and($badIssues)->toHaveCount(2); + /** @var Editor $editor */ + $editor = createEditorForTesting($root); + setEditorProperty($editor, 'workspace', $workspace); + callEditorMethod($editor, 'reportRefusedMapsAtBoot'); + expect(getEditorProperty($editor, 'statusMessage'))->toBe('2 map(s) are read-only (Ctrl+E for details).') + ->and(getEditorProperty($editor, 'statusLevel'))->toBe(StatusLevel::WARN) + ->and(getEditorProperty($editor, 'statusDetailLines'))->toHaveCount(2); + expect(fn () => $badA->setMapField('name', 'Changed'))->toThrow(MapSourceRefusal::class, 'read-only'); expect(fn () => $badA->save())->toThrow(MapSourceRefusal::class, 'read-only'); expect(fn () => $workspace->deleteMap(array_search('bad-a', $workspace->mapIds, true)))->toThrow(MapSourceRefusal::class, 'read-only'); From dcd2307785ea4584bf9d56b524070036e46a3798 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 20:26:12 +0200 Subject: [PATCH 015/344] fix(source): preserve trailing comments when appending entries --- src/Database/PhpSourceDocument.php | 13 +++++++++++-- tests/Unit/SkillSourcePreservationTest.php | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/Database/PhpSourceDocument.php b/src/Database/PhpSourceDocument.php index d1bd280..e1d901c 100644 --- a/src/Database/PhpSourceDocument.php +++ b/src/Database/PhpSourceDocument.php @@ -387,13 +387,22 @@ public function getWithEntryBlockSource(string $block, ?int $before = null): sel } $beforeClose = substr($this->source, 0, $this->arrayClose); + if ($this->entries !== []) { + foreach (array_reverse(self::tokenize($beforeClose)) as $token) { + if (self::isSkippable($token)) { continue; } + if ($token['text'] !== ',') { + $offset = $token['offset'] + strlen($token['text']); + $beforeClose = substr($beforeClose, 0, $offset) . ',' . substr($beforeClose, $offset); + } + break; + } + } $after = substr($this->source, $this->arrayClose); $trimmed = rtrim($beforeClose); - $needsComma = ! str_ends_with($trimmed, ',') && ! str_ends_with($trimmed, '['); $closingIndent = substr($beforeClose, strrpos($beforeClose, "\n") === false ? 0 : strrpos($beforeClose, "\n") + 1); return self::parse( - $trimmed . ($needsComma ? ',' : '') . "\n" . $block . (trim($closingIndent) === '' ? $closingIndent : '') . $after, + $trimmed . "\n" . $block . (trim($closingIndent) === '' ? $closingIndent : '') . $after, ); } diff --git a/tests/Unit/SkillSourcePreservationTest.php b/tests/Unit/SkillSourcePreservationTest.php index 0927abf..3a563ab 100644 --- a/tests/Unit/SkillSourcePreservationTest.php +++ b/tests/Unit/SkillSourcePreservationTest.php @@ -160,3 +160,19 @@ expect($document->source)->toBe(" PhpToken::tokenize($document->source, TOKEN_PARSE))->not->toThrow(ParseError::class); }); + +it('adds skills after a trailing comment without hiding the separator in that comment', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/skills.php'; + file_put_contents($path, <<<'PHP' +addSkill('New'); + $database->save(); + expect(require $path)->toHaveCount(2) + ->and(file_get_contents($path))->toContain("0), // Keep this note.\n"); +}); From 3b252e385e8203f1b5d3cc68fc4f7bf26243dbeb Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 20:28:52 +0200 Subject: [PATCH 016/344] fix(preview): disable voice in silent cinematic sessions Mute voice alongside music and sound effects before the Editor starts an automated cinematic preview. --- src/Cutscenes/Preview/CinematicPreviewSession.php | 2 +- tests/Unit/Cutscenes/CinematicPreviewSessionTest.php | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Cutscenes/Preview/CinematicPreviewSession.php b/src/Cutscenes/Preview/CinematicPreviewSession.php index 025e3bb..1c6bc31 100644 --- a/src/Cutscenes/Preview/CinematicPreviewSession.php +++ b/src/Cutscenes/Preview/CinematicPreviewSession.php @@ -1001,7 +1001,7 @@ private function installConfiguration(): void 'ui' => ['hud' => ['location' => false]], // The engine records what a cinematic asked for either way; with // music off it never starts a player process for it. - 'audio' => ['music' => false, 'sfx' => false], + 'audio' => ['music' => false, 'sfx' => false, 'voice' => false], ])); ConfigStore::put(PlaySettings::class, new PreviewConfig([ 'screen' => ['width' => $width, 'height' => $height], diff --git a/tests/Unit/Cutscenes/CinematicPreviewSessionTest.php b/tests/Unit/Cutscenes/CinematicPreviewSessionTest.php index e27dcfb..26d78c5 100644 --- a/tests/Unit/Cutscenes/CinematicPreviewSessionTest.php +++ b/tests/Unit/Cutscenes/CinematicPreviewSessionTest.php @@ -5,6 +5,8 @@ use Ichiloto\Editor\Cutscenes\CutsceneHydration; use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewSession; use Ichiloto\Editor\ProjectDirectoryContext; +use Ichiloto\Engine\Util\Config\ConfigStore; +use Ichiloto\Engine\Util\Config\ProjectConfig; /** * The Engine plays a cinematic inside the editor: the interpreter, @@ -25,6 +27,9 @@ function harbourDefinition(string $root): \Ichiloto\Engine\Cutscenes\Cinematics\ $preview = CinematicPreviewSession::start($root, harbourDefinition($root), ['x' => 2, 'y' => 3, 'width' => 40, 'height' => 12]); try { + expect(ConfigStore::get(ProjectConfig::class)->get('audio.music'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('audio.sfx'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('audio.voice'))->toBeFalse(); expect($preview->status())->toBe(CinematicPreviewSession::STATUS_PAUSED) ->and($preview->lanes())->not->toBe([]) ->and($preview->lanes()[0]['key'])->toBe('commands.0'); From ea64d8e717650bcfc0a2160694470af0769f3f29 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 20:33:30 +0200 Subject: [PATCH 017/344] fix(actors): remove editable established ids and default skits to actor selection --- docs/roadmap.md | 5 +- src/Database/BattleEntryActorResolver.php | 1 + src/Database/RecordSchemaCatalog.php | 4 +- src/Database/ReferenceCatalog.php | 4 +- src/Editor.php | 39 ++++++++++- src/ProjectActor.php | 46 ++++++++++--- src/ProjectActorDatabase.php | 15 ++++- src/Validation/ProjectValidator.php | 19 +++--- .../assets/Data/Actors/Kaelion.php | 1 + tests/Unit/ActorNaturalStatsTest.php | 67 ++++++++++++++++--- tests/Unit/BattleEntryParityTest.php | 11 ++- tests/Unit/RecordCategoryEditorTest.php | 2 + tests/Unit/SkitAndEventEditorTest.php | 34 +++++++++- 13 files changed, 208 insertions(+), 40 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 02e24f7..71189a9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -998,8 +998,9 @@ line and leaves comments, fully-qualified class names, nested constructors and emoji exactly as authored. A file it cannot read by argument name is refused rather than rewritten, and the ordinary writer takes over. -**An actor is more than its class.** A durable definition id (with the row -saying what a save resolves without one), actor-natural adjustments that keep +**An actor is more than its class.** An explicit, immutable definition id +(assigned at creation; missing legacy ids are validation errors and must be +declared before renaming), actor-natural adjustments that keep their sign and drop zeroes, and named natural variants with a default — where the rows edit whichever set the runtime has in force. Beside them, what each stat actually comes to, resolved by `StatResolver` and diff --git a/src/Database/BattleEntryActorResolver.php b/src/Database/BattleEntryActorResolver.php index 64033b5..b79e9ef 100644 --- a/src/Database/BattleEntryActorResolver.php +++ b/src/Database/BattleEntryActorResolver.php @@ -49,6 +49,7 @@ public static function fromActors(array $actors): self $normalizedId = self::normalize($id); if ($normalizedId === '') { + $problems[] = sprintf('%s must declare an explicit non-empty actor id. Display names are not identities.', $actor->path); continue; } diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 2ddb1b0..207ee42 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -855,7 +855,7 @@ private static function skits(): RecordSchema 'id' => 'new-skit', 'title' => 'New Skit', 'beats' => [ - ['speaker' => 'Speaker', 'text' => 'Say something.'], + ['actor' => '', 'text' => 'Say something.'], ], ], subList: new RecordSubList( @@ -867,7 +867,7 @@ private static function skits(): RecordSchema new RecordField('speaker', 'Non-actor Speaker', removeWhenEmpty: true), new RecordField('text', 'Text'), ], - blank: ['speaker' => 'Speaker', 'text' => 'Say something.'], + blank: ['actor' => '', 'text' => 'Say something.'], exclusiveFields: [['actor', 'speaker']], ), ); diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index cbeb17f..7755655 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -117,7 +117,8 @@ public function valuesFor(string $category): array // definition id, which is the one thing a rename never changes. 'actor_ids' => array_map( static fn(ProjectActor $actor): string => $actor->getDefinitionId(), - $this->workspace->actorDatabase->getActors() + array_values(array_filter($this->workspace->actorDatabase->getActors(), + static fn(ProjectActor $actor): bool => $actor->hasDefinitionId())) ), 'classes' => array_map( static fn(ProjectClass $class): string => $class->getName(), @@ -350,6 +351,7 @@ public function labelsFor(string $category): array foreach ($this->workspace->actorDatabase->getActors() as $actor) { $id = $actor->getDefinitionId(); $name = $actor->getName(); + if ($id === '') { continue; } if ($name !== '' && $name !== $id) { $labels[$id] = sprintf('%s (%s)', $name, $id); diff --git a/src/Editor.php b/src/Editor.php index 70e050f..f9ad45f 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -9025,6 +9025,10 @@ private function createDatabaseRecord(): void $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; $this->setStatus(sprintf('Created a new %s.', $database->schema->entryNoun), StatusLevel::SUCCESS); $this->renderDatabaseArea(); + if ($database->schema->key === 'skits') { + $this->selectNewSkitActor(0); + return; + } $this->beginDatabaseEdit(); } @@ -9410,6 +9414,21 @@ private function addDatabaseRecordSubItem(): void $this->setStatus(sprintf('Added %s %d.', $subList->singular, $entryIndex + 1), StatusLevel::SUCCESS); $this->renderDatabaseArea(); + if ($database->schema->key === 'skits') { + $this->selectNewSkitActor($entryIndex); + } + } + + /** New beats start with a resource choice, never an invented speaker. */ + private function selectNewSkitActor(int $entryIndex): void + { + foreach ($this->getDatabaseSettingsFields() as $index => $field) { + if (($field['field'] ?? '') !== sprintf('beat%dActor', $entryIndex)) { continue; } + $this->databaseSelectedSettingIndex = $index; + $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; + $this->openReferencePicker($field); + return; + } } /** @@ -10192,10 +10211,9 @@ private function actorIdentityFields(ProjectActor $actor): array 'label' => 'Definition Id', 'value' => $actor->hasDefinitionId() ? $actor->getDefinitionId() : '', 'control' => new InputControl(InputControlType::TEXT, $actor->hasDefinitionId() ? $actor->getDefinitionId() : ''), + 'editable' => ! $actor->hasDefinitionId(), 'field' => 'id', - // A project that declares no id is resolved by name, which is - // what strands a save when the actor is renamed. - 'displayDefault' => sprintf('%s (the name; declare an id so a rename keeps saves)', $actor->getName()), + 'displayDefault' => sprintf('Required: use the original identity (%s for an unmigrated legacy actor)', $actor->getName()), ], ]; } @@ -10517,6 +10535,7 @@ private function getDatabaseActorSettingsFields(): array 'label' => 'Name', 'value' => $actor->getName(), 'control' => new InputControl(InputControlType::TEXT, $actor->getName()), + 'editable' => $actor->hasDefinitionId(), 'field' => 'name', ], [ @@ -12272,8 +12291,22 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) $record = $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()); $before = $record?->toArray(); + $actor = $this->isActorsDatabaseSelected() ? $this->getSelectedActor() : null; + $actorBefore = $actor?->getData(); $this->applyDatabaseFieldValue($fieldId, $rawValue); + if ($actor !== null && $actorBefore !== null) { + $actorAfter = $actor->getData(); + if ($actorBefore !== $actorAfter) { + $this->recordCommand(new GenericCommand( + sprintf('%s edit', $field['label'] ?? 'Actor field'), + static fn() => $actor->restoreData($actorAfter), + static fn() => $actor->restoreData($actorBefore), + )); + } + return; + } + if ($record !== null && $before !== null) { $after = $record->toArray(); if ($before !== $after) { diff --git a/src/ProjectActor.php b/src/ProjectActor.php index 06413c8..13d2c3d 100644 --- a/src/ProjectActor.php +++ b/src/ProjectActor.php @@ -16,6 +16,9 @@ final class ProjectActor { use TracksPersistedState; + private ?string $establishedDefinitionId; + private readonly bool $loadedWithoutDefinitionId; + /** * The sentinel option meaning "no class reference" in the editor picker. */ @@ -30,6 +33,9 @@ public function __construct( private array $payload, bool $isDirty = false, ) { + $id = $this->getDefinitionId(); + $this->establishedDefinitionId = $id === '' ? null : $id; + $this->loadedWithoutDefinitionId = $id === ''; if (! $isDirty) { // Loaded from disk: the current content is the saved content. $this->captureBaseline(); @@ -73,6 +79,7 @@ public static function createBlank(string $path, string $id, string $name): self payload: [ 'class' => 'Ichiloto\\Engine\\Entities\\Character', 'data' => [ + 'id' => $id, 'name' => $name, 'description' => '', 'level' => 1, @@ -251,16 +258,12 @@ private static function withAdjustment(array $data, array $segments, int $amount /** * Returns the durable definition id a save resolves this actor by. * - * The engine falls back to the display name when a project has not - * declared one, which is why renaming an actor used to strand a save. - * - * @return string The id, or the name when none is declared. + * @return string The explicit id, or an empty string for an invalid legacy asset. */ public function getDefinitionId(): string { - $id = trim(strval($this->getData()['id'] ?? '')); - - return $id === '' ? $this->getName() : $id; + $id = $this->getData()['id'] ?? null; + return is_string($id) ? trim($id) : ''; } /** @@ -270,7 +273,7 @@ public function getDefinitionId(): string */ public function hasDefinitionId(): bool { - return trim(strval($this->getData()['id'] ?? '')) !== ''; + return $this->getDefinitionId() !== ''; } /** @@ -496,6 +499,17 @@ public function getMagic(): array */ public function setField(string $field, mixed $value): void { + if ($field === 'id') { + $identity = trim((string) $value); + if ($identity === $this->getDefinitionId()) { return; } + if (($this->establishedDefinitionId !== null && $identity !== $this->establishedDefinitionId) || $identity === '') { + throw new RuntimeException('An actor id is permanent. Change the display name, not its identity.'); + } + $this->establishedDefinitionId = $identity; + } + if ($field === 'name' && ! $this->hasDefinitionId()) { + throw new RuntimeException('Declare the existing actor identity before renaming this actor so references and saves remain valid.'); + } if (! isset($this->payload['data']) || ! is_array($this->payload['data'])) { $this->payload['data'] = []; } @@ -616,6 +630,20 @@ public function save(): void $this->captureBaseline(); } + /** Restores an authoring snapshot for undo/redo without allowing identity retargeting. + * @param array $data + */ + public function restoreData(array $data): void + { + $id = $data['id'] ?? null; + $isLegacyUndo = $this->loadedWithoutDefinitionId && $id === null; + if (! $isLegacyUndo && $id !== $this->establishedDefinitionId) { + throw new RuntimeException('An actor id is permanent. Undo cannot retarget its identity.'); + } + $this->payload['data'] = $data; + $this->touchState(); + } + /** * @inheritDoc */ @@ -632,7 +660,7 @@ protected function buildPersistedPayload(): string * * @return array */ - private function getData(): array + public function getData(): array { $data = $this->payload['data'] ?? []; diff --git a/src/ProjectActorDatabase.php b/src/ProjectActorDatabase.php index f2bff9c..9fd6767 100644 --- a/src/ProjectActorDatabase.php +++ b/src/ProjectActorDatabase.php @@ -4,6 +4,8 @@ namespace Ichiloto\Editor; +use Ichiloto\Engine\Entities\Actors\ActorDefinition; +use Ichiloto\Engine\Util\Stores\ActorStore; use Throwable; use RuntimeException; @@ -72,6 +74,15 @@ public function getActors(): array return array_values($this->actors); } + /** Builds the runtime registry from the same current records used by pickers. */ + public function createActorStore(): ActorStore + { + return new ActorStore(definitions: array_map( + static fn(ProjectActor $actor): ActorDefinition => ActorDefinition::fromArray($actor->getData(), $actor->path), + $this->getActors(), + )); + } + /** * Returns whether the database has unsaved changes. * @@ -256,8 +267,10 @@ private function getNextAvailableId(string $baseName): string $candidate = $baseId; $suffix = 2; $existingIds = array_map(static fn(ProjectActor $actor): string => $actor->id, $this->actors); + $existingIds = array_map('strtolower', [...$existingIds, + ...array_map(static fn(ProjectActor $actor): string => $actor->getDefinitionId(), $this->actors)]); - while (in_array($candidate, $existingIds, true)) { + while (in_array(strtolower($candidate), $existingIds, true)) { $candidate = $baseId . $suffix; $suffix++; } diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 718bc35..1a3ab7a 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -23,7 +23,6 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Core\WorldConditionType; use Ichiloto\Engine\Field\SkitSpeaker; -use Ichiloto\Engine\Util\Stores\ActorStore; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; use Ichiloto\Engine\Events\Interpreter\MovementRouteRunner; use Ichiloto\Engine\IO\Console\TerminalText; @@ -277,11 +276,10 @@ protected function checkActorDefinitions(ProjectWorkspace $workspace): array $seen[$id] = true; - // An actor without its own id is resolved by display name, which the - // runtime supports deliberately. That is a convention worth adopting - // rather than a defect, so the Inspector says so on the row where it - // can be acted on; a validator reporting it on every legacy actor - // would be noise in front of the findings that are defects. + if (! $actor->hasDefinitionId()) { + $issues[] = Issue::error($where, 'Actor has no explicit stable id.', + 'Declare its existing identity before renaming it. For legacy actors, use the original display name to preserve references and saves.'); + } $variants = $actor->getNaturalVariants(); $default = $actor->getDefaultNaturalVariantId(); @@ -3021,7 +3019,7 @@ protected function checkSkitReferences(ProjectWorkspace $workspace, array $known $issues = []; try { - $actors = new ActorStore($workspace->projectRoot . '/assets/Data/Actors'); + $actors = $workspace->actorDatabase->createActorStore(); } catch (InvalidArgumentException|\RuntimeException $exception) { $actors = null; $issues[] = Issue::error('skit actors', $exception->getMessage(), 'Repair the actor registry before validating skit actor references.'); @@ -3053,7 +3051,12 @@ protected function checkSkitReferences(ProjectWorkspace $workspace, array $known $issues[] = Issue::warning($beatLocation, $notice, 'Choose the Actor picker to persist the stable actor id.'); } foreach ($speaker->errors as $error) { - $issues[] = Issue::error($beatLocation, $error, 'Choose an Actor, or enter a non-actor speaker, not both.'); + $hint = array_key_exists('actor', $beat) && array_key_exists('speaker', $beat) + ? 'Choose an Actor, or enter a non-actor speaker, not both.' + : (array_key_exists('actor', $beat) + ? 'Choose an existing actor from the Actor picker; the reference must use its stable id, not its display name.' + : 'Enter plain display text for a non-actor speaker.'); + $issues[] = Issue::error($beatLocation, $error, $hint); } } } diff --git a/tests/Fixtures/sample-project/assets/Data/Actors/Kaelion.php b/tests/Fixtures/sample-project/assets/Data/Actors/Kaelion.php index 14ec7bf..c22294f 100644 --- a/tests/Fixtures/sample-project/assets/Data/Actors/Kaelion.php +++ b/tests/Fixtures/sample-project/assets/Data/Actors/Kaelion.php @@ -5,6 +5,7 @@ return [ 'class' => Character::class, 'data' => [ + 'id' => 'Kaelion', 'name' => 'Kaelion', 'description' => 'A brave and steadfast swordsman.', 'level' => 1, diff --git a/tests/Unit/ActorNaturalStatsTest.php b/tests/Unit/ActorNaturalStatsTest.php index 8f6cdb1..0ff9b0b 100644 --- a/tests/Unit/ActorNaturalStatsTest.php +++ b/tests/Unit/ActorNaturalStatsTest.php @@ -51,25 +51,70 @@ function actorRow(Editor $editor, string $fieldId): array throw new RuntimeException(sprintf('No actor row "%s".', $fieldId)); } -it('authors a durable definition id, and says what a save resolves without one', function () { +it('keeps explicit actor identity read-only while renaming display text', function () { [$root, $database, $actor] = actorUnderTest(); - // A project that declares no id is resolved by name, which is exactly - // what strands a save when the actor is renamed. - expect($actor->hasDefinitionId())->toBeFalse() + expect($actor->hasDefinitionId())->toBeTrue() ->and($actor->getDefinitionId())->toBe('Kaelion'); $editor = actorEditorOn($root); - expect(actorRow($editor, 'id')['displayDefault'])->toContain('Kaelion (the name;'); - - $database->setField(0, 'id', 'actor.kaelion'); - - expect($database->getActors()[0]->hasDefinitionId())->toBeTrue() - ->and($database->getActors()[0]->getDefinitionId())->toBe('actor.kaelion'); + expect(actorRow($editor, 'id')['editable'])->toBeFalse(); + expect(fn() => $database->setField(0, 'id', 'actor.kaelion'))->toThrow(RuntimeException::class, 'permanent'); + expect(fn() => $database->setField(0, 'id', ''))->toThrow(RuntimeException::class, 'permanent'); // Renaming now leaves the identity where it was. $database->setField(0, 'name', 'Kaelion the Elder'); - expect($database->getActors()[0]->getDefinitionId())->toBe('actor.kaelion'); + expect($database->getActors()[0]->getDefinitionId())->toBe('Kaelion'); + $database->save(); + $reloaded = ProjectActorDatabase::fromProject($root)->getActors()[0]; + expect($reloaded->getName())->toBe('Kaelion the Elder')->and($reloaded->getDefinitionId())->toBe('Kaelion'); +}); + +it('assigns an explicit immutable id to a new actor before its first save', function () { + [$root, $database] = actorUnderTest(); + $index = $database->addActor('Liora'); + $actor = $database->getActorByIndex($index); + expect($actor->hasDefinitionId())->toBeTrue()->and($actor->getDefinitionId())->toBe('Liora'); + $database->setField($index, 'name', 'Liora Vey'); + expect($actor->getDefinitionId())->toBe('Liora'); + $database->save(); + expect((require $actor->path)['data'])->toMatchArray(['id' => 'Liora', 'name' => 'Liora Vey']); +}); + +it('flags missing explicit ids and requires repairing identity before a legacy rename', function () { + [$root] = actorUnderTest(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + $payload = require $path; + unset($payload['data']['id']); + file_put_contents($path, 'actorDatabase->getActors()[0]; + $validator = new \Ichiloto\Editor\Validation\ProjectValidator(); + $issues = new ReflectionMethod($validator, 'checkActorDefinitions')->invoke($validator, $workspace); + expect(array_column($issues, 'message'))->toContain('Actor has no explicit stable id.'); + expect(fn() => $actor->setField('name', 'Renamed'))->toThrow(RuntimeException::class, 'before renaming'); + $actor->setField('id', 'Kaelion'); + $actor->setField('name', 'Renamed'); + expect($actor->getDefinitionId())->toBe('Kaelion'); +}); + +it('undoes and redoes a legacy identity repair and display rename without retargeting the actor', function () { + [$root] = actorUnderTest(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + $payload = require $path; + unset($payload['data']['id']); + file_put_contents($path, 'actorDatabase->getActors()[0]; + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', actorRow($editor, 'id'), 'Kaelion'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', actorRow($editor, 'name'), 'Kaelion the Elder'); + callEditorMethod($editor, 'performUndo'); + expect($actor->getName())->toBe('Kaelion')->and($actor->getDefinitionId())->toBe('Kaelion'); + callEditorMethod($editor, 'performUndo'); + expect($actor->hasDefinitionId())->toBeFalse()->and($actor->isDirty())->toBeFalse(); + callEditorMethod($editor, 'performRedo'); + callEditorMethod($editor, 'performRedo'); + expect($actor->getName())->toBe('Kaelion the Elder')->and($actor->getDefinitionId())->toBe('Kaelion'); }); it('authors actor-natural adjustments, keeping their sign and dropping zeroes', function () { diff --git a/tests/Unit/BattleEntryParityTest.php b/tests/Unit/BattleEntryParityTest.php index 1eca454..8e07e52 100644 --- a/tests/Unit/BattleEntryParityTest.php +++ b/tests/Unit/BattleEntryParityTest.php @@ -196,7 +196,7 @@ function parityRuleCorpus(): array // durable identity; an unknown reference is refused with the same // wording; a healthy store accepts all three reference kinds. file_put_contents($ruleFile, " [['id' => 'one', 'actors' => [['actor' => 'actor.rook', 'presence' => 'any'], ['actor' => 'Rook', 'presence' => 'active'], ['actor' => 'RookFile', 'presence' => 'reserve']], 'effects' => [['type' => 'stat_stage', 'actor' => 'ROOK', 'stat' => 'speed', 'delta' => 1]]]]];"); - $healthy = parityActorsDirectory(['RookFile' => ['Rook', 'actor.rook'], 'Vale' => ['Vale', null]]); + $healthy = parityActorsDirectory(['RookFile' => ['Rook', 'actor.rook'], 'Vale' => ['Vale', 'Vale']]); $engine = hydrateThroughAcceptedEngine($engineRoot, $ruleFile, $healthy); $resolver = parityResolver($healthy); @@ -216,10 +216,17 @@ function parityRuleCorpus(): array // A contested reference refuses the store itself, with the same // wording the resolver reports. - $contested = parityActorsDirectory(['Kael' => ['Kael', null], 'Impostor' => ['Kael', 'actor.impostor']]); + $contested = parityActorsDirectory(['Kael' => ['Kael', 'Kael'], 'Impostor' => ['Kael', 'actor.impostor']]); $engine = hydrateThroughAcceptedEngine($engineRoot, $ruleFile, $contested); $resolver = parityResolver($contested); + expect($engine['phase'] ?? null)->toBe('store') + ->and($resolver->problems())->toContain(strval($engine['error'] ?? '')); + + // Missing ids are no longer inferred from display names in either surface. + $missing = parityActorsDirectory(['Missing' => ['Display Name', null]]); + $engine = hydrateThroughAcceptedEngine($engineRoot, $ruleFile, $missing); + $resolver = parityResolver($missing); expect($engine['phase'] ?? null)->toBe('store') ->and($resolver->problems())->toContain(strval($engine['error'] ?? '')); diff --git a/tests/Unit/RecordCategoryEditorTest.php b/tests/Unit/RecordCategoryEditorTest.php index fc4d76b..963333c 100644 --- a/tests/Unit/RecordCategoryEditorTest.php +++ b/tests/Unit/RecordCategoryEditorTest.php @@ -103,6 +103,8 @@ callEditorMethod($editor, 'dispatchInput', 'O'); expect($database->countSubItems(0))->toBe(3); + expect(getEditorProperty($editor, 'referencePicker')->isOpen())->toBeTrue(); + callEditorMethod($editor, 'dispatchInput', "\x1b"); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($database->countSubItems(0))->toBe(2); diff --git a/tests/Unit/SkitAndEventEditorTest.php b/tests/Unit/SkitAndEventEditorTest.php index b1773f9..1a134ea 100644 --- a/tests/Unit/SkitAndEventEditorTest.php +++ b/tests/Unit/SkitAndEventEditorTest.php @@ -70,7 +70,7 @@ $database->save(); $payload = require $root . '/assets/Data/Skits/breakfast-banter.php'; - expect($payload['beats'][2])->toBe(['speaker' => 'Drazek', 'text' => 'I have seen her with a wooden spoon.']); + expect($payload['beats'][2])->toEqual(['speaker' => 'Drazek', 'text' => 'I have seen her with a wooden spoon.']); $removed = $database->removeSubItem(0, 2); expect($removed['speaker'])->toBe('Drazek'); @@ -126,6 +126,38 @@ ->and($speakers[0]->message)->toContain('Deprecated') ->and($speakers[1]->message)->toContain('stable actor id') ->and($speakers[2]->message)->toContain('not both'); + expect($speakers[1]->hint)->toContain('existing actor')->not->toContain('not both'); +}); + +it('uses unsaved and renamed actors consistently in the skit picker and validator', function (): void { + $root = makeTemporaryProject(); + $workspace = \Ichiloto\Editor\ProjectWorkspace::fromProject($root); + $index = $workspace->actorDatabase->addActor('Liora'); + $workspace->actorDatabase->setField($index, 'name', 'Liora Vey'); + $catalog = new \Ichiloto\Editor\Database\ReferenceCatalog($workspace); + expect($catalog->valuesFor('actor_ids'))->toContain('Liora') + ->and($catalog->labelsFor('actor_ids')['Liora'])->toBe('Liora Vey (Liora)') + ->and(is_file($root . '/assets/Data/Actors/Liora.php'))->toBeFalse(); + $workspace->getRecordDatabase('skits')->getRecords()[0]->setSubList('beats', [['actor' => 'Liora', 'text' => 'Hello.']]); + $validator = new \Ichiloto\Editor\Validation\ProjectValidator(); + $issues = new ReflectionMethod($validator, 'checkSkitReferences')->invoke($validator, $workspace, + ['maps' => ['happyville/town-center'], 'quests' => ['breakfast-duty']]); + expect($issues)->toBeEmpty(); +}); + +it('opens the Actor picker when adding a beat or creating a skit instead of inventing a speaker', function (): void { + $root = makeTemporaryProject(); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'skits'); + callEditorMethod($editor, 'addDatabaseRecordSubItem'); + $picker = getEditorProperty($editor, 'referencePicker'); + expect($picker->isOpen())->toBeTrue(); + $database = getEditorProperty($editor, 'workspace')->getRecordDatabase('skits'); + expect($database->getRecords()[0]->toArray()['beats'][2])->toBe(['actor' => '', 'text' => 'Say something.']); + $picker->close(); + callEditorMethod($editor, 'createDatabaseRecord'); + expect($picker->isOpen())->toBeTrue() + ->and($database->getRecords()[1]->toArray()['beats'][0])->toBe(['actor' => '', 'text' => 'Say something.']); }); it('creates a new skit as its own file', function (): void { From a771bc325ae9b6b2c1e33e06bce0be906a992f46 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 20:34:04 +0200 Subject: [PATCH 018/344] fix(source): remove commented optional arguments without stray separators --- src/Database/PhpSourceDocument.php | 15 +++++++++++++++ tests/Unit/SkillSourcePreservationTest.php | 12 ++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/Database/PhpSourceDocument.php b/src/Database/PhpSourceDocument.php index e1d901c..f912611 100644 --- a/src/Database/PhpSourceDocument.php +++ b/src/Database/PhpSourceDocument.php @@ -272,6 +272,21 @@ public function withoutArgument(int $entryIndex, string $path): self $end = $span['end']; $source = $this->source; + // A comment may separate the value from its comma. Keep the comment, + // but remove the separator as well as the argument it belonged to. + $hasComment = false; + foreach (self::tokenize($source) as $token) { + if ($token['offset'] < $end) { continue; } + if (in_array($token['id'], [T_COMMENT, T_DOC_COMMENT], true)) { $hasComment = true; } + if (self::isSkippable($token)) { continue; } + if ($hasComment && $token['text'] === ',') { + return self::parse(substr($source, 0, $start) + . substr($source, $end, $token['offset'] - $end) + . substr($source, $token['offset'] + 1)); + } + break; + } + // Take the separator and the blank line the argument occupied with // it, so removing one never leaves a stray comma or an empty line. while ($end < strlen($source) && ($source[$end] === ' ' || $source[$end] === "\t")) { diff --git a/tests/Unit/SkillSourcePreservationTest.php b/tests/Unit/SkillSourcePreservationTest.php index 3a563ab..4d11de3 100644 --- a/tests/Unit/SkillSourcePreservationTest.php +++ b/tests/Unit/SkillSourcePreservationTest.php @@ -176,3 +176,15 @@ expect(require $path)->toHaveCount(2) ->and(file_get_contents($path))->toContain("0), // Keep this note.\n"); }); + +it('clears a commented animation argument without leaving an orphan separator', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/skills.php'; + file_put_contents($path, "setField(0, 'animationId', null); + $database->save(); + expect((require $path)[0]->animationId)->toBeNull() + ->and(file_get_contents($path))->not->toContain('animationId:') + ->and(file_get_contents($path))->toContain('/* authored note */'); +}); From 21174719f22de483548cbf8feaf74eb27054a7fb Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 22:20:14 +0200 Subject: [PATCH 019/344] fix(canvas): colour every cell of a styled run and recolour selections A styled run such as #### kept its opening tag on the first cell and its closing tag on the last, so the preview, the eyedropper and colour checks saw the colour on the first cell only, and edits inside a run could leave tags unbalanced. Every cell now carries its full style; an edited row is written back as balanced runs, and a row whose cells match what was loaded keeps its original bytes. Choosing a colour with a selection active now recolours every cell in the selection as one undoable stroke; without a selection the brush still recolours the cursor cell. --- docs/manual.md | 7 ++- src/Editor.php | 43 +++++++++++--- src/ProjectMap.php | 97 ++++++++++++++++++++++--------- tests/Unit/CanvasToolsTest.php | 30 ++++++++++ tests/Unit/ProjectMapTest.php | 103 +++++++++++++++++++++++++++++++++ 5 files changed, 244 insertions(+), 36 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 12498c7..974a5d4 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -252,9 +252,10 @@ styling authored by hand. - **Keep cell colour** (the default): painting changes the glyph and leaves each cell's existing styling byte-for-byte, authored options included. - **No colour**: painting strips styling and writes plain glyphs. -- **A colour**: painting writes the glyph in that colour. Selecting a - colour under the brush tool also recolours the cell at the cursor in - place, keeping its glyph, as one undoable stroke. +- **A colour**: painting writes the glyph in that colour. Choosing a + colour recolours in place, keeping the glyphs, as one undoable stroke: + with a selection active (`s`), every cell in the selection; otherwise, + under the brush tool, the cell at the cursor. Spaces stay uncoloured. The eyedropper (`k` / `Ctrl+K`) picks up a cell's colour along with its glyph; an uncoloured cell loads an uncoloured brush. A painted space is diff --git a/src/Editor.php b/src/Editor.php index f9ad45f..5a6a04f 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -4076,6 +4076,25 @@ private function handleColorPickerInput(string $input, string $normalizedInput): } } + /** + * Lists the cells a canvas selection covers, row by row. + * + * @param array{x: int, y: int, width: int, height: int} $selection The selection. + * @return array + */ + private function getSelectionCells(array $selection): array + { + $cells = []; + + for ($y = $selection['y']; $y < $selection['y'] + $selection['height']; $y++) { + for ($x = $selection['x']; $x < $selection['x'] + $selection['width']; $x++) { + $cells[] = ['x' => $x, 'y' => $y]; + } + } + + return $cells; + } + /** * Applies the selected palette colour to the brush, and under the brush * tool recolours the cell under the cursor in place. @@ -4099,17 +4118,27 @@ private function applyColorPaletteSelection(): void if ( $entry['value'] !== null - && $this->canvasTool === CanvasTool::BRUSH && $selectedMap instanceof ProjectMap && $this->editingMode !== self::MODE_EVENT ) { - // Recolour in place: the cell keeps its glyph and takes the - // brush colour, as one undoable stroke. - $symbol = $selectedMap->getTileSymbol($this->cursorX, $this->cursorY); - $this->paintCanvasCells( + // Recolour in place: cells keep their glyphs and take the brush + // colour, as one undoable stroke. A selection recolours every + // cell in it; otherwise the brush recolours the cursor cell. + $cells = $this->canvasSelection !== null + ? $this->getSelectionCells($this->canvasSelection) + : ($this->canvasTool === CanvasTool::BRUSH ? [['x' => $this->cursorX, 'y' => $this->cursorY]] : []); + + $this->applyCanvasWrites( $selectedMap, - [['x' => $this->cursorX, 'y' => $this->cursorY]], - $symbol, + array_map( + fn(array $cell): array => [ + 'x' => $cell['x'], + 'y' => $cell['y'], + 'symbol' => $selectedMap->getTileSymbol($cell['x'], $cell['y']), + 'color' => $this->selectedPaintColor, + ], + $cells, + ), 'Recolour', ); } diff --git a/src/ProjectMap.php b/src/ProjectMap.php index e85f419..d5a9b5f 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -31,6 +31,13 @@ final class ProjectMap * @var array> */ private array $tileCells; + /** + * The tile cells as loaded, so a row that ends up unchanged is written + * back as its original bytes rather than rebuilt. + * + * @var array> + */ + private array $loadedTileCells; /** * @var array> */ @@ -101,6 +108,7 @@ public function __construct( ) { $this->editableData = $data; $this->tileCells = self::parseStyledLines($tileLines); + $this->loadedTileCells = $this->tileCells; $this->eventCells = self::parsePlainLines($eventLines); $this->loadedData = $data; $this->adoptDataSource($dataSource ?? "'; + private const array ANSI_FOREGROUNDS = [ 'black' => 30, 'red' => 31, 'green' => 32, 'yellow' => 33, 'blue' => 34, 'magenta' => 35, 'cyan' => 36, 'white' => 37, @@ -430,12 +440,12 @@ public function renderPreview( */ private static function ansiOpenForPrefix(string $prefix): ?string { - if ($prefix === '' || preg_match('/]+)[^>]*>/', $prefix, $matches) !== 1) { + $value = self::getInnermostForeground($prefix); + + if ($value === null) { return null; } - $value = $matches[1]; - if (isset(self::ANSI_FOREGROUNDS[$value])) { return sprintf("\033[%dm", self::ANSI_FOREGROUNDS[$value]); } @@ -572,13 +582,23 @@ public function getTileCellStyle(int $x, int $y): array */ public function getTileColor(int $x, int $y): ?string { - $style = $this->getTileCellStyle($x, $y); + return self::getInnermostForeground($this->getTileCellStyle($x, $y)['prefix']); + } - if (preg_match('/]+)[^>]*>/', $style['prefix'], $matches) === 1) { - return $matches[1]; + /** + * Returns the `fg=` value a styling prefix renders in. Nested tags + * override outer ones, so the last declared foreground wins. + * + * @param string $prefix The cell's styling prefix bytes. + * @return string|null The `fg=` value, or null when none is declared. + */ + private static function getInnermostForeground(string $prefix): ?string + { + if (preg_match_all('/<[^>]*\bfg=([^;>]+)[^>]*>/', $prefix, $matches) < 1) { + return null; } - return null; + return $matches[1][array_key_last($matches[1])]; } /** @@ -1672,29 +1692,26 @@ private static function parseStyledLines(array $lines): array preg_match_all('/<[^>]+>|[^<]+/u', $line, $matches); $segments = $matches[0] ?? []; $cells = []; - $activePrefix = ''; + $openTags = []; foreach ($segments as $segment) { if (preg_match('/^<[^\/][^>]*>$/u', $segment) === 1) { - $activePrefix .= $segment; + $openTags[] = $segment; continue; } - if (preg_match('/^<\/[^>]*>$/u', $segment) === 1 || $segment === '') { - if ($cells !== []) { - $cells[array_key_last($cells)]['suffix'] .= $segment; - } - - $activePrefix = ''; + if (preg_match('/^<\/[^>]*>$/u', $segment) === 1) { + array_pop($openTags); continue; } - foreach (self::toSymbols($segment) as $index => $symbol) { - $cells[] = [ - 'symbol' => $symbol, - 'prefix' => $index === 0 ? $activePrefix : '', - 'suffix' => '', - ]; + // Each cell carries the complete style it renders in, so a + // cell inside a run is as coloured as the run's first cell. + $prefix = implode('', $openTags); + $suffix = str_repeat(self::STYLE_CLOSE_TAG, count($openTags)); + + foreach (self::toSymbols($segment) as $symbol) { + $cells[] = ['symbol' => $symbol, 'prefix' => $prefix, 'suffix' => $suffix]; } } @@ -1760,15 +1777,43 @@ private static function toSymbols(string $line): array * @param array $cells The tile cells. * @return string */ - private function buildStyledLine(array $cells): string + private static function buildStyledLine(array $cells): string { $line = ''; + $open = null; foreach ($cells as $cell) { - $line .= $cell['prefix'] . $cell['symbol'] . $cell['suffix']; + $style = [$cell['prefix'], $cell['suffix']]; + + if ($style !== $open) { + $line .= ($open[1] ?? '') . $style[0]; + $open = $style; + } + + $line .= $cell['symbol']; + } + + return $line . ($open[1] ?? ''); + } + + /** + * Rebuilds every tile row. A row whose cells still match what was + * loaded keeps its original bytes; an edited row is written as runs of + * identically styled cells. + * + * @return string[] + */ + private function buildStyledLines(): array + { + $lines = []; + + foreach ($this->tileCells as $rowIndex => $cells) { + $lines[] = isset($this->tileLines[$rowIndex]) && ($this->loadedTileCells[$rowIndex] ?? null) === $cells + ? $this->tileLines[$rowIndex] + : self::buildStyledLine($cells); } - return $line; + return $lines; } /** @@ -1798,7 +1843,7 @@ private function buildPlainLine(array $cells): string private function buildMapPayload(): string { return MapGridSource::buildSource( - implode(PHP_EOL, array_map($this->buildStyledLine(...), $this->tileCells)), + implode(PHP_EOL, $this->buildStyledLines()), 'ICHILOTO_MAP', ); } @@ -1898,7 +1943,7 @@ public function moveTo(string $newRelativeId): self $transaction->remove($this->eventPath); $expectedGrids = [ - $movedMapPath => array_map($this->buildStyledLine(...), $this->tileCells), + $movedMapPath => $this->buildStyledLines(), $movedEventPath => array_map($this->buildPlainLine(...), $this->eventCells), ]; $sourceDirectoryMetadata = self::captureDirectoryMetadata($previousDirectory); diff --git a/tests/Unit/CanvasToolsTest.php b/tests/Unit/CanvasToolsTest.php index 5472b48..dbd8f61 100644 --- a/tests/Unit/CanvasToolsTest.php +++ b/tests/Unit/CanvasToolsTest.php @@ -745,3 +745,33 @@ function canvasCellAt(Editor $editor, int $mapX, int $mapY): array expect($lines[1])->toContain('cursor 7,3'); }); + +it('recolours every cell of an active selection from the colour picker', function () { + $editor = canvasEditor(); + /** @var ProjectWorkspace $workspace */ + $workspace = getEditorProperty($editor, 'workspace'); + $map = $workspace->getMapByIndex(0); + + foreach ([2, 3, 4, 5] as $x) { + $map->setTileSymbol($x, 2, '#'); + } + + setEditorProperty($editor, 'canvasSelection', ['x' => 2, 'y' => 2, 'width' => 4, 'height' => 1]); + callEditorMethod($editor, 'dispatchInput', 'o'); + // Down twice from 'Keep cell colour' lands on the first ANSI colour. + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\n"); + + foreach ([2, 3, 4, 5] as $x) { + expect($map->getTileSymbol($x, 2))->toBe('#') + ->and($map->getTileColor($x, 2))->toBe('black', "cell {$x} of the selection"); + } + + // One undo restores the whole recolour. + callEditorMethod($editor, 'dispatchInput', 'u'); + + foreach ([2, 3, 4, 5] as $x) { + expect($map->getTileColor($x, 2))->toBeNull(); + } +}); diff --git a/tests/Unit/ProjectMapTest.php b/tests/Unit/ProjectMapTest.php index bb20b13..69dcfce 100644 --- a/tests/Unit/ProjectMapTest.php +++ b/tests/Unit/ProjectMapTest.php @@ -262,3 +262,106 @@ function removeScratchTree(string $root): void removeScratchTree($root); } }); + +/** + * Replaces the scratch map's tile grid and reloads it. + * + * @param string[] $rows The grid rows, styling tags included. + * @return array{0: string, 1: ProjectMap} The map file path and the reloaded map. + */ +function styledScratchMap(string $root, array $rows): array +{ + $mapsRoot = $root . '/assets/Maps'; + $mapFile = $mapsRoot . '/test-map/test-map.map.php'; + file_put_contents($mapFile, "#### #', + '# #', + '# #', + '############', + ]); + + foreach ([1, 2, 3, 4] as $x) { + expect($map->getTileColor($x, 1))->toBe('gray', "cell {$x} of the run"); + } + + expect($map->getTileColor(5, 1))->toBeNull() + ->and(substr_count($map->renderPreview(12, 5)[1], "\033[90m#\033[0m"))->toBe(4); + } finally { + removeScratchTree($root); + } +}); + +it('rebuilds an edited row as balanced runs and keeps untouched rows byte-identical', function () { + [$root] = scratchMapCopy(); + + try { + $rows = [ + '############', + '##### #', + '# ~~ # #', + '# #', + '############', + ]; + [$mapFile, $map] = styledScratchMap($root, $rows); + + // Recolour a cell inside the run, and erase the run's last cell. + $map->setTileCell(2, 1, '#', '', ''); + $map->setTileCell(4, 1, ' ', '', ''); + $map->save(); + + $saved = explode("\n", (string) file_get_contents($mapFile)); + + expect($saved[4])->toBe('#### #') + ->and($saved[5])->toBe($rows[2]) + ->and($saved[3])->toBe($rows[0]); + + $reloaded = ProjectMap::fromDirectory($root . '/assets/Maps', $root . '/assets/Maps/test-map'); + + expect([$reloaded->getTileColor(1, 1), $reloaded->getTileColor(2, 1), $reloaded->getTileColor(3, 1)]) + ->toBe(['gray', 'red', 'gray']) + ->and($reloaded->getTileColor(4, 1))->toBeNull() + ->and($reloaded->getTileColor(5, 1))->toBeNull(); + } finally { + removeScratchTree($root); + } +}); + +it('writes a row restored to its loaded cells back as its original bytes', function () { + [$root] = scratchMapCopy(); + + try { + $rows = [ + '############', + '##### #', + '# #', + '# #', + '############', + ]; + [$mapFile, $map] = styledScratchMap($root, $rows); + $original = (string) file_get_contents($mapFile); + $style = $map->getTileCellStyle(2, 1); + + $map->setTileCell(2, 1, '#', '', ''); + $map->setTileCell(2, 1, '#', $style['prefix'], $style['suffix']); + $map->setTileCell(9, 3, 'x', '', ''); + $map->save(); + + $saved = explode("\n", (string) file_get_contents($mapFile)); + + expect($saved[4])->toBe($rows[1]) + ->and($saved[6])->toBe('# x #') + ->and(str_replace('# x #', '# #', (string) file_get_contents($mapFile)))->toBe($original); + } finally { + removeScratchTree($root); + } +}); From 25d37592c0e228b528e56e6e9ede1ca52d2f2859 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 22:40:33 +0200 Subject: [PATCH 020/344] fix(editor): preserve opaque skills when moving editable neighbours Show unsupported skill fields and their refusal reason read-only. Remove edit-mode access for fields explicitly marked read-only, including established actor IDs, before controls or pickers open. --- src/Editor.php | 23 ++++++- src/ProjectSkillDatabase.php | 71 ++++++++++++++++------ tests/Unit/AnimationReferenceTest.php | 41 +++++++++++++ tests/Unit/SkillSourcePreservationTest.php | 39 ++++++++++++ 4 files changed, 154 insertions(+), 20 deletions(-) diff --git a/src/Editor.php b/src/Editor.php index 5a6a04f..f3da6bb 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -10801,8 +10801,18 @@ private function getDatabaseSkillSettingsFields(): array 'noneLabel' => '(Legacy fallback)', ], ]; - return array_values(array_filter($fields, fn(array $field): bool => - $this->workspace->skillDatabase->canEditField($this->databaseSelectedSkillIndex, $field['field']))); + $database = $this->workspace->skillDatabase; + $fields = array_values(array_filter($fields, fn(array $field): bool => + $database->supportsField($this->databaseSelectedSkillIndex, $field['field']))); + $reason = $database->getReadOnlyReason($this->databaseSelectedSkillIndex); + if ($reason !== null) { + foreach ($fields as &$field) { + $field['editable'] = false; + } + unset($field); + array_unshift($fields, ['label' => 'Read-only', 'value' => $reason, 'editable' => false]); + } + return $fields; } /** * Returns the editable settings fields for the selected quest. @@ -10946,6 +10956,7 @@ private function getDatabaseClassBaseValueFields(ProjectClass $class): array */ private function getDatabaseFieldControl(array $field): ?InputControl { + if (($field['editable'] ?? null) === false) { return null; } $control = $field['control'] ?? null; return $control instanceof InputControl ? $control : null; @@ -10965,6 +10976,10 @@ private function beginDatabaseEdit(): void return; } + if (($field['editable'] ?? null) === false && ! isset($field['frame'])) { + return; + } + // A field that names another record is chosen from, never typed into. if (is_string($field['reference'] ?? null) && $this->openReferencePicker($field)) { return; @@ -12620,7 +12635,7 @@ private function adjustDatabaseOptionField(int $step): void $fields = $this->getDatabaseSettingsFields(); $field = $fields[$this->databaseSelectedSettingIndex] ?? null; - if (! is_array($field)) { + if (! is_array($field) || ($field['editable'] ?? null) === false) { return; } @@ -17350,6 +17365,7 @@ private function inspectorPaneLayout(?array $fields = null): SettingsPaneLayout */ private function isInspectorFieldInteractive(array $field): bool { + if (($field['editable'] ?? null) === false) { return isset($field['frame']); } return $this->getInspectorFieldControl($field) instanceof InputControl || ($field['editable'] ?? null) === true || ! empty($field['options']) @@ -17368,6 +17384,7 @@ private function isInspectorFieldInteractive(array $field): bool */ private function getInspectorFieldControl(array $field): ?InputControl { + if (($field['editable'] ?? null) === false) { return null; } $control = $field['control'] ?? null; return $control instanceof InputControl ? $control : null; diff --git a/src/ProjectSkillDatabase.php b/src/ProjectSkillDatabase.php index dd8b199..cb9886e 100644 --- a/src/ProjectSkillDatabase.php +++ b/src/ProjectSkillDatabase.php @@ -108,11 +108,24 @@ public function setField(int $index, string $field, mixed $value): void } public function canEditField(int $index, string $field): bool + { + return $this->getReadOnlyReason($index) === null && $this->supportsField($index, $field); + } + + public function getReadOnlyReason(int $index): ?string { $skill = $this->getSkillByIndex($index); - if ($skill === null || in_array($field, ['type', 'effects'], true)) { return false; } + if ($skill === null) { return null; } $authored = $this->authored[spl_object_id($skill)] ?? null; - if ($authored !== null && $authored['source'] === null) { return false; } + return $authored !== null && $authored['source'] === null + ? $this->getSourceRefusal($skill)->getMessage() + : null; + } + + public function supportsField(int $index, string $field): bool + { + $skill = $this->getSkillByIndex($index); + if ($skill === null || in_array($field, ['type', 'effects'], true)) { return false; } $parameter = str_starts_with($field, 'scope') ? 'scope' : (str_starts_with($field, 'invocation') ? 'invocation' : $field); return in_array($parameter, $this->getConstructorParameters($skill), true); @@ -245,7 +258,20 @@ private function getUpdatedSource(): string foreach ($this->getSkills() as $index => $skill) { $key = spl_object_id($skill); $position = array_search($key, $order, true); - if ($position === $index && $skill->toArray() === $this->authored[$key]['payload']) { + $authored = $this->authored[$key] ?? null; + if ($authored !== null && $authored['source'] === null) { + if ($skill->toArray() !== $authored['payload']) { throw $this->getSourceRefusal($skill); } + // Opaque expressions stay untouched. Move editable neighbours past them. + while ($position !== $index) { + $neighbour = $this->authored[$order[$index]]['skill']; + $destination = array_search(spl_object_id($neighbour), $retained, true); + if ($destination === false || $destination <= $index) { throw $this->getSourceRefusal($skill); } + $document = $this->moveSkillEntry($document, $order, $neighbour, $destination); + $position = array_search($key, $order, true); + } + continue; + } + if ($position === $index && $skill->toArray() === ($authored['payload'] ?? null)) { continue; } $entrySource = $this->getUpdatedEntry($skill); @@ -258,20 +284,7 @@ private function getUpdatedSource(): string } continue; } - if ($position !== false) { - $document = $document->withoutEntry($position); - array_splice($order, $position, 1); - } - $before = $index < count($order) ? $index : null; - $block = $this->authored[$key]['block'] ?? null; - if ($block !== null) { - $preserved = PhpSourceDocument::parse("applySkillChanges($preserved, 0, $skill)->getEntryBlockSource(0); - $document = $document->getWithEntryBlockSource($block, $before); - } else { - $document = $document->getWithEntrySource($entrySource, $before); - } - array_splice($order, $index, 0, [$key]); + $document = $this->moveSkillEntry($document, $order, $skill, $index); } for ($index = count($order) - 1; $index >= count($this->skills); $index--) { $document = $document->withoutEntry($index); @@ -279,6 +292,30 @@ private function getUpdatedSource(): string return $document->source; } + /** @param list $order */ + private function moveSkillEntry(PhpSourceDocument $document, array &$order, ProjectSkill $skill, int $index): PhpSourceDocument + { + $key = spl_object_id($skill); + $entrySource = $this->getUpdatedEntry($skill); + $position = array_search($key, $order, true); + if ($position !== false) { + $document = $document->withoutEntry($position); + array_splice($order, $position, 1); + } + $index = min($index, count($order)); + $before = $index < count($order) ? $index : null; + $block = $this->authored[$key]['block'] ?? null; + if ($block !== null) { + $preserved = PhpSourceDocument::parse("applySkillChanges($preserved, 0, $skill)->getEntryBlockSource(0); + $document = $document->getWithEntryBlockSource($block, $before); + } else { + $document = $document->getWithEntrySource($entrySource, $before); + } + array_splice($order, $index, 0, [$key]); + return $document; + } + private function getUpdatedEntry(ProjectSkill $skill): string { $authored = $this->authored[spl_object_id($skill)] ?? null; diff --git a/tests/Unit/AnimationReferenceTest.php b/tests/Unit/AnimationReferenceTest.php index 36f2cc1..d58c6c3 100644 --- a/tests/Unit/AnimationReferenceTest.php +++ b/tests/Unit/AnimationReferenceTest.php @@ -139,3 +139,44 @@ $workspace->skillDatabase->save(); expect((require $root . '/assets/Data/skills.php')[0]->animationId)->toBe(1); })->with([BasicSkill::class, MagicSkill::class, SpecialSkill::class]); + +it('shows opaque skill fields read-only and refuses Enter and directional edits before opening controls', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/skills.php'; + $source = <<<'PHP' +toContain('Computed', 'read-only'); + $values = array_column($fields, 'value', 'label'); + expect($values)->toMatchArray(['Name' => 'Computed', 'Description' => 'Authored description', 'Cost' => '4']); + foreach ($fields as $index => $field) { + expect($field['editable'])->toBeFalse(); + setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); + callEditorMethod($editor, 'dispatchInput', "\r"); + callEditorMethod($editor, 'dispatchInput', "\033[C"); + expect(getEditorProperty($editor, 'isDatabaseEditing'))->toBeFalse() + ->and(callEditorMethod($editor, 'isInspectorFieldInteractive', $field))->toBeFalse(); + } + expect(getEditorProperty($editor, 'workspace')->skillDatabase->isDirty())->toBeFalse() + ->and(file_get_contents($path))->toBe($source); +}); + +it('does not enter text editing for an established actor id', function () { + $root = makeTemporaryProject(); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'actors'); + setEditorProperty($editor, 'databaseFocus', 'database_settings'); + $fields = callEditorMethod($editor, 'getDatabaseActorSettingsFields'); + $index = array_search('id', array_column($fields, 'field'), true); + expect($index)->not->toBeFalse()->and($fields[$index]['editable'])->toBeFalse(); + setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'isDatabaseEditing'))->toBeFalse(); +}); diff --git a/tests/Unit/SkillSourcePreservationTest.php b/tests/Unit/SkillSourcePreservationTest.php index 4d11de3..ee7573e 100644 --- a/tests/Unit/SkillSourcePreservationTest.php +++ b/tests/Unit/SkillSourcePreservationTest.php @@ -161,6 +161,45 @@ expect(fn() => PhpToken::tokenize($document->source, TOKEN_PARSE))->not->toThrow(ParseError::class); }); +it('moves supported skills across opaque neighbours without rewriting their expressions', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/skills.php'; + $source = <<<'PHP' +removeSkill(0); + $database->insertSkill(2, $moved); + $database->save(); + expect(array_map(static fn($skill) => $skill->name, require $path))->toBe(['Computed', 'After', 'Before']) + ->and(file_get_contents($path))->toContain("// Opaque documentation.\n \$computed, // Opaque tail.") + ->and(file_get_contents($path))->toContain("// Before documentation.\n new SpecialSkill('Before', '', '', 0, 0), // Before tail."); + $database = ProjectSkillDatabase::fromProject($root); + $moved = $database->removeSkill(2); + $database->insertSkill(0, $moved); + $database->save(); + expect(file_get_contents($path))->toBe($source); + $moved = $database->removeSkill(2); + $database->insertSkill(0, $moved); + $database->save(); + expect(array_map(static fn($skill) => $skill->name, require $path))->toBe(['After', 'Before', 'Computed']); + $moved = $database->removeSkill(0); + $database->insertSkill(2, $moved); + $database->save(); + expect(file_get_contents($path))->toBe($source); +}); + it('adds skills after a trailing comment without hiding the separator in that comment', function (): void { $root = makeTemporaryProject(); $path = $root . '/assets/Data/skills.php'; From 82fa324273a13a72075cdcd7dbaef99025a888aa Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 22:48:56 +0200 Subject: [PATCH 021/344] fix(editor): offer confirmed actor identity migration Remove free-text entry for missing actor IDs. Freeze the current name only after confirmation, with source-preserving save and undo/redo; established IDs never enter edit mode. Reuse the shared option dialog above the database surface. --- src/Editor.php | 72 ++++++++++++++++++--- src/UI/Modal.php | 6 +- tests/Unit/ActorIdentityMigrationUiTest.php | 41 ++++++++++++ 3 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 tests/Unit/ActorIdentityMigrationUiTest.php diff --git a/src/Editor.php b/src/Editor.php index f3da6bb..7492493 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -10,6 +10,7 @@ use Atatusoft\Termutil\IO\Enumerations\Color; use Atatusoft\Termutil\IO\Mouse\Enumerations\MouseButton; use Ichiloto\Editor\Backup\BackupSettings; +use Ichiloto\Editor\Actors\ActorIdentityMigration; use Ichiloto\Editor\Backup\BackupWriter; use Ichiloto\Editor\Canvas\CanvasTool; use Ichiloto\Editor\Canvas\Clipboard; @@ -7756,6 +7757,11 @@ private function applySelectedEventOption(): void $field = $this->optionDialogField; $title = $this->eventOptionDialogTitle; + if (($field['actorIdentityMigration'] ?? null) instanceof ProjectActor) { + $this->confirmActorIdentityMigration($field['actorIdentityMigration'], $selectedEntry['value'] === 'freeze'); + return; + } + try { $this->applyInspectorFieldValue($field, (string) $selectedEntry['value']); $this->closeEventOptionDialog(sprintf('Set %s to %s.', mb_strtolower($title), $selectedEntry['label'])); @@ -10232,17 +10238,17 @@ private function getDatabaseSettingsFields(): array * @param ProjectActor $actor The actor. * @return array> The rows. */ - private function actorIdentityFields(ProjectActor $actor): array + private function getActorIdentityFields(ProjectActor $actor): array { return [ ['label' => 'Identity', 'value' => '', 'editable' => false, 'field' => ''], [ 'label' => 'Definition Id', 'value' => $actor->hasDefinitionId() ? $actor->getDefinitionId() : '', - 'control' => new InputControl(InputControlType::TEXT, $actor->hasDefinitionId() ? $actor->getDefinitionId() : ''), - 'editable' => ! $actor->hasDefinitionId(), + 'editable' => ! array_key_exists('id', $actor->getData()), + 'actorIdentityMigration' => $actor, 'field' => 'id', - 'displayDefault' => sprintf('Required: use the original identity (%s for an unmigrated legacy actor)', $actor->getName()), + 'displayDefault' => sprintf('Enter to freeze the current name (%s) as its permanent id', $actor->getName()), ], ]; } @@ -10559,7 +10565,7 @@ private function getDatabaseActorSettingsFields(): array } return [ - ...$this->actorIdentityFields($actor), + ...$this->getActorIdentityFields($actor), [ 'label' => 'Name', 'value' => $actor->getName(), @@ -10980,6 +10986,11 @@ private function beginDatabaseEdit(): void return; } + if (($field['actorIdentityMigration'] ?? null) instanceof ProjectActor) { + $this->openActorIdentityMigration($field['actorIdentityMigration']); + return; + } + // A field that names another record is chosen from, never typed into. if (is_string($field['reference'] ?? null) && $this->openReferencePicker($field)) { return; @@ -11035,6 +11046,47 @@ private function beginDatabaseEdit(): void $this->renderDatabasePanes(['settings']); } + private function openActorIdentityMigration(ProjectActor $actor): void + { + $this->optionDialogField = ['actorIdentityMigration' => $actor]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = 'Freeze actor identity'; + $this->eventOptionDialogEntries = [ + ['label' => 'Cancel', 'value' => 'cancel', 'description' => 'Leave the actor unchanged.'], + ['label' => sprintf('Freeze "%s" as the permanent id', $actor->getName()), 'value' => 'freeze', + 'description' => 'Later display-name changes will keep this identity. Save the project to write it.'], + ]; + $this->selectedEventOptionIndex = 0; + $this->dialogFilter->clear(); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = 'Confirm the one-time actor identity migration.'; + $this->renderSelectionDependentArea(); + } + + private function confirmActorIdentityMigration(ProjectActor $actor, bool $confirmed): void + { + if (! $confirmed) { + $this->closeEventOptionDialog('Actor identity migration cancelled.'); + return; + } + try { + $before = $actor->getData(); + ActorIdentityMigration::freezeCurrentName($this->workspace->actorDatabase, $actor); + $after = $actor->getData(); + $this->recordCommand(new GenericCommand( + 'Freeze actor identity', + static fn() => $actor->restoreData($after), + static fn() => $actor->restoreData($before), + )); + $this->closeEventOptionDialog('Actor identity frozen. Save the project to write it.'); + } catch (Throwable $failure) { + $this->closeEventOptionDialog(''); + $this->setErrorStatus($failure, 'Actor identity migration'); + } + $this->renderDatabasePanes(['list', 'settings']); + } + /** * Handles inline database-field editing. * @@ -17563,6 +17615,11 @@ private function renderModalOverlays(array $layout): bool return true; } + if ($this->isEventOptionDialogOpen) { + $this->renderEventOptionDialogOverlay($layout); + return true; + } + return false; } @@ -17850,11 +17907,6 @@ private function drawOverlays(): void return; } - if ($this->isEventOptionDialogOpen) { - $this->renderEventOptionDialogOverlay($layout); - return; - } - if ($this->isDestinationSpawnConfirmationOpen) { $this->renderDestinationSpawnConfirmationOverlay($layout); return; diff --git a/src/UI/Modal.php b/src/UI/Modal.php index 74464db..19911a0 100644 --- a/src/UI/Modal.php +++ b/src/UI/Modal.php @@ -42,6 +42,8 @@ enum Modal: string * The `?` help overlay generated from the input binding tables. */ case HELP = 'help'; + /** Shared option and confirmation pickers can open above the Database. */ + case EVENT_OPTION_DIALOG = 'event_option_dialog'; /** * The Database screen. Consumes all input while open (below the safety * modals and the global Ctrl+E shortcut). @@ -64,10 +66,6 @@ enum Modal: string * The loot picker dialog. */ case LOOT_DIALOG = 'loot_dialog'; - /** - * The generic event-option picker dialog. - */ - case EVENT_OPTION_DIALOG = 'event_option_dialog'; /** The brush colour picker owns mouse input above the shared interceptor. */ case COLOR_PICKER = 'color_picker'; /** diff --git a/tests/Unit/ActorIdentityMigrationUiTest.php b/tests/Unit/ActorIdentityMigrationUiTest.php new file mode 100644 index 0000000..d6729c4 --- /dev/null +++ b/tests/Unit/ActorIdentityMigrationUiTest.php @@ -0,0 +1,41 @@ + 'Kaelion',\R/m", '', $source); + file_put_contents($path, $source); + expect((require $path)['data'])->not->toHaveKey('id'); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'actors'); + setEditorProperty($editor, 'databaseFocus', 'database_settings'); + $fields = callEditorMethod($editor, 'getDatabaseActorSettingsFields'); + $index = array_search('id', array_column($fields, 'field'), true); + setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); + $workspace = getEditorProperty($editor, 'workspace'); + $actor = $workspace->actorDatabase->getActors()[0]; + + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'isDatabaseEditing'))->toBeFalse() + ->and(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue(); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect($actor->hasDefinitionId())->toBeFalse(); + + callEditorMethod($editor, 'dispatchInput', "\r"); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'statusMessage'))->toBe('Actor identity frozen. Save the project to write it.'); + expect($actor->getDefinitionId())->toBe('Kaelion') + ->and(file_get_contents($path))->toBe($source); + $actor->save(); + expect((require $path)['data']['id'])->toBe('Kaelion'); + callEditorMethod($editor, 'performUndo'); + $actor->save(); + expect(file_get_contents($path))->toBe($source); + callEditorMethod($editor, 'performRedo'); + $actor->save(); + expect((require $path)['data']['id'])->toBe('Kaelion'); +}); From 70850b4e790841190b7eb1ff342f5d2666059981 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 22:52:13 +0200 Subject: [PATCH 022/344] fix(actors): add shared identity repair and remove display-name aliases Freeze absent legacy IDs from current names through one source-preserving migration service shared by TUI and CLI. Preserve undo snapshots and authored comments, imports and unchanged expressions. Preflight and validate staged readback, refuse unsupported edits, and roll back failed batch migration. Remove arbitrary legacy ID assignment and destructive actor-file regeneration. Keep established IDs immutable and remove filename and display-name lookup aliases from battle-entry validation. Duplicate display names no longer collide; duplicate IDs still fail. --- docs/roadmap.md | 12 +- src/Actors/ActorIdentityMigration.php | 73 ++++++++++++ src/Database/BattleEntryActorResolver.php | 26 +---- src/ProjectActor.php | 80 ++++++++++--- src/Validation/ProjectValidator.php | 7 +- tests/Unit/ActorIdentityMigrationTest.php | 134 ++++++++++++++++++++++ tests/Unit/BattleEntryParityTest.php | 27 ++--- tests/Unit/BattleEntryRulesTest.php | 9 +- 8 files changed, 310 insertions(+), 58 deletions(-) create mode 100644 src/Actors/ActorIdentityMigration.php create mode 100644 tests/Unit/ActorIdentityMigrationTest.php diff --git a/docs/roadmap.md b/docs/roadmap.md index 71189a9..3c166ac 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1000,7 +1000,7 @@ refused rather than rewritten, and the ordinary writer takes over. **An actor is more than its class.** An explicit, immutable definition id (assigned at creation; missing legacy ids are validation errors and must be -declared before renaming), actor-natural adjustments that keep +frozen from the current name before renaming), actor-natural adjustments that keep their sign and drop zeroes, and named natural variants with a default — where the rows edit whichever set the runtime has in force. Beside them, what each stat actually comes to, resolved by `StatResolver` and @@ -1008,6 +1008,16 @@ each stat actually comes to, resolved by `StatResolver` and with what the cap threw away. Accuracy and Critical are absent by design; the runtime does not resolve them as layers. +Legacy actor identity repair is shared by the TUI and CLI through +`ActorIdentityMigration`. It offers a one-time freeze of the current name, not +free-text identity retargeting. The TUI records the repair in normal undo history; +CLI migration preflights the whole batch and uses a file-set transaction. +Existing actor saves now preserve authored source instead of regenerating the +file: comments, imports and unchanged expressions survive repair and rename. +Unsupported source edits and externally changed files are refused before writes. +Display-name and filename lookup aliases are removed; only definition IDs bind +references, independently of names, filenames and artwork. + **A catalogue in a file that holds several.** `RecordProjection` is the seam for a data file shaped for the runtime rather than for an editor. A category declares how its records are read out of the whole payload and folded back diff --git a/src/Actors/ActorIdentityMigration.php b/src/Actors/ActorIdentityMigration.php new file mode 100644 index 0000000..1c46360 --- /dev/null +++ b/src/Actors/ActorIdentityMigration.php @@ -0,0 +1,73 @@ + Only absent IDs, never malformed authored IDs. */ + public static function getPendingActors(ProjectActorDatabase $database): array + { + return array_values(array_filter($database->getActors(), + static fn(ProjectActor $actor): bool => ! array_key_exists('id', $actor->getData()))); + } + + /** Freeze in memory only; the editor records getData/restoreData in its undo history. */ + public static function freezeCurrentName(ProjectActorDatabase $database, ProjectActor $actor): void + { + if (! in_array($actor, $database->getActors(), true)) { + throw new RuntimeException('The actor does not belong to this project database.'); + } + if (array_key_exists('id', $actor->getData())) { + throw new RuntimeException('Only an absent actor id can be migrated; established or malformed ids cannot be replaced.'); + } + $name = $actor->getData()['name'] ?? null; + if (! is_string($name) || trim($name) === '') { + throw new RuntimeException("{$actor->path} has no non-empty current name to freeze."); + } + $id = trim($name); + foreach ($database->getActors() as $other) { + if ($other === $actor) { continue; } + $otherId = array_key_exists('id', $other->getData()) ? $other->getDefinitionId() : trim($other->getName()); + if (strtolower($otherId) === strtolower($id)) { + throw new RuntimeException("Cannot freeze actor id \"{$id}\": it conflicts with {$other->path}."); + } + } + $data = $actor->getData(); + $data['id'] = $id; + $actor->getProposedSource($data); + $actor->setField('id', $id); + } + + /** Explicit CLI operation. Preflight the whole batch and install it as one transaction. + * @return list Changed actor file paths. + */ + public static function migrateProject(string $projectRoot): array + { + $database = ProjectActorDatabase::fromProject($projectRoot); + $actors = self::getPendingActors($database); + if ($actors === []) { return []; } + $transaction = new FileSetTransaction($database->directory); + foreach ($actors as $actor) { + self::freezeCurrentName($database, $actor); + $transaction->write($actor->path, $actor->getProposedSource()); + } + try { + $staged = $transaction->stage(); + foreach ($actors as $actor) { $actor->validateStagedSource($staged[$actor->path]); } + $transaction->commit(); + } catch (Throwable $failure) { + $transaction->rollBack(); + throw $failure; + } + return array_map(static fn(ProjectActor $actor): string => $actor->path, $actors); + } +} diff --git a/src/Database/BattleEntryActorResolver.php b/src/Database/BattleEntryActorResolver.php index b79e9ef..ba88a92 100644 --- a/src/Database/BattleEntryActorResolver.php +++ b/src/Database/BattleEntryActorResolver.php @@ -9,10 +9,8 @@ /** * The editor's mirror of the engine `ActorStore` reference resolution. * - * The engine registers every actor definition under three references — its - * durable id, its display name, and its file stem — normalized by lowercased - * trim, and refuses to build the store at all when two definitions share an - * identity or a reference points two ways. Battle-entry rules resolve actors + * Only durable IDs resolve, normalized by lowercased trim. Display names and + * filenames do not create aliases or collisions. Battle-entry rules resolve actors * through that store, so the editor resolves them the same way: a missing, * ambiguous, or contested identity stays visible and fails validation rather * than being substituted with whichever record happened to load first. @@ -61,25 +59,7 @@ public static function fromActors(array $actors): self $definitions[$normalizedId] = $id; - $fileStem = $actor->path === '' ? '' : pathinfo($actor->path, PATHINFO_FILENAME); - - foreach ([$id, $actor->getName(), $fileStem] as $reference) { - $reference = self::normalize($reference); - - if ($reference === '') { - continue; - } - - $existing = $references[$reference] ?? null; - - if ($existing !== null && $existing !== $normalizedId) { - $problems[] = sprintf('Actor reference "%s" is ambiguous.', $reference); - - continue; - } - - $references[$reference] = $normalizedId; - } + $references[$normalizedId] = $normalizedId; } $canonical = []; diff --git a/src/ProjectActor.php b/src/ProjectActor.php index 13d2c3d..7a510d6 100644 --- a/src/ProjectActor.php +++ b/src/ProjectActor.php @@ -5,7 +5,12 @@ namespace Ichiloto\Editor; use Ichiloto\Editor\History\TracksPersistedState; -use Ichiloto\Editor\IO\AtomicFile; +use Ichiloto\Editor\Cutscenes\Source\ArraySourceWriter; +use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; +use Ichiloto\Editor\Cutscenes\Source\SourceNode; +use Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal; +use Ichiloto\Editor\Database\PhpDataFile; +use Ichiloto\Editor\Storage\FileSetTransaction; use RuntimeException; @@ -18,6 +23,10 @@ final class ProjectActor private ?string $establishedDefinitionId; private readonly bool $loadedWithoutDefinitionId; + private ?string $originalSource = null; + private ?string $writtenSource = null; + /** @var array */ + private readonly array $originalPayload; /** * The sentinel option meaning "no class reference" in the editor picker. @@ -35,7 +44,8 @@ public function __construct( ) { $id = $this->getDefinitionId(); $this->establishedDefinitionId = $id === '' ? null : $id; - $this->loadedWithoutDefinitionId = $id === ''; + $this->loadedWithoutDefinitionId = ! array_key_exists('id', $this->getData()); + $this->originalPayload = $payload; if (! $isDirty) { // Loaded from disk: the current content is the saved content. $this->captureBaseline(); @@ -56,11 +66,16 @@ public static function fromFile(string $path): self throw new RuntimeException("Unable to parse {$path}."); } - return new self( + $actor = new self( id: pathinfo($path, PATHINFO_FILENAME), path: $path, payload: $payload, ); + $source = file_get_contents($path); + if ($source === false) { throw new RuntimeException("Unable to read {$path}."); } + $actor->originalSource = $source; + $actor->writtenSource = $source; + return $actor; } /** @@ -505,6 +520,10 @@ public function setField(string $field, mixed $value): void if (($this->establishedDefinitionId !== null && $identity !== $this->establishedDefinitionId) || $identity === '') { throw new RuntimeException('An actor id is permanent. Change the display name, not its identity.'); } + if ($this->establishedDefinitionId === null + && (! $this->loadedWithoutDefinitionId || $identity !== trim($this->getName()))) { + throw new RuntimeException('A legacy actor repair must freeze its current name as its permanent id.'); + } $this->establishedDefinitionId = $identity; } if ($field === 'name' && ! $this->hasDefinitionId()) { @@ -620,14 +639,50 @@ public function setField(string $field, mixed $value): void */ public function save(): void { - $directory = dirname($this->path); + $source = $this->getProposedSource(); + $transaction = new FileSetTransaction(dirname($this->path)); + $transaction->write($this->path, $source); + try { + $staged = $transaction->stage(); + $this->validateStagedSource($staged[$this->path]); + $transaction->commit(); + } catch (\Throwable $failure) { + $transaction->rollBack(); + throw $failure; + } + $this->writtenSource = $source; + $this->captureBaseline(); + } - if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { - throw new RuntimeException("Unable to create {$directory}."); + /** Preflights edits without writing or flattening authored PHP. + * @param array|null $data Optional candidate for migration preflight. + */ + public function getProposedSource(?array $data = null): string + { + $current = $this->payload; + if ($data !== null) { $current['data'] = $data; } + $disk = is_file($this->path) ? file_get_contents($this->path) : null; + if ($disk !== null && $disk !== $this->writtenSource) { + throw new RuntimeException("{$this->path} changed outside the editor; reload before saving."); + } + if ($this->originalSource !== null) { + $document = PhpArraySourceDocument::parse($this->originalSource); + if ($current !== $this->originalPayload && $document->nodeAt(['data'])?->kind !== SourceNode::ARRAY) { + throw new SourcePreservationRefusal('Actor data is not an editable array literal; refusing to flatten its source.'); + } + return ArraySourceWriter::rewrite($document, $this->originalPayload, $current)->source; } + return "path, $this->buildPersistedPayload()); - $this->captureBaseline(); + /** Verifies the exact proposed file before a transaction installs it. */ + public function validateStagedSource(string $path): void + { + $root = basename(dirname($this->path)) === 'Actors' ? dirname($this->path, 4) : dirname($this->path); + $evaluated = PhpDataFile::evaluateIsolated($path, $root); + if (PhpDataFile::valueFingerprint($evaluated) !== PhpDataFile::valueFingerprint($this->payload)) { + throw new RuntimeException("{$this->path} would not read back as the edited actor; nothing was written."); + } } /** Restores an authoring snapshot for undo/redo without allowing identity retargeting. @@ -636,7 +691,7 @@ public function save(): void public function restoreData(array $data): void { $id = $data['id'] ?? null; - $isLegacyUndo = $this->loadedWithoutDefinitionId && $id === null; + $isLegacyUndo = $this->loadedWithoutDefinitionId && ! array_key_exists('id', $data); if (! $isLegacyUndo && $id !== $this->establishedDefinitionId) { throw new RuntimeException('An actor id is permanent. Undo cannot retarget its identity.'); } @@ -649,10 +704,9 @@ public function restoreData(array $data): void */ protected function buildPersistedPayload(): string { - return " Character::class,\n" - . " 'data' => " . self::exportPhpValue($this->getData(), 1) . ",\n" - . "];\n"; + // Fingerprint the editable state, including unsupported source shapes. + // Source-preservation refusals belong to preflight/save, not dirty checks. + return serialize($this->payload); } /** diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 1a3ab7a..fcb07da 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -266,7 +266,8 @@ protected function checkActorDefinitions(ProjectWorkspace $workspace): array $where = sprintf('actor %s', $actor->getName()); $id = $actor->getDefinitionId(); - if (isset($seen[$id])) { + $identityKey = strtolower($id); + if ($id !== '' && isset($seen[$identityKey])) { $issues[] = Issue::error( $where, sprintf('Two actors resolve to the identity "%s".', $id), @@ -274,11 +275,11 @@ protected function checkActorDefinitions(ProjectWorkspace $workspace): array ); } - $seen[$id] = true; + if ($id !== '') { $seen[$identityKey] = true; } if (! $actor->hasDefinitionId()) { $issues[] = Issue::error($where, 'Actor has no explicit stable id.', - 'Declare its existing identity before renaming it. For legacy actors, use the original display name to preserve references and saves.'); + 'For an absent id, use Freeze current name as ID in the actor editor or the CLI validation migration before renaming. Malformed explicit ids require manual correction; no file is repaired automatically.'); } $variants = $actor->getNaturalVariants(); diff --git a/tests/Unit/ActorIdentityMigrationTest.php b/tests/Unit/ActorIdentityMigrationTest.php new file mode 100644 index 0000000..48348e0 --- /dev/null +++ b/tests/Unit/ActorIdentityMigrationTest.php @@ -0,0 +1,134 @@ + Character::class, + 'data' => [ + // Keep this name comment. + 'name' => 'Kaelion', + 'description' => $profile, + 'level' => 1 + 2, + ], +]; +PHP; + file_put_contents($path, $source); + return [$root, $path, $source]; +} + +it('shares an explicit source preserving batch repair and keeps ids stable after later rename', function () { + [$root, $path, $source] = createLegacyActorProject(); + $before = sourceHashTree($root); + $database = ProjectActorDatabase::fromProject($root); + expect(ActorIdentityMigration::getPendingActors($database))->toHaveCount(1) + ->and(sourceHashTree($root))->toBe($before); + expect(ActorIdentityMigration::migrateProject($root))->toBe([$path]); + $written = file_get_contents($path); + expect($written)->toContain("'id' => 'Kaelion'", '// Keep this authored header.', '// Keep this name comment.', "'level' => 1 + 2", "'description' => \$profile") + ->and(ActorIdentityMigration::migrateProject($root))->toBe([]); + $database = ProjectActorDatabase::fromProject($root); + $actor = $database->getActors()[0]; + $actor->setField('name', 'Kaelion Renamed'); + $database->save(); + expect(file_get_contents($path))->toBe(str_replace("'name' => 'Kaelion'", "'name' => 'Kaelion Renamed'", $written)); + $store = $database->createActorStore(); + expect($store->require('Kaelion', 'restoring identity')->id)->toBe('Kaelion') + ->and($store->has('Kaelion Renamed'))->toBeFalse(); +}); + +it('supports source exact repair undo and redo across saves without allowing another identity', function () { + [$root, $path, $source] = createLegacyActorProject(); + $database = ProjectActorDatabase::fromProject($root); + $actor = $database->getActors()[0]; + $before = $actor->getData(); + ActorIdentityMigration::freezeCurrentName($database, $actor); + $after = $actor->getData(); + expect(file_get_contents($path))->toBe($source); + $database->save(); + $repaired = file_get_contents($path); + $actor->restoreData($before); + $database->save(); + expect(file_get_contents($path))->toBe($source); + $actor->restoreData($after); + $database->save(); + expect(file_get_contents($path))->toBe($repaired); + expect(fn() => $actor->setField('id', 'another'))->toThrow(RuntimeException::class, 'permanent'); +}); + +it('refuses unsupported source anywhere in the batch before writing any actor', function () { + [$root, $path] = createLegacyActorProject(); + $other = dirname($path) . '/Other.php'; + file_put_contents($other, " ['name' => 'Other']]; return \$actor;"); + $before = sourceHashTree($root); + expect(fn() => ActorIdentityMigration::migrateProject($root))->toThrow(SourceUnreadable::class); + expect(sourceHashTree($root))->toBe($before); +}); + +it('refuses a variable backed data block without flattening it', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " 'Kaelion']; return ['data' => \$data];"); + $before = sourceHashTree($root); + expect(fn() => ActorIdentityMigration::migrateProject($root))->toThrow(RuntimeException::class, 'refusing to flatten'); + expect(sourceHashTree($root))->toBe($before); +}); + +it('rolls back all staged actors when readback cannot reproduce the authored value', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents(dirname($path) . '/Other.php', " ['name' => 'Other', 'description' => __FILE__]];"); + $before = sourceHashTree($root); + expect(fn() => ActorIdentityMigration::migrateProject($root))->toThrow(RuntimeException::class, 'would not read back'); + expect(sourceHashTree($root))->toBe($before); +}); + +it('refuses legacy identity collisions and never repairs malformed explicit ids', function () { + [$root, $path] = createLegacyActorProject(); + $other = dirname($path) . '/Other.php'; + file_put_contents($other, " ['id' => 'Kaelion', 'name' => 'Different']];"); + $before = sourceHashTree($root); + expect(fn() => ActorIdentityMigration::migrateProject($root))->toThrow(RuntimeException::class, 'conflicts'); + expect(sourceHashTree($root))->toBe($before); + foreach (['', null, 42] as $invalidId) { + file_put_contents($path, ' ['id' => $invalidId, 'name' => 'Kaelion']], true) . ';'); + $database = ProjectActorDatabase::fromProject($root); + expect(ActorIdentityMigration::getPendingActors($database))->toBe([]); + expect(fn() => $database->getActors()[0]->setField('id', 'Kaelion'))->toThrow(RuntimeException::class, 'freeze'); + } +}); + +it('allows duplicate display names and a display name matching another id in the authoring registry', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " ['id' => 'one', 'name' => 'two']];"); + file_put_contents(dirname($path) . '/Second.php', " ['id' => 'two', 'name' => 'Shared']];"); + file_put_contents(dirname($path) . '/Third.php', " ['id' => 'three', 'name' => 'Shared']];"); + $store = ProjectActorDatabase::fromProject($root)->createActorStore(); + expect($store->get('two')->id)->toBe('two')->and($store->has('Shared'))->toBeFalse()->and($store->has('Kaelion'))->toBeFalse(); +}); + +it('refuses outside source changes and changed expressions rather than flattening the actor', function () { + [$root, $path, $source] = createLegacyActorProject(); + ActorIdentityMigration::migrateProject($root); + $database = ProjectActorDatabase::fromProject($root); + $actor = $database->getActors()[0]; + $before = file_get_contents($path); + $actor->setField('level', 9); + expect(fn() => $database->save())->toThrow(RuntimeException::class, 'expression'); + expect(file_get_contents($path))->toBe($before); + $actor->setField('level', 3); + $actor->setField('name', 'Renamed'); + file_put_contents($path, $before . "\n// Author's later edit\n"); + expect(fn() => $database->save())->toThrow(RuntimeException::class, 'changed outside'); + expect(file_get_contents($path))->toBe($before . "\n// Author's later edit\n"); +}); diff --git a/tests/Unit/BattleEntryParityTest.php b/tests/Unit/BattleEntryParityTest.php index 8e07e52..03a8905 100644 --- a/tests/Unit/BattleEntryParityTest.php +++ b/tests/Unit/BattleEntryParityTest.php @@ -192,10 +192,8 @@ function parityRuleCorpus(): array try { $ruleFile = $directory . '/rules.php'; - // Resolution: id, display name, and file stem all reach the same - // durable identity; an unknown reference is refused with the same - // wording; a healthy store accepts all three reference kinds. - file_put_contents($ruleFile, " [['id' => 'one', 'actors' => [['actor' => 'actor.rook', 'presence' => 'any'], ['actor' => 'Rook', 'presence' => 'active'], ['actor' => 'RookFile', 'presence' => 'reserve']], 'effects' => [['type' => 'stat_stage', 'actor' => 'ROOK', 'stat' => 'speed', 'delta' => 1]]]]];"); + // ID-only resolution removes inferred display-name and filename aliases. + file_put_contents($ruleFile, " [['id' => 'one', 'actors' => [['actor' => 'actor.rook', 'presence' => 'any']], 'effects' => [['type' => 'stat_stage', 'actor' => 'ACTOR.ROOK', 'stat' => 'speed', 'delta' => 1]]]]];"); $healthy = parityActorsDirectory(['RookFile' => ['Rook', 'actor.rook'], 'Vale' => ['Vale', 'Vale']]); $engine = hydrateThroughAcceptedEngine($engineRoot, $ruleFile, $healthy); $resolver = parityResolver($healthy); @@ -203,8 +201,9 @@ function parityRuleCorpus(): array expect($engine['ok'] ?? false)->toBeTrue() ->and($resolver->problems())->toBe([]) ->and(BattleEntryRuleContract::problems(require $ruleFile, $ruleFile, $resolver))->toBe([]) - ->and($resolver->canonicalId('ROOK'))->toBe('actor.rook') - ->and($resolver->canonicalId('RookFile'))->toBe('actor.rook'); + ->and($resolver->canonicalId('ACTOR.ROOK'))->toBe('actor.rook') + ->and($resolver->canonicalId('ROOK'))->toBeNull() + ->and($resolver->canonicalId('RookFile'))->toBeNull(); // Unknown actor: same verdict, same wording. file_put_contents($ruleFile, " [['id' => 'one', 'actors' => [['actor' => 'Nobody', 'presence' => 'any']], 'effects' => [['type' => 'stat_stage', 'actor' => 'Rook', 'stat' => 'speed', 'delta' => 1]]]]];"); @@ -214,21 +213,23 @@ function parityRuleCorpus(): array expect($engine['ok'] ?? true)->toBeFalse() ->and($problems[0] ?? null)->toBe(strval($engine['error'] ?? '')); - // A contested reference refuses the store itself, with the same - // wording the resolver reports. + // A shared name is not a contested identity anymore. + file_put_contents($ruleFile, " []];"); $contested = parityActorsDirectory(['Kael' => ['Kael', 'Kael'], 'Impostor' => ['Kael', 'actor.impostor']]); $engine = hydrateThroughAcceptedEngine($engineRoot, $ruleFile, $contested); $resolver = parityResolver($contested); - expect($engine['phase'] ?? null)->toBe('store') - ->and($resolver->problems())->toContain(strval($engine['error'] ?? '')); + expect($engine['ok'] ?? false)->toBeTrue() + ->and($resolver->problems())->toBe([]) + ->and($resolver->canonicalId('Kael'))->toBe('Kael'); - // Missing ids are no longer inferred from display names in either surface. + // Runtime file compatibility is provisional; authoring still requires explicit repair. $missing = parityActorsDirectory(['Missing' => ['Display Name', null]]); $engine = hydrateThroughAcceptedEngine($engineRoot, $ruleFile, $missing); $resolver = parityResolver($missing); - expect($engine['phase'] ?? null)->toBe('store') - ->and($resolver->problems())->toContain(strval($engine['error'] ?? '')); + expect($engine['ok'] ?? false)->toBeTrue() + ->and($resolver->problems())->not->toBeEmpty() + ->and($resolver->canonicalId('Display Name'))->toBeNull(); // Two definitions sharing an identity refuse the store the same way. $duplicated = parityActorsDirectory(['EchoOne' => ['Echo', 'actor.echo'], 'EchoTwo' => ['Echo Again', 'actor.echo']]); diff --git a/tests/Unit/BattleEntryRulesTest.php b/tests/Unit/BattleEntryRulesTest.php index a3e9c20..4543a1f 100644 --- a/tests/Unit/BattleEntryRulesTest.php +++ b/tests/Unit/BattleEntryRulesTest.php @@ -323,7 +323,7 @@ } }); -it('keeps missing, ambiguous, and contested actor identities visible', function (): void { +it('keeps missing and duplicate actor ids visible while removing display-name ambiguity', function (): void { $root = makeTemporaryProject(); try { @@ -347,14 +347,13 @@ 'assets/Data/battle-entry-rules.php rule "ghost-rule" field "actors[0]" field "actor" references unknown actor "Nobody".', ]); - // A reference two definitions claim is contested, and the rules are - // then checked shape-only, exactly as the runtime never reaches them. + // Display names are no longer lookup aliases, so this is not a collision. addProjectActor($root, 'Impostor', 'Kaelion', 'actor.impostor'); $messages = battleEntryProblemMessages($root); - expect($messages)->toContain('Actor reference "kaelion" is ambiguous.') - ->and($messages)->not->toContain( + expect($messages)->not->toContain('Actor reference "kaelion" is ambiguous.') + ->and($messages)->toContain( 'assets/Data/battle-entry-rules.php rule "ghost-rule" field "actors[0]" field "actor" references unknown actor "Nobody".', ); From 4facf2494c3697d938aeb48881219fc4bd02aaaa Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 23:06:21 +0200 Subject: [PATCH 023/344] fix(editor): explain malformed actor ids without offering repair --- src/Editor.php | 9 ++++++--- tests/Unit/ActorIdentityMigrationUiTest.php | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/Editor.php b/src/Editor.php index 7492493..88fd320 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -10240,15 +10240,18 @@ private function getDatabaseSettingsFields(): array */ private function getActorIdentityFields(ProjectActor $actor): array { + $missingId = ! array_key_exists('id', $actor->getData()); return [ ['label' => 'Identity', 'value' => '', 'editable' => false, 'field' => ''], [ 'label' => 'Definition Id', 'value' => $actor->hasDefinitionId() ? $actor->getDefinitionId() : '', - 'editable' => ! array_key_exists('id', $actor->getData()), - 'actorIdentityMigration' => $actor, + 'editable' => $missingId, + 'actorIdentityMigration' => $missingId ? $actor : null, 'field' => 'id', - 'displayDefault' => sprintf('Enter to freeze the current name (%s) as its permanent id', $actor->getName()), + 'displayDefault' => $missingId + ? sprintf('Enter to freeze the current name (%s) as its permanent id', $actor->getName()) + : 'Malformed explicit id: correct the authored actor file; automatic replacement is not allowed.', ], ]; } diff --git a/tests/Unit/ActorIdentityMigrationUiTest.php b/tests/Unit/ActorIdentityMigrationUiTest.php index d6729c4..43ae871 100644 --- a/tests/Unit/ActorIdentityMigrationUiTest.php +++ b/tests/Unit/ActorIdentityMigrationUiTest.php @@ -39,3 +39,21 @@ $actor->save(); expect((require $path)['data']['id'])->toBe('Kaelion'); }); + +it('explains malformed explicit ids without offering a migration or opening input', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + file_put_contents($path, str_replace("'id' => 'Kaelion'", "'id' => ''", (string) file_get_contents($path))); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'actors'); + setEditorProperty($editor, 'databaseFocus', 'database_settings'); + $fields = callEditorMethod($editor, 'getDatabaseActorSettingsFields'); + $index = array_search('id', array_column($fields, 'field'), true); + expect($fields[$index]['editable'])->toBeFalse() + ->and($fields[$index]['displayDefault'])->toContain('Malformed explicit id') + ->and($fields[$index]['actorIdentityMigration'])->toBeNull(); + setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'isDatabaseEditing'))->toBeFalse() + ->and(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse(); +}); From 92e0f7cfd4a9d7287d5f768e2f7f93a3f8a69276 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 24 Sep 2026 00:00:07 +0200 Subject: [PATCH 024/344] fix(editor): refuse opaque skill deletion before confirmation Remove delete confirmation and in-memory removal for opaque skills. Show the existing source refusal before prompting and preserve pending supported edits for a normal save. --- src/Editor.php | 9 +++++++ src/ProjectSkillDatabase.php | 4 +++ tests/Unit/DatabaseDeletionTest.php | 42 +++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/src/Editor.php b/src/Editor.php index 88fd320..681d37f 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -6202,6 +6202,15 @@ private function openDatabaseEntryDeleteConfirmation(): void return; } + if ($this->getSelectedDatabaseCategoryDefinition()->key === self::DATABASE_CATEGORY_SKILLS) { + $reason = $this->workspace->skillDatabase->getReadOnlyReason($index); + if ($reason !== null) { + $this->setStatus($reason, StatusLevel::WARN); + $this->renderFooter(); + return; + } + } + $this->pendingDatabaseDeletion = [ 'category' => $this->getSelectedDatabaseCategoryDefinition()->key, 'index' => $index, diff --git a/src/ProjectSkillDatabase.php b/src/ProjectSkillDatabase.php index cb9886e..a45c473 100644 --- a/src/ProjectSkillDatabase.php +++ b/src/ProjectSkillDatabase.php @@ -155,6 +155,10 @@ public function removeSkill(int $index): ?ProjectSkill return null; } + if ($this->getReadOnlyReason($index) !== null) { + throw $this->getSourceRefusal($skill); + } + array_splice($skills, $index, 1); $this->skills = $skills; $this->touchState(); diff --git a/tests/Unit/DatabaseDeletionTest.php b/tests/Unit/DatabaseDeletionTest.php index 4a7fc88..5f89bb3 100644 --- a/tests/Unit/DatabaseDeletionTest.php +++ b/tests/Unit/DatabaseDeletionTest.php @@ -197,3 +197,45 @@ removeDirectoryRecursively($root); } }); + +it('refuses opaque skill deletion before prompting and still saves supported pending edits', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/skills.php'; + $source = <<<'PHP' +skillDatabase; + $database->setField(1, 'animationId', 3); + $reason = $database->getReadOnlyReason(0); + + callEditorMethod($editor, 'dispatchInput', "\033[3~"); + + expect(getEditorProperty($editor, 'isDatabaseEntryDeleteConfirmationOpen'))->toBeFalse() + ->and(getEditorProperty($editor, 'pendingDatabaseDeletion'))->toBeNull() + ->and(getEditorProperty($editor, 'statusMessage'))->toBe($reason) + ->and($database->getSkills())->toHaveCount(2) + ->and($database->isDirty())->toBeTrue(); + expect(fn() => $database->removeSkill(0))->toThrow(RuntimeException::class, $reason); + + callEditorMethod($editor, 'dispatchInput', "\x13"); + + expect($database->isDirty())->toBeFalse() + ->and(file_get_contents($path))->toBe(str_replace( + "'Editable', '', '', 0, 0)", "'Editable', '', '', 0, 0, animationId: 3)", $source, + )); + } finally { + removeDirectoryRecursively($root); + } +}); From db8d2fdacbff3586da3b0e236c1385ffa48d43fc Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 24 Sep 2026 00:21:07 +0200 Subject: [PATCH 025/344] fix(actors): migrate legacy identities and project references atomically --- docs/roadmap.md | 9 +- src/Actors/ActorIdentityIndex.php | 53 +++++++++ src/Actors/ActorIdentityMigration.php | 71 ++++++++--- src/Actors/ActorIdentityMigrationPlan.php | 85 +++++++++++++ src/Actors/ActorReferenceInventory.php | 92 +++++++++++++++ src/Actors/ActorReferenceSource.php | 131 +++++++++++++++++++++ src/Database/PhpSourceDocument.php | 6 + src/ProjectActor.php | 2 +- src/Validation/ActorReferenceValidator.php | 62 ++++++++++ src/Validation/ProjectValidator.php | 1 + tests/Unit/ActorIdentityMigrationTest.php | 100 +++++++++++++++- 11 files changed, 589 insertions(+), 23 deletions(-) create mode 100644 src/Actors/ActorIdentityIndex.php create mode 100644 src/Actors/ActorIdentityMigrationPlan.php create mode 100644 src/Actors/ActorReferenceInventory.php create mode 100644 src/Actors/ActorReferenceSource.php create mode 100644 src/Validation/ActorReferenceValidator.php diff --git a/docs/roadmap.md b/docs/roadmap.md index 3c166ac..eb0e328 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1010,8 +1010,13 @@ the runtime does not resolve them as layers. Legacy actor identity repair is shared by the TUI and CLI through `ActorIdentityMigration`. It offers a one-time freeze of the current name, not -free-text identity retargeting. The TUI records the repair in normal undo history; -CLI migration preflights the whole batch and uses a file-set transaction. +free-text identity retargeting. Read-only project plans include starting-party, +skit, presentation and battle-entry-rule actor-reference repairs, even when +IDs were frozen earlier. Confirmation applies all listed files transactionally; +the reversible plan guards undo/redo against outside changes. Project repair +writes immediately after confirmation, unlike the retained single-actor freeze +which stays deferred until save when no other reference files need changing. +Both TUI and CLI use that plan; unresolved references are validation errors. Existing actor saves now preserve authored source instead of regenerating the file: comments, imports and unchanged expressions survive repair and rename. Unsupported source edits and externally changed files are refused before writes. diff --git a/src/Actors/ActorIdentityIndex.php b/src/Actors/ActorIdentityIndex.php new file mode 100644 index 0000000..62e2b4c --- /dev/null +++ b/src/Actors/ActorIdentityIndex.php @@ -0,0 +1,53 @@ +getActors() as $actor) { + $data = $actor->getData(); + $id = array_key_exists('id', $data) ? $data['id'] : ($data['name'] ?? null); + if (! is_string($id) || trim($id) === '') { + throw new RuntimeException("{$actor->path}: actor identity must be a non-empty string."); + } + $id = trim($id); + $key = strtolower($id); + if (isset($this->ids[$key])) { + throw new RuntimeException("{$actor->path}: actor id \"{$id}\" conflicts with another actor."); + } + $this->ids[$key] = $id; + foreach ([$actor->id, $actor->getName()] as $alias) { + $this->aliases[strtolower(trim($alias))][$id] = true; + } + } + } + + public function resolveReference(mixed $reference, string $where, bool $optionalSpeaker = false): ?string + { + if (! is_string($reference) || trim($reference) === '') { + if ($optionalSpeaker) { return null; } + throw new RuntimeException("{$where}: actor reference must be a non-empty string."); + } + $key = strtolower(trim($reference)); + // An explicit ID is never hijacked by another actor's name or file. + if (isset($this->ids[$key])) { return $this->ids[$key]; } + $ids = array_keys($this->aliases[$key] ?? []); + if (count($ids) > 1) { + throw new RuntimeException("{$where}: ambiguous legacy actor reference \"{$reference}\"."); + } + if ($ids !== []) { return $ids[0]; } + if ($optionalSpeaker) { return null; } + throw new RuntimeException("{$where}: unresolved actor reference \"{$reference}\"."); + } +} diff --git a/src/Actors/ActorIdentityMigration.php b/src/Actors/ActorIdentityMigration.php index 1c46360..9630335 100644 --- a/src/Actors/ActorIdentityMigration.php +++ b/src/Actors/ActorIdentityMigration.php @@ -6,7 +6,8 @@ use Ichiloto\Editor\ProjectActor; use Ichiloto\Editor\ProjectActorDatabase; -use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Editor\ProjectDirectoryContext; +use Ichiloto\Editor\Database\PhpDataFile; use RuntimeException; use Throwable; @@ -47,27 +48,59 @@ public static function freezeCurrentName(ProjectActorDatabase $database, Project $actor->setField('id', $id); } - /** Explicit CLI operation. Preflight the whole batch and install it as one transaction. - * @return list Changed actor file paths. - */ - public static function migrateProject(string $projectRoot): array + /** Read-only plan; callers show changed paths and obtain confirmation before apply. */ + public static function planProject(string $projectRoot): ActorIdentityMigrationPlan + { + return ProjectDirectoryContext::run($projectRoot, self::buildPlan(...)); + } + + private static function buildPlan(string $projectRoot): ActorIdentityMigrationPlan { $database = ProjectActorDatabase::fromProject($projectRoot); - $actors = self::getPendingActors($database); - if ($actors === []) { return []; } - $transaction = new FileSetTransaction($database->directory); - foreach ($actors as $actor) { - self::freezeCurrentName($database, $actor); - $transaction->write($actor->path, $actor->getProposedSource()); + $index = new ActorIdentityIndex($database); + $watched = $originals = $proposals = $before = $after = []; + foreach ($database->getActors() as $actor) { + $watched[$actor->path] = (string) file_get_contents($actor->path); + if (array_key_exists('id', $actor->getData())) { continue; } + $before[$actor->path] = ActorReferenceInventory::getComparableValue(PhpDataFile::evaluateIsolated($actor->path, $projectRoot)); + try { + self::freezeCurrentName($database, $actor); + $proposals[$actor->path] = $actor->getProposedSource(); + } catch (Throwable $failure) { + throw new ($failure::class)("{$actor->path}: {$failure->getMessage()}"); + } + $originals[$actor->path] = $watched[$actor->path]; + $after[$actor->path] = $before[$actor->path]; + $after[$actor->path]['data'] = $actor->getData(); } - try { - $staged = $transaction->stage(); - foreach ($actors as $actor) { $actor->validateStagedSource($staged[$actor->path]); } - $transaction->commit(); - } catch (Throwable $failure) { - $transaction->rollBack(); - throw $failure; + foreach (ActorReferenceInventory::getSourcePaths($projectRoot) as $path) { + $source = (string) file_get_contents($path); + $watched[$path] = $source; + $payload = PhpDataFile::evaluateIsolated($path, $projectRoot); + $relative = substr($path, strlen($projectRoot) + 1); + $changes = []; + foreach (ActorReferenceInventory::getReferences($payload, $relative) as $reference) { + $target = $index->resolveReference($reference['reference'], $path . ':' . implode('.', $reference['path']), $reference['kind'] === 'speaker'); + if ($target !== null && ($target !== $reference['reference'] || $reference['kind'] === 'speaker')) { + $changes[] = [...$reference, 'target' => $target]; + } + } + if ($changes === []) { continue; } + try { + $after[$path] = ActorReferenceSource::getUpdatedValue($payload, $changes); + $proposals[$path] = ActorReferenceSource::rewrite($source, $changes); + } catch (Throwable $failure) { + throw new RuntimeException("{$path}: {$failure->getMessage()}", previous: $failure); + } + $originals[$path] = $source; + $before[$path] = ActorReferenceInventory::getComparableValue($payload); } - return array_map(static fn(ProjectActor $actor): string => $actor->path, $actors); + return new ActorIdentityMigrationPlan($projectRoot, $watched, $originals, $proposals, $before, $after); + } + + /** Explicit confirmed operation. Returns every changed actor and reference file. */ + public static function migrateProject(string $projectRoot): array + { + return self::planProject($projectRoot)->apply(); } } diff --git a/src/Actors/ActorIdentityMigrationPlan.php b/src/Actors/ActorIdentityMigrationPlan.php new file mode 100644 index 0000000..672eee9 --- /dev/null +++ b/src/Actors/ActorIdentityMigrationPlan.php @@ -0,0 +1,85 @@ +proposals); } + public function getOriginalSources(): array { return $this->originals; } + public function getProposedSources(): array { return $this->proposals; } + + public function apply(): array + { + if (! $this->applied) { + $this->installSources($this->proposals, $this->afterValues); + $this->applied = true; + } + return $this->getChangedPaths(); + } + + public function revert(): void + { + if ($this->applied) { + $this->installSources($this->originals, $this->beforeValues); + $this->applied = false; + } + } + + private function installSources(array $sources, array $expectedValues): void + { + $this->assertSourcesUnchanged(); + if ($sources === []) { return; } + $transaction = new FileSetTransaction($this->root . '/assets'); + foreach ($sources as $path => $source) { $transaction->write($path, $source); } + try { + $staged = $transaction->stage(); + $this->assertSourcesUnchanged(); + foreach ($staged as $path => $temporary) { + $value = ActorReferenceInventory::getComparableValue(PhpDataFile::evaluateIsolated($temporary, $this->root)); + if (serialize($value) !== serialize($expectedValues[$path])) { + throw new RuntimeException("{$path} would not read back as the planned actor migration; nothing was written."); + } + } + $transaction->commit(); + } catch (Throwable $failure) { + $transaction->rollBack(); + throw $failure; + } + } + + private function assertSourcesUnchanged(): void + { + foreach ($this->watched as $path => $source) { + $expected = $this->applied ? ($this->proposals[$path] ?? $source) : $source; + if (! is_file($path) || file_get_contents($path) !== $expected) { + throw new RuntimeException("{$path} changed outside the migration; reload and plan again."); + } + } + $current = [...(glob($this->root . '/assets/Data/Actors/*.php') ?: []), ...ActorReferenceInventory::getSourcePaths($this->root)]; + $expected = array_keys($this->watched); + sort($current, SORT_STRING); + sort($expected, SORT_STRING); + if ($current !== $expected) { + throw new RuntimeException('Project actor/reference files changed outside the migration; reload and plan again.'); + } + } +} diff --git a/src/Actors/ActorReferenceInventory.php b/src/Actors/ActorReferenceInventory.php new file mode 100644 index 0000000..65de5db --- /dev/null +++ b/src/Actors/ActorReferenceInventory.php @@ -0,0 +1,92 @@ + */ + public static function getReferences(mixed $payload, string $relativePath): array + { + $payload = self::getComparableValue($payload); + if (! is_array($payload)) { return []; } + $references = []; + if ($relativePath === 'assets/Data/system.php') { + foreach ((array) ($payload['startingParty'] ?? []) as $index => $id) { + $references[] = ['path' => ['startingParty', $index], 'reference' => $id, 'kind' => 'value', 'caseInsensitive' => true]; + } + } + if (str_starts_with($relativePath, 'assets/Data/Presentation/')) { + foreach (['actors', 'portraits'] as $field) { + foreach (array_keys((array) ($payload[$field] ?? [])) as $id) { + $references[] = ['path' => [$field, $id], 'reference' => $id, 'kind' => 'key']; + } + } + foreach (array_keys((array) ($payload['results']['portraits'] ?? [])) as $id) { + $references[] = ['path' => ['results', 'portraits', $id], 'reference' => $id, 'kind' => 'key']; + } + return $references; + } + if (str_starts_with($relativePath, 'assets/Data/Skits/')) { + foreach ((array) ($payload['beats'] ?? []) as $index => $beat) { + if (! is_array($beat)) { continue; } + $key = array_key_exists('actor', $beat) ? 'actor' : 'speaker'; + if (array_key_exists($key, $beat)) { + $references[] = ['path' => ['beats', $index, $key], 'reference' => $beat[$key], 'kind' => $key === 'actor' ? 'value' : 'speaker']; + } + } + } + if (str_starts_with($relativePath, 'assets/Data/battle-entry-rules.php')) { + $rules = isset($payload['rules']) ? $payload['rules'] : [$payload]; + foreach ((array) $rules as $index => $rule) { + if (! is_array($rule)) { continue; } + $prefix = isset($payload['rules']) ? ['rules', $index] : []; + foreach (['actors', 'effects'] as $field) { + foreach ((array) ($rule[$field] ?? []) as $entryIndex => $entry) { + if (is_array($entry) && array_key_exists('actor', $entry)) { + $references[] = ['path' => [...$prefix, $field, $entryIndex, 'actor'], 'reference' => $entry['actor'], 'kind' => 'value', 'caseInsensitive' => true]; + } + } + } + } + } + // Troops use enemy IDs; event actorId/actor target staged subjects. + // Display names and summon wielder names are not this identity contract. + return $references; + } + + /** Strip object identity, not authored values, for isolated readback comparisons. */ + public static function getComparableValue(mixed $value): mixed + { + if (is_object($value)) { + $fields = get_object_vars($value); + $class = $fields['__PHP_Incomplete_Class_Name'] ?? $value::class; + unset($fields['__PHP_Incomplete_Class_Name']); + return ['__class' => $class, ...array_map(self::getComparableValue(...), $fields)]; + } + return is_array($value) ? array_map(self::getComparableValue(...), $value) : $value; + } + + /** Only runtime content locations, never saves, unrelated PHP or map glyph layers. */ + public static function getSourcePaths(string $root): array + { + $paths = []; + foreach (['assets/Data/system.php', 'assets/Data/battle-entry-rules.php', 'assets/Data/troops.php', + 'assets/Data/Presentation/battle.php', 'assets/Data/Presentation/dialogue.php', 'assets/Data/Presentation/menus.php'] as $relative) { + if (is_file($root . '/' . $relative)) { $paths[] = $root . '/' . $relative; } + } + foreach (['assets/Data/Skits', 'assets/Events', 'assets/Maps'] as $directory) { + if (! is_dir($root . '/' . $directory)) { continue; } + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root . '/' . $directory, \FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + if ($file->isFile() && str_ends_with($file->getFilename(), $directory === 'assets/Maps' ? '.data.php' : '.php')) { + $paths[] = $file->getPathname(); + } + } + } + sort($paths, SORT_STRING); + return $paths; + } +} diff --git a/src/Actors/ActorReferenceSource.php b/src/Actors/ActorReferenceSource.php new file mode 100644 index 0000000..30d2fc3 --- /dev/null +++ b/src/Actors/ActorReferenceSource.php @@ -0,0 +1,131 @@ +entryAt($path); + if ($entry === null || $entry->keyIsOpaque) { + throw new SourcePreservationRefusal('Actor reference at ' . implode('.', $path) . ' is not an editable literal.'); + } + if ($change['kind'] === 'key' || $change['kind'] === 'speaker') { + $tokens = \PhpToken::tokenize('start, $entry->value->start - $entry->start)); + $keyToken = null; + foreach ($tokens as $token) { + if ($token->is([T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { continue; } + $keyToken = $token; + break; + } + if ($keyToken === null || ! $keyToken->is(T_CONSTANT_ENCAPSED_STRING)) { + throw new SourcePreservationRefusal('Actor reference key is not a string literal.'); + } + $start = $entry->start + $keyToken->pos - strlen('text), var_export($change['kind'] === 'speaker' ? 'actor' : $change['target'], true)]; + } + if ($change['kind'] !== 'key') { + if ($entry->value->kind !== SourceNode::SCALAR) { + throw new SourcePreservationRefusal('Actor reference at ' . implode('.', $path) . ' is an expression or variable; refusing to flatten it.'); + } + $edits[] = [$entry->value->start, $entry->value->end, var_export($change['target'], true)]; + } + } + return $document->withEdits($edits)->source; + } + + /** Presentation constructors retain imports, comments, argument order and unrelated expressions. */ + private static function rewriteReturnedConstructor(string $source, array $changes): string + { + $depth = 0; + $start = $end = null; + foreach (\PhpToken::tokenize($source) as $token) { + if ($token->is(T_RETURN) && $depth === 0) { + $start = $token->pos + strlen($token->text); + continue; + } + if ($token->text === ';' && $depth === 0 && $start !== null) { $end = $token->pos; break; } + if (in_array($token->text, ['(', '[', '{'], true)) { $depth++; } + if (in_array($token->text, [')', ']', '}'], true)) { $depth--; } + } + if ($start === null || $end === null) { throw new SourceUnreadable('Cannot locate the returned presentation constructor.'); } + $expression = substr($source, $start, $end - $start); + $rewritten = self::rewriteConstructor($expression, $changes); + // Preserve whitespace/comments surrounding the constructor itself. + $original = PhpSourceDocument::parse('entrySource(0); + if ($original === null) { throw new SourceUnreadable('Presentation must return a supported constructor literal.'); } + $offset = strpos($expression, $original); + return substr($source, 0, $start) . substr_replace($expression, $rewritten, $offset, strlen($original)) . substr($source, $end); + } + + private static function rewriteConstructor(string $expression, array $changes): string + { + $document = PhpSourceDocument::parse('entryClasses()[0] ?? '')); + $parameters = match ($class) { + 'DialoguePresentationCatalog' => ['actors'], + 'BattlePresentationCatalog' => ['arenas', 'actors', 'enemies', 'ui', 'results', 'pause'], + 'BattleResultsSkin' => ['textures', 'colors', 'portraits', 'icons', 'assetRoot'], + 'MenuPresentationCatalog' => ['assetRoot', 'data'], + default => throw new SourcePreservationRefusal('Unsupported presentation constructor; refusing to guess its arguments.'), + }; + $groups = []; + foreach ($changes as $change) { + if ($class === 'MenuPresentationCatalog') { array_unshift($change['path'], 'data'); } + $argument = array_shift($change['path']); + $groups[$argument][] = $change; + } + foreach ($groups as $argument => $edits) { + $literal = $document->getConstructorArgumentSource(0, $argument, $parameters); + if ($literal === null) { throw new SourcePreservationRefusal('Missing actor reference argument ' . $argument . '.'); } + if ($argument === 'results') { + $updated = self::rewriteConstructor($literal, $edits); + } else { + $wrapper = 'getWithConstructorArgument(0, $argument, $updated, $parameters); + } + return $document->entrySource(0) ?? throw new SourcePreservationRefusal('Cannot preserve the presentation constructor.'); + } + + /** Expected value after the same bounded reference edits, for staged readback. */ + public static function getUpdatedValue(mixed $payload, array $changes): mixed + { + $value = ActorReferenceInventory::getComparableValue($payload); + foreach ($changes as $change) { + $path = $change['path']; + $key = array_pop($path); + $parent =& $value; + foreach ($path as $part) { $parent =& $parent[$part]; } + if ($change['kind'] === 'key' || $change['kind'] === 'speaker') { + $newKey = $change['kind'] === 'speaker' ? 'actor' : $change['target']; + if ($newKey !== $key && array_key_exists($newKey, $parent)) { + throw new SourcePreservationRefusal('Actor reference migration would overwrite key ' . $newKey . '.'); + } + $copy = []; + foreach ($parent as $oldKey => $oldValue) { + $copy[$oldKey === $key ? $newKey : $oldKey] = $oldKey === $key && $change['kind'] === 'speaker' ? $change['target'] : $oldValue; + } + $parent = $copy; + } else { $parent[$key] = $change['target']; } + unset($parent); + } + return $value; + } +} diff --git a/src/Database/PhpSourceDocument.php b/src/Database/PhpSourceDocument.php index f912611..87eedbb 100644 --- a/src/Database/PhpSourceDocument.php +++ b/src/Database/PhpSourceDocument.php @@ -198,6 +198,12 @@ public function getWithConstructorArgument(int $entryIndex, string $name, string return $this->getMappedConstructor($entryIndex, $name, $parameters)->withArgument($entryIndex, $name, $literal); } + /** Reads a named or positional argument using a known constructor signature. @param list $parameters */ + public function getConstructorArgumentSource(int $entryIndex, string $name, array $parameters): ?string + { + return $this->getMappedConstructor($entryIndex, $name, $parameters)->argumentSource($entryIndex, $name); + } + /** Removes an optional argument without shifting any later positional arguments. @param list $parameters */ public function getWithoutConstructorArgument(int $entryIndex, string $name, array $parameters): self { diff --git a/src/ProjectActor.php b/src/ProjectActor.php index 7a510d6..43fc046 100644 --- a/src/ProjectActor.php +++ b/src/ProjectActor.php @@ -668,7 +668,7 @@ public function getProposedSource(?array $data = null): string if ($this->originalSource !== null) { $document = PhpArraySourceDocument::parse($this->originalSource); if ($current !== $this->originalPayload && $document->nodeAt(['data'])?->kind !== SourceNode::ARRAY) { - throw new SourcePreservationRefusal('Actor data is not an editable array literal; refusing to flatten its source.'); + throw new SourcePreservationRefusal("{$this->path}: Actor data is not an editable array literal; refusing to flatten its source."); } return ArraySourceWriter::rewrite($document, $this->originalPayload, $current)->source; } diff --git a/src/Validation/ActorReferenceValidator.php b/src/Validation/ActorReferenceValidator.php new file mode 100644 index 0000000..3948a22 --- /dev/null +++ b/src/Validation/ActorReferenceValidator.php @@ -0,0 +1,62 @@ +actorDatabase->getActors() as $actor) { + if ($actor->hasDefinitionId()) { $ids[] = $actor->getDefinitionId(); } + } + $payloads = ['assets/Data/system.php' => ['startingParty' => $workspace->systemDatabase->getField('startingParty') ?? []]]; + foreach (['skits', 'troops', 'common_events', 'battle_entry_rules'] as $category) { + $database = $workspace->getRecordDatabase($category); + if ($database === null) { continue; } + foreach ($database->getRecords() as $index => $record) { + $path = $category === 'skits' ? 'assets/Data/Skits/' . $index . '.php' : $database->schema->relativePath . ':' . $index; + $payloads[$path] = $record->toArray(); + } + } + foreach ($workspace->maps as $map) { + $payloads[$map->dataPath] = $map->getEditableData(); + } + $issues = []; + foreach (['battle', 'dialogue', 'menus'] as $name) { + $relative = 'assets/Data/Presentation/' . $name . '.php'; + $path = $workspace->projectRoot . '/' . $relative; + if (! is_file($path)) { continue; } + try { $payloads[$relative] = PhpDataFile::evaluateIsolated($path, $workspace->projectRoot); } + catch (Throwable $failure) { + $issues[] = Issue::error($relative, 'Actor presentation references could not be checked: ' . $failure->getMessage()); + } + } + foreach ($payloads as $where => $payload) { + foreach (ActorReferenceInventory::getReferences($payload, $where) as $reference) { + // Legacy speaker is display text, not an explicit actor reference. + if ($reference['kind'] === 'speaker') { continue; } + $value = $reference['reference']; + $known = $ids; + if (is_string($value) && ($reference['caseInsensitive'] ?? false)) { + $value = strtolower(trim($value)); + $known = array_map(static fn(string $id): string => strtolower(trim($id)), $ids); + } + if (! is_string($value) || ! in_array($value, $known, true)) { + $issues[] = Issue::error($where . ':' . implode('.', $reference['path']), + 'Unresolved actor reference ' . var_export($reference['reference'], true) . '; use an explicit stable actor id.', + 'Review and confirm the project actor identity migration, or select an existing actor.'); + } + } + } + return $issues; + } +} diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index fcb07da..2804005 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -122,6 +122,7 @@ public function validate(ProjectWorkspace $workspace): array ...new AnimationReferenceValidator()->validate($workspace), ...$this->checkDefinitionIdentities($workspace), ...$this->checkActorDefinitions($workspace), + ...new ActorReferenceValidator()->validate($workspace), ...$this->checkKnowledgeCatalog($workspace), ...$this->checkSpecialProperties($workspace), ...$this->checkPermanentGrowth($workspace), diff --git a/tests/Unit/ActorIdentityMigrationTest.php b/tests/Unit/ActorIdentityMigrationTest.php index 48348e0..bf9e857 100644 --- a/tests/Unit/ActorIdentityMigrationTest.php +++ b/tests/Unit/ActorIdentityMigrationTest.php @@ -35,7 +35,7 @@ function createLegacyActorProject(): array $database = ProjectActorDatabase::fromProject($root); expect(ActorIdentityMigration::getPendingActors($database))->toHaveCount(1) ->and(sourceHashTree($root))->toBe($before); - expect(ActorIdentityMigration::migrateProject($root))->toBe([$path]); + expect(ActorIdentityMigration::migrateProject($root))->toBe([$path, $root . '/assets/Data/Skits/breakfast-banter.php']); $written = file_get_contents($path); expect($written)->toContain("'id' => 'Kaelion'", '// Keep this authored header.', '// Keep this name comment.', "'level' => 1 + 2", "'description' => \$profile") ->and(ActorIdentityMigration::migrateProject($root))->toBe([]); @@ -132,3 +132,101 @@ function createLegacyActorProject(): array expect(fn() => $database->save())->toThrow(RuntimeException::class, 'changed outside'); expect(file_get_contents($path))->toBe($before . "\n// Author's later edit\n"); }); + +it('plans every actor reference without writes and atomically applies undoes and redoes exact source', function () { + [$root, $actorPath] = createLegacyActorProject(); + // Already-frozen IDs still need repair of the original name and file references. + file_put_contents($actorPath, " ['id' => 'hero', 'name' => 'Kaelion']];"); + $sources = [ + 'assets/Data/system.php' => " [/* member */ 'Kaelion'], 'note' => 'Kaelion'];", + 'assets/Data/Skits/old.php' => " [['actor' => 'Kaelion', 'text' => 'Kaelion'], ['speaker' => 'Kaelion', 'text' => 'Hello'], ['speaker' => 'Stranger', 'text' => 'Kaelion']]];", + 'assets/Data/battle-entry-rules.php' => " [['actors' => [['actor' => 'Kaelion']], 'effects' => [['type' => 'stat_stage', 'actor' => 'Kaelion']]]]];", + 'assets/Events/actor-reference.php' => " 'sequence', 'commands' => [['type' => 'stage_actor', 'actor' => ['id' => 'Kaelion']]]], ['type' => 'show_actor', 'actorId' => 'Kaelion'], ['type' => 'text', 'name' => 'Kaelion', 'text' => 'Hello']];", + 'assets/Data/troops.php' => " 'Kaelion', 'enemies' => [['enemy' => 'Kaelion']], 'events' => [['type' => 'text', 'name' => 'Kaelion', 'text' => 'Hello']]]];", + 'assets/Data/Presentation/dialogue.php' => " ['emotions' => ['Neutral' => 'Kaelion.png']]]);", + 'assets/Data/Presentation/battle.php' => " new BattlerArtwork('hero.png', 1, 1, 0, 0)], []);", + 'assets/Data/Presentation/menus.php' => " [/* keep */ 'Kaelion' => 'Kaelion.png']];", + ]; + foreach ($sources as $relative => $source) { + if (! is_dir(dirname($root . '/' . $relative))) { mkdir(dirname($root . '/' . $relative), 0777, true); } + file_put_contents($root . '/' . $relative, $source); + } + $before = sourceHashTree($root); + $plan = ActorIdentityMigration::planProject($root); + expect($plan->getChangedPaths())->not->toContain($actorPath)->and(sourceHashTree($root))->toBe($before); + foreach (array_keys($sources) as $relative) { + if (in_array($relative, ['assets/Events/actor-reference.php', 'assets/Data/troops.php'], true)) { + expect($plan->getChangedPaths())->not->toContain($root . '/' . $relative); + } else { expect($plan->getChangedPaths())->toContain($root . '/' . $relative); } + } + $plan->apply(); + $after = sourceHashTree($root); + expect(file_get_contents($root . '/assets/Data/system.php'))->toBe(str_replace("/* member */ 'Kaelion'", "/* member */ 'hero'", $sources['assets/Data/system.php'])) + ->and(file_get_contents($root . '/assets/Events/actor-reference.php'))->toBe($sources['assets/Events/actor-reference.php']) + ->and(file_get_contents($root . '/assets/Data/troops.php'))->toBe($sources['assets/Data/troops.php']) + ->and(file_get_contents($root . '/assets/Events/actor-reference.php'))->toContain("'actorId' => 'Kaelion'", "'id' => 'Kaelion'", "'name' => 'Kaelion'") + ->and(file_get_contents($root . '/assets/Data/Presentation/dialogue.php'))->toContain("/* portrait */ 'hero'", "'Neutral' => 'Kaelion.png'") + ->and(ActorIdentityMigration::planProject($root)->getChangedPaths())->toBe([]); + $plan->revert(); + expect(sourceHashTree($root))->toBe($before); + $plan->apply(); + expect(sourceHashTree($root))->toBe($after); + file_put_contents($root . '/assets/Data/system.php', $sources['assets/Data/system.php'] . '// outside edit'); + $outside = sourceHashTree($root); + expect(fn() => $plan->revert())->toThrow(RuntimeException::class, 'changed outside'); + expect(sourceHashTree($root))->toBe($outside); +}); + +it('refuses ambiguous references and key collisions before modifying any file but honours explicit ids first', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " ['id' => 'first', 'name' => 'Shared']];"); + file_put_contents(dirname($path) . '/Other.php', " ['id' => 'second', 'name' => 'Shared']];"); + $system = $root . '/assets/Data/system.php'; + file_put_contents($system, " ['Shared']];"); + $before = sourceHashTree($root); + expect(fn() => ActorIdentityMigration::planProject($root))->toThrow(RuntimeException::class, 'ambiguous'); + expect(sourceHashTree($root))->toBe($before); + file_put_contents(dirname($path) . '/Shared.php', " ['id' => 'Shared', 'name' => 'Modern']];"); + expect(ActorIdentityMigration::planProject($root)->getChangedPaths())->not->toContain($system); + mkdir($root . '/assets/Data/Presentation'); + file_put_contents($root . '/assets/Data/Presentation/menus.php', " ['Kaelion' => 'old.png', 'first' => 'new.png']];"); + $before = sourceHashTree($root); + expect(fn() => ActorIdentityMigration::planProject($root))->toThrow(RuntimeException::class, 'overwrite key'); + expect(sourceHashTree($root))->toBe($before); +}); + +it('refuses dynamic reference expressions with file-qualified diagnostics and no partial actor migration', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " ['name' => 'Full Name']];"); + $system = $root . '/assets/Data/system.php'; + file_put_contents($system, " [\$member]];"); + $before = sourceHashTree($root); + expect(fn() => ActorIdentityMigration::planProject($root))->toThrow(RuntimeException::class, 'system.php:') + ->and(sourceHashTree($root))->toBe($before); +}); + +it('validates unresolved starting party and all explicit actor references against stable ids', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " ['id' => 'hero', 'name' => 'Kaelion']];"); + file_put_contents($root . '/assets/Data/system.php', " ['Kaelion']];"); + $workspace = \Ichiloto\Editor\ProjectWorkspace::fromProject($root); + $issues = new \Ichiloto\Editor\Validation\ProjectValidator()->validate($workspace); + expect(array_filter($issues, static fn($issue) => str_contains($issue->where, 'startingParty') && str_contains($issue->message, 'Unresolved actor')))->not->toBeEmpty(); + ActorIdentityMigration::migrateProject($root); + $issues = new \Ichiloto\Editor\Validation\ActorReferenceValidator()->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)); + expect($issues)->toBe([]); +}); + +it('uses each consumer case semantics without restoring display name aliases', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " ['id' => 'HERO', 'name' => 'Kaelion']];"); + file_put_contents($root . '/assets/Data/system.php', " [' hero ']];"); + $validator = new \Ichiloto\Editor\Validation\ActorReferenceValidator(); + expect($validator->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)))->toBe([]); + file_put_contents($root . '/assets/Data/Skits/case.php', " [['actor' => 'hero', 'text' => 'Hello']]];"); + $issues = $validator->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)); + expect($issues)->toHaveCount(1)->and($issues[0]->where)->toContain('beats.0.actor'); + file_put_contents($root . '/assets/Data/system.php', " ['Kaelion']];"); + $issues = $validator->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)); + expect($issues)->toHaveCount(2); +}); From 8bf6e63664bf8f135ee4fb548928d4bd4773187d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 24 Sep 2026 00:25:12 +0200 Subject: [PATCH 026/344] fix(editor): confirm and undo project-wide actor reference repair Remove deferred-save behavior only for confirmed multi-file actor repair: the preview explicitly writes the complete transaction now. Retain deferred actor-only ID repair and preserve unrelated edits and earlier undo history. Refused undo or redo keeps its command available for retry. --- src/Actors/ActorIdentityMigrationCommand.php | 62 +++++++++++ src/Editor.php | 92 +++++++++++++++- src/History/CommandHistory.php | 6 +- tests/Unit/ActorIdentityMigrationUiTest.php | 104 +++++++++++++++++++ tests/Unit/CommandHistoryTest.php | 22 ++++ 5 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 src/Actors/ActorIdentityMigrationCommand.php diff --git a/src/Actors/ActorIdentityMigrationCommand.php b/src/Actors/ActorIdentityMigrationCommand.php new file mode 100644 index 0000000..d2c8716 --- /dev/null +++ b/src/Actors/ActorIdentityMigrationCommand.php @@ -0,0 +1,62 @@ +label = 'Migrate actor identities and references'; + } + + public function execute(): void + { + $this->assertWorkspace($this->before); + $this->plan->apply(); + try { + $this->after ??= ProjectWorkspace::fromProject($this->before->projectRoot); + } catch (Throwable $failure) { + $this->plan->revert(); + throw $failure; + } + ($this->replaceWorkspace)($this->after); + } + + public function undo(): void + { + $this->assertWorkspace($this->after); + $this->plan->revert(); + ($this->replaceWorkspace)($this->before); + } + + private function assertWorkspace(?ProjectWorkspace $expected): void + { + $current = ($this->getWorkspace)(); + if ($current === null || $current !== $expected) { + throw new RuntimeException('The workspace changed since this actor migration. Reload before migrating again.'); + } + if ($current->hasUnsavedChanges()) { + throw new RuntimeException('Save or undo pending edits before changing the actor migration. No files were changed.'); + } + } +} diff --git a/src/Editor.php b/src/Editor.php index 681d37f..8ffa84a 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -11,6 +11,8 @@ use Atatusoft\Termutil\IO\Mouse\Enumerations\MouseButton; use Ichiloto\Editor\Backup\BackupSettings; use Ichiloto\Editor\Actors\ActorIdentityMigration; +use Ichiloto\Editor\Actors\ActorIdentityMigrationCommand; +use Ichiloto\Editor\Actors\ActorIdentityMigrationPlan; use Ichiloto\Editor\Backup\BackupWriter; use Ichiloto\Editor\Canvas\CanvasTool; use Ichiloto\Editor\Canvas\Clipboard; @@ -1081,7 +1083,12 @@ private function recordCommand(Command $command): void private function performUndo(): void { $this->finalizeActiveStroke(); - $command = $this->history->undo(); + try { + $command = $this->history->undo(); + } catch (Throwable $failure) { + $this->setErrorStatus($failure, 'Undo'); + return; + } if (! $command instanceof Command) { $this->setStatus('Nothing to undo.'); @@ -1103,7 +1110,12 @@ private function performUndo(): void */ private function performRedo(): void { - $command = $this->history->redo(); + try { + $command = $this->history->redo(); + } catch (Throwable $failure) { + $this->setErrorStatus($failure, 'Redo'); + return; + } if (! $command instanceof Command) { $this->setStatus('Nothing to redo.'); @@ -5773,6 +5785,7 @@ private function buildPaletteItems(): array new PaletteItem('Redo', 'Ctrl+Y', fn() => $this->performRedo()), new PaletteItem('Playtest Selected Map', 'Ctrl+T', fn() => $this->startPlaytest()), new PaletteItem('Reload Workspace', 'Ctrl+R', fn() => $this->requestReload()), + new PaletteItem('Repair Actor Identities and References', '', fn() => $this->openActorReferenceMigration()), new PaletteItem('Tool: Map Mode', 'm', function (): void { $this->closeDatabaseIfOpen(); $this->setEditingMode(self::MODE_MAP); @@ -7766,6 +7779,12 @@ private function applySelectedEventOption(): void $field = $this->optionDialogField; $title = $this->eventOptionDialogTitle; + if (($field['actorReferenceMigration'] ?? null) instanceof ActorIdentityMigrationPlan) { + if ($selectedEntry['value'] === 'preview') { return; } + $this->confirmActorReferenceMigration($field['actorReferenceMigration'], $selectedEntry['value'] === 'migrate'); + return; + } + if (($field['actorIdentityMigration'] ?? null) instanceof ProjectActor) { $this->confirmActorIdentityMigration($field['actorIdentityMigration'], $selectedEntry['value'] === 'freeze'); return; @@ -11060,6 +11079,16 @@ private function beginDatabaseEdit(): void private function openActorIdentityMigration(ProjectActor $actor): void { + try { + $plan = ActorIdentityMigration::planProject($this->projectRoot); + if ($plan->getChangedPaths() !== [$actor->path]) { + $this->openActorReferenceMigration($plan); + return; + } + } catch (Throwable $failure) { + $this->setErrorStatus($failure, 'Actor identity migration'); + return; + } $this->optionDialogField = ['actorIdentityMigration' => $actor]; $this->eventOptionDialogMarker = null; $this->eventOptionDialogPath = null; @@ -11099,6 +11128,65 @@ private function confirmActorIdentityMigration(ProjectActor $actor, bool $confir $this->renderDatabasePanes(['list', 'settings']); } + private function openActorReferenceMigration(?ActorIdentityMigrationPlan $plan = null): void + { + if (! $this->workspace instanceof ProjectWorkspace) { return; } + if ($this->workspace->hasUnsavedChanges()) { + $this->setStatus('Save or undo pending edits before repairing actor references. No files were changed.', StatusLevel::WARN); + return; + } + try { + $plan ??= ActorIdentityMigration::planProject($this->projectRoot); + $paths = $plan->getChangedPaths(); + if ($paths === []) { + $this->setStatus('Actor identities and references need no repair.'); + return; + } + $relativePaths = array_map(fn(string $path): string => substr($path, strlen($this->projectRoot) + 1), $paths); + $this->optionDialogField = ['actorReferenceMigration' => $plan]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = 'Repair actor identities and references'; + $this->eventOptionDialogEntries = [ + ['label' => 'Cancel', 'value' => 'cancel', 'description' => 'Leave all files unchanged.'], + ['label' => sprintf('Write %d repaired files now', count($paths)), 'value' => 'migrate', + 'description' => 'Writes now, not on Save. Ctrl+Z restores files.'], + ...array_map(static fn(string $path): array => [ + 'label' => $path, 'value' => 'preview', + 'description' => 'Repair preview only. Select Write to apply all files.', + ], $relativePaths), + ]; + $this->selectedEventOptionIndex = 0; + $this->dialogFilter->clear(); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = 'Confirm source-preserving actor reference repair.'; + $this->renderSelectionDependentArea(); + } catch (Throwable $failure) { + $this->setErrorStatus($failure, 'Actor reference migration'); + } + } + + private function confirmActorReferenceMigration(ActorIdentityMigrationPlan $plan, bool $confirmed): void + { + if (! $confirmed || ! $this->workspace instanceof ProjectWorkspace) { + $this->closeEventOptionDialog('Actor reference migration cancelled.'); + return; + } + try { + $command = new ActorIdentityMigrationCommand($plan, $this->workspace, + fn(): ?ProjectWorkspace => $this->workspace, + function (ProjectWorkspace $workspace): void { $this->workspace = $workspace; }, + ); + $command->execute(); + $this->recordCommand($command); + $this->closeEventOptionDialog('Actor identities and references repaired. Ctrl+Z restores the original files.'); + $this->requestFullRender(); + } catch (Throwable $failure) { + $this->closeEventOptionDialog(''); + $this->setErrorStatus($failure, 'Actor reference migration'); + } + } + /** * Handles inline database-field editing. * diff --git a/src/History/CommandHistory.php b/src/History/CommandHistory.php index 9a5f040..836903a 100644 --- a/src/History/CommandHistory.php +++ b/src/History/CommandHistory.php @@ -53,13 +53,14 @@ public function record(Command $command): void */ public function undo(): ?Command { - $command = array_pop($this->undoStack); + $command = end($this->undoStack); if (! $command instanceof Command) { return null; } $command->undo(); + array_pop($this->undoStack); $this->redoStack[] = $command; return $command; @@ -72,13 +73,14 @@ public function undo(): ?Command */ public function redo(): ?Command { - $command = array_pop($this->redoStack); + $command = end($this->redoStack); if (! $command instanceof Command) { return null; } $command->execute(); + array_pop($this->redoStack); $this->undoStack[] = $command; return $command; diff --git a/tests/Unit/ActorIdentityMigrationUiTest.php b/tests/Unit/ActorIdentityMigrationUiTest.php index 43ae871..b7ae9af 100644 --- a/tests/Unit/ActorIdentityMigrationUiTest.php +++ b/tests/Unit/ActorIdentityMigrationUiTest.php @@ -8,6 +8,9 @@ $source = (string) file_get_contents($path); $source = preg_replace("/^[^\S\r\n]*'id' => 'Kaelion',\R/m", '', $source); file_put_contents($path, $source); + // This case has no legacy references to repair, so the single-file edit stays deferred. + $skit = $root . '/assets/Data/Skits/breakfast-banter.php'; + file_put_contents($skit, str_replace("'speaker' => 'Kaelion'", "'speaker' => 'Narrator'", (string) file_get_contents($skit))); expect((require $path)['data'])->not->toHaveKey('id'); $editor = deletionEditor($root); openDatabaseCategory($editor, 'actors'); @@ -40,6 +43,107 @@ expect((require $path)['data']['id'])->toBe('Kaelion'); }); +it('repairs actor references through the palette only after confirmation and preserves migration undo redo', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + file_put_contents($path, str_replace("'id' => 'Kaelion'", "'id' => 'Aria Vale'", (string) file_get_contents($path))); + $system = $root . '/assets/Data/system.php'; + file_put_contents($system, " ['Kaelion']];\n"); + $originalActor = file_get_contents($path); + $originalSystem = file_get_contents($system); + $editor = deletionEditor($root); + $openRepair = static function () use ($editor): void { + $items = callEditorMethod($editor, 'buildPaletteItems'); + $item = array_values(array_filter($items, static fn($item) => $item->label === 'Repair Actor Identities and References'))[0]; + ($item->action)(); + }; + $openRepair(); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue() + ->and(file_get_contents($system))->toBe($originalSystem); + $entries = getEditorProperty($editor, 'eventOptionDialogEntries'); + expect($entries[1]['description'])->toContain('Writes now, not on Save') + ->and(array_column($entries, 'label'))->toContain('assets/Data/system.php'); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue() + ->and(file_get_contents($system))->toBe($originalSystem); + callEditorMethod($editor, 'dispatchInput', "\033[A"); + callEditorMethod($editor, 'dispatchInput', "\033[A"); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(file_get_contents($system))->toBe($originalSystem); + + $openRepair(); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect((require $system)['startingParty'])->toBe(['Aria Vale']) + ->and(file_get_contents($path))->toBe($originalActor); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect(file_get_contents($system))->toBe($originalSystem); + callEditorMethod($editor, 'dispatchInput', "\x19"); + expect((require $system)['startingParty'])->toBe(['Aria Vale']); + + $migratedSystem = file_get_contents($system); + file_put_contents($system, $migratedSystem . "// External author edit.\n"); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect(getEditorProperty($editor, 'statusMessage'))->toContain('changed outside') + ->and(getEditorProperty($editor, 'history')->canUndo())->toBeTrue() + ->and(file_get_contents($system))->toBe($migratedSystem . "// External author edit.\n"); + file_put_contents($system, $migratedSystem); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect(file_get_contents($system))->toBe($originalSystem); +}); + +it('retains earlier saved editor commands across actor migration undo and redo', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + file_put_contents($path, str_replace("'id' => 'Kaelion'", "'id' => 'Aria Vale'", (string) file_get_contents($path))); + $system = $root . '/assets/Data/system.php'; + file_put_contents($system, " ['Kaelion']];\n"); + $editor = deletionEditor($root); + $before = getEditorProperty($editor, 'workspace'); + $actor = $before->actorDatabase->getActors()[0]; + $rename = new \Ichiloto\Editor\History\GenericCommand('Rename actor', + static fn() => $actor->setField('name', 'Current Display Name'), + static fn() => $actor->setField('name', 'Kaelion'), + ); + $rename->execute(); + callEditorMethod($editor, 'recordCommand', $rename); + $actor->save(); + callEditorMethod($editor, 'openActorReferenceMigration'); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\r"); + $after = getEditorProperty($editor, 'workspace'); + expect($after)->not->toBe($before) + ->and((require $system)['startingParty'])->toBe(['Aria Vale']); + + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect(getEditorProperty($editor, 'workspace'))->toBe($before); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($actor->getName())->toBe('Kaelion'); + $actor->save(); + callEditorMethod($editor, 'dispatchInput', "\x19"); + expect($actor->getName())->toBe('Current Display Name'); + $actor->save(); + callEditorMethod($editor, 'dispatchInput', "\x19"); + expect(getEditorProperty($editor, 'workspace'))->toBe($after) + ->and((require $system)['startingParty'])->toBe(['Aria Vale']); +}); + +it('refuses project actor migration while unrelated edits are pending without saving or discarding them', function (): void { + $root = makeTemporaryProject(); + $editor = deletionEditor($root); + $workspace = getEditorProperty($editor, 'workspace'); + $database = $workspace->skillDatabase; + $database->addSkill('Pending skill'); + $database->setField(0, 'cost', 123); + callEditorMethod($editor, 'openActorReferenceMigration'); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('pending edits') + ->and($database->isDirty())->toBeTrue() + ->and(is_file($database->path))->toBeFalse(); +}); + it('explains malformed explicit ids without offering a migration or opening input', function (): void { $root = makeTemporaryProject(); $path = $root . '/assets/Data/Actors/Kaelion.php'; diff --git a/tests/Unit/CommandHistoryTest.php b/tests/Unit/CommandHistoryTest.php index ff1dbd0..6493b1f 100644 --- a/tests/Unit/CommandHistoryTest.php +++ b/tests/Unit/CommandHistoryTest.php @@ -61,6 +61,28 @@ function strokeTestMap(): ProjectMap ->and($history->canRedo())->toBeFalse(); }); +it('retains failed commands for a safe retry without losing undo or redo history', function () { + $history = new CommandHistory(); + $refuse = new ArrayObject(['undo' => true, 'redo' => true]); + $command = new GenericCommand('Guarded source transaction', + static function () use ($refuse): void { + if ($refuse['redo']) { throw new RuntimeException('Changed source'); } + }, + static function () use ($refuse): void { + if ($refuse['undo']) { throw new RuntimeException('Changed source'); } + }, + ); + $history->record($command); + expect(fn() => $history->undo())->toThrow(RuntimeException::class, 'Changed source'); + expect($history->count())->toBe(1)->and($history->canRedo())->toBeFalse(); + $refuse['undo'] = false; + expect($history->undo())->toBe($command); + expect(fn() => $history->redo())->toThrow(RuntimeException::class, 'Changed source'); + expect($history->canUndo())->toBeFalse()->and($history->canRedo())->toBeTrue(); + $refuse['redo'] = false; + expect($history->redo())->toBe($command)->and($history->count())->toBe(1); +}); + it('clears the redo stack when a new command is recorded', function () { $state = new ArrayObject(['value' => 0]); $history = new CommandHistory(); From 0273f686cf4e8e827a50e90656424f15bd6598b3 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 24 Sep 2026 02:23:53 +0200 Subject: [PATCH 027/344] fix(actors): preserve diagnostic paths and migration causes Use actual skit filenames and a stable unresolved-reference issue code. Wrap migration failures with their original cause instead of reconstructing exception classes or duplicating paths. Keep strict Editor validation and update parity coverage for removal of Engine startup-wide actor-rule failures; invalid rules remain disabled. --- docs/roadmap.md | 4 +++ src/Actors/ActorIdentityMigration.php | 11 ++++-- src/Validation/ActorReferenceValidator.php | 12 +++++-- src/Validation/Issue.php | 7 ++-- tests/Support/battle-entry-parity-runner.php | 3 +- tests/Unit/ActorIdentityMigrationTest.php | 36 ++++++++++++++++++-- tests/Unit/BattleEntryParityTest.php | 23 +++++++++++-- 7 files changed, 83 insertions(+), 13 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index eb0e328..9204060 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1017,6 +1017,10 @@ the reversible plan guards undo/redo against outside changes. Project repair writes immediately after confirmation, unlike the retained single-actor freeze which stays deferred until save when no other reference files need changing. Both TUI and CLI use that plan; unresolved references are validation errors. +Actor-reference issues carry the shared `UNRESOLVED_ACTOR_REFERENCE` code so +callers need not match diagnostic prose. Skit diagnostics identify the actual +source filename, not its sorted record index. Migration failures retain their +original exception as the cause and add file context only once. Existing actor saves now preserve authored source instead of regenerating the file: comments, imports and unchanged expressions survive repair and rename. Unsupported source edits and externally changed files are refused before writes. diff --git a/src/Actors/ActorIdentityMigration.php b/src/Actors/ActorIdentityMigration.php index 9630335..7990a4b 100644 --- a/src/Actors/ActorIdentityMigration.php +++ b/src/Actors/ActorIdentityMigration.php @@ -67,7 +67,7 @@ private static function buildPlan(string $projectRoot): ActorIdentityMigrationPl self::freezeCurrentName($database, $actor); $proposals[$actor->path] = $actor->getProposedSource(); } catch (Throwable $failure) { - throw new ($failure::class)("{$actor->path}: {$failure->getMessage()}"); + throw self::createContextualFailure($actor->path, $failure); } $originals[$actor->path] = $watched[$actor->path]; $after[$actor->path] = $before[$actor->path]; @@ -90,7 +90,7 @@ private static function buildPlan(string $projectRoot): ActorIdentityMigrationPl $after[$path] = ActorReferenceSource::getUpdatedValue($payload, $changes); $proposals[$path] = ActorReferenceSource::rewrite($source, $changes); } catch (Throwable $failure) { - throw new RuntimeException("{$path}: {$failure->getMessage()}", previous: $failure); + throw self::createContextualFailure($path, $failure); } $originals[$path] = $source; $before[$path] = ActorReferenceInventory::getComparableValue($payload); @@ -103,4 +103,11 @@ public static function migrateProject(string $projectRoot): array { return self::planProject($projectRoot)->apply(); } + + private static function createContextualFailure(string $path, Throwable $failure): RuntimeException + { + $message = $failure->getMessage(); + if (! str_contains($message, $path)) { $message = "{$path}: {$message}"; } + return new RuntimeException($message, previous: $failure); + } } diff --git a/src/Validation/ActorReferenceValidator.php b/src/Validation/ActorReferenceValidator.php index 3948a22..2299858 100644 --- a/src/Validation/ActorReferenceValidator.php +++ b/src/Validation/ActorReferenceValidator.php @@ -12,6 +12,8 @@ /** Check the same catalogue-reference locations the confirmed migration repairs. */ final class ActorReferenceValidator { + public const string UNRESOLVED_ACTOR_REFERENCE = 'actor.reference.unresolved'; + public function validate(ProjectWorkspace $workspace): array { $ids = []; @@ -23,7 +25,12 @@ public function validate(ProjectWorkspace $workspace): array $database = $workspace->getRecordDatabase($category); if ($database === null) { continue; } foreach ($database->getRecords() as $index => $record) { - $path = $category === 'skits' ? 'assets/Data/Skits/' . $index . '.php' : $database->schema->relativePath . ':' . $index; + $path = $database->schema->relativePath . ':' . $index; + if ($category === 'skits') { + $path = $record->sourcePath ?? $database->path . '/' . $record->recordId . '.php'; + $root = rtrim($workspace->projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + if (str_starts_with($path, $root)) { $path = substr($path, strlen($root)); } + } $payloads[$path] = $record->toArray(); } } @@ -53,7 +60,8 @@ public function validate(ProjectWorkspace $workspace): array if (! is_string($value) || ! in_array($value, $known, true)) { $issues[] = Issue::error($where . ':' . implode('.', $reference['path']), 'Unresolved actor reference ' . var_export($reference['reference'], true) . '; use an explicit stable actor id.', - 'Review and confirm the project actor identity migration, or select an existing actor.'); + 'Review and confirm the project actor identity migration, or select an existing actor.', + self::UNRESOLVED_ACTOR_REFERENCE); } } } diff --git a/src/Validation/Issue.php b/src/Validation/Issue.php index e5e664d..3ef0b41 100644 --- a/src/Validation/Issue.php +++ b/src/Validation/Issue.php @@ -14,12 +14,14 @@ * @param string $where What it is about: a map id, a data file, a quest. * @param string $message What is wrong. * @param string $hint What to do about it. + * @param string|null $code Optional stable identity for consumers; never derived from message prose. */ public function __construct( public Severity $severity, public string $where, public string $message, public string $hint = '', + public ?string $code = null, ) { } @@ -30,11 +32,12 @@ public function __construct( * @param string $where What it is about. * @param string $message What is wrong. * @param string $hint What to do about it. + * @param string|null $code Optional stable identity for consumers. * @return self The issue. */ - public static function error(string $where, string $message, string $hint = ''): self + public static function error(string $where, string $message, string $hint = '', ?string $code = null): self { - return new self(Severity::ERROR, $where, $message, $hint); + return new self(Severity::ERROR, $where, $message, $hint, $code); } /** diff --git a/tests/Support/battle-entry-parity-runner.php b/tests/Support/battle-entry-parity-runner.php index ec4ce54..98d23a1 100644 --- a/tests/Support/battle-entry-parity-runner.php +++ b/tests/Support/battle-entry-parity-runner.php @@ -11,7 +11,7 @@ * source checkout. * * argv: [1] engine source root, [2] rule file path, [3] actors directory - * (empty string for none). Prints one JSON object: {ok, order} on success, + * (empty string for none). Prints one JSON object: {ok, order, diagnostics} on success, * {ok: false, phase, class, error} on refusal. */ @@ -55,6 +55,7 @@ $catalog = Ichiloto\Engine\Battle\Entry\BattleEntryRuleCatalog::fromProject($ruleFile, $store); echo json_encode([ 'ok' => true, + 'diagnostics' => $catalog->getDiagnostics(), 'order' => array_map( static fn(Ichiloto\Engine\Battle\Entry\BattleEntryRule $rule): string => $rule->id, $catalog->rules(), diff --git a/tests/Unit/ActorIdentityMigrationTest.php b/tests/Unit/ActorIdentityMigrationTest.php index bf9e857..92a61a3 100644 --- a/tests/Unit/ActorIdentityMigrationTest.php +++ b/tests/Unit/ActorIdentityMigrationTest.php @@ -73,7 +73,14 @@ function createLegacyActorProject(): array $other = dirname($path) . '/Other.php'; file_put_contents($other, " ['name' => 'Other']]; return \$actor;"); $before = sourceHashTree($root); - expect(fn() => ActorIdentityMigration::migrateProject($root))->toThrow(SourceUnreadable::class); + // The public migration supplies file context without discarding the source parser cause. + try { + ActorIdentityMigration::migrateProject($root); + $this->fail('Unsupported actor source must be refused.'); + } catch (RuntimeException $failure) { + expect($failure->getPrevious())->toBeInstanceOf(SourceUnreadable::class) + ->and(substr_count($failure->getMessage(), $other))->toBe(1); + } expect(sourceHashTree($root))->toBe($before); }); @@ -81,7 +88,14 @@ function createLegacyActorProject(): array [$root, $path] = createLegacyActorProject(); file_put_contents($path, " 'Kaelion']; return ['data' => \$data];"); $before = sourceHashTree($root); - expect(fn() => ActorIdentityMigration::migrateProject($root))->toThrow(RuntimeException::class, 'refusing to flatten'); + try { + ActorIdentityMigration::migrateProject($root); + $this->fail('Variable-backed actor data must be refused.'); + } catch (RuntimeException $failure) { + expect($failure->getPrevious())->toBeInstanceOf(\Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal::class) + ->and($failure->getMessage())->toContain('refusing to flatten') + ->and(substr_count($failure->getMessage(), $path))->toBe(1); + } expect(sourceHashTree($root))->toBe($before); }); @@ -225,8 +239,24 @@ function createLegacyActorProject(): array expect($validator->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)))->toBe([]); file_put_contents($root . '/assets/Data/Skits/case.php', " [['actor' => 'hero', 'text' => 'Hello']]];"); $issues = $validator->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)); - expect($issues)->toHaveCount(1)->and($issues[0]->where)->toContain('beats.0.actor'); + expect($issues)->toHaveCount(1) + ->and($issues[0]->where)->toBe('assets/Data/Skits/case.php:beats.0.actor') + ->and($issues[0]->code)->toBe(\Ichiloto\Editor\Validation\ActorReferenceValidator::UNRESOLVED_ACTOR_REFERENCE); file_put_contents($root . '/assets/Data/system.php', " ['Kaelion']];"); $issues = $validator->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)); expect($issues)->toHaveCount(2); }); + +it('keeps skit diagnostics attached to their files when sorted record positions change', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " ['id' => 'Kaelion', 'name' => 'Kaelion']];"); + $skits = $root . '/assets/Data/Skits'; + file_put_contents($skits . '/z-last-scene.php', " [['actor' => 'missing', 'text' => 'Hello']]];"); + $validator = new \Ichiloto\Editor\Validation\ActorReferenceValidator(); + $before = $validator->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)); + file_put_contents($skits . '/a-first-scene.php', " [['actor' => 'Kaelion', 'text' => 'Hello']]];"); + $after = $validator->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)); + expect($before)->toHaveCount(1)->and($after)->toEqual($before) + ->and($after[0]->where)->toBe('assets/Data/Skits/z-last-scene.php:beats.0.actor') + ->and(\Ichiloto\Editor\Validation\Issue::error('elsewhere', 'Other failure')->code)->toBeNull(); +}); diff --git a/tests/Unit/BattleEntryParityTest.php b/tests/Unit/BattleEntryParityTest.php index 03a8905..3fc181f 100644 --- a/tests/Unit/BattleEntryParityTest.php +++ b/tests/Unit/BattleEntryParityTest.php @@ -159,6 +159,18 @@ function parityRuleCorpus(): array $engine = hydrateThroughAcceptedEngine($engineRoot, $ruleFile); $problems = BattleEntryRuleContract::problems(require $ruleFile, $ruleFile); + // Runtime now removes only the invalid-actor rule instead of aborting + // startup; authoring still rejects it with the same root diagnostic. + if ($case === 'actor-empty-identity') { + expect($engine['ok'] ?? false)->toBeTrue() + ->and($engine['order'] ?? null)->toBe([]) + ->and($problems)->toHaveCount(1) + ->and($engine['diagnostics'] ?? null)->toBe([ + $problems[0] . ' This battle-entry rule is disabled; correct its actor reference.', + ]); + continue; + } + if (($engine['ok'] ?? false) === true) { expect($problems)->toBe([], sprintf( '%s: the engine accepted what the editor refused: %s', @@ -205,13 +217,18 @@ function parityRuleCorpus(): array ->and($resolver->canonicalId('ROOK'))->toBeNull() ->and($resolver->canonicalId('RookFile'))->toBeNull(); - // Unknown actor: same verdict, same wording. + // Unknown actor: rule disabled, startup-wide failure removed. Authoring + // remains strict, with the same root diagnostic and no alias fallback. file_put_contents($ruleFile, " [['id' => 'one', 'actors' => [['actor' => 'Nobody', 'presence' => 'any']], 'effects' => [['type' => 'stat_stage', 'actor' => 'Rook', 'stat' => 'speed', 'delta' => 1]]]]];"); $engine = hydrateThroughAcceptedEngine($engineRoot, $ruleFile, $healthy); $problems = BattleEntryRuleContract::problems(require $ruleFile, $ruleFile, $resolver); - expect($engine['ok'] ?? true)->toBeFalse() - ->and($problems[0] ?? null)->toBe(strval($engine['error'] ?? '')); + expect($engine['ok'] ?? false)->toBeTrue() + ->and($engine['order'] ?? null)->toBe([]) + ->and($problems)->not->toBeEmpty() + ->and($engine['diagnostics'] ?? null)->toBe([ + $problems[0] . ' This battle-entry rule is disabled; correct its actor reference.', + ]); // A shared name is not a contested identity anymore. file_put_contents($ruleFile, " []];"); From 5fc67330bc07cc4ef648d2bcb4c45226e77f2355 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 24 Sep 2026 03:05:57 +0200 Subject: [PATCH 028/344] chore(deps): lock published engine develop --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 7ed6b5d..70166a8 100644 --- a/composer.lock +++ b/composer.lock @@ -260,12 +260,12 @@ "source": { "type": "git", "url": "https://github.com/ichiloto/engine.git", - "reference": "2161b83d8ac9ef1a7153dc0d5001994a3caff551" + "reference": "c9bc37885cd0414867bbbcad2d4a8ba167d81010" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ichiloto/engine/zipball/2161b83d8ac9ef1a7153dc0d5001994a3caff551", - "reference": "2161b83d8ac9ef1a7153dc0d5001994a3caff551", + "url": "https://api.github.com/repos/ichiloto/engine/zipball/c9bc37885cd0414867bbbcad2d4a8ba167d81010", + "reference": "c9bc37885cd0414867bbbcad2d4a8ba167d81010", "shasum": "" }, "require": { @@ -306,7 +306,7 @@ "issues": "https://github.com/ichiloto/engine/issues", "source": "https://github.com/ichiloto/engine/tree/develop" }, - "time": "2026-09-22T13:49:38+00:00" + "time": "2026-09-24T00:34:30+00:00" }, { "name": "league/climate", From 11fc7e906a8d41d6ae8a113eb156d07ca8296682 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 24 Sep 2026 15:33:37 +0200 Subject: [PATCH 029/344] feat(maps): preserve and manage authored layer file sets Reuse Engine layer, collision and crop contracts while retaining legacy maps and ragged row widths. Preserve unchanged source bytes through transactional save, duplicate, move and layer lifecycle undo. Keep numeric layer order and rename local crop keys surgically. Require confirmation for resolved collision changes without editing shared dictionaries. Preserve flat crop table expressions during explicit legacy conversion. Load sibling Engine sources for integration tests and isolated evaluation without vendor edits. Verify PHP 8.5 and 8.4 with 1122 tests and 6516 assertions each; static analysis is clean on both. --- README.md | 8 + phpunit.xml | 2 +- src/Cutscenes/Preview/PreviewCamera.php | 6 + src/Cutscenes/Preview/PreviewMapManager.php | 9 +- src/Cutscenes/Source/ArraySourceWriter.php | 20 +- .../Source/PhpArraySourceDocument.php | 42 + src/Database/PhpDataFile.php | 17 +- src/Maps/EditableGrid.php | 152 ++++ src/Maps/MapLayers.php | 277 +++++++ src/ProjectMap.php | 723 ++++++++++-------- src/ProjectWorkspace.php | 29 +- src/Validation/ProjectValidator.php | 8 + tests/Pest.php | 3 +- tests/Support/LayeredMapFixtures.php | 59 ++ tests/Unit/LayerSourceEditsTest.php | 33 + tests/Unit/LayeredMapTest.php | 271 +++++++ tests/Unit/LayeredPreviewTest.php | 45 ++ tests/Unit/SourceCheckoutTest.php | 23 + tests/bootstrap.php | 13 + tests/phpstan-engine.php | 14 + 20 files changed, 1423 insertions(+), 331 deletions(-) create mode 100644 src/Maps/EditableGrid.php create mode 100644 src/Maps/MapLayers.php create mode 100644 tests/Support/LayeredMapFixtures.php create mode 100644 tests/Unit/LayerSourceEditsTest.php create mode 100644 tests/Unit/LayeredMapTest.php create mode 100644 tests/Unit/LayeredPreviewTest.php create mode 100644 tests/Unit/SourceCheckoutTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/phpstan-engine.php diff --git a/README.md b/README.md index 2d7d807..e99cd42 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,14 @@ Set `ICHILOTO_GAME_SRC=/path/to/game` to include a game project in the production-verification tests. `docs/manual.md` is covered by `tests/Unit/ManualCoverageTest.php`; registered keybindings must be documented. +For layered-map integration tests against an unpublished sibling Engine, +set `ICHILOTO_ENGINE_SRC=/path/to/engine`. The test bootstrap prepends that +checkout's source directory, including in isolated data-evaluation children; +it does not modify `vendor`, dependency versions or the lockfile. +Use `vendor/bin/phpstan analyse -c tests/phpstan-engine.php +--autoload-file=tests/bootstrap.php` with the same environment to analyse +against that checkout instead of the installed Engine sources. + ## Project Links - Engine repository: [github.com/ichiloto/engine](https://github.com/ichiloto/engine) diff --git a/phpunit.xml b/phpunit.xml index 1fdec1e..2a250d5 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,7 +1,7 @@ diff --git a/src/Cutscenes/Preview/PreviewCamera.php b/src/Cutscenes/Preview/PreviewCamera.php index f1128fe..e04cb1a 100644 --- a/src/Cutscenes/Preview/PreviewCamera.php +++ b/src/Cutscenes/Preview/PreviewCamera.php @@ -6,6 +6,7 @@ use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\Core\Vector2; +use Ichiloto\Engine\Field\MapLayerSet; use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\Rendering\Camera; @@ -83,6 +84,11 @@ public function renderMap(): void } } + public function renderLayeredMap(MapLayerSet $layers): void + { + $this->renderMap(); + } + public function draw(iterable|string $content, int $x = 0, int $y = 0): void { $lines = []; diff --git a/src/Cutscenes/Preview/PreviewMapManager.php b/src/Cutscenes/Preview/PreviewMapManager.php index a34423c..c0c9dd0 100644 --- a/src/Cutscenes/Preview/PreviewMapManager.php +++ b/src/Cutscenes/Preview/PreviewMapManager.php @@ -41,7 +41,9 @@ public function loadForPreview(string $mapId): array $this->calculateMapDimensions(); $dictionaryPath = getcwd() . '/assets/Maps/collisions.php'; $dictionary = is_file($dictionaryPath) ? $this->loadCollisionDictionary($dictionaryPath) : []; - $this->collisionMap = $this->generateCollisionMap($this->tileMap, $dictionary); + $this->collisionMap = $this->layers === null + ? $this->generateCollisionMap($this->tileMap, $dictionary) + : $this->generateLayerCollisionMap($this->layers, $dictionary); $this->gameScene->npcManager?->configure(is_array($map['npcs'] ?? null) ? $map['npcs'] : []); return $map; @@ -53,10 +55,7 @@ public function loadForPreview(string $mapId): array public function unload(): void { $this->mapData = []; - $this->tileMap = []; - $this->collisionMap = []; - $this->calculateMapDimensions(); - $this->camera->worldSpace = []; + $this->clearMapGeometry(); $this->gameScene->npcManager?->configure([]); } diff --git a/src/Cutscenes/Source/ArraySourceWriter.php b/src/Cutscenes/Source/ArraySourceWriter.php index fcaad44..62694ce 100644 --- a/src/Cutscenes/Source/ArraySourceWriter.php +++ b/src/Cutscenes/Source/ArraySourceWriter.php @@ -67,8 +67,26 @@ private function __construct(private readonly PhpArraySourceDocument $document) * instance when nothing differs. * @throws SourcePreservationRefusal When a change cannot be expressed in the source. */ - public static function rewrite(PhpArraySourceDocument $document, array $old, array $new): PhpArraySourceDocument + public static function rewrite(PhpArraySourceDocument $document, array $old, array $new, array $keyRenames = []): PhpArraySourceDocument { + foreach ($keyRenames as $rename) { + $path = $rename['path']; + $oldKey = array_pop($path); + $parent = &$old; + foreach ($path as $step) { + $parent = &$parent[$step]; + } + if (! is_array($parent) || ! array_key_exists($oldKey, $parent) || array_key_exists($rename['key'], $parent)) { + throw new SourcePreservationRefusal('The key rename does not match the loaded data.'); + } + $document = $document->withEdits([$document->renameKeyEdit($rename['path'], $rename['key'])]); + $renamed = []; + foreach ($parent as $key => $value) { + $renamed[$key === $oldKey ? $rename['key'] : $key] = $value; + } + $parent = $renamed; + unset($parent); + } if ($old === $new) { return $document; } diff --git a/src/Cutscenes/Source/PhpArraySourceDocument.php b/src/Cutscenes/Source/PhpArraySourceDocument.php index 3b55997..9e6cf11 100644 --- a/src/Cutscenes/Source/PhpArraySourceDocument.php +++ b/src/Cutscenes/Source/PhpArraySourceDocument.php @@ -388,6 +388,48 @@ public function replaceValueEdit(array $path, string $literal): array return [$entry->value->start, $entry->value->end, self::reindent($literal, $this->lineIndentBefore($entry->start) ?? '')]; } + /** Nests an authored value without regenerating its comments or expressions. */ + public function wrapValueEdit(array $path, array $keys): array + { + $entry = $this->entryAt($path); + if ($entry === null || $entry->keyIsOpaque) { + throw new SourcePreservationRefusal('The value cannot be wrapped at ' . self::describePath($path)); + } + $value = $entry->value; + $text = substr($this->source, $value->start, $value->end - $value->start); + foreach (array_reverse($keys) as $key) { + $text = '[' . var_export($key, true) . ' => ' . $text . ']'; + } + return [$value->start, $value->end, $text]; + } + + /** Renames only a literal key; its value, comments and position survive. */ + public function renameKeyEdit(array $path, string $newKey): array + { + $entry = $this->entryAt($path); + $parent = $this->nodeAt(array_slice($path, 0, -1)); + if ($entry === null || $entry->keyIsOpaque || $parent === null || $parent->hasOpaqueKey + || $parent->entryFor($newKey) !== null) { + throw new SourcePreservationRefusal('The requested key rename cannot be preserved at ' . self::describePath($path)); + } + $end = $entry->start; + foreach (PhpToken::tokenize($this->source) as $token) { + if ($token->pos < $entry->start) { + continue; + } + if ($token->is(T_DOUBLE_ARROW)) { + return [$entry->start, $end, var_export($newKey, true)]; + } + if (! $token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { + $end = $token->pos + strlen($token->text); + } + if ($end > $entry->value->start) { + break; + } + } + throw new SourcePreservationRefusal('No literal key at ' . self::describePath($path)); + } + /** * Plans inserting an entry into an array: ahead of the entry now at a * position, or after the last one. diff --git a/src/Database/PhpDataFile.php b/src/Database/PhpDataFile.php index dfd1f36..888549c 100644 --- a/src/Database/PhpDataFile.php +++ b/src/Database/PhpDataFile.php @@ -155,6 +155,13 @@ private static function evaluateIsolatedPaths(array $paths, ?string $workingDire $autoload = $localAutoload; } + // Child evaluation must use the same source roots as this process, + // including a development Engine selected by the test bootstrap. + $prefixes = []; + foreach (\Composer\Autoload\ClassLoader::getRegisteredLoaders() as $loader) { + $prefixes = array_replace($prefixes, $loader->getPrefixesPsr4()); + } + $runner = <<<'PHP' $directories) { + $loader->setPsr4($prefix, $directories); + } } if ($workingDirectory !== '' && is_dir($workingDirectory)) { @@ -247,7 +258,7 @@ private static function evaluateIsolatedPaths(array $paths, ?string $workingDire $runner = str_replace('/*__ICHILOTO_AUTHORED_REQUIRES__*/', $authoredRequires, $runner); $pipes = []; $resultMarker = 'ICHILOTO_EVAL_RESULT:' . bin2hex(random_bytes(16)) . ':'; - $arguments = [PHP_BINARY, '-r', substr($runner, strlen("> */ + public array $cells; + private array $originalCells; + private array $lines; + private array $separators; + + public function __construct(private string $text, private ?string $source = null, private bool $legacyTags = false) + { + $parts = preg_split('/(\r\n|\n|\r)/', rtrim($text, "\r\n"), -1, PREG_SPLIT_DELIM_CAPTURE) ?: ['']; + $this->lines = $this->separators = []; + foreach ($parts as $index => $part) { + if ($index % 2 === 0) { + $this->lines[] = $part; + } else { + $this->separators[] = $part; + } + } + $this->cells = self::parseLines($this->lines, $legacyTags); + $this->originalCells = $this->cells; + } + + public static function parseLines(array $lines, bool $legacyTags = false): array + { + return array_map(static function (string $line) use ($legacyTags): array { + preg_match_all('/<[^>]+>|[^<]+|]*>$/u', $segment) === 1 + && ($legacyTags || TerminalText::stripAnsi($segment . 'x') === 'x')) { + $tags[] = $segment; + } elseif (preg_match('/^<\/[^>]*>$/u', $segment) === 1 && ($legacyTags || $tags !== [])) { + array_pop($tags); + } else { + foreach (TerminalText::visibleSymbols($segment) as $symbol) { + preg_match('/\A((?:\x1b\[[0-9;]*m)*)(.*?)((?:\x1b\[[0-9;]*m)*)\z/us', $symbol, $styled); + $cells[] = ['symbol' => $styled[2] ?? $symbol, + 'prefix' => implode('', $tags) . ($styled[1] ?? ''), + 'suffix' => ($styled[3] ?? '') . str_repeat('', count($tags))]; + } + } + } + return $cells; + }, $lines); + } + + public function getSymbols(): array + { + return array_map(static fn(array $row): array => array_column($row, 'symbol'), $this->cells); + } + + public function captureSnapshot(): array + { + return ['text' => $this->text, 'source' => $this->source, 'cells' => $this->cells, 'legacyTags' => $this->legacyTags]; + } + + public static function createFromSnapshot(array $snapshot): self + { + $grid = new self($snapshot['text'], $snapshot['source'], $snapshot['legacyTags']); + $grid->cells = $snapshot['cells']; + return $grid; + } + + public function getLines(): array + { + $lines = []; + foreach ($this->cells as $index => $cells) { + if (($this->originalCells[$index] ?? null) === $cells) { + $lines[] = $this->lines[$index]; + continue; + } + $line = ''; + $open = null; + foreach ($cells as $cell) { + $style = [$cell['prefix'], $cell['suffix']]; + if ($style !== $open) { + $line .= ($open[1] ?? '') . $style[0]; + $open = $style; + } + $line .= $cell['symbol']; + } + $lines[] = $line . ($open[1] ?? ''); + } + return $lines; + } + + public function getSource(): string + { + if ($this->cells === $this->originalCells && $this->source !== null) { + return $this->source; + } + $text = ''; + foreach ($this->getLines() as $index => $line) { + $text .= ($index === 0 ? '' : ($this->separators[$index - 1] ?? $this->separators[0] ?? "\n")) . $line; + } + $text .= substr($this->text, strlen(rtrim($this->text, "\r\n"))); + if ($this->source === null) { + return MapGridSource::buildSource($text, 'ICHILOTO_MAP'); + } + + $offset = 0; + $start = $end = null; + $indent = ''; + $newline = "\n"; + foreach (token_get_all($this->source) as $token) { + $bytes = is_array($token) ? $token[1] : $token; + if (is_array($token) && $token[0] === T_START_HEREDOC) { + $start = $offset + strlen($bytes); + $newline = str_ends_with($bytes, "\r\n") ? "\r\n" : (str_ends_with($bytes, "\r") ? "\r" : "\n"); + } + if (is_array($token) && $token[0] === T_END_HEREDOC) { + $end = $offset; + preg_match('/^[ \t]*/', $bytes, $match); + $indent = $match[0]; + } + $offset += strlen($bytes); + } + $body = $indent . preg_replace('/(\r\n|\n|\r)/', '$1' . $indent, $text) . $newline; + $source = substr($this->source, 0, $start) . $body . substr($this->source, $end); + try { + if (MapGridSource::parseSource($source, '') !== $text) { + throw new \InvalidArgumentException('Edited grid would not read back identically.'); + } + } catch (\InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage() . ' Nothing was written.', previous: $error); + } + return $source; + } + + public function resize(int $width, int $height): void + { + $blank = ['symbol' => ' ', 'prefix' => '', 'suffix' => '']; + $this->cells = array_slice($this->cells, 0, $height); + foreach ($this->cells as &$row) { + $row = array_pad(array_slice($row, 0, $width), $width, $blank); + } + unset($row); + $this->cells = array_pad($this->cells, $height, array_fill(0, $width, $blank)); + } +} diff --git a/src/Maps/MapLayers.php b/src/Maps/MapLayers.php new file mode 100644 index 0000000..ac2d2bc --- /dev/null +++ b/src/Maps/MapLayers.php @@ -0,0 +1,277 @@ +layers[$layer['id']] = $layer; + } + $this->layers[self::EVENT] = [ + 'id' => self::EVENT, 'name' => 'Events', 'order' => null, 'decoration' => false, + 'path' => $eventPath, 'grid' => $events, + ]; + $this->captureBaseline(); + } + + public static function createFromSource(string $directory, MapLayerSet $set, string $eventPath, string $eventText): self + { + $layers = []; + foreach ($set->layers as $layer) { + $path = $directory . ($set->legacy ? '/' : '/layers/') . basename($layer->path); + $layers[] = [ + 'id' => $set->legacy ? self::BASE : 'map:' . $layer->order, + 'name' => $layer->name, 'order' => $layer->order, 'decoration' => $layer->decoration, + 'path' => $path, + 'grid' => new EditableGrid($layer->text, (string) file_get_contents($path), $set->legacy), + ]; + } + return new self($directory, $set->legacy, $layers, new EditableGrid($eventText, (string) file_get_contents($eventPath)), $eventPath); + } + + public function getLayers(): array + { + $layers = array_values($this->layers); + usort($layers, static fn(array $a, array $b): int => ($a['order'] ?? PHP_INT_MAX) <=> ($b['order'] ?? PHP_INT_MAX)); + return $layers; + } + + public function getBaseId(): string + { + foreach ($this->getLayers() as $layer) { + if ($layer['id'] !== self::EVENT && ! $layer['decoration']) { + return $layer['id']; + } + } + throw new MapSourceRefusal('A map needs at least one gameplay layer.'); + } + + public function getLayer(string $id): array + { + $id = $id === self::BASE && ! isset($this->layers[$id]) ? $this->getBaseId() : $id; + return $this->layers[$id] ?? throw new MapSourceRefusal("Unknown map layer {$id}."); + } + + public function getBaseGrid(): EditableGrid + { + return $this->getGrid($this->getBaseId()); + } + + public function getEventGrid(): EditableGrid + { + return $this->getGrid(self::EVENT); + } + + public function getGrid(string $id): EditableGrid + { + return $this->getLayer($id)['grid']; + } + + public function createLayer(string $name, bool $decoration, ?int $order = null): string + { + $this->assertNameAvailable($name); + $used = array_column($this->getLayers(), 'order'); + if ($order === null) { + $order = max(array_filter($used, is_int(...))) + 1; + } + if ($order < 0 || $order > 99 || in_array($order, $used, true)) { + throw new MapSourceRefusal('Layer order must be an unused two-digit number (00-99).'); + } + // Adding the first layer explicitly converts the legacy member in the + // same transaction; merely opening or saving a legacy map never does. + if ($this->legacy) { + $source = $this->getBaseGrid()->getSource(); + $this->layers[self::BASE]['grid'] = new EditableGrid(MapGridSource::parseSource($source, $this->layers[self::BASE]['path']), $source); + $this->layers[self::BASE]['path'] = $this->buildPath(0, 'terrain', false); + $this->legacy = false; + } + $id = 'map:' . $order; + $grid = new EditableGrid(implode("\n", array_map( + static fn(array $row): string => str_repeat(' ', count($row)), $this->getBaseGrid()->cells, + ))); + $this->layers[$id] = ['id' => $id, 'name' => $name, 'order' => $order, 'decoration' => $decoration, + 'path' => $this->buildPath($order, $name, $decoration), 'grid' => $grid]; + return $id; + } + + public function renameLayer(string $id, string $name): void + { + $layer = $this->getLayer($id); + if ($layer['name'] === $name) { + return; + } + $this->assertManagedLayer($layer); + $this->assertNameAvailable($name); + $this->layers[$layer['id']]['name'] = $name; + $this->layers[$layer['id']]['path'] = $this->buildPath($layer['order'], $name, $layer['decoration']); + } + + public function removeLayer(string $id): void + { + $layer = $this->getLayer($id); + $this->assertManagedLayer($layer); + if (! $layer['decoration'] && count(array_filter($this->layers, + static fn(array $entry): bool => $entry['id'] !== self::EVENT && ! $entry['decoration'], + )) === 1) { + throw new MapSourceRefusal('The last gameplay layer cannot be removed.'); + } + unset($this->layers[$layer['id']]); + } + + private function assertManagedLayer(array $layer): void + { + if ($this->legacy || $layer['id'] === self::EVENT) { + throw new MapSourceRefusal('Only authored layers in layers/ can be renamed or removed.'); + } + } + + private function assertNameAvailable(string $name): void + { + if (preg_match(MapLayerSource::FILENAME_PATTERN, '00.' . $name . '.map.php') !== 1) { + throw new MapSourceRefusal('Use a layer name beginning with a letter, followed by letters, digits, hyphens or underscores.'); + } + foreach ($this->layers as $layer) { + if ($layer['id'] !== self::EVENT && $layer['name'] === $name) { + throw new MapSourceRefusal("Layer {$name} already exists."); + } + } + } + + private function buildPath(int $order, string $name, bool $decoration): string + { + return sprintf('%s/layers/%02d.%s.%s.php', $this->directory, $order, $name, $decoration ? 'deco' : 'map'); + } + + public function captureSnapshot(): array + { + return ['legacy' => $this->legacy, 'layers' => array_map( + static fn(array $layer): array => [...$layer, 'grid' => $layer['grid']->captureSnapshot()], $this->layers, + )]; + } + + public function restoreSnapshot(array $snapshot): void + { + $this->legacy = $snapshot['legacy']; + $this->layers = array_map(static fn(array $layer): array => [...$layer, 'grid' => EditableGrid::createFromSnapshot($layer['grid'])], $snapshot['layers']); + } + + public function resize(int $width, int $height): void + { + foreach ($this->layers as $layer) { + $layer['grid']->resize($width, $height); + } + } + + public function assertDimensions(): void + { + $this->getLayerSet()->assertMatchingGrid($this->getEventGrid()->getSymbols(), $this->layers[self::EVENT]['path']); + } + + public function getLayerSet(?string $renamedId = null, ?string $newName = null): MapLayerSet + { + $layers = []; + foreach ($this->getLayers() as $layer) { + if ($layer['id'] !== self::EVENT) { + $layers[] = new MapLayer($layer['id'] === $renamedId ? $newName : $layer['name'], + $layer['order'], $layer['decoration'], $layer['path'], + MapGridSource::parseSource($layer['grid']->getSource(), $layer['path'])); + } + } + return new MapLayerSet($layers, $this->legacy); + } + + public function assertSourcesUnchanged(): void + { + if (! is_dir($this->directory)) { + return; + } + foreach ($this->baselineSources as $path => $source) { + if (! is_file($path)) { + throw new MapSourceRefusal("{$path} disappeared after opening; reload before saving."); + } + MapGridSource::readFile($path); + if (file_get_contents($path) !== $source) { + throw new MapSourceRefusal("{$path} changed after opening; reload before saving. Nothing was written."); + } + } + if ($this->baselineSources !== []) { + $set = MapLayerSource::loadFromDirectory($this->directory); + $known = array_keys($this->baselineSources); + foreach ($set->layers as $layer) { + if (! in_array($layer->path, $known, true)) { + throw new MapSourceRefusal("{$layer->path} was added after opening; reload before saving."); + } + } + } + } + + public function getSources(?string $directory = null, ?string $baseName = null): array + { + $sources = []; + foreach ($this->getLayers() as $layer) { + $path = $layer['path']; + if ($directory !== null) { + $path = $layer['id'] === self::EVENT ? "{$directory}/{$baseName}.event.php" + : ($this->legacy ? "{$directory}/{$baseName}.map.php" : $directory . '/layers/' . basename($path)); + } + $sources[$path] = $layer['grid']->getSource(); + } + return $sources; + } + + public function stageChanges(FileSetTransaction $transaction, ?string $directory = null, ?string $baseName = null, bool $removeOriginals = false): void + { + $sources = $this->getSources($directory, $baseName); + foreach ($sources as $path => $source) { + if ($directory !== null || ($this->baselineSources[$path] ?? null) !== $source || ! is_file($path)) { + $transaction->write($path, $source); + } + } + if ($directory === null || $removeOriginals) { + foreach ($this->baselineSources as $path => $source) { + if (! isset($sources[$path])) { + $transaction->remove($path); + } + } + } + } + + public function captureBaseline(): void + { + $this->baselineSources = []; + foreach ($this->getSources() as $path => $source) { + if (is_file($path)) { + $this->baselineSources[$path] = $source; + } + } + } + + public function getStoredPaths(): array + { + return array_keys($this->baselineSources); + } + +} diff --git a/src/ProjectMap.php b/src/ProjectMap.php index d5a9b5f..fb2b135 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -17,6 +17,15 @@ use Ichiloto\Editor\Storage\FilesystemFileSetOperations; use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Engine\Field\MapGridSource; +use Ichiloto\Engine\Field\MapLayer; +use Ichiloto\Engine\Field\MapLayerSource; +use Ichiloto\Engine\Field\MapLayerSet; +use Ichiloto\Engine\Field\MapCollisionResolver; +use Ichiloto\Engine\Rendering\Tiles\GraphicalTileDefinition; +use Ichiloto\Engine\IO\Console\TerminalText; +use Ichiloto\Engine\IO\Console\SgrStyleState; +use Ichiloto\Editor\Maps\EditableGrid; +use Ichiloto\Editor\Maps\MapLayers; use InvalidArgumentException; use RuntimeException; @@ -27,21 +36,10 @@ final class ProjectMap { use TracksPersistedState; - /** - * @var array> - */ - private array $tileCells; - /** - * The tile cells as loaded, so a row that ends up unchanged is written - * back as its original bytes rather than rebuilt. - * - * @var array> - */ - private array $loadedTileCells; - /** - * @var array> - */ - private array $eventCells; + private MapLayers $layers; + private ?string $tileDefinitionKey = null; + private array $tileDefinitions = []; + /** * @var array */ @@ -76,6 +74,8 @@ final class ProjectMap * what the file held at load, or at the last successful save. */ private array $loadedData; + private string $baselineDataSource; + private array $dataLayerNames = []; /** * The tile payload as of the last load or save, so a save can tell a @@ -105,13 +105,16 @@ public function __construct( public readonly array $eventLines, ?string $dataSource = null, private readonly ?string $gridSourceIssue = null, + ?MapLayers $layers = null, ) { $this->editableData = $data; - $this->tileCells = self::parseStyledLines($tileLines); - $this->loadedTileCells = $this->tileCells; - $this->eventCells = self::parsePlainLines($eventLines); + $this->layers = $layers ?? new MapLayers($directory, true, [[ + 'id' => MapLayers::BASE, 'name' => 'terrain', 'order' => 0, 'decoration' => false, + 'path' => $mapPath, 'grid' => new EditableGrid(implode("\n", $tileLines), legacyTags: true), + ]], new EditableGrid(implode("\n", $eventLines)), $eventPath); $this->loadedData = $data; $this->adoptDataSource($dataSource ?? "baselineDataSource = $this->dataDocument?->source ?? (string) $this->unparsedDataSource; $this->baselineMapPayload = $this->buildMapPayload(); $this->baselineEventPayload = $this->buildEventPayload(); } @@ -125,6 +128,7 @@ public function __construct( */ private function adoptDataSource(string $source): void { + $this->dataLayerNames = array_column($this->getLayers(), 'name', 'id'); try { $this->dataDocument = PhpArraySourceDocument::parse($source); $this->dataSourceIssue = null; @@ -150,7 +154,7 @@ public static function fromDirectory(string $mapsRoot, string $directory): self $mapPath = $directory . DIRECTORY_SEPARATOR . $baseName . '.map.php'; $eventPath = $directory . DIRECTORY_SEPARATOR . $baseName . '.event.php'; - if (! is_file($dataPath) || ! is_file($mapPath) || ! is_file($eventPath)) { + if (! is_file($dataPath) || (! is_file($mapPath) && ! is_dir($directory . '/layers')) || ! is_file($eventPath)) { throw new RuntimeException("Map files are incomplete in {$directory}."); } @@ -159,8 +163,16 @@ public static function fromDirectory(string $mapsRoot, string $directory): self // Grid PHP is data, not executable map-building code. Refuse it before // evaluating even the separate data member of an incomplete map. - $mapText = self::readGridSource($mapPath, $mapId); - $eventText = self::readGridSource($eventPath, $mapId); + try { + $set = MapLayerSource::loadFromDirectory($directory, $mapId); + $eventText = self::readGridSource($eventPath, $mapId); + if (! $set->legacy) { + $set->assertMatchingGrid(MapLayer::parseGrid($eventText), $mapId . '/' . basename($eventPath)); + } + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage(), previous: $error); + } + $mapText = $set->layers[0]->text; $data = require $dataPath; if (! is_array($data)) { @@ -177,6 +189,7 @@ public static function fromDirectory(string $mapsRoot, string $directory): self tileLines: self::splitMapText($mapText), eventLines: self::splitMapText($eventText), dataSource: (string) file_get_contents($dataPath), + layers: MapLayers::createFromSource($directory, $set, $eventPath, $eventText), )->withLoadedBaseline(); } @@ -227,10 +240,10 @@ private static function readGridSource(string $path, string $mapId): string /** Refuses a source changed on disk after this map was opened. */ private function assertGridSourcesCanonical(): void { - foreach ([$this->mapPath, $this->eventPath] as $path) { - if (is_file($path)) { - self::readGridSource($path, $this->mapId); - } + try { + $this->layers->assertSourcesUnchanged(); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage() . ' Nothing was written.', previous: $error); } } @@ -283,7 +296,7 @@ public function getDescription(): string */ public function getHeight(): int { - return count($this->tileCells); + return count($this->layers->getBaseGrid()->cells); } /** @@ -299,7 +312,7 @@ public function getWidth(): int $width = 0; - foreach ($this->tileCells as $row) { + foreach ($this->layers->getBaseGrid()->cells as $row) { $width = max($width, count($row)); } @@ -347,6 +360,217 @@ public function getEditableData(): array return $this->editableData; } + public function getLayers(): array + { + return array_map(static function (array $layer): array { + unset($layer['grid']); + return $layer; + }, $this->layers->getLayers()); + } + + public function getBaseLayerId(): string + { + return $this->layers->getBaseId(); + } + + public function isLegacyMap(): bool + { + return $this->layers->legacy; + } + + public function getLayerSymbol(string $layer, int $x, int $y): string + { + return $this->layers->getGrid($layer)->cells[$y][$x]['symbol'] ?? ' '; + } + + public function hasLayerCell(string $layer, int $x, int $y): bool + { + return isset($this->layers->getGrid($layer)->cells[$y][$x]); + } + + public function getLayerCellStyle(string $layer, int $x, int $y): array + { + $cell = $this->layers->getGrid($layer)->cells[$y][$x] ?? []; + return ['prefix' => $cell['prefix'] ?? '', 'suffix' => $cell['suffix'] ?? '']; + } + + public function getLayerColor(string $layer, int $x, int $y): ?string + { + return self::getInnermostForeground($this->getLayerCellStyle($layer, $x, $y)['prefix']); + } + + public function setLayerCell(string $layer, int $x, int $y, string $symbol, string $prefix = '', string $suffix = ''): void + { + $this->assertEditable(); + $grid = $this->layers->getGrid($layer); + if (isset($grid->cells[$y][$x])) { + $grid->cells[$y][$x] = ['symbol' => self::normalizeSymbol($symbol), 'prefix' => $prefix, 'suffix' => $suffix]; + $this->touchState(); + } + } + + public function createLayer(string $name, bool $decoration = false, ?int $order = null): string + { + $this->assertEditable(); + $data = $this->editableData; + $document = null; + if ($this->layers->legacy && isset($data['tiles2d'])) { + if ($this->dataDocument === null) { + $this->assertDataPreservable($data); + } + $current = PhpArraySourceDocument::parse($this->proposedDataSource()); + $document = $current->withEdits([$current->wrapValueEdit(['tiles2d'], ['layers', 'terrain'])]); + $data['tiles2d'] = ['layers' => ['terrain' => $data['tiles2d']]]; + } + $before = $this->layers->captureSnapshot(); + try { + $id = $this->layers->createLayer($name, $decoration, $order); + $this->layers->assertDimensions(); + } catch (\Throwable $error) { + $this->layers->restoreSnapshot($before); + throw $error; + } + if ($document !== null) { + $this->editableData = $this->loadedData = $data; + $this->adoptDataSource($document->source); + } + $this->cachedWidth = null; + $this->touchState(); + return $id; + } + + public function renameLayer(string $id, string $name, bool $confirmCollisionChange = false): void + { + $this->assertEditable(); + $oldName = $this->layers->getLayer($id)['name']; + $tables = $this->editableData['tiles2d']['layers'] ?? []; + if (! $confirmCollisionChange && $this->getLayerRenameCollisionChange($id, $name) !== null) { + throw new MapSourceRefusal('Renaming this layer changes resolved collisions. Explicit confirmation is required.'); + } + $data = $this->editableData; + if (array_key_exists($oldName, $tables) && $oldName !== $name) { + $renamed = []; + foreach ($tables as $key => $value) { + $renamed[$key === $oldName ? $name : $key] = $value; + } + $data['tiles2d']['layers'] = $renamed; + } + $before = $this->layers->captureSnapshot(); + try { + $this->layers->renameLayer($id, $name); + $this->writeData($data); + } catch (\Throwable $error) { + $this->layers->restoreSnapshot($before); + throw $error; + } + $this->touchState(); + } + + public function removeLayer(string $id): void + { + $this->assertEditable(); + $name = $this->layers->getLayer($id)['name']; + $data = $this->editableData; + if (isset($data['tiles2d']['layers'][$name])) { + unset($data['tiles2d']['layers'][$name]); + if ($data['tiles2d']['layers'] === []) { + unset($data['tiles2d']); + } + $this->assertDataPreservable($data); + } + $this->layers->removeLayer($id); + $this->writeData($data); + $this->cachedWidth = null; + $this->touchState(); + } + + public function getLayerTiles2d(string $id): array + { + if ($id === MapLayers::EVENT) { + return []; + } + $name = $this->layers->getLayer($id)['name']; + $definition = $this->getLayerTileDefinitions()[$name] ?? null; + if ($definition === null) { + return []; + } + $symbols = []; + foreach ($definition->symbols as $symbol => $index) { + $symbols[$symbol] = $definition->sources[$index]->toArray(); + } + return ['asset' => $definition->asset, 'symbols' => $symbols]; + } + + public function getLayerTileDefinitions(): array + { + $key = serialize([$this->editableData['tiles2d'] ?? null, $this->getLayers(), $this->layers->legacy]); + if ($key === $this->tileDefinitionKey) { + return $this->tileDefinitions; + } + $definitions = isset($this->editableData['tiles2d']) + ? GraphicalTileDefinition::getForLayers($this->editableData['tiles2d'], $this->layers->getLayerSet(), $this->mapId) + : []; + $this->tileDefinitionKey = $key; + return $this->tileDefinitions = $definitions; + } + + public function getLayerSet(): MapLayerSet + { + return $this->layers->getLayerSet(); + } + + public function getStoredGridPaths(): array + { + $this->assertGridSourcesCanonical(); + return $this->layers->getStoredPaths(); + } + + public function validateLayerContracts(): void + { + $this->assertGridsAgree(); + $set = $this->layers->getLayerSet(); + GraphicalTileDefinition::validateDecoration($set, $this->getLayerTileDefinitions()); + $path = $this->getMapsRoot() . '/collisions.php'; + $dictionary = is_file($path) ? (static fn(string $path): mixed => require $path)($path) : []; + if (! is_array($dictionary)) { + throw new MapSourceRefusal('The collision dictionary must return an array.'); + } + MapCollisionResolver::resolveLayers($set, $dictionary); + } + + public function getLayerRenameCollisionChange(string $id, string $name): ?string + { + $dictionaryPath = $this->getMapsRoot() . '/collisions.php'; + $dictionary = is_file($dictionaryPath) ? (static fn(string $path): mixed => require $path)($dictionaryPath) : []; + if (! is_array($dictionary)) { + throw new MapSourceRefusal('The collision dictionary must return an array.'); + } + $before = MapCollisionResolver::resolveLayers($this->layers->getLayerSet(), $dictionary); + $after = MapCollisionResolver::resolveLayers($this->layers->getLayerSet($id, $name), $dictionary); + $changed = 0; + foreach ($before as $y => $row) { + foreach ($row as $x => $type) { + $changed += $type !== $after[$y][$x] ? 1 : 0; + } + } + return $changed === 0 ? null : sprintf('Rename %s to %s changes collision at %d cell(s). Shared collisions.php stays unchanged.', + $this->layers->getLayer($id)['name'], $name, $changed); + } + + public function captureLayerSnapshot(): array + { + return ['grid' => $this->captureGridSnapshot(), 'data' => $this->editableData, + 'source' => $this->dataDocument === null ? $this->unparsedDataSource : $this->proposedDataSource()]; + } + + public function restoreLayerSnapshot(array $snapshot): void + { + $this->restoreGridSnapshot($snapshot['grid']); + $this->editableData = $this->loadedData = $snapshot['data']; + $this->adoptDataSource($snapshot['source']); + $this->touchState(); + } + /** * Builds a merged preview where event markers override map tiles. * @@ -366,6 +590,10 @@ public function renderPreview( bool $showNpcOverlay = false, ?int $selectedNpcIndex = null, ?string $selectedNpcSprite = null, + array $layerVisibility = [], + ?string $activeLayer = null, + bool $terminalPreview = false, + bool $dimInactive = false, ): array { if ($width < 1 || $height < 1) { @@ -375,12 +603,30 @@ public function renderPreview( $lines = []; $offsetX = max(0, $offsetX); $offsetY = max(0, $offsetY); - $rowLimit = min($offsetY + $height, max(count($this->tileCells), count($this->eventCells))); + if ($terminalPreview) { + $rows = array_slice($this->getLayerSet()->getComposedGrid(), $offsetY, $height); + return array_pad(array_map(static fn(array $row): string => rtrim(implode('', array_slice($row, $offsetX, $width))), $rows), $height, ''); + } + $rowLimit = min($offsetY + $height, max(count($this->layers->getBaseGrid()->cells), count($this->layers->getEventGrid()->getSymbols()))); $npcCells = $showNpcOverlay ? $this->npcOverlayCells($selectedNpcIndex, $selectedNpcSprite) : []; + $baseLayerId = $this->layers->getBaseId(); for ($row = $offsetY; $row < $rowLimit; $row++) { - $tileRow = $this->tileCells[$row] ?? []; - $eventSymbols = $showEventOverlay ? ($this->eventCells[$row] ?? []) : []; + $tileRow = []; + foreach ($this->layers->getLayers() as $layer) { + if ($terminalPreview && ($layer['decoration'] || $layer['id'] === MapLayers::EVENT)) { + continue; + } + if (! $terminalPreview && (($layerVisibility[$layer['id']] ?? true) === false + || ($layer['id'] === MapLayers::EVENT && ! $showEventOverlay))) { + continue; + } + foreach ($layer['grid']->cells[$row] ?? [] as $x => $cell) { + if ($cell['symbol'] !== ' ' || $layer['id'] === $baseLayerId) { + $tileRow[$x] = [...$cell, 'dim' => ! $terminalPreview && $dimInactive && $activeLayer !== $layer['id']]; + } + } + } $mergedSymbols = []; for ($column = $offsetX; $column < $offsetX + $width; $column++) { @@ -391,21 +637,18 @@ public function renderPreview( continue; } - $eventSymbol = $eventSymbols[$column] ?? ' '; - - if (trim($eventSymbol) !== '') { - // Event markers are authoring geometry: they stay plain - // so they read as markers over any terrain colour. - $mergedSymbols[] = $eventSymbol; - continue; - } - $tileCell = $tileRow[$column] ?? null; $tileSymbol = is_array($tileCell) ? $tileCell['symbol'] : ' '; + if (! $this->layers->legacy && is_array($tileCell)) { + $styled = TerminalText::formatStyles($tileCell['prefix'] . $tileSymbol . $tileCell['suffix']); + $mergedSymbols[] = ($tileCell['dim'] ?? false) ? "\033[2m" . $styled . "\033[0m" : $styled; + continue; + } $ansiOpen = is_array($tileCell) ? self::ansiOpenForPrefix((string) ($tileCell['prefix'] ?? '')) : null; - $mergedSymbols[] = $ansiOpen === null ? $tileSymbol : $ansiOpen . $tileSymbol . "\033[0m"; + $dim = ($tileCell['dim'] ?? false) ? "\033[2m" : ''; + $mergedSymbols[] = $ansiOpen === null && $dim === '' ? $tileSymbol : $dim . $ansiOpen . $tileSymbol . "\033[0m"; } $lines[] = rtrim(implode('', $mergedSymbols)); @@ -519,7 +762,7 @@ private function npcOverlayCells(?int $selectedNpcIndex, ?string $selectedNpcSpr */ public function getTileSymbol(int $x, int $y): string { - return $this->tileCells[$y][$x]['symbol'] ?? ' '; + return $this->layers->getBaseGrid()->cells[$y][$x]['symbol'] ?? ' '; } /** @@ -531,7 +774,7 @@ public function getTileSymbol(int $x, int $y): string */ public function getEventSymbol(int $x, int $y): string { - return $this->eventCells[$y][$x] ?? ' '; + return $this->getLayerSymbol(MapLayers::EVENT, $x, $y); } /** @@ -545,11 +788,11 @@ public function getEventSymbol(int $x, int $y): string public function setTileSymbol(int $x, int $y, string $symbol): void { $this->assertEditable(); - if (! isset($this->tileCells[$y][$x])) { + if (! isset($this->layers->getBaseGrid()->cells[$y][$x])) { return; } - $this->tileCells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); + $this->layers->getBaseGrid()->cells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); $this->touchState(); } @@ -565,7 +808,7 @@ public function setTileSymbol(int $x, int $y, string $symbol): void */ public function getTileCellStyle(int $x, int $y): array { - $cell = $this->tileCells[$y][$x] ?? null; + $cell = $this->layers->getBaseGrid()->cells[$y][$x] ?? null; return [ 'prefix' => is_array($cell) ? (string) ($cell['prefix'] ?? '') : '', @@ -594,6 +837,35 @@ public function getTileColor(int $x, int $y): ?string */ private static function getInnermostForeground(string $prefix): ?string { + $state = new SgrStyleState(); + preg_match_all('/\x1b\[[0-9;]*m/', $prefix, $sequences); + foreach ($sequences[0] as $sequence) { + $state->apply($sequence); + } + $ansi = $state->prefix(); + if (preg_match('/\x1b\[38;2;(\d+);(\d+);(\d+)m/', $ansi, $rgb) === 1) { + return sprintf('#%02x%02x%02x', (int) $rgb[1], (int) $rgb[2], (int) $rgb[3]); + } + foreach (self::ANSI_FOREGROUNDS as $name => $code) { + if (str_contains($ansi, "\033[{$code}m")) { + return $name; + } + } + if (preg_match('/\x1b\[38;5;(\d+)m/', $ansi, $indexed) === 1) { + $index = (int) $indexed[1]; + if ($index < 16) { + return array_keys(self::ANSI_FOREGROUNDS)[$index]; + } + if ($index <= 255) { + if ($index >= 232) { + $shade = 8 + ($index - 232) * 10; + return sprintf('#%02x%02x%02x', $shade, $shade, $shade); + } + $cube = [0, 95, 135, 175, 215, 255]; + $index -= 16; + return sprintf('#%02x%02x%02x', $cube[intdiv($index, 36)], $cube[intdiv($index % 36, 6)], $cube[$index % 6]); + } + } if (preg_match_all('/<[^>]*\bfg=([^;>]+)[^>]*>/', $prefix, $matches) < 1) { return null; } @@ -614,13 +886,13 @@ private static function getInnermostForeground(string $prefix): ?string public function setTileCell(int $x, int $y, string $symbol, string $prefix, string $suffix): void { $this->assertEditable(); - if (! isset($this->tileCells[$y][$x])) { + if (! isset($this->layers->getBaseGrid()->cells[$y][$x])) { return; } - $this->tileCells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); - $this->tileCells[$y][$x]['prefix'] = $prefix; - $this->tileCells[$y][$x]['suffix'] = $suffix; + $this->layers->getBaseGrid()->cells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); + $this->layers->getBaseGrid()->cells[$y][$x]['prefix'] = $prefix; + $this->layers->getBaseGrid()->cells[$y][$x]['suffix'] = $suffix; $this->touchState(); } @@ -635,11 +907,11 @@ public function setTileCell(int $x, int $y, string $symbol, string $prefix, stri public function setEventSymbol(int $x, int $y, string $symbol): void { $this->assertEditable(); - if (! isset($this->eventCells[$y][$x])) { + if (! $this->hasLayerCell(MapLayers::EVENT, $x, $y)) { return; } - $this->eventCells[$y][$x] = self::normalizeSymbol($symbol); + $this->layers->getEventGrid()->cells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); $this->touchState(); } @@ -652,7 +924,7 @@ public function getPlacedEventMarkers(): array { $markers = []; - foreach ($this->eventCells as $row) { + foreach ($this->layers->getEventGrid()->getSymbols() as $row) { foreach ($row as $symbol) { if (trim($symbol) !== '') { $markers[$symbol] = $symbol; @@ -667,27 +939,27 @@ public function getPlacedEventMarkers(): array * Captures the editable grid state for undoable whole-grid mutations * (resize, event bounds rewrites). * - * @return array{tiles: array>, events: array>} + * @return array{tiles: array, events: array, layers: array} */ public function captureGridSnapshot(): array { return [ - 'tiles' => $this->tileCells, - 'events' => $this->eventCells, + 'tiles' => $this->layers->getBaseGrid()->cells, + 'events' => $this->layers->getEventGrid()->getSymbols(), + 'layers' => $this->layers->captureSnapshot(), ]; } /** * Restores a previously captured grid snapshot. * - * @param array{tiles: array>, events: array>} $snapshot The captured state. + * @param array{tiles: array, events: array, layers: array} $snapshot The captured state. * @return void */ public function restoreGridSnapshot(array $snapshot): void { $this->assertEditable(); - $this->tileCells = $snapshot['tiles']; - $this->eventCells = $snapshot['events']; + $this->layers->restoreSnapshot($snapshot['layers']); $this->cachedWidth = null; $this->touchState(); } @@ -853,7 +1125,7 @@ private function assertDataPreservable(array $next): void } try { - ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $next); + ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $next, $this->getDataKeyRenames($next)); } catch (SourcePreservationRefusal $refusal) { throw new MapSourceRefusal(sprintf( '%s: %s — %s Everything else in the file is untouched.', @@ -864,6 +1136,22 @@ private function assertDataPreservable(array $next): void } } + private function getDataKeyRenames(array $next): array + { + $renames = []; + foreach ($this->getLayers() as $layer) { + $oldName = $this->dataLayerNames[$layer['id']] ?? $layer['name']; + $newName = $layer['name']; + if ($oldName === $newName) { + continue; + } + if (isset($this->loadedData['tiles2d']['layers'][$oldName], $next['tiles2d']['layers'][$newName])) { + $renames[] = ['path' => ['tiles2d', 'layers', $oldName], 'key' => $newName]; + } + } + return $renames; + } + /** * The data-file source this map's current content should be saved as. * @@ -880,7 +1168,7 @@ private function proposedDataSource(): string } try { - return ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $this->editableData)->source; + return ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $this->editableData, $this->getDataKeyRenames($this->editableData))->source; } catch (SourcePreservationRefusal $refusal) { throw new MapSourceRefusal(sprintf( '%s: %s — %s', @@ -1065,7 +1353,7 @@ public function getEventBounds(string $marker): ?array { $positions = []; - foreach ($this->eventCells as $rowIndex => $row) { + foreach ($this->layers->getEventGrid()->getSymbols() as $rowIndex => $row) { foreach ($row as $columnIndex => $symbol) { if ($symbol === $marker) { $positions[] = [$columnIndex, $rowIndex]; @@ -1151,32 +1439,7 @@ public function resize(int $width, int $height): void return; } - foreach ($this->tileCells as $rowIndex => $row) { - $this->tileCells[$rowIndex] = array_slice($row, 0, $width); - - while (count($this->tileCells[$rowIndex]) < $width) { - $this->tileCells[$rowIndex][] = self::createBlankTileCell(); - } - } - - foreach ($this->eventCells as $rowIndex => $row) { - $this->eventCells[$rowIndex] = array_slice($row, 0, $width); - - while (count($this->eventCells[$rowIndex]) < $width) { - $this->eventCells[$rowIndex][] = ' '; - } - } - - $this->tileCells = array_slice($this->tileCells, 0, $height); - $this->eventCells = array_slice($this->eventCells, 0, $height); - - while (count($this->tileCells) < $height) { - $this->tileCells[] = self::createBlankTileRow($width); - } - - while (count($this->eventCells) < $height) { - $this->eventCells[] = array_fill(0, $width, ' '); - } + $this->layers->resize($width, $height); $this->cachedWidth = null; $this->touchState(); @@ -1272,10 +1535,10 @@ public function removeEventDefinition(string $marker): void public function setEventBounds(string $marker, int $x, int $y, int $width, int $height): void { $this->assertEditable(); - foreach ($this->eventCells as $rowIndex => $row) { + foreach ($this->layers->getEventGrid()->getSymbols() as $rowIndex => $row) { foreach ($row as $columnIndex => $symbol) { if ($symbol === $marker) { - $this->eventCells[$rowIndex][$columnIndex] = ' '; + $this->layers->getEventGrid()->cells[$rowIndex][$columnIndex]['symbol'] = ' '; } } } @@ -1285,8 +1548,8 @@ public function setEventBounds(string $marker, int $x, int $y, int $width, int $ for ($row = max(0, $y); $row <= $maxY; $row++) { for ($column = max(0, $x); $column <= $maxX; $column++) { - if (isset($this->eventCells[$row][$column])) { - $this->eventCells[$row][$column] = $marker; + if ($this->hasLayerCell(MapLayers::EVENT, $column, $row)) { + $this->layers->getEventGrid()->cells[$row][$column]['symbol'] = $marker; } } } @@ -1329,7 +1592,7 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) $this->assertGridSourcesCanonical(); $tripletExists = is_file($this->dataPath) - && is_file($this->mapPath) + && count($this->layers->getSources()) > 0 && is_file($this->eventPath); if (! $this->isDirty() && ! $moving && is_dir($this->directory) && $tripletExists) { @@ -1338,7 +1601,7 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) return $target['mapId']; } - $this->assertGridsAgree(); + $this->validateLayerContracts(); // Which members actually changed. The tile and event files compare // against the grids as of the last save, never against the bytes on @@ -1349,9 +1612,9 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) // cached source bytes are its own and are restored unchanged. $dataSource = $this->dataDocument === null ? (string) $this->unparsedDataSource : $this->proposedDataSource(); $writeData = ! is_file($this->dataPath) - || ($this->dataDocument !== null && $dataSource !== $this->dataDocument->source); + || $dataSource !== $this->baselineDataSource; $mapPayload = $this->buildMapPayload(); - $writeMap = $mapPayload !== $this->baselineMapPayload || ! is_file($this->mapPath); + $writeMap = $this->isDirty(); $eventPayload = $this->buildEventPayload(); $writeEvent = $eventPayload !== $this->baselineEventPayload || ! is_file($this->eventPath); @@ -1365,27 +1628,12 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) $transaction = new FileSetTransaction($target['directory'], $files ?? new FilesystemFileSetOperations()); - if ($moving) { - // A move installs the complete triplet at the new path and takes - // the old one away, as one operation. + if ($writeData || $moving) { $transaction->write($target['dataPath'], $dataSource); - $transaction->write($target['mapPath'], $mapPayload); - $transaction->write($target['eventPath'], $eventPayload); + } + $this->layers->stageChanges($transaction, $moving ? $target['directory'] : null, $moving ? basename($target['directory']) : null, $moving); + if ($moving) { $transaction->remove($this->dataPath); - $transaction->remove($this->mapPath); - $transaction->remove($this->eventPath); - } else { - if ($writeData) { - $transaction->write($target['dataPath'], $dataSource); - } - - if ($writeMap) { - $transaction->write($target['mapPath'], $mapPayload); - } - - if ($writeEvent) { - $transaction->write($target['eventPath'], $eventPayload); - } } // Stage everything beside its destination, then prove the staged @@ -1421,6 +1669,11 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) $transaction->commit($backup); + if ($this->layers->legacy && is_dir($target['directory'] . '/layers') + && array_diff(scandir($target['directory'] . '/layers') ?: [], ['.', '..']) === []) { + @rmdir($target['directory'] . '/layers'); + } + if ($moving) { // The transaction removed the triplet; the folder follows only // when nothing else of the author's lives in it. @@ -1442,9 +1695,11 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) private function adoptWritten(string $dataSource, string $mapPayload, string $eventPayload): void { $this->adoptDataSource($dataSource); + $this->baselineDataSource = $dataSource; $this->loadedData = $this->editableData; $this->baselineMapPayload = $mapPayload; $this->baselineEventPayload = $eventPayload; + $this->layers->captureBaseline(); $this->captureBaseline(); } @@ -1453,12 +1708,16 @@ private function adoptWritten(string $dataSource, string $mapPayload, string $ev */ private function assertGridsAgree(): void { - if (count($this->eventCells) !== count($this->tileCells)) { + if (! $this->layers->legacy) { + $this->layers->assertDimensions(); + return; + } + if (count($this->layers->getEventGrid()->getSymbols()) !== count($this->layers->getBaseGrid()->cells)) { throw new RuntimeException(sprintf( '%s: the event layer is %d rows but the map is %d; nothing was written.', $this->mapId, - count($this->eventCells), - count($this->tileCells), + count($this->layers->getEventGrid()->getSymbols()), + count($this->layers->getBaseGrid()->cells), )); } } @@ -1575,7 +1834,7 @@ public function duplicateTo(string $directory, string $baseName, string $display $duplicatedData['name'] = $displayName; try { - $dataSource = ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $duplicatedData)->source; + $dataSource = ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $duplicatedData, $this->getDataKeyRenames($duplicatedData))->source; } catch (SourcePreservationRefusal $refusal) { throw new MapSourceRefusal(sprintf( '%s: %s — %s Nothing was duplicated.', @@ -1588,18 +1847,8 @@ public function duplicateTo(string $directory, string $baseName, string $display $transaction = new FileSetTransaction($directory, $files ?? new FilesystemFileSetOperations()); $duplicatedDataPath = $directory . DIRECTORY_SEPARATOR . $baseName . '.data.php'; $transaction->write($duplicatedDataPath, $dataSource); - $transaction->write( - $directory . DIRECTORY_SEPARATOR . $baseName . '.map.php', - $this->buildMapPayload() === $this->baselineMapPayload && is_file($this->mapPath) - ? (string) file_get_contents($this->mapPath) - : $this->buildMapPayload(), - ); - $transaction->write( - $directory . DIRECTORY_SEPARATOR . $baseName . '.event.php', - $this->buildEventPayload() === $this->baselineEventPayload && is_file($this->eventPath) - ? (string) file_get_contents($this->eventPath) - : $this->buildEventPayload(), - ); + $this->validateLayerContracts(); + $this->layers->stageChanges($transaction, $directory, $baseName); // The staged copy must evaluate to exactly the duplicate's content // where it will live, before anything is installed. @@ -1636,11 +1885,11 @@ public function duplicateTo(string $directory, string $baseName, string $display * * @return string[] */ - public function getCharacterPalette(): array + public function getCharacterPalette(?string $layer = null): array { $symbols = []; - foreach ($this->tileCells as $row) { + foreach (($layer === null ? $this->layers->getBaseGrid() : $this->layers->getGrid($layer))->cells as $row) { foreach ($row as $cell) { $symbol = $cell['symbol']; @@ -1652,7 +1901,7 @@ public function getCharacterPalette(): array } } - foreach ($this->eventCells as $row) { + foreach ($this->layers->getEventGrid()->getSymbols() as $row) { foreach ($row as $symbol) { if (trim($symbol) === '') { continue; @@ -1680,67 +1929,6 @@ private static function splitMapText(string $text): array return preg_split('/\R/u', rtrim($text, "\r\n")) ?: []; } - /** - * Parses formatted tile-map lines into editable cells. - * - * @param string[] $lines The raw tile-map lines. - * @return array> - */ - private static function parseStyledLines(array $lines): array - { - return array_map(static function (string $line): array { - preg_match_all('/<[^>]+>|[^<]+/u', $line, $matches); - $segments = $matches[0] ?? []; - $cells = []; - $openTags = []; - - foreach ($segments as $segment) { - if (preg_match('/^<[^\/][^>]*>$/u', $segment) === 1) { - $openTags[] = $segment; - continue; - } - - if (preg_match('/^<\/[^>]*>$/u', $segment) === 1) { - array_pop($openTags); - continue; - } - - // Each cell carries the complete style it renders in, so a - // cell inside a run is as coloured as the run's first cell. - $prefix = implode('', $openTags); - $suffix = str_repeat(self::STYLE_CLOSE_TAG, count($openTags)); - - foreach (self::toSymbols($segment) as $symbol) { - $cells[] = ['symbol' => $symbol, 'prefix' => $prefix, 'suffix' => $suffix]; - } - } - - return $cells; - }, $lines); - } - - /** - * Parses plain event-map lines into editable symbols. - * - * @param string[] $lines The raw event-map lines. - * @return array> - */ - private static function parsePlainLines(array $lines): array - { - return array_map(static fn(string $line): array => self::toSymbols($line), $lines); - } - - /** - * Strips text-formatting tags from a map line. - * - * @param string $line The formatted line. - * @return string - */ - private static function stripFormatting(string $line): string - { - return preg_replace('/<[^>]+>/', '', $line) ?? $line; - } - /** * Normalizes input down to a single visible symbol. * @@ -1771,69 +1959,6 @@ private static function toSymbols(string $line): array return $matches[0] ?? []; } - /** - * Rebuilds a formatted tile row from editable cells. - * - * @param array $cells The tile cells. - * @return string - */ - private static function buildStyledLine(array $cells): string - { - $line = ''; - $open = null; - - foreach ($cells as $cell) { - $style = [$cell['prefix'], $cell['suffix']]; - - if ($style !== $open) { - $line .= ($open[1] ?? '') . $style[0]; - $open = $style; - } - - $line .= $cell['symbol']; - } - - return $line . ($open[1] ?? ''); - } - - /** - * Rebuilds every tile row. A row whose cells still match what was - * loaded keeps its original bytes; an edited row is written as runs of - * identically styled cells. - * - * @return string[] - */ - private function buildStyledLines(): array - { - $lines = []; - - foreach ($this->tileCells as $rowIndex => $cells) { - $lines[] = isset($this->tileLines[$rowIndex]) && ($this->loadedTileCells[$rowIndex] ?? null) === $cells - ? $this->tileLines[$rowIndex] - : self::buildStyledLine($cells); - } - - return $lines; - } - - /** - * Rebuilds a plain row from editable symbols. - * - * @param array $cells The plain symbols. - * @return string - */ - private function buildPlainLine(array $cells): string - { - return implode('', $cells); - } - - /** - * The content fingerprint compares canonical exports, deliberately not - * the source writer's output: what makes a map dirty is its content, - * and a map whose authored source cannot even be parsed must still - * fingerprint cleanly so its grids stay saveable. - */ - /** * The tile file the current grid should be saved as. * @@ -1842,10 +1967,7 @@ private function buildPlainLine(array $cells): string */ private function buildMapPayload(): string { - return MapGridSource::buildSource( - implode(PHP_EOL, $this->buildStyledLines()), - 'ICHILOTO_MAP', - ); + return $this->layers->getBaseGrid()->getSource(); } /** @@ -1853,10 +1975,7 @@ private function buildMapPayload(): string */ private function buildEventPayload(): string { - return MapGridSource::buildSource( - implode(PHP_EOL, array_map($this->buildPlainLine(...), $this->eventCells)), - 'ICHILOTO_EVENT_MAP', - ); + return $this->layers->getEventGrid()->getSource(); } /** @@ -1868,8 +1987,7 @@ protected function buildPersistedPayload(): string // its old self even when every cell matches. return $this->mapId . "\0" . self::exportPhpValue($this->editableData) - . "\0" . $this->buildMapPayload() - . $this->buildEventPayload(); + . "\0" . serialize($this->layers->getSources()); } /** @@ -1926,27 +2044,13 @@ public function moveTo(string $newRelativeId): self $movedDataPath, $this->dataDocument === null ? (string) $this->unparsedDataSource : $this->proposedDataSource(), ); - $transaction->write( - $movedMapPath, - $this->buildMapPayload() === $this->baselineMapPayload && is_file($this->mapPath) - ? (string) file_get_contents($this->mapPath) - : $this->buildMapPayload(), - ); - $transaction->write( - $movedEventPath, - $this->buildEventPayload() === $this->baselineEventPayload && is_file($this->eventPath) - ? (string) file_get_contents($this->eventPath) - : $this->buildEventPayload(), - ); + $this->validateLayerContracts(); + $this->layers->stageChanges($transaction, $directory, $baseName, true); $transaction->remove($this->dataPath); - $transaction->remove($this->mapPath); - $transaction->remove($this->eventPath); - - $expectedGrids = [ - $movedMapPath => $this->buildStyledLines(), - $movedEventPath => array_map($this->buildPlainLine(...), $this->eventCells), - ]; + $expectedGrids = $this->layers->getSources($directory, $baseName); $sourceDirectoryMetadata = self::captureDirectoryMetadata($previousDirectory); + $layerDirectoryMetadata = is_dir($previousDirectory . '/layers') + ? self::captureDirectoryMetadata($previousDirectory . '/layers') : null; $removedSourceDirectories = []; $directoryRestorationFailures = []; @@ -1956,20 +2060,21 @@ public function moveTo(string $newRelativeId): self // neighbours, or old member can make an invalid move look valid. $transaction->commit(validate: function () use ( $movedDataPath, - $movedMapPath, $movedEventPath, $expectedGrids, $newRelativeId, $previousDirectory, $mapsRoot, $sourceDirectoryMetadata, + $layerDirectoryMetadata, &$removedSourceDirectories, &$directoryRestorationFailures, ): void { $removedSourceDirectories = self::removeEmptyDirectoryChain( - $previousDirectory, + $layerDirectoryMetadata['path'] ?? $previousDirectory, $mapsRoot, - $sourceDirectoryMetadata, + $layerDirectoryMetadata ?? $sourceDirectoryMetadata, + [$previousDirectory => $sourceDirectoryMetadata], ); try { @@ -1995,22 +2100,15 @@ public function moveTo(string $newRelativeId): self )); } - $evaluatedGrids = [ - $movedMapPath => MapGridSource::readFile($movedMapPath), - $movedEventPath => MapGridSource::readFile($movedEventPath), - ]; - - foreach ($expectedGrids as $path => $expectedLines) { - $evaluatedGrid = $evaluatedGrids[$path]; - - if (self::splitMapText($evaluatedGrid) !== $expectedLines) { - throw new RuntimeException(sprintf( - '%s would not read back as this map at %s.', - basename($path), - $newRelativeId, - )); + foreach ($expectedGrids as $path => $expectedSource) { + if (MapGridSource::readFile($path) !== MapGridSource::parseSource($expectedSource, $path)) { + throw new RuntimeException(basename($path) . ' would not read back as this map at ' . $newRelativeId); } } + $set = MapLayerSource::loadFromDirectory(dirname($movedDataPath), $newRelativeId); + if (! $set->legacy) { + $set->assertMatchingGrid(MapLayer::parseGrid(MapGridSource::readFile($movedEventPath)), $movedEventPath); + } } catch (\Throwable $validationFailure) { // The file rollback needs the old directories in place. // Recreate only directories this validation removed; the @@ -2061,16 +2159,12 @@ public function moveTo(string $newRelativeId): self ), previous: $throwable); } - return new self( - mapId: $newRelativeId, - directory: $directory, - dataPath: $movedDataPath, - mapPath: $movedMapPath, - eventPath: $movedEventPath, - data: $this->editableData, - tileLines: $expectedGrids[$movedMapPath], - eventLines: $expectedGrids[$movedEventPath], - dataSource: (string) file_get_contents($movedDataPath), + $set = MapLayerSource::loadFromDirectory($directory, $newRelativeId); + $eventText = MapGridSource::readFile($movedEventPath); + return new self($newRelativeId, $directory, $movedDataPath, $movedMapPath, $movedEventPath, + $this->editableData, self::splitMapText($set->layers[0]->text), self::splitMapText($eventText), + (string) file_get_contents($movedDataPath), + layers: MapLayers::createFromSource($directory, $set, $movedEventPath, $eventText), )->withLoadedBaseline(); } @@ -2176,6 +2270,7 @@ private static function removeEmptyDirectoryChain( string $directory, string $mapsRoot, array $sourceDirectoryMetadata, + array $knownMetadata = [], ): array { $candidates = []; $mapsRoot = rtrim($mapsRoot, DIRECTORY_SEPARATOR); @@ -2185,7 +2280,7 @@ private static function removeEmptyDirectoryChain( while ($directory !== '' && $directory !== $mapsRoot && str_starts_with($directory, $mapsRoot . DIRECTORY_SEPARATOR)) { $metadata = $directory === $sourceDirectoryMetadata['path'] ? $sourceDirectoryMetadata - : self::captureDirectoryMetadata($directory); + : ($knownMetadata[$directory] ?? self::captureDirectoryMetadata($directory)); $entries = is_dir($directory) ? scandir($directory) : false; if (! is_array($entries)) { diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index 95143ff..fb89e35 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -163,7 +163,7 @@ public function getInspectorLines(int $selectedMapIndex = 0): array ]), '', basename($selectedMap->dataPath), - basename($selectedMap->mapPath), + $selectedMap->isLegacyMap() ? basename($selectedMap->mapPath) : 'layers/ (' . (count($selectedMap->getLayers()) - 1) . ' layers)', basename($selectedMap->eventPath), ] : [ 'No map selected.', @@ -295,6 +295,10 @@ public function getCanvasLines( ?int $selectedNpcIndex = null, ?string $selectedNpcSprite = null, ?array $cursor = null, + array $layerVisibility = [], + ?string $activeLayer = null, + bool $terminalPreview = false, + bool $dimInactive = false, ): array { $selectedMap = $this->getMapByIndex($selectedMapIndex); @@ -316,7 +320,7 @@ public function getCanvasLines( } $previewHeight = max(0, $height - self::CANVAS_HEADER_ROWS); - $previewLines = $selectedMap->renderPreview($width, $previewHeight, $offsetX, $offsetY, $showEventOverlay, $showNpcOverlay, $selectedNpcIndex, $selectedNpcSprite); + $previewLines = $selectedMap->renderPreview($width, $previewHeight, $offsetX, $offsetY, $showEventOverlay, $showNpcOverlay, $selectedNpcIndex, $selectedNpcSprite, $layerVisibility, $activeLayer, $terminalPreview, $dimInactive); return [ sprintf('Preview: %s', $selectedMap->mapId), @@ -416,10 +420,17 @@ public function deleteMap(int $selectedMapIndex, ?FileSetOperations $files = nul // the folder goes only once it is empty. $transaction = new FileSetTransaction($selectedMap->directory, $files ?? new FilesystemFileSetOperations()); $transaction->remove($selectedMap->dataPath); - $transaction->remove($selectedMap->mapPath); - $transaction->remove($selectedMap->eventPath); + foreach ($selectedMap->getStoredGridPaths() as $path) { + $transaction->remove($path); + } $transaction->commit(); + foreach ([$selectedMap->directory . '/layers', $selectedMap->directory] as $directory) { + if (is_dir($directory) && array_diff(scandir($directory) ?: [], ['.', '..']) === []) { + @rmdir($directory); + } + } + return $selectedMap->mapId; } @@ -540,7 +551,7 @@ private static function humanizeBaseName(string $baseName): string * * @return string[] */ - public function getCollisionGlyphs(): array + public function getCollisionGlyphs(?string $layerName = null): array { $dictionaryFile = rtrim($this->projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . 'Maps' . DIRECTORY_SEPARATOR . 'collisions.php'; @@ -561,7 +572,13 @@ public function getCollisionGlyphs(): array $glyphs = []; - foreach (array_keys($dictionary) as $key) { + $entries = array_filter($dictionary, static fn(mixed $value): bool => ! is_array($value)); + foreach ($dictionary as $name => $section) { + if (is_array($section) && ($layerName === null || $name === $layerName)) { + $entries = array_replace($entries, $section); + } + } + foreach (array_keys($entries) as $key) { // PHP normalizes digit-only string keys such as "8" to integers. $glyph = (string) $key; diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 2804005..5c3ea22 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -1592,6 +1592,14 @@ protected function checkMaps(ProjectWorkspace $workspace): array */ protected function checkLayers(ProjectMap $map): array { + if (! $map->isLegacyMap()) { + try { + $map->validateLayerContracts(); + } catch (\Throwable $error) { + return [Issue::error($map->mapId, $error->getMessage(), 'Repair the authored layer or its dictionary/crop table before saving.')]; + } + return []; + } $tileRows = count($map->tileLines); $eventRows = count($map->eventLines); diff --git a/tests/Pest.php b/tests/Pest.php index 2780e27..6dad86e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -12,7 +12,7 @@ function gameSourceRoot(): ?string $pinned = getenv('ICHILOTO_GAME_SRC'); return is_string($pinned) && $pinned !== '' && is_file($pinned . '/assets/Data/items.php') - ? rtrim($pinned, '/') + ? (realpath($pinned) ?: null) : null; } @@ -366,6 +366,7 @@ function plainTextOfFrame(string $frame, int $width, int $height): string require_once __DIR__ . '/Support/FileSetFixtures.php'; require_once __DIR__ . '/Support/MapFixtures.php'; +require_once __DIR__ . '/Support/LayeredMapFixtures.php'; require_once __DIR__ . '/Support/BattleEntryFixtures.php'; diff --git a/tests/Support/LayeredMapFixtures.php b/tests/Support/LayeredMapFixtures.php new file mode 100644 index 0000000..f98cf47 --- /dev/null +++ b/tests/Support/LayeredMapFixtures.php @@ -0,0 +1,59 @@ +....\n...."; + $building = " / \n xx "; + $decoration = "d \n d"; + $events = " E\n "; + if ($ragged) { + $terrain = "....\n.."; + $building = " / \nx "; + $decoration = "d \n d"; + $events = " E\n "; + } + foreach (['01.terrain.map.php' => $terrain, '04.buildings.map.php' => $building, '07.detail.deco.php' => $decoration] as $file => $text) { + $source = MapGridSource::buildSource($text, 'AUTHORED', '// keep ' . $file . "\n"); + file_put_contents($directory . '/layers/' . $file, $source); + } + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource($events, 'EVENTS')); + file_put_contents($directory . '/test-map.data.php', <<<'SOURCE' + 'Test Map', 'region' => '', 'events' => [], + 'tiles2d' => [ + 'asset' => 'Graphics/Tilesets/shared.png', + 'layers' => [ + // Facade crop table. + 'buildings' => ['symbols' => ['x' => ['x' => 0, 'y' => 0, 'width' => 16, 'height' => 16]]], + 'detail' => ['asset' => 'Graphics/Tilesets/detail.png', 'symbols' => ['d' => ['x' => 16, 'y' => 0, 'width' => 16, 'height' => 16]]], + ], + ], +]; +SOURCE); + return $root; +} + +function loadLayeredMap(string $root): ProjectMap +{ + return ProjectMap::fromDirectory($root . '/assets/Maps', $root . '/assets/Maps/test-map'); +} + +function layeredCanvasEditor(?string $root = null): array +{ + $root ??= layeredMapProject(); + $editor = deletionEditor($root); + setEditorProperty($editor, 'focusedPane', 'canvas'); + return [$editor, getEditorProperty($editor, 'workspace')->getMapByIndex(0), $root]; +} diff --git a/tests/Unit/LayerSourceEditsTest.php b/tests/Unit/LayerSourceEditsTest.php new file mode 100644 index 0000000..e025a2f --- /dev/null +++ b/tests/Unit/LayerSourceEditsTest.php @@ -0,0 +1,33 @@ + ['buildings' /* keep */ => array_replace([], ['symbols' => []])]];"; + $document = PhpArraySourceDocument::parse($source); + $old = ['layers' => ['buildings' => ['symbols' => []]]]; + $new = ['layers' => ['houses' => ['symbols' => []]]]; + $rewritten = ArraySourceWriter::rewrite($document, $old, $new, + [['path' => ['layers', 'buildings'], 'key' => 'houses']]); + expect($rewritten->source)->toBe(str_replace("'buildings'", "'houses'", $source)); +}); + +it('refuses key renames into an existing key or across an opaque sibling', function (string $source) { + $document = PhpArraySourceDocument::parse($source); + expect(fn() => $document->renameKeyEdit(['layers', 'buildings'], 'houses'))->toThrow(SourcePreservationRefusal::class) + ->and($document->source)->toBe($source); +})->with([ + " ['buildings' => [], 'houses' => []]];", + " ['buildings' => [], strtolower('OTHER') => []]];", +]); + +it('wraps a legacy table expression without evaluating or flattening it', function () { + $source = " require __DIR__ . '/crops.php'];"; + $document = PhpArraySourceDocument::parse($source); + expect($document->withEdits([$document->wrapValueEdit(['tiles2d'], ['layers', 'terrain'])])->source) + ->toBe(" ['layers' => ['terrain' => require __DIR__ . '/crops.php']]];"); +}); diff --git a/tests/Unit/LayeredMapTest.php b/tests/Unit/LayeredMapTest.php new file mode 100644 index 0000000..69e9eed --- /dev/null +++ b/tests/Unit/LayeredMapTest.php @@ -0,0 +1,271 @@ +getLayers(), 'name'))->toBe(['terrain', 'buildings', 'detail', 'Events']) + ->and(array_column($map->getLayers(), 'order'))->toBe([1, 4, 7, null]) + ->and($map->getLayerSymbol('map:4', 1, 0))->toBe('/') + ->and($map->getLayerSymbol('map:7', 0, 0))->toBe('d'); + $terminal = array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, terminalPreview: true)); + expect($terminal)->toBe(['./..', '.xx.']); + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2)))->toBe(['d/.E', '.xxd']); + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showEventOverlay: false, layerVisibility: ['map:7' => false])))->toBe($terminal); + expect(implode('', $map->renderPreview(4, 2, activeLayer: 'map:4', dimInactive: true)))->toContain("\033[2m") + ->and(implode('', $map->renderPreview(4, 2, terminalPreview: true, layerVisibility: ['map:4' => false], dimInactive: true)))->not->toContain("\033[2m"); +}); + +it('does not write any untouched layer and preserves source scaffolding and unchanged colour-run rows', function () { + $root = layeredMapProject(); + $map = loadLayeredMap($root); + $path = $map->directory . '/layers/01.terrain.map.php'; + $source = file_get_contents($path); + foreach ($map->getStoredGridPaths() as $file) { + touch($file, 1000000000); + } + $before = sourceHashTree($map->directory); + $map->save(); + expect(sourceHashTree($map->directory))->toBe($before); + $map->setLayerCell('map:1', 0, 0, 'T', '', ''); + $map->setLayerCell('map:4', 0, 0, '|'); + $map->save(); + expect(file_get_contents($path))->toContain('// keep 01.terrain.map.php', "return <<<'AUTHORED'", '....') + ->and(filemtime($map->directory . '/layers/07.detail.deco.php'))->toBe(1000000000) + ->and(filemtime($map->eventPath))->toBe(1000000000); + $map->setLayerCell('map:1', 0, 0, '.', '', ''); + $map->save(); + expect(file_get_contents($path))->toBe($source); +}); + +it('retains CRLF source indentation comments and unedited rows', function () { + $root = layeredMapProject(); + $path = $root . '/assets/Maps/test-map/layers/04.buildings.map.php'; + $source = "setLayerCell('map:4', 2, 0, '|'); + $map->save(); + expect(file_get_contents($path))->toBe(str_replace(' / ', ' / | ', $source)); +}); + +it('preserves ragged per-row dimensions when creating and round-tripping layers', function () { + $root = layeredMapProject(true); + $map = loadLayeredMap($root); + $id = $map->createLayer('fixtures'); + $map->setLayerCell($id, 1, 1, 'i'); + $map->save(); + $loaded = loadLayeredMap($root); + foreach ($loaded->getLayerSet()->layers as $layer) { + expect(array_map(count(...), $layer->grid))->toBe([4, 2]); + } + expect($loaded->getLayerSymbol($id, 1, 1))->toBe('i'); +}); + +it('rolls the complete layered save and rename back after any installation failure', function () { + $map = loadLayeredMap(layeredMapProject()); + $before = sourceHashTree($map->directory); + $map->setLayerCell('map:1', 0, 0, 'T'); + $map->renameLayer('map:4', 'houses'); + $failing = new FailingFileSetOperations(failures: ['move' => [$map->directory . '/layers/04.houses.map.php']]); + expect(fn() => $map->save(files: $failing))->toThrow(FileSetTransactionFailure::class) + ->and(sourceHashTree($map->directory))->toBe($before) + ->and($map->isDirty())->toBeTrue(); + $map->save(); + expect(is_file($map->directory . '/layers/04.houses.map.php'))->toBeTrue() + ->and(is_file($map->directory . '/layers/04.buildings.map.php'))->toBeFalse() + ->and($map->getEditableData()['tiles2d']['layers'])->toHaveKey('houses'); +}); + +it('duplicates and moves every layer preserving authored bytes and numeric order', function () { + $root = layeredMapProject(); + $map = loadLayeredMap($root); + $before = sourceHashTree($map->directory . '/layers'); + $map->duplicateTo($root . '/assets/Maps/copy', 'copy', 'Copy'); + expect(sourceHashTree($root . '/assets/Maps/copy/layers'))->toBe($before); + $moved = $map->moveTo('district/new'); + expect($moved->mapId)->toBe('district/new') + ->and(sourceHashTree($moved->directory . '/layers'))->toBe($before) + ->and(is_dir($map->directory))->toBeFalse() + ->and(is_file($moved->directory . '/new.map.php'))->toBeFalse(); +}); + +it('refuses an executable unchanged or removed layer before save duplicate or move without executing it', function (string $action) { + $root = layeredMapProject(); + $map = loadLayeredMap($root); + $map->removeLayer('map:7'); + $path = $map->directory . '/layers/07.detail.deco.php'; + file_put_contents($path, " match ($action) { + 'save' => $map->save(), + 'duplicate' => $map->duplicateTo($root . '/assets/Maps/copy', 'copy', 'Copy'), + 'move' => $map->moveTo('new'), + })->toThrow(MapSourceRefusal::class, '07.detail.deco.php'); + expect(sourceHashTree($root))->toBe($before)->and(is_file($root . '/executed'))->toBeFalse(); +})->with(['save', 'duplicate', 'move']); + +it('isolates a malformed layer and refuses mismatched dimensions before evaluating data', function () { + $root = layeredMapProject(); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/layers/07.detail.deco.php', MapGridSource::buildSource('too short', 'BAD')); + file_put_contents($directory . '/test-map.data.php', "maps)->toHaveCount(2) + ->and($workspace->maps[1]->getGridSourceIssue())->toContain('07.detail.deco.php') + ->and($workspace->maps[0]->getGridSourceIssue())->toBeNull() + ->and(is_file($root . '/executed'))->toBeFalse(); +}); + +it('rejects externally changed or newly added layers without overwriting author work', function (bool $added) { + $map = loadLayeredMap(layeredMapProject()); + $path = $map->directory . '/layers/' . ($added ? '09.other.map.php' : '04.buildings.map.php'); + file_put_contents($path, MapGridSource::buildSource("new \n ", 'OTHER')); + $map->setLayerCell('map:1', 0, 0, 'X'); + $before = sourceHashTree($map->directory); + expect(fn() => $map->save())->toThrow(MapSourceRefusal::class) + ->and(sourceHashTree($map->directory))->toBe($before); +})->with([false, true]); + +it('converts legacy only when a layer is created and undo restores the original file shape', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Maps/test-map/test-map.map.php'; + file_put_contents($path, str_replace(['', ''], ['', ''], file_get_contents($path))); + $map = authoredMap($root); + $before = $map->captureLayerSnapshot(); + $source = file_get_contents($map->mapPath); + $map->createLayer('buildings'); + $map->save(); + expect(is_file($map->mapPath))->toBeFalse() + ->and(file_get_contents($map->directory . '/layers/00.terrain.map.php'))->toBe($source); + $map->restoreLayerSnapshot($before); + $map->save(); + expect(file_get_contents($map->mapPath))->toBe($source) + ->and(authoredMap($root)->isLegacyMap())->toBeTrue(); +}); + +it('rejects decoration glyphs without crops and reports normalized layer-specific crop tables', function () { + $map = loadLayeredMap(layeredMapProject()); + expect($map->getLayerTiles2d('map:4')['asset'])->toBe('Graphics/Tilesets/shared.png') + ->and($map->getLayerTiles2d('map:7')['asset'])->toBe('Graphics/Tilesets/detail.png') + ->and($map->getLayerTiles2d('map:1'))->toBe([]); + $before = sourceHashTree($map->directory); + $map->setLayerCell('map:7', 2, 0, '?'); + expect(fn() => $map->save())->toThrow(InvalidArgumentException::class, 'no crop mapping') + ->and(sourceHashTree($map->directory))->toBe($before); +}); + +it('composes terminal preview with the exact shared Engine style and transparency semantics', function () { + $root = layeredMapProject(); + $path = $root . '/assets/Maps/test-map/layers/04.buildings.map.php'; + file_put_contents($path, MapGridSource::buildSource(" / \n xx ", 'STYLE')); + $map = loadLayeredMap($root); + $expected = array_map(static fn(array $row): string => rtrim(implode('', $row)), $map->getLayerSet()->getComposedGrid()); + expect($map->renderPreview(4, 2, showNpcOverlay: true, terminalPreview: true))->toBe($expected); + $map->setLayerCell('map:4', 2, 0, 'x', '', ''); + $expected = array_map(static fn(array $row): string => rtrim(implode('', $row)), $map->getLayerSet()->getComposedGrid()); + expect($map->renderPreview(4, 2, terminalPreview: true))->toBe($expected); +}); + +it('converts a legacy flat crop table without losing authored comments expressions or crops', function () { + $root = makeTemporaryProject(); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/test-map.map.php', MapGridSource::buildSource("xx\nx ", 'MAP')); + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource(" \n ", 'EVENT')); + $source = <<<'PHP' + 'Test Map', + // Preserve the complete table, not merely its evaluated value. + 'tiles2d' => [ + 'asset' => 'Graphics/Tilesets/shared.png', + // An author-owned expression. + 'symbols' => ['x' => ['x' => 0, 'y' => 0, 'width' => $cropWidth, 'height' => 16]], + ], +]; +PHP; + file_put_contents($directory . '/test-map.data.php', $source); + $map = authoredMap($root); + $before = $map->captureLayerSnapshot(); + $crops = $map->getLayerTiles2d('tile'); + $map->createLayer('buildings'); + $after = $map->captureLayerSnapshot(); + $map->save(); + $converted = str_replace("'tiles2d' => [", "'tiles2d' => ['layers' => ['terrain' => [", $source); + $converted = str_replace(" ],\n];", " ]]],\n];", $converted); + expect(file_get_contents($map->dataPath))->toBe($converted) + ->and(authoredMap($root)->getLayerTiles2d('map:0'))->toBe($crops); + $map->restoreLayerSnapshot($before); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe($source); + $map->restoreLayerSnapshot($after); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe($converted); +}); + +it('restores removed crop-table source and layer bytes through undo after save', function () { + $root = layeredMapProject(); + $map = loadLayeredMap($root); + $before = $map->captureLayerSnapshot(); + $files = sourceHashTree($map->directory); + $map->removeLayer('map:7'); + $map->save(); + $map->restoreLayerSnapshot($before); + $map->save(); + expect(sourceHashTree($map->directory))->toBe($files); + $map->renameLayer('map:7', 'floors'); + $map->save(); + expect(file_get_contents($map->dataPath))->toContain("'floors' => ['asset' =>"); +}); + +it('validates combined named and flat collision fallback through the shared resolver', function () { + $root = layeredMapProject(); + file_put_contents($root . '/assets/Maps/collisions.php', <<<'PHP' + CollisionType::NONE, 'x' => CollisionType::PASS_THROUGH, + 'buildings' => ['/' => CollisionType::NONE]]; +PHP); + $map = loadLayeredMap($root); + $map->validateLayerContracts(); + $dictionary = require $root . '/assets/Maps/collisions.php'; + expect(Ichiloto\Engine\Field\MapCollisionResolver::resolveLayers($map->getLayerSet(), $dictionary))->toBe([[0, 0, 0, 0], [0, 0, 0, 0]]); + file_put_contents($root . '/assets/Maps/collisions.php', " []];"); + expect(fn() => $map->validateLayerContracts())->toThrow(InvalidArgumentException::class, 'Decoration'); +}); + +it('rolls every layered source back when a move changes relative data evaluation', function () { + $root = layeredMapProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($path, str_replace("'region' => ''", "'region' => basename(__DIR__)", file_get_contents($path))); + $map = loadLayeredMap($root); + $before = sourceHashTree($root); + expect(fn() => $map->moveTo('district/new'))->toThrow(RuntimeException::class, 'rolled back') + ->and(sourceHashTree($root))->toBe($before) + ->and(is_dir($root . '/assets/Maps/district'))->toBeFalse(); +}); + +it('keeps raw ANSI styling separate from editable glyphs and preserves untouched source rows', function (string $prefix, string $color) { + $root = layeredMapProject(); + $path = $root . '/assets/Maps/test-map/layers/04.buildings.map.php'; + $source = MapGridSource::buildSource($prefix . " / \033[0m\n xx ", 'ANSI'); + file_put_contents($path, $source); + $map = loadLayeredMap($root); + expect($map->getLayerSymbol('map:4', 1, 0))->toBe('/') + ->and($map->getLayerColor('map:4', 1, 0))->toBe($color); + $map->setLayerCell('map:4', 0, 1, '|'); + $map->save(); + expect(file_get_contents($path))->toBe(str_replace(' xx ', '|xx ', $source)); +})->with([ + ["\033[31m", 'red'], ["\033[38;2;12;34;56m", '#0c2238'], + ["\033[38;5;196m", '#ff0000'], ["\033[38;5;232m", '#080808'], +]); diff --git a/tests/Unit/LayeredPreviewTest.php b/tests/Unit/LayeredPreviewTest.php new file mode 100644 index 0000000..abb9b5f --- /dev/null +++ b/tests/Unit/LayeredPreviewTest.php @@ -0,0 +1,45 @@ + CollisionType::NONE, + 'buildings' => ['/' => CollisionType::SOLID, 'x' => CollisionType::PASS_THROUGH]]; +PHP); + $definition = CutsceneHydration::cinematic(['id' => 'preview', 'name' => 'Preview'], + [['type' => 'wait', 'seconds' => 1]], $root); + $preview = CinematicPreviewSession::start($root, $definition, ['mapId' => 'test-map', 'x' => 0, 'y' => 0]); + try { + $scene = new ReflectionProperty($preview, 'scene')->getValue($preview); + $manager = $scene->previewMap; + expect($manager->layers?->legacy)->toBeFalse() + ->and(array_keys($manager->layerTiles2d))->toBe(['buildings', 'detail']) + ->and(new ReflectionProperty(MapManager::class, 'collisionMap')->getValue($manager))->toBe([[0, 1, 0, 0], [0, 0, 0, 0]]); + $camera = $scene->previewCamera(); + ob_start(); + $camera->renderLayeredMap($manager->layers); + $output = ob_get_clean(); + expect($output)->toBe('') + ->and(implode("\n", $camera->frame()))->toContain('./..', '.xx.'); + $manager->unload(); + expect($manager->layers)->toBeNull() + ->and($manager->layerTiles2d)->toBe([]) + ->and($manager->tiles2d)->toBeNull() + ->and($manager->tileMap)->toBe([]) + ->and($manager->mapWidth)->toBe(0) + ->and($manager->mapHeight)->toBe(0) + ->and($camera->worldSpace)->toBe([]); + } finally { + $preview->dispose(); + } +}); diff --git a/tests/Unit/SourceCheckoutTest.php b/tests/Unit/SourceCheckoutTest.php new file mode 100644 index 0000000..0463269 --- /dev/null +++ b/tests/Unit/SourceCheckoutTest.php @@ -0,0 +1,23 @@ +toBe(realpath($root)); + $copy = disposableLastLegend(); + expect(is_link($copy . '/assets/Audio'))->toBeTrue() + ->and(file_get_contents($copy . '/assets/Audio/reference.wav'))->toBe('fixture audio bytes'); + } finally { + chdir($directory); + putenv($previous === false ? 'ICHILOTO_GAME_SRC' : 'ICHILOTO_GAME_SRC=' . $previous); + } +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..3ff4226 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,13 @@ +addPsr4('Ichiloto\\Engine\\', $source, prepend: true); +} diff --git a/tests/phpstan-engine.php b/tests/phpstan-engine.php new file mode 100644 index 0000000..df48e2a --- /dev/null +++ b/tests/phpstan-engine.php @@ -0,0 +1,14 @@ + [dirname(__DIR__) . '/phpstan.neon'], + 'parameters' => ['scanDirectories' => [$source]], +]; From 00024b0264d5a2e115b093aceb776f270cebf357 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 24 Sep 2026 15:33:55 +0200 Subject: [PATCH 030/344] feat(canvas): author every map layer with catalogue facade brushes Add layer cycling, visibility, dimming, exact composed terminal preview, layer lifecycle dialogs and read-only per-layer crop inspection with painting warnings. Remove the event-layer colour restriction. Vim, mouse, colour, selection, clipboard and undo workflows now apply to the active gameplay, decoration or event layer. Read facade shapes from the catalogue on every stamp. Keep Normal mouse clicks selection-only, require Paint mode for mouse stamps, and clear or refuse brushes after mode, map or layer changes. Keep contextual hints in borders and global commands in the bottom bar. Cover these workflows and refresh the manual and shell snapshot. Full PHP 8.5 and 8.4 suites each pass 1122 tests with 6516 assertions; both static checks are clean. --- docs/manual.md | 43 ++- src/Canvas/FacadeCatalogue.php | 32 +++ src/Canvas/LayerCanvas.php | 295 +++++++++++++++++++++ src/Editor.php | 179 +++++++------ src/History/PaintStrokeCommand.php | 7 +- src/IO/InputRouter.php | 2 +- src/UI/Modal.php | 1 + tests/Unit/CanvasToolsTest.php | 8 +- tests/Unit/LayeredCanvasTest.php | 234 ++++++++++++++++ tests/__snapshots__/main_shell_100x30.ansi | 2 +- 10 files changed, 706 insertions(+), 97 deletions(-) create mode 100644 src/Canvas/FacadeCatalogue.php create mode 100644 src/Canvas/LayerCanvas.php create mode 100644 tests/Unit/LayeredCanvasTest.php diff --git a/docs/manual.md b/docs/manual.md index 974a5d4..3300987 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -65,7 +65,7 @@ is what the code draws rather than a sketch of it: │ │ │ │ │ Background Music: (None) │ │ │ │ │ │ Music Variants · None │ │ │ │ │ │ Encounters · off │ - └─/:Filter Del:Delete─────────┘ └─i:Paint m:Map e:Event────┘ └─Enter:Edit─────────────────────┘ + └─/:Filter Del:Delete─────────┘ └─i:Paint []:Layer t:Terminal┘ └─Enter:Edit─────────────────────┘ ┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐ │ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 │ │ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │ @@ -179,6 +179,41 @@ asks first and defaults to Cancel. ## Canvas Panel +### Authored Layers + +Maps with `layers/` show every numbered gameplay (`NN.name.map.php`) and +decoration (`NN.name.deco.php`) layer, followed by Events. In Normal mode, +`[` and `]` cycle layers, `v` toggles the selected layer's visibility, `d` +dims inactive layers, and `t` opens a read-only terminal preview. That preview +always composes all gameplay layers, ignores decoration and event markers, +and ignores authoring visibility settings. Spaces above the base show through. +Layer visibility and dimming are session settings, not map data. + +Use `Ctrl+P` and select a layer by name, or choose a Layers action to create, +rename or remove it. New layers preserve each row's width, including ragged +maps. Renaming retains its numeric order and changes its local `tiles2d` key +without regenerating the crop table. If the actual resolved collisions would +change, the rename dialog names the change and requires `y` confirmation; +shared `collisions.php` is never changed. Removal is confirmed and undoable. +Creating a layer on a legacy map explicitly moves its terrain into +`layers/00.terrain.map.php` in the same save transaction; ordinary legacy +saves keep the original layout. + +Every layer uses the same Paint/Normal modes, tools, mouse strokes, selection, +colour, clipboard and undo. The inspector lists visibility and read-only +per-layer `tiles2d` atlas/crop mappings. Painting a mapped glyph, or replacing +one, shows a crop warning. Invalid decoration crop coverage is refused at save. +Untouched layer files are never written; unchanged rows retain their authored +bytes, and all changed files are saved or rolled back together. + +Text catalogues in `assets/Graphics/Tilesets/*.txt` appear as Facade brushes in +`Ctrl+P`. Separate multi-row shapes with blank lines. Select a brush to target +the gameplay `buildings` layer, then press `Enter` to stamp it as one undo +step. In Paint mode (`i`), a left click also stamps; Normal-mode clicks only +move the cursor. Mode and map changes clear the stamp brush; `b` returns to a +glyph brush. Every stamp rereads its catalogue, so +the catalogue remains the single source of shapes. Clipping never grows rows. + The canvas previews the selected map and is where you paint. It is modal, in the vim tradition: in **Normal mode** letters are commands, and in **Paint mode** every printable key is a glyph. This is what guarantees that no @@ -245,7 +280,7 @@ beside the viewport offset. `o` in Normal mode opens the brush colour picker: the 16 standard 4-bit ANSI colours (in the map format's Symfony colour names, where `gray` is bright black), rendered as live swatches, plus two brush states above them. -The brush colour applies to every paint on the Map layer - brush dabs, +The brush colour applies to every paint on every layer - brush dabs, shapes, flood fills - and is written as `` tags, exactly the styling authored by hand. @@ -260,8 +295,8 @@ styling authored by hand. The eyedropper (`k` / `Ctrl+K`) picks up a cell's colour along with its glyph; an uncoloured cell loads an uncoloured brush. A painted space is always uncoloured, so erasing never leaves invisible styling behind. Event -markers are authoring geometry and carry no colour; the picker says so on -the Event layer. The Status pane shows the brush colour beside the tool. +markers remain authoring geometry, but can now carry colour through the same +picker and undo workflow. The Status pane shows the brush colour beside the tool. ### NPC Mode diff --git a/src/Canvas/FacadeCatalogue.php b/src/Canvas/FacadeCatalogue.php new file mode 100644 index 0000000..de52e62 --- /dev/null +++ b/src/Canvas/FacadeCatalogue.php @@ -0,0 +1,32 @@ + new EditableGrid($block)->cells, + array_filter($blocks, static fn(string $block): bool => trim($block) !== ''), + )); + } + + public static function discover(string $root): array + { + $directory = $root . '/assets/Graphics/Tilesets'; + $files = is_dir($directory) ? (glob($directory . '/*.txt') ?: []) : []; + sort($files); + return $files; + } +} diff --git a/src/Canvas/LayerCanvas.php b/src/Canvas/LayerCanvas.php new file mode 100644 index 0000000..dfe2b35 --- /dev/null +++ b/src/Canvas/LayerCanvas.php @@ -0,0 +1,295 @@ +getLayerTiles2d($this->getActiveCanvasLayer())['symbols'] ?? []; + if (array_key_exists($symbol, $symbols) || array_key_exists($oldSymbol, $symbols)) { + $this->canvasPaintWarning = 'Crop mapping on this layer: painted glyph affects graphical artwork (tiles2d is read-only).'; + } + } catch (\Throwable $error) { + $this->canvasPaintWarning = $error->getMessage(); + } + } + + private function getCanvasLayerState(): array + { + $map = $this->getSelectedMap(); + $state = $this->canvasLayerStates[$map?->mapId ?? ''] ?? []; + return $state + ['selected' => $map?->getBaseLayerId() ?? MapLayers::BASE, + 'visibility' => [], 'dim' => false, 'terminal' => false]; + } + + private function getCanvasLayerTitle(): string + { + $map = $this->getSelectedMap(); + if ($map === null || $map->isLegacyMap() || $this->editingMode === self::MODE_NPC) { + return ''; + } + foreach ($map->getLayers() as $layer) { + if ($layer['id'] === $this->getActiveCanvasLayer()) { + return ' [' . $layer['name'] . ']'; + } + } + return ''; + } + + private function setCanvasLayerState(string $key, mixed $value): void + { + $this->canvasLayerStates[$this->getSelectedMap()?->mapId ?? ''][$key] = $value; + $this->renderCanvasArea(); + } + + private function selectCanvasLayer(string $id): void + { + $this->finalizeActiveStroke(); + $this->activeMousePaintButton = null; + $this->lastMousePaintPoint = null; + $this->canvasToolAnchor = null; + $this->canvasSelection = null; + $this->facadeBrush = null; + $this->canvasPaintWarning = ''; + $this->setCanvasLayerState('selected', $id); + $this->setCanvasLayerState('terminal', false); + $this->setEditingMode($id === MapLayers::EVENT ? self::MODE_EVENT : self::MODE_MAP); + } + + private function cycleCanvasLayer(int $step = 1): void + { + $ids = array_column($this->getSelectedMap()?->getLayers() ?? [], 'id'); + if ($ids === []) { + return; + } + $index = (int) array_search($this->getActiveCanvasLayer(), $ids, true); + $this->selectCanvasLayer($ids[($index + $step + count($ids)) % count($ids)]); + } + + private function toggleCanvasLayerVisibility(?string $id = null): void + { + $id ??= $this->getActiveCanvasLayer(); + $visibility = $this->getCanvasLayerState()['visibility']; + $visibility[$id] = ! ($visibility[$id] ?? true); + $this->setCanvasLayerState('visibility', $visibility); + } + + private function toggleCanvasLayerOption(string $option): void + { + $this->finalizeActiveStroke(); + $this->inputMode = self::INPUT_NORMAL; + $this->setCanvasLayerState($option, ! $this->getCanvasLayerState()[$option]); + } + + private function buildLayerPaletteItems(): array + { + $map = $this->getSelectedMap(); + if ($map === null || $map->getGridSourceIssue() !== null) { + return []; + } + $items = [ + new PaletteItem('Layers: Create gameplay layer', '', fn() => $this->openLayerPrompt('create')), + new PaletteItem('Layers: Create decoration layer', '', fn() => $this->openLayerPrompt('decoration')), + new PaletteItem('Layers: Rename selected layer', '', fn() => $this->openLayerPrompt('rename')), + new PaletteItem('Layers: Remove selected layer', '', fn() => $this->openLayerPrompt('remove')), + new PaletteItem('Layers: Terminal preview', 't', fn() => $this->toggleCanvasLayerOption('terminal')), + new PaletteItem('Layers: Dim inactive layers', 'd', fn() => $this->toggleCanvasLayerOption('dim')), + ]; + foreach ($map->getLayers() as $layer) { + $label = sprintf('%s %s (%s)', $layer['order'] === null ? '--' : sprintf('%02d', $layer['order']), + $layer['name'], $layer['id'] === MapLayers::EVENT ? 'events' : ($layer['decoration'] ? 'decoration' : 'gameplay')); + $items[] = new PaletteItem('Layer: ' . $label, '', fn() => $this->selectCanvasLayer($layer['id'])); + $items[] = new PaletteItem('Visibility: ' . $label, '', fn() => $this->toggleCanvasLayerVisibility($layer['id'])); + } + foreach (FacadeCatalogue::discover($this->workspace->projectRoot) as $path) { + try { + foreach (FacadeCatalogue::load($path) as $index => $rows) { + $items[] = new PaletteItem(sprintf('Facade: %s #%d (%dx%d)', basename($path), $index + 1, + max(array_map(count(...), $rows)), count($rows)), '', fn() => $this->selectFacadeBrush($path, $index)); + } + } catch (\Throwable $error) { + $items[] = new PaletteItem('Facade: ' . basename($path) . ' (unreadable)', '', fn() => $this->setStatus($error->getMessage(), StatusLevel::WARN)); + } + } + return $items; + } + + private function openLayerPrompt(string $action): void + { + $map = $this->getSelectedMap(); + if ($map === null) { + return; + } + $this->finalizeActiveStroke(); + $id = $this->getActiveCanvasLayer(); + $layer = array_values(array_filter($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $id))[0] ?? null; + $this->layerPrompt = ['action' => $action, 'id' => $id, 'name' => $action === 'rename' ? ($layer['name'] ?? '') : '']; + $this->modals->push(Modal::LAYER_EDIT); + $this->requestFullRender(); + } + + private function handleLayerPromptInput(string $input): void + { + if ($input === "\033") { + $this->layerPrompt = null; + $this->modals->remove(Modal::LAYER_EDIT); + $this->requestFullRender(); + return; + } + $prompt = $this->layerPrompt; + if ($prompt === null) { + return; + } + if (($input === "\r" || $input === "\n") && $prompt['action'] !== 'remove' && ! isset($prompt['confirmation']) + || ($input === 'y' && ($prompt['action'] === 'remove' || isset($prompt['confirmation'])))) { + $map = $this->getSelectedMap(); + $before = $map->captureLayerSnapshot(); + try { + if ($prompt['action'] === 'rename' && ! isset($prompt['confirmation'])) { + $change = $map->getLayerRenameCollisionChange($prompt['id'], $prompt['name']); + if ($change !== null) { + $this->layerPrompt['confirmation'] = $change; + $this->requestFullRender(); + return; + } + } + $id = match ($prompt['action']) { + 'create', 'decoration' => $map->createLayer($prompt['name'], $prompt['action'] === 'decoration'), + 'rename' => (function () use ($map, $prompt): string { + $map->renameLayer($prompt['id'], $prompt['name'], isset($prompt['confirmation'])); + return $prompt['id']; + })(), + 'remove' => (function () use ($map, $prompt): string { + $map->removeLayer($prompt['id']); + return $map->getBaseLayerId(); + })(), + }; + $after = $map->captureLayerSnapshot(); + $this->recordCommand(new GenericCommand('Layer ' . $prompt['action'], + fn() => $map->restoreLayerSnapshot($after), fn() => $map->restoreLayerSnapshot($before))); + $this->selectCanvasLayer($id); + $this->layerPrompt = null; + $this->modals->remove(Modal::LAYER_EDIT); + $this->setStatus('Layer change staged. Ctrl+S saves the complete file set.'); + } catch (\Throwable $error) { + $this->setStatus($error->getMessage(), StatusLevel::WARN); + } + } elseif (isset($prompt['confirmation'])) { + return; + } elseif ($input === "\177" || $input === "\010") { + $this->layerPrompt['name'] = mb_substr($prompt['name'], 0, -1); + } elseif (preg_match('/\A[a-zA-Z0-9_-]\z/', $input) === 1) { + $this->layerPrompt['name'] .= $input; + } + $this->requestFullRender(); + } + + private function renderLayerPrompt(array $layout): void + { + $width = min(68, $layout['width'] - 4); + $remove = $this->layerPrompt['action'] === 'remove'; + $confirmation = $this->layerPrompt['confirmation'] ?? null; + $message = $confirmation ?? ($remove ? 'Remove this layer and its painted cells?' : 'Name: ' . $this->layerPrompt['name']); + $lines = [...explode("\n", wordwrap($message, max(1, $this->getWindowContentWidth($width)), "\n", true)), + 'Rename keeps its numeric order. Undo restores the layer.', + 'New layers use the next free order; legacy terrain becomes 00.', + 'Collision lookup uses the layer name, not its order.']; + new EditorWindow( + title: 'Layer: ' . $this->layerPrompt['action'], + help: $confirmation !== null ? 'Y:Confirm collision change Esc:Cancel' : ($remove ? 'Y:Remove Esc:Cancel' : 'Enter:Apply Esc:Cancel'), + position: ['x' => max(2, intdiv($layout['width'] - $width, 2)), 'y' => 6], + width: $width, height: count($lines) + 2, + content: $this->fitLines($lines, $this->getWindowContentWidth($width), count($lines)), + )->render(); + } + + private function selectFacadeBrush(string $path, int $index): void + { + foreach ($this->getSelectedMap()?->getLayers() ?? [] as $layer) { + if ($layer['name'] === 'buildings' && ! $layer['decoration']) { + $this->selectCanvasLayer($layer['id']); + $this->facadeBrush = ['path' => $path, 'index' => $index, 'mapId' => $this->getSelectedMap()->mapId]; + $this->canvasTool = CanvasTool::BRUSH; + $this->inputMode = self::INPUT_NORMAL; + $this->focusedPane = self::FOCUS_CANVAS; + $this->setStatus('Facade brush selected. Enter stamps; i enables mouse painting; b returns to glyphs.'); + return; + } + } + $this->setStatus('Create a gameplay buildings layer before selecting a facade brush.', StatusLevel::WARN); + } + + private function stampFacadeBrush(): void + { + if ($this->facadeBrush === null) { + return; + } + $map = $this->getSelectedMap(); + $target = array_values(array_filter($map?->getLayers() ?? [], + fn(array $layer): bool => $layer['id'] === $this->getActiveCanvasLayer()))[0] ?? null; + if ($map?->mapId !== $this->facadeBrush['mapId'] || $this->editingMode !== self::MODE_MAP + || ($target['name'] ?? null) !== 'buildings' || ($target['decoration'] ?? true)) { + $this->facadeBrush = null; + $this->setStatus('Select the facade brush again on a gameplay buildings layer.', StatusLevel::WARN); + return; + } + try { + $rows = FacadeCatalogue::load($this->facadeBrush['path'])[$this->facadeBrush['index']] ?? null; + if ($rows === null) { + throw new \RuntimeException('This facade no longer exists in its catalogue. Select it again.'); + } + $writes = []; + foreach ($rows as $y => $row) { + foreach ($row as $x => $cell) { + $writes[] = ['x' => $this->cursorX + $x, 'y' => $this->cursorY + $y, + 'symbol' => $cell['symbol'], 'style' => ['prefix' => $cell['prefix'], 'suffix' => $cell['suffix']]]; + } + } + $this->applyCanvasWrites($map, $writes, 'Facade stamp'); + $this->renderCanvasArea(); + } catch (\Throwable $error) { + $this->setStatus($error->getMessage(), StatusLevel::WARN); + } + } + + private function getLayerInspectorFields(): array + { + $map = $this->getSelectedMap(); + if ($map === null) { + return []; + } + $id = $this->getActiveCanvasLayer(); + $fields = []; + foreach ($map->getLayers() as $layer) { + $fields[] = ['label' => ($id === $layer['id'] ? '> ' : ' ') . $layer['name'], + 'value' => ($this->getCanvasLayerState()['visibility'][$layer['id']] ?? true) ? 'visible' : 'hidden', 'editable' => false]; + } + try { + $tiles = $map->getLayerTiles2d($id); + } catch (\Throwable $error) { + return [...$fields, ['label' => 'tiles2d error', 'value' => $error->getMessage(), 'editable' => false]]; + } + $fields[] = ['label' => 'tiles2d (read-only)', 'value' => (string) ($tiles['asset'] ?? '(no atlas)'), 'editable' => false]; + foreach ($tiles['symbols'] ?? [] as $symbol => $crop) { + $fields[] = ['label' => ' ' . $symbol, 'value' => json_encode($crop, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'editable' => false]; + } + return $fields; + } +} diff --git a/src/Editor.php b/src/Editor.php index 8ffa84a..226ac31 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -109,6 +109,7 @@ */ final class Editor { + use \Ichiloto\Editor\Canvas\LayerCanvas; use CutscenesWorkspace; use CutsceneOutlinePane; use CutscenePreviewPane; @@ -1252,6 +1253,7 @@ private function buildInputRouter(): InputRouter $router->bindModal(Modal::UNSAVED_CHANGES_GUARD, $this->handleUnsavedChangesGuardInput(...)); $router->bindModal(Modal::DATABASE_ENTRY_DELETE_CONFIRMATION, $this->handleDatabaseEntryDeleteConfirmationInput(...)); $router->bindModal(Modal::RENAME_CONFIRMATION, $this->handleRenameConfirmationInput(...)); + $router->bindModal(Modal::LAYER_EDIT, $this->handleLayerPromptInput(...)); $router->bindModal(Modal::COMMAND_PALETTE, $this->handleCommandPaletteInput(...)); $router->bindModal(Modal::HELP, $this->handleHelpInput(...)); $router->bindModal(Modal::DATABASE, $this->handleDatabaseInput(...)); @@ -1367,6 +1369,11 @@ private function buildInputRouter(): InputRouter // glyph. The predicates keep NPC mode's own letters and the // other panes' typing untouched. KeyBinding::when($this->isNormalModeCommand('i'), $this->enterPaintMode(...), 'i', 'Canvas: enter Paint mode (every key paints; Esc returns to Normal)'), + KeyBinding::when($this->isNormalModeCommand(']'), fn() => $this->cycleCanvasLayer(), ']', 'Canvas: next layer'), + KeyBinding::when($this->isNormalModeCommand('['), fn() => $this->cycleCanvasLayer(-1), '[', 'Canvas: previous layer'), + KeyBinding::when($this->isNormalModeCommand('v'), fn() => $this->toggleCanvasLayerVisibility(), 'v', 'Canvas: toggle selected layer visibility'), + KeyBinding::when($this->isNormalModeCommand('d'), fn() => $this->toggleCanvasLayerOption('dim'), 'd', 'Canvas: dim inactive layers'), + KeyBinding::when($this->isNormalModeCommand('t'), fn() => $this->toggleCanvasLayerOption('terminal'), 't', 'Canvas: terminal gameplay preview'), KeyBinding::when($this->isNormalModeCommand('m'), fn() => $this->setEditingMode(self::MODE_MAP), 'm', 'Canvas: Map mode (paint tiles)'), KeyBinding::when($this->isNormalModeCommand('e'), fn() => $this->setEditingMode(self::MODE_EVENT), 'e', 'Canvas: Event mode (paint event markers)'), KeyBinding::when($this->isNormalModeCommand('n'), fn() => $this->setEditingMode(self::MODE_NPC), 'n', 'Canvas: NPC mode (place and edit the map\'s NPCs)'), @@ -3762,6 +3769,8 @@ private function moveSelection(int $step): void */ private function selectAsset(int $selectedIndex): void { + $this->facadeBrush = null; + $this->canvasPaintWarning = ''; if (! $this->workspace instanceof ProjectWorkspace || $selectedIndex === $this->selectedAssetIndex) { return; } @@ -3839,6 +3848,8 @@ private function moveCursor(int $deltaX, int $deltaY): void */ private function setEditingMode(string $mode): void { + $this->finalizeActiveStroke(); + $this->facadeBrush = null; $this->editingMode = $mode; $this->showEventOverlay = $mode === self::MODE_EVENT; @@ -4006,12 +4017,6 @@ private function openColorPicker(): void return; } - if ($this->editingMode === self::MODE_EVENT) { - $this->statusMessage = 'Colour applies to the Map layer; event markers are authoring geometry.'; - $this->renderFooter(); - return; - } - $this->isColorPickerOpen = true; $this->colorPaletteIndex = $this->currentColorPaletteIndex(); $this->statusMessage = 'Colour picker open.'; @@ -4132,7 +4137,7 @@ private function applyColorPaletteSelection(): void if ( $entry['value'] !== null && $selectedMap instanceof ProjectMap - && $this->editingMode !== self::MODE_EVENT + && $this->editingMode !== self::MODE_NPC ) { // Recolour in place: cells keep their glyphs and take the brush // colour, as one undoable stroke. A selection recolours every @@ -4147,7 +4152,7 @@ private function applyColorPaletteSelection(): void fn(array $cell): array => [ 'x' => $cell['x'], 'y' => $cell['y'], - 'symbol' => $selectedMap->getTileSymbol($cell['x'], $cell['y']), + 'symbol' => $this->readCanvasSymbol($selectedMap, $cell['x'], $cell['y']), 'color' => $this->selectedPaintColor, ], $cells, @@ -4447,6 +4452,14 @@ private function handleCanvasMouseEdit(MouseEvent $event): bool $focusChanged = $this->focusedPane !== self::FOCUS_CANVAS; $this->setFocusedPane(self::FOCUS_CANVAS, false); + if ($this->facadeBrush !== null && $this->inputMode === self::INPUT_PAINT + && $event->button === MouseButton::LEFT_BUTTON) { + $this->cursorX = $targetX; + $this->cursorY = $targetY; + $this->stampFacadeBrush(); + return true; + } + if ($this->editingMode === self::MODE_MAP && $this->inputMode === self::INPUT_NORMAL) { // The mouse honors the canvas's modality: in Normal mode a click // selects the cell, moving the cursor and reading out its @@ -4542,6 +4555,9 @@ private function paintCanvasStroke( MouseButton $button, bool $paintEvents = false, ): void { + if ($this->getCanvasLayerState()['terminal']) { + return; + } $isStartingStroke = $this->activeMousePaintButton !== $button || ! is_array($this->lastMousePaintPoint); $start = $isStartingStroke ? ['x' => $targetX, 'y' => $targetY] @@ -4557,7 +4573,7 @@ private function paintCanvasStroke( $this->finalizeActiveStroke(); $this->activeStrokeCommand = new PaintStrokeCommand( $selectedMap, - $paintEvents ? PaintStrokeCommand::LAYER_EVENT : PaintStrokeCommand::LAYER_TILE, + $this->getActiveCanvasLayer(), $paintEvents ? 'Event stroke' : 'Paint stroke', ); } @@ -4568,28 +4584,16 @@ private function paintCanvasStroke( ); foreach ($points as $point) { - if ( - $point['x'] < 0 || - $point['y'] < 0 || - $point['x'] >= $selectedMap->getWidth() || - $point['y'] >= $selectedMap->getHeight() - ) { - continue; - } - - if ($paintEvents) { - $oldSymbol = $selectedMap->getEventSymbol($point['x'], $point['y']); - $selectedMap->setEventSymbol($point['x'], $point['y'], $symbol); - $newSymbol = $selectedMap->getEventSymbol($point['x'], $point['y']); - $this->activeStrokeCommand->appendCell($point['x'], $point['y'], $oldSymbol, $newSymbol); + if (! $selectedMap->hasLayerCell($this->getActiveCanvasLayer(), $point['x'], $point['y'])) { continue; } - $oldSymbol = $selectedMap->getTileSymbol($point['x'], $point['y']); - $oldStyle = $selectedMap->getTileCellStyle($point['x'], $point['y']); + $oldSymbol = $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']); + $oldStyle = $selectedMap->getLayerCellStyle($this->getActiveCanvasLayer(), $point['x'], $point['y']); [$newPrefix, $newSuffix] = $this->resolvePaintStyle($symbol, $this->selectedPaintColor, $oldStyle); - $selectedMap->setTileCell($point['x'], $point['y'], $symbol, $newPrefix, $newSuffix); - $newSymbol = $selectedMap->getTileSymbol($point['x'], $point['y']); + $selectedMap->setLayerCell($this->getActiveCanvasLayer(), $point['x'], $point['y'], $symbol, $newPrefix, $newSuffix); + $newSymbol = $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']); + $this->recordCanvasCropWarning($selectedMap, $symbol, $oldSymbol); $this->activeStrokeCommand->appendCell( $point['x'], $point['y'], @@ -4682,6 +4686,10 @@ private function isNormalModeCommand(string $letter): Closure */ private function enterPaintMode(): void { + if ($this->getCanvasLayerState()['terminal']) { + $this->setStatus('Terminal preview is read-only. Press t to return to authoring.'); + return; + } if ($this->editingMode !== self::MODE_MAP && $this->editingMode !== self::MODE_EVENT) { $this->statusMessage = 'Paint mode needs the Map or Event layer. Press m or e first.'; $this->renderFooter(); @@ -4718,6 +4726,7 @@ private function leavePaintMode(string $statusMessage = 'Normal mode.'): void */ private function selectCanvasTool(CanvasTool $tool): void { + $this->facadeBrush = null; $this->canvasTool = $tool; $this->canvasToolAnchor = null; @@ -4736,9 +4745,14 @@ private function selectCanvasTool(CanvasTool $tool): void */ private function getActiveCanvasLayer(): string { - return $this->editingMode === self::MODE_EVENT - ? PaintStrokeCommand::LAYER_EVENT - : PaintStrokeCommand::LAYER_TILE; + if ($this->editingMode === self::MODE_EVENT) { + return PaintStrokeCommand::LAYER_EVENT; + } + $map = $this->getSelectedMap(); + $selected = $this->getCanvasLayerState()['selected']; + $ids = array_column($map?->getLayers() ?? [], 'id'); + return $selected !== PaintStrokeCommand::LAYER_EVENT && in_array($selected, $ids, true) + ? $selected : ($map?->getBaseLayerId() ?? PaintStrokeCommand::LAYER_TILE); } /** @@ -4751,9 +4765,7 @@ private function getActiveCanvasLayer(): string */ private function readCanvasSymbol(ProjectMap $map, int $x, int $y): string { - return $this->editingMode === self::MODE_EVENT - ? $map->getEventSymbol($x, $y) - : $map->getTileSymbol($x, $y); + return $map->getLayerSymbol($this->getActiveCanvasLayer(), $x, $y); } /** @@ -4767,12 +4779,8 @@ private function readCanvasSymbol(ProjectMap $map, int $x, int $y): string */ private function writeCanvasSymbol(ProjectMap $map, int $x, int $y, string $symbol): void { - if ($this->editingMode === self::MODE_EVENT) { - $map->setEventSymbol($x, $y, $symbol); - return; - } - - $map->setTileSymbol($x, $y, $symbol); + $style = $map->getLayerCellStyle($this->getActiveCanvasLayer(), $x, $y); + $map->setLayerCell($this->getActiveCanvasLayer(), $x, $y, $symbol, $style['prefix'], $style['suffix']); } /** @@ -4789,58 +4797,47 @@ private function writeCanvasSymbol(ProjectMap $map, int $x, int $y, string $symb */ private function applyCanvasWrites(ProjectMap $map, array $writes, string $label): int { - if ($writes === []) { + if ($writes === [] || $this->getCanvasLayerState()['terminal']) { return 0; } $this->finalizeActiveStroke(); - $isTileLayer = $this->getActiveCanvasLayer() === PaintStrokeCommand::LAYER_TILE; $stroke = new PaintStrokeCommand($map, $this->getActiveCanvasLayer(), $label); $changed = 0; foreach ($writes as $write) { - if ($write['x'] < 0 || $write['y'] < 0 || $write['x'] >= $map->getWidth() || $write['y'] >= $map->getHeight()) { + if (! $map->hasLayerCell($this->getActiveCanvasLayer(), $write['x'], $write['y'])) { continue; } $oldSymbol = $this->readCanvasSymbol($map, $write['x'], $write['y']); - if ($isTileLayer) { - $oldStyle = $map->getTileCellStyle($write['x'], $write['y']); - [$newPrefix, $newSuffix] = isset($write['style']) - ? [$write['style']['prefix'], $write['style']['suffix']] - : $this->resolvePaintStyle( - $write['symbol'], - $write['color'] ?? null, - $oldStyle, - ); - $map->setTileCell($write['x'], $write['y'], $write['symbol'], $newPrefix, $newSuffix); - $newSymbol = $this->readCanvasSymbol($map, $write['x'], $write['y']); - $stroke->appendCell( - $write['x'], - $write['y'], - $oldSymbol, - $newSymbol, - $oldStyle['prefix'], - $oldStyle['suffix'], - $newPrefix, - $newSuffix, + $oldStyle = $map->getLayerCellStyle($this->getActiveCanvasLayer(), $write['x'], $write['y']); + [$newPrefix, $newSuffix] = isset($write['style']) + ? [$write['style']['prefix'], $write['style']['suffix']] + : $this->resolvePaintStyle( + $write['symbol'], + $write['color'] ?? null, + $oldStyle, ); - - if ($oldSymbol !== $newSymbol || $oldStyle['prefix'] !== $newPrefix || $oldStyle['suffix'] !== $newSuffix) { - $changed++; - } - - continue; - } - - $this->writeCanvasSymbol($map, $write['x'], $write['y'], $write['symbol']); + $map->setLayerCell($this->getActiveCanvasLayer(), $write['x'], $write['y'], $write['symbol'], $newPrefix, $newSuffix); $newSymbol = $this->readCanvasSymbol($map, $write['x'], $write['y']); - $stroke->appendCell($write['x'], $write['y'], $oldSymbol, $newSymbol); + $stroke->appendCell( + $write['x'], + $write['y'], + $oldSymbol, + $newSymbol, + $oldStyle['prefix'], + $oldStyle['suffix'], + $newPrefix, + $newSuffix, + ); - if ($oldSymbol !== $newSymbol) { + if ($oldSymbol !== $newSymbol || $oldStyle['prefix'] !== $newPrefix || $oldStyle['suffix'] !== $newSuffix) { $changed++; } + + $this->recordCanvasCropWarning($map, $write['symbol'], $oldSymbol); } if ($stroke->hasChanges()) { @@ -4990,6 +4987,10 @@ private function describeCanvasToolState(): string */ private function applyCanvasToolAtCursor(): void { + if ($this->facadeBrush !== null) { + $this->stampFacadeBrush(); + return; + } $selectedMap = $this->getSelectedMap(); if (! $selectedMap instanceof ProjectMap) { @@ -5157,8 +5158,8 @@ private function pickSymbolUnderCursor(): void // an uncoloured cell loads an uncoloured brush. $pickedColor = null; - if ($this->editingMode !== self::MODE_EVENT) { - $pickedColor = $selectedMap->getTileColor($this->cursorX, $this->cursorY); + if ($this->editingMode !== self::MODE_NPC) { + $pickedColor = $selectedMap->getLayerColor($this->getActiveCanvasLayer(), $this->cursorX, $this->cursorY); $this->selectedPaintColor = $pickedColor ?? ''; } @@ -5253,9 +5254,7 @@ private function captureCanvasSelection(): int for ($columnIndex = 0; $columnIndex < $selection['width']; $columnIndex++) { $row[] = $this->readCanvasSymbol($selectedMap, $selection['x'] + $columnIndex, $selection['y'] + $rowIndex); - if ($this->getActiveCanvasLayer() === PaintStrokeCommand::LAYER_TILE) { - $styleRow[] = $selectedMap->getTileCellStyle($selection['x'] + $columnIndex, $selection['y'] + $rowIndex); - } + $styleRow[] = $selectedMap->getLayerCellStyle($this->getActiveCanvasLayer(), $selection['x'] + $columnIndex, $selection['y'] + $rowIndex); } $rows[] = $row; @@ -5778,6 +5777,7 @@ private function handleCommandPaletteInput(string $input, string $normalizedInpu private function buildPaletteItems(): array { $items = [ + ...$this->buildLayerPaletteItems(), new PaletteItem('Save Map', 'Ctrl+S', fn() => $this->saveSelectedMap()), new PaletteItem('Move Map to Derived Path', '', fn() => $this->beginExplicitMapMove()), new PaletteItem('Save All', 'Ctrl+A', fn() => $this->saveAllAssets()), @@ -13502,6 +13502,7 @@ private function getInspectorFields(): array 'editable' => false, ], ...$this->mapRuntimeFields($selectedMap), + ...$this->getLayerInspectorFields(), ]; if ($this->editingMode !== self::MODE_EVENT) { @@ -17419,11 +17420,12 @@ private function getCharacterPalette(): array // Erasing a glyph's last use can therefore never make it unpaintable. $palette = []; - foreach ($selectedMap->getCharacterPalette() as $symbol) { + foreach ($selectedMap->getCharacterPalette($this->getActiveCanvasLayer()) as $symbol) { $palette[$symbol] = $symbol; } - foreach ($this->workspace->getCollisionGlyphs() as $symbol) { + $layerName = array_values(array_filter($selectedMap->getLayers(), fn(array $layer): bool => $layer['id'] === $this->getActiveCanvasLayer()))[0]['name'] ?? null; + foreach ($this->workspace->getCollisionGlyphs($layerName) as $symbol) { $palette[$symbol] = $symbol; } @@ -17705,6 +17707,11 @@ private function renderModalOverlays(array $layout): bool return true; } + if ($this->modals->has(Modal::LAYER_EDIT)) { + $this->renderLayerPrompt($layout); + return true; + } + if ($this->isCommandPaletteOpen) { $this->renderCommandPaletteOverlay($layout); return true; @@ -18167,7 +18174,8 @@ private function createCanvasWindow(): EditorWindow $contentHeight = $bounds['height'] + ProjectWorkspace::CANVAS_HEADER_ROWS; return new EditorWindow( - title: $this->focusedPane === self::FOCUS_CANVAS ? 'Canvas [Focus]' : 'Canvas', + title: ($this->focusedPane === self::FOCUS_CANVAS ? 'Canvas [Focus]' : 'Canvas') + . ($this->getCanvasLayerState()['terminal'] ? ' [Terminal preview]' : $this->getCanvasLayerTitle()), help: match (true) { $this->isDestinationSpawnConfirmationOpen => 'Enter:Apply Esc:Back', $this->isDestinationSpawnSelectionOpen => 'Arrows:Move Enter:Select Spawn Esc:Cancel', @@ -18187,6 +18195,9 @@ private function createCanvasWindow(): EditorWindow ), default => $this->fitHelp( $layout['centerWidth'], + 'i:Paint []:Layer v:Hide d:Dim t:Terminal o:Colour Ctrl+P:Layers', + 'i:Paint []:Layer v:Hide t:Terminal Ctrl+P:Layers', + 'i:Paint []:Layer t:Terminal', 'i:Paint m:Map e:Event n:NPC c:Chars o:Colour ?:Help', 'i:Paint m:Map e:Event n:NPC c:Chars o:Colour', 'i:Paint m:Map e:Event', @@ -18208,6 +18219,10 @@ private function createCanvasWindow(): EditorWindow $this->selectedNpcIndex, $this->previewedNpcSprite(), ['x' => $this->cursorX, 'y' => $this->cursorY], + $this->getCanvasLayerState()['visibility'], + $this->getActiveCanvasLayer(), + $this->getCanvasLayerState()['terminal'], + $this->getCanvasLayerState()['dim'], ) ?? [], $contentWidth, $contentHeight @@ -18295,7 +18310,7 @@ private function createFooterWindow(): EditorWindow $this->cursorY, $this->canvasOffsetX, $this->canvasOffsetY, - $this->getFooterStatusText() + $this->canvasPaintWarning !== '' ? $this->canvasPaintWarning : $this->getFooterStatusText() ), ], $contentWidth, 2), ); diff --git a/src/History/PaintStrokeCommand.php b/src/History/PaintStrokeCommand.php index 3398870..e58dcd8 100644 --- a/src/History/PaintStrokeCommand.php +++ b/src/History/PaintStrokeCommand.php @@ -158,11 +158,6 @@ public function undo(): void */ private function applyCell(int $x, int $y, string $symbol, string $prefix, string $suffix): void { - if ($this->layer === self::LAYER_EVENT) { - $this->map->setEventSymbol($x, $y, $symbol); - return; - } - - $this->map->setTileCell($x, $y, $symbol, $prefix, $suffix); + $this->map->setLayerCell($this->layer, $x, $y, $symbol, $prefix, $suffix); } } diff --git a/src/IO/InputRouter.php b/src/IO/InputRouter.php index 2e38245..10df0c8 100644 --- a/src/IO/InputRouter.php +++ b/src/IO/InputRouter.php @@ -207,7 +207,7 @@ public function route(string $input): void $active = $this->modals->active(); // 1. Safety modals consume everything, including global shortcuts. - if ($active !== null && $active->priority() <= Modal::RENAME_CONFIRMATION->priority()) { + if ($active !== null && $active->priority() <= Modal::LAYER_EDIT->priority()) { $this->dispatchModal($active, $input, $normalized); return; } diff --git a/src/UI/Modal.php b/src/UI/Modal.php index 19911a0..c75e98d 100644 --- a/src/UI/Modal.php +++ b/src/UI/Modal.php @@ -33,6 +33,7 @@ enum Modal: string * The folder-move confirmation raised by a renaming save. */ case RENAME_CONFIRMATION = 'rename_confirmation'; + case LAYER_EDIT = 'layer_edit'; /** * The Ctrl+P command palette. Opens above the Database screen and the * main shell, below the safety modals. diff --git a/tests/Unit/CanvasToolsTest.php b/tests/Unit/CanvasToolsTest.php index dbd8f61..3a601c5 100644 --- a/tests/Unit/CanvasToolsTest.php +++ b/tests/Unit/CanvasToolsTest.php @@ -526,13 +526,15 @@ function tileRows(Editor $editor): array ->and($map->getTileColor(5, 2))->toBe('black'); }); -it('refuses the colour picker on the event layer and in Paint mode', function () { +it('allows event colour authoring while Paint mode still treats o as a glyph', function () { $editor = canvasEditor(); callEditorMethod($editor, 'dispatchInput', 'e'); callEditorMethod($editor, 'dispatchInput', 'o'); - expect(getEditorProperty($editor, 'isColorPickerOpen'))->toBeFalse() - ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Map layer'); + // Phase 2 removes the event-layer colour restriction; all layers use + // the same styled-cell workflow now. + expect(getEditorProperty($editor, 'isColorPickerOpen'))->toBeTrue(); + callEditorMethod($editor, 'dispatchInput', "\033"); callEditorMethod($editor, 'dispatchInput', 'm'); callEditorMethod($editor, 'dispatchInput', 'i'); diff --git a/tests/Unit/LayeredCanvasTest.php b/tests/Unit/LayeredCanvasTest.php new file mode 100644 index 0000000..b9f06af --- /dev/null +++ b/tests/Unit/LayeredCanvasTest.php @@ -0,0 +1,234 @@ +toBe($id); + } + callEditorMethod($editor, 'selectCanvasLayer', 'map:7'); + callEditorMethod($editor, 'dispatchInput', 'i'); + foreach (['[', ']', 'v', 'd', 't', 'o'] as $glyph) { + callEditorMethod($editor, 'dispatchInput', $glyph); + expect($map->getLayerSymbol('map:7', 0, 0))->toBe($glyph) + ->and(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:7'); + } +}); + +it('uses colour selection clipboard shapes fill eyedropper and undo on every layer', function (string $id) { + [$editor, $map] = layeredCanvasEditor(); + callEditorMethod($editor, 'selectCanvasLayer', $id); + callEditorMethod($editor, 'applyCanvasWrites', $map, [ + ['x' => 0, 'y' => 0, 'symbol' => 'Q', 'color' => 'red'], + ['x' => 1, 'y' => 0, 'symbol' => 'R', 'color' => 'blue'], + ], 'Layer test'); + $before = $map->captureGridSnapshot(); + setEditorProperty($editor, 'canvasSelection', ['x' => 0, 'y' => 0, 'width' => 2, 'height' => 1]); + callEditorMethod($editor, 'openColorPicker'); + setEditorProperty($editor, 'colorPaletteIndex', 5); // yellow + callEditorMethod($editor, 'dispatchInput', "\n"); + expect($map->getLayerSymbol($id, 0, 0))->toBe('Q') + ->and($map->getLayerColor($id, 0, 0))->toBe('yellow') + ->and($map->getLayerColor($id, 1, 0))->toBe('yellow'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); + callEditorMethod($editor, 'captureCanvasSelection'); + setEditorProperty($editor, 'cursorY', 1); + callEditorMethod($editor, 'pasteCanvasClipboard'); + expect($map->getLayerSymbol($id, 0, 1))->toBe('Q') + ->and($map->getLayerColor($id, 0, 1))->toBe('red'); + callEditorMethod($editor, 'pickSymbolUnderCursor'); + expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('Q') + ->and(getEditorProperty($editor, 'selectedPaintColor'))->toBe('red'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); + callEditorMethod($editor, 'selectCanvasTool', CanvasTool::LINE); + setEditorProperty($editor, 'cursorX', 0); + callEditorMethod($editor, 'applyCanvasToolAtCursor'); + setEditorProperty($editor, 'cursorX', 3); + callEditorMethod($editor, 'applyCanvasToolAtCursor'); + expect($map->getLayerSymbol($id, 3, 1))->toBe('Q'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); + setEditorProperty($editor, 'selectedPaintSymbol', 'F'); + callEditorMethod($editor, 'floodFillFromCursor'); + expect($map->getLayerSymbol($id, 3, 1))->toBe('F'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); +})->with(['map:1', 'map:4', 'map:7', 'event']); + +it('keeps mouse strokes layer-bound through undo and a later layer switch', function (string $id) { + [$editor, $map] = layeredCanvasEditor(); + callEditorMethod($editor, 'selectCanvasLayer', $id); + callEditorMethod($editor, 'enterPaintMode'); + setEditorProperty($editor, 'selectedPaintSymbol', 'M'); + setEditorProperty($editor, 'selectedPaintColor', 'cyan'); + $before = $map->captureGridSnapshot(); + $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + 2, $bounds['top'])); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<32;%d;%dM", $bounds['left'] + 2, $bounds['top'] + 1)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + 2, $bounds['top'] + 1)); + expect($map->getLayerSymbol($id, 2, 1))->toBe('M') + ->and($map->getLayerColor($id, 2, 1))->toBe('cyan'); + callEditorMethod($editor, 'selectCanvasLayer', $id === 'map:1' ? 'map:4' : 'map:1'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); + callEditorMethod($editor, 'dispatchInput', "\x19"); + expect($map->getLayerSymbol($id, 2, 1))->toBe('M'); +})->with(['map:1', 'map:4', 'map:7', 'event']); + +it('keeps visibility dimming and terminal preview out of authored state and blocks preview painting', function () { + [$editor, $map] = layeredCanvasEditor(); + $before = $map->captureLayerSnapshot(); + callEditorMethod($editor, 'dispatchInput', 'v'); + callEditorMethod($editor, 'dispatchInput', 'd'); + callEditorMethod($editor, 'dispatchInput', 't'); + callEditorMethod($editor, 'applyCanvasWrites', $map, [['x' => 0, 'y' => 0, 'symbol' => 'X']], 'preview'); + $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); + callEditorMethod($editor, 'enterPaintMode'); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'], $bounds['top'])); + expect($map->captureLayerSnapshot())->toBe($before)->and($map->isDirty())->toBeFalse(); + $frame = renderEditorPlainFrame($editor, 160, 45); + expect($frame)->toContain('Terminal preview', 'Ctrl+P:Palette'); + $footer = callEditorMethod($editor, 'createFooterWindow'); + expect($footer->help)->not->toContain('Layer', 'Colour', 'Terminal'); +}); + +it('shows crop tables read-only and keeps layer-specific painting warnings visible', function () { + [$editor, $map] = layeredCanvasEditor(); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + $fields = callEditorMethod($editor, 'getLayerInspectorFields'); + expect(array_column($fields, 'editable'))->each->toBeFalse() + ->and(json_encode($fields))->toContain('shared.png', '16'); + callEditorMethod($editor, 'applyCanvasWrites', $map, [['x' => 0, 'y' => 0, 'symbol' => 'x']], 'mapped'); + expect(getEditorProperty($editor, 'canvasPaintWarning'))->toContain('Crop mapping'); + callEditorMethod($editor, 'selectCanvasLayer', 'map:1'); + callEditorMethod($editor, 'applyCanvasWrites', $map, [['x' => 0, 'y' => 0, 'symbol' => 'x']], 'unmapped'); + expect(getEditorProperty($editor, 'canvasPaintWarning'))->toBe(''); +}); + +it('requires explicit collision-change confirmation before renaming and preserves local crop source bytes', function (bool $changes) { + [$editor, $map, $root] = layeredCanvasEditor(); + $dictionaryPath = $root . '/assets/Maps/collisions.php'; + file_put_contents($dictionaryPath, ' ["x" => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::SOLID], "houses" => ["x" => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::' . ($changes ? 'PASS_THROUGH' : 'SOLID') . '], "." => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::NONE];'); + $disk = sourceHashTree($root); + $before = $map->captureLayerSnapshot(); + $dataSource = file_get_contents($map->dataPath); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + callEditorMethod($editor, 'openLayerPrompt', 'rename'); + setEditorProperty($editor, 'layerPrompt', ['action' => 'rename', 'id' => 'map:4', 'name' => 'houses']); + callEditorMethod($editor, 'dispatchInput', "\n"); + if ($changes) { + expect($map->captureLayerSnapshot())->toBe($before) + ->and(sourceHashTree($root))->toBe($disk) + ->and(getEditorProperty($editor, 'layerPrompt')['confirmation'])->toContain('changes collision at 2 cell(s)', 'collisions.php stays unchanged'); + expect(renderEditorPlainFrame($editor, 160, 45))->toContain('Y:Confirm collision change'); + callEditorMethod($editor, 'dispatchInput', "\n"); + expect($map->captureLayerSnapshot())->toBe($before); + callEditorMethod($editor, 'dispatchInput', 'y'); + } + expect(getEditorProperty($editor, 'layerPrompt'))->toBeNull() + ->and(array_column($map->getLayers(), 'name'))->toContain('houses'); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe(str_replace("'buildings' =>", "'houses' =>", $dataSource)) + ->and(hash_file('sha256', $dictionaryPath))->toBe($disk['assets/Maps/collisions.php']); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe($dataSource) + ->and(is_file($map->directory . '/layers/04.buildings.map.php'))->toBeTrue(); +})->with([false, true]); + +it('creates renames and removes layers through modal controls as undoable operations', function () { + [$editor, $map] = layeredCanvasEditor(); + callEditorMethod($editor, 'openLayerPrompt', 'create'); + foreach (str_split('fixtures') as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + callEditorMethod($editor, 'dispatchInput', "\n"); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:8'); + $map->save(); + expect(is_file($map->directory . '/layers/08.fixtures.map.php'))->toBeTrue(); + callEditorMethod($editor, 'openLayerPrompt', 'remove'); + callEditorMethod($editor, 'dispatchInput', "\n"); + expect($map->getLayers())->toHaveCount(5); + callEditorMethod($editor, 'dispatchInput', 'y'); + expect($map->getLayers())->toHaveCount(4); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->getLayers())->toHaveCount(5); +}); + +it('reads facade brushes from the catalogue on every stamp and preserves multi-row shapes in one undo step', function () { + [$editor, $map, $root] = layeredCanvasEditor(); + $directory = $root . '/assets/Graphics/Tilesets'; + mkdir($directory, 0777, true); + $path = $directory . '/buildings.txt'; + file_put_contents($path, " /\nxx\n\nabc\n def\n"); + expect(FacadeCatalogue::load($path))->toHaveCount(2); + $items = callEditorMethod($editor, 'buildLayerPaletteItems'); + expect(array_filter($items, static fn($item): bool => str_starts_with($item->label, 'Facade:')))->toHaveCount(2); + callEditorMethod($editor, 'selectFacadeBrush', $path, 0); + $before = $map->captureGridSnapshot(); + callEditorMethod($editor, 'applyCanvasToolAtCursor'); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('x'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); + file_put_contents($path, " /\n||\n\nabc\n def\n"); + callEditorMethod($editor, 'applyCanvasToolAtCursor'); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('|') + ->and($map->getLayerSymbol('map:1', 0, 1))->toBe('.'); +}); + +it('keeps facade mouse clicks selection-only in Normal mode and stamps explicitly or in Paint mode', function () { + [$editor, $map, $root] = layeredCanvasEditor(); + mkdir($root . '/assets/Graphics/Tilesets', 0777, true); + $path = $root . '/assets/Graphics/Tilesets/buildings.txt'; + file_put_contents($path, " /\n||\n"); + callEditorMethod($editor, 'selectFacadeBrush', $path, 0); + $before = $map->captureGridSnapshot(); + $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); + $click = sprintf("\033[<0;%d;%dM", $bounds['left'], $bounds['top']); + $release = sprintf("\033[<0;%d;%dm", $bounds['left'], $bounds['top']); + callEditorMethod($editor, 'dispatchInput', $click); + callEditorMethod($editor, 'dispatchInput', $release); + expect($map->captureGridSnapshot())->toBe($before); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('|'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); + callEditorMethod($editor, 'dispatchInput', 'i'); + callEditorMethod($editor, 'dispatchInput', $click); + callEditorMethod($editor, 'dispatchInput', $release); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('|'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); +}); + +it('clears facade brushes on legacy mode commands and asset navigation', function (string $command) { + $root = layeredMapProject(); + loadLayeredMap($root)->duplicateTo($root . '/assets/Maps/zz-copy', 'zz-copy', 'Copy'); + [$editor, $map] = layeredCanvasEditor($root); + mkdir($root . '/assets/Graphics/Tilesets', 0777, true); + $path = $root . '/assets/Graphics/Tilesets/buildings.txt'; + file_put_contents($path, " /\n||\n"); + callEditorMethod($editor, 'selectFacadeBrush', $path, 0); + $before = $map->captureLayerSnapshot(); + if ($command === 'asset') { + setEditorProperty($editor, 'focusedPane', 'assets'); + callEditorMethod($editor, 'dispatchInput', 'j'); + expect(getEditorProperty($editor, 'selectedAssetIndex'))->toBe(1); + } else { + callEditorMethod($editor, 'dispatchInput', $command); + } + expect(getEditorProperty($editor, 'facadeBrush'))->toBeNull() + ->and($map->captureLayerSnapshot())->toBe($before); + callEditorMethod($editor, 'stampFacadeBrush'); + expect($map->captureLayerSnapshot())->toBe($before); +})->with(['m', 'e', 'n', 'asset']); diff --git a/tests/__snapshots__/main_shell_100x30.ansi b/tests/__snapshots__/main_shell_100x30.ansi index 2154c7c..eae4032 100644 --- a/tests/__snapshots__/main_shell_100x30.ansi +++ b/tests/__snapshots__/main_shell_100x30.ansi @@ -1 +1 @@ -[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint m:Map e:Event────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ ││ ││ ││ ││ │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file +[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint []:Layer t:Terminal┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > terrain · visible ││ Events · visible ││ tiles2d (read-only) ││ (no atlas) ││ │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file From 4c3147198e86e375da32f2b12437ea8f79ed2ec7 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 25 Sep 2026 13:02:52 +0200 Subject: [PATCH 031/344] chore(deps): lock published engine develop --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 70166a8..d977829 100644 --- a/composer.lock +++ b/composer.lock @@ -260,12 +260,12 @@ "source": { "type": "git", "url": "https://github.com/ichiloto/engine.git", - "reference": "c9bc37885cd0414867bbbcad2d4a8ba167d81010" + "reference": "9011d7e2c07bc991cf523ec2da6e20072d88efe1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ichiloto/engine/zipball/c9bc37885cd0414867bbbcad2d4a8ba167d81010", - "reference": "c9bc37885cd0414867bbbcad2d4a8ba167d81010", + "url": "https://api.github.com/repos/ichiloto/engine/zipball/9011d7e2c07bc991cf523ec2da6e20072d88efe1", + "reference": "9011d7e2c07bc991cf523ec2da6e20072d88efe1", "shasum": "" }, "require": { @@ -306,7 +306,7 @@ "issues": "https://github.com/ichiloto/engine/issues", "source": "https://github.com/ichiloto/engine/tree/develop" }, - "time": "2026-09-24T00:34:30+00:00" + "time": "2026-09-24T12:17:01+00:00" }, { "name": "league/climate", From 35a26ecf88fd937635dd6987b7a48ea771215002 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 25 Sep 2026 14:52:59 +0200 Subject: [PATCH 032/344] fix(maps): share repeated editable cell values Reuse exact glyph, prefix and suffix tuples within each parse using PHP copy-on-write arrays. Keep cells, maps and snapshots independently editable without a global cache or altered source rows. Cover exact styled identities, source restoration and a retained multi-map subprocess at the unchanged 128 MiB budget. Real Last Legend validation now fits that budget on PHP 8.5 and 8.4; their different default limits explained the original asymmetry. Verified the complete Editor candidate on PHP 8.5 and 8.4: 1143 tests, 62279 assertions, no skips, and clean PHPStan on both against installed Engine 9011d7e. --- src/Maps/EditableGrid.php | 14 +++-- tests/Unit/EditableGridTest.php | 103 ++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/EditableGridTest.php diff --git a/src/Maps/EditableGrid.php b/src/Maps/EditableGrid.php index 692da8c..b72a359 100644 --- a/src/Maps/EditableGrid.php +++ b/src/Maps/EditableGrid.php @@ -34,7 +34,10 @@ public function __construct(private string $text, private ?string $source = null public static function parseLines(array $lines, bool $legacyTags = false): array { - return array_map(static function (string $line) use ($legacyTags): array { + // Equal cells share PHP copy-on-write values, not mutable references. + // Keep the pool local to this parse so closed maps retain no cache. + $cellValues = []; + return array_map(static function (string $line) use ($legacyTags, &$cellValues): array { preg_match_all('/<[^>]+>|[^<]+| $styled[2] ?? $symbol, - 'prefix' => implode('', $tags) . ($styled[1] ?? ''), - 'suffix' => ($styled[3] ?? '') . str_repeat('', count($tags))]; + $glyph = $styled[2] ?? $symbol; + $prefix = implode('', $tags) . ($styled[1] ?? ''); + $suffix = ($styled[3] ?? '') . str_repeat('', count($tags)); + $cells[] = $cellValues[$prefix][$suffix][$glyph] ??= [ + 'symbol' => $glyph, 'prefix' => $prefix, 'suffix' => $suffix, + ]; } } } diff --git a/tests/Unit/EditableGridTest.php b/tests/Unit/EditableGridTest.php new file mode 100644 index 0000000..9ff46fe --- /dev/null +++ b/tests/Unit/EditableGridTest.php @@ -0,0 +1,103 @@ +000x\n\033[31m00\033[0m0\n0"; + $source = MapGridSource::buildSource($text, 'IDENTITIES'); + $grid = new EditableGrid($text, $source); + expect($grid->cells)->toBe([ + [ + ['symbol' => '0', 'prefix' => '', 'suffix' => ''], + ['symbol' => '0', 'prefix' => '', 'suffix' => ''], + ['symbol' => '0', 'prefix' => '', 'suffix' => ''], + ['symbol' => 'x', 'prefix' => '', 'suffix' => ''], + ], + [ + ['symbol' => '0', 'prefix' => "\033[31m", 'suffix' => "\033[0m"], + ['symbol' => '0', 'prefix' => "\033[31m", 'suffix' => "\033[0m"], + ['symbol' => '0', 'prefix' => '', 'suffix' => ''], + ], + [['symbol' => '0', 'prefix' => '', 'suffix' => '']], + ])->and($grid->getSource())->toBe($source); +}); + +it('keeps repeated cell values independently editable with exact source and snapshot restoration', function () { + $text = "xx\nxx\nx"; + $source = MapGridSource::buildSource($text, 'CELLS', "// Preserve the authored rows.\n"); + $grid = new EditableGrid($text, $source); + $before = $grid->captureSnapshot(); + $cell = &$grid->cells[0][0]; + $cell['symbol'] = 'y'; + $cell['prefix'] = ''; + unset($cell); + + expect($grid->cells[0][1])->toBe(['symbol' => 'x', 'prefix' => '', 'suffix' => '']) + ->and($grid->cells[1])->toBe($before['cells'][1]) + ->and($grid->cells[2])->toBe($before['cells'][2]) + ->and($before['cells'][0][0]['symbol'])->toBe('x') + ->and($grid->getSource())->toBe(str_replace( + "xx\nxx", + "yx\nxx", + $source, + )); + $edited = $grid->captureSnapshot(); + $restored = EditableGrid::createFromSnapshot($before); + expect($restored->getSource())->toBe($source) + ->and(array_map(count(...), $restored->cells))->toBe([2, 2, 1]); + $redone = EditableGrid::createFromSnapshot($edited); + expect($redone->getSource())->toBe($grid->getSource()); + $redone->cells[1][0]['suffix'] = ''; + expect($grid->cells[1][0]['suffix'])->toBe('') + ->and($redone->cells[1][1]['suffix'])->toBe(''); +}); + +it('holds a multi-map workload of repeated editable cells within the unchanged 128 MiB CLI budget', function () { + $probe = <<<'PHP' + require $argv[1]; + $lines = []; + for ($row = 0; $row < 96; $row++) { + $lines[] = '' . str_repeat('.', 80) . '' . str_repeat(' ', 80 - $row % 2); + } + $text = implode("\n", $lines); + $source = \Ichiloto\Engine\Field\MapGridSource::buildSource($text, 'CELLS'); + $grids = []; + $cellCount = 0; + for ($index = 0; $index < 30; $index++) { + $grid = new \Ichiloto\Editor\Maps\EditableGrid($text, $source); + $cellCount += array_sum(array_map(count(...), $grid->cells)); + $grids[] = $grid; + } + $grids[0]->cells[0][0]['symbol'] = '#'; + echo json_encode([ + 'grids' => count($grids), + 'cells' => $cellCount, + 'independent' => $grids[1]->cells[0][0]['symbol'] === '.' && $grids[0]->cells[0][1]['symbol'] === '.', + 'limit' => ini_get('memory_limit'), + 'peak' => memory_get_peak_usage(true), + ], JSON_THROW_ON_ERROR); + PHP; + $process = proc_open( + [PHP_BINARY, '-d', 'memory_limit=128M', '-r', $probe, dirname(__DIR__) . '/bootstrap.php'], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + ); + expect(is_resource($process))->toBeTrue(); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + expect($exitCode)->toBe(0, $error) + ->and($error)->toBe(''); + $result = json_decode($output, true, flags: JSON_THROW_ON_ERROR); + expect($result['grids'])->toBe(30) + ->and($result['cells'])->toBe(459360) + ->and($result['independent'])->toBeTrue() + ->and($result['limit'])->toBe('128M') + ->and($result['peak'])->toBeLessThan(128 * 1024 * 1024); +}); From 82833ad8a3ee1aa77e588af9214df173a89fadbf Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 25 Sep 2026 14:53:19 +0200 Subject: [PATCH 033/344] fix(npcs): respect map-owned interaction sprites Remove the forced @ fallback and blank-sprite warning for explicitly empty base sprites. Keep missing-sprite defaults and whitespace-only or style-only warnings. Draw no unselected overlay for an empty sprite and highlight the underlying map cell at its selected anchor without replacing glyphs. Preserve the NPC identity, dialogue and collision anchor. Cover the real Sprite field clear/save/reload/undo/redo path, layered and legacy overlays, and installed Engine NPC parity. Document map-owned appearance. Full PHP 8.5/8.4 suites and PHPStan pass against locked Engine 9011d7e. --- docs/manual.md | 14 ++- src/Field/ProjectNpc.php | 7 +- src/ProjectMap.php | 22 ++++- src/Validation/ProjectValidator.php | 7 +- tests/Unit/NpcAuthoringTest.php | 137 ++++++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 9 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 3300987..5cc2ea6 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -304,8 +304,10 @@ picker and undo workflow. The Status pane shows the brush colour beside the tool an overlay - sprites at their authored anchor, wide glyphs occupying the two columns the game gives them, styled sprites as the plain glyph - and nothing you do here paints a tile or an event marker. The selected NPC is shown in -brackets. NPC mode sits on `n` in Normal mode and on `F3` everywhere, so no paintable -character is taken from you (and not a control byte, since the terminal driver +brackets, except an explicitly empty sprite: selection highlights its existing +map cell without replacing the glyph or neighbouring cells. An unselected empty +sprite draws no overlay. NPC mode sits on `n` in Normal mode and on `F3` +everywhere, so no paintable character is taken from you (and not a control byte, since the terminal driver reserves the remaining ones). | Key | Action | @@ -360,6 +362,14 @@ heading (`Dialogue variant 2 · when switch:gate_open` once it has a condition) with short row labels under it, and long lines wrap, so what a character says is read in the pane rather than in the edit buffer. +- **Map-owned appearance.** For a fixed interaction already drawn on a map + layer (such as a mounted notice on `fixtures`), clear the `Sprite` text + completely and press `Enter`. This stores `'sprite' => ''`, not a missing + field, and removes the duplicate NPC overlay. The NPC retains its stable id, + dialogue, conditions and blocking anchor. Select it at that anchor, through + `L`, or with `[` / `]`; selection highlights the underlying cell. Omitting + `sprite` still defaults to `@`. Whitespace-only or style-only sprite text + still warns: use genuinely empty text when the map owns the appearance. - **Movement.** `wander` roams one tile at a time; the wander bounds only appear while wandering, and loaded bounds are kept (not shown) for a fixed NPC. Omitting every bound leaves the game's unbounded wander. Patrol routes, diff --git a/src/Field/ProjectNpc.php b/src/Field/ProjectNpc.php index a3c44e4..5b10156 100644 --- a/src/Field/ProjectNpc.php +++ b/src/Field/ProjectNpc.php @@ -113,13 +113,18 @@ public function getVisibleSprite(): string /** * Returns a sprite as the terminal shows it: style tags stripped, - * measured the way the engine measures it, `@` when nothing is left. + * measured the way the engine measures it. An explicit empty sprite + * draws no overlay; style-only source retains the diagnostic fallback. * * @param string $sprite The sprite as authored. * @return string The visible glyph(s). */ public static function visibleGlyph(string $sprite): string { + if ($sprite === '') { + return ''; + } + $stripped = self::strippedGlyph($sprite); return $stripped === '' ? '@' : $stripped; diff --git a/src/ProjectMap.php b/src/ProjectMap.php index fb2b135..169ad31 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -639,15 +639,18 @@ public function renderPreview( $tileCell = $tileRow[$column] ?? null; $tileSymbol = is_array($tileCell) ? $tileCell['symbol'] : ' '; + // A selected map-owned NPC marks the existing cell, never a replacement sprite. + $anchorHighlight = array_key_exists($column, $npcCells[$row] ?? []) ? "\033[7m" : ''; if (! $this->layers->legacy && is_array($tileCell)) { - $styled = TerminalText::formatStyles($tileCell['prefix'] . $tileSymbol . $tileCell['suffix']); - $mergedSymbols[] = ($tileCell['dim'] ?? false) ? "\033[2m" . $styled . "\033[0m" : $styled; + $styled = TerminalText::formatStyles($tileCell['prefix'] . $anchorHighlight . $tileSymbol . $tileCell['suffix']); + $styled = ($tileCell['dim'] ?? false) ? "\033[2m" . $styled . "\033[0m" : $styled; + $mergedSymbols[] = $anchorHighlight === '' ? $styled : $styled . "\033[0m"; continue; } $ansiOpen = is_array($tileCell) ? self::ansiOpenForPrefix((string) ($tileCell['prefix'] ?? '')) : null; - $dim = ($tileCell['dim'] ?? false) ? "\033[2m" : ''; + $dim = (($tileCell['dim'] ?? false) ? "\033[2m" : '') . $anchorHighlight; $mergedSymbols[] = $ansiOpen === null && $dim === '' ? $tileSymbol : $dim . $ansiOpen . $tileSymbol . "\033[0m"; } @@ -714,13 +717,15 @@ private static function ansiOpenForPrefix(string $prefix): ?string * cell holds an empty string, so the terminal draws the wide glyph in * the space it needs rather than a symbol shoved half under it. The * selected NPC is drawn with brackets around a one-column sprite, or as - * itself when wide, since brackets would misalign the row. + * itself when wide, since brackets would misalign the row. An explicitly + * empty sprite contributes only a selected anchor (null), highlighting + * the underlying map cell without replacing its glyph or neighbours. * * @param int|null $selectedNpcIndex The NPC to mark selected. * @param string|null $selectedNpcSprite A glyph to draw for the selected * NPC in place of its base sprite -- a directional sprite being * previewed -- as authored; it is shown as the terminal would show it. - * @return array> The cells. + * @return array> The cells, or selected map-owned anchors. */ private function npcOverlayCells(?int $selectedNpcIndex, ?string $selectedNpcSprite = null): array { @@ -737,6 +742,13 @@ private function npcOverlayCells(?int $selectedNpcIndex, ?string $selectedNpcSpr $columns = max(1, mb_strwidth($sprite)); } + if ($sprite === '') { + if ($index === $selectedNpcIndex) { + $cells[$y][$x] = null; + } + continue; + } + if ($index === $selectedNpcIndex && $columns === 1) { $sprite = '[' . $sprite . ']'; $x = max(0, $x - 1); diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 5c3ea22..0bab66d 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -2020,7 +2020,8 @@ protected function checkNpcPlacement(array $entry, string $where, int $width, in } /** - * Checks the base sprite: present as text, visible, and inside the map. + * Checks the base sprite: text and inside the map, with explicit empty + * text reserved for an interaction whose appearance belongs to the map. * * @param array $entry The NPC entry. * @param string $where Where it lives. @@ -2041,6 +2042,10 @@ protected function checkNpcSprite(array $entry, string $where, int $width): arra )]; } + if ($entry['sprite'] === '') { + return []; + } + $sprite = strval($entry['sprite']); if (trim(ProjectNpc::strippedGlyph($sprite)) === '') { diff --git a/tests/Unit/NpcAuthoringTest.php b/tests/Unit/NpcAuthoringTest.php index 250eba4..e1d2ec1 100644 --- a/tests/Unit/NpcAuthoringTest.php +++ b/tests/Unit/NpcAuthoringTest.php @@ -12,6 +12,7 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Validation\ProjectValidator; use Ichiloto\Editor\Validation\Severity; +use Ichiloto\Engine\IO\Console\TerminalText; /** * Writes a map's data file from an array, the way the fixtures are written. @@ -240,6 +241,20 @@ function npcIssueLines(string $root, ?Severity $severity = null): array ->and(ProjectNpc::strippedGlyph('<'))->toBe('<'); }); +it('keeps explicit empty NPC sprites distinct from missing sprites and diagnostic fallbacks', function () { + $entry = ['id' => 'notice', 'name' => 'Notice', 'x' => 2, 'y' => 1]; + $empty = new ProjectNpc($entry + ['sprite' => '']); + + expect($empty->getSprite())->toBe('') + ->and($empty->getVisibleSprite())->toBe('') + ->and(ProjectNpc::visibleGlyph(''))->toBe('') + ->and($empty->getSpriteWidth())->toBe(1, 'interaction and selection retain their anchor') + ->and($empty->toArray()['sprite'])->toBe('') + ->and(new ProjectNpc($entry)->getVisibleSprite())->toBe('@') + ->and(ProjectNpc::visibleGlyph(''))->toBe('@') + ->and(ProjectNpc::visibleGlyph(' '))->toBe(' '); +}); + it('refuses to change an id through with(), and folds dialogue both ways', function () { $npc = ProjectNpc::createAt('a', 'A', 0, 0); @@ -402,6 +417,50 @@ function npcIssueLines(string $root, ?Severity $severity = null): array expect($previewed[3])->toBe('# [^]#'); }); +it('leaves map-owned NPC glyphs untouched and highlights only their selected anchor', function (bool $layered, string $prefix, string $suffix) { + $root = $layered ? layeredMapProject() : npcProject([])[0]; + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + $map->setLayerCell($map->getBaseLayerId(), 1, 0, 'T', $prefix, $suffix); + $map->setNpcs(NpcCollection::fromMapData([ + ['id' => 'notice', 'name' => 'Notice', 'sprite' => '', 'x' => 1, 'y' => 0, 'movement' => 'fixed', 'dialogue' => [['text' => 'Read me.']]], + ])); + $before = $map->captureLayerSnapshot(); + $files = npcHashTree($root); + $plain = $map->renderPreview(4, 2, showEventOverlay: false); + $selected = $map->renderPreview(4, 2, showEventOverlay: false, showNpcOverlay: true, selectedNpcIndex: 0); + + expect($map->renderPreview(4, 2, showEventOverlay: false, showNpcOverlay: true))->toBe($plain) + ->and(array_map(TerminalText::stripAnsi(...), $selected))->toBe(array_map(TerminalText::stripAnsi(...), $plain)) + ->and($selected[0])->toContain("\033[7m") + ->and(TerminalText::stripAnsi($selected[0]))->not->toContain('@', '[', ']') + ->and($map->getNpcs()->indexAt(1, 0))->toBe(0); + + // Highlight the actual visible stack, respecting hidden layers and dimming. + $hidden = ['map:4' => false]; + $plainHidden = $map->renderPreview(4, 2, layerVisibility: $hidden, activeLayer: 'event', dimInactive: true); + $selectedHidden = $map->renderPreview(4, 2, showNpcOverlay: true, selectedNpcIndex: 0, layerVisibility: $hidden, activeLayer: 'event', dimInactive: true); + expect(array_map(TerminalText::stripAnsi(...), $selectedHidden))->toBe(array_map(TerminalText::stripAnsi(...), $plainHidden)) + ->and($selectedHidden[0])->toContain("\033[7m") + ->and($map->renderPreview(4, 2, terminalPreview: true, showNpcOverlay: true, selectedNpcIndex: 0))->toBe($map->renderPreview(4, 2, terminalPreview: true)); + if ($layered) { + expect($selectedHidden[0])->toContain("\033[7mT"); + } + + $allHidden = array_fill_keys(array_column($map->getLayers(), 'id'), false); + $anchor = $map->renderPreview(4, 2, showNpcOverlay: true, selectedNpcIndex: 0, layerVisibility: $allHidden); + expect($anchor[0])->toContain("\033[7m \033[0m") + ->and($anchor[0])->not->toContain('@'); + + $directional = $map->renderPreview(4, 2, showNpcOverlay: true, selectedNpcIndex: 0, selectedNpcSprite: '^'); + expect($directional[0])->toContain('[^]') + ->and($map->captureLayerSnapshot())->toBe($before) + ->and(npcHashTree($root))->toBe($files); +})->with([ + 'legacy' => [false, '', ''], + 'layered' => [true, '', ''], + 'layered ANSI reset' => [true, "\033[0m\033[32m", "\033[0m"], +]); + it('moves an NPC on the canvas, and undoes and redoes the move', function () { [$root] = npcProject([['id' => 'a', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1]]); $editor = npcEditor($root); @@ -545,6 +604,68 @@ function npcIssueLines(string $root, ?Severity $severity = null): array ->and(npcField($editor, 'sprite')['value'])->toBe('@'); }); +it('authors an explicit empty base sprite through Inspector input and round-trips it with undo', function (?string $initialSprite) { + $entry = ['id' => 'notice', 'name' => 'Notice', 'x' => 3, 'y' => 2, 'movement' => 'fixed', 'dialogue' => [['text' => 'Read me.']], 'authorNote' => 'keep']; + if ($initialSprite !== null) { + $entry['sprite'] = $initialSprite; + } + [$root, $path] = npcProject([$entry]); + $source = file_get_contents($path); + $grids = npcHashTree($root . '/assets/Maps'); + $editor = npcEditor($root); + $map = npcMap($editor); + callEditorMethod($editor, 'selectNpc', 0); + setEditorProperty($editor, 'focusedPane', 'inspector'); + restNpcCursorOn($editor, 'sprite'); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'isDatabaseEditing'))->toBeTrue() + ->and(getEditorProperty($editor, 'databaseEditBuffer'))->toBe($initialSprite ?? '@'); + callEditorMethod($editor, 'dispatchInput', "\x7f"); + expect(getEditorProperty($editor, 'databaseEditBuffer'))->toBe(''); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect($map->getNpcs()->get(0)?->toArray()['sprite'])->toBe('') + ->and(npcField($editor, 'sprite')['value'])->toBe('') + ->and(getEditorProperty($editor, 'statusMessage'))->toBe('Sprite updated.'); + callEditorMethod($editor, 'saveSelectedMap'); + $edited = file_get_contents($path); + expect(npcsOnDisk($path)[0])->toBe(array_replace($entry, ['sprite' => ''])) + ->and($edited)->toContain("'sprite' => ''"); + $reloaded = npcEditor($root); + setEditorProperty($reloaded, 'cursorX', 3); + setEditorProperty($reloaded, 'cursorY', 2); + callEditorMethod($reloaded, 'dispatchInput', "\r"); + expect(getEditorProperty($reloaded, 'selectedNpcIndex'))->toBe(0) + ->and(npcMap($reloaded)->getNpcs()->count())->toBe(1) + ->and(npcField($reloaded, 'sprite')['value'])->toBe('') + ->and(npcMap($reloaded)->getNpcs()->get(0)?->getVisibleSprite())->toBe(''); + + // The installed Engine keeps identity, dialogue and the occupied anchor without a sprite. + $scene = new class extends \Ichiloto\Engine\Scenes\Game\GameScene { + public function __construct() + { + $this->currentMapId = 'test-map'; + $this->gameState = new \Ichiloto\Engine\Core\GameState(); + $this->party = new \Ichiloto\Engine\Entities\Party(); + } + }; + $manager = new \Ichiloto\Engine\Field\NpcManager($scene); + $manager->configure(npcsOnDisk($path)); + expect($manager->findById('notice')?->sprite)->toBe('') + ->and($manager->findById('notice')?->dialogue)->toBe($entry['dialogue']) + ->and($manager->npcAt(3, 2)?->id)->toBe('notice') + ->and($manager->visibleNpcs())->toHaveCount(1); + + callEditorMethod($editor, 'performUndo'); + callEditorMethod($editor, 'saveSelectedMap'); + expect(file_get_contents($path))->toBe($source); + callEditorMethod($editor, 'performRedo'); + callEditorMethod($editor, 'saveSelectedMap'); + expect(file_get_contents($path))->toBe($edited); + $after = npcHashTree($root . '/assets/Maps'); + unset($grids['test-map/test-map.data.php'], $after['test-map/test-map.data.php']); + expect($after)->toBe($grids, 'NPC appearance never rewrites map or event grids'); +})->with(['authored sprite' => 'N', 'missing sprite' => [null]]); + it('authors directional sprites, previews the one under the cursor, and removes them cleanly', function () { [$root] = npcProject([['id' => 'a', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1]]); $editor = npcEditor($root); @@ -813,6 +934,22 @@ function npcIssueLines(string $root, ?Severity $severity = null): array // -- Validation ------------------------------------------------------------ +it('accepts explicit empty base sprites but still warns on whitespace and style-only sprites', function (array $appearance, bool $warns) { + [$root] = npcProject([ + ['id' => 'notice', 'name' => 'Notice', 'x' => 3, 'y' => 2, 'movement' => 'fixed'] + $appearance, + ]); + $messages = implode("\n", npcIssueLines($root)); + expect(str_contains($messages, 'NPC Notice: Its sprite draws nothing.'))->toBe($warns) + ->and(implode("\n", npcIssueLines($root, Severity::ERROR)))->not->toContain('NPC Notice'); +})->with([ + 'explicit empty' => [['sprite' => ''], false], + 'missing' => [[], false], + 'spaces' => [['sprite' => ' '], true], + 'formatter only' => [['sprite' => ''], true], + 'ANSI only' => [['sprite' => "\033[31m\033[0m"], true], + 'non-string false' => [['sprite' => false], true], +]); + it('reports every malformed NPC shape with the runtime consequence', function () { [$root] = npcProject([ 'not an entry', From 8f30d4bf430740b5b4b7d20b22facf5ffaa1f7b1 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 25 Sep 2026 14:53:38 +0200 Subject: [PATCH 034/344] test(maps): verify production layered authoring integrity Exercise every production gameplay and event layer through paint, transactional save, undo and redo while preserving untouched bytes, mtimes and authored row widths. Include ragged synthetic decoration layers because the migrated production maps currently contain none. Verify composed terminal preview independently of canvas visibility and reject noncanonical grid source before execution or save, duplicate and move writes. Assert complete production map inventory and fail unusable explicitly configured fixtures rather than skipping them. Verified all 30 migrated maps at Game 940748f against installed locked Engine 9011d7e. Full Editor suites pass on PHP 8.5 and 8.4 with 1143 tests, 62279 assertions and no skips; PHPStan is clean on both. --- tests/Unit/LastLegendMapIntegrityTest.php | 259 +++++++++++++++++++++- 1 file changed, 248 insertions(+), 11 deletions(-) diff --git a/tests/Unit/LastLegendMapIntegrityTest.php b/tests/Unit/LastLegendMapIntegrityTest.php index cc9ae0b..0f8f9bf 100644 --- a/tests/Unit/LastLegendMapIntegrityTest.php +++ b/tests/Unit/LastLegendMapIntegrityTest.php @@ -3,8 +3,16 @@ declare(strict_types=1); use Ichiloto\Editor\Field\MapEncounters; +use Ichiloto\Editor\History\CommandHistory; +use Ichiloto\Editor\History\PaintStrokeCommand; +use Ichiloto\Editor\MapSourceRefusal; +use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Field\MapGridSource; +use Ichiloto\Engine\Field\MapLayer; +use Ichiloto\Engine\Field\MapLayerSource; +use Ichiloto\Engine\IO\Console\TerminalText; /** * Production evidence over disposable copies of Last Legend: the maps that @@ -21,6 +29,7 @@ */ function mapTreeState(string $root): array { + clearstatcache(); $state = []; $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/assets/Maps', FilesystemIterator::SKIP_DOTS)); @@ -38,15 +47,25 @@ function mapTreeState(string $root): array return $state; } +function createLastLegendMapIntegrityProject(string $prefix): ?string +{ + $root = disposableLastLegend($prefix); + if (getenv('ICHILOTO_GAME_SRC')) { + expect($root)->not->toBeNull('ICHILOTO_GAME_SRC is pinned but its Last Legend fixture is unusable'); + } + + return $root; +} + it('opens every Last Legend map, and merely opening, validating and saving clean writes nothing', function () { - $root = disposableLastLegend('last-legend-map-integrity-'); + $root = createLastLegendMapIntegrityProject('last-legend-map-integrity-'); if ($root === null) { $this->markTestSkipped('No Last Legend checkout is pinned (ICHILOTO_GAME_SRC).'); } - foreach (glob($root . '/assets/Maps/**') ?: [] as $ignored) { - // glob only forces the copy to exist; the workspace does the walk. + foreach (array_keys(mapTreeState($root)) as $path) { + touch($root . '/' . $path, 1000000000); } $before = mapTreeState($root); @@ -59,6 +78,7 @@ function mapTreeState(string $root): array $preserved++; } + $map->validateLayerContracts(); $map->save(); } @@ -67,7 +87,7 @@ function mapTreeState(string $root): array }); it('edits one metadata field on the require-and-enum map and restores it byte-for-byte', function () { - $root = disposableLastLegend('last-legend-map-edit-'); + $root = createLastLegendMapIntegrityProject('last-legend-map-edit-'); if ($root === null) { $this->markTestSkipped('No Last Legend checkout is pinned (ICHILOTO_GAME_SRC).'); @@ -75,9 +95,7 @@ function mapTreeState(string $root): array $directory = $root . '/assets/Maps/overworld/garden-of-roads'; - if (! is_dir($directory)) { - $this->markTestSkipped('The baseline holds no garden-of-roads map.'); - } + expect(is_dir($directory))->toBeTrue('the pinned game must contain Garden of Roads'); $before = mapTreeState($root); $map = ProjectMap::fromDirectory($root . '/assets/Maps', $directory); @@ -119,7 +137,7 @@ function mapTreeState(string $root): array }); it('round-trips Garden BGM and encounters through the accepted map runtime metadata model', function () { - $root = disposableLastLegend('last-legend-map-garden-'); + $root = createLastLegendMapIntegrityProject('last-legend-map-garden-'); if ($root === null) { $this->markTestSkipped('No Last Legend checkout is pinned (ICHILOTO_GAME_SRC).'); @@ -127,9 +145,7 @@ function mapTreeState(string $root): array $directory = $root . '/assets/Maps/overworld/garden-of-roads'; - if (! is_dir($directory)) { - $this->markTestSkipped('The baseline holds no garden-of-roads map.'); - } + expect(is_dir($directory))->toBeTrue('the pinned game must contain Garden of Roads'); $map = ProjectMap::fromDirectory($root . '/assets/Maps', $directory); $before = (string) file_get_contents($map->dataPath); @@ -157,3 +173,224 @@ function mapTreeState(string $root): array $map->save(); expect((string) file_get_contents($map->dataPath))->toBe($before, 'the committed bytes are back'); }); + +/** Independently inventory the source tree so read-only or undiscovered maps cannot evade coverage. */ +function getIntegrityMaps(string $root, bool $production): array +{ + $files = array_keys(mapTreeState($root)); + $dataFiles = array_values(array_filter($files, static fn(string $path): bool => str_ends_with($path, '.data.php'))); + $gridFiles = array_values(array_filter($files, static fn(string $path): bool => preg_match('/\.(map|deco|event)\.php$/', $path) === 1)); + $workspace = ProjectWorkspace::fromProject($root); + expect(count($workspace->maps))->toBeGreaterThanOrEqual($production ? 30 : 1); + $loadedData = []; + $loadedGrids = []; + foreach ($workspace->maps as $map) { + expect($map->getGridSourceIssue())->toBeNull($map->mapId) + ->and($map->dataSourceIssue())->toBeNull($map->mapId) + ->and($map->isLegacyMap())->toBeFalse($map->mapId) + ->and(is_file($map->mapPath))->toBeFalse($map->mapId . ' has no root map grid'); + $map->validateLayerContracts(); + $loadedData[] = substr($map->dataPath, strlen($root) + 1); + foreach ($map->getLayers() as $layer) { + $loadedGrids[] = substr($layer['path'], strlen($root) + 1); + } + } + sort($loadedData); + sort($loadedGrids); + expect($loadedData)->toBe($dataFiles, 'every authored map was opened') + ->and($loadedGrids)->toBe($gridFiles, 'every gameplay, decoration and event file was opened'); + + return $workspace->maps; +} + +/** Prefer an occupied cell, but also exercise intentionally empty layers. */ +function findIntegrityCell(ProjectMap $map, string $layer): array +{ + $first = null; + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x < $map->getWidth(); $x++) { + if ($map->hasLayerCell($layer, $x, $y)) { + $first ??= [$x, $y]; + if ($map->getLayerSymbol($layer, $x, $y) !== ' ') { + return [$x, $y]; + } + } + } + } + expect($first)->not->toBeNull($map->mapId . '/' . $layer . ' must have an editable cell'); + + return $first; +} + +/** Raw source lines include their exact line endings, indentation and formatter tags. */ +function assertIntegrityRowEdit(string $before, string $after, int $row, string $path): void +{ + $bodyLine = null; + foreach (token_get_all($before, TOKEN_PARSE) as $token) { + if (is_array($token) && $token[0] === T_START_HEREDOC) { + $bodyLine = $token[2]; + break; + } + } + expect($bodyLine)->not->toBeNull($path); + $oldLines = preg_split('/(?<=\n)|(?<=\r)(?!\n)/', $before); + $newLines = preg_split('/(?<=\n)|(?<=\r)(?!\n)/', $after); + expect(count($newLines))->toBe(count($oldLines), $path . ' source line count'); + expect($newLines[$bodyLine + $row])->not->toBe($oldLines[$bodyLine + $row], $path . ' edited row'); + $newLines[$bodyLine + $row] = $oldLines[$bodyLine + $row]; + expect($newLines)->toBe($oldLines, $path . ' all other rows and source scaffolding'); +} + +it('round-trips every authored gameplay decoration and event layer through save undo and redo', function (bool $production) { + $root = $production ? createLastLegendMapIntegrityProject('last-legend-layer-roundtrip-') : layeredMapProject(true); + if ($root === null) { + $this->markTestSkipped('No Last Legend checkout is pinned (ICHILOTO_GAME_SRC).'); + } + $maps = getIntegrityMaps($root, $production); + $visited = []; + foreach ($maps as $map) { + foreach ($map->getLayers() as $layer) { + foreach (array_keys(mapTreeState($root)) as $path) { + touch($root . '/' . $path, 1000000000); + } + $before = mapTreeState($root); + $path = $layer['path']; + $relativePath = substr($path, strlen($root) + 1); + $source = (string) file_get_contents($path); + $widths = array_map(count(...), MapLayer::parseGrid(MapGridSource::readFile($path))); + [$x, $y] = findIntegrityCell($map, $layer['id']); + $symbol = $map->getLayerSymbol($layer['id'], $x, $y); + $style = $map->getLayerCellStyle($layer['id'], $x, $y); + // Preserve decoration crop keys and event identities; recolouring is still a real edit. + $painted = $layer['decoration'] || $layer['id'] === 'event' ? $symbol : ($symbol === '#' ? '@' : '#'); + $prefix = $style['prefix'] === '' ? '' : ''; + $command = new PaintStrokeCommand($map, $layer['id']); + $command->appendCell($x, $y, $symbol, $painted, $style['prefix'], $style['suffix'], $prefix, ''); + $history = new CommandHistory(); + $command->execute(); + $history->record($command); + expect($command->hasChanges())->toBeTrue(); + $map->save(); + $edited = (string) file_get_contents($path); + assertIntegrityRowEdit($source, $edited, $y, $relativePath); + expect(array_map(count(...), MapLayer::parseGrid(MapGridSource::readFile($path))))->toBe($widths, $relativePath . ' row widths'); + $reloaded = ProjectDirectoryContext::run($root, fn() => ProjectMap::fromDirectory($root . '/assets/Maps', $map->directory)); + expect($reloaded->getLayerSymbol($layer['id'], $x, $y))->toBe($painted) + ->and($reloaded->getLayerCellStyle($layer['id'], $x, $y))->toBe(['prefix' => $prefix, 'suffix' => '']) + ->and($reloaded->isDirty())->toBeFalse(); + $after = mapTreeState($root); + expect(array_keys($after))->toBe(array_keys($before)); + foreach ($before as $file => $state) { + if ($file !== $relativePath) { + expect($after[$file])->toBe($state, $file . ' untouched bytes and mtime'); + } + } + expect($after[$relativePath][0])->not->toBe($before[$relativePath][0]); + expect($history->undo())->toBe($command); + $map->save(); + expect(file_get_contents($path))->toBe($source, $relativePath . ' undo after save'); + expect($history->redo())->toBe($command); + $map->save(); + expect(file_get_contents($path))->toBe($edited, $relativePath . ' redo after save'); + $history->undo(); + $map->save(); + $restored = mapTreeState($root); + foreach ($before as $file => $state) { + expect($restored[$file][0])->toBe($state[0], $file . ' restored bytes'); + if ($file !== $relativePath) { + expect($restored[$file][1])->toBe($state[1], $file . ' untouched mtime after history'); + } + } + $clean = mapTreeState($root); + $map->save(); + expect(mapTreeState($root))->toBe($clean, 'saving the restored clean map writes nothing'); + $visited[] = $relativePath; + } + } + sort($visited); + expect($visited)->toBe(array_values(array_filter(array_keys(mapTreeState($root)), static fn(string $path): bool => preg_match('/\.(map|deco|event)\.php$/', $path) === 1))); +})->with(['ragged probe' => false, 'Last Legend' => true]); + +it('previews every authored map as the Engine composed terminal grid including unsaved edits', function (bool $production) { + $root = $production ? createLastLegendMapIntegrityProject('last-legend-layer-preview-') : layeredMapProject(true); + if ($root === null) { + $this->markTestSkipped('No Last Legend checkout is pinned (ICHILOTO_GAME_SRC).'); + } + $before = mapTreeState($root); + foreach (getIntegrityMaps($root, $production) as $map) { + $composed = MapLayerSource::loadFromDirectory($map->directory, $map->mapId)->getComposedGrid(); + $expected = array_map(static fn(array $row): string => rtrim(implode('', $row)), $composed); + $snapshot = $map->captureLayerSnapshot(); + $hidden = array_fill_keys(array_column($map->getLayers(), 'id'), false); + expect($map->renderPreview($map->getWidth(), $map->getHeight(), terminalPreview: true))->toBe($expected, $map->mapId); + expect($map->renderPreview($map->getWidth(), $map->getHeight(), showNpcOverlay: true, layerVisibility: $hidden, activeLayer: 'event', terminalPreview: true, dimInactive: true))->toBe($expected, 'canvas overlays and visibility do not change terminal output'); + $cropped = array_map(static fn(array $row): string => rtrim(implode('', array_slice($row, 1, 3))), array_slice($composed, 1, 4)); + expect($map->renderPreview(3, 4, 1, 1, terminalPreview: true))->toBe(array_pad($cropped, 4, ''), $map->mapId . ' clipped viewport'); + $gameplay = []; + foreach ($map->getLayers() as $layer) { + if ($layer['decoration'] || $layer['id'] === 'event') { + [$x, $y] = findIntegrityCell($map, $layer['id']); + $map->setLayerCell($layer['id'], $x, $y, '!', '', ''); + expect($map->renderPreview($map->getWidth(), $map->getHeight(), terminalPreview: true))->toBe($expected, $layer['path'] . ' is terminal-invisible'); + } else { + $gameplay[] = $layer['id']; + } + } + $top = $gameplay[array_key_last($gameplay)]; + [$x, $y] = findIntegrityCell($map, $top); + $symbol = TerminalText::stripAnsi($composed[$y][$x]) === '#' ? '@' : '#'; + $map->setLayerCell($top, $x, $y, $symbol, '', ''); + $composed[$y][$x] = TerminalText::visibleSymbols('' . $symbol . '')[0]; + $edited = array_map(static fn(array $row): string => rtrim(implode('', $row)), $composed); + expect($edited)->not->toBe($expected) + ->and($map->renderPreview($map->getWidth(), $map->getHeight(), terminalPreview: true))->toBe($edited, 'unsaved topmost gameplay edit is visible'); + $map->restoreLayerSnapshot($snapshot); + expect($map->renderPreview($map->getWidth(), $map->getHeight(), terminalPreview: true))->toBe($expected); + } + expect(mapTreeState($root))->toBe($before, 'preview never rewrites authored files'); +})->with(['ragged probe' => false, 'Last Legend' => true]); + +it('refuses executable source in every authored layer before evaluation save duplicate or move', function (bool $production) { + $root = $production ? createLastLegendMapIntegrityProject('last-legend-layer-refusal-') : layeredMapProject(true); + if ($root === null) { + $this->markTestSkipped('No Last Legend checkout is pinned (ICHILOTO_GAME_SRC).'); + } + foreach (getIntegrityMaps($root, $production) as $map) { + $data = (string) file_get_contents($map->dataPath); + $dataMtime = filemtime($map->dataPath); + file_put_contents($map->dataPath, "getLayers() as $layer) { + $path = $layer['path']; + $source = (string) file_get_contents($path); + $mtime = filemtime($path); + $injected = "file_put_contents(" . var_export($root . '/grid-executed', true) . ", 'yes');\n"; + file_put_contents($path, preg_replace('/<\?php\s*/', " ProjectMap::fromDirectory($root . '/assets/Maps', $map->directory); + expect($load)->toThrow(MapSourceRefusal::class, basename($path)) + ->and($load)->toThrow(MapSourceRefusal::class, 'T_STRING at line 2'); + foreach ([ + fn() => $map->save(), + fn() => $map->duplicateTo($root . '/assets/Maps/refused-copy', 'refused-copy', 'Refused Copy'), + fn() => $map->moveTo('refused-move'), + ] as $action) { + expect($action)->toThrow(MapSourceRefusal::class, basename($path)); + expect(mapTreeState($root))->toBe($poisoned, $path . ' refusal leaves all bytes and mtimes untouched'); + } + expect(is_file($root . '/grid-executed'))->toBeFalse() + ->and(is_file($root . '/data-executed'))->toBeFalse() + ->and(is_dir($root . '/assets/Maps/refused-copy'))->toBeFalse() + ->and(is_dir($root . '/assets/Maps/refused-move'))->toBeFalse(); + } finally { + file_put_contents($path, $source); + touch($path, $mtime); + } + } + } finally { + file_put_contents($map->dataPath, $data); + touch($map->dataPath, $dataMtime); + } + } +})->with(['ragged probe' => false, 'Last Legend' => true]); From 0cbaa52e220a9279c8d067d5a313c7ecdeed445a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 25 Sep 2026 16:16:36 +0200 Subject: [PATCH 035/344] test(maps): cover mixed material authoring workflow Exercise real canvas input for mapped wood, kitchen tile, stone, rugs, carpet and cosmetic wall detail. Preserve blank walkable floors, solid gameplay walls and a separate interactive fixture through layer switching, undo/redo, save and reload. Verify distinct per-layer crop definitions, terminal preview and unmapped-decoration save refusal. Document the existing read-only atlas/crop authoring gap and a recipe for painting predefined materials; no runtime or renderer changes. Full Editor suites on PHP 8.5 and 8.4 each pass 1145 tests and 62339 assertions with no skips. PHPStan passes both against installed locked Engine 9011d7e. --- docs/manual.md | 32 ++++ tests/Unit/MixedMaterialAuthoringTest.php | 186 ++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 tests/Unit/MixedMaterialAuthoringTest.php diff --git a/docs/manual.md b/docs/manual.md index 5cc2ea6..a17fc1c 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -225,6 +225,38 @@ Normal. While painting, the Status pane shows `[PAINT]` beside the mode. Control-byte and function-key shortcuts (`Ctrl+S`, `F3`, ...) work in both modes, since they are not glyphs. +### Mixed Floor and Wall Materials + +Keep walkable floors as spaces and solid walls as wall glyphs on gameplay +layers. Put wood, kitchen tile, stone, rugs, carpet, and purely cosmetic wall +writing or ornaments on decoration layers. Their markers select graphical +crops; they never change terminal glyphs or collision. A readable notice or +other interactive fixture still needs its own gameplay glyph and interaction, +not just a decoration marker. + +1. Start with decoration layers and their per-layer `tiles2d` mappings already + defined in the map source. For example, a `floors` layer might map `w`, `k` + and `s` to wood, kitchen tile and stone, with `r`/`c` on a higher `rugs` + layer for rugs/red carpet. A `wall-detail` layer can use its own markers + for ornamental writing and trim over solid gameplay walls. +2. Focus the canvas, use `[` / `]` or `Ctrl+P` to choose the layer, and press + `i` to paint its mapped markers. The crop warning is expected. `Esc` + returns to Normal mode before switching layers; in Paint mode brackets + paint characters rather than switching. The same tools and mouse strokes + work on every decoration layer. +3. Use `Ctrl+Z` / `Ctrl+Y` to undo/redo, even after switching layers, then + `Ctrl+S` to save. `Ctrl+R` reloads the saved workspace and clears history. + Press `t` in Normal mode to check terminal preview: floors remain blank, + walls retain their gameplay glyphs, and interaction markers remain visible. + +**Current authoring gap:** the TUI displays atlas paths and crop rectangles +read-only. It can place existing mapped materials, but cannot select a new +material asset or add/change a glyph's crop mapping. Creating a decoration +layer does not create those mappings; they must currently be authored in the +map's `.data.php` source outside the TUI. Saving an unmapped decoration glyph +is refused before writing. There is no graphical material picker or image +preview in this workflow; the canvas shows authoring markers, not the artwork. + ### Normal mode | Key | Action | diff --git a/tests/Unit/MixedMaterialAuthoringTest.php b/tests/Unit/MixedMaterialAuthoringTest.php new file mode 100644 index 0000000..06d4f82 --- /dev/null +++ b/tests/Unit/MixedMaterialAuthoringTest.php @@ -0,0 +1,186 @@ + $blank, + '02.floors.deco.php' => $blank, + '03.rugs.deco.php' => $blank, + '04.walls.map.php' => $walls, + '05.wall-detail.deco.php' => $blank, + '06.fixtures.map.php' => $fixtures, + ] as $file => $grid) { + file_put_contents($directory . '/layers/' . $file, MapGridSource::buildSource($grid, 'ROOM', "// Keep $file.\n")); + } + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource($blank, 'EVENTS')); + $crops = []; + foreach (['floors' => ['w', 'k', 's'], 'rugs' => ['r', 'c'], 'wall-detail' => ['w', 'o']] as $layer => $symbols) { + $y = count($crops) * 16; + foreach ($symbols as $x => $symbol) { + $crops[$layer]['symbols'][$symbol] = ['x' => $x * 16, 'y' => $y, 'width' => 16, 'height' => 16]; + } + } + file_put_contents($directory . '/test-map.data.php', " 'Mixed materials', 'region' => '', 'events' => [], + 'npcs' => [['id' => 'notice', 'name' => 'Notice', 'x' => 4, 'y' => 0, 'sprite' => '', + 'movement' => 'fixed', 'dialogue' => [['text' => 'Read the notice.']]]], + 'tiles2d' => ['asset' => 'Graphics/Tilesets/materials.png', 'layers' => $crops], + ], true) . ";\n"); + file_put_contents($root . '/assets/Maps/collisions.php', <<<'PHP' + CollisionType::NONE, '#' => CollisionType::SOLID, 'i' => CollisionType::SOLID]; +PHP); + return $root; +} + +/** Select a cell with a Normal-mode mouse click, then paint through the real key router. */ +function paintMixedMaterial(Editor $editor, int $x, int $y, string $symbol): void +{ + callEditorMethod($editor, 'dispatchInput', "\033"); + $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + $x, $bounds['top'] + $y)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + $x, $bounds['top'] + $y)); + callEditorMethod($editor, 'dispatchInput', 'i'); + callEditorMethod($editor, 'dispatchInput', $symbol); + callEditorMethod($editor, 'dispatchInput', "\033"); +} + +function getMixedMaterialCanvasRows(Editor $editor): array +{ + $window = callEditorMethod($editor, 'createCanvasWindow'); + return array_map(static fn(string $line): string => substr(TerminalText::stripAnsi($line), 0, 10), + array_slice($window->content, ProjectWorkspace::CANVAS_HEADER_ROWS, 7)); +} + +it('authors mapped floor and wall decoration through real canvas commands without changing gameplay', function () { + $root = createMixedMaterialProject(); + [$editor, $map] = layeredCanvasEditor($root); + $dictionary = require $root . '/assets/Maps/collisions.php'; + $collision = MapCollisionResolver::resolveLayers($map->getLayerSet(), $dictionary); + $terminal = ['####i#####', '# #', '# #', '##########', '# #', '# #', '##########']; + $originalFiles = sourceHashTree($root . '/assets/Maps'); + $getCells = static fn(array $snapshot): array => array_map( + static fn(array $layer): array => $layer['grid']['cells'], $snapshot['layers']['layers'], + ); + expect($collision[1][2])->toBe(CollisionType::NONE->value) + ->and($collision[4][2])->toBe(CollisionType::NONE->value) + ->and($collision[0][1])->toBe(CollisionType::SOLID->value) + ->and($collision[3][5])->toBe(CollisionType::SOLID->value); + + callEditorMethod($editor, 'dispatchInput', ']'); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:2'); + foreach ([[1, 1, 'w'], [2, 1, 'w'], [3, 1, 'w'], [7, 1, 'k'], [8, 1, 'k'], [1, 4, 's'], [2, 4, 's']] as [$x, $y, $glyph]) { + paintMixedMaterial($editor, $x, $y, $glyph); + expect($map->getLayerSymbol('map:2', $x, $y))->toBe($glyph); + } + expect(getEditorProperty($editor, 'canvasPaintWarning'))->toContain('Crop mapping', 'read-only'); + callEditorMethod($editor, 'dispatchInput', ']'); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:3'); + paintMixedMaterial($editor, 2, 1, 'r'); + paintMixedMaterial($editor, 2, 4, 'c'); + callEditorMethod($editor, 'dispatchInput', ']'); // Solid gameplay walls, left untouched. + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:4'); + callEditorMethod($editor, 'dispatchInput', ']'); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:5'); + paintMixedMaterial($editor, 1, 0, 'w'); // Cosmetic writing, not an interaction marker. + paintMixedMaterial($editor, 5, 3, 'o'); + $painted = $map->captureGridSnapshot(); + expect(getMixedMaterialCanvasRows($editor))->toBe([ + '#w##i#####', '#wrw kk#', '# #', '#####o####', '#sc #', '# #', '##########', + ]); + + // Undo stays bound to its layer even after selecting the interactive fixture layer. + callEditorMethod($editor, 'dispatchInput', ']'); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:6'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->getLayerSymbol('map:5', 5, 3))->toBe(' ') + ->and($map->getLayerSymbol('map:6', 4, 0))->toBe('i'); + callEditorMethod($editor, 'dispatchInput', "\x19"); + expect($map->captureGridSnapshot())->toBe($painted); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect($map->isDirty())->toBeFalse(); + $saved = sourceHashTree($root . '/assets/Maps'); + expect(array_keys(array_diff_assoc($saved, $originalFiles)))->toBe([ + 'test-map/layers/02.floors.deco.php', 'test-map/layers/03.rugs.deco.php', 'test-map/layers/05.wall-detail.deco.php', + ]); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(loadLayeredMap($root)->getLayerSymbol('map:5', 5, 3))->toBe(' '); + callEditorMethod($editor, 'dispatchInput', "\x19"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(sourceHashTree($root . '/assets/Maps'))->toBe($saved); + callEditorMethod($editor, 'dispatchInput', "\x12"); + $reloaded = callEditorMethod($editor, 'getSelectedMap'); + expect($reloaded)->not->toBe($map) + ->and($getCells($reloaded->captureGridSnapshot()))->toBe($getCells($painted)) + ->and(MapCollisionResolver::resolveLayers($reloaded->getLayerSet(), $dictionary))->toBe($collision); + + $rectangles = []; + foreach (['map:2' => ['w', 'k', 's'], 'map:3' => ['r', 'c'], 'map:5' => ['w', 'o']] as $id => $glyphs) { + $definition = $reloaded->getLayerTiles2d($id); + expect($definition['asset'])->toBe('Graphics/Tilesets/materials.png'); + foreach ($glyphs as $glyph) { + $rectangles[] = json_encode($definition['symbols'][$glyph]); + } + } + expect(array_unique($rectangles))->toHaveCount(7); + // Terminal preview ignores the hidden gameplay layer as well as every cosmetic marker. + callEditorMethod($editor, 'dispatchInput', 'v'); + callEditorMethod($editor, 'dispatchInput', 'd'); + callEditorMethod($editor, 'dispatchInput', 't'); + expect(callEditorMethod($editor, 'createCanvasWindow')->title)->toContain('Terminal preview') + ->and(getMixedMaterialCanvasRows($editor))->toBe($terminal) + ->and($reloaded->getLayerSymbol('map:6', 4, 0))->toBe('i') + ->and($reloaded->getEditableData()['npcs'][0]['dialogue'])->toBe([['text' => 'Read the notice.']]) + ->and($reloaded->getEditableData()['npcs'][0]['sprite'])->toBe(''); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(sourceHashTree($root . '/assets/Maps'))->toBe($saved); +}); + +it('keeps new material crop authoring explicitly read-only and refuses unmapped decoration through Ctrl-S', function () { + $root = createMixedMaterialProject(); + [$editor, $map] = layeredCanvasEditor($root); + $before = sourceHashTree($root); + callEditorMethod($editor, 'dispatchInput', ']'); + $fields = callEditorMethod($editor, 'getLayerInspectorFields'); + expect(array_column($fields, 'editable'))->each->toBeFalse(); + callEditorMethod($editor, 'dispatchInput', "\t"); + $attempted = []; + foreach (callEditorMethod($editor, 'getInspectorFields') as $index => $field) { + if (! in_array($field['label'], ['tiles2d (read-only)', ' w', ' k', ' s'], true)) { + continue; + } + setEditorProperty($editor, 'selectedInspectorFieldIndex', $index); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'isInspectorEditing'))->toBeFalse(); + $attempted[] = $field['label']; + } + expect($attempted)->toBe(['tiles2d (read-only)', ' w', ' k', ' s']); + callEditorMethod($editor, 'dispatchInput', "\033[Z"); + paintMixedMaterial($editor, 1, 1, 'z'); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(getEditorProperty($editor, 'statusMessage'))->toContain('has no crop mapping') + ->and($map->isDirty())->toBeTrue() + ->and(sourceHashTree($root))->toBe($before); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect($map->isDirty())->toBeFalse() + ->and(sourceHashTree($root))->toBe($before); +}); From cd04ff658c34baddd923b4ea7f424c751554d401 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 25 Sep 2026 19:11:53 +0200 Subject: [PATCH 036/344] fix(source): preserve and scale cell override list edits Remove quadratic LCS allocation for declared tiles2d.cells identities without changing public limits or inferring coordinates as identities in other lists. Reuse the existing move and comment ownership rules and precompute insertion anchors. Fix indented inline entry removal so its span no longer includes sibling entries or closing syntax. Preserve headings, exact leaf edits, insertion, removal and reordered source. Cover NPC, track and cue consumers plus a 3000-cell rewrite under 128 MiB. Verified the full Editor candidate on PHP 8.5 and 8.4: 1190 tests, 65101 assertions, no skips each; PHPStan clean on both with test-only Engine source override 32bb718. No dependency lock or vendor changes. --- src/Cutscenes/Source/ArraySourceWriter.php | 64 ++++++-- .../Source/PhpArraySourceDocument.php | 11 +- .../Unit/Cutscenes/ArraySourceWriterTest.php | 145 ++++++++++++++++++ 3 files changed, 206 insertions(+), 14 deletions(-) diff --git a/src/Cutscenes/Source/ArraySourceWriter.php b/src/Cutscenes/Source/ArraySourceWriter.php index 62694ce..c22f956 100644 --- a/src/Cutscenes/Source/ArraySourceWriter.php +++ b/src/Cutscenes/Source/ArraySourceWriter.php @@ -216,6 +216,9 @@ private function diffArray(SourceNode $node, ?array $old, array $new, array $pat */ private function diffList(SourceNode $node, array $old, array $new, array $path): void { + if ($this->tryDiffCoordinateList($node, $old, $new, $path)) { + return; + } $oldCount = count($old); $newCount = count($new); $matchedOld = []; @@ -339,6 +342,12 @@ private function diffList(SourceNode $node, array $old, array $new, array $path) $previousNew = $anchorNew; } + $this->emitListMatches($node, $old, $new, $path, $matchedOld, $matchedNew); + } + + /** Emit aligned entries through the same source/comment ownership rules for every list. */ + private function emitListMatches(SourceNode $node, array $old, array $new, array $path, array $matchedOld, array $matchedNew): void + { // 6. Which matched entries stay in place: the longest run of them // whose old order agrees with their new order. The rest move. ksort($matchedNew); @@ -372,22 +381,19 @@ private function diffList(SourceNode $node, array $old, array $new, array $path) } } + $anchor = null; + $anchors = []; + for ($j = count($new) - 1; $j >= 0; $j--) { + $anchors[$j] = $anchor; + if (isset($kept[$j])) { $anchor = $kept[$j]; } + } + $inline = self::prefersInline($node); foreach ($new as $j => $item) { if (isset($kept[$j])) { continue; } - $anchor = null; - - for ($next = $j + 1; $next < $newCount; $next++) { - if (isset($kept[$next])) { - $anchor = $kept[$next]; - - break; - } - } - - $position = $anchor ?? count($node->entries); + $position = $anchors[$j] ?? count($node->entries); if (isset($moved[$j])) { $this->emitMove($node, $moved[$j], $old[$moved[$j]], $item, $path, $position); @@ -396,7 +402,7 @@ private function diffList(SourceNode $node, array $old, array $new, array $path) } if ($position >= count($node->entries)) { - $this->queueAppend($node, null, $this->literalFor($item, [...$path, $j], self::prefersInline($node))); + $this->queueAppend($node, null, $this->literalFor($item, [...$path, $j], $inline)); continue; } @@ -405,11 +411,43 @@ private function diffList(SourceNode $node, array $old, array $new, array $path) $path, $position, null, - $this->literalFor($item, [...$path, $j], self::prefersInline($node)), + $this->literalFor($item, [...$path, $j], $inline), ); } } + /** Declared cell identities avoid a quadratic LCS table for large tile-art lists. */ + private function tryDiffCoordinateList(SourceNode $node, array $old, array $new, array $path): bool + { + // Row/column is identity only in the declared tile-art contract, not arbitrary user lists. + if ($path !== ['tiles2d', 'cells'] && ! (count($path) === 4 && $path[0] === 'tiles2d' + && $path[1] === 'layers' && is_string($path[2]) && $path[3] === 'cells')) { + return false; + } + $indices = []; + foreach ([$old, $new] as $side => $items) { + $indices[$side] = []; + foreach ($items as $index => $item) { + if (! is_array($item) || ! is_int($item['row'] ?? null) || ! is_int($item['column'] ?? null)) { + return false; + } + $key = $item['row'] . ':' . $item['column']; + if (isset($indices[$side][$key])) { return false; } + $indices[$side][$key] = $index; + } + } + $matchedOld = []; + $matchedNew = []; + foreach ($indices[1] as $key => $index) { + if (! isset($indices[0][$key])) { continue; } + $previous = $indices[0][$key]; + $matchedOld[$previous] = $index; + $matchedNew[$index] = $previous; + } + $this->emitListMatches($node, $old, $new, $path, $matchedOld, $matchedNew); + return true; + } + /** * Queues an entry for the end of an array. Appends flush together, one * composable edit per parent, so they never collide with each other or diff --git a/src/Cutscenes/Source/PhpArraySourceDocument.php b/src/Cutscenes/Source/PhpArraySourceDocument.php index 9e6cf11..be12d12 100644 --- a/src/Cutscenes/Source/PhpArraySourceDocument.php +++ b/src/Cutscenes/Source/PhpArraySourceDocument.php @@ -365,7 +365,16 @@ public function blockSpan(SourceEntry $entry): array $start = $previousStart; } - return [$start, $this->lineEndAfter(max($entry->end, $entry->separatorEnd) - 1)]; + $end = $this->lineEndAfter(max($entry->end, $entry->separatorEnd) - 1); + // An indented first entry may still share its line with siblings or + // closing syntax. Its heading belongs to it; those later bytes do not. + $tail = substr($this->source, $entry->separatorEnd, $end - $entry->separatorEnd); + foreach (PhpToken::tokenize('is([T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { + return [$start, $entry->separatorEnd]; + } + } + return [$start, $end]; } // -- Planning edits ------------------------------------------------------ diff --git a/tests/Unit/Cutscenes/ArraySourceWriterTest.php b/tests/Unit/Cutscenes/ArraySourceWriterTest.php index 22f0b67..8061981 100644 --- a/tests/Unit/Cutscenes/ArraySourceWriterTest.php +++ b/tests/Unit/Cutscenes/ArraySourceWriterTest.php @@ -299,6 +299,151 @@ function rewriteAuthored(callable $change): string expect(ArraySourceWriter::rewrite($document, $old, $new)->source)->toBe(str_replace("'plain' => 1", "'plain' => 2", $source)); }); +it('preserves cell-list comment ownership across insert remove and edited reorder operations', function (bool $layered, bool $inline) { + $body = <<<'PHP' + // First cell owns this heading. + ['column'=>23, 'row'=>1, 'source'=>['x'=>0, 'y'=>0, 'width'=>16, 'height'=>16]], // first + // Second cell owns this heading. + ['column'=>3, 'row'=>12, 'source'=>['x'=>0, 'y'=>0, 'width'=>16, 'height'=>16]], // second + // Third cell owns this heading. + ['column'=>5, 'row'=>3, 'source'=>['x'=>0, 'y'=>0, 'width'=>16, 'height'=>16]], // third +PHP; + if ($inline) { + $body = preg_replace('/\/\/[^\n]*/', '', $body); + $body = str_replace("\n", '', $body); + } + $source = " " . ($layered ? "['layers'=>['furniture'=>" : '') + . "['cells'=>[\n$body\n]]" . ($layered ? ']]' : '') . "];\n"; + $path = $layered ? ['tiles2d', 'layers', 'furniture', 'cells'] : ['tiles2d', 'cells']; + foreach (['edit', 'insert-remove', 'reorder', 'replace-all'] as $operation) { + $old = evaluateSource($source); + $new = $old; + $cells = &$new; + foreach ($path as $step) { $cells = &$cells[$step]; } + if ($operation === 'edit') { + $cells[1]['source']['x'] = 32; + } elseif ($operation === 'insert-remove') { + $insert = $cells[1]; + $insert['column'] = 4; + $other = $insert; + $other['column'] = 6; + $cells = [$insert, $cells[1], $other, $cells[2], $insert + []]; + $cells[4]['column'] = 7; + } elseif ($operation === 'reorder') { + $cells = [$cells[2], $cells[0], $cells[1]]; + $cells[0]['source']['x'] = 32; + } else { + $cells = [array_replace($cells[0], ['row' => 99])]; + } + unset($cells); + $rewritten = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source; + expect(evaluateSource($rewritten))->toBe($new); + if ($operation === 'edit') { + expect($rewritten)->toBe(str_replace("'column'=>3, 'row'=>12, 'source'=>['x'=>0", "'column'=>3, 'row'=>12, 'source'=>['x'=>32", $source)); + } + if (! $inline) { + if ($operation === 'insert-remove') { + expect($rewritten)->not->toContain('First cell owns', '// first') + ->and($rewritten)->toContain("// Second cell owns this heading.\n ['column'=>3", "// Third cell owns this heading.\n ['column'=>5"); + } elseif ($operation === 'reorder') { + expect($rewritten)->toContain("// Third cell owns this heading.\n ['column'=>5, 'row'=>3, 'source'=>['x'=>32") + ->and(strpos($rewritten, 'Third cell owns'))->toBeLessThan(strpos($rewritten, 'First cell owns')); + } elseif ($operation === 'replace-all') { + expect($rewritten)->not->toContain('First cell owns', 'Second cell owns', 'Third cell owns'); + } + } + } +})->with([[false, false], [true, false], [false, true], [true, true]]); + +it('retains existing non-tile list identity and comment rules without treating arbitrary coordinates as ids', function () { + $source = <<<'PHP' + [ + // The named NPC moves with its source. + ['name' => 'Mira', 'column' => 0, 'row' => 0, 'text' => 'first'], + // The other NPC. + ['name' => 'Tari', 'column' => 1, 'row' => 0, 'text' => 'second'], +], 'keyframes' => [ + // This is an edited position, not a new tile identity. + ['column' => 0, 'row' => 0, 'duration' => 10], + ['column' => 1, 'row' => 0, 'duration' => 20], +]]; +PHP; + $old = evaluateSource($source); + $new = $old; + $new['npcs'] = [$old['npcs'][1], $old['npcs'][0]]; + $new['npcs'][1]['text'] = 'edited'; + $new['npcs'][1]['column'] = 8; + $new['keyframes'][0]['column'] = 9; + $rewritten = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source; + expect(evaluateSource($rewritten))->toBe($new) + ->and($rewritten)->toContain("// The named NPC moves with its source.\n ['name' => 'Mira', 'column' => 8, 'row' => 0, 'text' => 'edited']") + ->and($rewritten)->toContain("// This is an edited position, not a new tile identity.\n ['column' => 9"); +}); + +it('removes only an indented inline entry and its heading in other existing list consumers', function (string $key) { + $source = " [\n // Removed entry owns this heading.\n ['id'=>'first'], ['id'=>'second'], ['id'=>'third']]];\n"; + $old = evaluateSource($source); + $new = $old; + array_shift($new[$key]); + $rewritten = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source; + expect(evaluateSource($rewritten))->toBe($new) + ->and($rewritten)->toBe(str_replace(" // Removed entry owns this heading.\n ['id'=>'first'],", '', $source)); +})->with(['npcs', 'tracks', 'cues']); + +it('refuses opaque changed leaves inside coordinate lists without regenerating their expressions', function () { + $source = "['cells'=>[['column'=>0,'row'=>0,'source'=>['x'=>abs(0),'y'=>0,'width'=>16,'height'=>16]]]]];"; + $old = evaluateSource($source); + $new = $old; + $new['tiles2d']['cells'][0]['source']['x'] = 16; + expect(fn() => ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)) + ->toThrow(SourcePreservationRefusal::class, 'expression'); +}); + +it('patches a large ordered cell list without a quadratic alignment table within 128 MiB', function () { + $probe = <<<'PHP' + require $argv[1]; + $rows = []; + $cells = []; + for ($index = 0; $index < 3000; $index++) { + $rows[] = " ['column'=>$index, 'row'=>0, 'source'=>['x'=>0, 'y'=>0, 'width'=>16, 'height'=>16]], // cell $index"; + $cells[] = ['column'=>$index, 'row'=>0, 'source'=>['x'=>0, 'y'=>0, 'width'=>16, 'height'=>16]]; + } + $source = "['cells'=>[\n" . implode("\n", $rows) . "\n]]];\n"; + $old = ['tiles2d'=>['cells'=>$cells]]; + $new = $old; + $expected = $source; + foreach ([0, 1500, 2999] as $index) { + $new['tiles2d']['cells'][$index]['source']['x'] = 32; + $expected = str_replace("'column'=>$index, 'row'=>0, 'source'=>['x'=>0", "'column'=>$index, 'row'=>0, 'source'=>['x'=>32", $expected); + } + $document = \Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument::parse($source); + $edited = \Ichiloto\Editor\Cutscenes\Source\ArraySourceWriter::rewrite($document, $old, $new); + $exact = $edited->source === $expected; + unset($document); + $changed = $new; + array_splice($changed['tiles2d']['cells'], 1500, 1); + $changed['tiles2d']['cells'][] = ['column'=>3000, 'row'=>0, 'source'=>['x'=>16, 'y'=>0, 'width'=>16, 'height'=>16]]; + $updated = \Ichiloto\Editor\Cutscenes\Source\ArraySourceWriter::rewrite($edited, $new, $changed); + $evaluated = eval(substr($updated->source, 5)); + echo json_encode(['exact'=>$exact, 'values'=>$evaluated === $changed, + 'kept'=>str_contains($updated->source, '// cell 2999'), 'removed'=>!str_contains($updated->source, '// cell 1500'), + 'limit'=>ini_get('memory_limit'), 'peak'=>memory_get_peak_usage(true)], JSON_THROW_ON_ERROR); + PHP; + $process = proc_open([PHP_BINARY, '-d', 'memory_limit=128M', '-r', $probe, dirname(__DIR__, 2) . '/bootstrap.php'], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + expect(is_resource($process))->toBeTrue(); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + expect(proc_close($process))->toBe(0, $error)->and($error)->toBe(''); + $result = json_decode($output, true, flags: JSON_THROW_ON_ERROR); + expect($result)->toMatchArray(['exact' => true, 'values' => true, 'kept' => true, 'removed' => true, 'limit' => '128M']) + ->and($result['peak'])->toBeLessThan(128 * 1024 * 1024); +}); + it('rewrites the real Last Legend summon timelines to themselves and back from any change', function () { $game = gameSourceRoot(); From 2edc683ee169ace3720517121f611dfa710411bb Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 25 Sep 2026 19:12:15 +0200 Subject: [PATCH 037/344] feat(tile-art): author selected cell crop overrides Remove the blanket read-only restriction for per-cell crop and layer-atlas authoring. Glyph-wide symbol defaults remain read-only. Add the shared project PNG picker, staged numeric crop form, explicit atlas impact confirmation, Apply/Remove and captured map/layer/cell undo. Preserve authored PHP key order and untouched source, refuse unsupported opaque writes before mutation, prune empty definitions safely, and reuse Engine schema and PNG preflight. Keep coordinates fixed through painting and grid resizing; validate bounds and ownership through rename, duplicate, move, removal, restoration and transactional rollback. Cache validated definitions across painting while detecting retained layer/grid changes. Document the workflow and remaining visual-picker/symbol-default gaps. Full PHP 8.5 and 8.4 suites each pass 1190 tests and 65101 assertions with zero skips; PHPStan clean both. Real terminal Editor boot/form/cancel/quit smoke passed without game or audio launch. This candidate uses test-only ICHILOTO_ENGINE_SRC pointing to local Engine 32bb718, not the published package. Preserve composer.lock and vendor at 9011d7e pending coordinated dependency integration and publishing approval. --- docs/manual.md | 62 ++- src/Canvas/LayerCanvas.php | 18 +- src/Canvas/TileArtCanvas.php | 185 ++++++++ src/Database/ReferenceCatalog.php | 22 + src/Editor.php | 16 +- src/Maps/TileArt.php | 158 +++++++ src/ProjectMap.php | 25 +- src/UI/Modal.php | 1 + tests/Unit/CellTileArtTest.php | 475 +++++++++++++++++++++ tests/Unit/LayeredCanvasTest.php | 2 +- tests/Unit/MixedMaterialAuthoringTest.php | 5 +- tests/__snapshots__/main_shell_100x30.ansi | 2 +- 12 files changed, 947 insertions(+), 24 deletions(-) create mode 100644 src/Canvas/TileArtCanvas.php create mode 100644 src/Maps/TileArt.php create mode 100644 tests/Unit/CellTileArtTest.php diff --git a/docs/manual.md b/docs/manual.md index a17fc1c..03e8128 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -200,9 +200,10 @@ Creating a layer on a legacy map explicitly moves its terrain into saves keep the original layout. Every layer uses the same Paint/Normal modes, tools, mouse strokes, selection, -colour, clipboard and undo. The inspector lists visibility and read-only -per-layer `tiles2d` atlas/crop mappings. Painting a mapped glyph, or replacing -one, shows a crop warning. Invalid decoration crop coverage is refused at save. +colour, clipboard and undo. The inspector lists visibility, inherited per-layer +`tiles2d` mappings, and an editable selected-cell tile-art action. Painting a +mapped glyph, replacing one, or painting at a coordinate with a crop override +shows a warning. Invalid decoration crop coverage is refused at save. Untouched layer files are never written; unchanged rows retain their authored bytes, and all changed files are saved or rolled back together. @@ -249,13 +250,54 @@ not just a decoration marker. Press `t` in Normal mode to check terminal preview: floors remain blank, walls retain their gameplay glyphs, and interaction markers remain visible. -**Current authoring gap:** the TUI displays atlas paths and crop rectangles -read-only. It can place existing mapped materials, but cannot select a new -material asset or add/change a glyph's crop mapping. Creating a decoration -layer does not create those mappings; they must currently be authored in the -map's `.data.php` source outside the TUI. Saving an unmapped decoration glyph -is refused before writing. There is no graphical material picker or image -preview in this workflow; the canvas shows authoring markers, not the artwork. +Alternatively, use selected-cell tile art below to author material crops without +predefined glyph mappings, including crops on blank decoration cells. +**Remaining authoring gap:** glyph-wide defaults remain read-only in the TUI; +adding/changing a reusable glyph mapping still requires the map's `.data.php` +source. There is no visual crop picker or image preview: the canvas shows +authoring glyphs, and the crop form uses pixel coordinates. + +### Selected-Cell Tile Art + +This workflow requires the Engine per-cell `tiles2d.cells` capability, currently +available in the local Engine candidate, not the Editor's older locked +Engine package. Integration tests select that Engine source explicitly; the +dependency lock must be updated separately when publishing is authorized. + +1. In Normal mode, select the gameplay or decoration layer and cell. Choose + `Tile art: Edit selected cell` in `Ctrl+P`, or activate `Tile art: selected + cell` in the inspector. Events and terminal-preview mode cannot own tile art. +2. Press `a` or activate `Layer atlas` to select a PNG already under the project's + `assets/` directory. Type to filter the shared picker, then select with + arrows and `Enter`; typed text is never saved as a resource path. +3. Use arrows/Tab and `Enter` to edit X, Y, width and height in pixels. Origins + start at zero, extents must be positive, and crops must fit the selected PNG. + The form starts from this cell's crop, its inherited glyph crop, or a + 16-by-16 rectangle. Press `s` or activate `Apply override` to stage the edit. +4. Changing the atlas of a layer that already inherits/owns one requires `y` + confirmation: every existing crop on that layer will use the new PNG. The + change writes only that layer's asset override, never the shared atlas or + another layer. The dialog names the old and new assets and this impact. +5. `r` / `Remove override` asks for confirmation before removing only that cell's + crop; its glyph default, if any, becomes effective again. Empty definitions + are pruned. `Esc` cancels a field, picker or confirmation first, then the form. + Nothing changes until Apply or confirmed Remove. `Ctrl+S` saves transactionally; + `Ctrl+Z` / `Ctrl+Y` undo/redo even after changing the selected layer or saving. + +Overrides belong to the captured map, layer and coordinate, not the painted +glyph. They do not change text or walkability. Paint, erase and clipboard tools +leave crop coordinates in place; layer rename/duplicate/move preserve ownership, +and layer removal removes its own table. Shrinking a grid never silently moves +or drops crops: save refuses coordinates outside a row. Undo the resize, remove +the affected crops, then resize again if intended. Reload reads saved crops. + +Literal `.data.php` arrays retain untouched source bytes and comments. Opaque +expressions, variables, unreadable keys or explicitly indexed cell lists that +cannot be safely edited are refused before mutation with a reason; they are +never flattened to evaluated data. The picker and shared Engine preflight check +asset containment, PNG header and crop bounds; native rendering remains the +full PNG decode authority. Decoration coverage may be staged across several +cell edits but must be complete before save. ### Normal mode diff --git a/src/Canvas/LayerCanvas.php b/src/Canvas/LayerCanvas.php index dfe2b35..c1451a9 100644 --- a/src/Canvas/LayerCanvas.php +++ b/src/Canvas/LayerCanvas.php @@ -19,12 +19,14 @@ trait LayerCanvas private ?array $facadeBrush = null; private string $canvasPaintWarning = ''; - private function recordCanvasCropWarning(\Ichiloto\Editor\ProjectMap $map, string $symbol, string $oldSymbol): void + private function recordCanvasCropWarning(\Ichiloto\Editor\ProjectMap $map, string $symbol, string $oldSymbol, int $column, int $row): void { try { $symbols = $map->getLayerTiles2d($this->getActiveCanvasLayer())['symbols'] ?? []; - if (array_key_exists($symbol, $symbols) || array_key_exists($oldSymbol, $symbols)) { - $this->canvasPaintWarning = 'Crop mapping on this layer: painted glyph affects graphical artwork (tiles2d is read-only).'; + $override = $this->getActiveCanvasLayer() !== MapLayers::EVENT + && $map->getCellTileArt($this->getActiveCanvasLayer(), $column, $row)['override'] !== null; + if ($override || array_key_exists($symbol, $symbols) || array_key_exists($oldSymbol, $symbols)) { + $this->canvasPaintWarning = 'Crop mapping on this layer: glyphs can affect artwork; cell overrides stay at their coordinates. Tile art edits cell crops; symbol defaults are read-only.'; } } catch (\Throwable $error) { $this->canvasPaintWarning = $error->getMessage(); @@ -105,6 +107,7 @@ private function buildLayerPaletteItems(): array return []; } $items = [ + new PaletteItem('Tile art: Edit selected cell', '', fn() => $this->openCellTileArt()), new PaletteItem('Layers: Create gameplay layer', '', fn() => $this->openLayerPrompt('create')), new PaletteItem('Layers: Create decoration layer', '', fn() => $this->openLayerPrompt('decoration')), new PaletteItem('Layers: Rename selected layer', '', fn() => $this->openLayerPrompt('rename')), @@ -290,6 +293,15 @@ private function getLayerInspectorFields(): array foreach ($tiles['symbols'] ?? [] as $symbol => $crop) { $fields[] = ['label' => ' ' . $symbol, 'value' => json_encode($crop, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'editable' => false]; } + if ($id !== MapLayers::EVENT) { + try { + $art = $map->getCellTileArt($id, $this->cursorX, $this->cursorY); + $fields[] = ['label' => 'Tile art: selected cell', 'value' => $art['override'] === null ? 'Inherited / unmapped' : json_encode($art['override']), + 'editable' => true, 'target' => 'tile-art']; + } catch (\Throwable $error) { + $fields[] = ['label' => 'Cell art', 'value' => $error->getMessage(), 'editable' => false]; + } + } return $fields; } } diff --git a/src/Canvas/TileArtCanvas.php b/src/Canvas/TileArtCanvas.php new file mode 100644 index 0000000..0b422f4 --- /dev/null +++ b/src/Canvas/TileArtCanvas.php @@ -0,0 +1,185 @@ + 0, 'y' => 0, 'width' => 16, 'height' => 16]; + + private function openCellTileArt(): void + { + $map = $this->getSelectedMap(); + if ($map === null || $this->referencePicker->isOpen()) { return; } + try { + if ($this->getCanvasLayerState()['terminal']) { + throw new MapSourceRefusal('Leave terminal preview before editing tile art.'); + } + $this->finalizeActiveStroke(); + $id = $this->getActiveCanvasLayer(); + $art = $map->getCellTileArt($id, $this->cursorX, $this->cursorY); + $this->tileArtDialog = ['map' => $map, 'layer' => $id, 'column' => $this->cursorX, 'row' => $this->cursorY, + 'initial' => $art, 'asset' => $art['asset'], 'source' => $art['override'] ?? $art['inherited'] ?? self::TILE_ART_DEFAULT_SOURCE, + 'selected' => 0, 'confirmation' => null, 'error' => '']; + $this->tileArtFieldEditor = new TextFieldEditor(); + $this->modals->push(Modal::TILE_ART); + $this->requestFullRender(); + } catch (\Throwable $error) { + $this->setErrorStatus($error, 'Tile art'); + } + } + + private function closeCellTileArt(): void + { + $this->referencePicker->close(); + $this->tileArtFieldEditor?->close(); + $this->tileArtDialog = null; + $this->modals->remove(Modal::TILE_ART); + $this->requestFullRender(); + } + + private function openTileArtAssetPicker(): void + { + if (! $this->referencePicker->open('tile_art_asset', 'Layer PNG', 'png_assets', + $this->referenceCatalog()->valuesFor('png_assets'), $this->tileArtDialog['asset'] ?? '')) { + $this->tileArtDialog['error'] = 'No PNG assets found. Add artwork under this project\'s assets, then choose again.'; + } + } + + private function handleTileArtInput(string $input): void + { + if ($this->tileArtDialog === null) { return; } + if ($this->referencePicker->isOpen()) { + if ($input === "\033") { $this->referencePicker->close(); } + elseif ($input === "\r" || $input === "\n") { + if (($asset = $this->referencePicker->selected()) !== null) { + $this->tileArtDialog['asset'] = $asset; + $this->referencePicker->close(); + } + } elseif ($input === "\033[A") { $this->referencePicker->move(-1); } + elseif ($input === "\033[B") { $this->referencePicker->move(1); } + elseif ($input === "\177" || $input === "\010") { $this->referencePicker->backspace(); } + elseif (mb_strlen($input) === 1 && ! ctype_cntrl($input)) { $this->referencePicker->type($input); } + } elseif ($this->tileArtFieldEditor?->isActive) { + $result = $this->tileArtFieldEditor->handleKey($input, new InputControl(InputControlType::INTEGER)); + if ($result === TextFieldKeyResult::CANCELLED) { $this->tileArtFieldEditor->close(); } + elseif ($result === TextFieldKeyResult::SUBMITTED) { + $value = filter_var($this->tileArtFieldEditor->value, FILTER_VALIDATE_INT); + if ($value === false) { $this->tileArtDialog['error'] = 'Enter a whole number within the supported integer range.'; } + else { + $key = self::TILE_ART_ROWS[$this->tileArtDialog['selected']]; + $this->tileArtDialog['source'][$key] = $value; + $this->tileArtDialog['error'] = ''; + $this->tileArtFieldEditor->close(); + } + } + } elseif ($this->tileArtDialog['confirmation'] !== null) { + if ($input === "\033") { $this->tileArtDialog['confirmation'] = null; } + elseif ($input === 'y') { $this->commitCellTileArt($this->tileArtDialog['confirmation'], true); } + } elseif ($input === "\033") { + $this->closeCellTileArt(); + } elseif ($input === "\033[A" || $input === "\033[Z") { + $this->tileArtDialog['selected'] = ($this->tileArtDialog['selected'] + count(self::TILE_ART_ROWS) - 1) % count(self::TILE_ART_ROWS); + } elseif ($input === "\033[B" || $input === "\t") { + $this->tileArtDialog['selected'] = ($this->tileArtDialog['selected'] + 1) % count(self::TILE_ART_ROWS); + } elseif ($input === 'a') { + $this->openTileArtAssetPicker(); + } elseif ($input === 's') { + $this->commitCellTileArt('apply'); + } elseif ($input === 'r') { + $this->tileArtDialog['confirmation'] = 'remove'; + } elseif ($input === "\r" || $input === "\n") { + $key = self::TILE_ART_ROWS[$this->tileArtDialog['selected']]; + if ($key === 'asset') { $this->openTileArtAssetPicker(); } + elseif (in_array($key, self::TILE_ART_FIELDS, true)) { $this->tileArtFieldEditor->open((string) $this->tileArtDialog['source'][$key]); } + elseif ($key === 'apply') { $this->commitCellTileArt('apply'); } + else { $this->tileArtDialog['confirmation'] = 'remove'; } + } + $this->requestFullRender(); + } + + private function commitCellTileArt(string $action, bool $confirmed = false): void + { + $dialog = $this->tileArtDialog; + $map = $dialog['map']; + try { + if ($map !== $this->getSelectedMap() || $map->getCellTileArt($dialog['layer'], $dialog['column'], $dialog['row']) !== $dialog['initial']) { + throw new MapSourceRefusal('The captured map, layer or cell art changed. Cancel and reopen Tile art.'); + } + if ($action === 'apply' && $dialog['initial']['asset'] !== null && $dialog['asset'] !== $dialog['initial']['asset'] && ! $confirmed) { + $this->tileArtDialog['confirmation'] = 'apply'; + return; + } + $before = $map->captureLayerSnapshot(); + if ($action === 'remove') { $map->removeCellTileArt($dialog['layer'], $dialog['column'], $dialog['row']); } + else { $map->setCellTileArt($dialog['layer'], $dialog['column'], $dialog['row'], $dialog['source'], $dialog['asset'], $confirmed); } + $after = $map->captureLayerSnapshot(); + if ($before !== $after) { + $this->recordCommand(new GenericCommand('Cell tile art ' . $action, + fn() => $map->restoreLayerSnapshot($after), fn() => $map->restoreLayerSnapshot($before))); + } + $this->closeCellTileArt(); + $this->setStatus('Cell tile art staged. Ctrl+S saves; Ctrl+Z undoes.', StatusLevel::SUCCESS); + } catch (\Throwable $error) { + $this->tileArtDialog['confirmation'] = null; + $this->tileArtDialog['error'] = $error->getMessage(); + } + } + + private function renderTileArtDialog(array $layout): void + { + $dialog = $this->tileArtDialog; + $width = min(76, $layout['width'] - 4); + $inner = $this->getWindowContentWidth($width); + $lines = [sprintf('%s / %s / cell %d,%d', $dialog['map']->mapId, $dialog['initial']['layer'], $dialog['column'], $dialog['row'])]; + $help = 'Arrows:Field Enter:Edit A:Atlas S:Apply R:Remove Esc:Cancel'; + if ($this->referencePicker->isOpen()) { + $lines[] = 'Choose layer PNG: ' . $this->referencePicker->filter(); + $choices = array_map(fn(string $path): string => ($path === $this->referencePicker->selected() ? '> ' : ' ') . $path, $this->referencePicker->matches()); + $lines = [...$lines, ...ScrollWindow::slice($choices, $this->referencePicker->selectedIndex(), 9)]; + if ($choices === []) { $lines[] = 'No matching PNG assets.'; } + $help = 'Type:Filter Arrows:Choose Enter:Select Esc:Back'; + } elseif ($dialog['confirmation'] !== null) { + $message = $dialog['confirmation'] === 'remove' + ? 'Remove only this cell override? Its symbol default (if any) will be used again.' + : sprintf('Change this layer atlas from %s to %s? Every existing symbol and cell crop on this layer will use the new PNG. Other layers and the shared atlas stay unchanged.', $dialog['initial']['asset'], $dialog['asset']); + $lines = [...$lines, ...explode("\n", wordwrap($message, $inner, "\n", true))]; + $help = 'Y:Confirm Esc:Back'; + } else { + $lines[] = 'Glyph: ' . var_export($dialog['initial']['symbol'], true) . ' | ' . ($dialog['initial']['override'] !== null ? 'Cell override' : 'Inherited / unmapped'); + $values = ['Layer atlas: ' . ($dialog['asset'] ?? '(choose PNG)')]; + foreach (self::TILE_ART_FIELDS as $index => $key) { + $editing = $dialog['selected'] === $index + 1 && $this->tileArtFieldEditor?->isActive; + $values[] = ucfirst($key) . ': ' . ($editing ? $this->tileArtFieldEditor->value . '|' : $dialog['source'][$key]); + } + foreach ([...$values, 'Apply override', 'Remove override'] as $index => $value) { + $button = in_array(self::TILE_ART_ROWS[$index], ['apply', 'remove'], true); + $line = $button ? str_pad($value, $inner, ' ', STR_PAD_BOTH) : ($index === $dialog['selected'] ? '> ' : ' ') . $value; + $lines[] = $button && $index === $dialog['selected'] ? "\033[7m" . $line . "\033[0m" : $line; + } + $lines[] = 'Crop pixels: X/Y start at zero; width/height must be positive.'; + $lines[] = 'Only artwork changes. Glyphs and collision stay unchanged.'; + } + if ($dialog['error'] !== '') { $lines = [...$lines, ...explode("\n", wordwrap($dialog['error'], $inner, "\n", true))]; } + $height = min(count($lines) + 2, $layout['height'] - 4); + new EditorWindow(title: 'Tile art: selected cell', help: $help, position: ['x' => max(2, intdiv($layout['width'] - $width, 2)), 'y' => 3], + width: $width, height: $height, content: $this->fitLines($lines, $inner, $height - 2))->render(); + } +} diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 7755655..7b891d1 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -50,6 +50,7 @@ final class ReferenceCatalog 'bgm', 'sfx', 'enemy_sprites', + 'png_assets', 'elements', 'map_npcs', 'knowledge_subjects', @@ -148,6 +149,7 @@ public function valuesFor(string $category): array // Graphics/Enemies/.txt, appending the extension itself, // so the file stems are the values. 'enemy_sprites' => $this->fileValues('assets/Graphics/Enemies'), + 'png_assets' => self::getPngAssets($this->workspace->projectRoot), 'elements' => $this->elementValues(), // An Optimize weight may apply to one element or to whichever // element an outcome happened to be, which the runtime spells @@ -643,6 +645,26 @@ private function fileValues(string $relativeDirectory): array return $names; } + /** Returns asset-root-relative PNG choices without following paths outside the asset root. */ + public static function getPngAssets(string $projectRoot): array + { + $root = realpath(rtrim($projectRoot, '/') . '/assets'); + if ($root === false) { + return []; + } + $paths = []; + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + $resolved = $file->getRealPath(); + if ($file->isFile() && strtolower($file->getExtension()) === 'png' && $resolved !== false + && str_starts_with($resolved, $root . DIRECTORY_SEPARATOR)) { + $paths[] = str_replace(DIRECTORY_SEPARATOR, '/', substr($file->getPathname(), strlen($root) + 1)); + } + } + sort($paths); + return $paths; + } + /** * Returns the values a schema-driven category defines. * diff --git a/src/Editor.php b/src/Editor.php index 226ac31..5a3799e 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -110,6 +110,7 @@ final class Editor { use \Ichiloto\Editor\Canvas\LayerCanvas; + use \Ichiloto\Editor\Canvas\TileArtCanvas; use CutscenesWorkspace; use CutsceneOutlinePane; use CutscenePreviewPane; @@ -1254,6 +1255,7 @@ private function buildInputRouter(): InputRouter $router->bindModal(Modal::DATABASE_ENTRY_DELETE_CONFIRMATION, $this->handleDatabaseEntryDeleteConfirmationInput(...)); $router->bindModal(Modal::RENAME_CONFIRMATION, $this->handleRenameConfirmationInput(...)); $router->bindModal(Modal::LAYER_EDIT, $this->handleLayerPromptInput(...)); + $router->bindModal(Modal::TILE_ART, $this->handleTileArtInput(...)); $router->bindModal(Modal::COMMAND_PALETTE, $this->handleCommandPaletteInput(...)); $router->bindModal(Modal::HELP, $this->handleHelpInput(...)); $router->bindModal(Modal::DATABASE, $this->handleDatabaseInput(...)); @@ -4593,7 +4595,7 @@ private function paintCanvasStroke( [$newPrefix, $newSuffix] = $this->resolvePaintStyle($symbol, $this->selectedPaintColor, $oldStyle); $selectedMap->setLayerCell($this->getActiveCanvasLayer(), $point['x'], $point['y'], $symbol, $newPrefix, $newSuffix); $newSymbol = $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']); - $this->recordCanvasCropWarning($selectedMap, $symbol, $oldSymbol); + $this->recordCanvasCropWarning($selectedMap, $symbol, $oldSymbol, $point['x'], $point['y']); $this->activeStrokeCommand->appendCell( $point['x'], $point['y'], @@ -4837,7 +4839,7 @@ private function applyCanvasWrites(ProjectMap $map, array $writes, string $label $changed++; } - $this->recordCanvasCropWarning($map, $write['symbol'], $oldSymbol); + $this->recordCanvasCropWarning($map, $write['symbol'], $oldSymbol, $write['x'], $write['y']); } if ($stroke->hasChanges()) { @@ -8390,6 +8392,11 @@ private function activateInspectorField(): void return; } + if (($field['target'] ?? null) === 'tile-art') { + $this->openCellTileArt(); + return; + } + if (($field['target'] ?? null) === 'event-type') { $this->openEventTypeDialog((string) ($field['marker'] ?? '')); return; @@ -17712,6 +17719,11 @@ private function renderModalOverlays(array $layout): bool return true; } + if ($this->modals->has(Modal::TILE_ART)) { + $this->renderTileArtDialog($layout); + return true; + } + if ($this->isCommandPaletteOpen) { $this->renderCommandPaletteOverlay($layout); return true; diff --git a/src/Maps/TileArt.php b/src/Maps/TileArt.php new file mode 100644 index 0000000..44ccaf3 --- /dev/null +++ b/src/Maps/TileArt.php @@ -0,0 +1,158 @@ +getTileArtPath($layer, $column, $row); + $name = $this->layers->getLayer($layer)['name']; + $definition = $this->getLayerTileDefinitions()[$name] ?? null; + $glyph = $this->getLayerSymbol($layer, $column, $row); + $inherited = $definition?->symbols[$glyph] ?? null; + $override = $definition?->hasCellOverride($column, $row) ?? false; + $index = $override ? $definition->getSourceIndex($glyph, $column, $row) : null; + return ['layer' => $name, 'symbol' => $glyph, + 'asset' => $definition?->asset ?? ($this->getMapDataField($path)['asset'] ?? ($this->editableData['tiles2d']['asset'] ?? null)), + 'inherited' => $inherited === null ? null : $definition->sources[$inherited]->toArray(), + 'override' => $index === null ? null : $definition->sources[$index]->toArray()]; + } + + /** Applies one override without retargeting any inherited atlas unless explicitly confirmed. */ + public function setCellTileArt(string $layer, int $column, int $row, array $source, ?string $asset = null, bool $confirmAssetChange = false): void + { + $this->assertEditable(); + $path = $this->getTileArtPath($layer, $column, $row); + $current = $this->getCellTileArt($layer, $column, $row); + $asset ??= $current['asset']; + if ($asset === null || ! in_array($asset, ReferenceCatalog::getPngAssets(dirname($this->getMapsRoot(), 2)), true)) { + throw new MapSourceRefusal('Choose an existing PNG inside this project\'s assets through the asset picker.'); + } + if ($current['asset'] !== null && $current['asset'] !== $asset && ! $confirmAssetChange) { + throw new MapSourceRefusal('Changing this layer atlas affects every crop on the layer; explicit confirmation is required.'); + } + $definition = $this->getMapDataField($path) ?? []; + $cells = $definition['cells'] ?? []; + $index = count($cells); + foreach ($cells as $key => $cell) { + if ($cell['column'] === $column && $cell['row'] === $row) { + $index = $key; + break; + } + } + if (isset($cells[$index])) { + // Keep authored key order; source verification compares exact PHP values. + $cells[$index]['source'] = array_replace(array_intersect_key($cells[$index]['source'], $source), $source); + } else { + $cells[$index] = ['column' => $column, 'row' => $row, 'source' => $source]; + } + $definition['cells'] = $cells; + if ($asset !== $current['asset']) { + $this->assertTileArtSourceEditable([...$path, 'asset']); + $definition['asset'] = $asset; + } + $this->writeTileArtDefinition($path, $definition, inspectAsset: true); + } + + public function removeCellTileArt(string $layer, int $column, int $row): void + { + $this->assertEditable(); + $path = $this->getTileArtPath($layer, $column, $row); + if ($this->getCellTileArt($layer, $column, $row)['override'] === null) { + return; + } + $definition = $this->getMapDataField($path); + $definition['cells'] = array_values(array_filter($definition['cells'], + static fn(array $cell): bool => $cell['column'] !== $column || $cell['row'] !== $row)); + if ($definition['cells'] === []) { + unset($definition['cells']); + } + $this->writeTileArtDefinition($path, ($definition['symbols'] ?? []) === [] && ! isset($definition['cells']) ? null : $definition); + } + + private function getTileArtPath(string $layer, int $column, int $row): array + { + $target = $this->layers->getLayer($layer); + if ($target['id'] === MapLayers::EVENT || ! isset($target['grid']->cells[$row][$column])) { + throw new MapSourceRefusal('Tile art needs an existing gameplay or decoration cell, not Events or a position outside its row.'); + } + return $this->layers->legacy ? ['tiles2d'] : ['tiles2d', 'layers', $target['name']]; + } + + private function writeTileArtDefinition(array $path, ?array $definition, bool $inspectAsset = false): void + { + $this->assertTileArtSourceEditable([...$path, 'cells'], true); + if ($definition === null) { $this->assertTileArtSourceEditable($path, true); } + $next = $this->editableData; + if ($this->layers->legacy) { + if ($definition === null) { unset($next['tiles2d']); } + else { $next['tiles2d'] = $definition; } + } else { + $name = $path[2]; + if ($definition === null) { + unset($next['tiles2d']['layers'][$name]); + if ($next['tiles2d']['layers'] === []) { + $this->assertTileArtSourceEditable(['tiles2d'], true); + unset($next['tiles2d']); + } + } else { + $next['tiles2d']['layers'][$name] = $definition; + } + } + $set = $this->getLayerSet(); + $definitions = isset($next['tiles2d']) ? GraphicalTileDefinition::getForLayers($next['tiles2d'], $set, $this->mapId) : []; + if ($inspectAsset) { + $resolved = $definitions[$this->layers->legacy ? 'terrain' : $path[2]]; + foreach ($resolved->sources as $crop) { + PngAssetPreflight::inspect(dirname($this->getMapsRoot()), $resolved->asset, $crop); + } + } + // Coverage can be incomplete while authoring; ProjectMap::save checks all decoration together. + $this->writeData($next); + } + + /** Refuse opaque target containers even when the general writer could replace a variable. */ + private function assertTileArtSourceEditable(array $path, bool $recursive = false): void + { + if ($this->dataDocument === null) { + throw new MapSourceRefusal($this->dataSourceIssue ?? 'The map data source cannot be preserved.'); + } + $document = PhpArraySourceDocument::parse($this->proposedDataSource()); + $node = $document->root(); + $walked = []; + foreach ($path as $key) { + $this->assertTileArtSourceNode($node, $walked); + $walked[] = $key; + $node = $node->entryFor($key)?->value; + if ($node === null) { return; } + } + if (end($path) === 'cells' && $recursive && $node->kind === SourceNode::ARRAY && $node->entries !== [] && ! $node->isList) { + throw new MapSourceRefusal('Tile-art cells require implicit list entries so insertion and removal preserve source ownership.'); + } + $this->assertTileArtSourceNode($node, $path, $recursive); + } + + private function assertTileArtSourceNode(SourceNode $node, array $path, bool $recursive = false): void + { + if (in_array($node->kind, [SourceNode::VARIABLE, SourceNode::EXPRESSION], true) || $node->hasOpaqueKey) { + throw new MapSourceRefusal(sprintf('%s: tile art at %s requires literal data; opaque expressions, variables and keys are not rewritten.', + $this->mapId, PhpArraySourceDocument::describePath($path))); + } + if ($recursive) { + foreach ($node->entries as $index => $entry) { + $this->assertTileArtSourceNode($entry->value, [...$path, $entry->key ?? $index], true); + } + } + } +} diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 169ad31..0c5e7e4 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -35,10 +35,12 @@ final class ProjectMap { use TracksPersistedState; + use \Ichiloto\Editor\Maps\TileArt; private MapLayers $layers; - private ?string $tileDefinitionKey = null; - private array $tileDefinitions = []; + /** Invalidated by data/layer/dimension changes, not by painting individual glyphs. */ + private ?array $tileDefinitions = null; + private array $tileDefinitionGeometry = []; /** * @var array @@ -128,6 +130,7 @@ public function __construct( */ private function adoptDataSource(string $source): void { + $this->tileDefinitions = null; $this->dataLayerNames = array_column($this->getLayers(), 'name', 'id'); try { $this->dataDocument = PhpArraySourceDocument::parse($source); @@ -434,6 +437,7 @@ public function createLayer(string $name, bool $decoration = false, ?int $order $this->editableData = $this->loadedData = $data; $this->adoptDataSource($document->source); } + $this->tileDefinitions = null; $this->cachedWidth = null; $this->touchState(); return $id; @@ -463,6 +467,7 @@ public function renameLayer(string $id, string $name, bool $confirmCollisionChan $this->layers->restoreSnapshot($before); throw $error; } + $this->tileDefinitions = null; $this->touchState(); } @@ -480,6 +485,7 @@ public function removeLayer(string $id): void } $this->layers->removeLayer($id); $this->writeData($data); + $this->tileDefinitions = null; $this->cachedWidth = null; $this->touchState(); } @@ -503,14 +509,20 @@ public function getLayerTiles2d(string $id): array public function getLayerTileDefinitions(): array { - $key = serialize([$this->editableData['tiles2d'] ?? null, $this->getLayers(), $this->layers->legacy]); - if ($key === $this->tileDefinitionKey) { + // Retained MapLayers/EditableGrid objects may change outside our setters. + // Compare only row shapes and layer identity, never every crop on each paint dab. + $geometry = [$this->layers->legacy, array_map(static function (array $layer): array { + $rows = array_map(count(...), $layer['grid']->cells); + unset($layer['grid']); + return [$layer, $rows]; + }, $this->layers->getLayers())]; + if ($this->tileDefinitions !== null && $this->tileDefinitionGeometry === $geometry) { return $this->tileDefinitions; } $definitions = isset($this->editableData['tiles2d']) ? GraphicalTileDefinition::getForLayers($this->editableData['tiles2d'], $this->layers->getLayerSet(), $this->mapId) : []; - $this->tileDefinitionKey = $key; + $this->tileDefinitionGeometry = $geometry; return $this->tileDefinitions = $definitions; } @@ -972,6 +984,7 @@ public function restoreGridSnapshot(array $snapshot): void { $this->assertEditable(); $this->layers->restoreSnapshot($snapshot['layers']); + $this->tileDefinitions = null; $this->cachedWidth = null; $this->touchState(); } @@ -1116,6 +1129,7 @@ private function writeData(array $next): void $this->assertDataPreservable($next); $this->editableData = $next; + $this->tileDefinitions = null; $this->touchState(); } @@ -1453,6 +1467,7 @@ public function resize(int $width, int $height): void $this->layers->resize($width, $height); + $this->tileDefinitions = null; $this->cachedWidth = null; $this->touchState(); } diff --git a/src/UI/Modal.php b/src/UI/Modal.php index c75e98d..5433cbc 100644 --- a/src/UI/Modal.php +++ b/src/UI/Modal.php @@ -33,6 +33,7 @@ enum Modal: string * The folder-move confirmation raised by a renaming save. */ case RENAME_CONFIRMATION = 'rename_confirmation'; + case TILE_ART = 'tile_art'; case LAYER_EDIT = 'layer_edit'; /** * The Ctrl+P command palette. Opens above the Database screen and the diff --git a/tests/Unit/CellTileArtTest.php b/tests/Unit/CellTileArtTest.php new file mode 100644 index 0000000..660a59b --- /dev/null +++ b/tests/Unit/CellTileArtTest.php @@ -0,0 +1,475 @@ + pack('N', strlen($bytes)) . $kind . $bytes . pack('N', crc32($kind . $bytes)); + $png = "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNC5', 64, 64, 8, 6, 0, 0, 0)) + . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xff\xff\xff\xff", 64), 64))) . $chunk('IEND', ''); + foreach (['shared', 'replacement', 'detail'] as $name) { + file_put_contents($root . '/assets/Graphics/Tilesets/' . $name . '.png', $png); + } + file_put_contents($root . '/assets/Graphics/Tilesets/not-art.txt', 'not art'); + if ($legacy) { + file_put_contents($root . '/assets/Maps/test-map/test-map.map.php', MapGridSource::buildSource("xx \nx ", 'TILES')); + file_put_contents($root . '/assets/Maps/test-map/test-map.event.php', MapGridSource::buildSource(" \n ", 'EVENTS')); + file_put_contents($root . '/assets/Maps/test-map/test-map.data.php', " 'Legacy', 'events' => []];\n"); + } + return $root; +} + +function getCellTileArtRectangle(int $x = 16): array +{ + return ['x' => $x, 'y' => 0, 'width' => 16, 'height' => 16]; +} + +it('sets and removes cell crops without changing inherited symbols other layers or authored grids', function () { + $root = createCellTileArtProject(); + $map = loadLayeredMap($root); + $before = $map->captureLayerSnapshot(); + $files = sourceHashTree($root . '/assets/Maps'); + $map->setCellTileArt('map:4', 1, 0, getCellTileArtRectangle()); + $map->setCellTileArt('map:4', 0, 1, getCellTileArtRectangle(32)); + $after = $map->captureLayerSnapshot(); + expect($map->getCellTileArt('map:4', 0, 1)['override'])->toBe(getCellTileArtRectangle(32)) + ->and($map->getCellTileArt('map:4', 0, 1)['inherited'])->toBe(getCellTileArtRectangle(0)) + ->and($map->getCellTileArt('map:4', 1, 1)['override'])->toBeNull() + ->and($map->getEditableData()['tiles2d']['layers']['detail'])->toBe($before['data']['tiles2d']['layers']['detail']) + ->and($map->getEditableData()['tiles2d']['layers']['buildings']['symbols'])->toBe($before['data']['tiles2d']['layers']['buildings']['symbols']) + ->and($map->captureGridSnapshot())->toBe($before['grid']); + $map->save(); + expect(array_keys(array_diff_assoc(sourceHashTree($root . '/assets/Maps'), $files)))->toBe(['test-map/test-map.data.php']); + $savedSource = file_get_contents($map->dataPath); + $map->restoreLayerSnapshot($before); + $map->save(); + expect(sourceHashTree($root . '/assets/Maps'))->toBe($files); + $map->restoreLayerSnapshot($after); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe($savedSource); + $map = loadLayeredMap($root); + $map->removeCellTileArt('map:4', 0, 1); + expect($map->getCellTileArt('map:4', 0, 1)['override'])->toBeNull() + ->and($map->getCellTileArt('map:4', 0, 1)['inherited'])->toBe(getCellTileArtRectangle(0)) + ->and($map->getCellTileArt('map:4', 1, 0)['override'])->toBe(getCellTileArtRectangle()); + $map->removeCellTileArt('map:4', 1, 0); + $map->save(); + expect($map->getEditableData())->toBe($before['data']); +}); + +it('creates cells-only art on blank cells and prunes only the final empty definition', function (bool $legacy) { + $root = createCellTileArtProject($legacy); + $map = loadLayeredMap($root); + $id = $map->getBaseLayerId(); + $before = $map->captureLayerSnapshot(); + $map->setCellTileArt($id, 3, 0, getCellTileArtRectangle(), 'Graphics/Tilesets/shared.png'); + $map->save(); + $map = loadLayeredMap($root); + expect($map->getCellTileArt($id, 3, 0)['override'])->toBe(getCellTileArtRectangle()) + ->and($map->isLegacyMap())->toBe($legacy) + ->and($map->captureGridSnapshot())->toBe($before['grid']); + $map->removeCellTileArt($id, 3, 0); + $map->save(); + expect($map->getEditableData())->toBe($before['data']); +})->with([false, true]); + +it('refuses out-of-row coordinates events invalid rectangles and unpicked asset paths before mutation', function (string $id, int $x, int $y, array $rect, ?string $asset) { + $root = createCellTileArtProject(); + $map = loadLayeredMap($root); + $before = $map->captureLayerSnapshot(); + $files = sourceHashTree($root); + expect(fn() => $map->setCellTileArt($id, $x, $y, $rect, $asset))->toThrow(Exception::class) + ->and($map->captureLayerSnapshot())->toBe($before) + ->and(sourceHashTree($root))->toBe($files) + ->and($map->isDirty())->toBeFalse(); +})->with([ + ['map:4', 2, 1, getCellTileArtRectangle(), null], + ['map:4', -1, 0, getCellTileArtRectangle(), null], + ['event', 0, 0, getCellTileArtRectangle(), null], + ['map:4', 0, 0, ['x' => 0, 'y' => 0, 'width' => 0, 'height' => 16], null], + ['map:4', 0, 0, ['x' => -1, 'y' => 0, 'width' => 16, 'height' => 16], null], + ['map:4', 0, 0, ['x' => 60, 'y' => 0, 'width' => 16, 'height' => 16], null], + ['map:4', 0, 0, ['x' => 0, 'y' => 0, 'width' => 16, 'height' => 16, 'extra' => true], null], + ['map:4', 0, 0, ['x' => 0, 'y' => 0, 'width' => 16], null], + ['map:4', 0, 0, getCellTileArtRectangle(), '../outside.png'], + ['map:4', 0, 0, getCellTileArtRectangle(), 'Graphics/Tilesets/not-art.txt'], +]); + +it('refuses duplicate or out-of-bounds loaded overrides rather than silently normalizing them', function (bool $duplicate) { + $root = createCellTileArtProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $source = file_get_contents($path); + $cell = ['column' => $duplicate ? 0 : 2, 'row' => 1, 'source' => getCellTileArtRectangle()]; + $source = str_replace("'buildings' => [", "'buildings' => ['cells' => " . var_export($duplicate ? [$cell, $cell] : [$cell], true) . ', ', $source); + file_put_contents($path, $source); + $map = loadLayeredMap($root); + $before = $map->getEditableData(); + expect(fn() => $map->setCellTileArt('map:4', 0, 0, getCellTileArtRectangle()))->toThrow(InvalidArgumentException::class) + ->and($map->getEditableData())->toBe($before) + ->and(file_get_contents($path))->toBe($source) + ->and($map->isDirty())->toBeFalse(); +})->with([false, true]); + +it('refuses opaque tile-art containers but leaves unrelated executable data alone', function (string $shape) { + $root = createCellTileArtProject(true); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $cell = "['column' => 0, 'row' => 0, 'source' => ['x' => 0, 'y' => 0, 'width' => 16, 'height' => 16]]"; + $literal = "['asset' => 'Graphics/Tilesets/shared.png', 'cells' => [$cell]]"; + $source = match ($shape) { + 'variable' => " \$art];", + 'expression' => " array_replace([], $literal)];", + 'cells' => " ['asset' => 'Graphics/Tilesets/shared.png', 'cells' => \$cells]];", + 'key' => " ['asset' => 'Graphics/Tilesets/shared.png', \$key => [$cell]]];", + 'explicit index' => " ['asset' => 'Graphics/Tilesets/shared.png', 'cells' => [0 => $cell]]];", + }; + file_put_contents($path, $source); + $map = loadLayeredMap($root); + $before = $map->captureLayerSnapshot(); + expect(fn() => $map->setCellTileArt('tile', 0, 0, getCellTileArtRectangle()))->toThrow(MapSourceRefusal::class) + ->and(fn() => $map->removeCellTileArt('tile', 0, 0))->toThrow(MapSourceRefusal::class) + ->and($map->captureLayerSnapshot())->toBe($before) + ->and(file_get_contents($path))->toBe($source); +})->with(['variable', 'expression', 'cells', 'key', 'explicit index']); + +it('edits a literal crop leaf surgically without rewriting symbol expressions or neighboring comments', function () { + $root = createCellTileArtProject(true); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $source = <<<'PHP' + strtoupper('legacy'), 'tiles2d' => [ + 'asset' => 'Graphics/Tilesets/shared.png', + 'symbols' => array_replace([], ['x' => ['x' => 0, 'y' => 0, 'width' => 16, 'height' => 16]]), + 'cells' => [ + // Selected cell keeps this heading. + ['column' => 0, 'row' => 0, 'source' => ['x' => 8 /* crop start */, 'y' => 0, 'width' => 16, 'height' => 16]], + // Unrelated cell keeps its heading and spacing. + ['column'=>1, 'row'=>0, 'source'=>['x'=>0, 'y'=>0, 'width'=>16, 'height'=>16]], + ], +]]; +PHP; + file_put_contents($path, $source); + $map = loadLayeredMap($root); + $map->setCellTileArt('tile', 0, 0, getCellTileArtRectangle(32)); + $map->save(); + expect(file_get_contents($path))->toBe(str_replace('8 /* crop start */', '32 /* crop start */', $source)); +}); + +it('retains authored cell and rectangle key order for exact transaction verification and no-op edits', function () { + $root = createCellTileArtProject(true); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $source = <<<'PHP' + ['asset' => 'Graphics/Tilesets/shared.png', 'cells' => [ + ['source' => ['width' => 16, 'height' => 16, 'y' => 0, 'x' => 16], 'row' => 0, 'column' => 0], +]]]; +PHP; + file_put_contents($path, $source); + $map = loadLayeredMap($root); + $before = $map->getEditableData(); + expect(fn() => $map->setCellTileArt('tile', 0, 0, ['x' => 0, 'y' => 0, 'width' => 16])) + ->toThrow(InvalidArgumentException::class, 'height'); + $map->setCellTileArt('tile', 0, 0, getCellTileArtRectangle()); + expect($map->getEditableData())->toBe($before)->and($map->isDirty())->toBeFalse(); + $map->setCellTileArt('tile', 0, 0, getCellTileArtRectangle(32)); + $map->save(); + expect(file_get_contents($path))->toBe(str_replace("'x' => 16", "'x' => 32", $source)); +}); + +it('uses the real palette asset picker crop editor confirmation save and undo without losing context', function () { + $root = createCellTileArtProject(); + [$editor, $map] = layeredCanvasEditor($root); + callEditorMethod($editor, 'dispatchInput', ']'); + $before = $map->captureLayerSnapshot(); + callEditorMethod($editor, 'dispatchInput', "\x10"); + foreach (str_split('Tile art: Edit selected cell') as $key) { callEditorMethod($editor, 'dispatchInput', $key); } + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'modals')->has(Modal::TILE_ART))->toBeTrue() + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('buildings / cell 0,0'); + foreach ([']', "\x04", "\x13", "\033[<0;5;5M"] as $key) { callEditorMethod($editor, 'dispatchInput', $key); } + expect($map->captureLayerSnapshot())->toBe($before) + ->and(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:4'); + callEditorMethod($editor, 'dispatchInput', 'a'); + foreach (str_split('replacement') as $key) { callEditorMethod($editor, 'dispatchInput', $key); } + callEditorMethod($editor, 'dispatchInput', "\r"); + callEditorMethod($editor, 'dispatchInput', "\t"); + callEditorMethod($editor, 'dispatchInput', "\r"); + callEditorMethod($editor, 'dispatchInput', "\177"); + callEditorMethod($editor, 'dispatchInput', '3'); + callEditorMethod($editor, 'dispatchInput', '2'); + callEditorMethod($editor, 'dispatchInput', "\r"); + callEditorMethod($editor, 'dispatchInput', 's'); + expect(renderEditorPlainFrame($editor, 160, 45))->toContain('Every existing symbol and cell crop', 'Y:Confirm') + ->and($map->captureLayerSnapshot())->toBe($before); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect($map->captureLayerSnapshot())->toBe($before); + callEditorMethod($editor, 'dispatchInput', 'y'); + expect($map->getCellTileArt('map:4', 0, 0)['override'])->toBe(getCellTileArtRectangle(32)) + ->and($map->getEditableData()['tiles2d']['asset'])->toBe('Graphics/Tilesets/shared.png') + ->and($map->getCellTileArt('map:7', 0, 0)['asset'])->toBe('Graphics/Tilesets/detail.png'); + callEditorMethod($editor, 'dispatchInput', "\x13"); + $saved = file_get_contents($map->dataPath); + callEditorMethod($editor, 'dispatchInput', ']'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(file_get_contents($map->dataPath))->toBe($before['source']); + callEditorMethod($editor, 'dispatchInput', "\x19"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(file_get_contents($map->dataPath))->toBe($saved); + callEditorMethod($editor, 'dispatchInput', "\x12"); + expect(callEditorMethod($editor, 'getSelectedMap')->getCellTileArt('map:4', 0, 0)['override'])->toBe(getCellTileArtRectangle(32)); +}); + +it('offers only project-contained PNG assets and never treats picker text as a resource path', function () { + $root = createCellTileArtProject(); + copy($root . '/assets/Graphics/Tilesets/shared.png', $root . '/outside.png'); + symlink($root . '/outside.png', $root . '/assets/Graphics/Tilesets/outside.png'); + expect(ReferenceCatalog::getPngAssets($root))->toBe([ + 'Graphics/Tilesets/detail.png', 'Graphics/Tilesets/replacement.png', 'Graphics/Tilesets/shared.png', + ]); + [$editor, $map] = layeredCanvasEditor($root); + $before = $map->captureLayerSnapshot(); + callEditorMethod($editor, 'openCellTileArt'); + callEditorMethod($editor, 'dispatchInput', 'a'); + foreach (str_split('../outside.png') as $key) { callEditorMethod($editor, 'dispatchInput', $key); } + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'referencePicker')->isOpen())->toBeTrue(); + callEditorMethod($editor, 'dispatchInput', "\033"); + callEditorMethod($editor, 'dispatchInput', "\033"); + expect($map->captureLayerSnapshot())->toBe($before); +}); + +it('requires atlas confirmation before mutation and rejects invalid PNGs or existing crops outside the replacement', function () { + $root = createCellTileArtProject(); + $map = loadLayeredMap($root); + $before = $map->captureLayerSnapshot(); + $asset = 'Graphics/Tilesets/replacement.png'; + expect(fn() => $map->setCellTileArt('map:4', 0, 1, getCellTileArtRectangle(), $asset)) + ->toThrow(MapSourceRefusal::class, 'explicit confirmation') + ->and($map->captureLayerSnapshot())->toBe($before); + file_put_contents($root . '/assets/' . $asset, 'not a PNG'); + expect(fn() => $map->setCellTileArt('map:4', 0, 1, getCellTileArtRectangle(), $asset, true)) + ->toThrow(RuntimeException::class, 'Invalid PNG header') + ->and($map->captureLayerSnapshot())->toBe($before); + // The new cell fits, but the inherited 16px symbol crop would not. + $png = file_get_contents($root . '/assets/Graphics/Tilesets/shared.png'); + file_put_contents($root . '/assets/' . $asset, substr_replace($png, pack('NN', 8, 8), 16, 8)); + expect(fn() => $map->setCellTileArt('map:4', 0, 1, ['x' => 0, 'y' => 0, 'width' => 1, 'height' => 1], $asset, true)) + ->toThrow(RuntimeException::class, 'crop exceed image bounds') + ->and($map->captureLayerSnapshot())->toBe($before); +}); + +it('keeps coordinate ownership through layer rename map duplication move removal and restoration', function () { + $root = createCellTileArtProject(); + $map = loadLayeredMap($root); + foreach (['map:4', 'map:7'] as $id) { $map->setCellTileArt($id, 1, 1, getCellTileArtRectangle(32)); } + $map->save(); + $before = $map->captureLayerSnapshot(); + $files = sourceHashTree($map->directory); + $map->renameLayer('map:4', 'furniture'); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe(str_replace("'buildings' =>", "'furniture' =>", $before['source'])) + ->and($map->getCellTileArt('map:4', 1, 1)['override'])->toBe(getCellTileArtRectangle(32)); + $map->duplicateTo($root . '/assets/Maps/copy', 'copy', 'Copy'); + $copy = ProjectMap::fromDirectory($root . '/assets/Maps', $root . '/assets/Maps/copy'); + expect($copy->getEditableData()['tiles2d'])->toBe($map->getEditableData()['tiles2d']) + ->and(sourceHashTree($copy->directory . '/layers'))->toBe(sourceHashTree($map->directory . '/layers')); + $moved = $copy->moveTo('district/copy'); + expect($moved->getEditableData()['tiles2d'])->toBe($map->getEditableData()['tiles2d']) + ->and($moved->getCellTileArt('map:7', 1, 1)['override'])->toBe(getCellTileArtRectangle(32)); + $map->removeLayer('map:4'); + $map->save(); + expect($map->getEditableData()['tiles2d']['layers'])->not->toHaveKey('furniture') + ->and($map->getCellTileArt('map:7', 1, 1)['override'])->toBe(getCellTileArtRectangle(32)); + $map->restoreLayerSnapshot($before); + $map->save(); + expect(sourceHashTree($map->directory))->toBe($files); +}); + +it('leaves coordinates in place on resizing and revalidates cached bounds before saving', function (bool $legacy) { + $root = createCellTileArtProject($legacy); + $map = loadLayeredMap($root); + $id = $map->getBaseLayerId(); + $map->setCellTileArt($id, 3, 0, getCellTileArtRectangle(), 'Graphics/Tilesets/shared.png'); + $map->setCellTileArt($id, 1, 1, getCellTileArtRectangle(32)); + $map->save(); + $original = $map->captureLayerSnapshot(); + $files = sourceHashTree($map->directory); + $data = $map->getEditableData(); + $map->getLayerTileDefinitions(); + $map->resize(3, 2); + expect($map->getEditableData())->toBe($data) + ->and(fn() => $map->getLayerTileDefinitions())->toThrow(InvalidArgumentException::class, 'outside layer') + ->and(fn() => $map->save())->toThrow(InvalidArgumentException::class, 'outside layer') + ->and(sourceHashTree($map->directory))->toBe($files); + $map->restoreLayerSnapshot($original); + $map->getLayerTileDefinitions(); + $map->resize(4, 1); + expect(fn() => $map->save())->toThrow(InvalidArgumentException::class, 'outside layer'); + $map->restoreLayerSnapshot($original); + $map->resize(5, 3); + $map->save(); + expect($map->getEditableData())->toBe($data) + ->and($map->getCellTileArt($id, 3, 0)['override'])->toBe(getCellTileArtRectangle()) + ->and($map->getCellTileArt($id, 1, 1)['override'])->toBe(getCellTileArtRectangle(32)); + $map->restoreLayerSnapshot($original); + $map->save(); + expect(sourceHashTree($map->directory))->toBe($files) + ->and(array_map(count(...), $map->getLayerSet()->layers[0]->grid))->toBe([4, 2]); +})->with([false, true]); + +it('rolls back cell metadata and changed grids together when transactional installation fails', function () { + $root = createCellTileArtProject(); + $map = loadLayeredMap($root); + $before = sourceHashTree($map->directory); + $map->setCellTileArt('map:4', 0, 1, getCellTileArtRectangle(32)); + $map->setLayerCell('map:1', 0, 0, '#'); + $failure = new FailingFileSetOperations(failures: ['move' => [$map->dataPath]]); + expect(fn() => $map->save(files: $failure))->toThrow(FileSetTransactionFailure::class) + ->and(sourceHashTree($map->directory))->toBe($before) + ->and($map->isDirty())->toBeTrue(); + $map->save(); + expect(loadLayeredMap($root)->getCellTileArt('map:4', 0, 1)['override'])->toBe(getCellTileArtRectangle(32)); +}); + +it('uses inspector remove confirmation and undo without changing inherited defaults', function () { + [$editor, $map] = layeredCanvasEditor(createCellTileArtProject()); + $map->setCellTileArt('map:4', 0, 0, getCellTileArtRectangle(32)); + $map->save(); + $before = $map->captureLayerSnapshot(); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + callEditorMethod($editor, 'dispatchInput', "\t"); + $found = false; + foreach (callEditorMethod($editor, 'getInspectorFields') as $index => $field) { + if (($field['target'] ?? '') !== 'tile-art') { continue; } + $found = true; + setEditorProperty($editor, 'selectedInspectorFieldIndex', $index); + callEditorMethod($editor, 'dispatchInput', "\r"); + } + expect($found)->toBeTrue()->and(getEditorProperty($editor, 'modals')->has(Modal::TILE_ART))->toBeTrue(); + callEditorMethod($editor, 'dispatchInput', 'r'); + expect(renderEditorPlainFrame($editor, 160, 45))->toContain('Remove only this cell override?') + ->and($map->captureLayerSnapshot())->toBe($before); + callEditorMethod($editor, 'dispatchInput', "\033"); + expect($map->captureLayerSnapshot())->toBe($before); + callEditorMethod($editor, 'dispatchInput', 'r'); + callEditorMethod($editor, 'dispatchInput', 'y'); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect($map->getCellTileArt('map:4', 0, 0)['override'])->toBeNull(); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(file_get_contents($map->dataPath))->toBe($before['source']); +}); + +it('refuses stale captured cell art without changing the newer author state', function () { + [$editor, $map] = layeredCanvasEditor(createCellTileArtProject()); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + callEditorMethod($editor, 'openCellTileArt'); + $map->setCellTileArt('map:4', 0, 0, getCellTileArtRectangle(32)); + $newer = $map->captureLayerSnapshot(); + callEditorMethod($editor, 'dispatchInput', 's'); + expect($map->captureLayerSnapshot())->toBe($newer) + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('Cancel and reopen Tile art'); +}); + +it('authors coordinate-only decoration in stages while terminal preview and collision stay unchanged', function () { + $root = createCellTileArtProject(); + [$editor, $map] = layeredCanvasEditor($root); + $dictionary = []; + $collision = MapCollisionResolver::resolveLayers($map->getLayerSet(), $dictionary); + $terminal = $map->renderPreview(4, 2, terminalPreview: true); + $id = $map->createLayer('materials', true); + $map->setLayerCell($id, 1, 0, 'w'); + $map->setLayerCell($id, 2, 0, 'k'); + $map->setCellTileArt($id, 1, 0, getCellTileArtRectangle(), 'Graphics/Tilesets/shared.png'); + $before = sourceHashTree($map->directory); + expect(fn() => $map->save())->toThrow(InvalidArgumentException::class, 'no crop mapping') + ->and(sourceHashTree($map->directory))->toBe($before); + $map->setCellTileArt($id, 2, 0, getCellTileArtRectangle(32)); + $map->save(); + callEditorMethod($editor, 'selectCanvasLayer', $id); + $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + 1, $bounds['top'])); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + 1, $bounds['top'])); + foreach (['i', ' ', "\033"] as $key) { callEditorMethod($editor, 'dispatchInput', $key); } + expect(getEditorProperty($editor, 'canvasPaintWarning'))->toContain('cell overrides stay at their coordinates') + ->and($map->getCellTileArt($id, 1, 0)['override'])->toBe(getCellTileArtRectangle()); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect($map->renderPreview(4, 2, terminalPreview: true))->toBe($terminal) + ->and(MapCollisionResolver::resolveLayers($map->getLayerSet(), $dictionary))->toBe($collision); + $map->removeCellTileArt($id, 2, 0); + expect(fn() => $map->save())->toThrow(InvalidArgumentException::class, 'no crop mapping'); +}); + +it('reuses validated crop definitions across every paint dab and invalidates them for data and layer changes', function () { + [$editor, $map] = layeredCanvasEditor(createCellTileArtProject()); + $map->setCellTileArt('map:4', 0, 0, getCellTileArtRectangle(32)); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + $definitions = $map->getLayerTileDefinitions(); + for ($dab = 0; $dab < 20; $dab++) { + $map->setLayerCell('map:4', 0, 0, $dab % 2 === 0 ? 'x' : ' '); + callEditorMethod($editor, 'recordCanvasCropWarning', $map, 'x', ' ', 0, 0); + expect($map->getLayerTileDefinitions()['buildings'])->toBe($definitions['buildings']); + } + $map->setCellTileArt('map:4', 0, 0, getCellTileArtRectangle()); + expect($map->getLayerTileDefinitions()['buildings'])->not->toBe($definitions['buildings']); + $map->renameLayer('map:4', 'furniture'); + expect($map->getLayerTileDefinitions())->toHaveKey('furniture')->not->toHaveKey('buildings'); + $map->removeLayer('map:4'); + expect($map->getLayerTileDefinitions())->not->toHaveKey('furniture'); +}); + +it('detects retained layer and grid mutations without relying on ProjectMap setters', function () { + $root = createCellTileArtProject(); + $original = loadLayeredMap($root); + $original->setCellTileArt('map:4', 3, 0, getCellTileArtRectangle()); + $original->save(); + $layers = \Ichiloto\Editor\Maps\MapLayers::createFromSource($original->directory, $original->getLayerSet(), + $original->eventPath, MapGridSource::readFile($original->eventPath)); + $map = new ProjectMap($original->mapId, $original->directory, $original->dataPath, $original->mapPath, + $original->eventPath, $original->getEditableData(), $original->tileLines, $original->eventLines, + file_get_contents($original->dataPath), layers: $layers); + $before = $layers->captureSnapshot(); + $map->getLayerTileDefinitions(); + $retained = $layers->getGrid('map:4'); + array_pop($retained->cells[0]); + expect(fn() => $map->getLayerTileDefinitions())->toThrow(InvalidArgumentException::class); + $layers->restoreSnapshot($before); + $map->getLayerTileDefinitions(); + $layers->resize(3, 2); + expect(fn() => $map->getLayerTileDefinitions())->toThrow(InvalidArgumentException::class, 'outside layer'); + $layers->restoreSnapshot($before); + $map->getLayerTileDefinitions(); + $layers->renameLayer('map:4', 'furniture'); + expect(fn() => $map->getLayerTileDefinitions())->toThrow(InvalidArgumentException::class, 'unknown or malformed layer'); +}); + +it('refuses pruning the final layer definition when it would remove an opaque shared atlas expression', function () { + $root = createCellTileArtProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $source = <<<'PHP' + [ + 'asset' => strval('Graphics/Tilesets/shared.png'), + 'layers' => ['buildings' => ['cells' => [ + ['column' => 0, 'row' => 0, 'source' => ['x' => 0, 'y' => 0, 'width' => 16, 'height' => 16]], + ]]], +]]; +PHP; + file_put_contents($path, $source); + $map = loadLayeredMap($root); + $before = $map->captureLayerSnapshot(); + expect(fn() => $map->removeCellTileArt('map:4', 0, 0))->toThrow(MapSourceRefusal::class, 'opaque') + ->and($map->captureLayerSnapshot())->toBe($before) + ->and(file_get_contents($path))->toBe($source); +}); diff --git a/tests/Unit/LayeredCanvasTest.php b/tests/Unit/LayeredCanvasTest.php index b9f06af..9215421 100644 --- a/tests/Unit/LayeredCanvasTest.php +++ b/tests/Unit/LayeredCanvasTest.php @@ -106,7 +106,7 @@ [$editor, $map] = layeredCanvasEditor(); callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); $fields = callEditorMethod($editor, 'getLayerInspectorFields'); - expect(array_column($fields, 'editable'))->each->toBeFalse() + expect(array_column(array_filter($fields, static fn(array $field): bool => ($field['target'] ?? '') !== 'tile-art'), 'editable'))->each->toBeFalse() ->and(json_encode($fields))->toContain('shared.png', '16'); callEditorMethod($editor, 'applyCanvasWrites', $map, [['x' => 0, 'y' => 0, 'symbol' => 'x']], 'mapped'); expect(getEditorProperty($editor, 'canvasPaintWarning'))->toContain('Crop mapping'); diff --git a/tests/Unit/MixedMaterialAuthoringTest.php b/tests/Unit/MixedMaterialAuthoringTest.php index 06d4f82..39a791f 100644 --- a/tests/Unit/MixedMaterialAuthoringTest.php +++ b/tests/Unit/MixedMaterialAuthoringTest.php @@ -154,13 +154,14 @@ function getMixedMaterialCanvasRows(Editor $editor): array expect(sourceHashTree($root . '/assets/Maps'))->toBe($saved); }); -it('keeps new material crop authoring explicitly read-only and refuses unmapped decoration through Ctrl-S', function () { +it('keeps symbol defaults read-only alongside cell art authoring and refuses unmapped decoration through Ctrl-S', function () { $root = createMixedMaterialProject(); [$editor, $map] = layeredCanvasEditor($root); $before = sourceHashTree($root); callEditorMethod($editor, 'dispatchInput', ']'); $fields = callEditorMethod($editor, 'getLayerInspectorFields'); - expect(array_column($fields, 'editable'))->each->toBeFalse(); + expect(array_column(array_filter($fields, static fn(array $field): bool => ($field['target'] ?? '') !== 'tile-art'), 'editable'))->each->toBeFalse(); + expect(array_values(array_filter($fields, static fn(array $field): bool => ($field['target'] ?? '') === 'tile-art'))[0]['editable'])->toBeTrue(); callEditorMethod($editor, 'dispatchInput', "\t"); $attempted = []; foreach (callEditorMethod($editor, 'getInspectorFields') as $index => $field) { diff --git a/tests/__snapshots__/main_shell_100x30.ansi b/tests/__snapshots__/main_shell_100x30.ansi index eae4032..7a84534 100644 --- a/tests/__snapshots__/main_shell_100x30.ansi +++ b/tests/__snapshots__/main_shell_100x30.ansi @@ -1 +1 @@ -[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint []:Layer t:Terminal┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > terrain · visible ││ Events · visible ││ tiles2d (read-only) ││ (no atlas) ││ │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file +[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint []:Layer t:Terminal┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > terrain · visible ││ Events · visible ││ tiles2d (read-only) ││ (no atlas) ││ Tile art: selected cell: │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file From e0635739d5e7064d41f7bb0cdaa0d78440151449 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 25 Sep 2026 20:37:22 +0200 Subject: [PATCH 038/344] feat(authoring): edit field zoom and NPC graphical sprites Add staged four-direction NPC PNG roles, pose crops and sheet fields using the shared asset picker, Engine validation, source-preserving map saves and undo. Author graphics.field.zoom from the System numeric row through one configuration owner shared with Terms. Remove whole-config Terms rewrites and the blanket inline-comment Terms refusal. Keep opaque expressions read-only; narrow ambiguous dotted literal Terms keys to read-only rather than risk editing a different nested value. Remove sprites2d from unknown NPC field warnings while retaining malformed-art diagnostics. Verify both PHP 8.5 and 8.4 full suites: 1228 passed, 65258 assertions, no skips each; PHPStan clean on both and render-only TUI form smoke passed. Tests use the local Engine source now committed at fad4c36. Keep the installed dependency and composer.lock unchanged at 9011d7e; no publishing. --- docs/manual.md | 40 ++++- src/Database/ProjectRecordDatabase.php | 115 ++++--------- src/Database/RecordSchemaCatalog.php | 6 +- src/Editor.php | 41 ++++- src/Field/DirectionalSpriteDraft.php | 102 +++++++++++ src/Field/NpcSpriteArt.php | 53 ++++++ src/Field/NpcSpriteCanvas.php | 178 +++++++++++++++++++ src/Field/ProjectNpc.php | 2 +- src/ProjectConfig.php | 202 ++++++++++++++++++++++ src/ProjectMap.php | 1 + src/ProjectWorkspace.php | 7 +- src/UI/Modal.php | 1 + src/Validation/ProjectValidator.php | 10 ++ tests/Unit/NpcGraphicalAuthoringTest.php | 210 +++++++++++++++++++++++ tests/Unit/ProjectConfigTest.php | 154 +++++++++++++++++ tests/Unit/RecordDatabaseTest.php | 16 +- 16 files changed, 1032 insertions(+), 106 deletions(-) create mode 100644 src/Field/DirectionalSpriteDraft.php create mode 100644 src/Field/NpcSpriteArt.php create mode 100644 src/Field/NpcSpriteCanvas.php create mode 100644 src/ProjectConfig.php create mode 100644 tests/Unit/NpcGraphicalAuthoringTest.php create mode 100644 tests/Unit/ProjectConfigTest.php diff --git a/docs/manual.md b/docs/manual.md index 03e8128..57202c0 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -423,7 +423,7 @@ category uses - pickers, condition lines, world-write rows, command frames, | --- | --- | | Identity | `Id` (read-only), `Name` | | Placement | `X`, `Y` (the canvas moves it too) | -| Appearance | `Sprite`, `Facing North/South/East/West` | +| Appearance | `Sprite`, `Facing North/South/East/West`, `Graphical Sprites` | | Movement | `Movement` (`fixed` / `wander`), and while wandering `Wander X/Y/Width/Height` | | Visibility | `Visible When` - a condition line | | Interaction | `Script` (a command frame), then one `Dialogue variant N` heading per variant with its rows `When`, `Then Set`, `Script Commands`, `Line 1 Speaker`, `Line 1 Text`, … | @@ -452,6 +452,21 @@ character says is read in the pane rather than in the edit buffer. - **Directional sprites.** Optional glyphs shown when the NPC turns; the base sprite covers a heading you leave blank. Resting the Inspector cursor on a `Facing …` row previews that glyph on the canvas in the NPC's place. +- **Graphical sprites.** Select an NPC, focus its Inspector, and press `Enter` + on `Graphical Sprites`. Choose a direction/role, then select its PNG through + the shared project asset picker. Set its logical-pixel width and height; + optional crop X/Y/width/height are source-image pixels. Repeat for all four + directions. `S` applies the complete set as one undo step; `Ctrl+S` saves it. + `Esc` cancels without changing the NPC. `R`, then `Y`, removes the optional + `sprites2d` key, not its terminal glyph, identity, dialogue or collision. + For new animated artwork choose `sheet` before entering fields: size, + frame size, timing and anchor are shared, while each direction picks its own + PNG, columns, rows and populated frame count. Existing sheets and pose crops + retain their shape; changing representation never silently discards them. + Apply uses Engine validation and PNG header/bounds preflight; native rendering + remains the decode authority. Opaque source is refused before mutation. + This is a numeric authoring form, not an image preview. Player and cinematic + staged-actor `sprites2d` authoring are still separate TUI gaps. - **Dialogue.** Pages are shown as variants: one variant with lines is written back as plain pages; add a second variant, or give one a `When` condition, a `Then Set`, or a `Script`, and the whole thing is written as conditional @@ -737,7 +752,7 @@ status line says exactly why. | Optimize Exclusions | `assets/Data/equipment-optimization.php` | Editable | | System | `assets/Data/system.php` | Editable | | Types | `assets/Data/Types/*.php` | Read-only - PHP enum declarations | -| Terms | `config.php` (`vocab`, `messages`) | Editable when the config carries no inline comments | +| Terms | `config.php` (`vocab`, `messages`) | Literal terms editable; comments and unrelated expressions preserved | Why a category can still turn out read-only: a file the editor cannot evaluate, a value it could not write back out, or a comment sitting inside @@ -899,10 +914,23 @@ The `vocab` and `messages` trees of the project's `config.php`, flattened to one row per term with its dotted path - `vocab.game.new_game`, `messages.confirm.quit`, and so on. -Current limit: the category is read-only when `config.php` contains comments -inside the returned array, because rewriting the file would drop them. Move -such comments above the `return` statement and the category becomes editable - -everything before `return` is preserved byte-for-byte on save. +Terms and System's field zoom share one source-preserving configuration owner. +Saving either writes all pending configuration edits in one atomic operation. +Only edited literal values are patched; comments, spacing, enum references and +unrelated executable expressions remain untouched. An opaque term expression +is individually read-only, and ambiguous parent arrays or nonliteral returned +configuration are refused rather than regenerated. A config changed on disk +must be reloaded before saving, so newer external edits are not overwritten. + +### Field Zoom + +Open **Database > System > Field Zoom (GPUI only)**. Enter a finite number from +`1` through `8` (`2.5` is valid); the default is `1`. This writes the game's +`config.php` at `graphics.field.zoom`, not `system.php` or `ichiloto.json`. +It magnifies the GPUI field only: terminal maps, menus and UI sizing do not +change. `Ctrl+S` saves, `Ctrl+Z` undoes, and `Ctrl+Y` redoes. Undoing the first +edit restores an originally absent setting without leaving a default entry. +The same pending Terms edits are included when this configuration is saved. ### Knowledge diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index fe6fc3a..c07fc77 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -11,6 +11,7 @@ use Ichiloto\Editor\History\TracksPersistedState; use Ichiloto\Editor\ProjectDirectoryContext; +use Ichiloto\Editor\ProjectConfig; use Ichiloto\Editor\Inspector\InputControl; use Ichiloto\Editor\Inspector\InputControlType; use RuntimeException; @@ -30,7 +31,7 @@ */ final class ProjectRecordDatabase { - use TracksPersistedState; + use TracksPersistedState { isDirty as private isRecordStateDirty; } /** * @param RecordSchema $schema The category schema. @@ -39,7 +40,6 @@ final class ProjectRecordDatabase * @param PhpDataFile|null $file The backing file, for single-file categories. * @param bool $isDirty Whether structural changes are unsaved. * @param string|null $readOnlyReason Why the category cannot be written. - * @param mixed $rootPayload The whole config payload, for CONFIG_SUBTREE storage. * @param string[] $stagedDeletions Record files to unlink on the next save. */ private function __construct( @@ -49,10 +49,10 @@ private function __construct( private ?PhpDataFile $file = null, bool $isDirty = false, private ?string $readOnlyReason = null, - private mixed $rootPayload = null, private array $stagedDeletions = [], private ?\Closure $writeBack = null, private ?string $projectRoot = null, + private ?ProjectConfig $config = null, ) { if ($schema->storage === RecordStorage::LIST_FILE && $schema->projection === null) { // What the file declares for each record -- the identity it is @@ -256,7 +256,7 @@ public static function overOwnedList(RecordSchema $schema, string $ownerPath, ar * @param RecordSchema $schema The category schema. * @return self */ - public static function fromProject(string $projectRoot, RecordSchema $schema): self + public static function fromProject(string $projectRoot, RecordSchema $schema, ?ProjectConfig $config = null): self { // The categories evaluate authored files that construct engine // objects, and creating an entry constructs them too. The runtime @@ -269,7 +269,7 @@ public static function fromProject(string $projectRoot, RecordSchema $schema): s return match ($schema->storage) { RecordStorage::LIST_FILE => self::loadListFile($path, $schema, $projectRoot), RecordStorage::DIRECTORY => self::loadDirectory($path, $schema, $projectRoot), - RecordStorage::CONFIG_SUBTREE => self::loadConfigSubtree($path, $schema, $projectRoot), + RecordStorage::CONFIG_SUBTREE => self::loadConfigSubtree($path, $schema, $projectRoot, $config), RecordStorage::FILE_LISTING => self::loadFileListing($path, $schema), RecordStorage::MAP_OWNED => throw new RuntimeException('Map-owned records are built over their owner, not loaded from a path.'), }; @@ -323,6 +323,17 @@ public function isEditable(): bool return $this->readOnlyReason === null; } + public function isDirty(): bool + { + if ($this->config !== null) { + foreach ($this->records as $record) { + if ($record->isDirty()) { return true; } + } + return false; + } + return $this->isRecordStateDirty(); + } + /** * Returns the honest explanation for a read-only category. * @@ -435,11 +446,14 @@ public function getSettingsFields(int $index): array } $isEditable = $this->isEditable() && $record->isEditable(); + $configIssue = $this->config?->getFieldIssue($record->getDisplayValue('path')); + $isEditable = $isEditable && $configIssue === null; $fields = []; foreach ($this->schema->fieldsFor($record->toArray()) as $field) { $fields[] = self::describeField($field, self::displayValue($field, $record->get($field->key)), $field->key, $isEditable); } + if ($configIssue !== null) { $fields[] = ['label' => 'Read-only', 'value' => $configIssue, 'editable' => false]; } foreach ($this->schema->commandLists as $listKey => $commandList) { // A record-level command list (an NPC's inline script) is a @@ -618,6 +632,9 @@ public function setField(int $index, string $fieldId, string $rawValue): void if (! $record instanceof ProjectRecord || ! $this->isEditable() || ! $record->isEditable()) { return; } + if ($this->config !== null && ($issue = $this->config->getFieldIssue($record->getDisplayValue('path'))) !== null) { + throw new \Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal($issue); + } $subList = $this->schema->subList; @@ -1157,6 +1174,10 @@ public function save(): void if (! $this->isEditable()) { throw new RuntimeException(sprintf('%s is read-only: %s.', $this->schema->entryNoun, $this->readOnlyReason)); } + if ($this->config !== null) { + $this->config->save(); + return; + } if (! $this->isDirty()) { // Nothing diverges from the last save; writing would only @@ -1174,7 +1195,7 @@ public function save(): void match ($this->schema->storage) { RecordStorage::DIRECTORY => $this->saveDirectory(), - RecordStorage::CONFIG_SUBTREE => $this->saveConfigSubtree(), + RecordStorage::CONFIG_SUBTREE => null, RecordStorage::FILE_LISTING => null, RecordStorage::MAP_OWNED => $this->writeBack !== null ? ($this->writeBack)(array_map(static fn(ProjectRecord $record): array|object => $record->toArray(), $this->getRecords())) @@ -1275,29 +1296,11 @@ private static function loadDirectory(string $path, RecordSchema $schema, ?strin * @param RecordSchema $schema The category schema. * @return self */ - private static function loadConfigSubtree(string $path, RecordSchema $schema, ?string $projectRoot = null): self + private static function loadConfigSubtree(string $path, RecordSchema $schema, ?string $projectRoot = null, ?ProjectConfig $config = null): self { - $file = PhpDataFile::load($path, $projectRoot); - $payload = is_array($file->payload) ? $file->payload : []; - $records = []; - - foreach ($schema->configPath as $root) { - if (! is_array($payload[$root] ?? null)) { - continue; - } - - foreach (self::flattenLeaves($payload[$root], $root) as $leafPath => $value) { - $records[] = new ProjectRecord(['path' => $leafPath, 'value' => $value]); - } - } - - $reason = $schema->isAlwaysReadOnly ? $schema->readOnlyNote : $file->readOnlyReason; - - if ($reason === null && ! $file->exists) { - $reason = sprintf('%s does not exist in this project', basename($path)); - } - - return new self($schema, $path, $records, $file, readOnlyReason: $reason, rootPayload: $payload); + $config ??= new ProjectConfig($projectRoot ?? dirname($path)); + return new self($schema, $path, $config->getTermRecords($schema->configPath), + readOnlyReason: $schema->isAlwaysReadOnly ? $schema->readOnlyNote : $config->getReadOnlyReason(), config: $config); } /** @@ -1427,35 +1430,6 @@ private static function withoutEmptyFields(array $payload): array return $normalized; } - /** - * Flattens a nested config subtree into dotted leaf paths. - * - * @param array $tree The subtree. - * @param string $prefix The path prefix. - * @return array Leaf values keyed by dotted path. - */ - private static function flattenLeaves(array $tree, string $prefix): array - { - $leaves = []; - - foreach ($tree as $key => $value) { - $path = $prefix . '.' . $key; - - if (is_array($value) && $value !== []) { - $leaves += self::flattenLeaves($value, $path); - continue; - } - - if (is_array($value) || is_object($value)) { - continue; - } - - $leaves[$path] = $value; - } - - return $leaves; - } - /** * Re-reads the backing file so this category composes against what is * actually there, not what it held when this category loaded. @@ -2303,33 +2277,6 @@ private function saveDirectory(): void $this->stagedDeletions = []; } - /** - * Writes edited terms back into the project config file. - * - * @return void - */ - private function saveConfigSubtree(): void - { - if (! $this->file instanceof PhpDataFile) { - throw new RuntimeException('No backing config file to save.'); - } - - $payload = is_array($this->rootPayload) ? $this->rootPayload : []; - - foreach ($this->records as $record) { - $path = $record->getDisplayValue('path'); - - if ($path === '') { - continue; - } - - $payload = self::writeNested($payload, explode('.', $path), $record->get('value')); - } - - $this->file->save($payload); - $this->rootPayload = $payload; - } - /** * Applies one sub-list field edit. * diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 207ee42..68b7169 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -909,9 +909,9 @@ private static function commonEvents(): RecordSchema * UI vocabulary — the `vocab` and `messages` trees of the project's * `config.php`, flattened to one editable row per term. * - * Editable only when the whole config file round-trips (it holds enum - * cases, which export fine; a `new Something()` in there would make the - * category read-only, and say so). + * ProjectConfig shares these records with System's field zoom and + * patches literal leaves only. Unrelated comments and expressions stay + * untouched; opaque term values are individually read-only. * * @return RecordSchema */ diff --git a/src/Editor.php b/src/Editor.php index 5a3799e..29e5cb1 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -111,6 +111,7 @@ final class Editor { use \Ichiloto\Editor\Canvas\LayerCanvas; use \Ichiloto\Editor\Canvas\TileArtCanvas; + use \Ichiloto\Editor\Field\NpcSpriteCanvas; use CutscenesWorkspace; use CutsceneOutlinePane; use CutscenePreviewPane; @@ -1256,6 +1257,7 @@ private function buildInputRouter(): InputRouter $router->bindModal(Modal::RENAME_CONFIRMATION, $this->handleRenameConfirmationInput(...)); $router->bindModal(Modal::LAYER_EDIT, $this->handleLayerPromptInput(...)); $router->bindModal(Modal::TILE_ART, $this->handleTileArtInput(...)); + $router->bindModal(Modal::NPC_ART, $this->handleNpcArtInput(...)); $router->bindModal(Modal::COMMAND_PALETTE, $this->handleCommandPaletteInput(...)); $router->bindModal(Modal::HELP, $this->handleHelpInput(...)); $router->bindModal(Modal::DATABASE, $this->handleDatabaseInput(...)); @@ -1903,6 +1905,10 @@ private function handleNpcInspectorInput(string $input, string $normalizedInput) return; } + if (is_array($current) && ($current['field'] ?? null) === self::NPC_ART_FIELD) { + $this->openNpcSpriteArt(); + return; + } $this->beginDatabaseEdit(); @@ -2086,6 +2092,9 @@ private function getNpcInspectorFields(): array $records = $this->npcInspector->records(); $fields = $records->getFrameSettingsFields($this->selectedNpcIndex, $this->databaseCommandFramePath); + if ($this->databaseCommandFramePath === []) { + $fields[] = ['label' => 'Graphical Sprites', 'value' => 'Enter: four directional PNG roles', 'field' => self::NPC_ART_FIELD, 'editable' => true]; + } if ( $this->databaseCommandFramePath !== [] @@ -2128,7 +2137,7 @@ private function getNpcInspectorFields(): array $sections = [ 'Identity' => ['id', 'name'], 'Placement' => ['x', 'y'], - 'Appearance' => ['sprite', 'sprites.north', 'sprites.south', 'sprites.east', 'sprites.west'], + 'Appearance' => ['sprite', 'sprites.north', 'sprites.south', 'sprites.east', 'sprites.west', self::NPC_ART_FIELD], 'Movement' => ['movement', 'wanderArea.x', 'wanderArea.y', 'wanderArea.width', 'wanderArea.height'], 'Visibility' => ['conditions'], 'Interaction' => ['commandListScript'], @@ -6966,6 +6975,7 @@ private function getSaveableDatabases(): array 'Animations' => $this->workspace->animationDatabase, 'System' => $this->workspace->systemDatabase, ]; + if ($this->workspace->config !== null) { $databases['Project configuration'] = $this->workspace->config; } // Read-only categories never join Save All: they hold no edits, and // asking them to save would raise instead of no-op. @@ -10107,6 +10117,10 @@ private function saveActiveDatabase(): void $this->workspace->animationDatabase->save(); $this->setStatus('Animation database saved.', StatusLevel::SUCCESS); } elseif ($this->isSystemDatabaseSelected()) { + if ($this->workspace->config?->isDirty()) { + $this->backupBeforeSave($this->workspace->config->path); + $this->workspace->config->save(); + } $this->backupBeforeSave(...$this->getDatabaseBackupPaths($this->workspace->systemDatabase)); $this->workspace->systemDatabase->save(); $this->setStatus('System database saved.', StatusLevel::SUCCESS); @@ -12412,6 +12426,15 @@ private function getDatabaseSystemSettingsFields(): array } $system = $this->workspace->systemDatabase; + $config = $this->workspace->config; + $zoomIssue = $config?->getFieldIssue(ProjectConfig::FIELD_ZOOM); + $zoom = $config === null ? [] : [[ + 'label' => 'Field Zoom (GPUI only)', + 'value' => ProjectRecord::stringify($config->getFieldZoom()), + 'field' => ProjectConfig::FIELD_ZOOM, + ...($zoomIssue === null ? ['control' => new InputControl(InputControlType::FLOAT, ProjectRecord::stringify($config->getFieldZoom()))] : []), + ], ['label' => $zoomIssue === null ? 'Field Zoom Range' : 'Read-only', + 'value' => $zoomIssue ?? '1 to 8; default 1. Terminal and UI size stay unchanged.', 'editable' => false]]; return [ [ @@ -12438,6 +12461,7 @@ private function getDatabaseSystemSettingsFields(): array 'control' => new InputControl(InputControlType::INTEGER, (string) $system->getAtbSpeedFactorPercent()), 'field' => 'atbSpeedFactorPercent', ], + ...$zoom, ]; } @@ -12493,6 +12517,9 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) ]; $record = $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()); + if ($this->isSystemDatabaseSelected() && $fieldId === ProjectConfig::FIELD_ZOOM) { + $record = $this->workspace?->config?->getRecord(ProjectConfig::FIELD_ZOOM); + } $before = $record?->toArray(); $actor = $this->isActorsDatabaseSelected() ? $this->getSelectedActor() : null; $actorBefore = $actor?->getData(); @@ -12689,6 +12716,10 @@ private function applyDatabaseFieldValue(string $field, string $rawValue): void } if ($this->isSystemDatabaseSelected()) { + if ($field === ProjectConfig::FIELD_ZOOM) { + $this->workspace->config?->setFieldZoom($rawValue); + return; + } $value = in_array($field, ['battleEngine', 'atbMode'], true) ? trim($rawValue) : max(0, intval($rawValue)); @@ -16563,7 +16594,7 @@ private function getDatabaseAnimationListLines(): array */ private function getDatabaseSystemListLines(): array { - $dirty = $this->workspace?->systemDatabase->isDirty() === true ? ' *' : ''; + $dirty = $this->workspace?->systemDatabase->isDirty() === true || $this->workspace?->config?->isDirty() === true ? ' *' : ''; return [sprintf('> Project System%s', $dirty)]; } @@ -17369,7 +17400,7 @@ private function isDatabaseCategoryDirty(string $categoryKey): bool self::DATABASE_CATEGORY_CLASSES => $this->workspace->classDatabase->isDirty(), self::DATABASE_CATEGORY_SKILLS => $this->workspace->skillDatabase->isDirty(), self::DATABASE_CATEGORY_ANIMATIONS => $this->workspace->animationDatabase->isDirty(), - self::DATABASE_CATEGORY_SYSTEM => $this->workspace->systemDatabase->isDirty(), + self::DATABASE_CATEGORY_SYSTEM => $this->workspace->systemDatabase->isDirty() || ($this->workspace->config?->isDirty() ?? false), self::DATABASE_CATEGORY_QUESTS => $this->workspace->questDatabase->isDirty(), default => false, }; @@ -17723,6 +17754,10 @@ private function renderModalOverlays(array $layout): bool $this->renderTileArtDialog($layout); return true; } + if ($this->modals->has(Modal::NPC_ART)) { + $this->renderNpcArtDialog($layout); + return true; + } if ($this->isCommandPaletteOpen) { $this->renderCommandPaletteOverlay($layout); diff --git a/src/Field/DirectionalSpriteDraft.php b/src/Field/DirectionalSpriteDraft.php new file mode 100644 index 0000000..e440739 --- /dev/null +++ b/src/Field/DirectionalSpriteDraft.php @@ -0,0 +1,102 @@ +record = new ProjectRecord($data ?? []); + } + + public function getData(): array + { + return (array) $this->record->toArray(); + } + + public function getMode(): string + { + return $this->record->get('mode') === 'sheet' ? 'sheet' : 'poses'; + } + + public function setMode(string $mode): void + { + if ($mode === $this->getMode()) { return; } + if (array_diff_key($this->getData(), ['mode' => true]) !== []) { + throw new RuntimeException('Cancel the draft or remove existing art before changing representation. Existing poses/sheets are never converted silently.'); + } + $this->record->set('mode', $mode === 'sheet' ? 'sheet' : null); + } + + /** Fields describe existing Engine roles and units; references are picker-only. */ + public function getFields(string $direction): array + { + $sheet = $this->getMode() === 'sheet'; + $prefix = $sheet ? 'directions.' . $direction : $direction; + $fields = [['field' => $prefix . '.asset', 'label' => ucfirst($direction) . ' PNG', 'reference' => 'png_assets']]; + foreach ($sheet ? ['columns', 'rows', 'frames'] : [] as $key) { + $fields[] = ['field' => $prefix . '.' . $key, 'label' => ucfirst($key), 'type' => InputControlType::INTEGER]; + } + $shared = $sheet ? '' : $direction . '.'; + foreach ($sheet ? ['width', 'height', 'frameWidth', 'frameHeight', 'idleFrame', 'frameDurationMs', 'stepDurationMs', 'layer'] : ['width', 'height', 'layer'] as $key) { + $fields[] = ['field' => $shared . $key, 'label' => ucfirst($key) . (in_array($key, ['width', 'height'], true) ? ' (logical px)' : ''), 'type' => InputControlType::INTEGER]; + } + $fields[] = ['field' => $shared . 'anchor', 'label' => 'Anchor', 'options' => array_column(PresentationSpriteAnchor::cases(), 'value')]; + if (! $sheet) { + foreach (['x', 'y', 'width', 'height'] as $key) { + $fields[] = ['field' => $direction . '.sourceRect.' . $key, 'label' => 'Optional crop ' . $key . ' (px)', 'type' => InputControlType::INTEGER]; + } + } + return array_map(fn(array $field): array => $field + ['value' => $this->record->getDisplayValue($field['field'])], $fields); + } + + public function setField(string $direction, string $key, string $value): void + { + foreach ($this->getFields($direction) as $field) { + if ($field['field'] !== $key) { continue; } + if (isset($field['options']) && ! in_array($value, $field['options'], true)) { + throw new RuntimeException('Choose a supported anchor.'); + } + $parsed = $value === '' ? null : $value; + if (($field['type'] ?? null) === InputControlType::INTEGER && $value !== '') { + $parsed = filter_var($value, FILTER_VALIDATE_INT); + if ($parsed === false) { throw new RuntimeException('Enter a supported whole number.'); } + } + $this->record->set($key, $parsed); + return; + } + throw new RuntimeException('The selected sprite field no longer exists.'); + } + + public function validate(?string $assetRoot = null): DirectionalGraphicalSpriteSet + { + $set = DirectionalGraphicalSpriteSet::fromArray($this->getData()); + foreach (ProjectNpc::DIRECTIONS as $direction) { + $definition = $set->$direction; + if ($definition->layer < PresentationLayerPolicy::WORLD || $definition->layer >= PresentationLayerPolicy::UI) { + throw new RuntimeException('Automatic Game world sprites require layers 0..999; UI layers are reserved.'); + } + if ($assetRoot !== null) { + $sheet = $definition->sheet; + $crop = $sheet === null ? $definition->sourceRect : new SpriteSourceRect(0, 0, + $sheet->frameWidth * $sheet->columns, $sheet->frameHeight * $sheet->rows); + PngAssetPreflight::inspect($assetRoot, $definition->asset, $crop); + } + } + return $set; + } +} diff --git a/src/Field/NpcSpriteArt.php b/src/Field/NpcSpriteArt.php new file mode 100644 index 0000000..9e61347 --- /dev/null +++ b/src/Field/NpcSpriteArt.php @@ -0,0 +1,53 @@ +getNpcs()->get($index); + if ($npc === null) { throw new MapSourceRefusal('Select an existing NPC before editing artwork.'); } + $document = PhpArraySourceDocument::parse($this->proposedDataSource()); + $node = $document->root(); + foreach (['npcs', $index, 'sprites2d'] as $key) { + $this->assertNpcArtLiteral($node); + $node = $node->entryFor($key)?->value; + if ($node === null) { break; } + } + if ($node !== null) { $this->assertNpcArtLiteral($node, true); } + if ($data !== null) { + $set = (new DirectionalSpriteDraft($data))->validate(dirname($this->getMapsRoot())); + $assets = ReferenceCatalog::getPngAssets(dirname($this->getMapsRoot(), 2)); + foreach (ProjectNpc::DIRECTIONS as $direction) { + $definition = $set->$direction; + if (! in_array($definition->asset, $assets, true)) { + throw new MapSourceRefusal('Choose each directional PNG through the project asset picker.'); + } + } + } + $entries = $this->getNpcs()->toMapData(); + $entries[$index] = $npc->with('sprites2d', $data)->toArray(); + $this->setNpcs(NpcCollection::fromMapData($entries)); + } + + private function assertNpcArtLiteral(SourceNode $node, bool $recursive = false): void + { + $keys = array_map(static fn($entry, $index) => $entry->key ?? $index, $node->entries, array_keys($node->entries)); + if (in_array($node->kind, [SourceNode::VARIABLE, SourceNode::EXPRESSION], true) || $node->hasOpaqueKey + || count(array_unique($keys, SORT_REGULAR)) !== count($keys)) { + throw new MapSourceRefusal('NPC sprites2d requires literal, unambiguous source. Opaque expressions and variables are preserved, not rewritten.'); + } + if ($recursive) { + foreach ($node->entries as $entry) { $this->assertNpcArtLiteral($entry->value, true); } + } + } +} diff --git a/src/Field/NpcSpriteCanvas.php b/src/Field/NpcSpriteCanvas.php new file mode 100644 index 0000000..056ad2e --- /dev/null +++ b/src/Field/NpcSpriteCanvas.php @@ -0,0 +1,178 @@ +getSelectedMap(); + $index = $this->selectedNpcIndex; + $npc = $index === null ? null : $map?->getNpcs()->get($index); + if ($npc === null || $this->referencePicker->isOpen()) { return; } + $data = $npc->toArray()['sprites2d'] ?? null; + if ($data !== null && ! is_array($data)) { + $this->setStatus('NPC sprites2d is not an array. Repair its source before authoring art.', StatusLevel::WARN); + return; + } + $this->npcArtDialog = ['map' => $map, 'index' => $index, 'initial' => $npc->toArray(), + 'draft' => new DirectionalSpriteDraft($data), 'direction' => 'south', 'selected' => 0, + 'confirmation' => false, 'error' => '']; + $this->npcArtFieldEditor = new TextFieldEditor(); + $this->modals->push(Modal::NPC_ART); + $this->requestFullRender(); + } + + private function closeNpcSpriteArt(): void + { + $this->referencePicker->close(); + $this->npcArtDialog = null; + $this->npcArtFieldEditor?->close(); + $this->modals->remove(Modal::NPC_ART); + $this->requestFullRender(); + } + + private function getNpcArtFields(): array + { + $dialog = $this->npcArtDialog; + return [ + ['field' => 'mode', 'label' => 'Representation', 'value' => $dialog['draft']->getMode(), 'options' => ['poses', 'sheet']], + ['field' => 'direction', 'label' => 'Direction / role', 'value' => $dialog['direction'], 'options' => ProjectNpc::DIRECTIONS], + ...$dialog['draft']->getFields($dialog['direction']), + ['field' => 'apply', 'label' => 'Apply all four directions', 'value' => ''], + ['field' => 'remove', 'label' => 'Remove graphical sprites', 'value' => ''], + ]; + } + + private function handleNpcArtInput(string $input): void + { + if ($this->npcArtDialog === null) { return; } + try { + $fields = $this->getNpcArtFields(); + $field = $fields[$this->npcArtDialog['selected']]; + if ($this->referencePicker->isOpen()) { + if ($input === "\033") { $this->referencePicker->close(); } + elseif ($input === "\r" || $input === "\n") { + if (($asset = $this->referencePicker->selected()) !== null) { + $this->npcArtDialog['draft']->setField($this->npcArtDialog['direction'], $field['field'], $asset); + $this->referencePicker->close(); + } + } elseif ($input === "\033[A") { $this->referencePicker->move(-1); } + elseif ($input === "\033[B") { $this->referencePicker->move(1); } + elseif ($input === "\177" || $input === "\010") { $this->referencePicker->backspace(); } + elseif (mb_strlen($input) === 1 && ! ctype_cntrl($input)) { $this->referencePicker->type($input); } + } elseif ($this->npcArtFieldEditor?->isActive) { + $result = $this->npcArtFieldEditor->handleKey($input, new InputControl(InputControlType::INTEGER)); + if ($result === TextFieldKeyResult::CANCELLED) { $this->npcArtFieldEditor->close(); } + elseif ($result === TextFieldKeyResult::SUBMITTED) { + $this->npcArtDialog['draft']->setField($this->npcArtDialog['direction'], $field['field'], $this->npcArtFieldEditor->value); + $this->npcArtFieldEditor->close(); + } + } elseif ($this->npcArtDialog['confirmation']) { + if ($input === "\033") { $this->npcArtDialog['confirmation'] = false; } + elseif ($input === 'y') { $this->commitNpcSpriteArt(true); } + } elseif ($input === "\033") { $this->closeNpcSpriteArt(); } + elseif ($input === "\033[A" || $input === "\033[Z") { + $this->npcArtDialog['selected'] = ($this->npcArtDialog['selected'] + count($fields) - 1) % count($fields); + } elseif ($input === "\033[B" || $input === "\t") { + $this->npcArtDialog['selected'] = ($this->npcArtDialog['selected'] + 1) % count($fields); + } elseif ($input === 's') { $this->commitNpcSpriteArt(); } + elseif ($input === 'r') { $this->npcArtDialog['confirmation'] = true; } + elseif (in_array($input, ["\r", "\n", "\033[C", "\033[D"], true)) { + if (isset($field['options'])) { + $options = $field['options']; + $index = array_search($field['value'], $options, true); + $value = $options[((int) $index + ($input === "\033[D" ? count($options) - 1 : 1)) % count($options)]; + if ($field['field'] === 'direction') { $this->npcArtDialog['direction'] = $value; } + elseif ($field['field'] === 'mode') { $this->npcArtDialog['draft']->setMode($value); } + else { $this->npcArtDialog['draft']->setField($this->npcArtDialog['direction'], $field['field'], $value); } + } elseif (isset($field['reference'])) { + if (! $this->referencePicker->open('npc_art_asset', $field['label'], 'png_assets', + $this->referenceCatalog()->valuesFor('png_assets'), $field['value'])) { + throw new MapSourceRefusal('No PNG assets found inside this project.'); + } + } elseif ($field['field'] === 'apply') { $this->commitNpcSpriteArt(); } + elseif ($field['field'] === 'remove') { $this->npcArtDialog['confirmation'] = true; } + else { $this->npcArtFieldEditor->open($field['value']); } + } + } catch (\Throwable $error) { + $this->npcArtDialog['error'] = $error->getMessage(); + } + $this->requestFullRender(); + } + + private function commitNpcSpriteArt(bool $remove = false): void + { + $dialog = $this->npcArtDialog; + $map = $dialog['map']; + if ($map !== $this->getSelectedMap() || $map->getNpcs()->get($dialog['index'])?->toArray() !== $dialog['initial']) { + throw new MapSourceRefusal('The captured map or NPC changed. Cancel and reopen artwork before applying.'); + } + $before = $map->captureLayerSnapshot(); + $map->setNpcGraphicalSprites($dialog['index'], $remove ? null : $dialog['draft']->getData()); + $after = $map->captureLayerSnapshot(); + if ($before !== $after) { + $this->recordCommand(new GenericCommand($remove ? 'Remove NPC graphical sprites' : 'NPC graphical sprites', + function () use ($map, $after, $dialog): void { $map->restoreLayerSnapshot($after); $this->selectNpc($dialog['index']); }, + function () use ($map, $before, $dialog): void { $map->restoreLayerSnapshot($before); $this->selectNpc($dialog['index']); })); + } + $this->refreshNpcInspector(); + $this->closeNpcSpriteArt(); + $this->setStatus('NPC artwork staged. Ctrl+S saves; Ctrl+Z undoes. Terminal sprite and interaction stay unchanged.', StatusLevel::SUCCESS); + } + + private function renderNpcArtDialog(array $layout): void + { + $dialog = $this->npcArtDialog; + $width = min(82, $layout['width'] - 4); + $inner = $this->getWindowContentWidth($width); + $lines = [sprintf('%s / %s / %s', $dialog['map']->mapId, $dialog['initial']['id'] ?? $dialog['initial']['name'], $dialog['direction'])]; + $help = 'Arrows:Field Enter:Edit/Choose S:Apply R:Remove Esc:Cancel'; + if ($this->referencePicker->isOpen()) { + $lines[] = 'Choose ' . $dialog['direction'] . ' PNG: ' . $this->referencePicker->filter(); + $choices = array_map(fn(string $path): string => ($path === $this->referencePicker->selected() ? '> ' : ' ') . $path, $this->referencePicker->matches()); + $lines = [...$lines, ...ScrollWindow::slice($choices, $this->referencePicker->selectedIndex(), 10)]; + if ($choices === []) { $lines[] = 'No matching PNG assets.'; } + $help = 'Type:Filter Arrows:Choose Enter:Select Esc:Back'; + } elseif ($dialog['confirmation']) { + $lines[] = 'Remove this NPC\'s complete sprites2d set?'; + $lines[] = 'Terminal glyphs, dialogue, identity and collision stay unchanged.'; + $help = 'Y:Confirm removal Esc:Back'; + } else { + $rows = []; + foreach ($this->getNpcArtFields() as $index => $field) { + $button = in_array($field['field'], ['apply', 'remove'], true); + $selected = $dialog['selected'] === $index; + $value = $selected && $this->npcArtFieldEditor?->isActive ? $this->npcArtFieldEditor->value . '|' : ($field['value'] === '' ? '(not set)' : $field['value']); + $line = $button ? str_pad($field['label'], $inner, ' ', STR_PAD_BOTH) : ($selected ? '> ' : ' ') . $field['label'] . ': ' . $value; + $rows[] = $button && $selected ? "\033[7m" . $line . "\033[0m" : $line; + } + $lines = [...$lines, ...ScrollWindow::slice($rows, $dialog['selected'], max(3, $layout['height'] - 12))]; + $lines[] = 'All four directions need PNG + logical width/height.'; + $lines[] = $dialog['draft']->getMode() === 'sheet' ? 'Size/timing/anchor are shared; asset/grid/frame count belong to each role.' : 'Crop is optional: leave all four crop fields blank to use the whole PNG.'; + } + if ($dialog['error'] !== '') { $lines = [...$lines, ...explode("\n", wordwrap($dialog['error'], $inner, "\n", true))]; } + $height = min(count($lines) + 2, $layout['height'] - 4); + new EditorWindow(title: 'NPC graphical sprites', help: $help, + position: ['x' => max(2, intdiv($layout['width'] - $width, 2)), 'y' => 3], width: $width, + height: $height, content: $this->fitLines($lines, $inner, $height - 2))->render(); + } +} diff --git a/src/Field/ProjectNpc.php b/src/Field/ProjectNpc.php index 5b10156..47dca5b 100644 --- a/src/Field/ProjectNpc.php +++ b/src/Field/ProjectNpc.php @@ -30,7 +30,7 @@ final class ProjectNpc */ public const array KNOWN_FIELDS = [ 'id', 'name', 'sprite', 'x', 'y', 'movement', 'wanderArea', 'sprites', - 'conditions', 'dialogue', 'script', 'sets', + 'conditions', 'dialogue', 'script', 'sets', 'sprites2d', ]; public const array MOVEMENTS = ['fixed', 'wander']; diff --git a/src/ProjectConfig.php b/src/ProjectConfig.php new file mode 100644 index 0000000..8128cf1 --- /dev/null +++ b/src/ProjectConfig.php @@ -0,0 +1,202 @@ + */ + private array $records = []; + private array $originalValues = []; + private array $fieldIssues = []; + private string $persistedSource = ''; + public readonly string $path; + + public function __construct(private readonly string $projectRoot) + { + $this->path = rtrim($projectRoot, '/') . '/config.php'; + try { + if (! is_file($this->path)) { + throw new RuntimeException('config.php does not exist in this project.'); + } + $this->persistedSource = (string) file_get_contents($this->path); + $file = PhpDataFile::load($this->path, $projectRoot); + $this->payload = is_array($file->payload) ? $file->payload : []; + $this->document = PhpArraySourceDocument::parse($this->persistedSource); + if (! is_array($file->payload)) { + throw new RuntimeException($file->readOnlyReason ?? 'config.php must return an array.'); + } + } catch (Throwable $error) { + $this->issue = $error->getMessage(); + } + } + + /** Records are shared identities: the ordinary record undo path edits this owner too. */ + public function getRecord(string $path): ProjectRecord + { + if (! isset($this->records[$path])) { + $value = self::getValue($this->payload, $path); + $this->originalValues[$path] = $value; + $this->records[$path] = new ProjectRecord(['path' => $path, 'value' => $value]); + } + return $this->records[$path]; + } + + /** @return list */ + public function getTermRecords(array $roots): array + { + $records = []; + $visit = function (array $tree, string $prefix, bool $ambiguous = false) use (&$visit, &$records): void { + foreach ($tree as $key => $value) { + $path = $prefix . '.' . $key; + $hasDottedKey = $ambiguous || str_contains((string) $key, '.'); + if (is_array($value)) { $visit($value, $path, $hasDottedKey); } + elseif (! is_object($value)) { + if ($hasDottedKey) { + $this->fieldIssues[$path] = $path . ' contains a dotted literal key, ambiguous with a nested path; it is preserved read-only.'; + $records[] = new ProjectRecord(['path' => $path, 'value' => $value]); + } else { $records[] = $this->getRecord($path); } + } + } + }; + foreach ($roots as $root) { + if (is_array($this->payload[$root] ?? null)) { $visit($this->payload[$root], $root); } + } + return $records; + } + + public function getReadOnlyReason(): ?string + { + return $this->issue; + } + + public function getFieldIssue(string $path): ?string + { + if (isset($this->fieldIssues[$path])) { return $this->fieldIssues[$path]; } + try { + $this->createFieldEdit($this->document, $path, '1'); + return null; + } catch (Throwable $error) { + return $error->getMessage(); + } + } + + public function getFieldZoom(): mixed + { + $value = $this->getRecord(self::FIELD_ZOOM)->get('value'); + return $value === null && $this->document?->nodeAt(explode('.', self::FIELD_ZOOM)) === null + ? FieldViewport::DEFAULT_ZOOM : $value; + } + + public function setFieldZoom(string $raw): void + { + $value = filter_var($raw, FILTER_VALIDATE_FLOAT); + if ($value === false || ! is_finite($value) || $value < FieldViewport::MIN_ZOOM || $value > FieldViewport::MAX_ZOOM) { + throw new RuntimeException(sprintf('Field zoom must be a finite number from %g to %g.', FieldViewport::MIN_ZOOM, FieldViewport::MAX_ZOOM)); + } + if (($issue = $this->getFieldIssue(self::FIELD_ZOOM)) !== null) { throw new SourcePreservationRefusal($issue); } + $current = $this->getFieldZoom(); + if ((is_int($current) || is_float($current)) && $value == $current) { return; } + $this->getRecord(self::FIELD_ZOOM)->set('value', $value); + } + + public function isDirty(): bool + { + foreach ($this->records as $record) { + if ($record->isDirty()) { return true; } + } + return false; + } + + /** Rebuild only the changed leaf spans over the original bytes, including after save/undo. */ + public function getSource(): string + { + $document = $this->document; + foreach ($this->records as $path => $record) { + if ($record->get('value') === $this->originalValues[$path]) { continue; } + $edit = $this->createFieldEdit($document, $path, PhpValueExporter::export($record->get('value'))); + $document = $document->withEdits([$edit]); + } + return $document?->source ?? $this->persistedSource; + } + + /** Saves all pending configuration leaves together, never a second whole-value rewrite. */ + public function save(?FileSetOperations $files = null): void + { + if (! $this->isDirty()) { return; } + $source = $this->getSource(); + $transaction = new FileSetTransaction($this->projectRoot, $files ?? new FilesystemFileSetOperations()); + $transaction->write($this->path, $source); + try { + $staged = $transaction->stage(); + if ((string) file_get_contents($this->path) !== $this->persistedSource) { + throw new SourcePreservationRefusal('config.php changed on disk. Reload before saving; pending edits were not written.'); + } + $evaluated = PhpDataFile::evaluateIsolated($staged[$this->path], $this->projectRoot); + foreach ($this->records as $path => $record) { + if ($record->get('value') === $this->originalValues[$path]) { continue; } + if (self::getValue(is_array($evaluated) ? $evaluated : [], $path) !== $record->get('value')) { + throw new SourcePreservationRefusal('The staged config.php does not preserve ' . $path . '.'); + } + } + $transaction->commit(); + } catch (Throwable $error) { + $transaction->rollBack(); + throw $error; + } + $this->persistedSource = $source; + foreach ($this->records as $record) { $record->markClean(); } + } + + private function createFieldEdit(?PhpArraySourceDocument $document, string $path, string $literal): array + { + if ($this->issue !== null || $document === null) { throw new SourcePreservationRefusal($this->issue ?? 'config.php cannot be preserved.'); } + $segments = explode('.', $path); + $walked = []; + $node = $document->root(); + foreach ($segments as $index => $key) { + if ($node->kind !== SourceNode::ARRAY || $node->hasOpaqueKey || count(array_unique(array_column($node->entries, 'key'), SORT_REGULAR)) !== count($node->entries)) { + throw new SourcePreservationRefusal($path . ' needs literal, uniquely keyed parent arrays; opaque configuration is preserved read-only.'); + } + $entry = $node->entryFor($key); + if ($entry === null) { + foreach (array_reverse(array_slice($segments, $index + 1)) as $child) { + $literal = '[' . var_export($child, true) . ' => ' . $literal . ']'; + } + return $document->insertEntryEdit($walked, count($node->entries), $key, $literal); + } + $walked[] = $key; + $node = $entry->value; + } + if ($node->kind !== SourceNode::SCALAR) { + throw new SourcePreservationRefusal($path . ' is an authored expression, not an editable literal.'); + } + return $document->replaceValueEdit($walked, $literal); + } + + private static function getValue(array $payload, string $path): mixed + { + $value = $payload; + foreach (explode('.', $path) as $key) { $value = is_array($value) ? ($value[$key] ?? null) : null; } + return $value; + } +} diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 0c5e7e4..0eee0db 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -34,6 +34,7 @@ */ final class ProjectMap { + use \Ichiloto\Editor\Field\NpcSpriteArt; use TracksPersistedState; use \Ichiloto\Editor\Maps\TileArt; diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index fb89e35..f71e94e 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -45,6 +45,7 @@ public function __construct( public ProjectQuestDatabase $questDatabase, public array $recordDatabases = [], public ?CutsceneLibrary $cutscenes = null, + public ?ProjectConfig $config = null, ) { } @@ -111,6 +112,7 @@ private static function loadFromProject(string $projectRoot): self // item store; without this the category would report a bootstrap // failure instead of the author's enemies. EngineDataBootstrap::ensure($projectRoot); + $projectConfig = new ProjectConfig($projectRoot); return new self( projectRoot: $projectRoot, @@ -126,10 +128,11 @@ classDatabase: ProjectClassDatabase::fromProject($projectRoot), systemDatabase: ProjectSystemDatabase::fromProject($projectRoot), questDatabase: ProjectQuestDatabase::fromProject($projectRoot), recordDatabases: array_map( - static fn(RecordSchema $schema): ProjectRecordDatabase => ProjectRecordDatabase::fromProject($projectRoot, $schema), + static fn(RecordSchema $schema): ProjectRecordDatabase => ProjectRecordDatabase::fromProject($projectRoot, $schema, $projectConfig), RecordSchemaCatalog::all(), ), cutscenes: CutsceneLibrary::fromProject($projectRoot), + config: $projectConfig, ); } @@ -235,6 +238,7 @@ public function hasUnsavedChanges(): bool || $this->animationDatabase->isDirty() || $this->systemDatabase->isDirty() || $this->questDatabase->isDirty() + || ($this->config?->isDirty() ?? false) || ($this->cutscenes?->hasUnsavedChanges() ?? false); } @@ -270,6 +274,7 @@ classDatabase: $this->classDatabase, questDatabase: $this->questDatabase, recordDatabases: $this->recordDatabases, cutscenes: $this->cutscenes, + config: $this->config, ); } diff --git a/src/UI/Modal.php b/src/UI/Modal.php index 5433cbc..4df78ca 100644 --- a/src/UI/Modal.php +++ b/src/UI/Modal.php @@ -34,6 +34,7 @@ enum Modal: string */ case RENAME_CONFIRMATION = 'rename_confirmation'; case TILE_ART = 'tile_art'; + case NPC_ART = 'npc_art'; case LAYER_EDIT = 'layer_edit'; /** * The Ctrl+P command palette. Opens above the Database screen and the diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 0bab66d..f27121d 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -1926,6 +1926,16 @@ protected function checkNpcs(ProjectMap $map, ProjectWorkspace $workspace): arra $name = is_scalar($entry['name'] ?? null) ? trim(strval($entry['name'])) : ''; $where = sprintf('%s NPC %s', $map->mapId, $name !== '' ? $name : sprintf('entry %s', $ordinal)); + if (array_key_exists('sprites2d', $entry)) { + try { + if (! is_array($entry['sprites2d'])) { throw new \RuntimeException('sprites2d must be a complete directional definition array.'); } + (new \Ichiloto\Editor\Field\DirectionalSpriteDraft($entry['sprites2d']))->validate($workspace->projectRoot . '/assets'); + } catch (\Throwable $error) { + $issues[] = Issue::warning($where, 'Invalid sprites2d: ' . $error->getMessage(), + 'The game keeps terminal appearance and interaction. Choose Graphical Sprites in the NPC Inspector to repair literal art, or remove the optional set.'); + } + } + if ($name === '') { $issues[] = Issue::error( $where, diff --git a/tests/Unit/NpcGraphicalAuthoringTest.php b/tests/Unit/NpcGraphicalAuthoringTest.php new file mode 100644 index 0000000..139c73e --- /dev/null +++ b/tests/Unit/NpcGraphicalAuthoringTest.php @@ -0,0 +1,210 @@ + pack('N', strlen($bytes)) . $kind . $bytes . pack('N', crc32($kind . $bytes)); + $png = "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNC5', 64, 64, 8, 6, 0, 0, 0)) + . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xff\xff\xff\xff", 64), 64))) . $chunk('IEND', ''); + foreach (ProjectNpc::DIRECTIONS as $direction) { file_put_contents($root . '/assets/Graphics/Npcs/' . $direction . '.png', $png); } + file_put_contents($root . '/assets/Maps/test-map/test-map.data.php', " 'Test Map', 'events' => [], 'npcs' => [\n /* resident */ ['id' => 'resident', 'name' => 'Resident', 'sprite' => '', 'x' => 1, 'y' => 1, 'dialogue' => [['text' => 'Hello']]],\n /* neighbor */ ['id' => 'neighbor', 'name' => 'Neighbor', 'x' => 2, 'y' => 1],\n], 'custom' => strtoupper('unchanged')];\n"); + return $root; +} + +function getNpcArtPoses(): array +{ + $data = []; + foreach (ProjectNpc::DIRECTIONS as $direction) { + $data[$direction] = ['asset' => 'Graphics/Npcs/' . $direction . '.png', 'width' => 16, 'height' => 24]; + } + return $data; +} + +function createNpcArtEditor(string $root): array +{ + $editor = createEditorForTesting($root); + $workspace = ProjectWorkspace::fromProject($root); + setEditorProperty($editor, 'workspace', $workspace); + setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]); + setEditorProperty($editor, 'isRunning', true); + callEditorMethod($editor, 'setEditingMode', 'npc'); + callEditorMethod($editor, 'selectNpc', 0); + setEditorProperty($editor, 'focusedPane', 'inspector'); + foreach (callEditorMethod($editor, 'getInspectorFields') as $index => $field) { + if (($field['field'] ?? '') === '__npc_art') { setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); } + } + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'modals')->has(Modal::NPC_ART))->toBeTrue(); + return [$editor, $workspace->getMapByIndex(0)]; +} + +function selectNpcArtField(Editor $editor, string $key): void +{ + $fields = callEditorMethod($editor, 'getNpcArtFields'); + $target = array_search($key, array_column($fields, 'field'), true); + expect($target)->not->toBeFalse(); + for ($i = 0; $i < count($fields) && getEditorProperty($editor, 'npcArtDialog')['selected'] !== $target; $i++) { + callEditorMethod($editor, 'dispatchInput', "\t"); + } +} + +it('authors four NPC roles through the inspector picker numeric fields apply save and source-exact undo', function () { + $root = createNpcArtProject(); + [$editor, $map] = createNpcArtEditor($root); + $original = file_get_contents($map->dataPath); + $terminal = $map->renderPreview(20, 10); + callEditorMethod($editor, 'dispatchInput', 's'); + expect(getEditorProperty($editor, 'npcArtDialog')['error'])->toContain('direction') + ->and($map->isDirty())->toBeFalse(); + foreach (ProjectNpc::DIRECTIONS as $direction) { + selectNpcArtField($editor, 'direction'); + while (getEditorProperty($editor, 'npcArtDialog')['direction'] !== $direction) { callEditorMethod($editor, 'dispatchInput', "\r"); } + selectNpcArtField($editor, $direction . '.asset'); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'referencePicker')->isOpen())->toBeTrue(); + foreach (str_split($direction . '.png') as $key) { callEditorMethod($editor, 'dispatchInput', $key); } + callEditorMethod($editor, 'dispatchInput', "\r"); + foreach (['width' => '16', 'height' => '24'] as $key => $value) { + selectNpcArtField($editor, $direction . '.' . $key); + callEditorMethod($editor, 'dispatchInput', "\r"); + foreach (str_split($value) as $symbol) { callEditorMethod($editor, 'dispatchInput', $symbol); } + callEditorMethod($editor, 'dispatchInput', "\r"); + } + } + expect($map->isDirty())->toBeFalse()->and(file_get_contents($map->dataPath))->toBe($original); + callEditorMethod($editor, 'dispatchInput', 's'); + expect(getEditorProperty($editor, 'npcArtDialog'))->toBeNull() + ->and($map->getNpcs()->get(0)->toArray()['sprites2d'])->toEqual(getNpcArtPoses()) + ->and($map->getNpcs()->get(0)->getSprite())->toBe('') + ->and($map->renderPreview(20, 10))->toBe($terminal); + callEditorMethod($editor, 'dispatchInput', "\x13"); + $saved = file_get_contents($map->dataPath); + expect($saved)->toContain('/* resident */', '/* neighbor */', "strtoupper('unchanged')"); + callEditorMethod($editor, 'selectNpc', 1); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(file_get_contents($map->dataPath))->toBe($original); + callEditorMethod($editor, 'dispatchInput', "\x19"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(file_get_contents($map->dataPath))->toBe($saved); + $reloaded = ProjectMap::fromDirectory($root . '/assets/Maps', $map->directory); + expect(DirectionalGraphicalSpriteSet::fromArray($reloaded->getNpcs()->get(0)->toArray()['sprites2d'])->south->height)->toBe(24); + $map->duplicateTo($root . '/assets/Maps/art-copy', 'art-copy', 'Art Copy'); + expect((require $root . '/assets/Maps/art-copy/art-copy.data.php')['npcs'][0]['sprites2d'])->toEqual(getNpcArtPoses()); +}); + +it('preserves sheets and crop metadata while editing one role and refuses implicit representation conversion', function () { + $root = createNpcArtProject(); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + $data = ['mode' => 'sheet', 'frameWidth' => 16, 'frameHeight' => 16, 'width' => 16, 'height' => 24, + 'frameDurationMs' => 90, 'stepDurationMs' => 180, 'idleFrame' => 1, 'anchor' => 'bottom_center', 'layer' => 3, + 'directions' => array_map(static fn(array $pose): array => ['asset' => $pose['asset'], 'columns' => 4, 'rows' => 4, 'frames' => 12], getNpcArtPoses())]; + $map->setNpcGraphicalSprites(0, $data); + $map->save(); + $draft = new DirectionalSpriteDraft($data); + expect(fn() => $draft->setMode('poses'))->toThrow(RuntimeException::class, 'never converted'); + $draft->setField('north', 'directions.north.asset', 'Graphics/Npcs/south.png'); + $map->setNpcGraphicalSprites(0, $draft->getData()); + $map->save(); + $expected = $data; + $expected['directions']['north']['asset'] = 'Graphics/Npcs/south.png'; + expect((require $map->dataPath)['npcs'][0]['sprites2d'])->toBe($expected); +}); + +it('refuses incomplete unsafe invalid crop and reserved layer art before changing the map', function (Closure $change) { + $map = ProjectWorkspace::fromProject(createNpcArtProject())->getMapByIndex(0); + $before = $map->captureLayerSnapshot(); + expect(fn() => $map->setNpcGraphicalSprites(0, $change(getNpcArtPoses())))->toThrow(Exception::class) + ->and($map->captureLayerSnapshot())->toBe($before); +})->with([ + fn(array $data) => [], + function (array $data) { unset($data['north']); return $data; }, + function (array $data) { $data['north']['asset'] = '../outside.png'; return $data; }, + function (array $data) { $data['north']['asset'] = 'Graphics/Npcs/missing.png'; return $data; }, + function (array $data) { $data['north']['layer'] = 1000; return $data; }, + function (array $data) { $data['north']['width'] = 0; return $data; }, + function (array $data) { $data['north']['sourceRect'] = ['x' => 63, 'y' => 0, 'width' => 16, 'height' => 16]; return $data; }, +]); + +it('refuses opaque sprites without flattening them', function () { + $root = createNpcArtProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $source = str_replace("'sprite' => ''", "'sprites2d' => array_merge([], " . var_export(getNpcArtPoses(), true) . "), 'sprite' => ''", file_get_contents($path)); + file_put_contents($path, $source); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + expect(fn() => $map->setNpcGraphicalSprites(0, getNpcArtPoses()))->toThrow(RuntimeException::class, 'Opaque') + ->and(file_get_contents($path))->toBe($source); +}); + +it('cancels draft changes and confirms whole set removal with undo and stale NPC protection', function () { + $root = createNpcArtProject(); + [$editor, $map] = createNpcArtEditor($root); + callEditorMethod($editor, 'dispatchInput', "\033"); + $map->setNpcGraphicalSprites(0, getNpcArtPoses()); + $map->save(); + callEditorMethod($editor, 'openNpcSpriteArt'); + callEditorMethod($editor, 'dispatchInput', 'r'); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect($map->getNpcs()->get(0)->toArray())->toHaveKey('sprites2d'); + callEditorMethod($editor, 'dispatchInput', 'y'); + expect($map->getNpcs()->get(0)->toArray())->not->toHaveKey('sprites2d'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->getNpcs()->get(0)->toArray()['sprites2d'])->toBe(getNpcArtPoses()); + callEditorMethod($editor, 'openNpcSpriteArt'); + $entries = $map->getNpcs()->toMapData(); + $entries[0]['name'] = 'New user name'; + $map->setNpcs(NpcCollection::fromMapData($entries)); + callEditorMethod($editor, 'dispatchInput', 's'); + expect(getEditorProperty($editor, 'npcArtDialog')['error'])->toContain('captured map or NPC changed') + ->and($map->getNpcs()->get(0)->getName())->toBe('New user name'); +}); + +it('preserves pending NPC art and all map files when the save transaction fails', function () { + $root = createNpcArtProject(); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + $source = file_get_contents($map->dataPath); + $map->setNpcGraphicalSprites(0, getNpcArtPoses()); + expect(fn() => $map->save(files: new FailingFileSetOperations(failures: ['move' => [$map->dataPath]])))->toThrow(RuntimeException::class) + ->and(file_get_contents($map->dataPath))->toBe($source)->and($map->isDirty())->toBeTrue(); + $map->save(); + expect((require $map->dataPath)['npcs'][0]['sprites2d'])->toBe(getNpcArtPoses()); +}); + +it('warns for malformed optional NPC graphics while accepting omission and retaining terminal-only identity', function (mixed $graphics) { + $root = createNpcArtProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $source = str_replace("'sprite' => ''", "'sprites2d' => " . var_export($graphics, true) . ", 'sprite' => ''", file_get_contents($path)); + file_put_contents($path, $source); + $workspace = ProjectWorkspace::fromProject($root); + $issues = (new \Ichiloto\Editor\Validation\ProjectValidator())->validate($workspace); + $warnings = array_filter($issues, static fn($issue) => str_contains($issue->message, 'Invalid sprites2d:')); + expect($warnings)->toHaveCount(1)->and($workspace->getMapByIndex(0)->getNpcs()->get(0)->getId())->toBe('resident'); +})->with([[null], [[]], ['invalid']]); + +it('patches one literal NPC role asset without rewriting crop comments or other directions', function () { + $root = createNpcArtProject(); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + $poses = getNpcArtPoses(); + $poses['north']['sourceRect'] = ['x' => 16, 'y' => 0, 'width' => 16, 'height' => 32]; + $map->setNpcGraphicalSprites(0, $poses); + $map->save(); + $source = str_replace("'sourceRect' =>", "/* retain crop */ 'sourceRect' =>", file_get_contents($map->dataPath)); + file_put_contents($map->dataPath, $source); + $map = ProjectMap::fromDirectory($root . '/assets/Maps', $map->directory); + $poses['north']['asset'] = 'Graphics/Npcs/south.png'; + $map->setNpcGraphicalSprites(0, $poses); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe(str_replace('Graphics/Npcs/north.png', 'Graphics/Npcs/south.png', $source)); +}); diff --git a/tests/Unit/ProjectConfigTest.php b/tests/Unit/ProjectConfigTest.php new file mode 100644 index 0000000..1f375ae --- /dev/null +++ b/tests/Unit/ProjectConfigTest.php @@ -0,0 +1,154 @@ + [/* term */ 'title' => 'Hello'],\n 'messages' => ['bye' => 'Bye'],\n 'opaque' => new \\stdClass(),\n 'computed' => strtoupper('keep'), // Leave expression.\n" . $graphics . "];\n"); + return $root; +} + +it('shares pending Terms and field zoom in one source-preserving atomic config save', function () { + $root = createZoomProject(); + $original = file_get_contents($root . '/config.php'); + $workspace = ProjectWorkspace::fromProject($root); + $config = $workspace->config; + $terms = $workspace->getRecordDatabase('terms'); + $before = $config->getRecord(ProjectConfig::FIELD_ZOOM)->toArray(); + expect($config->getFieldZoom())->toBe(1.0)->and($terms->isEditable())->toBeTrue(); + $config->setFieldZoom('1'); + expect($workspace->hasUnsavedChanges())->toBeFalse(); + $config->setFieldZoom('2.5'); + $terms->setField(0, 'value', 'Welcome'); + expect(file_get_contents($config->path))->toBe($original); + $terms->save(); + expect($config->isDirty())->toBeFalse()->and($terms->isDirty())->toBeFalse() + ->and(file_get_contents($config->path))->toContain("/* term */ 'title' => 'Welcome'", "'opaque' => new \\stdClass()", "strtoupper('keep')", "'zoom' => 2.5"); + $config->getRecord(ProjectConfig::FIELD_ZOOM)->restorePayload($before); + $terms->setField(0, 'value', 'Hello'); + $config->save(); + expect(file_get_contents($config->path))->toBe($original)->and($terms->isDirty())->toBeFalse(); + $mtime = filemtime($config->path); + $config->save(); + expect(filemtime($config->path))->toBe($mtime); +}); + +it('refuses invalid zoom before mutation instead of clamping', function (string $value) { + $config = new ProjectConfig(createZoomProject()); + expect(fn() => $config->setFieldZoom($value))->toThrow(RuntimeException::class) + ->and($config->isDirty())->toBeFalse()->and($config->getFieldZoom())->toBe(1.0); +})->with(['0', '0.5', '-1', '8.01', 'INF', 'NAN', '1e309', 'no', '']); + +it('repairs explicit null or string zoom rather than mistaking them for an absent numeric default', function (string $literal) { + $config = new ProjectConfig(createZoomProject(" 'graphics' => ['field' => ['zoom' => " . $literal . "]],\n")); + $config->setFieldZoom('1'); + expect($config->isDirty())->toBeTrue(); + $config->save(); + expect((require $config->path)['graphics']['field']['zoom'])->toBe(1.0); +})->with(['null', "'1'"]); + +it('refuses opaque or ambiguous zoom containers without flattening unrelated config', function (string $graphics) { + $config = new ProjectConfig(createZoomProject($graphics)); + $original = file_get_contents($config->path); + expect(fn() => $config->setFieldZoom('3'))->toThrow(RuntimeException::class) + ->and(file_get_contents($config->path))->toBe($original)->and($config->isDirty())->toBeFalse(); +})->with([ + " 'graphics' => array_merge([], ['field' => ['zoom' => 2]]),\n", + " 'graphics' => ['field' => ['zoom' => 1 + 1]],\n", + " 'graphics' => ['field' => ['zoom' => 2, 'zoom' => 2]],\n", + " 'graphics' => [], 'graphics' => [],\n", +]); + +it('refuses concurrent source changes while retaining all pending config edits', function () { + $workspace = ProjectWorkspace::fromProject(createZoomProject()); + $config = $workspace->config; + $workspace->getRecordDatabase('terms')->setField(0, 'value', 'Pending'); + $config->setFieldZoom('8'); + $newSource = str_replace('Keep header.', 'New user header.', file_get_contents($config->path)); + file_put_contents($config->path, $newSource); + expect(fn() => $config->save())->toThrow(RuntimeException::class, 'changed on disk') + ->and(file_get_contents($config->path))->toBe($newSource)->and($config->isDirty())->toBeTrue() + ->and(glob($config->path . '.tmp-*'))->toBe([]); +}); + +it('preserves bytes mtimes and both pending config areas when installation fails', function () { + $workspace = ProjectWorkspace::fromProject(createZoomProject()); + $config = $workspace->config; + touch($config->path, 1000000000); + $source = file_get_contents($config->path); + $config->setFieldZoom('8'); + $terms = $workspace->getRecordDatabase('terms'); + $terms->setField(0, 'value', 'Pending'); + expect(fn() => $config->save(new FailingFileSetOperations(failures: ['move' => [$config->path]])))->toThrow(RuntimeException::class) + ->and(file_get_contents($config->path))->toBe($source)->and(filemtime($config->path))->toBe(1000000000) + ->and($config->isDirty())->toBeTrue()->and($terms->isDirty())->toBeTrue(); + $config->save(); + expect((require $config->path)['graphics']['field']['zoom'])->toBe(8.0)->and($terms->isDirty())->toBeFalse(); +}); + +it('makes only an opaque term read-only and preserves it through zoom and literal term edits', function () { + $root = createZoomProject(); + $path = $root . '/config.php'; + file_put_contents($path, str_replace("'bye' => 'Bye'", "'bye' => strtoupper('Bye')", file_get_contents($path))); + $workspace = ProjectWorkspace::fromProject($root); + $terms = $workspace->getRecordDatabase('terms'); + expect($terms->getSettingsFields(1)[2]['value'])->toContain('authored expression'); + expect(fn() => $terms->setField(1, 'value', 'Hidden rewrite'))->toThrow(RuntimeException::class); + $terms->setField(0, 'value', 'Literal edit'); + $workspace->config->setFieldZoom('2'); + $terms->save(); + expect(file_get_contents($path))->toContain("'bye' => strtoupper('Bye')", "'title' => 'Literal edit'"); +}); + +it('preserves the read-only Terms save refusal for nonliteral returned configuration', function () { + $root = createZoomProject(); + file_put_contents($root . '/config.php', " ['title' => 'Keep']]);\n"); + $terms = loadRecordDatabase($root, 'terms'); + expect($terms->isEditable())->toBeFalse()->and(fn() => $terms->save())->toThrow(RuntimeException::class, 'read-only'); +}); + +it('refuses ambiguous dotted literal term keys without changing their nested neighbors', function () { + $root = createZoomProject(); + file_put_contents($root . '/config.php', " ['a.b' => 'Literal', 'a' => ['b' => 'Nested']]];\n"); + $workspace = ProjectWorkspace::fromProject($root); + $terms = $workspace->getRecordDatabase('terms'); + foreach ([0, 1] as $index) { + expect(fn() => $terms->setField($index, 'value', 'Wrong target'))->toThrow(RuntimeException::class, 'dotted literal key'); + } + $workspace->config->setFieldZoom('2'); + $workspace->config->save(); + expect((require $root . '/config.php')['vocab'])->toBe(['a.b' => 'Literal', 'a' => ['b' => 'Nested']]); +}); + +it('authors field zoom through System numeric input and shares Terms undo save and reload', function () { + $root = createZoomProject(); + $editor = createEditorForTesting($root); + $workspace = ProjectWorkspace::fromProject($root); + setEditorProperty($editor, 'workspace', $workspace); + setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]); + setEditorProperty($editor, 'isRunning', true); + callEditorMethod($editor, 'openDatabaseWindow'); + setEditorProperty($editor, 'databaseCategoryIndex', DatabaseCatalog::indexOf('system')); + setEditorProperty($editor, 'databaseFocus', 'database_settings'); + foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $index => $field) { + if (($field['field'] ?? '') === ProjectConfig::FIELD_ZOOM) { setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); } + } + callEditorMethod($editor, 'dispatchInput', "\r"); + foreach (["\177", '2', '.', '5', "\r"] as $key) { callEditorMethod($editor, 'dispatchInput', $key); } + expect($workspace->config->getFieldZoom())->toBe(2.5); + $workspace->getRecordDatabase('terms')->setField(0, 'value', 'Concurrent term'); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect((require $workspace->config->path)['graphics']['field']['zoom'])->toBe(2.5); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + $saved = require $workspace->config->path; + expect($saved)->not->toHaveKey('graphics')->and($saved['vocab']['title'])->toBe('Concurrent term'); + callEditorMethod($editor, 'dispatchInput', "\x19"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect((new ProjectConfig($root))->getFieldZoom())->toBe(2.5); +}); diff --git a/tests/Unit/RecordDatabaseTest.php b/tests/Unit/RecordDatabaseTest.php index f789daa..81360cf 100644 --- a/tests/Unit/RecordDatabaseTest.php +++ b/tests/Unit/RecordDatabaseTest.php @@ -350,7 +350,7 @@ removeDirectoryRecursively($root); }); -it('turns terms read-only when the config carries inline comments', function (): void { +it('edits literal terms while preserving inline config comments byte for byte', function (): void { $root = makeTemporaryProject(); $path = $root . '/config.php'; $contents = (string) file_get_contents($path); @@ -358,14 +358,14 @@ $database = loadRecordDatabase($root, 'terms'); - expect($database->isEditable())->toBeFalse(); - expect($database->getReadOnlyReason())->toContain('comments inside its data'); - // Rows still render, just without controls. + expect($database->isEditable())->toBeTrue(); + expect($database->getReadOnlyReason())->toBeNull(); expect($database->getEntryLabels())->not->toBeEmpty(); - - foreach ($database->getSettingsFields(0) as $field) { - expect($field)->not->toHaveKey('control'); - } + $before = file_get_contents($path); + $old = $database->getRecordByIndex(0)->get('value'); + $database->setField(0, 'value', 'New title'); + $database->save(); + expect(file_get_contents($path))->toBe(str_replace(var_export($old, true), "'New title'", $before)); removeDirectoryRecursively($root); }); From 7879ab8accea51bc7bf460d85dad1260be26d40f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 15:38:16 +0200 Subject: [PATCH 039/344] refactor(editor)!: remove graphical marker and crop authoring from the terminal editor Graphical authoring belongs to a graphical editor. The terminal editor edits the terminal game: glyphs, colours, gameplay layers, events and NPCs. BREAKING CHANGE: removes the tile-art canvas (decoration markers and selected-cell crop overrides), the NPC graphical-sprite dialog and the terminal-preview toggle from the terminal editor. Existing graphical data in project files is preserved on round trips. --- docs/manual.md | 167 +++++++------------ src/Canvas/LayerCanvas.php | 82 ++++----- src/Canvas/TileArtCanvas.php | 185 --------------------- src/Editor.php | 57 ++----- src/Field/NpcSpriteCanvas.php | 178 -------------------- src/ProjectMap.php | 53 +++++- src/UI/Modal.php | 2 - tests/Unit/CellTileArtTest.php | 141 +++++----------- tests/Unit/LayeredCanvasTest.php | 164 ++++++++++++++---- tests/Unit/LayeredMapTest.php | 134 ++++++++++++++- tests/Unit/MixedMaterialAuthoringTest.php | 158 +++++++++--------- tests/Unit/NpcGraphicalAuthoringTest.php | 120 +++++-------- tests/__snapshots__/main_shell_100x30.ansi | 2 +- 13 files changed, 577 insertions(+), 866 deletions(-) delete mode 100644 src/Canvas/TileArtCanvas.php delete mode 100644 src/Field/NpcSpriteCanvas.php diff --git a/docs/manual.md b/docs/manual.md index 57202c0..d616d53 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -65,7 +65,7 @@ is what the code draws rather than a sketch of it: │ │ │ │ │ Background Music: (None) │ │ │ │ │ │ Music Variants · None │ │ │ │ │ │ Encounters · off │ - └─/:Filter Del:Delete─────────┘ └─i:Paint []:Layer t:Terminal┘ └─Enter:Edit─────────────────────┘ + └─/:Filter Del:Delete─────────┘ └─i:Paint []:Layer ?:Help────┘ └─Enter:Edit─────────────────────┘ ┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐ │ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 │ │ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │ @@ -181,16 +181,22 @@ asks first and defaults to Cancel. ### Authored Layers -Maps with `layers/` show every numbered gameplay (`NN.name.map.php`) and -decoration (`NN.name.deco.php`) layer, followed by Events. In Normal mode, -`[` and `]` cycle layers, `v` toggles the selected layer's visibility, `d` -dims inactive layers, and `t` opens a read-only terminal preview. That preview -always composes all gameplay layers, ignores decoration and event markers, -and ignores authoring visibility settings. Spaces above the base show through. -Layer visibility and dimming are session settings, not map data. - -Use `Ctrl+P` and select a layer by name, or choose a Layers action to create, -rename or remove it. New layers preserve each row's width, including ragged +The TUI edits the terminal experience. Maps with `layers/` show numbered +gameplay (`NN.name.map.php`) layers and Events, with NPC overlays in their +authoring context. Graphical decoration (`NN.name.deco.php`) never appears on +the terminal canvas or in its layer selection, palette or inspector. In Normal +mode, `[` and `]` cycle gameplay and event layers, `v` toggles the selected +layer's visibility, and `d` dims inactive layers. Spaces above the base show +through. Visibility and dimming are session settings, not map data. + +The normal canvas is editable immediately: press `i` and paint terminal glyphs. +The separate read-only terminal-preview toggle has been removed; there is no +graphical-marker view to switch away from. + +Use `Ctrl+P` and select a gameplay or event layer by name, or choose a Layers +action to create, rename or remove a gameplay layer. The TUI cannot create, +select, rename or remove graphical decoration layers. New gameplay layers +preserve each row's width, including ragged maps. Renaming retains its numeric order and changes its local `tiles2d` key without regenerating the crop table. If the actual resolved collisions would change, the rename dialog names the change and requires `y` confirmation; @@ -199,11 +205,17 @@ Creating a layer on a legacy map explicitly moves its terrain into `layers/00.terrain.map.php` in the same save transaction; ordinary legacy saves keep the original layout. -Every layer uses the same Paint/Normal modes, tools, mouse strokes, selection, -colour, clipboard and undo. The inspector lists visibility, inherited per-layer -`tiles2d` mappings, and an editable selected-cell tile-art action. Painting a -mapped glyph, replacing one, or painting at a coordinate with a crop override -shows a warning. Invalid decoration crop coverage is refused at save. +Gameplay and event layers use the same Paint/Normal modes, tools, mouse strokes, +selection, colour, clipboard and undo. The layer inspector lists terminal layer +visibility only. Graphical crop tables, selected-cell Tile art actions, crop +dialogs and crop-painting warnings have been removed from the terminal shell. +Ordinary glyph edits do not rewrite graphical decoration or `tiles2d` data. +An unchanged graphical definition that was already invalid when loaded does +not block terminal glyph, event or unrelated metadata saves, copies or moves. +It is preserved, not repaired or certified valid: explicit graphical validation +still reports it. Changes to graphical definitions, decoration contents or +layer structure/geometry still require graphical validation; canonical source +safety, collision and grid checks remain in force for terminal edits. Untouched layer files are never written; unchanged rows retain their authored bytes, and all changed files are saved or rolled back together. @@ -226,78 +238,33 @@ Normal. While painting, the Status pane shows `[PAINT]` beside the mode. Control-byte and function-key shortcuts (`Ctrl+S`, `F3`, ...) work in both modes, since they are not glyphs. -### Mixed Floor and Wall Materials - -Keep walkable floors as spaces and solid walls as wall glyphs on gameplay -layers. Put wood, kitchen tile, stone, rugs, carpet, and purely cosmetic wall -writing or ornaments on decoration layers. Their markers select graphical -crops; they never change terminal glyphs or collision. A readable notice or -other interactive fixture still needs its own gameplay glyph and interaction, -not just a decoration marker. - -1. Start with decoration layers and their per-layer `tiles2d` mappings already - defined in the map source. For example, a `floors` layer might map `w`, `k` - and `s` to wood, kitchen tile and stone, with `r`/`c` on a higher `rugs` - layer for rugs/red carpet. A `wall-detail` layer can use its own markers - for ornamental writing and trim over solid gameplay walls. -2. Focus the canvas, use `[` / `]` or `Ctrl+P` to choose the layer, and press - `i` to paint its mapped markers. The crop warning is expected. `Esc` - returns to Normal mode before switching layers; in Paint mode brackets - paint characters rather than switching. The same tools and mouse strokes - work on every decoration layer. -3. Use `Ctrl+Z` / `Ctrl+Y` to undo/redo, even after switching layers, then - `Ctrl+S` to save. `Ctrl+R` reloads the saved workspace and clears history. - Press `t` in Normal mode to check terminal preview: floors remain blank, - walls retain their gameplay glyphs, and interaction markers remain visible. - -Alternatively, use selected-cell tile art below to author material crops without -predefined glyph mappings, including crops on blank decoration cells. -**Remaining authoring gap:** glyph-wide defaults remain read-only in the TUI; -adding/changing a reusable glyph mapping still requires the map's `.data.php` -source. There is no visual crop picker or image preview: the canvas shows -authoring glyphs, and the crop form uses pixel coordinates. - -### Selected-Cell Tile Art - -This workflow requires the Engine per-cell `tiles2d.cells` capability, currently -available in the local Engine candidate, not the Editor's older locked -Engine package. Integration tests select that Engine source explicitly; the -dependency lock must be updated separately when publishing is authorized. - -1. In Normal mode, select the gameplay or decoration layer and cell. Choose - `Tile art: Edit selected cell` in `Ctrl+P`, or activate `Tile art: selected - cell` in the inspector. Events and terminal-preview mode cannot own tile art. -2. Press `a` or activate `Layer atlas` to select a PNG already under the project's - `assets/` directory. Type to filter the shared picker, then select with - arrows and `Enter`; typed text is never saved as a resource path. -3. Use arrows/Tab and `Enter` to edit X, Y, width and height in pixels. Origins - start at zero, extents must be positive, and crops must fit the selected PNG. - The form starts from this cell's crop, its inherited glyph crop, or a - 16-by-16 rectangle. Press `s` or activate `Apply override` to stage the edit. -4. Changing the atlas of a layer that already inherits/owns one requires `y` - confirmation: every existing crop on that layer will use the new PNG. The - change writes only that layer's asset override, never the shared atlas or - another layer. The dialog names the old and new assets and this impact. -5. `r` / `Remove override` asks for confirmation before removing only that cell's - crop; its glyph default, if any, becomes effective again. Empty definitions - are pruned. `Esc` cancels a field, picker or confirmation first, then the form. - Nothing changes until Apply or confirmed Remove. `Ctrl+S` saves transactionally; - `Ctrl+Z` / `Ctrl+Y` undo/redo even after changing the selected layer or saving. - -Overrides belong to the captured map, layer and coordinate, not the painted -glyph. They do not change text or walkability. Paint, erase and clipboard tools -leave crop coordinates in place; layer rename/duplicate/move preserve ownership, -and layer removal removes its own table. Shrinking a grid never silently moves -or drops crops: save refuses coordinates outside a row. Undo the resize, remove -the affected crops, then resize again if intended. Reload reads saved crops. - -Literal `.data.php` arrays retain untouched source bytes and comments. Opaque -expressions, variables, unreadable keys or explicitly indexed cell lists that -cannot be safely edited are refused before mutation with a reason; they are -never flattened to evaluated data. The picker and shared Engine preflight check -asset containment, PNG header and crop bounds; native rendering remains the -full PNG decode authority. Decoration coverage may be staged across several -cell edits but must be complete before save. +### Terminal Editing and Pending Graphical GUI + +Keep walkable floors and solid walls readable as terminal gameplay glyphs. +A readable notice or interactive fixture needs its gameplay glyph and +interaction, not a graphical decoration marker. Graphical representations +must not drive or change the terminal editing experience. + +1. Focus the canvas and choose a gameplay or event layer with `[` / `]` or + `Ctrl+P`. Press `i` to paint; `Esc` returns to Normal mode. Brackets and + other printable characters remain paintable while in Paint mode. +2. Use the existing glyph tools, colours, selections, clipboard and mouse + strokes. Continue authoring events and NPCs in their existing modes. +3. Use `Ctrl+Z` / `Ctrl+Y` to undo/redo, including after changing layers. + `Ctrl+S` saves; `Ctrl+R` reloads the saved workspace and clears history. + Existing graphical source data remains preserved during ordinary glyph edits. + +**Removed TUI workflows:** painting decoration markers, choosing graphical +layers, inspecting graphical crop tables and editing selected-cell crops are +no longer terminal authoring features. Their data is not deleted. The TileArt +model services, low-level decoration APIs and source-preserving round trips +remain available for the graphical editor. + +**Pending, not implemented:** richer renderers will use a separate, +RPG Maker-like GUI editor for graphical materials, atlases and crop authoring. +The planned `ichiloto edit` entry point will offer a TUI/GUI choice; that choice +and the GUI itself are not delivered by this TUI boundary correction. See the +[GUI editor plan](../../gui-editor/README.md). ### Normal mode @@ -452,21 +419,15 @@ character says is read in the pane rather than in the edit buffer. - **Directional sprites.** Optional glyphs shown when the NPC turns; the base sprite covers a heading you leave blank. Resting the Inspector cursor on a `Facing …` row previews that glyph on the canvas in the NPC's place. -- **Graphical sprites.** Select an NPC, focus its Inspector, and press `Enter` - on `Graphical Sprites`. Choose a direction/role, then select its PNG through - the shared project asset picker. Set its logical-pixel width and height; - optional crop X/Y/width/height are source-image pixels. Repeat for all four - directions. `S` applies the complete set as one undo step; `Ctrl+S` saves it. - `Esc` cancels without changing the NPC. `R`, then `Y`, removes the optional - `sprites2d` key, not its terminal glyph, identity, dialogue or collision. - For new animated artwork choose `sheet` before entering fields: size, - frame size, timing and anchor are shared, while each direction picks its own - PNG, columns, rows and populated frame count. Existing sheets and pose crops - retain their shape; changing representation never silently discards them. - Apply uses Engine validation and PNG header/bounds preflight; native rendering - remains the decode authority. Opaque source is refused before mutation. - This is a numeric authoring form, not an image preview. Player and cinematic - staged-actor `sprites2d` authoring are still separate TUI gaps. +- **Graphical sprites are not edited in the TUI.** The `Graphical Sprites` + inspector action and its numeric PNG/crop/sheet dialog have been removed. + Existing NPC `sprites2d` data remains preserved when editing terminal glyphs, + and its model services and source-preserving validation remain available. + Graphical NPC authoring belongs in the pending + [GUI editor](../../gui-editor/README.md), not a terminal form. Terminal base + and directional glyphs, identity, dialogue, movement and conditions remain + editable as before. This correction does not claim that all other actor or + database artwork controls have been removed. - **Dialogue.** Pages are shown as variants: one variant with lines is written back as plain pages; add a second variant, or give one a `When` condition, a `Then Set`, or a `Script`, and the whole thing is written as conditional diff --git a/src/Canvas/LayerCanvas.php b/src/Canvas/LayerCanvas.php index c1451a9..fe3a77f 100644 --- a/src/Canvas/LayerCanvas.php +++ b/src/Canvas/LayerCanvas.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\EditorWindow; use Ichiloto\Editor\History\GenericCommand; +use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\UI\Modal; use Ichiloto\Editor\UI\PaletteItem; @@ -17,20 +18,11 @@ trait LayerCanvas private array $canvasLayerStates = []; private ?array $layerPrompt = null; private ?array $facadeBrush = null; - private string $canvasPaintWarning = ''; - private function recordCanvasCropWarning(\Ichiloto\Editor\ProjectMap $map, string $symbol, string $oldSymbol, int $column, int $row): void + private function getTerminalCanvasLayers(): array { - try { - $symbols = $map->getLayerTiles2d($this->getActiveCanvasLayer())['symbols'] ?? []; - $override = $this->getActiveCanvasLayer() !== MapLayers::EVENT - && $map->getCellTileArt($this->getActiveCanvasLayer(), $column, $row)['override'] !== null; - if ($override || array_key_exists($symbol, $symbols) || array_key_exists($oldSymbol, $symbols)) { - $this->canvasPaintWarning = 'Crop mapping on this layer: glyphs can affect artwork; cell overrides stay at their coordinates. Tile art edits cell crops; symbol defaults are read-only.'; - } - } catch (\Throwable $error) { - $this->canvasPaintWarning = $error->getMessage(); - } + return array_values(array_filter($this->getSelectedMap()?->getLayers() ?? [], + static fn(array $layer): bool => ! $layer['decoration'])); } private function getCanvasLayerState(): array @@ -38,7 +30,7 @@ private function getCanvasLayerState(): array $map = $this->getSelectedMap(); $state = $this->canvasLayerStates[$map?->mapId ?? ''] ?? []; return $state + ['selected' => $map?->getBaseLayerId() ?? MapLayers::BASE, - 'visibility' => [], 'dim' => false, 'terminal' => false]; + 'visibility' => [], 'dim' => false]; } private function getCanvasLayerTitle(): string @@ -47,7 +39,7 @@ private function getCanvasLayerTitle(): string if ($map === null || $map->isLegacyMap() || $this->editingMode === self::MODE_NPC) { return ''; } - foreach ($map->getLayers() as $layer) { + foreach ($this->getTerminalCanvasLayers() as $layer) { if ($layer['id'] === $this->getActiveCanvasLayer()) { return ' [' . $layer['name'] . ']'; } @@ -63,21 +55,23 @@ private function setCanvasLayerState(string $key, mixed $value): void private function selectCanvasLayer(string $id): void { + if (! in_array($id, array_column($this->getTerminalCanvasLayers(), 'id'), true)) { + $this->setStatus('Graphical layers are preserved separately and are not editable in the terminal canvas.', StatusLevel::WARN); + return; + } $this->finalizeActiveStroke(); $this->activeMousePaintButton = null; $this->lastMousePaintPoint = null; $this->canvasToolAnchor = null; $this->canvasSelection = null; $this->facadeBrush = null; - $this->canvasPaintWarning = ''; $this->setCanvasLayerState('selected', $id); - $this->setCanvasLayerState('terminal', false); $this->setEditingMode($id === MapLayers::EVENT ? self::MODE_EVENT : self::MODE_MAP); } private function cycleCanvasLayer(int $step = 1): void { - $ids = array_column($this->getSelectedMap()?->getLayers() ?? [], 'id'); + $ids = array_column($this->getTerminalCanvasLayers(), 'id'); if ($ids === []) { return; } @@ -88,6 +82,9 @@ private function cycleCanvasLayer(int $step = 1): void private function toggleCanvasLayerVisibility(?string $id = null): void { $id ??= $this->getActiveCanvasLayer(); + if (! in_array($id, array_column($this->getTerminalCanvasLayers(), 'id'), true)) { + return; + } $visibility = $this->getCanvasLayerState()['visibility']; $visibility[$id] = ! ($visibility[$id] ?? true); $this->setCanvasLayerState('visibility', $visibility); @@ -95,6 +92,9 @@ private function toggleCanvasLayerVisibility(?string $id = null): void private function toggleCanvasLayerOption(string $option): void { + if ($option !== 'dim') { + return; + } $this->finalizeActiveStroke(); $this->inputMode = self::INPUT_NORMAL; $this->setCanvasLayerState($option, ! $this->getCanvasLayerState()[$option]); @@ -107,17 +107,14 @@ private function buildLayerPaletteItems(): array return []; } $items = [ - new PaletteItem('Tile art: Edit selected cell', '', fn() => $this->openCellTileArt()), new PaletteItem('Layers: Create gameplay layer', '', fn() => $this->openLayerPrompt('create')), - new PaletteItem('Layers: Create decoration layer', '', fn() => $this->openLayerPrompt('decoration')), new PaletteItem('Layers: Rename selected layer', '', fn() => $this->openLayerPrompt('rename')), new PaletteItem('Layers: Remove selected layer', '', fn() => $this->openLayerPrompt('remove')), - new PaletteItem('Layers: Terminal preview', 't', fn() => $this->toggleCanvasLayerOption('terminal')), new PaletteItem('Layers: Dim inactive layers', 'd', fn() => $this->toggleCanvasLayerOption('dim')), ]; - foreach ($map->getLayers() as $layer) { + foreach ($this->getTerminalCanvasLayers() as $layer) { $label = sprintf('%s %s (%s)', $layer['order'] === null ? '--' : sprintf('%02d', $layer['order']), - $layer['name'], $layer['id'] === MapLayers::EVENT ? 'events' : ($layer['decoration'] ? 'decoration' : 'gameplay')); + $layer['name'], $layer['id'] === MapLayers::EVENT ? 'events' : 'gameplay'); $items[] = new PaletteItem('Layer: ' . $label, '', fn() => $this->selectCanvasLayer($layer['id'])); $items[] = new PaletteItem('Visibility: ' . $label, '', fn() => $this->toggleCanvasLayerVisibility($layer['id'])); } @@ -137,12 +134,12 @@ private function buildLayerPaletteItems(): array private function openLayerPrompt(string $action): void { $map = $this->getSelectedMap(); - if ($map === null) { + if ($map === null || ! in_array($action, ['create', 'rename', 'remove'], true)) { return; } $this->finalizeActiveStroke(); $id = $this->getActiveCanvasLayer(); - $layer = array_values(array_filter($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $id))[0] ?? null; + $layer = array_values(array_filter($this->getTerminalCanvasLayers(), static fn(array $layer): bool => $layer['id'] === $id))[0] ?? null; $this->layerPrompt = ['action' => $action, 'id' => $id, 'name' => $action === 'rename' ? ($layer['name'] ?? '') : '']; $this->modals->push(Modal::LAYER_EDIT); $this->requestFullRender(); @@ -160,11 +157,18 @@ private function handleLayerPromptInput(string $input): void if ($prompt === null) { return; } + if (! in_array($prompt['action'], ['create', 'rename', 'remove'], true)) { + $this->setStatus('Only gameplay layers can be managed in the terminal canvas.', StatusLevel::WARN); + return; + } if (($input === "\r" || $input === "\n") && $prompt['action'] !== 'remove' && ! isset($prompt['confirmation']) || ($input === 'y' && ($prompt['action'] === 'remove' || isset($prompt['confirmation'])))) { $map = $this->getSelectedMap(); - $before = $map->captureLayerSnapshot(); try { + if ($map === null || ! in_array($prompt['id'], array_column($this->getTerminalCanvasLayers(), 'id'), true)) { + throw new MapSourceRefusal('This layer is not available in the terminal canvas. Cancel and select a gameplay layer.'); + } + $before = $map->captureLayerSnapshot(); if ($prompt['action'] === 'rename' && ! isset($prompt['confirmation'])) { $change = $map->getLayerRenameCollisionChange($prompt['id'], $prompt['name']); if ($change !== null) { @@ -174,7 +178,7 @@ private function handleLayerPromptInput(string $input): void } } $id = match ($prompt['action']) { - 'create', 'decoration' => $map->createLayer($prompt['name'], $prompt['action'] === 'decoration'), + 'create' => $map->createLayer($prompt['name']), 'rename' => (function () use ($map, $prompt): string { $map->renameLayer($prompt['id'], $prompt['name'], isset($prompt['confirmation'])); return $prompt['id']; @@ -225,8 +229,8 @@ private function renderLayerPrompt(array $layout): void private function selectFacadeBrush(string $path, int $index): void { - foreach ($this->getSelectedMap()?->getLayers() ?? [] as $layer) { - if ($layer['name'] === 'buildings' && ! $layer['decoration']) { + foreach ($this->getTerminalCanvasLayers() as $layer) { + if ($layer['name'] === 'buildings') { $this->selectCanvasLayer($layer['id']); $this->facadeBrush = ['path' => $path, 'index' => $index, 'mapId' => $this->getSelectedMap()->mapId]; $this->canvasTool = CanvasTool::BRUSH; @@ -245,7 +249,7 @@ private function stampFacadeBrush(): void return; } $map = $this->getSelectedMap(); - $target = array_values(array_filter($map?->getLayers() ?? [], + $target = array_values(array_filter($this->getTerminalCanvasLayers(), fn(array $layer): bool => $layer['id'] === $this->getActiveCanvasLayer()))[0] ?? null; if ($map?->mapId !== $this->facadeBrush['mapId'] || $this->editingMode !== self::MODE_MAP || ($target['name'] ?? null) !== 'buildings' || ($target['decoration'] ?? true)) { @@ -280,28 +284,10 @@ private function getLayerInspectorFields(): array } $id = $this->getActiveCanvasLayer(); $fields = []; - foreach ($map->getLayers() as $layer) { + foreach ($this->getTerminalCanvasLayers() as $layer) { $fields[] = ['label' => ($id === $layer['id'] ? '> ' : ' ') . $layer['name'], 'value' => ($this->getCanvasLayerState()['visibility'][$layer['id']] ?? true) ? 'visible' : 'hidden', 'editable' => false]; } - try { - $tiles = $map->getLayerTiles2d($id); - } catch (\Throwable $error) { - return [...$fields, ['label' => 'tiles2d error', 'value' => $error->getMessage(), 'editable' => false]]; - } - $fields[] = ['label' => 'tiles2d (read-only)', 'value' => (string) ($tiles['asset'] ?? '(no atlas)'), 'editable' => false]; - foreach ($tiles['symbols'] ?? [] as $symbol => $crop) { - $fields[] = ['label' => ' ' . $symbol, 'value' => json_encode($crop, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'editable' => false]; - } - if ($id !== MapLayers::EVENT) { - try { - $art = $map->getCellTileArt($id, $this->cursorX, $this->cursorY); - $fields[] = ['label' => 'Tile art: selected cell', 'value' => $art['override'] === null ? 'Inherited / unmapped' : json_encode($art['override']), - 'editable' => true, 'target' => 'tile-art']; - } catch (\Throwable $error) { - $fields[] = ['label' => 'Cell art', 'value' => $error->getMessage(), 'editable' => false]; - } - } return $fields; } } diff --git a/src/Canvas/TileArtCanvas.php b/src/Canvas/TileArtCanvas.php deleted file mode 100644 index 0b422f4..0000000 --- a/src/Canvas/TileArtCanvas.php +++ /dev/null @@ -1,185 +0,0 @@ - 0, 'y' => 0, 'width' => 16, 'height' => 16]; - - private function openCellTileArt(): void - { - $map = $this->getSelectedMap(); - if ($map === null || $this->referencePicker->isOpen()) { return; } - try { - if ($this->getCanvasLayerState()['terminal']) { - throw new MapSourceRefusal('Leave terminal preview before editing tile art.'); - } - $this->finalizeActiveStroke(); - $id = $this->getActiveCanvasLayer(); - $art = $map->getCellTileArt($id, $this->cursorX, $this->cursorY); - $this->tileArtDialog = ['map' => $map, 'layer' => $id, 'column' => $this->cursorX, 'row' => $this->cursorY, - 'initial' => $art, 'asset' => $art['asset'], 'source' => $art['override'] ?? $art['inherited'] ?? self::TILE_ART_DEFAULT_SOURCE, - 'selected' => 0, 'confirmation' => null, 'error' => '']; - $this->tileArtFieldEditor = new TextFieldEditor(); - $this->modals->push(Modal::TILE_ART); - $this->requestFullRender(); - } catch (\Throwable $error) { - $this->setErrorStatus($error, 'Tile art'); - } - } - - private function closeCellTileArt(): void - { - $this->referencePicker->close(); - $this->tileArtFieldEditor?->close(); - $this->tileArtDialog = null; - $this->modals->remove(Modal::TILE_ART); - $this->requestFullRender(); - } - - private function openTileArtAssetPicker(): void - { - if (! $this->referencePicker->open('tile_art_asset', 'Layer PNG', 'png_assets', - $this->referenceCatalog()->valuesFor('png_assets'), $this->tileArtDialog['asset'] ?? '')) { - $this->tileArtDialog['error'] = 'No PNG assets found. Add artwork under this project\'s assets, then choose again.'; - } - } - - private function handleTileArtInput(string $input): void - { - if ($this->tileArtDialog === null) { return; } - if ($this->referencePicker->isOpen()) { - if ($input === "\033") { $this->referencePicker->close(); } - elseif ($input === "\r" || $input === "\n") { - if (($asset = $this->referencePicker->selected()) !== null) { - $this->tileArtDialog['asset'] = $asset; - $this->referencePicker->close(); - } - } elseif ($input === "\033[A") { $this->referencePicker->move(-1); } - elseif ($input === "\033[B") { $this->referencePicker->move(1); } - elseif ($input === "\177" || $input === "\010") { $this->referencePicker->backspace(); } - elseif (mb_strlen($input) === 1 && ! ctype_cntrl($input)) { $this->referencePicker->type($input); } - } elseif ($this->tileArtFieldEditor?->isActive) { - $result = $this->tileArtFieldEditor->handleKey($input, new InputControl(InputControlType::INTEGER)); - if ($result === TextFieldKeyResult::CANCELLED) { $this->tileArtFieldEditor->close(); } - elseif ($result === TextFieldKeyResult::SUBMITTED) { - $value = filter_var($this->tileArtFieldEditor->value, FILTER_VALIDATE_INT); - if ($value === false) { $this->tileArtDialog['error'] = 'Enter a whole number within the supported integer range.'; } - else { - $key = self::TILE_ART_ROWS[$this->tileArtDialog['selected']]; - $this->tileArtDialog['source'][$key] = $value; - $this->tileArtDialog['error'] = ''; - $this->tileArtFieldEditor->close(); - } - } - } elseif ($this->tileArtDialog['confirmation'] !== null) { - if ($input === "\033") { $this->tileArtDialog['confirmation'] = null; } - elseif ($input === 'y') { $this->commitCellTileArt($this->tileArtDialog['confirmation'], true); } - } elseif ($input === "\033") { - $this->closeCellTileArt(); - } elseif ($input === "\033[A" || $input === "\033[Z") { - $this->tileArtDialog['selected'] = ($this->tileArtDialog['selected'] + count(self::TILE_ART_ROWS) - 1) % count(self::TILE_ART_ROWS); - } elseif ($input === "\033[B" || $input === "\t") { - $this->tileArtDialog['selected'] = ($this->tileArtDialog['selected'] + 1) % count(self::TILE_ART_ROWS); - } elseif ($input === 'a') { - $this->openTileArtAssetPicker(); - } elseif ($input === 's') { - $this->commitCellTileArt('apply'); - } elseif ($input === 'r') { - $this->tileArtDialog['confirmation'] = 'remove'; - } elseif ($input === "\r" || $input === "\n") { - $key = self::TILE_ART_ROWS[$this->tileArtDialog['selected']]; - if ($key === 'asset') { $this->openTileArtAssetPicker(); } - elseif (in_array($key, self::TILE_ART_FIELDS, true)) { $this->tileArtFieldEditor->open((string) $this->tileArtDialog['source'][$key]); } - elseif ($key === 'apply') { $this->commitCellTileArt('apply'); } - else { $this->tileArtDialog['confirmation'] = 'remove'; } - } - $this->requestFullRender(); - } - - private function commitCellTileArt(string $action, bool $confirmed = false): void - { - $dialog = $this->tileArtDialog; - $map = $dialog['map']; - try { - if ($map !== $this->getSelectedMap() || $map->getCellTileArt($dialog['layer'], $dialog['column'], $dialog['row']) !== $dialog['initial']) { - throw new MapSourceRefusal('The captured map, layer or cell art changed. Cancel and reopen Tile art.'); - } - if ($action === 'apply' && $dialog['initial']['asset'] !== null && $dialog['asset'] !== $dialog['initial']['asset'] && ! $confirmed) { - $this->tileArtDialog['confirmation'] = 'apply'; - return; - } - $before = $map->captureLayerSnapshot(); - if ($action === 'remove') { $map->removeCellTileArt($dialog['layer'], $dialog['column'], $dialog['row']); } - else { $map->setCellTileArt($dialog['layer'], $dialog['column'], $dialog['row'], $dialog['source'], $dialog['asset'], $confirmed); } - $after = $map->captureLayerSnapshot(); - if ($before !== $after) { - $this->recordCommand(new GenericCommand('Cell tile art ' . $action, - fn() => $map->restoreLayerSnapshot($after), fn() => $map->restoreLayerSnapshot($before))); - } - $this->closeCellTileArt(); - $this->setStatus('Cell tile art staged. Ctrl+S saves; Ctrl+Z undoes.', StatusLevel::SUCCESS); - } catch (\Throwable $error) { - $this->tileArtDialog['confirmation'] = null; - $this->tileArtDialog['error'] = $error->getMessage(); - } - } - - private function renderTileArtDialog(array $layout): void - { - $dialog = $this->tileArtDialog; - $width = min(76, $layout['width'] - 4); - $inner = $this->getWindowContentWidth($width); - $lines = [sprintf('%s / %s / cell %d,%d', $dialog['map']->mapId, $dialog['initial']['layer'], $dialog['column'], $dialog['row'])]; - $help = 'Arrows:Field Enter:Edit A:Atlas S:Apply R:Remove Esc:Cancel'; - if ($this->referencePicker->isOpen()) { - $lines[] = 'Choose layer PNG: ' . $this->referencePicker->filter(); - $choices = array_map(fn(string $path): string => ($path === $this->referencePicker->selected() ? '> ' : ' ') . $path, $this->referencePicker->matches()); - $lines = [...$lines, ...ScrollWindow::slice($choices, $this->referencePicker->selectedIndex(), 9)]; - if ($choices === []) { $lines[] = 'No matching PNG assets.'; } - $help = 'Type:Filter Arrows:Choose Enter:Select Esc:Back'; - } elseif ($dialog['confirmation'] !== null) { - $message = $dialog['confirmation'] === 'remove' - ? 'Remove only this cell override? Its symbol default (if any) will be used again.' - : sprintf('Change this layer atlas from %s to %s? Every existing symbol and cell crop on this layer will use the new PNG. Other layers and the shared atlas stay unchanged.', $dialog['initial']['asset'], $dialog['asset']); - $lines = [...$lines, ...explode("\n", wordwrap($message, $inner, "\n", true))]; - $help = 'Y:Confirm Esc:Back'; - } else { - $lines[] = 'Glyph: ' . var_export($dialog['initial']['symbol'], true) . ' | ' . ($dialog['initial']['override'] !== null ? 'Cell override' : 'Inherited / unmapped'); - $values = ['Layer atlas: ' . ($dialog['asset'] ?? '(choose PNG)')]; - foreach (self::TILE_ART_FIELDS as $index => $key) { - $editing = $dialog['selected'] === $index + 1 && $this->tileArtFieldEditor?->isActive; - $values[] = ucfirst($key) . ': ' . ($editing ? $this->tileArtFieldEditor->value . '|' : $dialog['source'][$key]); - } - foreach ([...$values, 'Apply override', 'Remove override'] as $index => $value) { - $button = in_array(self::TILE_ART_ROWS[$index], ['apply', 'remove'], true); - $line = $button ? str_pad($value, $inner, ' ', STR_PAD_BOTH) : ($index === $dialog['selected'] ? '> ' : ' ') . $value; - $lines[] = $button && $index === $dialog['selected'] ? "\033[7m" . $line . "\033[0m" : $line; - } - $lines[] = 'Crop pixels: X/Y start at zero; width/height must be positive.'; - $lines[] = 'Only artwork changes. Glyphs and collision stay unchanged.'; - } - if ($dialog['error'] !== '') { $lines = [...$lines, ...explode("\n", wordwrap($dialog['error'], $inner, "\n", true))]; } - $height = min(count($lines) + 2, $layout['height'] - 4); - new EditorWindow(title: 'Tile art: selected cell', help: $help, position: ['x' => max(2, intdiv($layout['width'] - $width, 2)), 'y' => 3], - width: $width, height: $height, content: $this->fitLines($lines, $inner, $height - 2))->render(); - } -} diff --git a/src/Editor.php b/src/Editor.php index 29e5cb1..e978254 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -110,8 +110,6 @@ final class Editor { use \Ichiloto\Editor\Canvas\LayerCanvas; - use \Ichiloto\Editor\Canvas\TileArtCanvas; - use \Ichiloto\Editor\Field\NpcSpriteCanvas; use CutscenesWorkspace; use CutsceneOutlinePane; use CutscenePreviewPane; @@ -1256,8 +1254,6 @@ private function buildInputRouter(): InputRouter $router->bindModal(Modal::DATABASE_ENTRY_DELETE_CONFIRMATION, $this->handleDatabaseEntryDeleteConfirmationInput(...)); $router->bindModal(Modal::RENAME_CONFIRMATION, $this->handleRenameConfirmationInput(...)); $router->bindModal(Modal::LAYER_EDIT, $this->handleLayerPromptInput(...)); - $router->bindModal(Modal::TILE_ART, $this->handleTileArtInput(...)); - $router->bindModal(Modal::NPC_ART, $this->handleNpcArtInput(...)); $router->bindModal(Modal::COMMAND_PALETTE, $this->handleCommandPaletteInput(...)); $router->bindModal(Modal::HELP, $this->handleHelpInput(...)); $router->bindModal(Modal::DATABASE, $this->handleDatabaseInput(...)); @@ -1377,7 +1373,6 @@ private function buildInputRouter(): InputRouter KeyBinding::when($this->isNormalModeCommand('['), fn() => $this->cycleCanvasLayer(-1), '[', 'Canvas: previous layer'), KeyBinding::when($this->isNormalModeCommand('v'), fn() => $this->toggleCanvasLayerVisibility(), 'v', 'Canvas: toggle selected layer visibility'), KeyBinding::when($this->isNormalModeCommand('d'), fn() => $this->toggleCanvasLayerOption('dim'), 'd', 'Canvas: dim inactive layers'), - KeyBinding::when($this->isNormalModeCommand('t'), fn() => $this->toggleCanvasLayerOption('terminal'), 't', 'Canvas: terminal gameplay preview'), KeyBinding::when($this->isNormalModeCommand('m'), fn() => $this->setEditingMode(self::MODE_MAP), 'm', 'Canvas: Map mode (paint tiles)'), KeyBinding::when($this->isNormalModeCommand('e'), fn() => $this->setEditingMode(self::MODE_EVENT), 'e', 'Canvas: Event mode (paint event markers)'), KeyBinding::when($this->isNormalModeCommand('n'), fn() => $this->setEditingMode(self::MODE_NPC), 'n', 'Canvas: NPC mode (place and edit the map\'s NPCs)'), @@ -1905,10 +1900,6 @@ private function handleNpcInspectorInput(string $input, string $normalizedInput) return; } - if (is_array($current) && ($current['field'] ?? null) === self::NPC_ART_FIELD) { - $this->openNpcSpriteArt(); - return; - } $this->beginDatabaseEdit(); @@ -2092,9 +2083,6 @@ private function getNpcInspectorFields(): array $records = $this->npcInspector->records(); $fields = $records->getFrameSettingsFields($this->selectedNpcIndex, $this->databaseCommandFramePath); - if ($this->databaseCommandFramePath === []) { - $fields[] = ['label' => 'Graphical Sprites', 'value' => 'Enter: four directional PNG roles', 'field' => self::NPC_ART_FIELD, 'editable' => true]; - } if ( $this->databaseCommandFramePath !== [] @@ -2137,7 +2125,7 @@ private function getNpcInspectorFields(): array $sections = [ 'Identity' => ['id', 'name'], 'Placement' => ['x', 'y'], - 'Appearance' => ['sprite', 'sprites.north', 'sprites.south', 'sprites.east', 'sprites.west', self::NPC_ART_FIELD], + 'Appearance' => ['sprite', 'sprites.north', 'sprites.south', 'sprites.east', 'sprites.west'], 'Movement' => ['movement', 'wanderArea.x', 'wanderArea.y', 'wanderArea.width', 'wanderArea.height'], 'Visibility' => ['conditions'], 'Interaction' => ['commandListScript'], @@ -3781,7 +3769,6 @@ private function moveSelection(int $step): void private function selectAsset(int $selectedIndex): void { $this->facadeBrush = null; - $this->canvasPaintWarning = ''; if (! $this->workspace instanceof ProjectWorkspace || $selectedIndex === $this->selectedAssetIndex) { return; } @@ -4566,9 +4553,6 @@ private function paintCanvasStroke( MouseButton $button, bool $paintEvents = false, ): void { - if ($this->getCanvasLayerState()['terminal']) { - return; - } $isStartingStroke = $this->activeMousePaintButton !== $button || ! is_array($this->lastMousePaintPoint); $start = $isStartingStroke ? ['x' => $targetX, 'y' => $targetY] @@ -4604,7 +4588,6 @@ private function paintCanvasStroke( [$newPrefix, $newSuffix] = $this->resolvePaintStyle($symbol, $this->selectedPaintColor, $oldStyle); $selectedMap->setLayerCell($this->getActiveCanvasLayer(), $point['x'], $point['y'], $symbol, $newPrefix, $newSuffix); $newSymbol = $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']); - $this->recordCanvasCropWarning($selectedMap, $symbol, $oldSymbol, $point['x'], $point['y']); $this->activeStrokeCommand->appendCell( $point['x'], $point['y'], @@ -4697,10 +4680,6 @@ private function isNormalModeCommand(string $letter): Closure */ private function enterPaintMode(): void { - if ($this->getCanvasLayerState()['terminal']) { - $this->setStatus('Terminal preview is read-only. Press t to return to authoring.'); - return; - } if ($this->editingMode !== self::MODE_MAP && $this->editingMode !== self::MODE_EVENT) { $this->statusMessage = 'Paint mode needs the Map or Event layer. Press m or e first.'; $this->renderFooter(); @@ -4761,7 +4740,7 @@ private function getActiveCanvasLayer(): string } $map = $this->getSelectedMap(); $selected = $this->getCanvasLayerState()['selected']; - $ids = array_column($map?->getLayers() ?? [], 'id'); + $ids = array_column($this->getTerminalCanvasLayers(), 'id'); return $selected !== PaintStrokeCommand::LAYER_EVENT && in_array($selected, $ids, true) ? $selected : ($map?->getBaseLayerId() ?? PaintStrokeCommand::LAYER_TILE); } @@ -4808,7 +4787,7 @@ private function writeCanvasSymbol(ProjectMap $map, int $x, int $y, string $symb */ private function applyCanvasWrites(ProjectMap $map, array $writes, string $label): int { - if ($writes === [] || $this->getCanvasLayerState()['terminal']) { + if ($writes === []) { return 0; } @@ -4847,8 +4826,6 @@ private function applyCanvasWrites(ProjectMap $map, array $writes, string $label if ($oldSymbol !== $newSymbol || $oldStyle['prefix'] !== $newPrefix || $oldStyle['suffix'] !== $newSuffix) { $changed++; } - - $this->recordCanvasCropWarning($map, $write['symbol'], $oldSymbol, $write['x'], $write['y']); } if ($stroke->hasChanges()) { @@ -8402,11 +8379,6 @@ private function activateInspectorField(): void return; } - if (($field['target'] ?? null) === 'tile-art') { - $this->openCellTileArt(); - return; - } - if (($field['target'] ?? null) === 'event-type') { $this->openEventTypeDialog((string) ($field['marker'] ?? '')); return; @@ -17462,7 +17434,7 @@ private function getCharacterPalette(): array $palette[$symbol] = $symbol; } - $layerName = array_values(array_filter($selectedMap->getLayers(), fn(array $layer): bool => $layer['id'] === $this->getActiveCanvasLayer()))[0]['name'] ?? null; + $layerName = array_values(array_filter($this->getTerminalCanvasLayers(), fn(array $layer): bool => $layer['id'] === $this->getActiveCanvasLayer()))[0]['name'] ?? null; foreach ($this->workspace->getCollisionGlyphs($layerName) as $symbol) { $palette[$symbol] = $symbol; } @@ -17750,15 +17722,6 @@ private function renderModalOverlays(array $layout): bool return true; } - if ($this->modals->has(Modal::TILE_ART)) { - $this->renderTileArtDialog($layout); - return true; - } - if ($this->modals->has(Modal::NPC_ART)) { - $this->renderNpcArtDialog($layout); - return true; - } - if ($this->isCommandPaletteOpen) { $this->renderCommandPaletteOverlay($layout); return true; @@ -18222,7 +18185,7 @@ private function createCanvasWindow(): EditorWindow return new EditorWindow( title: ($this->focusedPane === self::FOCUS_CANVAS ? 'Canvas [Focus]' : 'Canvas') - . ($this->getCanvasLayerState()['terminal'] ? ' [Terminal preview]' : $this->getCanvasLayerTitle()), + . $this->getCanvasLayerTitle(), help: match (true) { $this->isDestinationSpawnConfirmationOpen => 'Enter:Apply Esc:Back', $this->isDestinationSpawnSelectionOpen => 'Arrows:Move Enter:Select Spawn Esc:Cancel', @@ -18242,9 +18205,9 @@ private function createCanvasWindow(): EditorWindow ), default => $this->fitHelp( $layout['centerWidth'], - 'i:Paint []:Layer v:Hide d:Dim t:Terminal o:Colour Ctrl+P:Layers', - 'i:Paint []:Layer v:Hide t:Terminal Ctrl+P:Layers', - 'i:Paint []:Layer t:Terminal', + 'i:Paint []:Layer v:Hide d:Dim o:Colour Ctrl+P:Layers', + 'i:Paint []:Layer v:Hide Ctrl+P:Layers', + 'i:Paint []:Layer ?:Help', 'i:Paint m:Map e:Event n:NPC c:Chars o:Colour ?:Help', 'i:Paint m:Map e:Event n:NPC c:Chars o:Colour', 'i:Paint m:Map e:Event', @@ -18268,7 +18231,7 @@ private function createCanvasWindow(): EditorWindow ['x' => $this->cursorX, 'y' => $this->cursorY], $this->getCanvasLayerState()['visibility'], $this->getActiveCanvasLayer(), - $this->getCanvasLayerState()['terminal'], + false, $this->getCanvasLayerState()['dim'], ) ?? [], $contentWidth, @@ -18357,7 +18320,7 @@ private function createFooterWindow(): EditorWindow $this->cursorY, $this->canvasOffsetX, $this->canvasOffsetY, - $this->canvasPaintWarning !== '' ? $this->canvasPaintWarning : $this->getFooterStatusText() + $this->getFooterStatusText() ), ], $contentWidth, 2), ); diff --git a/src/Field/NpcSpriteCanvas.php b/src/Field/NpcSpriteCanvas.php deleted file mode 100644 index 056ad2e..0000000 --- a/src/Field/NpcSpriteCanvas.php +++ /dev/null @@ -1,178 +0,0 @@ -getSelectedMap(); - $index = $this->selectedNpcIndex; - $npc = $index === null ? null : $map?->getNpcs()->get($index); - if ($npc === null || $this->referencePicker->isOpen()) { return; } - $data = $npc->toArray()['sprites2d'] ?? null; - if ($data !== null && ! is_array($data)) { - $this->setStatus('NPC sprites2d is not an array. Repair its source before authoring art.', StatusLevel::WARN); - return; - } - $this->npcArtDialog = ['map' => $map, 'index' => $index, 'initial' => $npc->toArray(), - 'draft' => new DirectionalSpriteDraft($data), 'direction' => 'south', 'selected' => 0, - 'confirmation' => false, 'error' => '']; - $this->npcArtFieldEditor = new TextFieldEditor(); - $this->modals->push(Modal::NPC_ART); - $this->requestFullRender(); - } - - private function closeNpcSpriteArt(): void - { - $this->referencePicker->close(); - $this->npcArtDialog = null; - $this->npcArtFieldEditor?->close(); - $this->modals->remove(Modal::NPC_ART); - $this->requestFullRender(); - } - - private function getNpcArtFields(): array - { - $dialog = $this->npcArtDialog; - return [ - ['field' => 'mode', 'label' => 'Representation', 'value' => $dialog['draft']->getMode(), 'options' => ['poses', 'sheet']], - ['field' => 'direction', 'label' => 'Direction / role', 'value' => $dialog['direction'], 'options' => ProjectNpc::DIRECTIONS], - ...$dialog['draft']->getFields($dialog['direction']), - ['field' => 'apply', 'label' => 'Apply all four directions', 'value' => ''], - ['field' => 'remove', 'label' => 'Remove graphical sprites', 'value' => ''], - ]; - } - - private function handleNpcArtInput(string $input): void - { - if ($this->npcArtDialog === null) { return; } - try { - $fields = $this->getNpcArtFields(); - $field = $fields[$this->npcArtDialog['selected']]; - if ($this->referencePicker->isOpen()) { - if ($input === "\033") { $this->referencePicker->close(); } - elseif ($input === "\r" || $input === "\n") { - if (($asset = $this->referencePicker->selected()) !== null) { - $this->npcArtDialog['draft']->setField($this->npcArtDialog['direction'], $field['field'], $asset); - $this->referencePicker->close(); - } - } elseif ($input === "\033[A") { $this->referencePicker->move(-1); } - elseif ($input === "\033[B") { $this->referencePicker->move(1); } - elseif ($input === "\177" || $input === "\010") { $this->referencePicker->backspace(); } - elseif (mb_strlen($input) === 1 && ! ctype_cntrl($input)) { $this->referencePicker->type($input); } - } elseif ($this->npcArtFieldEditor?->isActive) { - $result = $this->npcArtFieldEditor->handleKey($input, new InputControl(InputControlType::INTEGER)); - if ($result === TextFieldKeyResult::CANCELLED) { $this->npcArtFieldEditor->close(); } - elseif ($result === TextFieldKeyResult::SUBMITTED) { - $this->npcArtDialog['draft']->setField($this->npcArtDialog['direction'], $field['field'], $this->npcArtFieldEditor->value); - $this->npcArtFieldEditor->close(); - } - } elseif ($this->npcArtDialog['confirmation']) { - if ($input === "\033") { $this->npcArtDialog['confirmation'] = false; } - elseif ($input === 'y') { $this->commitNpcSpriteArt(true); } - } elseif ($input === "\033") { $this->closeNpcSpriteArt(); } - elseif ($input === "\033[A" || $input === "\033[Z") { - $this->npcArtDialog['selected'] = ($this->npcArtDialog['selected'] + count($fields) - 1) % count($fields); - } elseif ($input === "\033[B" || $input === "\t") { - $this->npcArtDialog['selected'] = ($this->npcArtDialog['selected'] + 1) % count($fields); - } elseif ($input === 's') { $this->commitNpcSpriteArt(); } - elseif ($input === 'r') { $this->npcArtDialog['confirmation'] = true; } - elseif (in_array($input, ["\r", "\n", "\033[C", "\033[D"], true)) { - if (isset($field['options'])) { - $options = $field['options']; - $index = array_search($field['value'], $options, true); - $value = $options[((int) $index + ($input === "\033[D" ? count($options) - 1 : 1)) % count($options)]; - if ($field['field'] === 'direction') { $this->npcArtDialog['direction'] = $value; } - elseif ($field['field'] === 'mode') { $this->npcArtDialog['draft']->setMode($value); } - else { $this->npcArtDialog['draft']->setField($this->npcArtDialog['direction'], $field['field'], $value); } - } elseif (isset($field['reference'])) { - if (! $this->referencePicker->open('npc_art_asset', $field['label'], 'png_assets', - $this->referenceCatalog()->valuesFor('png_assets'), $field['value'])) { - throw new MapSourceRefusal('No PNG assets found inside this project.'); - } - } elseif ($field['field'] === 'apply') { $this->commitNpcSpriteArt(); } - elseif ($field['field'] === 'remove') { $this->npcArtDialog['confirmation'] = true; } - else { $this->npcArtFieldEditor->open($field['value']); } - } - } catch (\Throwable $error) { - $this->npcArtDialog['error'] = $error->getMessage(); - } - $this->requestFullRender(); - } - - private function commitNpcSpriteArt(bool $remove = false): void - { - $dialog = $this->npcArtDialog; - $map = $dialog['map']; - if ($map !== $this->getSelectedMap() || $map->getNpcs()->get($dialog['index'])?->toArray() !== $dialog['initial']) { - throw new MapSourceRefusal('The captured map or NPC changed. Cancel and reopen artwork before applying.'); - } - $before = $map->captureLayerSnapshot(); - $map->setNpcGraphicalSprites($dialog['index'], $remove ? null : $dialog['draft']->getData()); - $after = $map->captureLayerSnapshot(); - if ($before !== $after) { - $this->recordCommand(new GenericCommand($remove ? 'Remove NPC graphical sprites' : 'NPC graphical sprites', - function () use ($map, $after, $dialog): void { $map->restoreLayerSnapshot($after); $this->selectNpc($dialog['index']); }, - function () use ($map, $before, $dialog): void { $map->restoreLayerSnapshot($before); $this->selectNpc($dialog['index']); })); - } - $this->refreshNpcInspector(); - $this->closeNpcSpriteArt(); - $this->setStatus('NPC artwork staged. Ctrl+S saves; Ctrl+Z undoes. Terminal sprite and interaction stay unchanged.', StatusLevel::SUCCESS); - } - - private function renderNpcArtDialog(array $layout): void - { - $dialog = $this->npcArtDialog; - $width = min(82, $layout['width'] - 4); - $inner = $this->getWindowContentWidth($width); - $lines = [sprintf('%s / %s / %s', $dialog['map']->mapId, $dialog['initial']['id'] ?? $dialog['initial']['name'], $dialog['direction'])]; - $help = 'Arrows:Field Enter:Edit/Choose S:Apply R:Remove Esc:Cancel'; - if ($this->referencePicker->isOpen()) { - $lines[] = 'Choose ' . $dialog['direction'] . ' PNG: ' . $this->referencePicker->filter(); - $choices = array_map(fn(string $path): string => ($path === $this->referencePicker->selected() ? '> ' : ' ') . $path, $this->referencePicker->matches()); - $lines = [...$lines, ...ScrollWindow::slice($choices, $this->referencePicker->selectedIndex(), 10)]; - if ($choices === []) { $lines[] = 'No matching PNG assets.'; } - $help = 'Type:Filter Arrows:Choose Enter:Select Esc:Back'; - } elseif ($dialog['confirmation']) { - $lines[] = 'Remove this NPC\'s complete sprites2d set?'; - $lines[] = 'Terminal glyphs, dialogue, identity and collision stay unchanged.'; - $help = 'Y:Confirm removal Esc:Back'; - } else { - $rows = []; - foreach ($this->getNpcArtFields() as $index => $field) { - $button = in_array($field['field'], ['apply', 'remove'], true); - $selected = $dialog['selected'] === $index; - $value = $selected && $this->npcArtFieldEditor?->isActive ? $this->npcArtFieldEditor->value . '|' : ($field['value'] === '' ? '(not set)' : $field['value']); - $line = $button ? str_pad($field['label'], $inner, ' ', STR_PAD_BOTH) : ($selected ? '> ' : ' ') . $field['label'] . ': ' . $value; - $rows[] = $button && $selected ? "\033[7m" . $line . "\033[0m" : $line; - } - $lines = [...$lines, ...ScrollWindow::slice($rows, $dialog['selected'], max(3, $layout['height'] - 12))]; - $lines[] = 'All four directions need PNG + logical width/height.'; - $lines[] = $dialog['draft']->getMode() === 'sheet' ? 'Size/timing/anchor are shared; asset/grid/frame count belong to each role.' : 'Crop is optional: leave all four crop fields blank to use the whole PNG.'; - } - if ($dialog['error'] !== '') { $lines = [...$lines, ...explode("\n", wordwrap($dialog['error'], $inner, "\n", true))]; } - $height = min(count($lines) + 2, $layout['height'] - 4); - new EditorWindow(title: 'NPC graphical sprites', help: $help, - position: ['x' => max(2, intdiv($layout['width'] - $width, 2)), 'y' => 3], width: $width, - height: $height, content: $this->fitLines($lines, $inner, $height - 2))->render(); - } -} diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 0eee0db..45a6856 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -42,6 +42,7 @@ final class ProjectMap /** Invalidated by data/layer/dimension changes, not by painting individual glyphs. */ private ?array $tileDefinitions = null; private array $tileDefinitionGeometry = []; + private ?string $baselineGraphicalLayerFingerprint = null; /** * @var array @@ -258,6 +259,7 @@ private function assertGridSourcesCanonical(): void */ private function withLoadedBaseline(): self { + $this->baselineGraphicalLayerFingerprint = $this->getGraphicalLayerFingerprint(); $this->captureBaseline(); return $this; @@ -539,10 +541,15 @@ public function getStoredGridPaths(): array } public function validateLayerContracts(): void + { + $this->validateGameplayLayerContracts(); + GraphicalTileDefinition::validateDecoration($this->layers->getLayerSet(), $this->getLayerTileDefinitions()); + } + + private function validateGameplayLayerContracts(): void { $this->assertGridsAgree(); $set = $this->layers->getLayerSet(); - GraphicalTileDefinition::validateDecoration($set, $this->getLayerTileDefinitions()); $path = $this->getMapsRoot() . '/collisions.php'; $dictionary = is_file($path) ? (static fn(string $path): mixed => require $path)($path) : []; if (! is_array($dictionary)) { @@ -551,6 +558,35 @@ public function validateLayerContracts(): void MapCollisionResolver::resolveLayers($set, $dictionary); } + private function validateLayerPersistence(): void + { + // Unrelated terminal edits preserve existing art, even when that art + // needs repair. New maps and changed visual inputs still validate fully. + if ($this->baselineGraphicalLayerFingerprint === $this->getGraphicalLayerFingerprint()) { + $this->validateGameplayLayerContracts(); + } else { + $this->validateLayerContracts(); + } + } + + private function getGraphicalLayerFingerprint(): string + { + $layers = []; + foreach ($this->layers->getLayers() as $layer) { + if ($layer['id'] === MapLayers::EVENT) { + continue; + } + $layers[] = [$layer['id'], $layer['name'], $layer['order'], $layer['decoration'], + array_map(count(...), $layer['grid']->cells), + $layer['decoration'] ? $layer['grid']->cells : null]; + } + return PhpDataFile::valueFingerprint([ + array_intersect_key($this->editableData, ['tiles2d' => true]), + $this->layers->legacy, + $layers, + ]); + } + public function getLayerRenameCollisionChange(string $id, string $name): ?string { $dictionaryPath = $this->getMapsRoot() . '/collisions.php'; @@ -627,16 +663,16 @@ public function renderPreview( for ($row = $offsetY; $row < $rowLimit; $row++) { $tileRow = []; foreach ($this->layers->getLayers() as $layer) { - if ($terminalPreview && ($layer['decoration'] || $layer['id'] === MapLayers::EVENT)) { + if ($layer['decoration']) { continue; } - if (! $terminalPreview && (($layerVisibility[$layer['id']] ?? true) === false - || ($layer['id'] === MapLayers::EVENT && ! $showEventOverlay))) { + if (($layerVisibility[$layer['id']] ?? true) === false + || ($layer['id'] === MapLayers::EVENT && ! $showEventOverlay)) { continue; } foreach ($layer['grid']->cells[$row] ?? [] as $x => $cell) { if ($cell['symbol'] !== ' ' || $layer['id'] === $baseLayerId) { - $tileRow[$x] = [...$cell, 'dim' => ! $terminalPreview && $dimInactive && $activeLayer !== $layer['id']]; + $tileRow[$x] = [...$cell, 'dim' => $dimInactive && $activeLayer !== $layer['id']]; } } } @@ -1629,7 +1665,7 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) return $target['mapId']; } - $this->validateLayerContracts(); + $this->validateLayerPersistence(); // Which members actually changed. The tile and event files compare // against the grids as of the last save, never against the bytes on @@ -1728,6 +1764,7 @@ private function adoptWritten(string $dataSource, string $mapPayload, string $ev $this->baselineMapPayload = $mapPayload; $this->baselineEventPayload = $eventPayload; $this->layers->captureBaseline(); + $this->baselineGraphicalLayerFingerprint = $this->getGraphicalLayerFingerprint(); $this->captureBaseline(); } @@ -1875,7 +1912,7 @@ public function duplicateTo(string $directory, string $baseName, string $display $transaction = new FileSetTransaction($directory, $files ?? new FilesystemFileSetOperations()); $duplicatedDataPath = $directory . DIRECTORY_SEPARATOR . $baseName . '.data.php'; $transaction->write($duplicatedDataPath, $dataSource); - $this->validateLayerContracts(); + $this->validateLayerPersistence(); $this->layers->stageChanges($transaction, $directory, $baseName); // The staged copy must evaluate to exactly the duplicate's content @@ -2072,7 +2109,7 @@ public function moveTo(string $newRelativeId): self $movedDataPath, $this->dataDocument === null ? (string) $this->unparsedDataSource : $this->proposedDataSource(), ); - $this->validateLayerContracts(); + $this->validateLayerPersistence(); $this->layers->stageChanges($transaction, $directory, $baseName, true); $transaction->remove($this->dataPath); $expectedGrids = $this->layers->getSources($directory, $baseName); diff --git a/src/UI/Modal.php b/src/UI/Modal.php index 4df78ca..c75e98d 100644 --- a/src/UI/Modal.php +++ b/src/UI/Modal.php @@ -33,8 +33,6 @@ enum Modal: string * The folder-move confirmation raised by a renaming save. */ case RENAME_CONFIRMATION = 'rename_confirmation'; - case TILE_ART = 'tile_art'; - case NPC_ART = 'npc_art'; case LAYER_EDIT = 'layer_edit'; /** * The Ctrl+P command palette. Opens above the Database screen and the diff --git a/tests/Unit/CellTileArtTest.php b/tests/Unit/CellTileArtTest.php index 660a59b..ef728e0 100644 --- a/tests/Unit/CellTileArtTest.php +++ b/tests/Unit/CellTileArtTest.php @@ -8,7 +8,6 @@ use Ichiloto\Editor\Storage\FileSetTransactionFailure; use Ichiloto\Engine\Field\MapCollisionResolver; use Ichiloto\Engine\Field\MapGridSource; -use Ichiloto\Editor\UI\Modal; function createCellTileArtProject(bool $legacy = false): string { @@ -186,67 +185,48 @@ function getCellTileArtRectangle(int $x = 16): array expect(file_get_contents($path))->toBe(str_replace("'x' => 16", "'x' => 32", $source)); }); -it('uses the real palette asset picker crop editor confirmation save and undo without losing context', function () { - $root = createCellTileArtProject(); +it('preserves existing cell crops and graphical assets through terminal painting undo redo save and reload', function (bool $legacy) { + $root = createCellTileArtProject($legacy); + $map = loadLayeredMap($root); + $id = $legacy ? 'tile' : 'map:4'; + $map->setCellTileArt($id, 0, 0, getCellTileArtRectangle(32), 'Graphics/Tilesets/shared.png'); + $map->save(); + $source = file_get_contents($map->dataPath); + $assets = sourceHashTree($root . '/assets/Graphics'); + $layers = sourceHashTree($map->directory); [$editor, $map] = layeredCanvasEditor($root); - callEditorMethod($editor, 'dispatchInput', ']'); - $before = $map->captureLayerSnapshot(); - callEditorMethod($editor, 'dispatchInput', "\x10"); - foreach (str_split('Tile art: Edit selected cell') as $key) { callEditorMethod($editor, 'dispatchInput', $key); } - callEditorMethod($editor, 'dispatchInput', "\r"); - expect(getEditorProperty($editor, 'modals')->has(Modal::TILE_ART))->toBeTrue() - ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('buildings / cell 0,0'); - foreach ([']', "\x04", "\x13", "\033[<0;5;5M"] as $key) { callEditorMethod($editor, 'dispatchInput', $key); } - expect($map->captureLayerSnapshot())->toBe($before) - ->and(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:4'); - callEditorMethod($editor, 'dispatchInput', 'a'); - foreach (str_split('replacement') as $key) { callEditorMethod($editor, 'dispatchInput', $key); } - callEditorMethod($editor, 'dispatchInput', "\r"); - callEditorMethod($editor, 'dispatchInput', "\t"); - callEditorMethod($editor, 'dispatchInput', "\r"); - callEditorMethod($editor, 'dispatchInput', "\177"); - callEditorMethod($editor, 'dispatchInput', '3'); - callEditorMethod($editor, 'dispatchInput', '2'); - callEditorMethod($editor, 'dispatchInput', "\r"); - callEditorMethod($editor, 'dispatchInput', 's'); - expect(renderEditorPlainFrame($editor, 160, 45))->toContain('Every existing symbol and cell crop', 'Y:Confirm') - ->and($map->captureLayerSnapshot())->toBe($before); - callEditorMethod($editor, 'dispatchInput', "\r"); - expect($map->captureLayerSnapshot())->toBe($before); - callEditorMethod($editor, 'dispatchInput', 'y'); - expect($map->getCellTileArt('map:4', 0, 0)['override'])->toBe(getCellTileArtRectangle(32)) - ->and($map->getEditableData()['tiles2d']['asset'])->toBe('Graphics/Tilesets/shared.png') - ->and($map->getCellTileArt('map:7', 0, 0)['asset'])->toBe('Graphics/Tilesets/detail.png'); - callEditorMethod($editor, 'dispatchInput', "\x13"); - $saved = file_get_contents($map->dataPath); - callEditorMethod($editor, 'dispatchInput', ']'); + callEditorMethod($editor, 'selectCanvasLayer', $id); + $oldSymbol = $map->getLayerSymbol($id, 0, 0); + foreach (['i', 'Z', "\033", "\x13"] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + expect($map->getLayerSymbol($id, 0, 0))->toBe('Z') + ->and(file_get_contents($map->dataPath))->toBe($source) + ->and(renderEditorPlainFrame($editor, 160, 45))->not->toContain('Crop mapping', 'cell overrides', 'Tile art'); callEditorMethod($editor, 'dispatchInput', "\x1a"); callEditorMethod($editor, 'dispatchInput', "\x13"); - expect(file_get_contents($map->dataPath))->toBe($before['source']); - callEditorMethod($editor, 'dispatchInput', "\x19"); - callEditorMethod($editor, 'dispatchInput', "\x13"); - expect(file_get_contents($map->dataPath))->toBe($saved); - callEditorMethod($editor, 'dispatchInput', "\x12"); - expect(callEditorMethod($editor, 'getSelectedMap')->getCellTileArt('map:4', 0, 0)['override'])->toBe(getCellTileArtRectangle(32)); -}); + expect($map->getLayerSymbol($id, 0, 0))->toBe($oldSymbol) + ->and(sourceHashTree($map->directory))->toBe($layers); + foreach (["\x19", "\x13", "\x12"] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + $loaded = callEditorMethod($editor, 'getSelectedMap'); + expect($loaded->getLayerSymbol($id, 0, 0))->toBe('Z') + ->and($loaded->getCellTileArt($id, 0, 0)['override'])->toBe(getCellTileArtRectangle(32)) + ->and(file_get_contents($loaded->dataPath))->toBe($source) + ->and(sourceHashTree($root . '/assets/Graphics'))->toBe($assets); + if (! $legacy) { + expect(sourceHashTree($loaded->directory)['layers/07.detail.deco.php'])->toBe($layers['layers/07.detail.deco.php']); + } +})->with([false, true]); -it('offers only project-contained PNG assets and never treats picker text as a resource path', function () { +it('offers only project-contained PNG assets in the shared resource catalogue', function () { $root = createCellTileArtProject(); copy($root . '/assets/Graphics/Tilesets/shared.png', $root . '/outside.png'); symlink($root . '/outside.png', $root . '/assets/Graphics/Tilesets/outside.png'); expect(ReferenceCatalog::getPngAssets($root))->toBe([ 'Graphics/Tilesets/detail.png', 'Graphics/Tilesets/replacement.png', 'Graphics/Tilesets/shared.png', ]); - [$editor, $map] = layeredCanvasEditor($root); - $before = $map->captureLayerSnapshot(); - callEditorMethod($editor, 'openCellTileArt'); - callEditorMethod($editor, 'dispatchInput', 'a'); - foreach (str_split('../outside.png') as $key) { callEditorMethod($editor, 'dispatchInput', $key); } - callEditorMethod($editor, 'dispatchInput', "\r"); - expect(getEditorProperty($editor, 'referencePicker')->isOpen())->toBeTrue(); - callEditorMethod($editor, 'dispatchInput', "\033"); - callEditorMethod($editor, 'dispatchInput', "\033"); - expect($map->captureLayerSnapshot())->toBe($before); }); it('requires atlas confirmation before mutation and rejects invalid PNGs or existing crops outside the replacement', function () { @@ -342,49 +322,9 @@ function getCellTileArtRectangle(int $x = 16): array expect(loadLayeredMap($root)->getCellTileArt('map:4', 0, 1)['override'])->toBe(getCellTileArtRectangle(32)); }); -it('uses inspector remove confirmation and undo without changing inherited defaults', function () { - [$editor, $map] = layeredCanvasEditor(createCellTileArtProject()); - $map->setCellTileArt('map:4', 0, 0, getCellTileArtRectangle(32)); - $map->save(); - $before = $map->captureLayerSnapshot(); - callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); - callEditorMethod($editor, 'dispatchInput', "\t"); - $found = false; - foreach (callEditorMethod($editor, 'getInspectorFields') as $index => $field) { - if (($field['target'] ?? '') !== 'tile-art') { continue; } - $found = true; - setEditorProperty($editor, 'selectedInspectorFieldIndex', $index); - callEditorMethod($editor, 'dispatchInput', "\r"); - } - expect($found)->toBeTrue()->and(getEditorProperty($editor, 'modals')->has(Modal::TILE_ART))->toBeTrue(); - callEditorMethod($editor, 'dispatchInput', 'r'); - expect(renderEditorPlainFrame($editor, 160, 45))->toContain('Remove only this cell override?') - ->and($map->captureLayerSnapshot())->toBe($before); - callEditorMethod($editor, 'dispatchInput', "\033"); - expect($map->captureLayerSnapshot())->toBe($before); - callEditorMethod($editor, 'dispatchInput', 'r'); - callEditorMethod($editor, 'dispatchInput', 'y'); - callEditorMethod($editor, 'dispatchInput', "\x13"); - expect($map->getCellTileArt('map:4', 0, 0)['override'])->toBeNull(); - callEditorMethod($editor, 'dispatchInput', "\x1a"); - callEditorMethod($editor, 'dispatchInput', "\x13"); - expect(file_get_contents($map->dataPath))->toBe($before['source']); -}); - -it('refuses stale captured cell art without changing the newer author state', function () { - [$editor, $map] = layeredCanvasEditor(createCellTileArtProject()); - callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); - callEditorMethod($editor, 'openCellTileArt'); - $map->setCellTileArt('map:4', 0, 0, getCellTileArtRectangle(32)); - $newer = $map->captureLayerSnapshot(); - callEditorMethod($editor, 'dispatchInput', 's'); - expect($map->captureLayerSnapshot())->toBe($newer) - ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('Cancel and reopen Tile art'); -}); - it('authors coordinate-only decoration in stages while terminal preview and collision stay unchanged', function () { $root = createCellTileArtProject(); - [$editor, $map] = layeredCanvasEditor($root); + $map = loadLayeredMap($root); $dictionary = []; $collision = MapCollisionResolver::resolveLayers($map->getLayerSet(), $dictionary); $terminal = $map->renderPreview(4, 2, terminalPreview: true); @@ -397,14 +337,9 @@ function getCellTileArtRectangle(int $x = 16): array ->and(sourceHashTree($map->directory))->toBe($before); $map->setCellTileArt($id, 2, 0, getCellTileArtRectangle(32)); $map->save(); - callEditorMethod($editor, 'selectCanvasLayer', $id); - $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); - callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + 1, $bounds['top'])); - callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + 1, $bounds['top'])); - foreach (['i', ' ', "\033"] as $key) { callEditorMethod($editor, 'dispatchInput', $key); } - expect(getEditorProperty($editor, 'canvasPaintWarning'))->toContain('cell overrides stay at their coordinates') - ->and($map->getCellTileArt($id, 1, 0)['override'])->toBe(getCellTileArtRectangle()); - callEditorMethod($editor, 'dispatchInput', "\x13"); + $map->setLayerCell($id, 1, 0, ' '); + expect($map->getCellTileArt($id, 1, 0)['override'])->toBe(getCellTileArtRectangle()); + $map->save(); expect($map->renderPreview(4, 2, terminalPreview: true))->toBe($terminal) ->and(MapCollisionResolver::resolveLayers($map->getLayerSet(), $dictionary))->toBe($collision); $map->removeCellTileArt($id, 2, 0); @@ -412,13 +347,11 @@ function getCellTileArtRectangle(int $x = 16): array }); it('reuses validated crop definitions across every paint dab and invalidates them for data and layer changes', function () { - [$editor, $map] = layeredCanvasEditor(createCellTileArtProject()); + $map = loadLayeredMap(createCellTileArtProject()); $map->setCellTileArt('map:4', 0, 0, getCellTileArtRectangle(32)); - callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); $definitions = $map->getLayerTileDefinitions(); for ($dab = 0; $dab < 20; $dab++) { $map->setLayerCell('map:4', 0, 0, $dab % 2 === 0 ? 'x' : ' '); - callEditorMethod($editor, 'recordCanvasCropWarning', $map, 'x', ' ', 0, 0); expect($map->getLayerTileDefinitions()['buildings'])->toBe($definitions['buildings']); } $map->setCellTileArt('map:4', 0, 0, getCellTileArtRectangle()); diff --git a/tests/Unit/LayeredCanvasTest.php b/tests/Unit/LayeredCanvasTest.php index 9215421..d050b9c 100644 --- a/tests/Unit/LayeredCanvasTest.php +++ b/tests/Unit/LayeredCanvasTest.php @@ -4,26 +4,29 @@ use Ichiloto\Editor\Canvas\CanvasTool; use Ichiloto\Editor\Canvas\FacadeCatalogue; -use Ichiloto\Editor\MapSourceRefusal; +use Ichiloto\Editor\Field\NpcCollection; use Ichiloto\Editor\UI\Modal; -use Ichiloto\Engine\Events\Enumerations\CollisionType; -it('cycles every layer including events without stealing printable Paint-mode glyphs', function () { +it('cycles only gameplay and event layers without stealing printable Paint-mode glyphs', function () { [$editor, $map] = layeredCanvasEditor(); - foreach (['map:4', 'map:7', 'event', 'map:1'] as $id) { + foreach (['map:4', 'event', 'map:1'] as $id) { callEditorMethod($editor, 'dispatchInput', ']'); expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe($id); } - callEditorMethod($editor, 'selectCanvasLayer', 'map:7'); + foreach (['event', 'map:4', 'map:1'] as $id) { + callEditorMethod($editor, 'dispatchInput', '['); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe($id); + } + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); callEditorMethod($editor, 'dispatchInput', 'i'); foreach (['[', ']', 'v', 'd', 't', 'o'] as $glyph) { callEditorMethod($editor, 'dispatchInput', $glyph); - expect($map->getLayerSymbol('map:7', 0, 0))->toBe($glyph) - ->and(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:7'); + expect($map->getLayerSymbol('map:4', 0, 0))->toBe($glyph) + ->and(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:4'); } }); -it('uses colour selection clipboard shapes fill eyedropper and undo on every layer', function (string $id) { +it('uses colour selection clipboard shapes fill eyedropper and undo on gameplay and event layers', function (string $id) { [$editor, $map] = layeredCanvasEditor(); callEditorMethod($editor, 'selectCanvasLayer', $id); callEditorMethod($editor, 'applyCanvasWrites', $map, [ @@ -63,7 +66,7 @@ expect($map->getLayerSymbol($id, 3, 1))->toBe('F'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureGridSnapshot())->toBe($before); -})->with(['map:1', 'map:4', 'map:7', 'event']); +})->with(['map:1', 'map:4', 'event']); it('keeps mouse strokes layer-bound through undo and a later layer switch', function (string $id) { [$editor, $map] = layeredCanvasEditor(); @@ -83,36 +86,137 @@ expect($map->captureGridSnapshot())->toBe($before); callEditorMethod($editor, 'dispatchInput', "\x19"); expect($map->getLayerSymbol($id, 2, 1))->toBe('M'); -})->with(['map:1', 'map:4', 'map:7', 'event']); +})->with(['map:1', 'map:4', 'event']); -it('keeps visibility dimming and terminal preview out of authored state and blocks preview painting', function () { +it('renders readable gameplay immediately and keeps visibility and dimming session-only', function () { [$editor, $map] = layeredCanvasEditor(); $before = $map->captureLayerSnapshot(); + $frame = renderEditorPlainFrame($editor, 160, 45); + expect($frame)->toContain('./..', '.xx.') + ->not->toContain('d/..', '.xxd', 'Terminal preview', 't:Terminal'); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); callEditorMethod($editor, 'dispatchInput', 'v'); callEditorMethod($editor, 'dispatchInput', 'd'); - callEditorMethod($editor, 'dispatchInput', 't'); - callEditorMethod($editor, 'applyCanvasWrites', $map, [['x' => 0, 'y' => 0, 'symbol' => 'X']], 'preview'); - $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); - callEditorMethod($editor, 'enterPaintMode'); - callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'], $bounds['top'])); - expect($map->captureLayerSnapshot())->toBe($before)->and($map->isDirty())->toBeFalse(); - $frame = renderEditorPlainFrame($editor, 160, 45); - expect($frame)->toContain('Terminal preview', 'Ctrl+P:Palette'); - $footer = callEditorMethod($editor, 'createFooterWindow'); - expect($footer->help)->not->toContain('Layer', 'Colour', 'Terminal'); + expect(renderEditorPlainFrame($editor, 160, 45))->toContain('....') + ->and($map->captureLayerSnapshot())->toBe($before) + ->and($map->isDirty())->toBeFalse(); + callEditorMethod($editor, 'dispatchInput', 'v'); + expect(renderEditorPlainFrame($editor, 160, 45))->toContain('./..', '.xx.') + ->and($map->captureLayerSnapshot())->toBe($before); }); -it('shows crop tables read-only and keeps layer-specific painting warnings visible', function () { +it('edits terminal glyphs immediately through undo redo save and reload without changing graphical bytes', function (bool $invalidGraphics) { + $root = layeredMapProject(); + if ($invalidGraphics) { + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($path, preg_replace("/'width' => 16/", "'width' => 0", file_get_contents($path), 1)); + } + [$editor, $map] = layeredCanvasEditor($root); + $before = sourceHashTree($map->directory); + $graphicalSource = file_get_contents($map->dataPath); + $decorationSource = file_get_contents($map->directory . '/layers/07.detail.deco.php'); + foreach (['i', 'Z', "\033", "\x13"] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + expect($map->getLayerSymbol('map:1', 0, 0))->toBe('Z') + ->and(array_keys(array_diff_assoc(sourceHashTree($map->directory), $before)))->toBe(['layers/01.terrain.map.php']); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(sourceHashTree($map->directory))->toBe($before); + callEditorMethod($editor, 'dispatchInput', "\x19"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + callEditorMethod($editor, 'dispatchInput', "\x12"); + $reloaded = callEditorMethod($editor, 'getSelectedMap'); + expect($reloaded)->not->toBe($map) + ->and($reloaded->getLayerSymbol('map:1', 0, 0))->toBe('Z') + ->and(loadLayeredMap($root)->getLayerSymbol('map:1', 0, 0))->toBe('Z') + ->and(file_get_contents($reloaded->dataPath))->toBe($graphicalSource) + ->and(file_get_contents($reloaded->directory . '/layers/07.detail.deco.php'))->toBe($decorationSource) + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('Z/..'); +})->with(['valid graphical source' => false, 'invalid graphical source' => true]); + +it('does not expose a read-only terminal toggle or crop painting warnings', function () { [$editor, $map] = layeredCanvasEditor(); callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); - $fields = callEditorMethod($editor, 'getLayerInspectorFields'); - expect(array_column(array_filter($fields, static fn(array $field): bool => ($field['target'] ?? '') !== 'tile-art'), 'editable'))->each->toBeFalse() - ->and(json_encode($fields))->toContain('shared.png', '16'); - callEditorMethod($editor, 'applyCanvasWrites', $map, [['x' => 0, 'y' => 0, 'symbol' => 'x']], 'mapped'); - expect(getEditorProperty($editor, 'canvasPaintWarning'))->toContain('Crop mapping'); - callEditorMethod($editor, 'selectCanvasLayer', 'map:1'); - callEditorMethod($editor, 'applyCanvasWrites', $map, [['x' => 0, 'y' => 0, 'symbol' => 'x']], 'unmapped'); - expect(getEditorProperty($editor, 'canvasPaintWarning'))->toBe(''); + callEditorMethod($editor, 'dispatchInput', 't'); + foreach (['i', 'x', "\033"] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + expect($map->getLayerSymbol('map:4', 0, 0))->toBe('x') + ->and(renderEditorPlainFrame($editor, 160, 45))->not->toContain('Terminal preview', 'Crop mapping', 'cell overrides'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->getLayerSymbol('map:4', 0, 0))->toBe(' '); +}); + +it('excludes graphical layers and Tile art from the shell palette and inspector', function () { + [$editor, $map] = layeredCanvasEditor(); + $before = $map->captureLayerSnapshot(); + $labels = implode('\n', array_map(static fn($item): string => $item->label, callEditorMethod($editor, 'buildLayerPaletteItems'))); + expect($labels)->toContain('terrain', 'buildings', 'Events', 'Create gameplay layer') + ->not->toContain('detail', 'decoration', 'Tile art', 'Terminal preview'); + foreach (['map:1', 'map:4', 'event'] as $id) { + callEditorMethod($editor, 'selectCanvasLayer', $id); + $fields = json_encode(callEditorMethod($editor, 'getInspectorFields')); + expect($fields)->not->toContain('tile-art', 'Tile art', 'tiles2d', 'shared.png', 'detail.png'); + expect(json_encode(callEditorMethod($editor, 'getLayerInspectorFields')))->not->toContain('detail'); + } + callEditorMethod($editor, 'dispatchInput', "\x10"); + foreach (str_split('Tile art: Edit selected cell') as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(method_exists($editor, 'openCellTileArt'))->toBeFalse() + ->and(renderEditorPlainFrame($editor, 160, 45))->not->toContain('Layer atlas', 'Apply override') + ->and($map->captureLayerSnapshot())->toBe($before); +}); + +it('keeps NPC authoring and event overlays readable on the terminal canvas', function () { + [$editor, $map] = layeredCanvasEditor(); + $map->setNpcs(NpcCollection::fromMapData([ + ['id' => 'resident', 'name' => 'Resident', 'sprite' => 'N', 'x' => 0, 'y' => 0], + ])); + $before = $map->captureLayerSnapshot(); + callEditorMethod($editor, 'dispatchInput', 'n'); + expect(getEditorProperty($editor, 'editingMode'))->toBe('npc') + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('[N].', '.xx.', 'Name: Resident'); + callEditorMethod($editor, 'dispatchInput', "\033"); + callEditorMethod($editor, 'dispatchInput', 'e'); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('event') + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('./.E', '.xx.') + ->and($map->captureLayerSnapshot())->toBe($before); +}); + +it('refuses hidden decoration selection creation and stale graphical layer requests in the shell', function (string $action) { + [$editor, $map] = layeredCanvasEditor(); + $before = $map->captureLayerSnapshot(); + $disk = sourceHashTree($map->directory); + callEditorMethod($editor, 'selectCanvasLayer', 'map:7'); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:1'); + callEditorMethod($editor, 'openLayerPrompt', 'decoration'); + expect(getEditorProperty($editor, 'layerPrompt'))->toBeNull(); + setEditorProperty($editor, 'layerPrompt', ['action' => $action, 'id' => 'map:7', 'name' => 'hidden-art']); + getEditorProperty($editor, 'modals')->push(Modal::LAYER_EDIT); + callEditorMethod($editor, 'dispatchInput', $action === 'remove' ? 'y' : "\r"); + expect($map->captureLayerSnapshot())->toBe($before) + ->and(sourceHashTree($map->directory))->toBe($disk) + ->and($map->isDirty())->toBeFalse(); +})->with(['remove', 'rename', 'decoration']); + +it('ignores stale graphical selection and terminal read-only state when painting gameplay', function () { + [$editor, $map] = layeredCanvasEditor(); + $before = $map->captureLayerSnapshot(); + setEditorProperty($editor, 'canvasLayerStates', [$map->mapId => ['selected' => 'map:7', 'terminal' => true]]); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:1'); + foreach (['i', 'Z', "\033"] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + expect($map->getLayerSymbol('map:1', 0, 0))->toBe('Z') + ->and($map->getLayerSymbol('map:7', 0, 0))->toBe('d') + ->and($map->getEditableData())->toBe($before['data']) + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('Z/..') + ->not->toContain('Terminal preview'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureLayerSnapshot())->toBe($before); }); it('requires explicit collision-change confirmation before renaming and preserves local crop source bytes', function (bool $changes) { diff --git a/tests/Unit/LayeredMapTest.php b/tests/Unit/LayeredMapTest.php index 69e9eed..fbe2f2a 100644 --- a/tests/Unit/LayeredMapTest.php +++ b/tests/Unit/LayeredMapTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Ichiloto\Editor\MapSourceRefusal; +use Ichiloto\Editor\Field\NpcCollection; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Storage\FileSetTransactionFailure; @@ -17,12 +18,29 @@ ->and($map->getLayerSymbol('map:7', 0, 0))->toBe('d'); $terminal = array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, terminalPreview: true)); expect($terminal)->toBe(['./..', '.xx.']); - expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2)))->toBe(['d/.E', '.xxd']); - expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showEventOverlay: false, layerVisibility: ['map:7' => false])))->toBe($terminal); + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2)))->toBe(['./.E', '.xx.']); + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showEventOverlay: false)))->toBe($terminal); + foreach ([true, false] as $visible) { + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['map:7' => $visible])))->toBe(['./.E', '.xx.']); + } + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['map:4' => false])))->toBe(['...E', '....']) + ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['event' => false])))->toBe($terminal); expect(implode('', $map->renderPreview(4, 2, activeLayer: 'map:4', dimInactive: true)))->toContain("\033[2m") ->and(implode('', $map->renderPreview(4, 2, terminalPreview: true, layerVisibility: ['map:4' => false], dimInactive: true)))->not->toContain("\033[2m"); }); +it('keeps events and NPC overlays above terminal gameplay without exposing decoration', function () { + $map = loadLayeredMap(layeredMapProject()); + $map->setNpcs(NpcCollection::fromMapData([ + ['id' => 'resident', 'name' => 'Resident', 'sprite' => 'N', 'x' => 0, 'y' => 0], + ])); + $before = $map->captureLayerSnapshot(); + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showNpcOverlay: true)))->toBe(['N/.E', '.xx.']) + ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showEventOverlay: false, showNpcOverlay: true)))->toBe(['N/..', '.xx.']) + ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showNpcOverlay: true, layerVisibility: ['map:4' => false])))->toBe(['N..E', '....']) + ->and($map->captureLayerSnapshot())->toBe($before); +}); + it('does not write any untouched layer and preserves source scaffolding and unchanged colour-run rows', function () { $root = layeredMapProject(); $map = loadLayeredMap($root); @@ -163,6 +181,118 @@ ->and(sourceHashTree($map->directory))->toBe($before); }); +it('preserves unchanged invalid graphical source when persisting terminal edits', function (string $action, string $edit) { + $root = layeredMapProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $source = preg_replace("/'width' => 16/", "'width' => 0", file_get_contents($path), 1); + file_put_contents($path, $source); + $map = loadLayeredMap($root); + $decoration = file_get_contents($map->directory . '/layers/07.detail.deco.php'); + expect(fn() => $map->validateLayerContracts())->toThrow(InvalidArgumentException::class); + match ($edit) { + 'glyph' => $map->setLayerCell('map:1', 0, 0, 'Z'), + 'event' => $map->setLayerCell('event', 2, 0, 'Q'), + 'metadata' => $map->setMapField('name', 'Renamed Map'), + }; + $expectedSource = $edit === 'metadata' ? str_replace("'Test Map'", "'Renamed Map'", $source) : $source; + if ($action === 'save') { + $map->save(); + $loaded = loadLayeredMap($root); + } elseif ($action === 'duplicate') { + $map->duplicateTo($root . '/assets/Maps/copy', 'copy', $map->getDisplayName()); + $loaded = ProjectMap::fromDirectory($root . '/assets/Maps', $root . '/assets/Maps/copy'); + expect(file_get_contents($path))->toBe($source); + } else { + $loaded = $map->moveTo('district/moved'); + } + expect(file_get_contents($loaded->dataPath))->toBe($expectedSource) + ->and(file_get_contents($loaded->directory . '/layers/07.detail.deco.php'))->toBe($decoration) + ->and($loaded->getLayerSymbol('map:1', 0, 0))->toBe($edit === 'glyph' ? 'Z' : '.') + ->and($loaded->getLayerSymbol('event', 2, 0))->toBe($edit === 'event' ? 'Q' : ' ') + ->and($loaded->getDisplayName())->toBe($edit === 'metadata' ? 'Renamed Map' : 'Test Map') + ->and(fn() => $loaded->validateLayerContracts())->toThrow(InvalidArgumentException::class); +})->with(['save', 'duplicate', 'move'])->with(['glyph', 'event', 'metadata']); + +it('still refuses changed graphical inputs before saving invalid graphical definitions', function (string $edit) { + $root = layeredMapProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($path, preg_replace("/'width' => 16/", "'width' => 0", file_get_contents($path), 1)); + $map = loadLayeredMap($root); + $disk = sourceHashTree($root); + match ($edit) { + 'decoration' => $map->setLayerCell('map:7', 1, 0, 'd'), + 'geometry' => $map->resize(5, 3), + 'crop' => $map->setMapDataField(['tiles2d', 'layers', 'buildings', 'symbols', 'x', 'width'], -1), + }; + expect(fn() => $map->save())->toThrow(InvalidArgumentException::class) + ->and(sourceHashTree($root))->toBe($disk); +})->with(['decoration', 'geometry', 'crop']); + +it('keeps the graphical validation baseline sound across failed saves and restored terminal edits', function () { + $root = layeredMapProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $source = preg_replace("/'width' => 16/", "'width' => 0", file_get_contents($path), 1); + file_put_contents($path, $source); + $map = loadLayeredMap($root); + $before = $map->captureLayerSnapshot(); + $disk = sourceHashTree($root); + $map->setLayerCell('map:1', 0, 0, 'Z'); + $map->setMapField('name', 'Renamed Map'); + $edited = $map->captureLayerSnapshot(); + $failure = new FailingFileSetOperations(failures: ['move' => [$map->dataPath]]); + expect(fn() => $map->save(files: $failure))->toThrow(FileSetTransactionFailure::class) + ->and(sourceHashTree($root))->toBe($disk); + $map->save(); + $saved = sourceHashTree($root); + $map->restoreLayerSnapshot($before); + $map->save(); + expect(sourceHashTree($root))->toBe($disk); + $map->restoreLayerSnapshot($edited); + $map->save(); + expect(sourceHashTree($root))->toBe($saved) + ->and(fn() => $map->validateLayerContracts())->toThrow(InvalidArgumentException::class); +}); + +it('refuses invalid graphical data on a new unsaved map without installing any files', function () { + $root = layeredMapProject(); + $directory = $root . '/assets/Maps/new-map'; + $map = new ProjectMap( + mapId: 'new-map', directory: $directory, + dataPath: $directory . '/new-map.data.php', mapPath: $directory . '/new-map.map.php', + eventPath: $directory . '/new-map.event.php', + data: ['name' => 'New Map', 'tiles2d' => [ + 'asset' => 'Graphics/Tilesets/shared.png', + 'symbols' => ['x' => ['x' => 0, 'y' => 0, 'width' => 0, 'height' => 16]], + ]], tileLines: ['xx'], eventLines: [' '], + ); + $before = sourceHashTree($root); + expect(fn() => $map->save())->toThrow(InvalidArgumentException::class) + ->and(sourceHashTree($root))->toBe($before) + ->and(is_dir($directory))->toBeFalse(); +}); + +it('rejects restoring invalid graphical data after a valid graphical correction has been persisted', function () { + $root = layeredMapProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $validSource = file_get_contents($path); + file_put_contents($path, preg_replace("/'width' => 16/", "'width' => 0", $validSource, 1)); + $map = loadLayeredMap($root); + $invalid = $map->captureLayerSnapshot(); + $map->setMapDataField(['tiles2d', 'layers', 'buildings', 'symbols', 'x', 'width'], 16); + $valid = $map->captureLayerSnapshot(); + $map->save(); + expect(file_get_contents($path))->toBe($validSource); + $saved = sourceHashTree($root); + $map->restoreLayerSnapshot($invalid); + expect(fn() => $map->save())->toThrow(InvalidArgumentException::class) + ->and(sourceHashTree($root))->toBe($saved); + $map->restoreLayerSnapshot($valid); + $map->setLayerCell('map:1', 0, 0, 'Z'); + $map->save(); + expect(loadLayeredMap($root)->getLayerSymbol('map:1', 0, 0))->toBe('Z') + ->and(file_get_contents($path))->toBe($validSource); +}); + it('composes terminal preview with the exact shared Engine style and transparency semantics', function () { $root = layeredMapProject(); $path = $root . '/assets/Maps/test-map/layers/04.buildings.map.php'; diff --git a/tests/Unit/MixedMaterialAuthoringTest.php b/tests/Unit/MixedMaterialAuthoringTest.php index 39a791f..3f1019e 100644 --- a/tests/Unit/MixedMaterialAuthoringTest.php +++ b/tests/Unit/MixedMaterialAuthoringTest.php @@ -69,68 +69,48 @@ function getMixedMaterialCanvasRows(Editor $editor): array array_slice($window->content, ProjectWorkspace::CANVAS_HEADER_ROWS, 7)); } -it('authors mapped floor and wall decoration through real canvas commands without changing gameplay', function () { +it('preserves mixed graphical materials through model save restore and reload without changing terminal gameplay', function () { $root = createMixedMaterialProject(); - [$editor, $map] = layeredCanvasEditor($root); + $map = loadLayeredMap($root); $dictionary = require $root . '/assets/Maps/collisions.php'; $collision = MapCollisionResolver::resolveLayers($map->getLayerSet(), $dictionary); - $terminal = ['####i#####', '# #', '# #', '##########', '# #', '# #', '##########']; + $terminal = $map->renderPreview(10, 7); + $original = $map->captureLayerSnapshot(); $originalFiles = sourceHashTree($root . '/assets/Maps'); - $getCells = static fn(array $snapshot): array => array_map( - static fn(array $layer): array => $layer['grid']['cells'], $snapshot['layers']['layers'], - ); expect($collision[1][2])->toBe(CollisionType::NONE->value) ->and($collision[4][2])->toBe(CollisionType::NONE->value) ->and($collision[0][1])->toBe(CollisionType::SOLID->value) ->and($collision[3][5])->toBe(CollisionType::SOLID->value); - callEditorMethod($editor, 'dispatchInput', ']'); - expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:2'); - foreach ([[1, 1, 'w'], [2, 1, 'w'], [3, 1, 'w'], [7, 1, 'k'], [8, 1, 'k'], [1, 4, 's'], [2, 4, 's']] as [$x, $y, $glyph]) { - paintMixedMaterial($editor, $x, $y, $glyph); - expect($map->getLayerSymbol('map:2', $x, $y))->toBe($glyph); + foreach ([ + 'map:2' => [[1, 1, 'w'], [2, 1, 'w'], [3, 1, 'w'], [7, 1, 'k'], [8, 1, 'k'], [1, 4, 's'], [2, 4, 's']], + 'map:3' => [[2, 1, 'r'], [2, 4, 'c']], + 'map:5' => [[1, 0, 'w'], [5, 3, 'o']], + ] as $id => $cells) { + foreach ($cells as [$x, $y, $glyph]) { + $map->setLayerCell($id, $x, $y, $glyph); + } } - expect(getEditorProperty($editor, 'canvasPaintWarning'))->toContain('Crop mapping', 'read-only'); - callEditorMethod($editor, 'dispatchInput', ']'); - expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:3'); - paintMixedMaterial($editor, 2, 1, 'r'); - paintMixedMaterial($editor, 2, 4, 'c'); - callEditorMethod($editor, 'dispatchInput', ']'); // Solid gameplay walls, left untouched. - expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:4'); - callEditorMethod($editor, 'dispatchInput', ']'); - expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:5'); - paintMixedMaterial($editor, 1, 0, 'w'); // Cosmetic writing, not an interaction marker. - paintMixedMaterial($editor, 5, 3, 'o'); - $painted = $map->captureGridSnapshot(); - expect(getMixedMaterialCanvasRows($editor))->toBe([ - '#w##i#####', '#wrw kk#', '# #', '#####o####', '#sc #', '# #', '##########', - ]); - - // Undo stays bound to its layer even after selecting the interactive fixture layer. - callEditorMethod($editor, 'dispatchInput', ']'); - expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:6'); - callEditorMethod($editor, 'dispatchInput', "\x1a"); - expect($map->getLayerSymbol('map:5', 5, 3))->toBe(' ') - ->and($map->getLayerSymbol('map:6', 4, 0))->toBe('i'); - callEditorMethod($editor, 'dispatchInput', "\x19"); - expect($map->captureGridSnapshot())->toBe($painted); - callEditorMethod($editor, 'dispatchInput', "\x13"); - expect($map->isDirty())->toBeFalse(); + $painted = $map->captureLayerSnapshot(); + $map->save(); $saved = sourceHashTree($root . '/assets/Maps'); expect(array_keys(array_diff_assoc($saved, $originalFiles)))->toBe([ 'test-map/layers/02.floors.deco.php', 'test-map/layers/03.rugs.deco.php', 'test-map/layers/05.wall-detail.deco.php', ]); - callEditorMethod($editor, 'dispatchInput', "\x1a"); - callEditorMethod($editor, 'dispatchInput', "\x13"); - expect(loadLayeredMap($root)->getLayerSymbol('map:5', 5, 3))->toBe(' '); - callEditorMethod($editor, 'dispatchInput', "\x19"); - callEditorMethod($editor, 'dispatchInput', "\x13"); + $map->restoreLayerSnapshot($original); + $map->save(); + expect(sourceHashTree($root . '/assets/Maps'))->toBe($originalFiles); + $map->restoreLayerSnapshot($painted); + $map->save(); expect(sourceHashTree($root . '/assets/Maps'))->toBe($saved); - callEditorMethod($editor, 'dispatchInput', "\x12"); - $reloaded = callEditorMethod($editor, 'getSelectedMap'); - expect($reloaded)->not->toBe($map) - ->and($getCells($reloaded->captureGridSnapshot()))->toBe($getCells($painted)) - ->and(MapCollisionResolver::resolveLayers($reloaded->getLayerSet(), $dictionary))->toBe($collision); + $reloaded = loadLayeredMap($root); + expect($reloaded->getLayerSymbol('map:2', 7, 1))->toBe('k') + ->and($reloaded->getLayerSymbol('map:3', 2, 4))->toBe('c') + ->and($reloaded->getLayerSymbol('map:5', 5, 3))->toBe('o') + ->and($reloaded->renderPreview(10, 7))->toBe($terminal) + ->and(MapCollisionResolver::resolveLayers($reloaded->getLayerSet(), $dictionary))->toBe($collision) + ->and($reloaded->getEditableData()['npcs'][0]['dialogue'])->toBe([['text' => 'Read the notice.']]) + ->and($reloaded->getEditableData()['npcs'][0]['sprite'])->toBe(''); $rectangles = []; foreach (['map:2' => ['w', 'k', 's'], 'map:3' => ['r', 'c'], 'map:5' => ['w', 'o']] as $id => $glyphs) { @@ -141,47 +121,65 @@ function getMixedMaterialCanvasRows(Editor $editor): array } } expect(array_unique($rectangles))->toHaveCount(7); - // Terminal preview ignores the hidden gameplay layer as well as every cosmetic marker. - callEditorMethod($editor, 'dispatchInput', 'v'); - callEditorMethod($editor, 'dispatchInput', 'd'); - callEditorMethod($editor, 'dispatchInput', 't'); - expect(callEditorMethod($editor, 'createCanvasWindow')->title)->toContain('Terminal preview') - ->and(getMixedMaterialCanvasRows($editor))->toBe($terminal) - ->and($reloaded->getLayerSymbol('map:6', 4, 0))->toBe('i') - ->and($reloaded->getEditableData()['npcs'][0]['dialogue'])->toBe([['text' => 'Read the notice.']]) - ->and($reloaded->getEditableData()['npcs'][0]['sprite'])->toBe(''); - callEditorMethod($editor, 'dispatchInput', "\x13"); - expect(sourceHashTree($root . '/assets/Maps'))->toBe($saved); }); -it('keeps symbol defaults read-only alongside cell art authoring and refuses unmapped decoration through Ctrl-S', function () { +it('excludes material markers and crop controls from the TUI while terminal editing preserves their source', function () { $root = createMixedMaterialProject(); + $map = loadLayeredMap($root); + foreach (['map:2' => [1, 1, 'w'], 'map:3' => [2, 1, 'r'], 'map:5' => [1, 0, 'o']] as $id => [$x, $y, $glyph]) { + $map->setLayerCell($id, $x, $y, $glyph); + } + $map->save(); + $before = sourceHashTree($root . '/assets/Maps'); [$editor, $map] = layeredCanvasEditor($root); - $before = sourceHashTree($root); - callEditorMethod($editor, 'dispatchInput', ']'); - $fields = callEditorMethod($editor, 'getLayerInspectorFields'); - expect(array_column(array_filter($fields, static fn(array $field): bool => ($field['target'] ?? '') !== 'tile-art'), 'editable'))->each->toBeFalse(); - expect(array_values(array_filter($fields, static fn(array $field): bool => ($field['target'] ?? '') === 'tile-art'))[0]['editable'])->toBeTrue(); - callEditorMethod($editor, 'dispatchInput', "\t"); - $attempted = []; - foreach (callEditorMethod($editor, 'getInspectorFields') as $index => $field) { - if (! in_array($field['label'], ['tiles2d (read-only)', ' w', ' k', ' s'], true)) { - continue; - } - setEditorProperty($editor, 'selectedInspectorFieldIndex', $index); - callEditorMethod($editor, 'dispatchInput', "\r"); - expect(getEditorProperty($editor, 'isInspectorEditing'))->toBeFalse(); - $attempted[] = $field['label']; + $terminal = ['####i#####', '# #', '# #', '##########', '# #', '# #', '##########']; + expect(getMixedMaterialCanvasRows($editor))->toBe($terminal); + foreach (['map:4', 'map:6', 'event', 'map:1'] as $id) { + callEditorMethod($editor, 'dispatchInput', ']'); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe($id); } - expect($attempted)->toBe(['tiles2d (read-only)', ' w', ' k', ' s']); - callEditorMethod($editor, 'dispatchInput', "\033[Z"); - paintMixedMaterial($editor, 1, 1, 'z'); + foreach (['map:2', 'map:3', 'map:5'] as $id) { + callEditorMethod($editor, 'selectCanvasLayer', $id); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:1'); + } + $labels = implode("\n", array_map(static fn($item): string => $item->label, callEditorMethod($editor, 'buildLayerPaletteItems'))); + expect($labels)->not->toContain('floors', 'rugs', 'wall-detail', 'Tile art', 'Terminal preview') + ->and(json_encode(callEditorMethod($editor, 'getLayerInspectorFields')))->not->toContain('floors', 'rugs', 'wall-detail', 'tiles2d', 'tile-art'); + + paintMixedMaterial($editor, 2, 1, '#'); callEditorMethod($editor, 'dispatchInput', "\x13"); - expect(getEditorProperty($editor, 'statusMessage'))->toContain('has no crop mapping') - ->and($map->isDirty())->toBeTrue() - ->and(sourceHashTree($root))->toBe($before); + expect($map->getLayerSymbol('map:1', 2, 1))->toBe('#') + ->and(array_keys(array_diff_assoc(sourceHashTree($root . '/assets/Maps'), $before)))->toBe(['test-map/layers/01.terrain.map.php']); callEditorMethod($editor, 'dispatchInput', "\x1a"); callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(sourceHashTree($root . '/assets/Maps'))->toBe($before) + ->and(getMixedMaterialCanvasRows($editor))->toBe($terminal); + callEditorMethod($editor, 'dispatchInput', "\x19"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + callEditorMethod($editor, 'dispatchInput', "\x12"); + $loaded = callEditorMethod($editor, 'getSelectedMap'); + expect($loaded->getLayerSymbol('map:1', 2, 1))->toBe('#') + ->and($loaded->getLayerSymbol('map:2', 1, 1))->toBe('w') + ->and($loaded->getLayerSymbol('map:3', 2, 1))->toBe('r') + ->and($loaded->getLayerSymbol('map:5', 1, 0))->toBe('o'); + callEditorMethod($editor, 'selectCanvasLayer', 'map:6'); + callEditorMethod($editor, 'dispatchInput', 'v'); + expect(getMixedMaterialCanvasRows($editor)[0])->toBe('##########'); + callEditorMethod($editor, 'dispatchInput', 'v'); + expect(getMixedMaterialCanvasRows($editor)[0])->toBe('####i#####'); +}); + +it('retains strict model refusal of newly unmapped graphical material without exposing a TUI crop workflow', function () { + $root = createMixedMaterialProject(); + $map = loadLayeredMap($root); + $before = sourceHashTree($root); + $original = $map->captureLayerSnapshot(); + $map->setLayerCell('map:2', 1, 1, 'z'); + expect(fn() => $map->save())->toThrow(InvalidArgumentException::class, 'has no crop mapping') + ->and($map->isDirty())->toBeTrue() + ->and(sourceHashTree($root))->toBe($before); + $map->restoreLayerSnapshot($original); + $map->save(); expect($map->isDirty())->toBeFalse() ->and(sourceHashTree($root))->toBe($before); }); diff --git a/tests/Unit/NpcGraphicalAuthoringTest.php b/tests/Unit/NpcGraphicalAuthoringTest.php index 139c73e..c29e603 100644 --- a/tests/Unit/NpcGraphicalAuthoringTest.php +++ b/tests/Unit/NpcGraphicalAuthoringTest.php @@ -2,13 +2,10 @@ declare(strict_types=1); -use Ichiloto\Editor\Editor; use Ichiloto\Editor\Field\DirectionalSpriteDraft; -use Ichiloto\Editor\Field\NpcCollection; use Ichiloto\Editor\Field\ProjectNpc; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; -use Ichiloto\Editor\UI\Modal; use Ichiloto\Engine\Rendering\Sprites\DirectionalGraphicalSpriteSet; function createNpcArtProject(): string @@ -32,72 +29,25 @@ function getNpcArtPoses(): array return $data; } -function createNpcArtEditor(string $root): array -{ - $editor = createEditorForTesting($root); - $workspace = ProjectWorkspace::fromProject($root); - setEditorProperty($editor, 'workspace', $workspace); - setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]); - setEditorProperty($editor, 'isRunning', true); - callEditorMethod($editor, 'setEditingMode', 'npc'); - callEditorMethod($editor, 'selectNpc', 0); - setEditorProperty($editor, 'focusedPane', 'inspector'); - foreach (callEditorMethod($editor, 'getInspectorFields') as $index => $field) { - if (($field['field'] ?? '') === '__npc_art') { setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); } - } - callEditorMethod($editor, 'dispatchInput', "\r"); - expect(getEditorProperty($editor, 'modals')->has(Modal::NPC_ART))->toBeTrue(); - return [$editor, $workspace->getMapByIndex(0)]; -} - -function selectNpcArtField(Editor $editor, string $key): void -{ - $fields = callEditorMethod($editor, 'getNpcArtFields'); - $target = array_search($key, array_column($fields, 'field'), true); - expect($target)->not->toBeFalse(); - for ($i = 0; $i < count($fields) && getEditorProperty($editor, 'npcArtDialog')['selected'] !== $target; $i++) { - callEditorMethod($editor, 'dispatchInput', "\t"); - } -} - -it('authors four NPC roles through the inspector picker numeric fields apply save and source-exact undo', function () { +it('keeps graphical NPC model save restore reload and duplication source-preserving', function () { $root = createNpcArtProject(); - [$editor, $map] = createNpcArtEditor($root); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); $original = file_get_contents($map->dataPath); + $before = $map->captureLayerSnapshot(); $terminal = $map->renderPreview(20, 10); - callEditorMethod($editor, 'dispatchInput', 's'); - expect(getEditorProperty($editor, 'npcArtDialog')['error'])->toContain('direction') - ->and($map->isDirty())->toBeFalse(); - foreach (ProjectNpc::DIRECTIONS as $direction) { - selectNpcArtField($editor, 'direction'); - while (getEditorProperty($editor, 'npcArtDialog')['direction'] !== $direction) { callEditorMethod($editor, 'dispatchInput', "\r"); } - selectNpcArtField($editor, $direction . '.asset'); - callEditorMethod($editor, 'dispatchInput', "\r"); - expect(getEditorProperty($editor, 'referencePicker')->isOpen())->toBeTrue(); - foreach (str_split($direction . '.png') as $key) { callEditorMethod($editor, 'dispatchInput', $key); } - callEditorMethod($editor, 'dispatchInput', "\r"); - foreach (['width' => '16', 'height' => '24'] as $key => $value) { - selectNpcArtField($editor, $direction . '.' . $key); - callEditorMethod($editor, 'dispatchInput', "\r"); - foreach (str_split($value) as $symbol) { callEditorMethod($editor, 'dispatchInput', $symbol); } - callEditorMethod($editor, 'dispatchInput', "\r"); - } - } - expect($map->isDirty())->toBeFalse()->and(file_get_contents($map->dataPath))->toBe($original); - callEditorMethod($editor, 'dispatchInput', 's'); - expect(getEditorProperty($editor, 'npcArtDialog'))->toBeNull() - ->and($map->getNpcs()->get(0)->toArray()['sprites2d'])->toEqual(getNpcArtPoses()) + $map->setNpcGraphicalSprites(0, getNpcArtPoses()); + $after = $map->captureLayerSnapshot(); + expect($map->getNpcs()->get(0)->toArray()['sprites2d'])->toEqual(getNpcArtPoses()) ->and($map->getNpcs()->get(0)->getSprite())->toBe('') ->and($map->renderPreview(20, 10))->toBe($terminal); - callEditorMethod($editor, 'dispatchInput', "\x13"); + $map->save(); $saved = file_get_contents($map->dataPath); expect($saved)->toContain('/* resident */', '/* neighbor */', "strtoupper('unchanged')"); - callEditorMethod($editor, 'selectNpc', 1); - callEditorMethod($editor, 'dispatchInput', "\x1a"); - callEditorMethod($editor, 'dispatchInput', "\x13"); + $map->restoreLayerSnapshot($before); + $map->save(); expect(file_get_contents($map->dataPath))->toBe($original); - callEditorMethod($editor, 'dispatchInput', "\x19"); - callEditorMethod($editor, 'dispatchInput', "\x13"); + $map->restoreLayerSnapshot($after); + $map->save(); expect(file_get_contents($map->dataPath))->toBe($saved); $reloaded = ProjectMap::fromDirectory($root . '/assets/Maps', $map->directory); expect(DirectionalGraphicalSpriteSet::fromArray($reloaded->getNpcs()->get(0)->toArray()['sprites2d'])->south->height)->toBe(24); @@ -148,27 +98,41 @@ function (array $data) { $data['north']['sourceRect'] = ['x' => 63, 'y' => 0, 'w ->and(file_get_contents($path))->toBe($source); }); -it('cancels draft changes and confirms whole set removal with undo and stale NPC protection', function () { +it('removes graphical NPC authoring from the TUI while terminal sprite edits preserve graphical source', function () { $root = createNpcArtProject(); - [$editor, $map] = createNpcArtEditor($root); - callEditorMethod($editor, 'dispatchInput', "\033"); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); $map->setNpcGraphicalSprites(0, getNpcArtPoses()); $map->save(); - callEditorMethod($editor, 'openNpcSpriteArt'); - callEditorMethod($editor, 'dispatchInput', 'r'); - callEditorMethod($editor, 'dispatchInput', "\r"); - expect($map->getNpcs()->get(0)->toArray())->toHaveKey('sprites2d'); - callEditorMethod($editor, 'dispatchInput', 'y'); - expect($map->getNpcs()->get(0)->toArray())->not->toHaveKey('sprites2d'); + $source = file_get_contents($map->dataPath); + $assets = sourceHashTree($root . '/assets/Graphics'); + $editor = deletionEditor($root); + $map = callEditorMethod($editor, 'getSelectedMap'); + callEditorMethod($editor, 'setEditingMode', 'npc'); + callEditorMethod($editor, 'selectNpc', 0); + setEditorProperty($editor, 'focusedPane', 'inspector'); + $fields = callEditorMethod($editor, 'getInspectorFields'); + expect(json_encode($fields))->not->toContain('__npc_art', 'Graphical Sprites', 'sprites2d') + ->and(method_exists($editor, 'openNpcSpriteArt'))->toBeFalse() + ->and(renderEditorPlainFrame($editor, 160, 45))->not->toContain('Graphical Sprites'); + $index = array_find_key($fields, static fn(array $field): bool => ($field['field'] ?? null) === 'sprite'); + expect($index)->not->toBeNull(); + setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); + foreach (["\r", 'N', "\r", "\x13"] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + $saved = str_replace("'sprite' => ''", "'sprite' => 'N'", $source); + expect($map->getNpcs()->get(0)->getSprite())->toBe('N') + ->and(file_get_contents($map->dataPath))->toBe($saved); callEditorMethod($editor, 'dispatchInput', "\x1a"); - expect($map->getNpcs()->get(0)->toArray()['sprites2d'])->toBe(getNpcArtPoses()); - callEditorMethod($editor, 'openNpcSpriteArt'); - $entries = $map->getNpcs()->toMapData(); - $entries[0]['name'] = 'New user name'; - $map->setNpcs(NpcCollection::fromMapData($entries)); - callEditorMethod($editor, 'dispatchInput', 's'); - expect(getEditorProperty($editor, 'npcArtDialog')['error'])->toContain('captured map or NPC changed') - ->and($map->getNpcs()->get(0)->getName())->toBe('New user name'); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(file_get_contents($map->dataPath))->toBe($source); + foreach (["\x19", "\x13", "\x12"] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + $loaded = callEditorMethod($editor, 'getSelectedMap'); + expect(file_get_contents($loaded->dataPath))->toBe($saved) + ->and($loaded->getNpcs()->get(0)->toArray()['sprites2d'])->toBe(getNpcArtPoses()) + ->and(sourceHashTree($root . '/assets/Graphics'))->toBe($assets); }); it('preserves pending NPC art and all map files when the save transaction fails', function () { diff --git a/tests/__snapshots__/main_shell_100x30.ansi b/tests/__snapshots__/main_shell_100x30.ansi index 7a84534..acff787 100644 --- a/tests/__snapshots__/main_shell_100x30.ansi +++ b/tests/__snapshots__/main_shell_100x30.ansi @@ -1 +1 @@ -[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint []:Layer t:Terminal┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > terrain · visible ││ Events · visible ││ tiles2d (read-only) ││ (no atlas) ││ Tile art: selected cell: │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file +[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint []:Layer ?:Help────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > terrain · visible ││ Events · visible ││ ││ ││ │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file From 4ddb44aa2f31b5e8ccec726ba957c3c305af026d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 16:20:42 +0200 Subject: [PATCH 040/344] feat(npcs)!: validate NPC art as RPG Maker character sheets NPC sprites2d is validated exactly as the game reads it: a character sheet ({sheet, index, layer}) whose image divides into RPG Maker's layout, with a world layer. The source-preserving NPC art service keeps its refusals and round trips for the graphical editor to reuse. BREAKING CHANGE: removes DirectionalSpriteDraft and the per-direction sprite format it staged. --- src/Field/DirectionalSpriteDraft.php | 102 ----------------------- src/Field/NpcCharacterSheet.php | 28 +++++++ src/Field/NpcSpriteArt.php | 10 +-- src/Validation/ProjectValidator.php | 6 +- tests/Unit/NpcGraphicalAuthoringTest.php | 75 ++++++++--------- 5 files changed, 69 insertions(+), 152 deletions(-) delete mode 100644 src/Field/DirectionalSpriteDraft.php create mode 100644 src/Field/NpcCharacterSheet.php diff --git a/src/Field/DirectionalSpriteDraft.php b/src/Field/DirectionalSpriteDraft.php deleted file mode 100644 index e440739..0000000 --- a/src/Field/DirectionalSpriteDraft.php +++ /dev/null @@ -1,102 +0,0 @@ -record = new ProjectRecord($data ?? []); - } - - public function getData(): array - { - return (array) $this->record->toArray(); - } - - public function getMode(): string - { - return $this->record->get('mode') === 'sheet' ? 'sheet' : 'poses'; - } - - public function setMode(string $mode): void - { - if ($mode === $this->getMode()) { return; } - if (array_diff_key($this->getData(), ['mode' => true]) !== []) { - throw new RuntimeException('Cancel the draft or remove existing art before changing representation. Existing poses/sheets are never converted silently.'); - } - $this->record->set('mode', $mode === 'sheet' ? 'sheet' : null); - } - - /** Fields describe existing Engine roles and units; references are picker-only. */ - public function getFields(string $direction): array - { - $sheet = $this->getMode() === 'sheet'; - $prefix = $sheet ? 'directions.' . $direction : $direction; - $fields = [['field' => $prefix . '.asset', 'label' => ucfirst($direction) . ' PNG', 'reference' => 'png_assets']]; - foreach ($sheet ? ['columns', 'rows', 'frames'] : [] as $key) { - $fields[] = ['field' => $prefix . '.' . $key, 'label' => ucfirst($key), 'type' => InputControlType::INTEGER]; - } - $shared = $sheet ? '' : $direction . '.'; - foreach ($sheet ? ['width', 'height', 'frameWidth', 'frameHeight', 'idleFrame', 'frameDurationMs', 'stepDurationMs', 'layer'] : ['width', 'height', 'layer'] as $key) { - $fields[] = ['field' => $shared . $key, 'label' => ucfirst($key) . (in_array($key, ['width', 'height'], true) ? ' (logical px)' : ''), 'type' => InputControlType::INTEGER]; - } - $fields[] = ['field' => $shared . 'anchor', 'label' => 'Anchor', 'options' => array_column(PresentationSpriteAnchor::cases(), 'value')]; - if (! $sheet) { - foreach (['x', 'y', 'width', 'height'] as $key) { - $fields[] = ['field' => $direction . '.sourceRect.' . $key, 'label' => 'Optional crop ' . $key . ' (px)', 'type' => InputControlType::INTEGER]; - } - } - return array_map(fn(array $field): array => $field + ['value' => $this->record->getDisplayValue($field['field'])], $fields); - } - - public function setField(string $direction, string $key, string $value): void - { - foreach ($this->getFields($direction) as $field) { - if ($field['field'] !== $key) { continue; } - if (isset($field['options']) && ! in_array($value, $field['options'], true)) { - throw new RuntimeException('Choose a supported anchor.'); - } - $parsed = $value === '' ? null : $value; - if (($field['type'] ?? null) === InputControlType::INTEGER && $value !== '') { - $parsed = filter_var($value, FILTER_VALIDATE_INT); - if ($parsed === false) { throw new RuntimeException('Enter a supported whole number.'); } - } - $this->record->set($key, $parsed); - return; - } - throw new RuntimeException('The selected sprite field no longer exists.'); - } - - public function validate(?string $assetRoot = null): DirectionalGraphicalSpriteSet - { - $set = DirectionalGraphicalSpriteSet::fromArray($this->getData()); - foreach (ProjectNpc::DIRECTIONS as $direction) { - $definition = $set->$direction; - if ($definition->layer < PresentationLayerPolicy::WORLD || $definition->layer >= PresentationLayerPolicy::UI) { - throw new RuntimeException('Automatic Game world sprites require layers 0..999; UI layers are reserved.'); - } - if ($assetRoot !== null) { - $sheet = $definition->sheet; - $crop = $sheet === null ? $definition->sourceRect : new SpriteSourceRect(0, 0, - $sheet->frameWidth * $sheet->columns, $sheet->frameHeight * $sheet->rows); - PngAssetPreflight::inspect($assetRoot, $definition->asset, $crop); - } - } - return $set; - } -} diff --git a/src/Field/NpcCharacterSheet.php b/src/Field/NpcCharacterSheet.php new file mode 100644 index 0000000..0275454 --- /dev/null +++ b/src/Field/NpcCharacterSheet.php @@ -0,0 +1,28 @@ + $data */ + public static function validate(array $data, ?string $assetRoot = null): CharacterSheet + { + $sheet = CharacterSheet::fromArray($data); + if ($sheet->layer < PresentationLayerPolicy::WORLD || $sheet->layer >= PresentationLayerPolicy::UI) { + throw new RuntimeException('Automatic Game world sprites require layers 0..999; UI layers are reserved.'); + } + if ($assetRoot !== null) { + $image = PngAssetPreflight::inspect($assetRoot, $sheet->asset); + $sheet->getFrameSize($image['width'], $image['height']); + } + return $sheet; + } +} diff --git a/src/Field/NpcSpriteArt.php b/src/Field/NpcSpriteArt.php index 9e61347..adf732e 100644 --- a/src/Field/NpcSpriteArt.php +++ b/src/Field/NpcSpriteArt.php @@ -25,13 +25,9 @@ public function setNpcGraphicalSprites(int $index, ?array $data): void } if ($node !== null) { $this->assertNpcArtLiteral($node, true); } if ($data !== null) { - $set = (new DirectionalSpriteDraft($data))->validate(dirname($this->getMapsRoot())); - $assets = ReferenceCatalog::getPngAssets(dirname($this->getMapsRoot(), 2)); - foreach (ProjectNpc::DIRECTIONS as $direction) { - $definition = $set->$direction; - if (! in_array($definition->asset, $assets, true)) { - throw new MapSourceRefusal('Choose each directional PNG through the project asset picker.'); - } + $sheet = NpcCharacterSheet::validate($data, dirname($this->getMapsRoot())); + if (! in_array($sheet->asset, ReferenceCatalog::getPngAssets(dirname($this->getMapsRoot(), 2)), true)) { + throw new MapSourceRefusal('Choose the character sheet through the project asset picker.'); } } $entries = $this->getNpcs()->toMapData(); diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index f27121d..9113ced 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -1928,11 +1928,11 @@ protected function checkNpcs(ProjectMap $map, ProjectWorkspace $workspace): arra if (array_key_exists('sprites2d', $entry)) { try { - if (! is_array($entry['sprites2d'])) { throw new \RuntimeException('sprites2d must be a complete directional definition array.'); } - (new \Ichiloto\Editor\Field\DirectionalSpriteDraft($entry['sprites2d']))->validate($workspace->projectRoot . '/assets'); + if (! is_array($entry['sprites2d'])) { throw new \RuntimeException('sprites2d must be a character sheet definition array.'); } + \Ichiloto\Editor\Field\NpcCharacterSheet::validate($entry['sprites2d'], $workspace->projectRoot . '/assets'); } catch (\Throwable $error) { $issues[] = Issue::warning($where, 'Invalid sprites2d: ' . $error->getMessage(), - 'The game keeps terminal appearance and interaction. Choose Graphical Sprites in the NPC Inspector to repair literal art, or remove the optional set.'); + 'The game keeps the terminal appearance and interaction. Name an RPG Maker character sheet (sheet, optional index and layer), or remove the optional sprites2d.'); } } diff --git a/tests/Unit/NpcGraphicalAuthoringTest.php b/tests/Unit/NpcGraphicalAuthoringTest.php index c29e603..6cd6dea 100644 --- a/tests/Unit/NpcGraphicalAuthoringTest.php +++ b/tests/Unit/NpcGraphicalAuthoringTest.php @@ -2,31 +2,30 @@ declare(strict_types=1); -use Ichiloto\Editor\Field\DirectionalSpriteDraft; -use Ichiloto\Editor\Field\ProjectNpc; +use Ichiloto\Editor\Field\NpcCharacterSheet; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; -use Ichiloto\Engine\Rendering\Sprites\DirectionalGraphicalSpriteSet; function createNpcArtProject(): string { $root = makeTemporaryProject('npc-art-'); - mkdir($root . '/assets/Graphics/Npcs', 0777, true); - $chunk = static fn(string $kind, string $bytes): string => pack('N', strlen($bytes)) . $kind . $bytes . pack('N', crc32($kind . $bytes)); - $png = "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNC5', 64, 64, 8, 6, 0, 0, 0)) - . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xff\xff\xff\xff", 64), 64))) . $chunk('IEND', ''); - foreach (ProjectNpc::DIRECTIONS as $direction) { file_put_contents($root . '/assets/Graphics/Npcs/' . $direction . '.png', $png); } + mkdir($root . '/assets/Graphics/Characters', 0777, true); + // RPG Maker character sheets: 12 x 8 frames, or 3 x 4 for a `$` single character. + $png = static function (int $width, int $height): string { + $chunk = static fn(string $kind, string $bytes): string => pack('N', strlen($bytes)) . $kind . $bytes . pack('N', crc32($kind . $bytes)); + return "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNC5', $width, $height, 8, 6, 0, 0, 0)) + . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xff\xff\xff\xff", $width), $height))) . $chunk('IEND', ''); + }; + file_put_contents($root . '/assets/Graphics/Characters/People.png', $png(48, 32)); + file_put_contents($root . '/assets/Graphics/Characters/$Guard.png', $png(12, 16)); + file_put_contents($root . '/assets/Graphics/Characters/Uneven.png', $png(50, 50)); file_put_contents($root . '/assets/Maps/test-map/test-map.data.php', " 'Test Map', 'events' => [], 'npcs' => [\n /* resident */ ['id' => 'resident', 'name' => 'Resident', 'sprite' => '', 'x' => 1, 'y' => 1, 'dialogue' => [['text' => 'Hello']]],\n /* neighbor */ ['id' => 'neighbor', 'name' => 'Neighbor', 'x' => 2, 'y' => 1],\n], 'custom' => strtoupper('unchanged')];\n"); return $root; } function getNpcArtPoses(): array { - $data = []; - foreach (ProjectNpc::DIRECTIONS as $direction) { - $data[$direction] = ['asset' => 'Graphics/Npcs/' . $direction . '.png', 'width' => 16, 'height' => 24]; - } - return $data; + return ['sheet' => 'Graphics/Characters/People.png', 'index' => 2, 'layer' => 100]; } it('keeps graphical NPC model save restore reload and duplication source-preserving', function () { @@ -50,27 +49,23 @@ function getNpcArtPoses(): array $map->save(); expect(file_get_contents($map->dataPath))->toBe($saved); $reloaded = ProjectMap::fromDirectory($root . '/assets/Maps', $map->directory); - expect(DirectionalGraphicalSpriteSet::fromArray($reloaded->getNpcs()->get(0)->toArray()['sprites2d'])->south->height)->toBe(24); + expect(NpcCharacterSheet::validate($reloaded->getNpcs()->get(0)->toArray()['sprites2d'])->index)->toBe(2); $map->duplicateTo($root . '/assets/Maps/art-copy', 'art-copy', 'Art Copy'); expect((require $root . '/assets/Maps/art-copy/art-copy.data.php')['npcs'][0]['sprites2d'])->toEqual(getNpcArtPoses()); }); -it('preserves sheets and crop metadata while editing one role and refuses implicit representation conversion', function () { +it('changes one character selection in place and accepts single-character sheets', function () { $root = createNpcArtProject(); $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); - $data = ['mode' => 'sheet', 'frameWidth' => 16, 'frameHeight' => 16, 'width' => 16, 'height' => 24, - 'frameDurationMs' => 90, 'stepDurationMs' => 180, 'idleFrame' => 1, 'anchor' => 'bottom_center', 'layer' => 3, - 'directions' => array_map(static fn(array $pose): array => ['asset' => $pose['asset'], 'columns' => 4, 'rows' => 4, 'frames' => 12], getNpcArtPoses())]; - $map->setNpcGraphicalSprites(0, $data); + $map->setNpcGraphicalSprites(0, getNpcArtPoses()); + $map->save(); + $map->setNpcGraphicalSprites(0, ['sheet' => 'Graphics/Characters/People.png', 'index' => 5, 'layer' => 100]); $map->save(); - $draft = new DirectionalSpriteDraft($data); - expect(fn() => $draft->setMode('poses'))->toThrow(RuntimeException::class, 'never converted'); - $draft->setField('north', 'directions.north.asset', 'Graphics/Npcs/south.png'); - $map->setNpcGraphicalSprites(0, $draft->getData()); + expect((require $map->dataPath)['npcs'][0]['sprites2d']) + ->toBe(['sheet' => 'Graphics/Characters/People.png', 'index' => 5, 'layer' => 100]); + $map->setNpcGraphicalSprites(1, ['sheet' => 'Graphics/Characters/$Guard.png']); $map->save(); - $expected = $data; - $expected['directions']['north']['asset'] = 'Graphics/Npcs/south.png'; - expect((require $map->dataPath)['npcs'][0]['sprites2d'])->toBe($expected); + expect((require $map->dataPath)['npcs'][1]['sprites2d'])->toBe(['sheet' => 'Graphics/Characters/$Guard.png']); }); it('refuses incomplete unsafe invalid crop and reserved layer art before changing the map', function (Closure $change) { @@ -80,12 +75,13 @@ function getNpcArtPoses(): array ->and($map->captureLayerSnapshot())->toBe($before); })->with([ fn(array $data) => [], - function (array $data) { unset($data['north']); return $data; }, - function (array $data) { $data['north']['asset'] = '../outside.png'; return $data; }, - function (array $data) { $data['north']['asset'] = 'Graphics/Npcs/missing.png'; return $data; }, - function (array $data) { $data['north']['layer'] = 1000; return $data; }, - function (array $data) { $data['north']['width'] = 0; return $data; }, - function (array $data) { $data['north']['sourceRect'] = ['x' => 63, 'y' => 0, 'width' => 16, 'height' => 16]; return $data; }, + function (array $data) { unset($data['sheet']); return $data; }, + function (array $data) { $data['sheet'] = '../outside.png'; return $data; }, + function (array $data) { $data['sheet'] = 'Graphics/Characters/Missing.png'; return $data; }, + function (array $data) { $data['sheet'] = 'Graphics/Characters/Uneven.png'; return $data; }, + function (array $data) { $data['layer'] = 1000; return $data; }, + function (array $data) { $data['index'] = 8; return $data; }, + function (array $data) { $data['width'] = 48; return $data; }, ]); it('refuses opaque sprites without flattening them', function () { @@ -157,18 +153,17 @@ function (array $data) { $data['north']['sourceRect'] = ['x' => 63, 'y' => 0, 'w expect($warnings)->toHaveCount(1)->and($workspace->getMapByIndex(0)->getNpcs()->get(0)->getId())->toBe('resident'); })->with([[null], [[]], ['invalid']]); -it('patches one literal NPC role asset without rewriting crop comments or other directions', function () { +it('patches one literal character selection without rewriting its comments or other fields', function () { $root = createNpcArtProject(); $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); - $poses = getNpcArtPoses(); - $poses['north']['sourceRect'] = ['x' => 16, 'y' => 0, 'width' => 16, 'height' => 32]; - $map->setNpcGraphicalSprites(0, $poses); + $sheet = getNpcArtPoses(); + $map->setNpcGraphicalSprites(0, $sheet); $map->save(); - $source = str_replace("'sourceRect' =>", "/* retain crop */ 'sourceRect' =>", file_get_contents($map->dataPath)); + $source = str_replace("'index' =>", "/* chosen character */ 'index' =>", file_get_contents($map->dataPath)); file_put_contents($map->dataPath, $source); $map = ProjectMap::fromDirectory($root . '/assets/Maps', $map->directory); - $poses['north']['asset'] = 'Graphics/Npcs/south.png'; - $map->setNpcGraphicalSprites(0, $poses); + $sheet['index'] = 6; + $map->setNpcGraphicalSprites(0, $sheet); $map->save(); - expect(file_get_contents($map->dataPath))->toBe(str_replace('Graphics/Npcs/north.png', 'Graphics/Npcs/south.png', $source)); + expect(file_get_contents($map->dataPath))->toBe(str_replace("'index' => 2", "'index' => 6", $source)); }); From 6b0b0ac6de63d48d1a340600c2c1465df8dada26 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 18:55:17 +0200 Subject: [PATCH 041/344] refactor(maps)!: remove tiles2d authoring The Engine no longer reads glyph-keyed tile crops (tiles2d): they cannot address two-column map cells, and tilesets replace them. Remove the editor's authoring of that data: - Remove the TileArt cell crop override API (get/set/removeCellTileArt). - Remove ProjectMap's tile definition getters and fingerprints (getLayerTiles2d, getLayerTileDefinitions, the graphical-layer fingerprint baseline) and its tiles2d sync when layers are created, renamed or removed, including the legacy flat table migration. - Remove the tiles2d coordinate-list special case from ArraySourceWriter and the now unused PhpArraySourceDocument::wrapValueEdit. - Layer validation now checks only what the Engine loads: matching grids and collision resolution. Tests that only exercised crop tables, crop validation or the removed list special case are deleted with them. --- src/Cutscenes/Source/ArraySourceWriter.php | 35 -- .../Source/PhpArraySourceDocument.php | 15 - src/Maps/TileArt.php | 158 ------- src/ProjectMap.php | 160 +------ tests/Support/LayeredMapFixtures.php | 8 - tests/Unit/CellTileArtTest.php | 408 ------------------ .../Unit/Cutscenes/ArraySourceWriterTest.php | 109 ----- tests/Unit/LayerSourceEditsTest.php | 7 - tests/Unit/LayeredMapTest.php | 168 +------- tests/Unit/LayeredPreviewTest.php | 7 +- tests/Unit/MixedMaterialAuthoringTest.php | 33 -- 11 files changed, 12 insertions(+), 1096 deletions(-) delete mode 100644 src/Maps/TileArt.php delete mode 100644 tests/Unit/CellTileArtTest.php diff --git a/src/Cutscenes/Source/ArraySourceWriter.php b/src/Cutscenes/Source/ArraySourceWriter.php index c22f956..2ec7b72 100644 --- a/src/Cutscenes/Source/ArraySourceWriter.php +++ b/src/Cutscenes/Source/ArraySourceWriter.php @@ -216,9 +216,6 @@ private function diffArray(SourceNode $node, ?array $old, array $new, array $pat */ private function diffList(SourceNode $node, array $old, array $new, array $path): void { - if ($this->tryDiffCoordinateList($node, $old, $new, $path)) { - return; - } $oldCount = count($old); $newCount = count($new); $matchedOld = []; @@ -416,38 +413,6 @@ private function emitListMatches(SourceNode $node, array $old, array $new, array } } - /** Declared cell identities avoid a quadratic LCS table for large tile-art lists. */ - private function tryDiffCoordinateList(SourceNode $node, array $old, array $new, array $path): bool - { - // Row/column is identity only in the declared tile-art contract, not arbitrary user lists. - if ($path !== ['tiles2d', 'cells'] && ! (count($path) === 4 && $path[0] === 'tiles2d' - && $path[1] === 'layers' && is_string($path[2]) && $path[3] === 'cells')) { - return false; - } - $indices = []; - foreach ([$old, $new] as $side => $items) { - $indices[$side] = []; - foreach ($items as $index => $item) { - if (! is_array($item) || ! is_int($item['row'] ?? null) || ! is_int($item['column'] ?? null)) { - return false; - } - $key = $item['row'] . ':' . $item['column']; - if (isset($indices[$side][$key])) { return false; } - $indices[$side][$key] = $index; - } - } - $matchedOld = []; - $matchedNew = []; - foreach ($indices[1] as $key => $index) { - if (! isset($indices[0][$key])) { continue; } - $previous = $indices[0][$key]; - $matchedOld[$previous] = $index; - $matchedNew[$index] = $previous; - } - $this->emitListMatches($node, $old, $new, $path, $matchedOld, $matchedNew); - return true; - } - /** * Queues an entry for the end of an array. Appends flush together, one * composable edit per parent, so they never collide with each other or diff --git a/src/Cutscenes/Source/PhpArraySourceDocument.php b/src/Cutscenes/Source/PhpArraySourceDocument.php index be12d12..3b71621 100644 --- a/src/Cutscenes/Source/PhpArraySourceDocument.php +++ b/src/Cutscenes/Source/PhpArraySourceDocument.php @@ -397,21 +397,6 @@ public function replaceValueEdit(array $path, string $literal): array return [$entry->value->start, $entry->value->end, self::reindent($literal, $this->lineIndentBefore($entry->start) ?? '')]; } - /** Nests an authored value without regenerating its comments or expressions. */ - public function wrapValueEdit(array $path, array $keys): array - { - $entry = $this->entryAt($path); - if ($entry === null || $entry->keyIsOpaque) { - throw new SourcePreservationRefusal('The value cannot be wrapped at ' . self::describePath($path)); - } - $value = $entry->value; - $text = substr($this->source, $value->start, $value->end - $value->start); - foreach (array_reverse($keys) as $key) { - $text = '[' . var_export($key, true) . ' => ' . $text . ']'; - } - return [$value->start, $value->end, $text]; - } - /** Renames only a literal key; its value, comments and position survive. */ public function renameKeyEdit(array $path, string $newKey): array { diff --git a/src/Maps/TileArt.php b/src/Maps/TileArt.php deleted file mode 100644 index 44ccaf3..0000000 --- a/src/Maps/TileArt.php +++ /dev/null @@ -1,158 +0,0 @@ -getTileArtPath($layer, $column, $row); - $name = $this->layers->getLayer($layer)['name']; - $definition = $this->getLayerTileDefinitions()[$name] ?? null; - $glyph = $this->getLayerSymbol($layer, $column, $row); - $inherited = $definition?->symbols[$glyph] ?? null; - $override = $definition?->hasCellOverride($column, $row) ?? false; - $index = $override ? $definition->getSourceIndex($glyph, $column, $row) : null; - return ['layer' => $name, 'symbol' => $glyph, - 'asset' => $definition?->asset ?? ($this->getMapDataField($path)['asset'] ?? ($this->editableData['tiles2d']['asset'] ?? null)), - 'inherited' => $inherited === null ? null : $definition->sources[$inherited]->toArray(), - 'override' => $index === null ? null : $definition->sources[$index]->toArray()]; - } - - /** Applies one override without retargeting any inherited atlas unless explicitly confirmed. */ - public function setCellTileArt(string $layer, int $column, int $row, array $source, ?string $asset = null, bool $confirmAssetChange = false): void - { - $this->assertEditable(); - $path = $this->getTileArtPath($layer, $column, $row); - $current = $this->getCellTileArt($layer, $column, $row); - $asset ??= $current['asset']; - if ($asset === null || ! in_array($asset, ReferenceCatalog::getPngAssets(dirname($this->getMapsRoot(), 2)), true)) { - throw new MapSourceRefusal('Choose an existing PNG inside this project\'s assets through the asset picker.'); - } - if ($current['asset'] !== null && $current['asset'] !== $asset && ! $confirmAssetChange) { - throw new MapSourceRefusal('Changing this layer atlas affects every crop on the layer; explicit confirmation is required.'); - } - $definition = $this->getMapDataField($path) ?? []; - $cells = $definition['cells'] ?? []; - $index = count($cells); - foreach ($cells as $key => $cell) { - if ($cell['column'] === $column && $cell['row'] === $row) { - $index = $key; - break; - } - } - if (isset($cells[$index])) { - // Keep authored key order; source verification compares exact PHP values. - $cells[$index]['source'] = array_replace(array_intersect_key($cells[$index]['source'], $source), $source); - } else { - $cells[$index] = ['column' => $column, 'row' => $row, 'source' => $source]; - } - $definition['cells'] = $cells; - if ($asset !== $current['asset']) { - $this->assertTileArtSourceEditable([...$path, 'asset']); - $definition['asset'] = $asset; - } - $this->writeTileArtDefinition($path, $definition, inspectAsset: true); - } - - public function removeCellTileArt(string $layer, int $column, int $row): void - { - $this->assertEditable(); - $path = $this->getTileArtPath($layer, $column, $row); - if ($this->getCellTileArt($layer, $column, $row)['override'] === null) { - return; - } - $definition = $this->getMapDataField($path); - $definition['cells'] = array_values(array_filter($definition['cells'], - static fn(array $cell): bool => $cell['column'] !== $column || $cell['row'] !== $row)); - if ($definition['cells'] === []) { - unset($definition['cells']); - } - $this->writeTileArtDefinition($path, ($definition['symbols'] ?? []) === [] && ! isset($definition['cells']) ? null : $definition); - } - - private function getTileArtPath(string $layer, int $column, int $row): array - { - $target = $this->layers->getLayer($layer); - if ($target['id'] === MapLayers::EVENT || ! isset($target['grid']->cells[$row][$column])) { - throw new MapSourceRefusal('Tile art needs an existing gameplay or decoration cell, not Events or a position outside its row.'); - } - return $this->layers->legacy ? ['tiles2d'] : ['tiles2d', 'layers', $target['name']]; - } - - private function writeTileArtDefinition(array $path, ?array $definition, bool $inspectAsset = false): void - { - $this->assertTileArtSourceEditable([...$path, 'cells'], true); - if ($definition === null) { $this->assertTileArtSourceEditable($path, true); } - $next = $this->editableData; - if ($this->layers->legacy) { - if ($definition === null) { unset($next['tiles2d']); } - else { $next['tiles2d'] = $definition; } - } else { - $name = $path[2]; - if ($definition === null) { - unset($next['tiles2d']['layers'][$name]); - if ($next['tiles2d']['layers'] === []) { - $this->assertTileArtSourceEditable(['tiles2d'], true); - unset($next['tiles2d']); - } - } else { - $next['tiles2d']['layers'][$name] = $definition; - } - } - $set = $this->getLayerSet(); - $definitions = isset($next['tiles2d']) ? GraphicalTileDefinition::getForLayers($next['tiles2d'], $set, $this->mapId) : []; - if ($inspectAsset) { - $resolved = $definitions[$this->layers->legacy ? 'terrain' : $path[2]]; - foreach ($resolved->sources as $crop) { - PngAssetPreflight::inspect(dirname($this->getMapsRoot()), $resolved->asset, $crop); - } - } - // Coverage can be incomplete while authoring; ProjectMap::save checks all decoration together. - $this->writeData($next); - } - - /** Refuse opaque target containers even when the general writer could replace a variable. */ - private function assertTileArtSourceEditable(array $path, bool $recursive = false): void - { - if ($this->dataDocument === null) { - throw new MapSourceRefusal($this->dataSourceIssue ?? 'The map data source cannot be preserved.'); - } - $document = PhpArraySourceDocument::parse($this->proposedDataSource()); - $node = $document->root(); - $walked = []; - foreach ($path as $key) { - $this->assertTileArtSourceNode($node, $walked); - $walked[] = $key; - $node = $node->entryFor($key)?->value; - if ($node === null) { return; } - } - if (end($path) === 'cells' && $recursive && $node->kind === SourceNode::ARRAY && $node->entries !== [] && ! $node->isList) { - throw new MapSourceRefusal('Tile-art cells require implicit list entries so insertion and removal preserve source ownership.'); - } - $this->assertTileArtSourceNode($node, $path, $recursive); - } - - private function assertTileArtSourceNode(SourceNode $node, array $path, bool $recursive = false): void - { - if (in_array($node->kind, [SourceNode::VARIABLE, SourceNode::EXPRESSION], true) || $node->hasOpaqueKey) { - throw new MapSourceRefusal(sprintf('%s: tile art at %s requires literal data; opaque expressions, variables and keys are not rewritten.', - $this->mapId, PhpArraySourceDocument::describePath($path))); - } - if ($recursive) { - foreach ($node->entries as $index => $entry) { - $this->assertTileArtSourceNode($entry->value, [...$path, $entry->key ?? $index], true); - } - } - } -} diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 45a6856..9b907fb 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -21,7 +21,6 @@ use Ichiloto\Engine\Field\MapLayerSource; use Ichiloto\Engine\Field\MapLayerSet; use Ichiloto\Engine\Field\MapCollisionResolver; -use Ichiloto\Engine\Rendering\Tiles\GraphicalTileDefinition; use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\IO\Console\SgrStyleState; use Ichiloto\Editor\Maps\EditableGrid; @@ -36,13 +35,8 @@ final class ProjectMap { use \Ichiloto\Editor\Field\NpcSpriteArt; use TracksPersistedState; - use \Ichiloto\Editor\Maps\TileArt; private MapLayers $layers; - /** Invalidated by data/layer/dimension changes, not by painting individual glyphs. */ - private ?array $tileDefinitions = null; - private array $tileDefinitionGeometry = []; - private ?string $baselineGraphicalLayerFingerprint = null; /** * @var array @@ -79,7 +73,6 @@ final class ProjectMap */ private array $loadedData; private string $baselineDataSource; - private array $dataLayerNames = []; /** * The tile payload as of the last load or save, so a save can tell a @@ -132,8 +125,6 @@ public function __construct( */ private function adoptDataSource(string $source): void { - $this->tileDefinitions = null; - $this->dataLayerNames = array_column($this->getLayers(), 'name', 'id'); try { $this->dataDocument = PhpArraySourceDocument::parse($source); $this->dataSourceIssue = null; @@ -259,7 +250,6 @@ private function assertGridSourcesCanonical(): void */ private function withLoadedBaseline(): self { - $this->baselineGraphicalLayerFingerprint = $this->getGraphicalLayerFingerprint(); $this->captureBaseline(); return $this; @@ -418,16 +408,6 @@ public function setLayerCell(string $layer, int $x, int $y, string $symbol, stri public function createLayer(string $name, bool $decoration = false, ?int $order = null): string { $this->assertEditable(); - $data = $this->editableData; - $document = null; - if ($this->layers->legacy && isset($data['tiles2d'])) { - if ($this->dataDocument === null) { - $this->assertDataPreservable($data); - } - $current = PhpArraySourceDocument::parse($this->proposedDataSource()); - $document = $current->withEdits([$current->wrapValueEdit(['tiles2d'], ['layers', 'terrain'])]); - $data['tiles2d'] = ['layers' => ['terrain' => $data['tiles2d']]]; - } $before = $this->layers->captureSnapshot(); try { $id = $this->layers->createLayer($name, $decoration, $order); @@ -436,11 +416,6 @@ public function createLayer(string $name, bool $decoration = false, ?int $order $this->layers->restoreSnapshot($before); throw $error; } - if ($document !== null) { - $this->editableData = $this->loadedData = $data; - $this->adoptDataSource($document->source); - } - $this->tileDefinitions = null; $this->cachedWidth = null; $this->touchState(); return $id; @@ -449,86 +424,21 @@ public function createLayer(string $name, bool $decoration = false, ?int $order public function renameLayer(string $id, string $name, bool $confirmCollisionChange = false): void { $this->assertEditable(); - $oldName = $this->layers->getLayer($id)['name']; - $tables = $this->editableData['tiles2d']['layers'] ?? []; if (! $confirmCollisionChange && $this->getLayerRenameCollisionChange($id, $name) !== null) { throw new MapSourceRefusal('Renaming this layer changes resolved collisions. Explicit confirmation is required.'); } - $data = $this->editableData; - if (array_key_exists($oldName, $tables) && $oldName !== $name) { - $renamed = []; - foreach ($tables as $key => $value) { - $renamed[$key === $oldName ? $name : $key] = $value; - } - $data['tiles2d']['layers'] = $renamed; - } - $before = $this->layers->captureSnapshot(); - try { - $this->layers->renameLayer($id, $name); - $this->writeData($data); - } catch (\Throwable $error) { - $this->layers->restoreSnapshot($before); - throw $error; - } - $this->tileDefinitions = null; + $this->layers->renameLayer($id, $name); $this->touchState(); } public function removeLayer(string $id): void { $this->assertEditable(); - $name = $this->layers->getLayer($id)['name']; - $data = $this->editableData; - if (isset($data['tiles2d']['layers'][$name])) { - unset($data['tiles2d']['layers'][$name]); - if ($data['tiles2d']['layers'] === []) { - unset($data['tiles2d']); - } - $this->assertDataPreservable($data); - } $this->layers->removeLayer($id); - $this->writeData($data); - $this->tileDefinitions = null; $this->cachedWidth = null; $this->touchState(); } - public function getLayerTiles2d(string $id): array - { - if ($id === MapLayers::EVENT) { - return []; - } - $name = $this->layers->getLayer($id)['name']; - $definition = $this->getLayerTileDefinitions()[$name] ?? null; - if ($definition === null) { - return []; - } - $symbols = []; - foreach ($definition->symbols as $symbol => $index) { - $symbols[$symbol] = $definition->sources[$index]->toArray(); - } - return ['asset' => $definition->asset, 'symbols' => $symbols]; - } - - public function getLayerTileDefinitions(): array - { - // Retained MapLayers/EditableGrid objects may change outside our setters. - // Compare only row shapes and layer identity, never every crop on each paint dab. - $geometry = [$this->layers->legacy, array_map(static function (array $layer): array { - $rows = array_map(count(...), $layer['grid']->cells); - unset($layer['grid']); - return [$layer, $rows]; - }, $this->layers->getLayers())]; - if ($this->tileDefinitions !== null && $this->tileDefinitionGeometry === $geometry) { - return $this->tileDefinitions; - } - $definitions = isset($this->editableData['tiles2d']) - ? GraphicalTileDefinition::getForLayers($this->editableData['tiles2d'], $this->layers->getLayerSet(), $this->mapId) - : []; - $this->tileDefinitionGeometry = $geometry; - return $this->tileDefinitions = $definitions; - } - public function getLayerSet(): MapLayerSet { return $this->layers->getLayerSet(); @@ -540,13 +450,8 @@ public function getStoredGridPaths(): array return $this->layers->getStoredPaths(); } + /** Proves the layers compose and resolve collisions as the engine will load them. */ public function validateLayerContracts(): void - { - $this->validateGameplayLayerContracts(); - GraphicalTileDefinition::validateDecoration($this->layers->getLayerSet(), $this->getLayerTileDefinitions()); - } - - private function validateGameplayLayerContracts(): void { $this->assertGridsAgree(); $set = $this->layers->getLayerSet(); @@ -558,35 +463,6 @@ private function validateGameplayLayerContracts(): void MapCollisionResolver::resolveLayers($set, $dictionary); } - private function validateLayerPersistence(): void - { - // Unrelated terminal edits preserve existing art, even when that art - // needs repair. New maps and changed visual inputs still validate fully. - if ($this->baselineGraphicalLayerFingerprint === $this->getGraphicalLayerFingerprint()) { - $this->validateGameplayLayerContracts(); - } else { - $this->validateLayerContracts(); - } - } - - private function getGraphicalLayerFingerprint(): string - { - $layers = []; - foreach ($this->layers->getLayers() as $layer) { - if ($layer['id'] === MapLayers::EVENT) { - continue; - } - $layers[] = [$layer['id'], $layer['name'], $layer['order'], $layer['decoration'], - array_map(count(...), $layer['grid']->cells), - $layer['decoration'] ? $layer['grid']->cells : null]; - } - return PhpDataFile::valueFingerprint([ - array_intersect_key($this->editableData, ['tiles2d' => true]), - $this->layers->legacy, - $layers, - ]); - } - public function getLayerRenameCollisionChange(string $id, string $name): ?string { $dictionaryPath = $this->getMapsRoot() . '/collisions.php'; @@ -1021,7 +897,6 @@ public function restoreGridSnapshot(array $snapshot): void { $this->assertEditable(); $this->layers->restoreSnapshot($snapshot['layers']); - $this->tileDefinitions = null; $this->cachedWidth = null; $this->touchState(); } @@ -1166,7 +1041,6 @@ private function writeData(array $next): void $this->assertDataPreservable($next); $this->editableData = $next; - $this->tileDefinitions = null; $this->touchState(); } @@ -1188,7 +1062,7 @@ private function assertDataPreservable(array $next): void } try { - ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $next, $this->getDataKeyRenames($next)); + ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $next); } catch (SourcePreservationRefusal $refusal) { throw new MapSourceRefusal(sprintf( '%s: %s — %s Everything else in the file is untouched.', @@ -1199,22 +1073,6 @@ private function assertDataPreservable(array $next): void } } - private function getDataKeyRenames(array $next): array - { - $renames = []; - foreach ($this->getLayers() as $layer) { - $oldName = $this->dataLayerNames[$layer['id']] ?? $layer['name']; - $newName = $layer['name']; - if ($oldName === $newName) { - continue; - } - if (isset($this->loadedData['tiles2d']['layers'][$oldName], $next['tiles2d']['layers'][$newName])) { - $renames[] = ['path' => ['tiles2d', 'layers', $oldName], 'key' => $newName]; - } - } - return $renames; - } - /** * The data-file source this map's current content should be saved as. * @@ -1231,7 +1089,7 @@ private function proposedDataSource(): string } try { - return ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $this->editableData, $this->getDataKeyRenames($this->editableData))->source; + return ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $this->editableData)->source; } catch (SourcePreservationRefusal $refusal) { throw new MapSourceRefusal(sprintf( '%s: %s — %s', @@ -1504,7 +1362,6 @@ public function resize(int $width, int $height): void $this->layers->resize($width, $height); - $this->tileDefinitions = null; $this->cachedWidth = null; $this->touchState(); } @@ -1665,7 +1522,7 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) return $target['mapId']; } - $this->validateLayerPersistence(); + $this->validateLayerContracts(); // Which members actually changed. The tile and event files compare // against the grids as of the last save, never against the bytes on @@ -1764,7 +1621,6 @@ private function adoptWritten(string $dataSource, string $mapPayload, string $ev $this->baselineMapPayload = $mapPayload; $this->baselineEventPayload = $eventPayload; $this->layers->captureBaseline(); - $this->baselineGraphicalLayerFingerprint = $this->getGraphicalLayerFingerprint(); $this->captureBaseline(); } @@ -1899,7 +1755,7 @@ public function duplicateTo(string $directory, string $baseName, string $display $duplicatedData['name'] = $displayName; try { - $dataSource = ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $duplicatedData, $this->getDataKeyRenames($duplicatedData))->source; + $dataSource = ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $duplicatedData)->source; } catch (SourcePreservationRefusal $refusal) { throw new MapSourceRefusal(sprintf( '%s: %s — %s Nothing was duplicated.', @@ -1912,7 +1768,7 @@ public function duplicateTo(string $directory, string $baseName, string $display $transaction = new FileSetTransaction($directory, $files ?? new FilesystemFileSetOperations()); $duplicatedDataPath = $directory . DIRECTORY_SEPARATOR . $baseName . '.data.php'; $transaction->write($duplicatedDataPath, $dataSource); - $this->validateLayerPersistence(); + $this->validateLayerContracts(); $this->layers->stageChanges($transaction, $directory, $baseName); // The staged copy must evaluate to exactly the duplicate's content @@ -2109,7 +1965,7 @@ public function moveTo(string $newRelativeId): self $movedDataPath, $this->dataDocument === null ? (string) $this->unparsedDataSource : $this->proposedDataSource(), ); - $this->validateLayerPersistence(); + $this->validateLayerContracts(); $this->layers->stageChanges($transaction, $directory, $baseName, true); $transaction->remove($this->dataPath); $expectedGrids = $this->layers->getSources($directory, $baseName); diff --git a/tests/Support/LayeredMapFixtures.php b/tests/Support/LayeredMapFixtures.php index f98cf47..296c957 100644 --- a/tests/Support/LayeredMapFixtures.php +++ b/tests/Support/LayeredMapFixtures.php @@ -32,14 +32,6 @@ function layeredMapProject(bool $ragged = false): string // Authored metadata remains authored. return [ 'name' => 'Test Map', 'region' => '', 'events' => [], - 'tiles2d' => [ - 'asset' => 'Graphics/Tilesets/shared.png', - 'layers' => [ - // Facade crop table. - 'buildings' => ['symbols' => ['x' => ['x' => 0, 'y' => 0, 'width' => 16, 'height' => 16]]], - 'detail' => ['asset' => 'Graphics/Tilesets/detail.png', 'symbols' => ['d' => ['x' => 16, 'y' => 0, 'width' => 16, 'height' => 16]]], - ], - ], ]; SOURCE); return $root; diff --git a/tests/Unit/CellTileArtTest.php b/tests/Unit/CellTileArtTest.php deleted file mode 100644 index ef728e0..0000000 --- a/tests/Unit/CellTileArtTest.php +++ /dev/null @@ -1,408 +0,0 @@ - pack('N', strlen($bytes)) . $kind . $bytes . pack('N', crc32($kind . $bytes)); - $png = "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNC5', 64, 64, 8, 6, 0, 0, 0)) - . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xff\xff\xff\xff", 64), 64))) . $chunk('IEND', ''); - foreach (['shared', 'replacement', 'detail'] as $name) { - file_put_contents($root . '/assets/Graphics/Tilesets/' . $name . '.png', $png); - } - file_put_contents($root . '/assets/Graphics/Tilesets/not-art.txt', 'not art'); - if ($legacy) { - file_put_contents($root . '/assets/Maps/test-map/test-map.map.php', MapGridSource::buildSource("xx \nx ", 'TILES')); - file_put_contents($root . '/assets/Maps/test-map/test-map.event.php', MapGridSource::buildSource(" \n ", 'EVENTS')); - file_put_contents($root . '/assets/Maps/test-map/test-map.data.php', " 'Legacy', 'events' => []];\n"); - } - return $root; -} - -function getCellTileArtRectangle(int $x = 16): array -{ - return ['x' => $x, 'y' => 0, 'width' => 16, 'height' => 16]; -} - -it('sets and removes cell crops without changing inherited symbols other layers or authored grids', function () { - $root = createCellTileArtProject(); - $map = loadLayeredMap($root); - $before = $map->captureLayerSnapshot(); - $files = sourceHashTree($root . '/assets/Maps'); - $map->setCellTileArt('map:4', 1, 0, getCellTileArtRectangle()); - $map->setCellTileArt('map:4', 0, 1, getCellTileArtRectangle(32)); - $after = $map->captureLayerSnapshot(); - expect($map->getCellTileArt('map:4', 0, 1)['override'])->toBe(getCellTileArtRectangle(32)) - ->and($map->getCellTileArt('map:4', 0, 1)['inherited'])->toBe(getCellTileArtRectangle(0)) - ->and($map->getCellTileArt('map:4', 1, 1)['override'])->toBeNull() - ->and($map->getEditableData()['tiles2d']['layers']['detail'])->toBe($before['data']['tiles2d']['layers']['detail']) - ->and($map->getEditableData()['tiles2d']['layers']['buildings']['symbols'])->toBe($before['data']['tiles2d']['layers']['buildings']['symbols']) - ->and($map->captureGridSnapshot())->toBe($before['grid']); - $map->save(); - expect(array_keys(array_diff_assoc(sourceHashTree($root . '/assets/Maps'), $files)))->toBe(['test-map/test-map.data.php']); - $savedSource = file_get_contents($map->dataPath); - $map->restoreLayerSnapshot($before); - $map->save(); - expect(sourceHashTree($root . '/assets/Maps'))->toBe($files); - $map->restoreLayerSnapshot($after); - $map->save(); - expect(file_get_contents($map->dataPath))->toBe($savedSource); - $map = loadLayeredMap($root); - $map->removeCellTileArt('map:4', 0, 1); - expect($map->getCellTileArt('map:4', 0, 1)['override'])->toBeNull() - ->and($map->getCellTileArt('map:4', 0, 1)['inherited'])->toBe(getCellTileArtRectangle(0)) - ->and($map->getCellTileArt('map:4', 1, 0)['override'])->toBe(getCellTileArtRectangle()); - $map->removeCellTileArt('map:4', 1, 0); - $map->save(); - expect($map->getEditableData())->toBe($before['data']); -}); - -it('creates cells-only art on blank cells and prunes only the final empty definition', function (bool $legacy) { - $root = createCellTileArtProject($legacy); - $map = loadLayeredMap($root); - $id = $map->getBaseLayerId(); - $before = $map->captureLayerSnapshot(); - $map->setCellTileArt($id, 3, 0, getCellTileArtRectangle(), 'Graphics/Tilesets/shared.png'); - $map->save(); - $map = loadLayeredMap($root); - expect($map->getCellTileArt($id, 3, 0)['override'])->toBe(getCellTileArtRectangle()) - ->and($map->isLegacyMap())->toBe($legacy) - ->and($map->captureGridSnapshot())->toBe($before['grid']); - $map->removeCellTileArt($id, 3, 0); - $map->save(); - expect($map->getEditableData())->toBe($before['data']); -})->with([false, true]); - -it('refuses out-of-row coordinates events invalid rectangles and unpicked asset paths before mutation', function (string $id, int $x, int $y, array $rect, ?string $asset) { - $root = createCellTileArtProject(); - $map = loadLayeredMap($root); - $before = $map->captureLayerSnapshot(); - $files = sourceHashTree($root); - expect(fn() => $map->setCellTileArt($id, $x, $y, $rect, $asset))->toThrow(Exception::class) - ->and($map->captureLayerSnapshot())->toBe($before) - ->and(sourceHashTree($root))->toBe($files) - ->and($map->isDirty())->toBeFalse(); -})->with([ - ['map:4', 2, 1, getCellTileArtRectangle(), null], - ['map:4', -1, 0, getCellTileArtRectangle(), null], - ['event', 0, 0, getCellTileArtRectangle(), null], - ['map:4', 0, 0, ['x' => 0, 'y' => 0, 'width' => 0, 'height' => 16], null], - ['map:4', 0, 0, ['x' => -1, 'y' => 0, 'width' => 16, 'height' => 16], null], - ['map:4', 0, 0, ['x' => 60, 'y' => 0, 'width' => 16, 'height' => 16], null], - ['map:4', 0, 0, ['x' => 0, 'y' => 0, 'width' => 16, 'height' => 16, 'extra' => true], null], - ['map:4', 0, 0, ['x' => 0, 'y' => 0, 'width' => 16], null], - ['map:4', 0, 0, getCellTileArtRectangle(), '../outside.png'], - ['map:4', 0, 0, getCellTileArtRectangle(), 'Graphics/Tilesets/not-art.txt'], -]); - -it('refuses duplicate or out-of-bounds loaded overrides rather than silently normalizing them', function (bool $duplicate) { - $root = createCellTileArtProject(); - $path = $root . '/assets/Maps/test-map/test-map.data.php'; - $source = file_get_contents($path); - $cell = ['column' => $duplicate ? 0 : 2, 'row' => 1, 'source' => getCellTileArtRectangle()]; - $source = str_replace("'buildings' => [", "'buildings' => ['cells' => " . var_export($duplicate ? [$cell, $cell] : [$cell], true) . ', ', $source); - file_put_contents($path, $source); - $map = loadLayeredMap($root); - $before = $map->getEditableData(); - expect(fn() => $map->setCellTileArt('map:4', 0, 0, getCellTileArtRectangle()))->toThrow(InvalidArgumentException::class) - ->and($map->getEditableData())->toBe($before) - ->and(file_get_contents($path))->toBe($source) - ->and($map->isDirty())->toBeFalse(); -})->with([false, true]); - -it('refuses opaque tile-art containers but leaves unrelated executable data alone', function (string $shape) { - $root = createCellTileArtProject(true); - $path = $root . '/assets/Maps/test-map/test-map.data.php'; - $cell = "['column' => 0, 'row' => 0, 'source' => ['x' => 0, 'y' => 0, 'width' => 16, 'height' => 16]]"; - $literal = "['asset' => 'Graphics/Tilesets/shared.png', 'cells' => [$cell]]"; - $source = match ($shape) { - 'variable' => " \$art];", - 'expression' => " array_replace([], $literal)];", - 'cells' => " ['asset' => 'Graphics/Tilesets/shared.png', 'cells' => \$cells]];", - 'key' => " ['asset' => 'Graphics/Tilesets/shared.png', \$key => [$cell]]];", - 'explicit index' => " ['asset' => 'Graphics/Tilesets/shared.png', 'cells' => [0 => $cell]]];", - }; - file_put_contents($path, $source); - $map = loadLayeredMap($root); - $before = $map->captureLayerSnapshot(); - expect(fn() => $map->setCellTileArt('tile', 0, 0, getCellTileArtRectangle()))->toThrow(MapSourceRefusal::class) - ->and(fn() => $map->removeCellTileArt('tile', 0, 0))->toThrow(MapSourceRefusal::class) - ->and($map->captureLayerSnapshot())->toBe($before) - ->and(file_get_contents($path))->toBe($source); -})->with(['variable', 'expression', 'cells', 'key', 'explicit index']); - -it('edits a literal crop leaf surgically without rewriting symbol expressions or neighboring comments', function () { - $root = createCellTileArtProject(true); - $path = $root . '/assets/Maps/test-map/test-map.data.php'; - $source = <<<'PHP' - strtoupper('legacy'), 'tiles2d' => [ - 'asset' => 'Graphics/Tilesets/shared.png', - 'symbols' => array_replace([], ['x' => ['x' => 0, 'y' => 0, 'width' => 16, 'height' => 16]]), - 'cells' => [ - // Selected cell keeps this heading. - ['column' => 0, 'row' => 0, 'source' => ['x' => 8 /* crop start */, 'y' => 0, 'width' => 16, 'height' => 16]], - // Unrelated cell keeps its heading and spacing. - ['column'=>1, 'row'=>0, 'source'=>['x'=>0, 'y'=>0, 'width'=>16, 'height'=>16]], - ], -]]; -PHP; - file_put_contents($path, $source); - $map = loadLayeredMap($root); - $map->setCellTileArt('tile', 0, 0, getCellTileArtRectangle(32)); - $map->save(); - expect(file_get_contents($path))->toBe(str_replace('8 /* crop start */', '32 /* crop start */', $source)); -}); - -it('retains authored cell and rectangle key order for exact transaction verification and no-op edits', function () { - $root = createCellTileArtProject(true); - $path = $root . '/assets/Maps/test-map/test-map.data.php'; - $source = <<<'PHP' - ['asset' => 'Graphics/Tilesets/shared.png', 'cells' => [ - ['source' => ['width' => 16, 'height' => 16, 'y' => 0, 'x' => 16], 'row' => 0, 'column' => 0], -]]]; -PHP; - file_put_contents($path, $source); - $map = loadLayeredMap($root); - $before = $map->getEditableData(); - expect(fn() => $map->setCellTileArt('tile', 0, 0, ['x' => 0, 'y' => 0, 'width' => 16])) - ->toThrow(InvalidArgumentException::class, 'height'); - $map->setCellTileArt('tile', 0, 0, getCellTileArtRectangle()); - expect($map->getEditableData())->toBe($before)->and($map->isDirty())->toBeFalse(); - $map->setCellTileArt('tile', 0, 0, getCellTileArtRectangle(32)); - $map->save(); - expect(file_get_contents($path))->toBe(str_replace("'x' => 16", "'x' => 32", $source)); -}); - -it('preserves existing cell crops and graphical assets through terminal painting undo redo save and reload', function (bool $legacy) { - $root = createCellTileArtProject($legacy); - $map = loadLayeredMap($root); - $id = $legacy ? 'tile' : 'map:4'; - $map->setCellTileArt($id, 0, 0, getCellTileArtRectangle(32), 'Graphics/Tilesets/shared.png'); - $map->save(); - $source = file_get_contents($map->dataPath); - $assets = sourceHashTree($root . '/assets/Graphics'); - $layers = sourceHashTree($map->directory); - [$editor, $map] = layeredCanvasEditor($root); - callEditorMethod($editor, 'selectCanvasLayer', $id); - $oldSymbol = $map->getLayerSymbol($id, 0, 0); - foreach (['i', 'Z', "\033", "\x13"] as $key) { - callEditorMethod($editor, 'dispatchInput', $key); - } - expect($map->getLayerSymbol($id, 0, 0))->toBe('Z') - ->and(file_get_contents($map->dataPath))->toBe($source) - ->and(renderEditorPlainFrame($editor, 160, 45))->not->toContain('Crop mapping', 'cell overrides', 'Tile art'); - callEditorMethod($editor, 'dispatchInput', "\x1a"); - callEditorMethod($editor, 'dispatchInput', "\x13"); - expect($map->getLayerSymbol($id, 0, 0))->toBe($oldSymbol) - ->and(sourceHashTree($map->directory))->toBe($layers); - foreach (["\x19", "\x13", "\x12"] as $key) { - callEditorMethod($editor, 'dispatchInput', $key); - } - $loaded = callEditorMethod($editor, 'getSelectedMap'); - expect($loaded->getLayerSymbol($id, 0, 0))->toBe('Z') - ->and($loaded->getCellTileArt($id, 0, 0)['override'])->toBe(getCellTileArtRectangle(32)) - ->and(file_get_contents($loaded->dataPath))->toBe($source) - ->and(sourceHashTree($root . '/assets/Graphics'))->toBe($assets); - if (! $legacy) { - expect(sourceHashTree($loaded->directory)['layers/07.detail.deco.php'])->toBe($layers['layers/07.detail.deco.php']); - } -})->with([false, true]); - -it('offers only project-contained PNG assets in the shared resource catalogue', function () { - $root = createCellTileArtProject(); - copy($root . '/assets/Graphics/Tilesets/shared.png', $root . '/outside.png'); - symlink($root . '/outside.png', $root . '/assets/Graphics/Tilesets/outside.png'); - expect(ReferenceCatalog::getPngAssets($root))->toBe([ - 'Graphics/Tilesets/detail.png', 'Graphics/Tilesets/replacement.png', 'Graphics/Tilesets/shared.png', - ]); -}); - -it('requires atlas confirmation before mutation and rejects invalid PNGs or existing crops outside the replacement', function () { - $root = createCellTileArtProject(); - $map = loadLayeredMap($root); - $before = $map->captureLayerSnapshot(); - $asset = 'Graphics/Tilesets/replacement.png'; - expect(fn() => $map->setCellTileArt('map:4', 0, 1, getCellTileArtRectangle(), $asset)) - ->toThrow(MapSourceRefusal::class, 'explicit confirmation') - ->and($map->captureLayerSnapshot())->toBe($before); - file_put_contents($root . '/assets/' . $asset, 'not a PNG'); - expect(fn() => $map->setCellTileArt('map:4', 0, 1, getCellTileArtRectangle(), $asset, true)) - ->toThrow(RuntimeException::class, 'Invalid PNG header') - ->and($map->captureLayerSnapshot())->toBe($before); - // The new cell fits, but the inherited 16px symbol crop would not. - $png = file_get_contents($root . '/assets/Graphics/Tilesets/shared.png'); - file_put_contents($root . '/assets/' . $asset, substr_replace($png, pack('NN', 8, 8), 16, 8)); - expect(fn() => $map->setCellTileArt('map:4', 0, 1, ['x' => 0, 'y' => 0, 'width' => 1, 'height' => 1], $asset, true)) - ->toThrow(RuntimeException::class, 'crop exceed image bounds') - ->and($map->captureLayerSnapshot())->toBe($before); -}); - -it('keeps coordinate ownership through layer rename map duplication move removal and restoration', function () { - $root = createCellTileArtProject(); - $map = loadLayeredMap($root); - foreach (['map:4', 'map:7'] as $id) { $map->setCellTileArt($id, 1, 1, getCellTileArtRectangle(32)); } - $map->save(); - $before = $map->captureLayerSnapshot(); - $files = sourceHashTree($map->directory); - $map->renameLayer('map:4', 'furniture'); - $map->save(); - expect(file_get_contents($map->dataPath))->toBe(str_replace("'buildings' =>", "'furniture' =>", $before['source'])) - ->and($map->getCellTileArt('map:4', 1, 1)['override'])->toBe(getCellTileArtRectangle(32)); - $map->duplicateTo($root . '/assets/Maps/copy', 'copy', 'Copy'); - $copy = ProjectMap::fromDirectory($root . '/assets/Maps', $root . '/assets/Maps/copy'); - expect($copy->getEditableData()['tiles2d'])->toBe($map->getEditableData()['tiles2d']) - ->and(sourceHashTree($copy->directory . '/layers'))->toBe(sourceHashTree($map->directory . '/layers')); - $moved = $copy->moveTo('district/copy'); - expect($moved->getEditableData()['tiles2d'])->toBe($map->getEditableData()['tiles2d']) - ->and($moved->getCellTileArt('map:7', 1, 1)['override'])->toBe(getCellTileArtRectangle(32)); - $map->removeLayer('map:4'); - $map->save(); - expect($map->getEditableData()['tiles2d']['layers'])->not->toHaveKey('furniture') - ->and($map->getCellTileArt('map:7', 1, 1)['override'])->toBe(getCellTileArtRectangle(32)); - $map->restoreLayerSnapshot($before); - $map->save(); - expect(sourceHashTree($map->directory))->toBe($files); -}); - -it('leaves coordinates in place on resizing and revalidates cached bounds before saving', function (bool $legacy) { - $root = createCellTileArtProject($legacy); - $map = loadLayeredMap($root); - $id = $map->getBaseLayerId(); - $map->setCellTileArt($id, 3, 0, getCellTileArtRectangle(), 'Graphics/Tilesets/shared.png'); - $map->setCellTileArt($id, 1, 1, getCellTileArtRectangle(32)); - $map->save(); - $original = $map->captureLayerSnapshot(); - $files = sourceHashTree($map->directory); - $data = $map->getEditableData(); - $map->getLayerTileDefinitions(); - $map->resize(3, 2); - expect($map->getEditableData())->toBe($data) - ->and(fn() => $map->getLayerTileDefinitions())->toThrow(InvalidArgumentException::class, 'outside layer') - ->and(fn() => $map->save())->toThrow(InvalidArgumentException::class, 'outside layer') - ->and(sourceHashTree($map->directory))->toBe($files); - $map->restoreLayerSnapshot($original); - $map->getLayerTileDefinitions(); - $map->resize(4, 1); - expect(fn() => $map->save())->toThrow(InvalidArgumentException::class, 'outside layer'); - $map->restoreLayerSnapshot($original); - $map->resize(5, 3); - $map->save(); - expect($map->getEditableData())->toBe($data) - ->and($map->getCellTileArt($id, 3, 0)['override'])->toBe(getCellTileArtRectangle()) - ->and($map->getCellTileArt($id, 1, 1)['override'])->toBe(getCellTileArtRectangle(32)); - $map->restoreLayerSnapshot($original); - $map->save(); - expect(sourceHashTree($map->directory))->toBe($files) - ->and(array_map(count(...), $map->getLayerSet()->layers[0]->grid))->toBe([4, 2]); -})->with([false, true]); - -it('rolls back cell metadata and changed grids together when transactional installation fails', function () { - $root = createCellTileArtProject(); - $map = loadLayeredMap($root); - $before = sourceHashTree($map->directory); - $map->setCellTileArt('map:4', 0, 1, getCellTileArtRectangle(32)); - $map->setLayerCell('map:1', 0, 0, '#'); - $failure = new FailingFileSetOperations(failures: ['move' => [$map->dataPath]]); - expect(fn() => $map->save(files: $failure))->toThrow(FileSetTransactionFailure::class) - ->and(sourceHashTree($map->directory))->toBe($before) - ->and($map->isDirty())->toBeTrue(); - $map->save(); - expect(loadLayeredMap($root)->getCellTileArt('map:4', 0, 1)['override'])->toBe(getCellTileArtRectangle(32)); -}); - -it('authors coordinate-only decoration in stages while terminal preview and collision stay unchanged', function () { - $root = createCellTileArtProject(); - $map = loadLayeredMap($root); - $dictionary = []; - $collision = MapCollisionResolver::resolveLayers($map->getLayerSet(), $dictionary); - $terminal = $map->renderPreview(4, 2, terminalPreview: true); - $id = $map->createLayer('materials', true); - $map->setLayerCell($id, 1, 0, 'w'); - $map->setLayerCell($id, 2, 0, 'k'); - $map->setCellTileArt($id, 1, 0, getCellTileArtRectangle(), 'Graphics/Tilesets/shared.png'); - $before = sourceHashTree($map->directory); - expect(fn() => $map->save())->toThrow(InvalidArgumentException::class, 'no crop mapping') - ->and(sourceHashTree($map->directory))->toBe($before); - $map->setCellTileArt($id, 2, 0, getCellTileArtRectangle(32)); - $map->save(); - $map->setLayerCell($id, 1, 0, ' '); - expect($map->getCellTileArt($id, 1, 0)['override'])->toBe(getCellTileArtRectangle()); - $map->save(); - expect($map->renderPreview(4, 2, terminalPreview: true))->toBe($terminal) - ->and(MapCollisionResolver::resolveLayers($map->getLayerSet(), $dictionary))->toBe($collision); - $map->removeCellTileArt($id, 2, 0); - expect(fn() => $map->save())->toThrow(InvalidArgumentException::class, 'no crop mapping'); -}); - -it('reuses validated crop definitions across every paint dab and invalidates them for data and layer changes', function () { - $map = loadLayeredMap(createCellTileArtProject()); - $map->setCellTileArt('map:4', 0, 0, getCellTileArtRectangle(32)); - $definitions = $map->getLayerTileDefinitions(); - for ($dab = 0; $dab < 20; $dab++) { - $map->setLayerCell('map:4', 0, 0, $dab % 2 === 0 ? 'x' : ' '); - expect($map->getLayerTileDefinitions()['buildings'])->toBe($definitions['buildings']); - } - $map->setCellTileArt('map:4', 0, 0, getCellTileArtRectangle()); - expect($map->getLayerTileDefinitions()['buildings'])->not->toBe($definitions['buildings']); - $map->renameLayer('map:4', 'furniture'); - expect($map->getLayerTileDefinitions())->toHaveKey('furniture')->not->toHaveKey('buildings'); - $map->removeLayer('map:4'); - expect($map->getLayerTileDefinitions())->not->toHaveKey('furniture'); -}); - -it('detects retained layer and grid mutations without relying on ProjectMap setters', function () { - $root = createCellTileArtProject(); - $original = loadLayeredMap($root); - $original->setCellTileArt('map:4', 3, 0, getCellTileArtRectangle()); - $original->save(); - $layers = \Ichiloto\Editor\Maps\MapLayers::createFromSource($original->directory, $original->getLayerSet(), - $original->eventPath, MapGridSource::readFile($original->eventPath)); - $map = new ProjectMap($original->mapId, $original->directory, $original->dataPath, $original->mapPath, - $original->eventPath, $original->getEditableData(), $original->tileLines, $original->eventLines, - file_get_contents($original->dataPath), layers: $layers); - $before = $layers->captureSnapshot(); - $map->getLayerTileDefinitions(); - $retained = $layers->getGrid('map:4'); - array_pop($retained->cells[0]); - expect(fn() => $map->getLayerTileDefinitions())->toThrow(InvalidArgumentException::class); - $layers->restoreSnapshot($before); - $map->getLayerTileDefinitions(); - $layers->resize(3, 2); - expect(fn() => $map->getLayerTileDefinitions())->toThrow(InvalidArgumentException::class, 'outside layer'); - $layers->restoreSnapshot($before); - $map->getLayerTileDefinitions(); - $layers->renameLayer('map:4', 'furniture'); - expect(fn() => $map->getLayerTileDefinitions())->toThrow(InvalidArgumentException::class, 'unknown or malformed layer'); -}); - -it('refuses pruning the final layer definition when it would remove an opaque shared atlas expression', function () { - $root = createCellTileArtProject(); - $path = $root . '/assets/Maps/test-map/test-map.data.php'; - $source = <<<'PHP' - [ - 'asset' => strval('Graphics/Tilesets/shared.png'), - 'layers' => ['buildings' => ['cells' => [ - ['column' => 0, 'row' => 0, 'source' => ['x' => 0, 'y' => 0, 'width' => 16, 'height' => 16]], - ]]], -]]; -PHP; - file_put_contents($path, $source); - $map = loadLayeredMap($root); - $before = $map->captureLayerSnapshot(); - expect(fn() => $map->removeCellTileArt('map:4', 0, 0))->toThrow(MapSourceRefusal::class, 'opaque') - ->and($map->captureLayerSnapshot())->toBe($before) - ->and(file_get_contents($path))->toBe($source); -}); diff --git a/tests/Unit/Cutscenes/ArraySourceWriterTest.php b/tests/Unit/Cutscenes/ArraySourceWriterTest.php index 8061981..4324766 100644 --- a/tests/Unit/Cutscenes/ArraySourceWriterTest.php +++ b/tests/Unit/Cutscenes/ArraySourceWriterTest.php @@ -299,62 +299,6 @@ function rewriteAuthored(callable $change): string expect(ArraySourceWriter::rewrite($document, $old, $new)->source)->toBe(str_replace("'plain' => 1", "'plain' => 2", $source)); }); -it('preserves cell-list comment ownership across insert remove and edited reorder operations', function (bool $layered, bool $inline) { - $body = <<<'PHP' - // First cell owns this heading. - ['column'=>23, 'row'=>1, 'source'=>['x'=>0, 'y'=>0, 'width'=>16, 'height'=>16]], // first - // Second cell owns this heading. - ['column'=>3, 'row'=>12, 'source'=>['x'=>0, 'y'=>0, 'width'=>16, 'height'=>16]], // second - // Third cell owns this heading. - ['column'=>5, 'row'=>3, 'source'=>['x'=>0, 'y'=>0, 'width'=>16, 'height'=>16]], // third -PHP; - if ($inline) { - $body = preg_replace('/\/\/[^\n]*/', '', $body); - $body = str_replace("\n", '', $body); - } - $source = " " . ($layered ? "['layers'=>['furniture'=>" : '') - . "['cells'=>[\n$body\n]]" . ($layered ? ']]' : '') . "];\n"; - $path = $layered ? ['tiles2d', 'layers', 'furniture', 'cells'] : ['tiles2d', 'cells']; - foreach (['edit', 'insert-remove', 'reorder', 'replace-all'] as $operation) { - $old = evaluateSource($source); - $new = $old; - $cells = &$new; - foreach ($path as $step) { $cells = &$cells[$step]; } - if ($operation === 'edit') { - $cells[1]['source']['x'] = 32; - } elseif ($operation === 'insert-remove') { - $insert = $cells[1]; - $insert['column'] = 4; - $other = $insert; - $other['column'] = 6; - $cells = [$insert, $cells[1], $other, $cells[2], $insert + []]; - $cells[4]['column'] = 7; - } elseif ($operation === 'reorder') { - $cells = [$cells[2], $cells[0], $cells[1]]; - $cells[0]['source']['x'] = 32; - } else { - $cells = [array_replace($cells[0], ['row' => 99])]; - } - unset($cells); - $rewritten = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source; - expect(evaluateSource($rewritten))->toBe($new); - if ($operation === 'edit') { - expect($rewritten)->toBe(str_replace("'column'=>3, 'row'=>12, 'source'=>['x'=>0", "'column'=>3, 'row'=>12, 'source'=>['x'=>32", $source)); - } - if (! $inline) { - if ($operation === 'insert-remove') { - expect($rewritten)->not->toContain('First cell owns', '// first') - ->and($rewritten)->toContain("// Second cell owns this heading.\n ['column'=>3", "// Third cell owns this heading.\n ['column'=>5"); - } elseif ($operation === 'reorder') { - expect($rewritten)->toContain("// Third cell owns this heading.\n ['column'=>5, 'row'=>3, 'source'=>['x'=>32") - ->and(strpos($rewritten, 'Third cell owns'))->toBeLessThan(strpos($rewritten, 'First cell owns')); - } elseif ($operation === 'replace-all') { - expect($rewritten)->not->toContain('First cell owns', 'Second cell owns', 'Third cell owns'); - } - } - } -})->with([[false, false], [true, false], [false, true], [true, true]]); - it('retains existing non-tile list identity and comment rules without treating arbitrary coordinates as ids', function () { $source = <<<'PHP' and($rewritten)->toBe(str_replace(" // Removed entry owns this heading.\n ['id'=>'first'],", '', $source)); })->with(['npcs', 'tracks', 'cues']); -it('refuses opaque changed leaves inside coordinate lists without regenerating their expressions', function () { - $source = "['cells'=>[['column'=>0,'row'=>0,'source'=>['x'=>abs(0),'y'=>0,'width'=>16,'height'=>16]]]]];"; - $old = evaluateSource($source); - $new = $old; - $new['tiles2d']['cells'][0]['source']['x'] = 16; - expect(fn() => ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)) - ->toThrow(SourcePreservationRefusal::class, 'expression'); -}); - -it('patches a large ordered cell list without a quadratic alignment table within 128 MiB', function () { - $probe = <<<'PHP' - require $argv[1]; - $rows = []; - $cells = []; - for ($index = 0; $index < 3000; $index++) { - $rows[] = " ['column'=>$index, 'row'=>0, 'source'=>['x'=>0, 'y'=>0, 'width'=>16, 'height'=>16]], // cell $index"; - $cells[] = ['column'=>$index, 'row'=>0, 'source'=>['x'=>0, 'y'=>0, 'width'=>16, 'height'=>16]]; - } - $source = "['cells'=>[\n" . implode("\n", $rows) . "\n]]];\n"; - $old = ['tiles2d'=>['cells'=>$cells]]; - $new = $old; - $expected = $source; - foreach ([0, 1500, 2999] as $index) { - $new['tiles2d']['cells'][$index]['source']['x'] = 32; - $expected = str_replace("'column'=>$index, 'row'=>0, 'source'=>['x'=>0", "'column'=>$index, 'row'=>0, 'source'=>['x'=>32", $expected); - } - $document = \Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument::parse($source); - $edited = \Ichiloto\Editor\Cutscenes\Source\ArraySourceWriter::rewrite($document, $old, $new); - $exact = $edited->source === $expected; - unset($document); - $changed = $new; - array_splice($changed['tiles2d']['cells'], 1500, 1); - $changed['tiles2d']['cells'][] = ['column'=>3000, 'row'=>0, 'source'=>['x'=>16, 'y'=>0, 'width'=>16, 'height'=>16]]; - $updated = \Ichiloto\Editor\Cutscenes\Source\ArraySourceWriter::rewrite($edited, $new, $changed); - $evaluated = eval(substr($updated->source, 5)); - echo json_encode(['exact'=>$exact, 'values'=>$evaluated === $changed, - 'kept'=>str_contains($updated->source, '// cell 2999'), 'removed'=>!str_contains($updated->source, '// cell 1500'), - 'limit'=>ini_get('memory_limit'), 'peak'=>memory_get_peak_usage(true)], JSON_THROW_ON_ERROR); - PHP; - $process = proc_open([PHP_BINARY, '-d', 'memory_limit=128M', '-r', $probe, dirname(__DIR__, 2) . '/bootstrap.php'], - [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); - expect(is_resource($process))->toBeTrue(); - fclose($pipes[0]); - $output = stream_get_contents($pipes[1]); - $error = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - expect(proc_close($process))->toBe(0, $error)->and($error)->toBe(''); - $result = json_decode($output, true, flags: JSON_THROW_ON_ERROR); - expect($result)->toMatchArray(['exact' => true, 'values' => true, 'kept' => true, 'removed' => true, 'limit' => '128M']) - ->and($result['peak'])->toBeLessThan(128 * 1024 * 1024); -}); - it('rewrites the real Last Legend summon timelines to themselves and back from any change', function () { $game = gameSourceRoot(); diff --git a/tests/Unit/LayerSourceEditsTest.php b/tests/Unit/LayerSourceEditsTest.php index e025a2f..fbf2cc6 100644 --- a/tests/Unit/LayerSourceEditsTest.php +++ b/tests/Unit/LayerSourceEditsTest.php @@ -24,10 +24,3 @@ " ['buildings' => [], 'houses' => []]];", " ['buildings' => [], strtolower('OTHER') => []]];", ]); - -it('wraps a legacy table expression without evaluating or flattening it', function () { - $source = " require __DIR__ . '/crops.php'];"; - $document = PhpArraySourceDocument::parse($source); - expect($document->withEdits([$document->wrapValueEdit(['tiles2d'], ['layers', 'terrain'])])->source) - ->toBe(" ['layers' => ['terrain' => require __DIR__ . '/crops.php']]];"); -}); diff --git a/tests/Unit/LayeredMapTest.php b/tests/Unit/LayeredMapTest.php index fbe2f2a..200a8e9 100644 --- a/tests/Unit/LayeredMapTest.php +++ b/tests/Unit/LayeredMapTest.php @@ -98,8 +98,7 @@ ->and($map->isDirty())->toBeTrue(); $map->save(); expect(is_file($map->directory . '/layers/04.houses.map.php'))->toBeTrue() - ->and(is_file($map->directory . '/layers/04.buildings.map.php'))->toBeFalse() - ->and($map->getEditableData()['tiles2d']['layers'])->toHaveKey('houses'); + ->and(is_file($map->directory . '/layers/04.buildings.map.php'))->toBeFalse(); }); it('duplicates and moves every layer preserving authored bytes and numeric order', function () { @@ -170,129 +169,6 @@ ->and(authoredMap($root)->isLegacyMap())->toBeTrue(); }); -it('rejects decoration glyphs without crops and reports normalized layer-specific crop tables', function () { - $map = loadLayeredMap(layeredMapProject()); - expect($map->getLayerTiles2d('map:4')['asset'])->toBe('Graphics/Tilesets/shared.png') - ->and($map->getLayerTiles2d('map:7')['asset'])->toBe('Graphics/Tilesets/detail.png') - ->and($map->getLayerTiles2d('map:1'))->toBe([]); - $before = sourceHashTree($map->directory); - $map->setLayerCell('map:7', 2, 0, '?'); - expect(fn() => $map->save())->toThrow(InvalidArgumentException::class, 'no crop mapping') - ->and(sourceHashTree($map->directory))->toBe($before); -}); - -it('preserves unchanged invalid graphical source when persisting terminal edits', function (string $action, string $edit) { - $root = layeredMapProject(); - $path = $root . '/assets/Maps/test-map/test-map.data.php'; - $source = preg_replace("/'width' => 16/", "'width' => 0", file_get_contents($path), 1); - file_put_contents($path, $source); - $map = loadLayeredMap($root); - $decoration = file_get_contents($map->directory . '/layers/07.detail.deco.php'); - expect(fn() => $map->validateLayerContracts())->toThrow(InvalidArgumentException::class); - match ($edit) { - 'glyph' => $map->setLayerCell('map:1', 0, 0, 'Z'), - 'event' => $map->setLayerCell('event', 2, 0, 'Q'), - 'metadata' => $map->setMapField('name', 'Renamed Map'), - }; - $expectedSource = $edit === 'metadata' ? str_replace("'Test Map'", "'Renamed Map'", $source) : $source; - if ($action === 'save') { - $map->save(); - $loaded = loadLayeredMap($root); - } elseif ($action === 'duplicate') { - $map->duplicateTo($root . '/assets/Maps/copy', 'copy', $map->getDisplayName()); - $loaded = ProjectMap::fromDirectory($root . '/assets/Maps', $root . '/assets/Maps/copy'); - expect(file_get_contents($path))->toBe($source); - } else { - $loaded = $map->moveTo('district/moved'); - } - expect(file_get_contents($loaded->dataPath))->toBe($expectedSource) - ->and(file_get_contents($loaded->directory . '/layers/07.detail.deco.php'))->toBe($decoration) - ->and($loaded->getLayerSymbol('map:1', 0, 0))->toBe($edit === 'glyph' ? 'Z' : '.') - ->and($loaded->getLayerSymbol('event', 2, 0))->toBe($edit === 'event' ? 'Q' : ' ') - ->and($loaded->getDisplayName())->toBe($edit === 'metadata' ? 'Renamed Map' : 'Test Map') - ->and(fn() => $loaded->validateLayerContracts())->toThrow(InvalidArgumentException::class); -})->with(['save', 'duplicate', 'move'])->with(['glyph', 'event', 'metadata']); - -it('still refuses changed graphical inputs before saving invalid graphical definitions', function (string $edit) { - $root = layeredMapProject(); - $path = $root . '/assets/Maps/test-map/test-map.data.php'; - file_put_contents($path, preg_replace("/'width' => 16/", "'width' => 0", file_get_contents($path), 1)); - $map = loadLayeredMap($root); - $disk = sourceHashTree($root); - match ($edit) { - 'decoration' => $map->setLayerCell('map:7', 1, 0, 'd'), - 'geometry' => $map->resize(5, 3), - 'crop' => $map->setMapDataField(['tiles2d', 'layers', 'buildings', 'symbols', 'x', 'width'], -1), - }; - expect(fn() => $map->save())->toThrow(InvalidArgumentException::class) - ->and(sourceHashTree($root))->toBe($disk); -})->with(['decoration', 'geometry', 'crop']); - -it('keeps the graphical validation baseline sound across failed saves and restored terminal edits', function () { - $root = layeredMapProject(); - $path = $root . '/assets/Maps/test-map/test-map.data.php'; - $source = preg_replace("/'width' => 16/", "'width' => 0", file_get_contents($path), 1); - file_put_contents($path, $source); - $map = loadLayeredMap($root); - $before = $map->captureLayerSnapshot(); - $disk = sourceHashTree($root); - $map->setLayerCell('map:1', 0, 0, 'Z'); - $map->setMapField('name', 'Renamed Map'); - $edited = $map->captureLayerSnapshot(); - $failure = new FailingFileSetOperations(failures: ['move' => [$map->dataPath]]); - expect(fn() => $map->save(files: $failure))->toThrow(FileSetTransactionFailure::class) - ->and(sourceHashTree($root))->toBe($disk); - $map->save(); - $saved = sourceHashTree($root); - $map->restoreLayerSnapshot($before); - $map->save(); - expect(sourceHashTree($root))->toBe($disk); - $map->restoreLayerSnapshot($edited); - $map->save(); - expect(sourceHashTree($root))->toBe($saved) - ->and(fn() => $map->validateLayerContracts())->toThrow(InvalidArgumentException::class); -}); - -it('refuses invalid graphical data on a new unsaved map without installing any files', function () { - $root = layeredMapProject(); - $directory = $root . '/assets/Maps/new-map'; - $map = new ProjectMap( - mapId: 'new-map', directory: $directory, - dataPath: $directory . '/new-map.data.php', mapPath: $directory . '/new-map.map.php', - eventPath: $directory . '/new-map.event.php', - data: ['name' => 'New Map', 'tiles2d' => [ - 'asset' => 'Graphics/Tilesets/shared.png', - 'symbols' => ['x' => ['x' => 0, 'y' => 0, 'width' => 0, 'height' => 16]], - ]], tileLines: ['xx'], eventLines: [' '], - ); - $before = sourceHashTree($root); - expect(fn() => $map->save())->toThrow(InvalidArgumentException::class) - ->and(sourceHashTree($root))->toBe($before) - ->and(is_dir($directory))->toBeFalse(); -}); - -it('rejects restoring invalid graphical data after a valid graphical correction has been persisted', function () { - $root = layeredMapProject(); - $path = $root . '/assets/Maps/test-map/test-map.data.php'; - $validSource = file_get_contents($path); - file_put_contents($path, preg_replace("/'width' => 16/", "'width' => 0", $validSource, 1)); - $map = loadLayeredMap($root); - $invalid = $map->captureLayerSnapshot(); - $map->setMapDataField(['tiles2d', 'layers', 'buildings', 'symbols', 'x', 'width'], 16); - $valid = $map->captureLayerSnapshot(); - $map->save(); - expect(file_get_contents($path))->toBe($validSource); - $saved = sourceHashTree($root); - $map->restoreLayerSnapshot($invalid); - expect(fn() => $map->save())->toThrow(InvalidArgumentException::class) - ->and(sourceHashTree($root))->toBe($saved); - $map->restoreLayerSnapshot($valid); - $map->setLayerCell('map:1', 0, 0, 'Z'); - $map->save(); - expect(loadLayeredMap($root)->getLayerSymbol('map:1', 0, 0))->toBe('Z') - ->and(file_get_contents($path))->toBe($validSource); -}); - it('composes terminal preview with the exact shared Engine style and transparency semantics', function () { $root = layeredMapProject(); $path = $root . '/assets/Maps/test-map/layers/04.buildings.map.php'; @@ -305,44 +181,7 @@ expect($map->renderPreview(4, 2, terminalPreview: true))->toBe($expected); }); -it('converts a legacy flat crop table without losing authored comments expressions or crops', function () { - $root = makeTemporaryProject(); - $directory = $root . '/assets/Maps/test-map'; - file_put_contents($directory . '/test-map.map.php', MapGridSource::buildSource("xx\nx ", 'MAP')); - file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource(" \n ", 'EVENT')); - $source = <<<'PHP' - 'Test Map', - // Preserve the complete table, not merely its evaluated value. - 'tiles2d' => [ - 'asset' => 'Graphics/Tilesets/shared.png', - // An author-owned expression. - 'symbols' => ['x' => ['x' => 0, 'y' => 0, 'width' => $cropWidth, 'height' => 16]], - ], -]; -PHP; - file_put_contents($directory . '/test-map.data.php', $source); - $map = authoredMap($root); - $before = $map->captureLayerSnapshot(); - $crops = $map->getLayerTiles2d('tile'); - $map->createLayer('buildings'); - $after = $map->captureLayerSnapshot(); - $map->save(); - $converted = str_replace("'tiles2d' => [", "'tiles2d' => ['layers' => ['terrain' => [", $source); - $converted = str_replace(" ],\n];", " ]]],\n];", $converted); - expect(file_get_contents($map->dataPath))->toBe($converted) - ->and(authoredMap($root)->getLayerTiles2d('map:0'))->toBe($crops); - $map->restoreLayerSnapshot($before); - $map->save(); - expect(file_get_contents($map->dataPath))->toBe($source); - $map->restoreLayerSnapshot($after); - $map->save(); - expect(file_get_contents($map->dataPath))->toBe($converted); -}); - -it('restores removed crop-table source and layer bytes through undo after save', function () { +it('restores removed layer bytes through undo after save', function () { $root = layeredMapProject(); $map = loadLayeredMap($root); $before = $map->captureLayerSnapshot(); @@ -352,9 +191,6 @@ $map->restoreLayerSnapshot($before); $map->save(); expect(sourceHashTree($map->directory))->toBe($files); - $map->renameLayer('map:7', 'floors'); - $map->save(); - expect(file_get_contents($map->dataPath))->toContain("'floors' => ['asset' =>"); }); it('validates combined named and flat collision fallback through the shared resolver', function () { diff --git a/tests/Unit/LayeredPreviewTest.php b/tests/Unit/LayeredPreviewTest.php index abb9b5f..7b71de5 100644 --- a/tests/Unit/LayeredPreviewTest.php +++ b/tests/Unit/LayeredPreviewTest.php @@ -6,7 +6,7 @@ use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewSession; use Ichiloto\Engine\Field\MapManager; -it('loads layer-aware cinematic collision and clears all geometry and crop state on unload', function () { +it('loads layer-aware cinematic collision and clears all geometry on unload', function () { $root = layeredMapProject(); file_put_contents($root . '/assets/Maps/test-map/test-map.event.php', Ichiloto\Engine\Field\MapGridSource::buildSource(" \n ", 'EVENT')); @@ -23,18 +23,15 @@ $scene = new ReflectionProperty($preview, 'scene')->getValue($preview); $manager = $scene->previewMap; expect($manager->layers?->legacy)->toBeFalse() - ->and(array_keys($manager->layerTiles2d))->toBe(['buildings', 'detail']) ->and(new ReflectionProperty(MapManager::class, 'collisionMap')->getValue($manager))->toBe([[0, 1, 0, 0], [0, 0, 0, 0]]); $camera = $scene->previewCamera(); ob_start(); - $camera->renderLayeredMap($manager->layers); + $manager->render(); $output = ob_get_clean(); expect($output)->toBe('') ->and(implode("\n", $camera->frame()))->toContain('./..', '.xx.'); $manager->unload(); expect($manager->layers)->toBeNull() - ->and($manager->layerTiles2d)->toBe([]) - ->and($manager->tiles2d)->toBeNull() ->and($manager->tileMap)->toBe([]) ->and($manager->mapWidth)->toBe(0) ->and($manager->mapHeight)->toBe(0) diff --git a/tests/Unit/MixedMaterialAuthoringTest.php b/tests/Unit/MixedMaterialAuthoringTest.php index 3f1019e..2334b51 100644 --- a/tests/Unit/MixedMaterialAuthoringTest.php +++ b/tests/Unit/MixedMaterialAuthoringTest.php @@ -29,18 +29,10 @@ function createMixedMaterialProject(): string file_put_contents($directory . '/layers/' . $file, MapGridSource::buildSource($grid, 'ROOM', "// Keep $file.\n")); } file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource($blank, 'EVENTS')); - $crops = []; - foreach (['floors' => ['w', 'k', 's'], 'rugs' => ['r', 'c'], 'wall-detail' => ['w', 'o']] as $layer => $symbols) { - $y = count($crops) * 16; - foreach ($symbols as $x => $symbol) { - $crops[$layer]['symbols'][$symbol] = ['x' => $x * 16, 'y' => $y, 'width' => 16, 'height' => 16]; - } - } file_put_contents($directory . '/test-map.data.php', " 'Mixed materials', 'region' => '', 'events' => [], 'npcs' => [['id' => 'notice', 'name' => 'Notice', 'x' => 4, 'y' => 0, 'sprite' => '', 'movement' => 'fixed', 'dialogue' => [['text' => 'Read the notice.']]]], - 'tiles2d' => ['asset' => 'Graphics/Tilesets/materials.png', 'layers' => $crops], ], true) . ";\n"); file_put_contents($root . '/assets/Maps/collisions.php', <<<'PHP' and(MapCollisionResolver::resolveLayers($reloaded->getLayerSet(), $dictionary))->toBe($collision) ->and($reloaded->getEditableData()['npcs'][0]['dialogue'])->toBe([['text' => 'Read the notice.']]) ->and($reloaded->getEditableData()['npcs'][0]['sprite'])->toBe(''); - - $rectangles = []; - foreach (['map:2' => ['w', 'k', 's'], 'map:3' => ['r', 'c'], 'map:5' => ['w', 'o']] as $id => $glyphs) { - $definition = $reloaded->getLayerTiles2d($id); - expect($definition['asset'])->toBe('Graphics/Tilesets/materials.png'); - foreach ($glyphs as $glyph) { - $rectangles[] = json_encode($definition['symbols'][$glyph]); - } - } - expect(array_unique($rectangles))->toHaveCount(7); }); it('excludes material markers and crop controls from the TUI while terminal editing preserves their source', function () { @@ -168,18 +150,3 @@ function getMixedMaterialCanvasRows(Editor $editor): array callEditorMethod($editor, 'dispatchInput', 'v'); expect(getMixedMaterialCanvasRows($editor)[0])->toBe('####i#####'); }); - -it('retains strict model refusal of newly unmapped graphical material without exposing a TUI crop workflow', function () { - $root = createMixedMaterialProject(); - $map = loadLayeredMap($root); - $before = sourceHashTree($root); - $original = $map->captureLayerSnapshot(); - $map->setLayerCell('map:2', 1, 1, 'z'); - expect(fn() => $map->save())->toThrow(InvalidArgumentException::class, 'has no crop mapping') - ->and($map->isDirty())->toBeTrue() - ->and(sourceHashTree($root))->toBe($before); - $map->restoreLayerSnapshot($original); - $map->save(); - expect($map->isDirty())->toBeFalse() - ->and(sourceHashTree($root))->toBe($before); -}); From 87569a3f515576ae6cf5db37ebb8965c9276a5ee Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 19:12:31 +0200 Subject: [PATCH 042/344] feat(maps)!: model map layers as two-column cells A map cell is now two terminal columns, as the Engine reads it: one two-column glyph or two one-column characters. The editor follows the Engine's grouping instead of treating every character as a cell. - EditableGrid groups each row through MapCell::parseRow and refuses a misaligned row with the Engine's row and column. A cell whose two characters are styled differently keeps one style per character, so regenerated rows write each back with its own style and unchanged rows keep their exact bytes. Legacy tags still parse as styles. - CellSymbol turns an entry into a whole cell: one character repeats to fill it, a pair or a two-column glyph fills it as it is, and an empty entry is the blank cell (two spaces). - ProjectMap reads and writes whole cells: getLayerSymbol returns cell text, getLayerCellStyle can carry per-character styles and setStyledLayerCell restores them exactly. Event markers follow MapCell::getMarker, cells are written with the marker repeated, and cells holding two markers are described for validation. - Widths, resizing, new layers, blank maps and blank rows are in cells. A new map defaults to 24 x 18 cells, the terminal footprint of the old 48 x 18 columns. - The canvas preview draws each cell in two columns. An NPC sprite of up to two columns sits in its cell and a wider one overhangs; the old one-column bracket around a selected NPC is replaced by reverse video within its cell. - PaintStrokeCommand records whole cell styles so undo restores a differently styled pair byte for byte. Test fixtures are authored in two-column cells, and the shared fixture map helpers move to tests/Support so suites no longer depend on file order. --- src/Canvas/Clipboard.php | 8 +- src/History/PaintStrokeCommand.php | 65 ++-- src/Maps/CellSymbol.php | 69 ++++ src/Maps/EditableGrid.php | 185 ++++++++-- src/Maps/MapLayers.php | 13 +- src/ProjectMap.php | 321 ++++++++---------- .../technical-map/technical-map.event.php | 10 +- .../Maps/technical-map/technical-map.map.php | 10 +- .../assets/Maps/test-map/test-map.event.php | 10 +- .../assets/Maps/test-map/test-map.map.php | 10 +- tests/Support/LayeredMapFixtures.php | 17 +- tests/Support/MapFixtures.php | 47 +++ tests/Unit/CommandHistoryTest.php | 44 +-- tests/Unit/EditableGridTest.php | 113 ++++-- tests/Unit/LastLegendMapIntegrityTest.php | 11 +- tests/Unit/LayeredMapTest.php | 35 +- tests/Unit/MapIsolationTest.php | 11 +- tests/Unit/ProjectMapTest.php | 117 ++----- 18 files changed, 663 insertions(+), 433 deletions(-) create mode 100644 src/Maps/CellSymbol.php diff --git a/src/Canvas/Clipboard.php b/src/Canvas/Clipboard.php index c88c194..ecbdbc7 100644 --- a/src/Canvas/Clipboard.php +++ b/src/Canvas/Clipboard.php @@ -5,7 +5,7 @@ namespace Ichiloto\Editor\Canvas; /** - * The canvas clipboard: one rectangular block of symbols lifted off a map + * The canvas clipboard: one rectangular block of whole cells lifted off a map * layer, ready to be stamped anywhere (repeatedly) as a single undoable * paste per stamp. * @@ -18,7 +18,7 @@ final class Clipboard * @var array> Rows of symbols, top-left first. */ private array $rows = []; - /** @var array> */ + /** @var array}>> */ private array $styles = []; /** * The layer the block was lifted from (a PaintStrokeCommand LAYER_* value). @@ -30,7 +30,7 @@ final class Clipboard * * @param array> $rows Rows of symbols, top-left first. * @param string $layer The source layer. - * @param array> $styles Optional tile styles, aligned with rows. + * @param array}>> $styles Optional cell styles, aligned with rows. * @return void */ public function store(array $rows, string $layer, array $styles = []): void @@ -105,7 +105,7 @@ public function getRows(): array * @param int $originY The paste origin y coordinate. * @param int $width The target map width. * @param int $height The target map height. - * @return array The clipped placements. + * @return array}}> The clipped placements. */ public function project(int $originX, int $originY, int $width, int $height): array { diff --git a/src/History/PaintStrokeCommand.php b/src/History/PaintStrokeCommand.php index e58dcd8..c8316dc 100644 --- a/src/History/PaintStrokeCommand.php +++ b/src/History/PaintStrokeCommand.php @@ -20,7 +20,7 @@ final class PaintStrokeCommand implements Command public const string LAYER_EVENT = 'event'; /** - * @var array + * @var array */ private array $cells = []; @@ -37,21 +37,20 @@ public function __construct( } /** - * Records one painted cell. The old symbol survives repeated appends so + * Records one painted cell. The old cell survives repeated appends so * undo restores the pre-stroke state. * - * On the tile layer a cell also carries its raw styling bytes, so undo - * restores authored formatter tags byte-for-byte. The event layer has no - * styling; its style arguments stay empty. + * A cell also carries its raw styling bytes, as ProjectMap reads them, + * so undo restores authored formatter tags byte-for-byte, a separate + * style per character included. The event layer has no styling; its + * styles stay empty. * * @param int $x The cell x coordinate. * @param int $y The cell y coordinate. - * @param string $oldSymbol The symbol before the stroke touched the cell. - * @param string $newSymbol The symbol painted onto the cell. - * @param string $oldPrefix The styling prefix before the stroke. - * @param string $oldSuffix The styling suffix before the stroke. - * @param string $newPrefix The styling prefix painted onto the cell. - * @param string $newSuffix The styling suffix painted onto the cell. + * @param string $oldSymbol The cell before the stroke touched it. + * @param string $newSymbol The cell painted. + * @param array{prefix?: string, suffix?: string, styles?: list} $oldStyle The styling before the stroke. + * @param array{prefix?: string, suffix?: string, styles?: list} $newStyle The styling painted. * @return void */ public function appendCell( @@ -59,17 +58,15 @@ public function appendCell( int $y, string $oldSymbol, string $newSymbol, - string $oldPrefix = '', - string $oldSuffix = '', - string $newPrefix = '', - string $newSuffix = '', + array $oldStyle = [], + array $newStyle = [], ): void { $key = $x . ':' . $y; + $newStyle = self::normalizeStyle($newStyle); if (isset($this->cells[$key])) { $this->cells[$key]['new'] = $newSymbol; - $this->cells[$key]['newPrefix'] = $newPrefix; - $this->cells[$key]['newSuffix'] = $newSuffix; + $this->cells[$key]['newStyle'] = $newStyle; return; } @@ -78,10 +75,8 @@ public function appendCell( 'y' => $y, 'old' => $oldSymbol, 'new' => $newSymbol, - 'oldPrefix' => $oldPrefix, - 'oldSuffix' => $oldSuffix, - 'newPrefix' => $newPrefix, - 'newSuffix' => $newSuffix, + 'oldStyle' => self::normalizeStyle($oldStyle), + 'newStyle' => $newStyle, ]; } @@ -93,11 +88,7 @@ public function appendCell( public function hasChanges(): bool { foreach ($this->cells as $cell) { - if ( - $cell['old'] !== $cell['new'] - || $cell['oldPrefix'] !== $cell['newPrefix'] - || $cell['oldSuffix'] !== $cell['newSuffix'] - ) { + if ($cell['old'] !== $cell['new'] || $cell['oldStyle'] !== $cell['newStyle']) { return true; } } @@ -132,7 +123,7 @@ public function targets(ProjectMap $map): bool public function execute(): void { foreach ($this->cells as $cell) { - $this->applyCell($cell['x'], $cell['y'], $cell['new'], $cell['newPrefix'], $cell['newSuffix']); + $this->applyCell($cell['x'], $cell['y'], $cell['new'], $cell['newStyle']); } } @@ -142,7 +133,7 @@ public function execute(): void public function undo(): void { foreach ($this->cells as $cell) { - $this->applyCell($cell['x'], $cell['y'], $cell['old'], $cell['oldPrefix'], $cell['oldSuffix']); + $this->applyCell($cell['x'], $cell['y'], $cell['old'], $cell['oldStyle']); } } @@ -151,13 +142,21 @@ public function undo(): void * * @param int $x The cell x coordinate. * @param int $y The cell y coordinate. - * @param string $symbol The symbol to apply. - * @param string $prefix The styling prefix bytes (tile layer only). - * @param string $suffix The styling suffix bytes (tile layer only). + * @param string $symbol The cell to apply. + * @param array{prefix: string, suffix: string, styles?: list} $style The styling to apply. * @return void */ - private function applyCell(int $x, int $y, string $symbol, string $prefix, string $suffix): void + private function applyCell(int $x, int $y, string $symbol, array $style): void { - $this->map->setLayerCell($this->layer, $x, $y, $symbol, $prefix, $suffix); + $this->map->setStyledLayerCell($this->layer, $x, $y, $symbol, $style); + } + + /** + * @param array{prefix?: string, suffix?: string, styles?: list} $style + * @return array{prefix: string, suffix: string, styles?: list} + */ + private static function normalizeStyle(array $style): array + { + return ['prefix' => $style['prefix'] ?? '', 'suffix' => $style['suffix'] ?? ''] + $style; } } diff --git a/src/Maps/CellSymbol.php b/src/Maps/CellSymbol.php new file mode 100644 index 0000000..ad3e78f --- /dev/null +++ b/src/Maps/CellSymbol.php @@ -0,0 +1,69 @@ + Entered characters, without control characters. */ + private static function getSymbols(string $input): array + { + if ($input === '' || preg_match_all('/\X/u', $input, $matches) === false) { + return []; + } + return array_values(array_filter($matches[0], static fn(string $symbol): bool => preg_match('/\A[\x00-\x1F\x7F]+\z/', $symbol) !== 1)); + } +} diff --git a/src/Maps/EditableGrid.php b/src/Maps/EditableGrid.php index b72a359..5961b1b 100644 --- a/src/Maps/EditableGrid.php +++ b/src/Maps/EditableGrid.php @@ -5,20 +5,40 @@ namespace Ichiloto\Editor\Maps; use Ichiloto\Editor\MapSourceRefusal; +use Ichiloto\Engine\Field\MapCell; use Ichiloto\Engine\Field\MapGridSource; use Ichiloto\Engine\IO\Console\TerminalText; +use InvalidArgumentException; -/** Editable colour runs, with the authored source and unchanged rows retained. */ +/** + * Editable rows of two-column map cells, with the authored source and + * unchanged rows retained. + * + * A cell's symbol is its unstyled text: one two-column glyph or two + * one-column characters. A cell whose characters share one style keeps it + * as its prefix and suffix. A cell whose characters are styled differently + * also lists one style per character, so an edited row writes every + * character back with its own style; its prefix and suffix are then those + * of its first visible character, which is the cell's colour. + */ final class EditableGrid { - /** @var array> */ + /** @var array}>> */ public array $cells; private array $originalCells; private array $lines; private array $separators; - public function __construct(private string $text, private ?string $source = null, private bool $legacyTags = false) - { + /** + * @param string $context Names the grid in refusals, for example its file. + * @throws InvalidArgumentException When a row is not whole two-column cells. + */ + public function __construct( + private string $text, + private ?string $source = null, + private bool $legacyTags = false, + private string $context = 'Map', + ) { $parts = preg_split('/(\r\n|\n|\r)/', rtrim($text, "\r\n"), -1, PREG_SPLIT_DELIM_CAPTURE) ?: ['']; $this->lines = $this->separators = []; foreach ($parts as $index => $part) { @@ -28,38 +48,129 @@ public function __construct(private string $text, private ?string $source = null $this->separators[] = $part; } } - $this->cells = self::parseLines($this->lines, $legacyTags); + $this->cells = self::parseLines($this->lines, $legacyTags, $context); $this->originalCells = $this->cells; } - public static function parseLines(array $lines, bool $legacyTags = false): array + /** + * Parses authored rows into cells, keeping each character's style. + * + * @param string[] $lines + * @throws InvalidArgumentException When a row is not whole two-column cells. + */ + public static function parseLines(array $lines, bool $legacyTags = false, string $context = 'Map'): array { // Equal cells share PHP copy-on-write values, not mutable references. // Keep the pool local to this parse so closed maps retain no cache. $cellValues = []; - return array_map(static function (string $line) use ($legacyTags, &$cellValues): array { - preg_match_all('/<[^>]+>|[^<]+|]*>$/u', $segment) === 1 - && ($legacyTags || TerminalText::stripAnsi($segment . 'x') === 'x')) { - $tags[] = $segment; - } elseif (preg_match('/^<\/[^>]*>$/u', $segment) === 1 && ($legacyTags || $tags !== [])) { - array_pop($tags); - } else { - foreach (TerminalText::visibleSymbols($segment) as $symbol) { - preg_match('/\A((?:\x1b\[[0-9;]*m)*)(.*?)((?:\x1b\[[0-9;]*m)*)\z/us', $symbol, $styled); - $glyph = $styled[2] ?? $symbol; - $prefix = implode('', $tags) . ($styled[1] ?? ''); - $suffix = ($styled[3] ?? '') . str_repeat('', count($tags)); - $cells[] = $cellValues[$prefix][$suffix][$glyph] ??= [ - 'symbol' => $glyph, 'prefix' => $prefix, 'suffix' => $suffix, - ]; - } + $rows = []; + foreach ($lines as $y => $line) { + $characters = self::parseCharacters($line, $legacyTags); + // The Engine owns how characters group into cells and how a + // misaligned row is refused; the editor only keeps the styles. + $texts = MapCell::parseRow(implode('', array_column($characters, 'symbol')), "{$context} row {$y}"); + $row = []; + $offset = 0; + foreach ($texts as $text) { + $count = count(MapCell::getCharacters($text)); + $members = array_slice($characters, $offset, $count); + $offset += $count; + $symbol = implode('', array_column($members, 'symbol')); + if ($symbol !== TerminalText::stripAnsi($text)) { + throw new InvalidArgumentException("{$context} row {$y} does not split into the characters the Engine reads."); } + $key = serialize($members); + $row[] = $cellValues[$key] ??= self::createCellFromCharacters($symbol, $members); } - return $cells; - }, $lines); + $rows[] = $row; + } + return $rows; + } + + /** + * Splits one authored row into characters with their own style runs. + * + * @return list + */ + private static function parseCharacters(string $line, bool $legacyTags): array + { + preg_match_all('/<[^>]+>|[^<]+|]*>$/u', $segment) === 1 + && ($legacyTags || TerminalText::stripAnsi($segment . 'x') === 'x')) { + $tags[] = $segment; + } elseif (preg_match('/^<\/[^>]*>$/u', $segment) === 1 && ($legacyTags || $tags !== [])) { + array_pop($tags); + } else { + foreach (TerminalText::visibleSymbols($segment) as $symbol) { + preg_match('/\A((?:\x1b\[[0-9;]*m)*)(.*?)((?:\x1b\[[0-9;]*m)*)\z/us', $symbol, $styled); + $characters[] = [ + 'symbol' => $styled[2] ?? $symbol, + 'prefix' => implode('', $tags) . ($styled[1] ?? ''), + 'suffix' => ($styled[3] ?? '') . str_repeat('', count($tags)), + ]; + } + } + } + return $characters; + } + + /** @param list $members */ + private static function createCellFromCharacters(string $symbol, array $members): array + { + $styles = array_map(static fn(array $member): array => ['prefix' => $member['prefix'], 'suffix' => $member['suffix']], $members); + return self::createCell($symbol, [...($styles[0] ?? ['prefix' => '', 'suffix' => '']), 'styles' => $styles]); + } + + /** + * Builds one cell from its unstyled text and a style. Per-character + * styles apply only when they match the cell's characters and differ; + * otherwise the whole cell takes the style's prefix and suffix. + * + * @param array{prefix: string, suffix: string, styles?: list} $style + * @return array{symbol: string, prefix: string, suffix: string, styles?: list} + */ + public static function createCell(string $symbol, array $style): array + { + $characters = MapCell::getCharacters($symbol); + $styles = array_values($style['styles'] ?? []); + if (count($styles) !== count($characters) || count(array_unique(array_map(serialize(...), $styles))) < 2) { + return ['symbol' => $symbol, 'prefix' => $style['prefix'], 'suffix' => $style['suffix']]; + } + // The cell's colour is its first visible character's. + $shown = array_key_first(array_filter($characters, static fn(string $character): bool => trim($character) !== '')) ?? 0; + return ['symbol' => $symbol, 'prefix' => $styles[$shown]['prefix'], 'suffix' => $styles[$shown]['suffix'], 'styles' => $styles]; + } + + /** + * A cell's style: its prefix and suffix, and one style per character + * when its characters are styled differently. + * + * @return array{prefix: string, suffix: string, styles?: list} + */ + public static function getCellStyle(array $cell): array + { + $style = ['prefix' => (string) ($cell['prefix'] ?? ''), 'suffix' => (string) ($cell['suffix'] ?? '')]; + return isset($cell['styles']) ? [...$style, 'styles' => $cell['styles']] : $style; + } + + /** + * A cell as styled runs of text: the whole cell when its characters share + * one style, one run per character otherwise. + * + * @return list + */ + public static function getCellRuns(array $cell): array + { + if (! isset($cell['styles'])) { + return [['symbol' => $cell['symbol'], 'prefix' => $cell['prefix'], 'suffix' => $cell['suffix']]]; + } + $runs = []; + foreach (MapCell::getCharacters($cell['symbol']) as $index => $character) { + $runs[] = ['symbol' => $character] + $cell['styles'][$index]; + } + return $runs; } public function getSymbols(): array @@ -69,12 +180,13 @@ public function getSymbols(): array public function captureSnapshot(): array { - return ['text' => $this->text, 'source' => $this->source, 'cells' => $this->cells, 'legacyTags' => $this->legacyTags]; + return ['text' => $this->text, 'source' => $this->source, 'cells' => $this->cells, + 'legacyTags' => $this->legacyTags, 'context' => $this->context]; } public static function createFromSnapshot(array $snapshot): self { - $grid = new self($snapshot['text'], $snapshot['source'], $snapshot['legacyTags']); + $grid = new self($snapshot['text'], $snapshot['source'], $snapshot['legacyTags'], $snapshot['context'] ?? 'Map'); $grid->cells = $snapshot['cells']; return $grid; } @@ -90,12 +202,14 @@ public function getLines(): array $line = ''; $open = null; foreach ($cells as $cell) { - $style = [$cell['prefix'], $cell['suffix']]; - if ($style !== $open) { - $line .= ($open[1] ?? '') . $style[0]; - $open = $style; + foreach (self::getCellRuns($cell) as $run) { + $style = [$run['prefix'], $run['suffix']]; + if ($style !== $open) { + $line .= ($open[1] ?? '') . $style[0]; + $open = $style; + } + $line .= $run['symbol']; } - $line .= $cell['symbol']; } $lines[] = $line . ($open[1] ?? ''); } @@ -145,9 +259,10 @@ public function getSource(): string return $source; } + /** Resizes to whole cells; new cells are blank. */ public function resize(int $width, int $height): void { - $blank = ['symbol' => ' ', 'prefix' => '', 'suffix' => '']; + $blank = ['symbol' => MapCell::BLANK, 'prefix' => '', 'suffix' => '']; $this->cells = array_slice($this->cells, 0, $height); foreach ($this->cells as &$row) { $row = array_pad(array_slice($row, 0, $width), $width, $blank); diff --git a/src/Maps/MapLayers.php b/src/Maps/MapLayers.php index ac2d2bc..b287fdc 100644 --- a/src/Maps/MapLayers.php +++ b/src/Maps/MapLayers.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Engine\Field\MapCell; use Ichiloto\Engine\Field\MapGridSource; use Ichiloto\Engine\Field\MapLayerSource; use Ichiloto\Engine\Field\MapLayerSet; @@ -37,8 +38,10 @@ public function __construct( $this->captureBaseline(); } - public static function createFromSource(string $directory, MapLayerSet $set, string $eventPath, string $eventText): self + /** @throws \InvalidArgumentException When a grid is not whole two-column cells. */ + public static function createFromSource(string $directory, MapLayerSet $set, string $eventPath, string $eventText, string $mapId = ''): self { + $prefix = $mapId === '' ? '' : $mapId . '/'; $layers = []; foreach ($set->layers as $layer) { $path = $directory . ($set->legacy ? '/' : '/layers/') . basename($layer->path); @@ -46,10 +49,11 @@ public static function createFromSource(string $directory, MapLayerSet $set, str 'id' => $set->legacy ? self::BASE : 'map:' . $layer->order, 'name' => $layer->name, 'order' => $layer->order, 'decoration' => $layer->decoration, 'path' => $path, - 'grid' => new EditableGrid($layer->text, (string) file_get_contents($path), $set->legacy), + 'grid' => new EditableGrid($layer->text, (string) file_get_contents($path), $set->legacy, $prefix . basename($path)), ]; } - return new self($directory, $set->legacy, $layers, new EditableGrid($eventText, (string) file_get_contents($eventPath)), $eventPath); + $events = new EditableGrid($eventText, (string) file_get_contents($eventPath), context: $prefix . basename($eventPath)); + return new self($directory, $set->legacy, $layers, $events, $eventPath); } public function getLayers(): array @@ -110,7 +114,7 @@ public function createLayer(string $name, bool $decoration, ?int $order = null): } $id = 'map:' . $order; $grid = new EditableGrid(implode("\n", array_map( - static fn(array $row): string => str_repeat(' ', count($row)), $this->getBaseGrid()->cells, + static fn(array $row): string => str_repeat(MapCell::BLANK, count($row)), $this->getBaseGrid()->cells, ))); $this->layers[$id] = ['id' => $id, 'name' => $name, 'order' => $order, 'decoration' => $decoration, 'path' => $this->buildPath($order, $name, $decoration), 'grid' => $grid]; @@ -178,6 +182,7 @@ public function restoreSnapshot(array $snapshot): void $this->layers = array_map(static fn(array $layer): array => [...$layer, 'grid' => EditableGrid::createFromSnapshot($layer['grid'])], $snapshot['layers']); } + /** Resizes every layer to whole cells. */ public function resize(int $width, int $height): void { foreach ($this->layers as $layer) { diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 9b907fb..45fb645 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -20,9 +20,11 @@ use Ichiloto\Engine\Field\MapLayer; use Ichiloto\Engine\Field\MapLayerSource; use Ichiloto\Engine\Field\MapLayerSet; +use Ichiloto\Engine\Field\MapCell; use Ichiloto\Engine\Field\MapCollisionResolver; use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\IO\Console\SgrStyleState; +use Ichiloto\Editor\Maps\CellSymbol; use Ichiloto\Editor\Maps\EditableGrid; use Ichiloto\Editor\Maps\MapLayers; use InvalidArgumentException; @@ -107,8 +109,8 @@ public function __construct( $this->editableData = $data; $this->layers = $layers ?? new MapLayers($directory, true, [[ 'id' => MapLayers::BASE, 'name' => 'terrain', 'order' => 0, 'decoration' => false, - 'path' => $mapPath, 'grid' => new EditableGrid(implode("\n", $tileLines), legacyTags: true), - ]], new EditableGrid(implode("\n", $eventLines)), $eventPath); + 'path' => $mapPath, 'grid' => new EditableGrid(implode("\n", $tileLines), legacyTags: true, context: $mapPath), + ]], new EditableGrid(implode("\n", $eventLines), context: $eventPath), $eventPath); $this->loadedData = $data; $this->adoptDataSource($dataSource ?? "baselineDataSource = $this->dataDocument?->source ?? (string) $this->unparsedDataSource; @@ -162,9 +164,11 @@ public static function fromDirectory(string $mapsRoot, string $directory): self try { $set = MapLayerSource::loadFromDirectory($directory, $mapId); $eventText = self::readGridSource($eventPath, $mapId); + $eventGrid = MapLayer::parseGrid($eventText, $mapId . '/' . basename($eventPath)); if (! $set->legacy) { - $set->assertMatchingGrid(MapLayer::parseGrid($eventText), $mapId . '/' . basename($eventPath)); + $set->assertMatchingGrid($eventGrid, $mapId . '/' . basename($eventPath)); } + $layers = MapLayers::createFromSource($directory, $set, $eventPath, $eventText, $mapId); } catch (InvalidArgumentException $error) { throw new MapSourceRefusal($error->getMessage(), previous: $error); } @@ -185,7 +189,7 @@ public static function fromDirectory(string $mapsRoot, string $directory): self tileLines: self::splitMapText($mapText), eventLines: self::splitMapText($eventText), dataSource: (string) file_get_contents($dataPath), - layers: MapLayers::createFromSource($directory, $set, $eventPath, $eventText), + layers: $layers, )->withLoadedBaseline(); } @@ -296,7 +300,7 @@ public function getHeight(): int } /** - * Returns the widest tile row after formatting tags are stripped. + * Returns the widest tile row, in two-column cells. * * @return int */ @@ -374,9 +378,10 @@ public function isLegacyMap(): bool return $this->layers->legacy; } + /** The unstyled text of one cell: a two-column glyph or two characters. */ public function getLayerSymbol(string $layer, int $x, int $y): string { - return $this->layers->getGrid($layer)->cells[$y][$x]['symbol'] ?? ' '; + return $this->layers->getGrid($layer)->cells[$y][$x]['symbol'] ?? MapCell::BLANK; } public function hasLayerCell(string $layer, int $x, int $y): bool @@ -384,10 +389,15 @@ public function hasLayerCell(string $layer, int $x, int $y): bool return isset($this->layers->getGrid($layer)->cells[$y][$x]); } + /** + * A cell's raw styling bytes, with one style per character when its + * characters are styled differently. + * + * @return array{prefix: string, suffix: string, styles?: list} + */ public function getLayerCellStyle(string $layer, int $x, int $y): array { - $cell = $this->layers->getGrid($layer)->cells[$y][$x] ?? []; - return ['prefix' => $cell['prefix'] ?? '', 'suffix' => $cell['suffix'] ?? '']; + return EditableGrid::getCellStyle($this->layers->getGrid($layer)->cells[$y][$x] ?? []); } public function getLayerColor(string $layer, int $x, int $y): ?string @@ -395,12 +405,24 @@ public function getLayerColor(string $layer, int $x, int $y): ?string return self::getInnermostForeground($this->getLayerCellStyle($layer, $x, $y)['prefix']); } + /** Replaces one whole cell, styled uniformly; `#` fills the cell as `##`. */ public function setLayerCell(string $layer, int $x, int $y, string $symbol, string $prefix = '', string $suffix = ''): void + { + $this->setStyledLayerCell($layer, $x, $y, $symbol, ['prefix' => $prefix, 'suffix' => $suffix]); + } + + /** + * Replaces one whole cell with a style as getLayerCellStyle() reads it, + * so a cell whose characters are styled differently restores exactly. + * + * @param array{prefix: string, suffix: string, styles?: list} $style + */ + public function setStyledLayerCell(string $layer, int $x, int $y, string $symbol, array $style): void { $this->assertEditable(); $grid = $this->layers->getGrid($layer); if (isset($grid->cells[$y][$x])) { - $grid->cells[$y][$x] = ['symbol' => self::normalizeSymbol($symbol), 'prefix' => $prefix, 'suffix' => $suffix]; + $grid->cells[$y][$x] = EditableGrid::createCell(CellSymbol::normalize($symbol), $style); $this->touchState(); } } @@ -497,9 +519,10 @@ public function restoreLayerSnapshot(array $snapshot): void } /** - * Builds a merged preview where event markers override map tiles. + * Builds a merged preview where event markers override map tiles. Each + * cell is two terminal columns of the returned lines. * - * @param int $width The preview width. + * @param int $width The preview width, in cells. * @param int $height The preview height. * @param int $offsetX The horizontal preview offset. * @param int $offsetY The vertical preview offset. @@ -547,7 +570,7 @@ public function renderPreview( continue; } foreach ($layer['grid']->cells[$row] ?? [] as $x => $cell) { - if ($cell['symbol'] !== ' ' || $layer['id'] === $baseLayerId) { + if (! MapCell::isBlank($cell['symbol']) || $layer['id'] === $baseLayerId) { $tileRow[$x] = [...$cell, 'dim' => $dimInactive && $activeLayer !== $layer['id']]; } } @@ -555,28 +578,19 @@ public function renderPreview( $mergedSymbols = []; for ($column = $offsetX; $column < $offsetX + $width; $column++) { - if (isset($npcCells[$row][$column])) { + // An overhang whose sprite starts left of the view leaves + // its cells to the map, so the row keeps its columns. + if (isset($npcCells[$row][$column]) && ($npcCells[$row][$column] !== '' || $column > $offsetX)) { // NPCs draw over everything, as they do in the game; the // overlay is derived from map data and never painted. $mergedSymbols[] = $npcCells[$row][$column]; continue; } - $tileCell = $tileRow[$column] ?? null; - $tileSymbol = is_array($tileCell) ? $tileCell['symbol'] : ' '; + $tileCell = $tileRow[$column] ?? ['symbol' => MapCell::BLANK, 'prefix' => '', 'suffix' => '']; // A selected map-owned NPC marks the existing cell, never a replacement sprite. - $anchorHighlight = array_key_exists($column, $npcCells[$row] ?? []) ? "\033[7m" : ''; - if (! $this->layers->legacy && is_array($tileCell)) { - $styled = TerminalText::formatStyles($tileCell['prefix'] . $anchorHighlight . $tileSymbol . $tileCell['suffix']); - $styled = ($tileCell['dim'] ?? false) ? "\033[2m" . $styled . "\033[0m" : $styled; - $mergedSymbols[] = $anchorHighlight === '' ? $styled : $styled . "\033[0m"; - continue; - } - $ansiOpen = is_array($tileCell) - ? self::ansiOpenForPrefix((string) ($tileCell['prefix'] ?? '')) - : null; - $dim = (($tileCell['dim'] ?? false) ? "\033[2m" : '') . $anchorHighlight; - $mergedSymbols[] = $ansiOpen === null && $dim === '' ? $tileSymbol : $dim . $ansiOpen . $tileSymbol . "\033[0m"; + $highlight = array_key_exists($column, $npcCells[$row] ?? []) ? "\033[7m" : ''; + $mergedSymbols[] = $this->renderPreviewCell($tileCell, $highlight); } $lines[] = rtrim(implode('', $mergedSymbols)); @@ -585,6 +599,28 @@ public function renderPreview( return array_pad($lines, $height, ''); } + /** + * Renders one cell for the canvas: each styled run of the cell, dimmed + * or highlighted as a whole. + * + * @param array{symbol: string, prefix: string, suffix: string, styles?: list, dim?: bool} $cell + */ + private function renderPreviewCell(array $cell, string $highlight): string + { + $dim = ($cell['dim'] ?? false) ? "\033[2m" : ''; + $text = ''; + foreach (EditableGrid::getCellRuns($cell) as $run) { + if (! $this->layers->legacy) { + $text .= TerminalText::formatStyles($run['prefix'] . $highlight . $run['symbol'] . $run['suffix']); + continue; + } + // Legacy tags are not formatter styles: only their colour is shown. + $open = self::ansiOpenForPrefix($run['prefix']); + $text .= $open === null && $highlight === '' ? $run['symbol'] : $highlight . $open . $run['symbol'] . "\033[0m"; + } + return $dim === '' && $highlight === '' ? $text : $dim . $text . "\033[0m"; + } + /** * The 4-bit ANSI foreground codes for the formatter's colour names. * `gray` is the formatter's name for bright black. @@ -634,17 +670,16 @@ private static function ansiOpenForPrefix(string $prefix): ?string } /** - * Returns the cells the NPC overlay occupies, row => column => symbol. + * Returns the cells the NPC overlay occupies, row => column => text. * - * The engine anchors a sprite at its tile and lets it overhang to the - * right (NpcManager::eraseNpc clears displayWidth cells), so a - * two-column emoji owns its anchor and the cell after it. The overhang - * cell holds an empty string, so the terminal draws the wide glyph in - * the space it needs rather than a symbol shoved half under it. The - * selected NPC is drawn with brackets around a one-column sprite, or as - * itself when wide, since brackets would misalign the row. An explicitly - * empty sprite contributes only a selected anchor (null), highlighting - * the underlying map cell without replacing its glyph or neighbours. + * A character occupies one cell, anchored at its first column as the + * engine draws it: a sprite of up to two columns sits in its cell, and a + * wider one overhangs to the right. Its anchor cell holds the sprite + * padded to whole cells, and every cell it overhangs holds an empty + * string, so the terminal draws the glyph in the space it needs. The + * selected NPC is shown in reverse video within those cells. An + * explicitly empty sprite contributes only a selected anchor (null), + * highlighting the underlying map cell without replacing its glyph. * * @param int|null $selectedNpcIndex The NPC to mark selected. * @param string|null $selectedNpcSprite A glyph to draw for the selected @@ -664,7 +699,7 @@ private function npcOverlayCells(?int $selectedNpcIndex, ?string $selectedNpcSpr if ($index === $selectedNpcIndex && $selectedNpcSprite !== null && trim($selectedNpcSprite) !== '') { $sprite = ProjectNpc::visibleGlyph($selectedNpcSprite); - $columns = max(1, mb_strwidth($sprite)); + $columns = ProjectNpc::glyphWidth($selectedNpcSprite); } if ($sprite === '') { @@ -674,16 +709,12 @@ private function npcOverlayCells(?int $selectedNpcIndex, ?string $selectedNpcSpr continue; } - if ($index === $selectedNpcIndex && $columns === 1) { - $sprite = '[' . $sprite . ']'; - $x = max(0, $x - 1); - $columns = 3; - } - - $cells[$y][$x] = $sprite; + $span = MapCell::getSpanCells($columns); + $text = $sprite . str_repeat(' ', max(0, $span * MapCell::COLUMNS - $columns)); + $cells[$y][$x] = $index === $selectedNpcIndex ? "\033[7m" . $text . "\033[0m" : $text; - for ($column = 1; $column < $columns; $column++) { - $cells[$y][$x + $column] = ''; + for ($cell = 1; $cell < $span; $cell++) { + $cells[$y][$x + $cell] = ''; } } @@ -699,7 +730,7 @@ private function npcOverlayCells(?int $selectedNpcIndex, ?string $selectedNpcSpr */ public function getTileSymbol(int $x, int $y): string { - return $this->layers->getBaseGrid()->cells[$y][$x]['symbol'] ?? ' '; + return $this->getLayerSymbol(MapLayers::BASE, $x, $y); } /** @@ -724,13 +755,7 @@ public function getEventSymbol(int $x, int $y): string */ public function setTileSymbol(int $x, int $y, string $symbol): void { - $this->assertEditable(); - if (! isset($this->layers->getBaseGrid()->cells[$y][$x])) { - return; - } - - $this->layers->getBaseGrid()->cells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); - $this->touchState(); + $this->setStyledLayerCell(MapLayers::BASE, $x, $y, $symbol, $this->getTileCellStyle($x, $y)); } /** @@ -741,16 +766,11 @@ public function setTileSymbol(int $x, int $y, string $symbol): void * * @param int $x The cell x coordinate. * @param int $y The cell y coordinate. - * @return array{prefix: string, suffix: string} + * @return array{prefix: string, suffix: string, styles?: list} */ public function getTileCellStyle(int $x, int $y): array { - $cell = $this->layers->getBaseGrid()->cells[$y][$x] ?? null; - - return [ - 'prefix' => is_array($cell) ? (string) ($cell['prefix'] ?? '') : '', - 'suffix' => is_array($cell) ? (string) ($cell['suffix'] ?? '') : '', - ]; + return $this->getLayerCellStyle(MapLayers::BASE, $x, $y); } /** @@ -822,15 +842,7 @@ private static function getInnermostForeground(string $prefix): ?string */ public function setTileCell(int $x, int $y, string $symbol, string $prefix, string $suffix): void { - $this->assertEditable(); - if (! isset($this->layers->getBaseGrid()->cells[$y][$x])) { - return; - } - - $this->layers->getBaseGrid()->cells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); - $this->layers->getBaseGrid()->cells[$y][$x]['prefix'] = $prefix; - $this->layers->getBaseGrid()->cells[$y][$x]['suffix'] = $suffix; - $this->touchState(); + $this->setLayerCell(MapLayers::BASE, $x, $y, $symbol, $prefix, $suffix); } /** @@ -843,17 +855,12 @@ public function setTileCell(int $x, int $y, string $symbol, string $prefix, stri */ public function setEventSymbol(int $x, int $y, string $symbol): void { - $this->assertEditable(); - if (! $this->hasLayerCell(MapLayers::EVENT, $x, $y)) { - return; - } - - $this->layers->getEventGrid()->cells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); - $this->touchState(); + $this->setLayerCell(MapLayers::EVENT, $x, $y, $symbol); } /** - * Returns every distinct event marker painted on the grid. + * Returns every distinct event marker painted on the grid. A cell + * holding two different markers marks nothing; validation reports it. * * @return string[] */ @@ -861,10 +868,12 @@ public function getPlacedEventMarkers(): array { $markers = []; - foreach ($this->layers->getEventGrid()->getSymbols() as $row) { - foreach ($row as $symbol) { - if (trim($symbol) !== '') { - $markers[$symbol] = $symbol; + foreach ($this->layers->getEventGrid()->getSymbols() as $y => $row) { + foreach (array_keys($row) as $x) { + $marker = $this->getEventMarkerAt($x, $y); + + if ($marker !== null) { + $markers[$marker] = $marker; } } } @@ -872,6 +881,29 @@ public function getPlacedEventMarkers(): array return array_values($markers); } + /** + * Describes every event cell holding two different markers, which the + * Engine refuses to load. + * + * @return string[] The Engine's refusal for each such cell. + */ + public function describeConflictingEventCells(): array + { + $conflicts = []; + + foreach ($this->layers->getEventGrid()->getSymbols() as $y => $row) { + foreach ($row as $x => $cell) { + try { + MapCell::getMarker($cell, "Event cell at row {$y}, column {$x}"); + } catch (InvalidArgumentException $conflict) { + $conflicts[] = $conflict->getMessage(); + } + } + } + + return $conflicts; + } + /** * Captures the editable grid state for undoable whole-grid mutations * (resize, event bounds rewrites). @@ -1240,9 +1272,11 @@ public function getEventField(string $marker, array $path): mixed */ public function getEventMarkerAt(int $x, int $y): ?string { - $symbol = trim($this->getEventSymbol($x, $y)); - - return $symbol === '' ? null : $symbol; + try { + return MapCell::getMarker($this->getEventSymbol($x, $y)); + } catch (InvalidArgumentException) { + return null; + } } /** @@ -1275,8 +1309,8 @@ public function getEventBounds(string $marker): ?array $positions = []; foreach ($this->layers->getEventGrid()->getSymbols() as $rowIndex => $row) { - foreach ($row as $columnIndex => $symbol) { - if ($symbol === $marker) { + foreach (array_keys($row) as $columnIndex) { + if ($this->getEventMarkerAt($columnIndex, $rowIndex) === $marker) { $positions[] = [$columnIndex, $rowIndex]; } } @@ -1319,7 +1353,7 @@ public function isEventMarkerSolidRectangle(string $marker): bool for ($y = $bounds['y']; $y < $maxY; $y++) { for ($x = $bounds['x']; $x < $maxX; $x++) { - if ($this->getEventSymbol($x, $y) !== $marker) { + if ($this->getEventMarkerAt($x, $y) !== $marker) { return false; } } @@ -1456,21 +1490,23 @@ public function removeEventDefinition(string $marker): void public function setEventBounds(string $marker, int $x, int $y, int $width, int $height): void { $this->assertEditable(); - foreach ($this->layers->getEventGrid()->getSymbols() as $rowIndex => $row) { - foreach ($row as $columnIndex => $symbol) { - if ($symbol === $marker) { - $this->layers->getEventGrid()->cells[$rowIndex][$columnIndex]['symbol'] = ' '; + $grid = $this->layers->getEventGrid(); + foreach ($grid->getSymbols() as $rowIndex => $row) { + foreach (array_keys($row) as $columnIndex) { + if ($this->getEventMarkerAt($columnIndex, $rowIndex) === $marker) { + $grid->cells[$rowIndex][$columnIndex] = EditableGrid::createCell(MapCell::BLANK, ['prefix' => '', 'suffix' => '']); } } } $maxX = max(0, min($this->getWidth() - 1, $x + max(1, $width) - 1)); $maxY = max(0, min($this->getHeight() - 1, $y + max(1, $height) - 1)); + $cell = EditableGrid::createCell(CellSymbol::normalize($marker), ['prefix' => '', 'suffix' => '']); for ($row = max(0, $y); $row <= $maxY; $row++) { for ($column = max(0, $x); $column <= $maxX; $column++) { if ($this->hasLayerCell(MapLayers::EVENT, $column, $row)) { - $this->layers->getEventGrid()->cells[$row][$column]['symbol'] = $marker; + $grid->cells[$row][$column] = $cell; } } } @@ -1686,14 +1722,14 @@ private function evaluateFiles(array $paths, ?array &$fingerprints = null): arra * @param string $directory The destination map directory. * @param string $baseName The base filename. * @param string $displayName The map display name. - * @param int $width The map width. + * @param int $width The map width, in cells. * @param int $height The map height. * @return void */ - public static function createBlank(string $directory, string $baseName, string $displayName, int $width = 48, int $height = 18, ?FileSetOperations $files = null): void + public static function createBlank(string $directory, string $baseName, string $displayName, int $width = 24, int $height = 18, ?FileSetOperations $files = null): void { - $blankTileLine = str_repeat(' ', $width); - $blankEventLine = str_repeat(' ', $width); + $blankTileLine = str_repeat(MapCell::BLANK, $width); + $blankEventLine = str_repeat(MapCell::BLANK, $width); $tileText = implode(PHP_EOL, array_fill(0, $height, $blankTileLine)); $eventText = implode(PHP_EOL, array_fill(0, $height, $blankEventLine)); $data = [ @@ -1809,31 +1845,21 @@ public function duplicateTo(string $directory, string $baseName, string $display public function getCharacterPalette(?string $layer = null): array { $symbols = []; + $grids = [$layer === null ? $this->layers->getBaseGrid() : $this->layers->getGrid($layer), $this->layers->getEventGrid()]; - foreach (($layer === null ? $this->layers->getBaseGrid() : $this->layers->getGrid($layer))->cells as $row) { - foreach ($row as $cell) { - $symbol = $cell['symbol']; - - if (trim($symbol) === '') { - continue; - } - - $symbols[$symbol] = $symbol; - } - } - - foreach ($this->layers->getEventGrid()->getSymbols() as $row) { - foreach ($row as $symbol) { - if (trim($symbol) === '') { - continue; + foreach ($grids as $grid) { + foreach ($grid->getSymbols() as $row) { + foreach ($row as $symbol) { + if (! MapCell::isBlank($symbol)) { + $symbols[$symbol] = $symbol; + } } - - $symbols[$symbol] = $symbol; } } foreach ([' ', ';', '~', '+', '-', '=', '|', '/', '\\', '_', '█', '░', '▒', '▓', '🧍', '🏃🏽‍➡️', '@'] as $symbol) { - $symbols[$symbol] = $symbol; + $cell = CellSymbol::normalize($symbol); + $symbols[$cell] = $cell; } return array_values($symbols); @@ -1850,36 +1876,6 @@ private static function splitMapText(string $text): array return preg_split('/\R/u', rtrim($text, "\r\n")) ?: []; } - /** - * Normalizes input down to a single visible symbol. - * - * @param string $symbol The raw input symbol. - * @return string - */ - private static function normalizeSymbol(string $symbol): string - { - $symbols = self::toSymbols($symbol); - - return $symbols[0] ?? ' '; - } - - /** - * Splits a line into Unicode grapheme symbols. - * - * @param string $line The line to split. - * @return string[] - */ - private static function toSymbols(string $line): array - { - if ($line === '') { - return []; - } - - preg_match_all('/\X/u', $line, $matches); - - return $matches[0] ?? []; - } - /** * The tile file the current grid should be saved as. * @@ -2405,37 +2401,6 @@ private static function slugify(string $value, string $fallback = 'new-map'): st return $value !== '' ? $value : $fallback; } - /** - * Creates a blank tile cell for padded map rows. - * - * @return array{symbol: string, prefix: string, suffix: string} - */ - private static function createBlankTileCell(): array - { - return [ - 'symbol' => ' ', - 'prefix' => '', - 'suffix' => '', - ]; - } - - /** - * Creates a blank tile row. - * - * @param int $width The desired row width. - * @return array - */ - private static function createBlankTileRow(int $width): array - { - $row = []; - - for ($column = 0; $column < $width; $column++) { - $row[] = self::createBlankTileCell(); - } - - return $row; - } - /** * Exports a PHP value using the repository's short-array style. * diff --git a/tests/Fixtures/phase7-project/assets/Maps/technical-map/technical-map.event.php b/tests/Fixtures/phase7-project/assets/Maps/technical-map/technical-map.event.php index 072afcb..f518eeb 100644 --- a/tests/Fixtures/phase7-project/assets/Maps/technical-map/technical-map.event.php +++ b/tests/Fixtures/phase7-project/assets/Maps/technical-map/technical-map.event.php @@ -1,9 +1,9 @@ ~~~ # -# # -# # -############ +######################## +## ~~~~~~ ## +## ## +## ## +######################## ICHILOTO_MAP; diff --git a/tests/Support/LayeredMapFixtures.php b/tests/Support/LayeredMapFixtures.php index 296c957..a9ccbc5 100644 --- a/tests/Support/LayeredMapFixtures.php +++ b/tests/Support/LayeredMapFixtures.php @@ -12,15 +12,16 @@ function layeredMapProject(bool $ragged = false): string $directory = $root . '/assets/Maps/test-map'; unlink($directory . '/test-map.map.php'); mkdir($directory . '/layers'); - $terrain = "....\n...."; - $building = " / \n xx "; - $decoration = "d \n d"; - $events = " E\n "; + // Every cell is two terminal columns; each fixture cell repeats one character. + $terrain = "........\n........"; + $building = " // \n xxxx "; + $decoration = "dd \n dd"; + $events = " EE\n "; if ($ragged) { - $terrain = "....\n.."; - $building = " / \nx "; - $decoration = "d \n d"; - $events = " E\n "; + $terrain = "........\n...."; + $building = " // \nxx "; + $decoration = "dd \n dd"; + $events = " EE\n "; } foreach (['01.terrain.map.php' => $terrain, '04.buildings.map.php' => $building, '07.detail.deco.php' => $decoration] as $file => $text) { $source = MapGridSource::buildSource($text, 'AUTHORED', '// keep ' . $file . "\n"); diff --git a/tests/Support/MapFixtures.php b/tests/Support/MapFixtures.php index b15539d..b1b861b 100644 --- a/tests/Support/MapFixtures.php +++ b/tests/Support/MapFixtures.php @@ -126,3 +126,50 @@ function backdateTriplet(ProjectMap $map): void touch($path, time() - 3600); } } + +/** + * Loads the fixture map fresh from disk. + */ +function fixtureMap(): ProjectMap +{ + $mapsRoot = fixturePath('sample-project/assets/Maps'); + + return ProjectMap::fromDirectory($mapsRoot, $mapsRoot . '/test-map'); +} + +/** + * Copies the fixture map into a scratch maps root for save tests. + */ +function scratchMapCopy(): array +{ + $root = rememberTemporaryProject(sys_get_temp_dir() . '/ichiloto-editor-test-' . bin2hex(random_bytes(4))); + $mapsRoot = $root . '/assets/Maps'; + $directory = $mapsRoot . '/test-map'; + mkdir($directory, 0777, true); + + foreach (['data', 'map', 'event'] as $part) { + copy( + fixturePath("sample-project/assets/Maps/test-map/test-map.{$part}.php"), + "{$directory}/test-map.{$part}.php", + ); + } + + return [$root, ProjectMap::fromDirectory($mapsRoot, $directory)]; +} + +/** + * Removes a scratch tree created by scratchMapCopy(). + */ +function removeScratchTree(string $root): void +{ + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($root); +} diff --git a/tests/Unit/CommandHistoryTest.php b/tests/Unit/CommandHistoryTest.php index 6493b1f..6a0fd6e 100644 --- a/tests/Unit/CommandHistoryTest.php +++ b/tests/Unit/CommandHistoryTest.php @@ -35,8 +35,8 @@ function strokeTestMap(): ProjectMap mapPath: '/virtual/Maps/stroke-map/stroke-map.map.php', eventPath: '/virtual/Maps/stroke-map/stroke-map.event.php', data: ['name' => 'Stroke Map', 'events' => []], - tileLines: ['....', '....', '....'], - eventLines: [' ', ' ', ' '], + tileLines: ['........', '........', '........'], + eventLines: [' ', ' ', ' '], ); } @@ -149,49 +149,49 @@ static function () use ($refuse): void { foreach ([[0, 0], [1, 0], [2, 0]] as [$x, $y]) { $old = $map->getTileSymbol($x, $y); - $map->setTileSymbol($x, $y, '#'); - $stroke->appendCell($x, $y, $old, '#'); + $map->setTileSymbol($x, $y, '##'); + $stroke->appendCell($x, $y, $old, '##'); } $history->record($stroke); expect($history->count())->toBe(1) - ->and($map->getTileSymbol(1, 0))->toBe('#'); + ->and($map->getTileSymbol(1, 0))->toBe('##'); $history->undo(); - expect($map->getTileSymbol(0, 0))->toBe('.') - ->and($map->getTileSymbol(1, 0))->toBe('.') - ->and($map->getTileSymbol(2, 0))->toBe('.'); + expect($map->getTileSymbol(0, 0))->toBe('..') + ->and($map->getTileSymbol(1, 0))->toBe('..') + ->and($map->getTileSymbol(2, 0))->toBe('..'); $history->redo(); - expect($map->getTileSymbol(2, 0))->toBe('#'); + expect($map->getTileSymbol(2, 0))->toBe('##'); }); -it('keeps the first old symbol when a stroke revisits a cell', function () { +it('keeps the first old cell when a stroke revisits a cell', function () { $map = strokeTestMap(); $stroke = new PaintStrokeCommand($map, PaintStrokeCommand::LAYER_TILE); - $stroke->appendCell(1, 1, '.', '#'); - $stroke->appendCell(1, 1, '#', '@'); + $stroke->appendCell(1, 1, '..', '##'); + $stroke->appendCell(1, 1, '##', '@@'); expect($stroke->getCellCount())->toBe(1); - $map->setTileSymbol(1, 1, '@'); + $map->setTileSymbol(1, 1, '@@'); $stroke->undo(); - expect($map->getTileSymbol(1, 1))->toBe('.'); + expect($map->getTileSymbol(1, 1))->toBe('..'); }); -it('reports no changes when a stroke paints identical symbols', function () { +it('reports no changes when a stroke paints identical cells', function () { $map = strokeTestMap(); $stroke = new PaintStrokeCommand($map, PaintStrokeCommand::LAYER_TILE); - $stroke->appendCell(0, 0, '.', '.'); + $stroke->appendCell(0, 0, '..', '..'); expect($stroke->hasChanges())->toBeFalse(); - $stroke->appendCell(1, 0, '.', '#'); + $stroke->appendCell(1, 0, '..', '##'); expect($stroke->hasChanges())->toBeTrue(); }); @@ -199,15 +199,15 @@ static function () use ($refuse): void { it('paints the event layer when constructed for events', function () { $map = strokeTestMap(); $stroke = new PaintStrokeCommand($map, PaintStrokeCommand::LAYER_EVENT, 'Event stroke'); - $map->setEventSymbol(2, 1, 'E'); - $stroke->appendCell(2, 1, ' ', 'E'); + $map->setEventSymbol(2, 1, 'EE'); + $stroke->appendCell(2, 1, ' ', 'EE'); $stroke->undo(); - expect($map->getEventSymbol(2, 1))->toBe(' ') - ->and($map->getTileSymbol(2, 1))->toBe('.'); + expect($map->getEventSymbol(2, 1))->toBe(' ') + ->and($map->getTileSymbol(2, 1))->toBe('..'); $stroke->execute(); - expect($map->getEventSymbol(2, 1))->toBe('E'); + expect($map->getEventSymbol(2, 1))->toBe('EE'); }); diff --git a/tests/Unit/EditableGridTest.php b/tests/Unit/EditableGridTest.php index 9ff46fe..7141434 100644 --- a/tests/Unit/EditableGridTest.php +++ b/tests/Unit/EditableGridTest.php @@ -5,43 +5,43 @@ use Ichiloto\Editor\Maps\EditableGrid; use Ichiloto\Engine\Field\MapGridSource; -it('keys repeated cells by the exact glyph prefix and suffix without merging styled identities', function () { - $text = "000x\n\033[31m00\033[0m0\n0"; +it('keys repeated cells by the exact cell text prefix and suffix without merging styled identities', function () { + $text = "000000xx\n\033[31m0000\033[0m00\n00"; $source = MapGridSource::buildSource($text, 'IDENTITIES'); $grid = new EditableGrid($text, $source); expect($grid->cells)->toBe([ [ - ['symbol' => '0', 'prefix' => '', 'suffix' => ''], - ['symbol' => '0', 'prefix' => '', 'suffix' => ''], - ['symbol' => '0', 'prefix' => '', 'suffix' => ''], - ['symbol' => 'x', 'prefix' => '', 'suffix' => ''], + ['symbol' => '00', 'prefix' => '', 'suffix' => ''], + ['symbol' => '00', 'prefix' => '', 'suffix' => ''], + ['symbol' => '00', 'prefix' => '', 'suffix' => ''], + ['symbol' => 'xx', 'prefix' => '', 'suffix' => ''], ], [ - ['symbol' => '0', 'prefix' => "\033[31m", 'suffix' => "\033[0m"], - ['symbol' => '0', 'prefix' => "\033[31m", 'suffix' => "\033[0m"], - ['symbol' => '0', 'prefix' => '', 'suffix' => ''], + ['symbol' => '00', 'prefix' => "\033[31m", 'suffix' => "\033[0m"], + ['symbol' => '00', 'prefix' => "\033[31m", 'suffix' => "\033[0m"], + ['symbol' => '00', 'prefix' => '', 'suffix' => ''], ], - [['symbol' => '0', 'prefix' => '', 'suffix' => '']], + [['symbol' => '00', 'prefix' => '', 'suffix' => '']], ])->and($grid->getSource())->toBe($source); }); it('keeps repeated cell values independently editable with exact source and snapshot restoration', function () { - $text = "xx\nxx\nx"; + $text = "xxxx\nxxxx\nxx"; $source = MapGridSource::buildSource($text, 'CELLS', "// Preserve the authored rows.\n"); $grid = new EditableGrid($text, $source); $before = $grid->captureSnapshot(); $cell = &$grid->cells[0][0]; - $cell['symbol'] = 'y'; + $cell['symbol'] = 'yy'; $cell['prefix'] = ''; unset($cell); - expect($grid->cells[0][1])->toBe(['symbol' => 'x', 'prefix' => '', 'suffix' => '']) + expect($grid->cells[0][1])->toBe(['symbol' => 'xx', 'prefix' => '', 'suffix' => '']) ->and($grid->cells[1])->toBe($before['cells'][1]) ->and($grid->cells[2])->toBe($before['cells'][2]) - ->and($before['cells'][0][0]['symbol'])->toBe('x') + ->and($before['cells'][0][0]['symbol'])->toBe('xx') ->and($grid->getSource())->toBe(str_replace( - "xx\nxx", - "yx\nxx", + "xxxx\nxxxx", + "yyxx\nxxxx", $source, )); $edited = $grid->captureSnapshot(); @@ -55,12 +55,83 @@ ->and($redone->cells[1][1]['suffix'])->toBe(''); }); -it('holds a multi-map workload of repeated editable cells within the unchanged 128 MiB CLI budget', function () { +it('groups pairs, two-column glyphs and blank cells into whole cells', function () { + $grid = new EditableGrid("##[]🌲 \n.. E🧍"); + expect($grid->getSymbols())->toBe([['##', '[]', '🌲', ' '], ['..', ' E', '🧍']]) + ->and($grid->cells[0][2])->toBe(['symbol' => '🌲', 'prefix' => '', 'suffix' => '']); +}); + +it('keeps each character of a differently styled pair and round-trips it byte for byte', function (string $row) { + $text = $row . "\n##.."; + $source = MapGridSource::buildSource($text, 'PAIRS'); + $grid = new EditableGrid($text, $source); + $pair = $grid->cells[0][0]; + expect($pair['symbol'])->toBe('[]') + ->and(EditableGrid::getCellRuns($pair))->toHaveCount(2) + ->and($grid->getSource())->toBe($source); + // Editing another cell regenerates the row; the pair keeps both styles. + $grid->cells[0][1] = EditableGrid::createCell('##', ['prefix' => '', 'suffix' => '']); + $expected = str_replace($row, preg_replace('/\.\.$/', '##', $row), $source); + expect($grid->getSource())->toBe($expected) + ->and(new EditableGrid(MapGridSource::parseSource($expected, 'PAIRS'))->cells[0][0])->toBe($pair); +})->with([ + 'two colours' => ['[]..'], + 'one uncoloured half' => ['[]..'], + 'raw ANSI' => ["\033[31m[\033[0m\033[34m]\033[0m.."], +]); + +it('keeps a nested style pair byte for byte while its row is unchanged', function () { + $text = "[]..\n####"; + $source = MapGridSource::buildSource($text, 'NESTED'); + $grid = new EditableGrid($text, $source); + $grid->cells[1][0] = EditableGrid::createCell('..', ['prefix' => '', 'suffix' => '']); + expect($grid->cells[0][0]['styles'])->toBe([ + ['prefix' => '', 'suffix' => ''], + ['prefix' => '', 'suffix' => ''], + ])->and($grid->getSource())->toBe(str_replace("\n####", "\n..##", $source)); +}); + +it('restores a differently styled pair from its style and colours it by its first visible character', function () { + $grid = new EditableGrid("[]\n x"); + $pair = $grid->cells[0][0]; + $style = EditableGrid::getCellStyle($pair); + expect($style)->toBe(['prefix' => '', 'suffix' => '', 'styles' => [ + ['prefix' => '', 'suffix' => ''], ['prefix' => '', 'suffix' => ''], + ]]) + ->and(EditableGrid::createCell('[]', $style))->toBe($pair) + ->and(EditableGrid::createCell('##', $style))->toBe(['symbol' => '##', ...$style]) + ->and(EditableGrid::createCell('🌲', $style))->toBe(['symbol' => '🌲', 'prefix' => '', 'suffix' => '']) + ->and($grid->cells[1][0]['prefix'])->toBe(''); +}); + +it('refuses misaligned rows with their row and column, as the Engine does', function (string $text, string $message) { + expect(fn() => new EditableGrid($text, context: 'test/grid.map.php'))->toThrow(InvalidArgumentException::class, $message); +})->with([ + 'glyph after a lone character' => ["####\n#🌲", 'test/grid.map.php row 1, column 1: a two-column glyph must begin a cell'], + 'lone trailing character' => ["###", 'test/grid.map.php row 0 ends halfway through a cell'], +]); + +it('keeps legacy tags as styles and writes them back around the cells they cover', function () { + $text = "##~~~~##"; + $grid = new EditableGrid($text, legacyTags: true); + expect($grid->getSymbols())->toBe([['##', '~~', '~~', '##']]) + ->and($grid->cells[0][1]['prefix'])->toBe(''); + $grid->cells[0][0] = EditableGrid::createCell('@@', ['prefix' => '', 'suffix' => '']); + expect($grid->getLines())->toBe(['@@~~~~##']); +}); + +it('resizes to whole blank cells', function () { + $grid = new EditableGrid("##..\n##.."); + $grid->resize(3, 3); + expect($grid->getSymbols())->toBe([['##', '..', ' '], ['##', '..', ' '], [' ', ' ', ' ']]); +}); + +it('holds a multi-map workload of repeated two-column cells within the unchanged 128 MiB CLI budget', function () { $probe = <<<'PHP' require $argv[1]; $lines = []; for ($row = 0; $row < 96; $row++) { - $lines[] = '' . str_repeat('.', 80) . '' . str_repeat(' ', 80 - $row % 2); + $lines[] = '' . str_repeat('.', 80) . '' . str_repeat(' ', 80 - ($row % 2) * 2); } $text = implode("\n", $lines); $source = \Ichiloto\Engine\Field\MapGridSource::buildSource($text, 'CELLS'); @@ -71,11 +142,11 @@ $cellCount += array_sum(array_map(count(...), $grid->cells)); $grids[] = $grid; } - $grids[0]->cells[0][0]['symbol'] = '#'; + $grids[0]->cells[0][0]['symbol'] = '##'; echo json_encode([ 'grids' => count($grids), 'cells' => $cellCount, - 'independent' => $grids[1]->cells[0][0]['symbol'] === '.' && $grids[0]->cells[0][1]['symbol'] === '.', + 'independent' => $grids[1]->cells[0][0]['symbol'] === '..' && $grids[0]->cells[0][1]['symbol'] === '..', 'limit' => ini_get('memory_limit'), 'peak' => memory_get_peak_usage(true), ], JSON_THROW_ON_ERROR); @@ -96,7 +167,7 @@ ->and($error)->toBe(''); $result = json_decode($output, true, flags: JSON_THROW_ON_ERROR); expect($result['grids'])->toBe(30) - ->and($result['cells'])->toBe(459360) + ->and($result['cells'])->toBe(228960) ->and($result['independent'])->toBeTrue() ->and($result['limit'])->toBe('128M') ->and($result['peak'])->toBeLessThan(128 * 1024 * 1024); diff --git a/tests/Unit/LastLegendMapIntegrityTest.php b/tests/Unit/LastLegendMapIntegrityTest.php index 0f8f9bf..391b269 100644 --- a/tests/Unit/LastLegendMapIntegrityTest.php +++ b/tests/Unit/LastLegendMapIntegrityTest.php @@ -9,6 +9,7 @@ use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Field\MapCell; use Ichiloto\Engine\Field\MapGridSource; use Ichiloto\Engine\Field\MapLayer; use Ichiloto\Engine\Field\MapLayerSource; @@ -211,7 +212,7 @@ function findIntegrityCell(ProjectMap $map, string $layer): array for ($x = 0; $x < $map->getWidth(); $x++) { if ($map->hasLayerCell($layer, $x, $y)) { $first ??= [$x, $y]; - if ($map->getLayerSymbol($layer, $x, $y) !== ' ') { + if (! MapCell::isBlank($map->getLayerSymbol($layer, $x, $y))) { return [$x, $y]; } } @@ -262,10 +263,10 @@ function assertIntegrityRowEdit(string $before, string $after, int $row, string $symbol = $map->getLayerSymbol($layer['id'], $x, $y); $style = $map->getLayerCellStyle($layer['id'], $x, $y); // Preserve decoration crop keys and event identities; recolouring is still a real edit. - $painted = $layer['decoration'] || $layer['id'] === 'event' ? $symbol : ($symbol === '#' ? '@' : '#'); + $painted = $layer['decoration'] || $layer['id'] === 'event' ? $symbol : ($symbol === '##' ? '@@' : '##'); $prefix = $style['prefix'] === '' ? '' : ''; $command = new PaintStrokeCommand($map, $layer['id']); - $command->appendCell($x, $y, $symbol, $painted, $style['prefix'], $style['suffix'], $prefix, ''); + $command->appendCell($x, $y, $symbol, $painted, $style, ['prefix' => $prefix, 'suffix' => '']); $history = new CommandHistory(); $command->execute(); $history->record($command); @@ -338,9 +339,9 @@ function assertIntegrityRowEdit(string $before, string $after, int $row, string } $top = $gameplay[array_key_last($gameplay)]; [$x, $y] = findIntegrityCell($map, $top); - $symbol = TerminalText::stripAnsi($composed[$y][$x]) === '#' ? '@' : '#'; + $symbol = TerminalText::stripAnsi($composed[$y][$x]) === '##' ? '@@' : '##'; $map->setLayerCell($top, $x, $y, $symbol, '', ''); - $composed[$y][$x] = TerminalText::visibleSymbols('' . $symbol . '')[0]; + $composed[$y][$x] = MapCell::parseRow('' . $symbol . '')[0]; $edited = array_map(static fn(array $row): string => rtrim(implode('', $row)), $composed); expect($edited)->not->toBe($expected) ->and($map->renderPreview($map->getWidth(), $map->getHeight(), terminalPreview: true))->toBe($edited, 'unsaved topmost gameplay edit is visible'); diff --git a/tests/Unit/LayeredMapTest.php b/tests/Unit/LayeredMapTest.php index 200a8e9..332e5d3 100644 --- a/tests/Unit/LayeredMapTest.php +++ b/tests/Unit/LayeredMapTest.php @@ -14,16 +14,16 @@ $map = loadLayeredMap(layeredMapProject()); expect(array_column($map->getLayers(), 'name'))->toBe(['terrain', 'buildings', 'detail', 'Events']) ->and(array_column($map->getLayers(), 'order'))->toBe([1, 4, 7, null]) - ->and($map->getLayerSymbol('map:4', 1, 0))->toBe('/') - ->and($map->getLayerSymbol('map:7', 0, 0))->toBe('d'); + ->and($map->getLayerSymbol('map:4', 1, 0))->toBe('//') + ->and($map->getLayerSymbol('map:7', 0, 0))->toBe('dd'); $terminal = array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, terminalPreview: true)); - expect($terminal)->toBe(['./..', '.xx.']); - expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2)))->toBe(['./.E', '.xx.']); + expect($terminal)->toBe(['..//....', '..xxxx..']); + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2)))->toBe(['..//..EE', '..xxxx..']); expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showEventOverlay: false)))->toBe($terminal); foreach ([true, false] as $visible) { - expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['map:7' => $visible])))->toBe(['./.E', '.xx.']); + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['map:7' => $visible])))->toBe(['..//..EE', '..xxxx..']); } - expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['map:4' => false])))->toBe(['...E', '....']) + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['map:4' => false])))->toBe(['......EE', '........']) ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['event' => false])))->toBe($terminal); expect(implode('', $map->renderPreview(4, 2, activeLayer: 'map:4', dimInactive: true)))->toContain("\033[2m") ->and(implode('', $map->renderPreview(4, 2, terminalPreview: true, layerVisibility: ['map:4' => false], dimInactive: true)))->not->toContain("\033[2m"); @@ -35,9 +35,10 @@ ['id' => 'resident', 'name' => 'Resident', 'sprite' => 'N', 'x' => 0, 'y' => 0], ])); $before = $map->captureLayerSnapshot(); - expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showNpcOverlay: true)))->toBe(['N/.E', '.xx.']) - ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showEventOverlay: false, showNpcOverlay: true)))->toBe(['N/..', '.xx.']) - ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showNpcOverlay: true, layerVisibility: ['map:4' => false])))->toBe(['N..E', '....']) + // A one-column sprite sits in the first column of its cell. + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showNpcOverlay: true)))->toBe(['N //..EE', '..xxxx..']) + ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showEventOverlay: false, showNpcOverlay: true)))->toBe(['N //....', '..xxxx..']) + ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showNpcOverlay: true, layerVisibility: ['map:4' => false])))->toBe(['N ....EE', '........']) ->and($map->captureLayerSnapshot())->toBe($before); }); @@ -55,7 +56,7 @@ $map->setLayerCell('map:1', 0, 0, 'T', '', ''); $map->setLayerCell('map:4', 0, 0, '|'); $map->save(); - expect(file_get_contents($path))->toContain('// keep 01.terrain.map.php', "return <<<'AUTHORED'", '....') + expect(file_get_contents($path))->toContain('// keep 01.terrain.map.php', "return <<<'AUTHORED'", '........') ->and(filemtime($map->directory . '/layers/07.detail.deco.php'))->toBe(1000000000) ->and(filemtime($map->eventPath))->toBe(1000000000); $map->setLayerCell('map:1', 0, 0, '.', '', ''); @@ -66,12 +67,12 @@ it('retains CRLF source indentation comments and unedited rows', function () { $root = layeredMapProject(); $path = $root . '/assets/Maps/test-map/layers/04.buildings.map.php'; - $source = "setLayerCell('map:4', 2, 0, '|'); $map->save(); - expect(file_get_contents($path))->toBe(str_replace(' / ', ' / | ', $source)); + expect(file_get_contents($path))->toBe(str_replace(' // ', ' // || ', $source)); }); it('preserves ragged per-row dimensions when creating and round-tripping layers', function () { @@ -84,7 +85,7 @@ foreach ($loaded->getLayerSet()->layers as $layer) { expect(array_map(count(...), $layer->grid))->toBe([4, 2]); } - expect($loaded->getLayerSymbol($id, 1, 1))->toBe('i'); + expect($loaded->getLayerSymbol($id, 1, 1))->toBe('ii'); }); it('rolls the complete layered save and rename back after any installation failure', function () { @@ -172,7 +173,7 @@ it('composes terminal preview with the exact shared Engine style and transparency semantics', function () { $root = layeredMapProject(); $path = $root . '/assets/Maps/test-map/layers/04.buildings.map.php'; - file_put_contents($path, MapGridSource::buildSource(" / \n xx ", 'STYLE')); + file_put_contents($path, MapGridSource::buildSource(" // \n xxxx ", 'STYLE')); $map = loadLayeredMap($root); $expected = array_map(static fn(array $row): string => rtrim(implode('', $row)), $map->getLayerSet()->getComposedGrid()); expect($map->renderPreview(4, 2, showNpcOverlay: true, terminalPreview: true))->toBe($expected); @@ -223,14 +224,14 @@ it('keeps raw ANSI styling separate from editable glyphs and preserves untouched source rows', function (string $prefix, string $color) { $root = layeredMapProject(); $path = $root . '/assets/Maps/test-map/layers/04.buildings.map.php'; - $source = MapGridSource::buildSource($prefix . " / \033[0m\n xx ", 'ANSI'); + $source = MapGridSource::buildSource($prefix . " // \033[0m\n xxxx ", 'ANSI'); file_put_contents($path, $source); $map = loadLayeredMap($root); - expect($map->getLayerSymbol('map:4', 1, 0))->toBe('/') + expect($map->getLayerSymbol('map:4', 1, 0))->toBe('//') ->and($map->getLayerColor('map:4', 1, 0))->toBe($color); $map->setLayerCell('map:4', 0, 1, '|'); $map->save(); - expect(file_get_contents($path))->toBe(str_replace(' xx ', '|xx ', $source)); + expect(file_get_contents($path))->toBe(str_replace(' xxxx ', '||xxxx ', $source)); })->with([ ["\033[31m", 'red'], ["\033[38;2;12;34;56m", '#0c2238'], ["\033[38;5;196m", '#ff0000'], ["\033[38;5;232m", '#080808'], diff --git a/tests/Unit/MapIsolationTest.php b/tests/Unit/MapIsolationTest.php index f479f1f..ec07a62 100644 --- a/tests/Unit/MapIsolationTest.php +++ b/tests/Unit/MapIsolationTest.php @@ -78,13 +78,14 @@ $directory . '/marker-map.map.php', $directory . '/marker-map.event.php', ['name' => 'Marker Map'], - ['ICHILOTO_MAP;'], - ['ICHILOTO_EVENT_MAP;'], + // One trailing space completes each row's last two-column cell. + ['ICHILOTO_MAP; '], + ['ICHILOTO_EVENT_MAP; '], ); $map->save(); - expect(MapGridSource::readFile($map->mapPath))->toBe('ICHILOTO_MAP;') - ->and(MapGridSource::readFile($map->eventPath))->toBe('ICHILOTO_EVENT_MAP;') - ->and(ProjectMap::fromDirectory($root . '/assets/Maps', $directory)->tileLines)->toBe(['ICHILOTO_MAP;']); + expect(MapGridSource::readFile($map->mapPath))->toBe('ICHILOTO_MAP; ') + ->and(MapGridSource::readFile($map->eventPath))->toBe('ICHILOTO_EVENT_MAP; ') + ->and(ProjectMap::fromDirectory($root . '/assets/Maps', $directory)->tileLines)->toBe(['ICHILOTO_MAP; ']); }); diff --git a/tests/Unit/ProjectMapTest.php b/tests/Unit/ProjectMapTest.php index 69dcfce..f062077 100644 --- a/tests/Unit/ProjectMapTest.php +++ b/tests/Unit/ProjectMapTest.php @@ -3,53 +3,7 @@ declare(strict_types=1); use Ichiloto\Editor\ProjectMap; - -/** - * Loads the fixture map fresh from disk. - */ -function fixtureMap(): ProjectMap -{ - $mapsRoot = fixturePath('sample-project/assets/Maps'); - - return ProjectMap::fromDirectory($mapsRoot, $mapsRoot . '/test-map'); -} - -/** - * Copies the fixture map into a scratch maps root for save tests. - */ -function scratchMapCopy(): array -{ - $root = rememberTemporaryProject(sys_get_temp_dir() . '/ichiloto-editor-test-' . bin2hex(random_bytes(4))); - $mapsRoot = $root . '/assets/Maps'; - $directory = $mapsRoot . '/test-map'; - mkdir($directory, 0777, true); - - foreach (['data', 'map', 'event'] as $part) { - copy( - fixturePath("sample-project/assets/Maps/test-map/test-map.{$part}.php"), - "{$directory}/test-map.{$part}.php", - ); - } - - return [$root, ProjectMap::fromDirectory($mapsRoot, $directory)]; -} - -/** - * Removes a scratch tree created by scratchMapCopy(). - */ -function removeScratchTree(string $root): void -{ - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST, - ); - - foreach ($iterator as $item) { - $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); - } - - rmdir($root); -} +use Ichiloto\Engine\IO\Console\TerminalText; it('parses the fixture map dimensions and styled tiles', function () { $map = fixtureMap(); @@ -57,8 +11,8 @@ function removeScratchTree(string $root): void expect($map->getWidth())->toBe(12) ->and($map->getHeight())->toBe(5) ->and($map->getDisplayName())->toBe('Test Map') - ->and($map->getTileSymbol(0, 0))->toBe('#') - ->and($map->getTileSymbol(3, 1))->toBe('~') + ->and($map->getTileSymbol(0, 0))->toBe('##') + ->and($map->getTileSymbol(3, 1))->toBe('~~') ->and($map->isDirty())->toBeFalse(); }); @@ -78,7 +32,7 @@ function removeScratchTree(string $root): void $map->setTileSymbol(1, 1, '@'); expect($map->isDirty())->toBeTrue() - ->and($map->getTileSymbol(1, 1))->toBe('@'); + ->and($map->getTileSymbol(1, 1))->toBe('@@'); $map = fixtureMap(); $map->setEventSymbol(2, 2, 'T'); @@ -89,12 +43,13 @@ function removeScratchTree(string $root): void it('renders a preview with the event overlay merged over tiles', function () { $map = fixtureMap(); - $withOverlay = $map->renderPreview(12, 5); - $withoutOverlay = $map->renderPreview(12, 5, showEventOverlay: false); + $withOverlay = array_map(TerminalText::stripAnsi(...), $map->renderPreview(12, 5)); + $withoutOverlay = array_map(TerminalText::stripAnsi(...), $map->renderPreview(12, 5, showEventOverlay: false)); - expect($withOverlay[0])->toBe('############') - ->and($withOverlay[1])->toBe('# ~~E #') - ->and($withoutOverlay[1])->toBe('# ~~~ #'); + // Every cell is two terminal columns of the preview. + expect($withOverlay[0])->toBe('########################') + ->and($withOverlay[1])->toBe('## ~~~~EE ##') + ->and($withoutOverlay[1])->toBe('## ~~~~~~ ##'); }); it('renders an offset preview window', function () { @@ -102,7 +57,7 @@ function removeScratchTree(string $root): void $lines = $map->renderPreview(4, 2, offsetX: 3, offsetY: 1); expect($lines)->toHaveCount(2) - ->and($lines[0])->toBe('~~E'); + ->and(TerminalText::stripAnsi($lines[0]))->toBe('~~~~EE'); }); it('resizes the grid preserving existing content', function () { @@ -111,14 +66,14 @@ function removeScratchTree(string $root): void expect($map->getWidth())->toBe(14) ->and($map->getHeight())->toBe(6) - ->and($map->getTileSymbol(0, 0))->toBe('#') - ->and($map->getTileSymbol(13, 5))->toBe(' '); + ->and($map->getTileSymbol(0, 0))->toBe('##') + ->and($map->getTileSymbol(13, 5))->toBe(' '); $map->resize(6, 3); expect($map->getWidth())->toBe(6) ->and($map->getHeight())->toBe(3) - ->and($map->getTileSymbol(3, 1))->toBe('~'); + ->and($map->getTileSymbol(3, 1))->toBe('~~'); }); it('restores a captured grid snapshot', function () { @@ -131,7 +86,7 @@ function removeScratchTree(string $root): void expect($map->getWidth())->toBe(12) ->and($map->getHeight())->toBe(5) - ->and($map->getTileSymbol(1, 1))->toBe(' ') + ->and($map->getTileSymbol(1, 1))->toBe(' ') // The snapshot restored the exact loaded content, and dirty is a fact // about content now: back to the baseline is back to pristine. ->and($map->isDirty())->toBeFalse(); @@ -193,8 +148,8 @@ function removeScratchTree(string $root): void $rawMap = (string) file_get_contents($root . '/assets/Maps/test-map/test-map.map.php'); // The styled water run survives the round trip and the edit lands. - expect($rawMap)->toContain('~~~') - ->and(ProjectMap::fromDirectory($root . '/assets/Maps', $root . '/assets/Maps/test-map')->getTileSymbol(1, 2))->toBe('@'); + expect($rawMap)->toContain('~~~~~~') + ->and(ProjectMap::fromDirectory($root . '/assets/Maps', $root . '/assets/Maps/test-map')->getTileSymbol(1, 2))->toBe('@@'); } finally { removeScratchTree($root); } @@ -283,11 +238,11 @@ function styledScratchMap(string $root, array $rows): array try { [, $map] = styledScratchMap($root, [ - '############', - '##### #', - '# #', - '# #', - '############', + '########################', + '########## ##', + '## ##', + '## ##', + '########################', ]); foreach ([1, 2, 3, 4] as $x) { @@ -295,7 +250,7 @@ function styledScratchMap(string $root, array $rows): array } expect($map->getTileColor(5, 1))->toBeNull() - ->and(substr_count($map->renderPreview(12, 5)[1], "\033[90m#\033[0m"))->toBe(4); + ->and(substr_count($map->renderPreview(12, 5)[1], "\033[90m##\033[0m"))->toBe(4); } finally { removeScratchTree($root); } @@ -306,11 +261,11 @@ function styledScratchMap(string $root, array $rows): array try { $rows = [ - '############', - '##### #', - '# ~~ # #', - '# #', - '############', + '########################', + '########## ##', + '## ~~~~ ## ##', + '## ##', + '########################', ]; [$mapFile, $map] = styledScratchMap($root, $rows); @@ -321,7 +276,7 @@ function styledScratchMap(string $root, array $rows): array $saved = explode("\n", (string) file_get_contents($mapFile)); - expect($saved[4])->toBe('#### #') + expect($saved[4])->toBe('######## ##') ->and($saved[5])->toBe($rows[2]) ->and($saved[3])->toBe($rows[0]); @@ -341,11 +296,11 @@ function styledScratchMap(string $root, array $rows): array try { $rows = [ - '############', - '##### #', - '# #', - '# #', - '############', + '########################', + '########## ##', + '## ##', + '## ##', + '########################', ]; [$mapFile, $map] = styledScratchMap($root, $rows); $original = (string) file_get_contents($mapFile); @@ -359,8 +314,8 @@ function styledScratchMap(string $root, array $rows): array $saved = explode("\n", (string) file_get_contents($mapFile)); expect($saved[4])->toBe($rows[1]) - ->and($saved[6])->toBe('# x #') - ->and(str_replace('# x #', '# #', (string) file_get_contents($mapFile)))->toBe($original); + ->and($saved[6])->toBe('## xx ##') + ->and(str_replace('## xx ##', $rows[3], (string) file_get_contents($mapFile)))->toBe($original); } finally { removeScratchTree($root); } From a23f15b78b1c2dc3bbf0b2d9255cb42575e12bbf Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 19:12:44 +0200 Subject: [PATCH 043/344] feat(canvas)!: paint and navigate two-column cells The canvas shows the whole cells that fit its columns and addresses the map in cells: offsets, clamping, viewport sync and the wheel count cells, the cursor rests on a cell's first column, and a click on either column selects the cell (a trailing column narrower than a cell selects nothing). Painting fills whole cells. One typed character paints the cell with it repeated; a second one-column key typed as the very next input at the same cell turns it into the pair, amending the first paint so the pair undoes in one step; a third starts over. A two-column glyph fills the cell, erase paints a blank cell, and the event layer never pairs: its brush paints one marker repeated. CellEntry owns the pairing state and dispatchInput forgets it after any other input. The character map offers whole cells (collision keys and reserved glyphs become repeated cells) and takes a typed pair. Flood fill, eyedropper, clipboard, recolour and facade stamps carry whole cells and their per-character styles. Duplicating an NPC places the copy one cell to the right instead of one sprite width. The golden shell snapshot is re-primed for the two-column map preview. --- src/Canvas/CellEntry.php | 77 ++++++ src/Canvas/FacadeCatalogue.php | 4 +- src/Canvas/LayerCanvas.php | 3 +- src/Editor.php | 294 +++++++++++++-------- tests/Unit/CanvasInputRegressionTest.php | 20 +- tests/Unit/CanvasToolsTest.php | 96 ++++--- tests/Unit/EditorSafetyTest.php | 6 +- tests/Unit/EditorShellTest.php | 10 +- tests/Unit/EditorUxTest.php | 4 +- tests/Unit/GoldenFrameTest.php | 2 +- tests/Unit/LayeredCanvasTest.php | 92 +++---- tests/Unit/ListFilterTest.php | 2 +- tests/Unit/MixedMaterialAuthoringTest.php | 39 +-- tests/__snapshots__/main_shell_100x30.ansi | 2 +- 14 files changed, 408 insertions(+), 243 deletions(-) create mode 100644 src/Canvas/CellEntry.php diff --git a/src/Canvas/CellEntry.php b/src/Canvas/CellEntry.php new file mode 100644 index 0000000..d3f9e7a --- /dev/null +++ b/src/Canvas/CellEntry.php @@ -0,0 +1,77 @@ +entered = false; + } + + /** Forgets the pending character unless this input entered one. */ + public function endInput(): void + { + if (! $this->entered) { + $this->pending = null; + } + } + + /** Forgets the pending character, so the next keystroke starts a cell. */ + public function reset(): void + { + $this->pending = null; + } + + /** + * Enters one typed character and returns the cell it paints. + * + * @param bool $pairs Whether a quick second keystroke may form a pair. + * @param string $target Identifies the cell being entered, so a pair + * only forms where its first character was entered. + */ + public function enterCharacter(string $character, bool $pairs = true, string $target = ''): string + { + $this->entered = true; + if (! $pairs || ! CellSymbol::isHalfCell($character)) { + $this->pending = null; + return CellSymbol::normalize($character); + } + if ($this->completesPair($character, $target)) { + $cell = $this->pending . $character; + $this->pending = null; + return $cell; + } + $this->pending = $character; + $this->pendingTarget = $target; + return $character . $character; + } + + /** Whether entering this character at this target would complete a pair. */ + public function completesPair(string $character, string $target = ''): bool + { + return $this->pending !== null && $this->pendingTarget === $target && CellSymbol::isHalfCell($character); + } +} diff --git a/src/Canvas/FacadeCatalogue.php b/src/Canvas/FacadeCatalogue.php index de52e62..a4b5c71 100644 --- a/src/Canvas/FacadeCatalogue.php +++ b/src/Canvas/FacadeCatalogue.php @@ -7,7 +7,7 @@ use Ichiloto\Editor\Maps\EditableGrid; use RuntimeException; -/** Blank-line-separated authored facades; no duplicated shape definitions. */ +/** Blank-line-separated authored facades of two-column cells; no duplicated shape definitions. */ final class FacadeCatalogue { public static function load(string $path): array @@ -17,7 +17,7 @@ public static function load(string $path): array throw new RuntimeException('The facade catalogue cannot be read as UTF-8: ' . $path); } $blocks = preg_split('/(?:\r\n|\n|\r)[ \t]*(?:\r\n|\n|\r)+/', rtrim($text, "\r\n")) ?: []; - return array_values(array_map(static fn(string $block): array => new EditableGrid($block)->cells, + return array_values(array_map(static fn(string $block): array => new EditableGrid($block, context: basename($path))->cells, array_filter($blocks, static fn(string $block): bool => trim($block) !== ''), )); } diff --git a/src/Canvas/LayerCanvas.php b/src/Canvas/LayerCanvas.php index fe3a77f..ea2042f 100644 --- a/src/Canvas/LayerCanvas.php +++ b/src/Canvas/LayerCanvas.php @@ -7,6 +7,7 @@ use Ichiloto\Editor\EditorWindow; use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\MapSourceRefusal; +use Ichiloto\Editor\Maps\EditableGrid; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\UI\Modal; use Ichiloto\Editor\UI\PaletteItem; @@ -266,7 +267,7 @@ private function stampFacadeBrush(): void foreach ($rows as $y => $row) { foreach ($row as $x => $cell) { $writes[] = ['x' => $this->cursorX + $x, 'y' => $this->cursorY + $y, - 'symbol' => $cell['symbol'], 'style' => ['prefix' => $cell['prefix'], 'suffix' => $cell['suffix']]]; + 'symbol' => $cell['symbol'], 'style' => EditableGrid::getCellStyle($cell)]; } } $this->applyCanvasWrites($map, $writes, 'Facade stamp'); diff --git a/src/Editor.php b/src/Editor.php index e978254..72bf996 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -15,6 +15,7 @@ use Ichiloto\Editor\Actors\ActorIdentityMigrationPlan; use Ichiloto\Editor\Backup\BackupWriter; use Ichiloto\Editor\Canvas\CanvasTool; +use Ichiloto\Editor\Canvas\CellEntry; use Ichiloto\Editor\Canvas\Clipboard; use Ichiloto\Editor\Canvas\ToolGeometry; use Ichiloto\Editor\Cutscenes\CutsceneType; @@ -56,6 +57,7 @@ use Ichiloto\Editor\History\PaintStrokeCommand; use Ichiloto\Editor\Inspector\InputControl; use Ichiloto\Editor\Inspector\InputControlType; +use Ichiloto\Editor\Maps\CellSymbol; use Ichiloto\Editor\IO\InputDecoder; use Ichiloto\Editor\IO\InputRouter; use Ichiloto\Editor\IO\KeyBinding; @@ -97,6 +99,7 @@ use Ichiloto\Engine\Entities\Roles\ParameterCurveGenerator; use Ichiloto\Engine\Events\Enumerations\ChestType; use Ichiloto\Engine\Events\Enumerations\LootType; +use Ichiloto\Engine\Field\MapCell; use Ichiloto\Engine\Quests\QuestObjectiveType; use RuntimeException; if (! class_exists(__NAMESPACE__ . chr(92) . 'Animation', false)) { class_alias('Ichiloto' . chr(92) . 'Engine' . chr(92) . 'Animations' . chr(92) . 'Animation', __NAMESPACE__ . chr(92) . 'Animation'); } @@ -328,7 +331,7 @@ final class Editor * The row the help overlay scrolls to keep visible. */ private int $helpScrollRow = 0; - private string $selectedPaintSymbol = ' '; + private string $selectedPaintSymbol = MapCell::BLANK; /** * The active canvas tool. Every tool commits through one * PaintStrokeCommand, so shapes and fills undo in a single step. @@ -695,6 +698,20 @@ final class Editor * The in-flight mouse-drag stroke; recorded as one command on release. */ private ?PaintStrokeCommand $activeStrokeCommand = null; + /** + * The stroke the last canvas write committed, which a pairing keystroke + * amends so a typed pair undoes in one step. + */ + private ?PaintStrokeCommand $lastCanvasStroke = null; + /** + * Typed characters becoming whole cells on the canvas. + */ + private readonly CellEntry $cellEntry; + /** + * The pair being typed in the character map, and the cell it enters. + */ + private readonly CellEntry $characterMapEntry; + private string $characterMapCell = ''; /** * Which guarded action ('quit'|'reload') the unsaved-changes prompt confirms. */ @@ -758,6 +775,8 @@ public function __construct(private readonly string $projectRoot) $this->toasts = new ToastQueue(); $this->commandPalette = new CommandPalette(); $this->clipboard = new Clipboard(); + $this->cellEntry = new CellEntry(); + $this->characterMapEntry = new CellEntry(); $this->assetFilter = new ListFilter(); $this->databaseFilter = new ListFilter(); $this->dialogFilter = new ListFilter(); @@ -895,7 +914,7 @@ private function boot(): void $this->optionDialogField = null; $this->eventOptionDialogTitle = 'Options'; $this->eventOptionDialogEntries = []; - $this->selectedPaintSymbol = ' '; + $this->selectedPaintSymbol = MapCell::BLANK; $this->activeMousePaintButton = null; $this->lastMousePaintPoint = null; $this->characterPaletteIndex = 0; @@ -1236,7 +1255,11 @@ private function handleInput(): void */ private function dispatchInput(string $input): void { + // A pairing keystroke must be the very next input: anything else + // routed in between forgets the half-typed cell. + $this->cellEntry->beginInput(); $this->inputRouter->route($input); + $this->cellEntry->endInput(); } /** @@ -2877,7 +2900,8 @@ private function duplicateSelectedNpc(): void $id = $collection->uniqueIdFor($npc->getName()); $copy = $npc->asCopyWithId($id); - $x = $npc->getX() + $npc->getSpriteWidth(); + // A character occupies one cell, so the copy stands in the next one. + $x = $npc->getX() + 1; if ($x < $map->getWidth() && $collection->indexAt($x, $npc->getY()) === null) { $copy = $copy->movedTo($x, $npc->getY()); @@ -3895,6 +3919,8 @@ private function openCharacterMap(): void $this->isCharacterMapOpen = true; $this->characterPaletteIndex = 0; + $this->characterMapEntry->reset(); + $this->characterMapCell = ''; $this->statusMessage = 'Character map open.'; $this->requestFullRender(); } @@ -3935,6 +3961,21 @@ private function handleCharacterMapInput(string $input, string $normalizedInput) if ($input === "\033") { $this->closeCharacterMap('Character map closed.'); + return; + } + + if ($input === "\177" || $input === "\010") { + $this->characterMapEntry->reset(); + $this->characterMapCell = ''; + $this->renderOverlays(); + return; + } + + // Typing enters a cell of its own: one character fills the cell, a + // second makes a pair such as [], and a third starts over. + if (! str_contains($input, "\033") && preg_match('/\A\X\z/u', $input) === 1 && preg_match('/\A[\x00-\x1F\x7F]\z/', $input) !== 1) { + $this->characterMapCell = $this->characterMapEntry->enterCharacter($input, $this->editingMode !== self::MODE_EVENT); + $this->renderOverlays(); } } @@ -3947,6 +3988,9 @@ private function handleCharacterMapInput(string $input, string $normalizedInput) */ private function moveCharacterPaletteSelection(int $deltaX, int $deltaY): void { + // Choosing from the palette sets aside a typed cell. + $this->characterMapEntry->reset(); + $this->characterMapCell = ''; $palette = $this->getCharacterPalette(); if ($palette === []) { @@ -3970,14 +4014,15 @@ private function moveCharacterPaletteSelection(int $deltaX, int $deltaY): void } /** - * Applies the selected character-map glyph to the current cursor location. + * Applies the typed cell, or else the selected character-map cell, to + * the current cursor location. * * @return void */ private function applyCharacterPaletteSelection(): void { $palette = $this->getCharacterPalette(); - $symbol = $palette[$this->characterPaletteIndex] ?? null; + $symbol = $this->characterMapCell !== '' ? $this->characterMapCell : ($palette[$this->characterPaletteIndex] ?? null); if ($symbol === null) { $this->closeCharacterMap('Character map closed.'); @@ -3985,9 +4030,9 @@ private function applyCharacterPaletteSelection(): void } $this->selectedPaintSymbol = $symbol; - $this->replaceCurrentSymbol($symbol); + $this->replaceCurrentSymbol($this->getBrushCell()); $this->isCharacterMapOpen = false; - $this->statusMessage = sprintf('Placed %s.', $symbol === ' ' ? 'space' : $symbol); + $this->statusMessage = sprintf('Placed %s.', CellSymbol::describe($this->getBrushCell())); $this->requestFullRender(); } @@ -4185,42 +4230,44 @@ private function handleTypedSymbolInput(string $input): void return; } - if ($symbol === ' ') { - $this->adoptPaintSymbol(' '); - return; - } - // Paint mode has no reserved glyphs: %, ^, @ and ? paint like - // anything else. Commands live in Normal mode. - $this->adoptPaintSymbol($symbol); + // anything else. Commands live in Normal mode. One character fills + // the cell; on a map layer a quick second one makes a pair. An event + // cell repeats its one marker. + $pairs = $this->editingMode !== self::MODE_EVENT; + $target = sprintf('%s|%s|%d|%d', $this->getSelectedMap()?->mapId ?? '', $this->getActiveCanvasLayer(), $this->cursorX, $this->cursorY); + $completesPair = $pairs && $this->cellEntry->completesPair($symbol, $target); + $this->adoptPaintSymbol($this->cellEntry->enterCharacter($symbol, $pairs, $target), $completesPair); } /** - * Makes a typed glyph the active paint symbol. + * Makes a typed cell the active paint symbol. * * Under the brush this also paints it, which is the historic behaviour. * Under a shape or selection tool it only loads the brush: an anchored * gesture must not be interrupted by a stray dab where the cursor * happens to sit. * - * @param string $symbol The typed symbol. + * @param string $symbol The whole cell to paint. + * @param bool $completesPair Whether this keystroke turns the previous + * one's cell into a pair, amending its paint into one undo step. * @return void */ - private function adoptPaintSymbol(string $symbol): void + private function adoptPaintSymbol(string $symbol, bool $completesPair = false): void { - $this->selectedPaintSymbol = $symbol; + $this->selectedPaintSymbol = CellSymbol::normalize($symbol); if ($this->canvasTool !== CanvasTool::BRUSH) { $this->setStatus(sprintf( 'Paint symbol is now %s. %s', - $symbol === ' ' ? 'space' : $symbol, + CellSymbol::describe($this->selectedPaintSymbol), $this->describeCanvasToolUsage(), )); $this->renderCanvasArea(); return; } - $this->replaceCurrentSymbol($symbol); + $this->replaceCurrentSymbol($this->getBrushCell(), $completesPair); $this->renderCanvasArea(); } @@ -4233,7 +4280,7 @@ private function adoptPaintSymbol(string $symbol): void private function handleEraseInput(string $input): bool { if ($input === "\177" || $input === "\010") { - $this->adoptPaintSymbol(' '); + $this->adoptPaintSymbol(MapCell::BLANK); return true; } @@ -4241,12 +4288,14 @@ private function handleEraseInput(string $input): bool } /** - * Replaces the current symbol on the active editing layer. + * Replaces the current cell on the active editing layer. * - * @param string $symbol The replacement symbol. + * @param string $symbol The replacement cell. + * @param bool $amendsLastStroke Whether the paint joins the previous + * canvas stroke, as a pairing keystroke does. * @return void */ - private function replaceCurrentSymbol(string $symbol): void + private function replaceCurrentSymbol(string $symbol, bool $amendsLastStroke = false): void { if (! $this->workspace instanceof ProjectWorkspace) { return; @@ -4267,6 +4316,7 @@ private function replaceCurrentSymbol(string $symbol): void ToolGeometry::brush($this->cursorX, $this->cursorY, $this->canvasBrushSize), $symbol, $isEventLayer ? 'Event edit' : 'Tile edit', + $amendsLastStroke ? $this->lastCanvasStroke : null, ); $this->statusMessage = sprintf( @@ -4284,10 +4334,10 @@ private function replaceCurrentSymbol(string $symbol): void */ private function applySelectedPaintSymbol(): void { - $this->replaceCurrentSymbol($this->selectedPaintSymbol); + $this->replaceCurrentSymbol($this->getBrushCell()); $this->statusMessage = sprintf( 'Painted %s at (%d, %d).', - $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, + CellSymbol::describe($this->getBrushCell()), $this->cursorX, $this->cursorY ); @@ -4380,7 +4430,7 @@ private function handleCanvasWheelScroll(MouseEvent $event): bool return true; } - $viewportWidth = $bounds['width']; + $viewportWidth = $bounds['cells']; $viewportHeight = $bounds['height']; $maxOffsetX = max(0, $selectedMap->getWidth() - $viewportWidth); $maxOffsetY = max(0, $selectedMap->getHeight() - $viewportHeight); @@ -4400,7 +4450,9 @@ private function handleCanvasWheelScroll(MouseEvent $event): bool /** * Shared geometry for rendering, cursor visibility, hit testing and scrolling. - * @return array{left: int, top: int, right: int, bottom: int, width: int, height: int} + * Width is in terminal columns; a map cell is MapCell::COLUMNS of them, so + * the canvas shows the whole cells that fit. + * @return array{left: int, top: int, right: int, bottom: int, width: int, height: int, cells: int} */ private function getCanvasPreviewBounds(?array $layout = null): array { @@ -4418,6 +4470,7 @@ private function getCanvasPreviewBounds(?array $layout = null): array 'bottom' => $mapTop + $previewHeight - 1, 'width' => $contentWidth, 'height' => $previewHeight, + 'cells' => max(1, intdiv($contentWidth, MapCell::COLUMNS)), ]; } @@ -4430,7 +4483,9 @@ private function handleCanvasMouseEdit(MouseEvent $event): bool $mapRight = $bounds['right']; $mapBottom = $bounds['bottom']; - if ($event->x < $mapLeft || $event->x > $mapRight || $event->y < $mapTop || $event->y > $mapBottom) { + // A trailing column narrower than a cell belongs to no cell. + if ($event->x < $mapLeft || $event->x > $mapRight || $event->y < $mapTop || $event->y > $mapBottom + || $event->x - $mapLeft >= $bounds['cells'] * MapCell::COLUMNS) { return true; } @@ -4440,7 +4495,7 @@ private function handleCanvasMouseEdit(MouseEvent $event): bool return true; } - $targetX = $this->canvasOffsetX + ($event->x - $mapLeft); + $targetX = $this->canvasOffsetX + intdiv($event->x - $mapLeft, MapCell::COLUMNS); $targetY = $this->canvasOffsetY + ($event->y - $mapTop); if ($targetX < 0 || $targetX >= $selectedMap->getWidth() || $targetY < 0 || $targetY >= $selectedMap->getHeight()) { @@ -4472,11 +4527,11 @@ private function handleCanvasMouseEdit(MouseEvent $event): bool if ($this->editingMode === self::MODE_EVENT) { $this->handleEventCanvasMouseEdit($selectedMap, $event->button, $targetX, $targetY); } else { - $paintSymbol = $event->button === MouseButton::RIGHT_BUTTON ? ' ' : $this->selectedPaintSymbol; + $paintSymbol = $event->button === MouseButton::RIGHT_BUTTON ? MapCell::BLANK : $this->getBrushCell(); $this->paintCanvasStroke($selectedMap, $paintSymbol, $targetX, $targetY, $event->button); $this->statusMessage = sprintf( 'Painted %s at (%d, %d).', - $paintSymbol === ' ' ? 'space' : $paintSymbol, + CellSymbol::describe($paintSymbol), $this->cursorX, $this->cursorY ); @@ -4515,10 +4570,10 @@ private function handleEventCanvasMouseEdit( $isStartingStroke = $this->activeMousePaintButton !== $button || ! is_array($this->lastMousePaintPoint); if ($button === MouseButton::LEFT_BUTTON && $clickedMarker !== null) { - $this->selectedPaintSymbol = $clickedMarker; + $this->selectedPaintSymbol = CellSymbol::normalize($clickedMarker); } - $paintSymbol = $button === MouseButton::RIGHT_BUTTON ? ' ' : $this->selectedPaintSymbol; + $paintSymbol = $button === MouseButton::RIGHT_BUTTON ? MapCell::BLANK : $this->getBrushCell(); $this->paintCanvasStroke($selectedMap, $paintSymbol, $targetX, $targetY, $button, true); if ($button === MouseButton::LEFT_BUTTON && $clickedMarker !== null && $isStartingStroke) { @@ -4526,10 +4581,11 @@ private function handleEventCanvasMouseEdit( return; } + $marker = MapCell::getMarker($paintSymbol); $this->statusMessage = sprintf( '%s event %s at (%d, %d).', - $paintSymbol === ' ' ? 'Cleared' : 'Placed', - $paintSymbol === ' ' ? 'cell' : $paintSymbol, + $marker === null ? 'Cleared' : 'Placed', + $marker ?? 'cell', $this->cursorX, $this->cursorY, ); @@ -4585,18 +4641,15 @@ private function paintCanvasStroke( $oldSymbol = $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']); $oldStyle = $selectedMap->getLayerCellStyle($this->getActiveCanvasLayer(), $point['x'], $point['y']); - [$newPrefix, $newSuffix] = $this->resolvePaintStyle($symbol, $this->selectedPaintColor, $oldStyle); - $selectedMap->setLayerCell($this->getActiveCanvasLayer(), $point['x'], $point['y'], $symbol, $newPrefix, $newSuffix); - $newSymbol = $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']); + $selectedMap->setStyledLayerCell($this->getActiveCanvasLayer(), $point['x'], $point['y'], $symbol, + $this->resolvePaintStyle($symbol, $this->selectedPaintColor, $oldStyle)); $this->activeStrokeCommand->appendCell( $point['x'], $point['y'], $oldSymbol, - $newSymbol, - $oldStyle['prefix'], - $oldStyle['suffix'], - $newPrefix, - $newSuffix, + $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']), + $oldStyle, + $selectedMap->getLayerCellStyle($this->getActiveCanvasLayer(), $point['x'], $point['y']), ); } @@ -4728,6 +4781,27 @@ private function selectCanvasTool(CanvasTool $tool): void $this->renderCanvasArea(); } + /** + * Returns the cell the brush paints. An event cell holds one marker, so + * on the event layer the brush paints its first marker repeated. + * + * @return string + */ + private function getBrushCell(): string + { + if ($this->editingMode !== self::MODE_EVENT) { + return $this->selectedPaintSymbol; + } + + foreach (MapCell::getCharacters($this->selectedPaintSymbol) as $character) { + if (trim($character) !== '') { + return CellSymbol::normalize($character); + } + } + + return MapCell::BLANK; + } + /** * Returns the layer the canvas tools currently paint on. * @@ -4769,8 +4843,7 @@ private function readCanvasSymbol(ProjectMap $map, int $x, int $y): string */ private function writeCanvasSymbol(ProjectMap $map, int $x, int $y, string $symbol): void { - $style = $map->getLayerCellStyle($this->getActiveCanvasLayer(), $x, $y); - $map->setLayerCell($this->getActiveCanvasLayer(), $x, $y, $symbol, $style['prefix'], $style['suffix']); + $map->setStyledLayerCell($this->getActiveCanvasLayer(), $x, $y, $symbol, $map->getLayerCellStyle($this->getActiveCanvasLayer(), $x, $y)); } /** @@ -4781,18 +4854,21 @@ private function writeCanvasSymbol(ProjectMap $map, int $x, int $y, string $symb * PaintStrokeCommand, so each undoes in exactly one Ctrl+Z. * * @param ProjectMap $map The target map. - * @param array $writes The cells to write. + * @param array}}> $writes The cells to write. * @param string $label The undo/status label. + * @param PaintStrokeCommand|null $amend A recorded stroke of the same map and layer to extend instead of opening a new one. * @return int The number of cells that actually changed. */ - private function applyCanvasWrites(ProjectMap $map, array $writes, string $label): int + private function applyCanvasWrites(ProjectMap $map, array $writes, string $label, ?PaintStrokeCommand $amend = null): int { if ($writes === []) { return 0; } $this->finalizeActiveStroke(); - $stroke = new PaintStrokeCommand($map, $this->getActiveCanvasLayer(), $label); + $amend = $amend?->targets($map) === true ? $amend : null; + $stroke = $amend ?? new PaintStrokeCommand($map, $this->getActiveCanvasLayer(), $label); + $recorded = $amend?->hasChanges() === true; $changed = 0; foreach ($writes as $write) { @@ -4801,36 +4877,24 @@ private function applyCanvasWrites(ProjectMap $map, array $writes, string $label } $oldSymbol = $this->readCanvasSymbol($map, $write['x'], $write['y']); - $oldStyle = $map->getLayerCellStyle($this->getActiveCanvasLayer(), $write['x'], $write['y']); - [$newPrefix, $newSuffix] = isset($write['style']) - ? [$write['style']['prefix'], $write['style']['suffix']] - : $this->resolvePaintStyle( - $write['symbol'], - $write['color'] ?? null, - $oldStyle, - ); - $map->setLayerCell($this->getActiveCanvasLayer(), $write['x'], $write['y'], $write['symbol'], $newPrefix, $newSuffix); + $map->setStyledLayerCell($this->getActiveCanvasLayer(), $write['x'], $write['y'], $write['symbol'], + $write['style'] ?? $this->resolvePaintStyle($write['symbol'], $write['color'] ?? null, $oldStyle)); $newSymbol = $this->readCanvasSymbol($map, $write['x'], $write['y']); - $stroke->appendCell( - $write['x'], - $write['y'], - $oldSymbol, - $newSymbol, - $oldStyle['prefix'], - $oldStyle['suffix'], - $newPrefix, - $newSuffix, - ); + $newStyle = $map->getLayerCellStyle($this->getActiveCanvasLayer(), $write['x'], $write['y']); + $stroke->appendCell($write['x'], $write['y'], $oldSymbol, $newSymbol, $oldStyle, $newStyle); - if ($oldSymbol !== $newSymbol || $oldStyle['prefix'] !== $newPrefix || $oldStyle['suffix'] !== $newSuffix) { + if ($oldSymbol !== $newSymbol || $oldStyle !== $newStyle) { $changed++; } } - if ($stroke->hasChanges()) { + // An amended stroke is already in the history, and it now undoes + // to the state before its first keystroke. + if (! $recorded && $stroke->hasChanges()) { $this->recordCommand($stroke); } + $this->lastCanvasStroke = $stroke; return $changed; } @@ -4839,30 +4903,27 @@ private function applyCanvasWrites(ProjectMap $map, array $writes, string $label * Resolves the styling bytes a tile paint writes. * * The colour directive follows the brush contract: null keeps the - * cell's existing styling byte-for-byte, an empty string paints without - * colour, and any other value becomes an `fg=` tag. A space is always - * uncoloured, so erasing never leaves invisible styling behind. + * cell's existing styling byte-for-byte, each character's included, an + * empty string paints without colour, and any other value becomes an + * `fg=` tag over the whole cell. A blank cell is always uncoloured, so + * erasing never leaves invisible styling behind. * - * @param string $symbol The symbol being painted. + * @param string $symbol The cell being painted. * @param string|null $colorDirective The brush colour directive. - * @param array{prefix: string, suffix: string} $oldStyle The cell's current styling. - * @return array{0: string, 1: string} The prefix and suffix to write. + * @param array{prefix: string, suffix: string, styles?: list} $oldStyle The cell's current styling. + * @return array{prefix: string, suffix: string, styles?: list} The style to write. */ private function resolvePaintStyle(string $symbol, ?string $colorDirective, array $oldStyle): array { - if ($symbol === ' ') { - return ['', '']; + if (MapCell::isBlank($symbol) || $colorDirective === '') { + return ['prefix' => '', 'suffix' => '']; } if ($colorDirective === null) { - return [$oldStyle['prefix'], $oldStyle['suffix']]; - } - - if ($colorDirective === '') { - return ['', '']; + return $oldStyle; } - return [sprintf('', $colorDirective), '']; + return ['prefix' => sprintf('', $colorDirective), 'suffix' => '']; } /** @@ -4872,9 +4933,10 @@ private function resolvePaintStyle(string $symbol, ?string $colorDirective, arra * @param array $cells The cells to paint. * @param string $symbol The symbol to paint. * @param string $label The undo/status label. + * @param PaintStrokeCommand|null $amend The recorded stroke this paint joins, if any. * @return int The number of cells that actually changed. */ - private function paintCanvasCells(ProjectMap $map, array $cells, string $symbol, string $label): int + private function paintCanvasCells(ProjectMap $map, array $cells, string $symbol, string $label, ?PaintStrokeCommand $amend = null): int { // Glyph paints carry the brush colour; the space rule and the event // layer's lack of styling are resolved at the commit path. @@ -4887,6 +4949,7 @@ private function paintCanvasCells(ProjectMap $map, array $cells, string $symbol, $cells, ), $label, + $amend, ); } @@ -5026,7 +5089,7 @@ private function applyCanvasToolAtCursor(): void $changed = $this->paintCanvasCells( $selectedMap, $cells, - $this->selectedPaintSymbol, + $this->getBrushCell(), $this->canvasTool->commandLabel(), ); @@ -5035,7 +5098,7 @@ private function applyCanvasToolAtCursor(): void $this->canvasTool->label(), $changed, $changed === 1 ? '' : 's', - $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, + CellSymbol::describe($this->getBrushCell()), )); $this->renderCanvasArea(); } @@ -5104,7 +5167,7 @@ private function floodFillFromCursor(): void $target = $this->readCanvasSymbol($selectedMap, $this->cursorX, $this->cursorY); - if ($target === $this->selectedPaintSymbol) { + if ($target === $this->getBrushCell()) { $this->setStatus('Flood fill skipped: the region already holds that symbol.', StatusLevel::WARN); $this->renderFooter(); return; @@ -5117,12 +5180,12 @@ private function floodFillFromCursor(): void $this->cursorX, $this->cursorY, ); - $changed = $this->paintCanvasCells($selectedMap, $cells, $this->selectedPaintSymbol, 'flood fill'); + $changed = $this->paintCanvasCells($selectedMap, $cells, $this->getBrushCell(), 'flood fill'); $this->setStatus(sprintf( 'Flood filled %d cell%s with %s.', $changed, $changed === 1 ? '' : 's', - $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, + CellSymbol::describe($this->getBrushCell()), )); $this->renderCanvasArea(); } @@ -5153,7 +5216,7 @@ private function pickSymbolUnderCursor(): void $this->setStatus(sprintf( 'Picked up %s%s from (%d, %d).', - $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, + CellSymbol::describe($this->selectedPaintSymbol), $pickedColor === null ? '' : ' (' . $pickedColor . ')', $this->cursorX, $this->cursorY, @@ -5201,7 +5264,7 @@ private function cutCanvasSelection(): void $selection['x'] + $selection['width'] - 1, $selection['y'] + $selection['height'] - 1, ); - $changed = $this->paintCanvasCells($selectedMap, $cells, ' ', 'cut selection'); + $changed = $this->paintCanvasCells($selectedMap, $cells, MapCell::BLANK, 'cut selection'); $this->setStatus(sprintf( 'Cut %d x %d (%d cell%s cleared).', $this->clipboard->getWidth(), @@ -14077,7 +14140,7 @@ private function clampCanvasOffsets(): void } $bounds = $this->getCanvasPreviewBounds(); - $viewportWidth = $bounds['width']; + $viewportWidth = $bounds['cells']; $viewportHeight = $bounds['height']; $this->canvasOffsetX = max(0, min(max(0, $selectedMap->getWidth() - $viewportWidth), $this->canvasOffsetX)); $this->canvasOffsetY = max(0, min(max(0, $selectedMap->getHeight() - $viewportHeight), $this->canvasOffsetY)); @@ -14116,7 +14179,7 @@ private function clampCursor(): void private function syncViewportToCursor(): void { $bounds = $this->getCanvasPreviewBounds(); - $viewportWidth = $bounds['width']; + $viewportWidth = $bounds['cells']; $viewportHeight = $bounds['height']; if ($this->cursorX < $this->canvasOffsetX) { @@ -14264,19 +14327,19 @@ private function renderCanvasCursor(array $layout): void } $previewRow = $this->cursorY - $this->canvasOffsetY; - $previewColumn = $this->cursorX - $this->canvasOffsetX; + $previewCell = $this->cursorX - $this->canvasOffsetX; $bounds = $this->getCanvasPreviewBounds($layout); $previewHeight = $bounds['height']; - $previewWidth = $bounds['width']; - if ($previewRow < 0 || $previewRow >= $previewHeight || $previewColumn < 0 || $previewColumn >= $previewWidth) { + if ($previewRow < 0 || $previewRow >= $previewHeight || $previewCell < 0 || $previewCell >= $bounds['cells']) { Console::cursor()->hide(); return; } + // The cursor rests on the first of the cell's two columns. Console::cursor()->show(); Console::cursor()->moveTo( - $bounds['left'] + $previewColumn, + $bounds['left'] + $previewCell * MapCell::COLUMNS, $bounds['top'] + $previewRow ); } @@ -14290,16 +14353,16 @@ private function renderCanvasCursor(array $layout): void private function renderCharacterMapOverlay(array $layout): void { $palette = $this->getCharacterPalette(); - $rows = []; + $rows = [sprintf('Type a cell: %s', $this->characterMapCell === '' ? '(one key fills it, two make a pair)' : $this->describeCharacterMapCell($this->characterMapCell)), '']; foreach (array_chunk($palette, self::CHARACTER_MAP_COLUMNS) as $rowIndex => $rowSymbols) { $cells = []; foreach ($rowSymbols as $columnIndex => $symbol) { $index = ($rowIndex * self::CHARACTER_MAP_COLUMNS) + $columnIndex; - $label = $index === $this->characterPaletteIndex - ? sprintf('[%s]', $symbol === ' ' ? '␠' : $symbol) - : sprintf(' %s ', $symbol === ' ' ? '␠' : $symbol); + $label = $index === $this->characterPaletteIndex && $this->characterMapCell === '' + ? sprintf('[%s]', $this->describeCharacterMapCell($symbol)) + : sprintf(' %s ', $this->describeCharacterMapCell($symbol)); $cells[] = $label; } @@ -14313,7 +14376,7 @@ private function renderCharacterMapOverlay(array $layout): void $window = new EditorWindow( title: 'Character Map', - help: 'Enter:Insert Esc:Close', + help: 'Enter:Insert Type:Cell Esc:Close', position: ['x' => $left, 'y' => $top], width: $overlayWidth, height: $overlayHeight, @@ -14324,6 +14387,17 @@ private function renderCharacterMapOverlay(array $layout): void $window->render(); } + /** + * Shows a cell in the character map, spaces made visible. + * + * @param string $cell The cell. + * @return string + */ + private function describeCharacterMapCell(string $cell): string + { + return str_replace(' ', '␠', $cell); + } + /** * Renders the brush colour picker overlay. * @@ -17434,13 +17508,12 @@ private function getCharacterPalette(): array $palette[$symbol] = $symbol; } + // Collision keys and reserved glyphs are single characters; the + // palette offers the whole cell each one paints. $layerName = array_values(array_filter($this->getTerminalCanvasLayers(), fn(array $layer): bool => $layer['id'] === $this->getActiveCanvasLayer()))[0]['name'] ?? null; - foreach ($this->workspace->getCollisionGlyphs($layerName) as $symbol) { - $palette[$symbol] = $symbol; - } - - foreach (['?', '%', '^', '@'] as $symbol) { - $palette[$symbol] = $symbol; + foreach ([...$this->workspace->getCollisionGlyphs($layerName), '?', '%', '^', '@'] as $symbol) { + $cell = CellSymbol::normalize($symbol); + $palette[$cell] = $cell; } return array_values($palette); @@ -18182,6 +18255,7 @@ private function createCanvasWindow(): EditorWindow $bounds = $this->getCanvasPreviewBounds($layout); $contentWidth = $bounds['width']; $contentHeight = $bounds['height'] + ProjectWorkspace::CANVAS_HEADER_ROWS; + $contentCells = $bounds['cells']; return new EditorWindow( title: ($this->focusedPane === self::FOCUS_CANVAS ? 'Canvas [Focus]' : 'Canvas') @@ -18220,7 +18294,7 @@ private function createCanvasWindow(): EditorWindow content: $this->fitLines( $this->workspace?->getCanvasLines( $this->selectedAssetIndex, - $contentWidth, + $contentCells, $contentHeight, $this->canvasOffsetX, $this->canvasOffsetY, diff --git a/tests/Unit/CanvasInputRegressionTest.php b/tests/Unit/CanvasInputRegressionTest.php index a8b9aeb..17aac5f 100644 --- a/tests/Unit/CanvasInputRegressionTest.php +++ b/tests/Unit/CanvasInputRegressionTest.php @@ -36,18 +36,18 @@ function createCanvasRegressionEditor(): array it('preserves copied tile styles through paste undo and redo', function (bool $cut) { [$editor, $map] = createCanvasRegressionEditor(); $source = [ - ['symbol' => 'A', 'prefix' => '', 'suffix' => ''], - ['symbol' => 'B', 'prefix' => '', 'suffix' => ''], - ['symbol' => ' ', 'prefix' => '', 'suffix' => ''], + ['symbol' => 'AA', 'prefix' => '', 'suffix' => ''], + ['symbol' => 'BB', 'prefix' => '', 'suffix' => ''], + ['symbol' => ' ', 'prefix' => '', 'suffix' => ''], ]; foreach ($source as $index => $cell) { $map->setTileCell(1 + $index, 1, ...array_values($cell)); - $map->setTileCell(5 + $index, 2, 'X', '', ''); + $map->setTileCell(5 + $index, 2, 'XX', '', ''); } setEditorProperty($editor, 'canvasSelection', ['x' => 1, 'y' => 1, 'width' => 3, 'height' => 1]); callEditorMethod($editor, $cut ? 'cutCanvasSelection' : 'captureCanvasSelection'); if ($cut) { - expect($map->getTileSymbol(1, 1))->toBe(' ') + expect($map->getTileSymbol(1, 1))->toBe(' ') ->and($map->getTileCellStyle(1, 1))->toBe(['prefix' => '', 'suffix' => '']); } setEditorProperty($editor, 'cursorX', 5); @@ -60,7 +60,7 @@ function createCanvasRegressionEditor(): array } callEditorMethod($editor, 'dispatchInput', "\x1a"); foreach (array_keys($source) as $index) { - expect($map->getTileSymbol(5 + $index, 2))->toBe('X') + expect($map->getTileSymbol(5 + $index, 2))->toBe('XX') ->and($map->getTileCellStyle(5 + $index, 2))->toBe(['prefix' => '', 'suffix' => '']); } callEditorMethod($editor, 'dispatchInput', "\x19"); @@ -122,12 +122,12 @@ function createCanvasRegressionEditor(): array expect(getEditorProperty($editor, 'canvasOffsetX'))->toBeGreaterThan(10); })->with(['map', 'event']); -it('uses the rendered viewport width through wheel undo tab cursor movement and resize', function (int $terminalWidth) { +it('uses the rendered viewport width in cells through wheel undo tab cursor movement and resize', function (int $terminalWidth) { [$editor, $map] = createCanvasRegressionEditor(); setEditorProperty($editor, 'lastTerminalSize', ['width' => $terminalWidth, 'height' => 40]); $map->resize(200, 100); $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); - $lastOffset = $map->getWidth() - $bounds['width']; + $lastOffset = $map->getWidth() - $bounds['cells']; setEditorProperty($editor, 'cursorX', $map->getWidth() - 1); callEditorMethod($editor, 'syncViewportToCursor'); expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe($lastOffset); @@ -142,7 +142,7 @@ function createCanvasRegressionEditor(): array expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe($lastOffset); setEditorProperty($editor, 'canvasOffsetX', 0); - setEditorProperty($editor, 'cursorX', $bounds['width']); + setEditorProperty($editor, 'cursorX', $bounds['cells']); callEditorMethod($editor, 'syncViewportToCursor'); expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(1); @@ -151,7 +151,7 @@ function createCanvasRegressionEditor(): array setEditorProperty($editor, 'canvasOffsetY', 99); callEditorMethod($editor, 'clampCanvasOffsets'); $resized = callEditorMethod($editor, 'getCanvasPreviewBounds'); - expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(200 - $resized['width']) + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(200 - $resized['cells']) ->and(getEditorProperty($editor, 'canvasOffsetY'))->toBe(100 - $resized['height']); $map->resize(2, 2); callEditorMethod($editor, 'clampCanvasOffsets'); diff --git a/tests/Unit/CanvasToolsTest.php b/tests/Unit/CanvasToolsTest.php index 3a601c5..458e6f6 100644 --- a/tests/Unit/CanvasToolsTest.php +++ b/tests/Unit/CanvasToolsTest.php @@ -33,7 +33,15 @@ function placeCursor(Editor $editor, int $x, int $y): void } /** - * Returns the fixture map's current tile grid as strings. + * Returns one cell of the fixture map's current tile grid. + */ +function tileCellAt(Editor $editor, int $x, int $y): string +{ + return getEditorProperty($editor, 'workspace')->getMapByIndex(0)->getTileSymbol($x, $y); +} + +/** + * Returns the fixture map's current tile grid as strings, two columns a cell. * * @return string[] */ @@ -105,7 +113,7 @@ function tileRows(Editor $editor): array $before = tileRows($editor); setEditorProperty($editor, 'canvasTool', CanvasTool::FILLED_RECTANGLE); - setEditorProperty($editor, 'selectedPaintSymbol', 'X'); + setEditorProperty($editor, 'selectedPaintSymbol', 'XX'); placeCursor($editor, 1, 1); callEditorMethod($editor, 'dispatchInput', "\n"); placeCursor($editor, 4, 3); @@ -114,8 +122,8 @@ function tileRows(Editor $editor): array /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); - expect(tileRows($editor)[1])->toBe('#XXXX~ #') - ->and(tileRows($editor)[3])->toBe('#XXXX #') + expect(tileRows($editor)[1])->toBe('##XXXXXXXX~~ ##') + ->and(tileRows($editor)[3])->toBe('##XXXXXXXX ##') ->and($history->count())->toBe(1); callEditorMethod($editor, 'dispatchInput', "\x1a"); @@ -127,7 +135,7 @@ function tileRows(Editor $editor): array it('draws a rectangle outline as one command and leaves the middle alone', function () { $editor = canvasEditor(); setEditorProperty($editor, 'canvasTool', CanvasTool::RECTANGLE); - setEditorProperty($editor, 'selectedPaintSymbol', 'O'); + setEditorProperty($editor, 'selectedPaintSymbol', 'OO'); placeCursor($editor, 1, 1); callEditorMethod($editor, 'dispatchInput', "\n"); placeCursor($editor, 4, 3); @@ -136,8 +144,8 @@ function tileRows(Editor $editor): array /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); - expect(tileRows($editor)[1])->toBe('#OOOO~ #') - ->and(tileRows($editor)[2])->toBe('#O O #') + expect(tileRows($editor)[1])->toBe('##OOOOOOOO~~ ##') + ->and(tileRows($editor)[2])->toBe('##OO OO ##') ->and($history->count())->toBe(1); }); @@ -145,7 +153,7 @@ function tileRows(Editor $editor): array $editor = canvasEditor(); $before = tileRows($editor); setEditorProperty($editor, 'canvasTool', CanvasTool::LINE); - setEditorProperty($editor, 'selectedPaintSymbol', '='); + setEditorProperty($editor, 'selectedPaintSymbol', '=='); placeCursor($editor, 1, 2); callEditorMethod($editor, 'dispatchInput', "\n"); placeCursor($editor, 5, 2); @@ -154,7 +162,7 @@ function tileRows(Editor $editor): array /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); - expect(tileRows($editor)[2])->toBe('#===== #') + expect(tileRows($editor)[2])->toBe('##========== ##') ->and($history->count())->toBe(1); callEditorMethod($editor, 'dispatchInput', "\x1a"); @@ -165,7 +173,7 @@ function tileRows(Editor $editor): array it('flood fills with Ctrl+F and undoes the whole region in ONE Ctrl+Z', function () { $editor = canvasEditor(); $before = tileRows($editor); - setEditorProperty($editor, 'selectedPaintSymbol', '.'); + setEditorProperty($editor, 'selectedPaintSymbol', '..'); placeCursor($editor, 5, 2); callEditorMethod($editor, 'dispatchInput', "\x06"); @@ -174,8 +182,8 @@ function tileRows(Editor $editor): array $filled = tileRows($editor); // The interior floods; the wall of `#` bounds it. - expect($filled[2])->toBe('#..........#') - ->and($filled[1])->toBe('#..~~~.....#') + expect($filled[2])->toBe('##....................##') + ->and($filled[1])->toBe('##....~~~~~~..........##') ->and($filled[0])->toBe($before[0]) ->and($history->count())->toBe(1); @@ -187,7 +195,7 @@ function tileRows(Editor $editor): array it('refuses a flood fill that would change nothing', function () { $editor = canvasEditor(); - setEditorProperty($editor, 'selectedPaintSymbol', '#'); + setEditorProperty($editor, 'selectedPaintSymbol', '##'); placeCursor($editor, 0, 0); callEditorMethod($editor, 'dispatchInput', "\x06"); @@ -199,11 +207,11 @@ function tileRows(Editor $editor): array it('picks up the symbol under the cursor with Ctrl+K', function () { $editor = canvasEditor(); - setEditorProperty($editor, 'selectedPaintSymbol', 'z'); + setEditorProperty($editor, 'selectedPaintSymbol', 'zz'); placeCursor($editor, 0, 0); callEditorMethod($editor, 'dispatchInput', "\x0b"); - expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('#'); + expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('##'); }); it('cycles tools with Ctrl+N and brush widths with Ctrl+W', function () { @@ -232,8 +240,8 @@ function tileRows(Editor $editor): array /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); - expect(tileRows($editor)[1])->toBe('# ~WWW #') - ->and(tileRows($editor)[3])->toBe('# WWW #') + expect(tileRows($editor)[1])->toBe('## ~~WWWWWW ##') + ->and(tileRows($editor)[3])->toBe('## WWWWWW ##') ->and($history->count())->toBe(1); callEditorMethod($editor, 'dispatchInput', "\x1a"); @@ -246,7 +254,7 @@ function tileRows(Editor $editor): array $before = tileRows($editor); // Author a 2x1 block to lift. - setEditorProperty($editor, 'selectedPaintSymbol', 'A'); + setEditorProperty($editor, 'selectedPaintSymbol', 'AA'); placeCursor($editor, 1, 1); callEditorMethod($editor, 'dispatchInput', "\n"); placeCursor($editor, 2, 1); @@ -265,7 +273,7 @@ function tileRows(Editor $editor): array /** @var Clipboard $clipboard */ $clipboard = getEditorProperty($editor, 'clipboard'); - expect($clipboard->getRows())->toBe([['A', 'A']]); + expect($clipboard->getRows())->toBe([['AA', 'AA']]); placeCursor($editor, 6, 3); callEditorMethod($editor, 'dispatchInput', "\x15"); @@ -273,7 +281,7 @@ function tileRows(Editor $editor): array /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); - expect(tileRows($editor)[3])->toBe('# AA #') + expect(tileRows($editor)[3])->toBe('## AAAA ##') ->and($history->count())->toBe(3); // One Ctrl+Z removes the whole stamp. @@ -299,8 +307,8 @@ function tileRows(Editor $editor): array /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); - expect($clipboard->getRows())->toBe([['~', '~', '~']]) - ->and(tileRows($editor)[1])->toBe('# #') + expect($clipboard->getRows())->toBe([['~~', '~~', '~~']]) + ->and(tileRows($editor)[1])->toBe('## ##') ->and($history->count())->toBe(1); callEditorMethod($editor, 'dispatchInput', "\x1a"); @@ -344,7 +352,7 @@ function tileRows(Editor $editor): array /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); - expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('Q') + expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('QQ') ->and(tileRows($editor))->toBe($before) ->and($history->count())->toBe(0); @@ -352,7 +360,7 @@ function tileRows(Editor $editor): array setEditorProperty($editor, 'canvasTool', CanvasTool::BRUSH); callEditorMethod($editor, 'dispatchInput', 'Q'); - expect(tileRows($editor)[2][5])->toBe('Q'); + expect(tileCellAt($editor, 5, 2))->toBe('QQ'); }); it('paints every reserved glyph in Paint mode instead of running commands', function () { @@ -365,7 +373,7 @@ function tileRows(Editor $editor): array foreach (['?', '%', '^', '@'] as $index => $glyph) { placeCursor($editor, 4 + $index, 2); callEditorMethod($editor, 'dispatchInput', $glyph); - expect(tileRows($editor)[2][4 + $index])->toBe($glyph); + expect(tileCellAt($editor, 4 + $index, 2))->toBe($glyph . $glyph); } expect(getEditorProperty($editor, 'isHelpOpen'))->toBeFalse() @@ -420,7 +428,7 @@ function tileRows(Editor $editor): array placeCursor($editor, 5, 2); callEditorMethod($editor, 'dispatchInput', '?'); - expect(tileRows($editor)[2][5])->toBe('?') + expect(tileCellAt($editor, 5, 2))->toBe('??') ->and(getEditorProperty($editor, 'isHelpOpen'))->toBeFalse(); }); @@ -435,13 +443,13 @@ function tileRows(Editor $editor): array callEditorMethod($editor, 'dispatchInput', 'i'); callEditorMethod($editor, 'dispatchInput', '?'); - expect($map->getTileSymbol(5, 2))->toBe('?') + expect($map->getTileSymbol(5, 2))->toBe('??') ->and($map->getTileColor(5, 2))->toBe('bright-cyan') ->and($map->getTileCellStyle(5, 2))->toBe(['prefix' => '', 'suffix' => '']); callEditorMethod($editor, 'dispatchInput', "\x1a"); - expect($map->getTileSymbol(5, 2))->toBe(' ') + expect($map->getTileSymbol(5, 2))->toBe(' ') ->and($map->getTileColor(5, 2))->toBeNull(); }); @@ -457,13 +465,13 @@ function tileRows(Editor $editor): array callEditorMethod($editor, 'dispatchInput', 'i'); callEditorMethod($editor, 'dispatchInput', 'Q'); - expect($map->getTileSymbol(5, 2))->toBe('Q') + expect($map->getTileSymbol(5, 2))->toBe('QQ') ->and($map->getTileCellStyle(5, 2))->toBe(['prefix' => '', 'suffix' => '']); // Undo restores the original symbol with the original bytes. callEditorMethod($editor, 'dispatchInput', "\x1a"); - expect($map->getTileSymbol(5, 2))->toBe('~') + expect($map->getTileSymbol(5, 2))->toBe('~~') ->and($map->getTileCellStyle(5, 2))->toBe(['prefix' => '', 'suffix' => '']); }); @@ -479,7 +487,7 @@ function tileRows(Editor $editor): array callEditorMethod($editor, 'dispatchInput', 'i'); callEditorMethod($editor, 'dispatchInput', "\177"); // Backspace erases. - expect($map->getTileSymbol(5, 2))->toBe(' ') + expect($map->getTileSymbol(5, 2))->toBe(' ') ->and($map->getTileCellStyle(5, 2))->toBe(['prefix' => '', 'suffix' => '']); }); @@ -493,7 +501,7 @@ function tileRows(Editor $editor): array callEditorMethod($editor, 'dispatchInput', 'k'); - expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('m') + expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('mm') ->and(getEditorProperty($editor, 'selectedPaintColor'))->toBe('yellow'); // An uncoloured cell loads an uncoloured brush. @@ -522,7 +530,7 @@ function tileRows(Editor $editor): array expect(getEditorProperty($editor, 'isColorPickerOpen'))->toBeFalse() ->and(getEditorProperty($editor, 'selectedPaintColor'))->toBe('black') - ->and($map->getTileSymbol(5, 2))->toBe('i') + ->and($map->getTileSymbol(5, 2))->toBe('ii') ->and($map->getTileColor(5, 2))->toBe('black'); }); @@ -543,7 +551,7 @@ function tileRows(Editor $editor): array // In Paint mode, o is a glyph. expect(getEditorProperty($editor, 'isColorPickerOpen'))->toBeFalse() - ->and(tileRows($editor)[2][5])->toBe('o'); + ->and(tileCellAt($editor, 5, 2))->toBe('oo'); }); it('exits NPC mode with Esc, cancelling a pending move first', function () { @@ -597,8 +605,8 @@ function tileRows(Editor $editor): array // Stolen shortcut glyphs and dictionary vocabulary are always pickable, // whether or not the current map still contains them. - foreach (['?', '%', '^', '@', 'z'] as $glyph) { - expect($palette)->toContain($glyph); + foreach (['??', '%%', '^^', '@@', 'zz'] as $cell) { + expect($palette)->toContain($cell); } }); @@ -663,15 +671,15 @@ function tileRows(Editor $editor): array // Named colours use the 4-bit palette; hex values use truecolor. Both // reset immediately so neighbouring cells stay untouched. - expect($lines[2])->toContain("\033[92m;\033[0m") - ->and($lines[2])->toContain("\033[38;2;184;115;51m~\033[0m"); + expect($lines[2])->toContain("\033[92m;;\033[0m") + ->and($lines[2])->toContain("\033[38;2;184;115;51m~~\033[0m"); // An event marker over a coloured tile stays plain authoring geometry. $map->setEventSymbol(2, 2, 'A'); $lines = $map->renderPreview(12, 5); expect($lines[2])->not->toContain("\033[92m") - ->and($lines[2])->toContain('A'); + ->and($lines[2])->toContain('AA'); }); /** Builds an SGR mouse press at 1-based terminal coordinates. */ @@ -680,13 +688,13 @@ function mousePress(int $code, int $column, int $row): string return sprintf("\033[<%d;%d;%dM", $code, $column, $row); } -/** Returns the terminal cell of a map coordinate under the current layout. */ +/** Returns the terminal position of a map cell's first column under the current layout. */ function canvasCellAt(Editor $editor, int $mapX, int $mapY): array { $layout = callEditorMethod($editor, 'resolveLayout'); return [ - 2 + $layout['leftWidth'] + $layout['gutter'] + 1 + 1 + $mapX, + 2 + $layout['leftWidth'] + $layout['gutter'] + 1 + 1 + $mapX * 2, 5 + 3 + $mapY, ]; } @@ -705,11 +713,11 @@ function canvasCellAt(Editor $editor, int $mapX, int $mapY): array ->and(tileRows($editor))->toBe($before); // Paint mode: the same click paints the brush symbol. - setEditorProperty($editor, 'selectedPaintSymbol', 'Q'); + setEditorProperty($editor, 'selectedPaintSymbol', 'QQ'); callEditorMethod($editor, 'dispatchInput', 'i'); callEditorMethod($editor, 'dispatchInput', mousePress(0, $column, $row)); - expect(tileRows($editor)[2][4])->toBe('Q'); + expect(tileCellAt($editor, 4, 2))->toBe('QQ'); }); it('scrolls the viewport with the wheel without moving the cursor, until the cursor reclaims it', function () { @@ -766,7 +774,7 @@ function canvasCellAt(Editor $editor, int $mapX, int $mapY): array callEditorMethod($editor, 'dispatchInput', "\n"); foreach ([2, 3, 4, 5] as $x) { - expect($map->getTileSymbol($x, 2))->toBe('#') + expect($map->getTileSymbol($x, 2))->toBe('##') ->and($map->getTileColor($x, 2))->toBe('black', "cell {$x} of the selection"); } diff --git a/tests/Unit/EditorSafetyTest.php b/tests/Unit/EditorSafetyTest.php index 8cd36ff..9affd9b 100644 --- a/tests/Unit/EditorSafetyTest.php +++ b/tests/Unit/EditorSafetyTest.php @@ -31,7 +31,7 @@ function workspaceEditor(): Editor $workspace = getEditorProperty($editor, 'workspace'); $map = $workspace->getMapByIndex(0); - expect($map->getTileSymbol(1, 2))->toBe('@') + expect($map->getTileSymbol(1, 2))->toBe('@@') ->and($map->isDirty())->toBeTrue(); /** @var CommandHistory $history */ @@ -41,11 +41,11 @@ function workspaceEditor(): Editor callEditorMethod($editor, 'performUndo'); - expect($map->getTileSymbol(1, 2))->toBe(' '); + expect($map->getTileSymbol(1, 2))->toBe(' '); callEditorMethod($editor, 'performRedo'); - expect($map->getTileSymbol(1, 2))->toBe('@'); + expect($map->getTileSymbol(1, 2))->toBe('@@'); }); it('does not record a command when the paint is a no-op', function () { diff --git a/tests/Unit/EditorShellTest.php b/tests/Unit/EditorShellTest.php index 801d1b0..02e94ec 100644 --- a/tests/Unit/EditorShellTest.php +++ b/tests/Unit/EditorShellTest.php @@ -102,14 +102,14 @@ function flushShell(Editor $editor): string /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe(' '); + expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe(' '); // Canvas focused: Paint mode enters and the same key paints. setEditorProperty($editor, 'focusedPane', 'canvas'); callEditorMethod($editor, 'dispatchInput', 'i'); // Paint mode. callEditorMethod($editor, 'dispatchInput', 'x'); - expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe('x'); + expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe('xx'); }); it('opens and closes the Database screen through the router', function () { @@ -175,15 +175,15 @@ function flushShell(Editor $editor): string /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe('#'); + expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe('##'); callEditorMethod($editor, 'dispatchInput', "\x1a"); - expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe(' '); + expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe(' '); callEditorMethod($editor, 'dispatchInput', "\x19"); - expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe('#'); + expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe('##'); }); it('repaints the header the tick the unsaved truth changes, a save included', function () { diff --git a/tests/Unit/EditorUxTest.php b/tests/Unit/EditorUxTest.php index 5fa7b6b..855d04a 100644 --- a/tests/Unit/EditorUxTest.php +++ b/tests/Unit/EditorUxTest.php @@ -33,7 +33,7 @@ function uxEditor(): Editor /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - expect($workspace->getMapByIndex(0)->getTileSymbol(1, 1))->toBe('!') + expect($workspace->getMapByIndex(0)->getTileSymbol(1, 1))->toBe('!!') ->and(getEditorProperty($editor, 'isDatabaseOpen'))->toBeFalse(); }); @@ -50,7 +50,7 @@ function uxEditor(): Editor setEditorProperty($editor, 'cursorY', 2); callEditorMethod($editor, 'dispatchInput', $glyph); - expect($workspace->getMapByIndex(0)->getTileSymbol($column, 2))->toBe($glyph) + expect($workspace->getMapByIndex(0)->getTileSymbol($column, 2))->toBe($glyph . $glyph) ->and(getEditorProperty($editor, 'cursorX'))->toBe($column) ->and(getEditorProperty($editor, 'cursorY'))->toBe(2); } diff --git a/tests/Unit/GoldenFrameTest.php b/tests/Unit/GoldenFrameTest.php index 6a9013c..67d97d3 100644 --- a/tests/Unit/GoldenFrameTest.php +++ b/tests/Unit/GoldenFrameTest.php @@ -25,7 +25,7 @@ ->and($plainFrame)->toContain('Project: Sample Project') ->and($plainFrame)->toContain('> test-map') ->and($plainFrame)->toContain('Preview: test-map') - ->and($plainFrame)->toContain('############') + ->and($plainFrame)->toContain('########################') ->and($plainFrame)->toContain('Name: Test Map') ->and($plainFrame)->toContain('Ready.'); }); diff --git a/tests/Unit/LayeredCanvasTest.php b/tests/Unit/LayeredCanvasTest.php index d050b9c..f24bd7f 100644 --- a/tests/Unit/LayeredCanvasTest.php +++ b/tests/Unit/LayeredCanvasTest.php @@ -19,9 +19,11 @@ } callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); callEditorMethod($editor, 'dispatchInput', 'i'); - foreach (['[', ']', 'v', 'd', 't', 'o'] as $glyph) { + // Each key paints: one fills the cell, a quick second makes a pair and + // a third starts the next cell over. + foreach (['[' => '[[', ']' => '[]', 'v' => 'vv', 'd' => 'vd', 't' => 'tt', 'o' => 'to'] as $glyph => $cell) { callEditorMethod($editor, 'dispatchInput', $glyph); - expect($map->getLayerSymbol('map:4', 0, 0))->toBe($glyph) + expect($map->getLayerSymbol('map:4', 0, 0))->toBe($cell) ->and(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:4'); } }); @@ -38,7 +40,7 @@ callEditorMethod($editor, 'openColorPicker'); setEditorProperty($editor, 'colorPaletteIndex', 5); // yellow callEditorMethod($editor, 'dispatchInput', "\n"); - expect($map->getLayerSymbol($id, 0, 0))->toBe('Q') + expect($map->getLayerSymbol($id, 0, 0))->toBe('QQ') ->and($map->getLayerColor($id, 0, 0))->toBe('yellow') ->and($map->getLayerColor($id, 1, 0))->toBe('yellow'); callEditorMethod($editor, 'dispatchInput', "\x1a"); @@ -46,10 +48,10 @@ callEditorMethod($editor, 'captureCanvasSelection'); setEditorProperty($editor, 'cursorY', 1); callEditorMethod($editor, 'pasteCanvasClipboard'); - expect($map->getLayerSymbol($id, 0, 1))->toBe('Q') + expect($map->getLayerSymbol($id, 0, 1))->toBe('QQ') ->and($map->getLayerColor($id, 0, 1))->toBe('red'); callEditorMethod($editor, 'pickSymbolUnderCursor'); - expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('Q') + expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('QQ') ->and(getEditorProperty($editor, 'selectedPaintColor'))->toBe('red'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureGridSnapshot())->toBe($before); @@ -58,12 +60,12 @@ callEditorMethod($editor, 'applyCanvasToolAtCursor'); setEditorProperty($editor, 'cursorX', 3); callEditorMethod($editor, 'applyCanvasToolAtCursor'); - expect($map->getLayerSymbol($id, 3, 1))->toBe('Q'); + expect($map->getLayerSymbol($id, 3, 1))->toBe('QQ'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureGridSnapshot())->toBe($before); - setEditorProperty($editor, 'selectedPaintSymbol', 'F'); + setEditorProperty($editor, 'selectedPaintSymbol', 'FF'); callEditorMethod($editor, 'floodFillFromCursor'); - expect($map->getLayerSymbol($id, 3, 1))->toBe('F'); + expect($map->getLayerSymbol($id, 3, 1))->toBe('FF'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureGridSnapshot())->toBe($before); })->with(['map:1', 'map:4', 'event']); @@ -72,45 +74,42 @@ [$editor, $map] = layeredCanvasEditor(); callEditorMethod($editor, 'selectCanvasLayer', $id); callEditorMethod($editor, 'enterPaintMode'); - setEditorProperty($editor, 'selectedPaintSymbol', 'M'); + setEditorProperty($editor, 'selectedPaintSymbol', 'MM'); setEditorProperty($editor, 'selectedPaintColor', 'cyan'); $before = $map->captureGridSnapshot(); $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); - callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + 2, $bounds['top'])); - callEditorMethod($editor, 'dispatchInput', sprintf("\033[<32;%d;%dM", $bounds['left'] + 2, $bounds['top'] + 1)); - callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + 2, $bounds['top'] + 1)); - expect($map->getLayerSymbol($id, 2, 1))->toBe('M') + // Cell 2 spans the canvas's fifth and sixth columns. + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + 4, $bounds['top'])); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<32;%d;%dM", $bounds['left'] + 5, $bounds['top'] + 1)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + 5, $bounds['top'] + 1)); + expect($map->getLayerSymbol($id, 2, 1))->toBe('MM') ->and($map->getLayerColor($id, 2, 1))->toBe('cyan'); callEditorMethod($editor, 'selectCanvasLayer', $id === 'map:1' ? 'map:4' : 'map:1'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureGridSnapshot())->toBe($before); callEditorMethod($editor, 'dispatchInput', "\x19"); - expect($map->getLayerSymbol($id, 2, 1))->toBe('M'); + expect($map->getLayerSymbol($id, 2, 1))->toBe('MM'); })->with(['map:1', 'map:4', 'event']); it('renders readable gameplay immediately and keeps visibility and dimming session-only', function () { [$editor, $map] = layeredCanvasEditor(); $before = $map->captureLayerSnapshot(); $frame = renderEditorPlainFrame($editor, 160, 45); - expect($frame)->toContain('./..', '.xx.') - ->not->toContain('d/..', '.xxd', 'Terminal preview', 't:Terminal'); + expect($frame)->toContain('..//....', '..xxxx..') + ->not->toContain('dd//....', '..xxxxdd', 'Terminal preview', 't:Terminal'); callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); callEditorMethod($editor, 'dispatchInput', 'v'); callEditorMethod($editor, 'dispatchInput', 'd'); - expect(renderEditorPlainFrame($editor, 160, 45))->toContain('....') + expect(renderEditorPlainFrame($editor, 160, 45))->toContain('........') ->and($map->captureLayerSnapshot())->toBe($before) ->and($map->isDirty())->toBeFalse(); callEditorMethod($editor, 'dispatchInput', 'v'); - expect(renderEditorPlainFrame($editor, 160, 45))->toContain('./..', '.xx.') + expect(renderEditorPlainFrame($editor, 160, 45))->toContain('..//....', '..xxxx..') ->and($map->captureLayerSnapshot())->toBe($before); }); -it('edits terminal glyphs immediately through undo redo save and reload without changing graphical bytes', function (bool $invalidGraphics) { +it('edits terminal glyphs immediately through undo redo save and reload without changing graphical bytes', function () { $root = layeredMapProject(); - if ($invalidGraphics) { - $path = $root . '/assets/Maps/test-map/test-map.data.php'; - file_put_contents($path, preg_replace("/'width' => 16/", "'width' => 0", file_get_contents($path), 1)); - } [$editor, $map] = layeredCanvasEditor($root); $before = sourceHashTree($map->directory); $graphicalSource = file_get_contents($map->dataPath); @@ -118,7 +117,7 @@ foreach (['i', 'Z', "\033", "\x13"] as $key) { callEditorMethod($editor, 'dispatchInput', $key); } - expect($map->getLayerSymbol('map:1', 0, 0))->toBe('Z') + expect($map->getLayerSymbol('map:1', 0, 0))->toBe('ZZ') ->and(array_keys(array_diff_assoc(sourceHashTree($map->directory), $before)))->toBe(['layers/01.terrain.map.php']); callEditorMethod($editor, 'dispatchInput', "\x1a"); callEditorMethod($editor, 'dispatchInput', "\x13"); @@ -128,12 +127,12 @@ callEditorMethod($editor, 'dispatchInput', "\x12"); $reloaded = callEditorMethod($editor, 'getSelectedMap'); expect($reloaded)->not->toBe($map) - ->and($reloaded->getLayerSymbol('map:1', 0, 0))->toBe('Z') - ->and(loadLayeredMap($root)->getLayerSymbol('map:1', 0, 0))->toBe('Z') + ->and($reloaded->getLayerSymbol('map:1', 0, 0))->toBe('ZZ') + ->and(loadLayeredMap($root)->getLayerSymbol('map:1', 0, 0))->toBe('ZZ') ->and(file_get_contents($reloaded->dataPath))->toBe($graphicalSource) ->and(file_get_contents($reloaded->directory . '/layers/07.detail.deco.php'))->toBe($decorationSource) - ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('Z/..'); -})->with(['valid graphical source' => false, 'invalid graphical source' => true]); + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('ZZ//....'); +}); it('does not expose a read-only terminal toggle or crop painting warnings', function () { [$editor, $map] = layeredCanvasEditor(); @@ -142,10 +141,10 @@ foreach (['i', 'x', "\033"] as $key) { callEditorMethod($editor, 'dispatchInput', $key); } - expect($map->getLayerSymbol('map:4', 0, 0))->toBe('x') + expect($map->getLayerSymbol('map:4', 0, 0))->toBe('xx') ->and(renderEditorPlainFrame($editor, 160, 45))->not->toContain('Terminal preview', 'Crop mapping', 'cell overrides'); callEditorMethod($editor, 'dispatchInput', "\x1a"); - expect($map->getLayerSymbol('map:4', 0, 0))->toBe(' '); + expect($map->getLayerSymbol('map:4', 0, 0))->toBe(' '); }); it('excludes graphical layers and Tile art from the shell palette and inspector', function () { @@ -178,11 +177,11 @@ $before = $map->captureLayerSnapshot(); callEditorMethod($editor, 'dispatchInput', 'n'); expect(getEditorProperty($editor, 'editingMode'))->toBe('npc') - ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('[N].', '.xx.', 'Name: Resident'); + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('N //....', '..xxxx..', 'Name: Resident'); callEditorMethod($editor, 'dispatchInput', "\033"); callEditorMethod($editor, 'dispatchInput', 'e'); expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('event') - ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('./.E', '.xx.') + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('..//..EE', '..xxxx..') ->and($map->captureLayerSnapshot())->toBe($before); }); @@ -210,16 +209,16 @@ foreach (['i', 'Z', "\033"] as $key) { callEditorMethod($editor, 'dispatchInput', $key); } - expect($map->getLayerSymbol('map:1', 0, 0))->toBe('Z') - ->and($map->getLayerSymbol('map:7', 0, 0))->toBe('d') + expect($map->getLayerSymbol('map:1', 0, 0))->toBe('ZZ') + ->and($map->getLayerSymbol('map:7', 0, 0))->toBe('dd') ->and($map->getEditableData())->toBe($before['data']) - ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('Z/..') + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('ZZ//....') ->not->toContain('Terminal preview'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureLayerSnapshot())->toBe($before); }); -it('requires explicit collision-change confirmation before renaming and preserves local crop source bytes', function (bool $changes) { +it('requires explicit collision-change confirmation before renaming and leaves map data untouched', function (bool $changes) { [$editor, $map, $root] = layeredCanvasEditor(); $dictionaryPath = $root . '/assets/Maps/collisions.php'; file_put_contents($dictionaryPath, ' ["x" => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::SOLID], "houses" => ["x" => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::' . ($changes ? 'PASS_THROUGH' : 'SOLID') . '], "." => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::NONE];'); @@ -242,7 +241,8 @@ expect(getEditorProperty($editor, 'layerPrompt'))->toBeNull() ->and(array_column($map->getLayers(), 'name'))->toContain('houses'); $map->save(); - expect(file_get_contents($map->dataPath))->toBe(str_replace("'buildings' =>", "'houses' =>", $dataSource)) + expect(file_get_contents($map->dataPath))->toBe($dataSource) + ->and(is_file($map->directory . '/layers/04.houses.map.php'))->toBeTrue() ->and(hash_file('sha256', $dictionaryPath))->toBe($disk['assets/Maps/collisions.php']); callEditorMethod($editor, 'dispatchInput', "\x1a"); $map->save(); @@ -274,27 +274,27 @@ $directory = $root . '/assets/Graphics/Tilesets'; mkdir($directory, 0777, true); $path = $directory . '/buildings.txt'; - file_put_contents($path, " /\nxx\n\nabc\n def\n"); + file_put_contents($path, " //\nxxxx\n\naabbcc\n ddeeff\n"); expect(FacadeCatalogue::load($path))->toHaveCount(2); $items = callEditorMethod($editor, 'buildLayerPaletteItems'); expect(array_filter($items, static fn($item): bool => str_starts_with($item->label, 'Facade:')))->toHaveCount(2); callEditorMethod($editor, 'selectFacadeBrush', $path, 0); $before = $map->captureGridSnapshot(); callEditorMethod($editor, 'applyCanvasToolAtCursor'); - expect($map->getLayerSymbol('map:4', 0, 1))->toBe('x'); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('xx'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureGridSnapshot())->toBe($before); - file_put_contents($path, " /\n||\n\nabc\n def\n"); + file_put_contents($path, " //\n||||\n\naabbcc\n ddeeff\n"); callEditorMethod($editor, 'applyCanvasToolAtCursor'); - expect($map->getLayerSymbol('map:4', 0, 1))->toBe('|') - ->and($map->getLayerSymbol('map:1', 0, 1))->toBe('.'); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('||') + ->and($map->getLayerSymbol('map:1', 0, 1))->toBe('..'); }); it('keeps facade mouse clicks selection-only in Normal mode and stamps explicitly or in Paint mode', function () { [$editor, $map, $root] = layeredCanvasEditor(); mkdir($root . '/assets/Graphics/Tilesets', 0777, true); $path = $root . '/assets/Graphics/Tilesets/buildings.txt'; - file_put_contents($path, " /\n||\n"); + file_put_contents($path, " //\n||||\n"); callEditorMethod($editor, 'selectFacadeBrush', $path, 0); $before = $map->captureGridSnapshot(); $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); @@ -304,13 +304,13 @@ callEditorMethod($editor, 'dispatchInput', $release); expect($map->captureGridSnapshot())->toBe($before); callEditorMethod($editor, 'dispatchInput', "\r"); - expect($map->getLayerSymbol('map:4', 0, 1))->toBe('|'); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('||'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureGridSnapshot())->toBe($before); callEditorMethod($editor, 'dispatchInput', 'i'); callEditorMethod($editor, 'dispatchInput', $click); callEditorMethod($editor, 'dispatchInput', $release); - expect($map->getLayerSymbol('map:4', 0, 1))->toBe('|'); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('||'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureGridSnapshot())->toBe($before); }); @@ -321,7 +321,7 @@ [$editor, $map] = layeredCanvasEditor($root); mkdir($root . '/assets/Graphics/Tilesets', 0777, true); $path = $root . '/assets/Graphics/Tilesets/buildings.txt'; - file_put_contents($path, " /\n||\n"); + file_put_contents($path, " //\n||||\n"); callEditorMethod($editor, 'selectFacadeBrush', $path, 0); $before = $map->captureLayerSnapshot(); if ($command === 'asset') { diff --git a/tests/Unit/ListFilterTest.php b/tests/Unit/ListFilterTest.php index 948100e..07a2481 100644 --- a/tests/Unit/ListFilterTest.php +++ b/tests/Unit/ListFilterTest.php @@ -130,7 +130,7 @@ function filterEditor(): Editor /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - expect($workspace->getMapByIndex(0)->getTileSymbol(2, 2))->toBe('/'); + expect($workspace->getMapByIndex(0)->getTileSymbol(2, 2))->toBe('//'); }); it('narrows a Database entry list with / and moves selection inside it', function () { diff --git a/tests/Unit/MixedMaterialAuthoringTest.php b/tests/Unit/MixedMaterialAuthoringTest.php index 2334b51..32658c0 100644 --- a/tests/Unit/MixedMaterialAuthoringTest.php +++ b/tests/Unit/MixedMaterialAuthoringTest.php @@ -15,9 +15,12 @@ function createMixedMaterialProject(): string $directory = $root . '/assets/Maps/test-map'; unlink($directory . '/test-map.map.php'); mkdir($directory . '/layers'); - $blank = implode("\n", array_fill(0, 7, str_repeat(' ', 10))); - $walls = "##########\n# #\n# #\n##########\n# #\n# #\n##########"; - $fixtures = substr_replace($blank, 'i', 4, 1); + // Ten cells across, two terminal columns each. + $blank = implode("\n", array_fill(0, 7, str_repeat(' ', 20))); + $wall = str_repeat('#', 20); + $room = '##' . str_repeat(' ', 16) . '##'; + $walls = implode("\n", [$wall, $room, $room, $wall, $room, $room, $wall]); + $fixtures = substr_replace($blank, 'ii', 8, 2); foreach ([ '01.terrain.map.php' => $blank, '02.floors.deco.php' => $blank, @@ -47,8 +50,8 @@ function paintMixedMaterial(Editor $editor, int $x, int $y, string $symbol): voi { callEditorMethod($editor, 'dispatchInput', "\033"); $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); - callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + $x, $bounds['top'] + $y)); - callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + $x, $bounds['top'] + $y)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + $x * 2, $bounds['top'] + $y)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + $x * 2, $bounds['top'] + $y)); callEditorMethod($editor, 'dispatchInput', 'i'); callEditorMethod($editor, 'dispatchInput', $symbol); callEditorMethod($editor, 'dispatchInput', "\033"); @@ -57,7 +60,7 @@ function paintMixedMaterial(Editor $editor, int $x, int $y, string $symbol): voi function getMixedMaterialCanvasRows(Editor $editor): array { $window = callEditorMethod($editor, 'createCanvasWindow'); - return array_map(static fn(string $line): string => substr(TerminalText::stripAnsi($line), 0, 10), + return array_map(static fn(string $line): string => substr(TerminalText::stripAnsi($line), 0, 20), array_slice($window->content, ProjectWorkspace::CANVAS_HEADER_ROWS, 7)); } @@ -96,9 +99,9 @@ function getMixedMaterialCanvasRows(Editor $editor): array $map->save(); expect(sourceHashTree($root . '/assets/Maps'))->toBe($saved); $reloaded = loadLayeredMap($root); - expect($reloaded->getLayerSymbol('map:2', 7, 1))->toBe('k') - ->and($reloaded->getLayerSymbol('map:3', 2, 4))->toBe('c') - ->and($reloaded->getLayerSymbol('map:5', 5, 3))->toBe('o') + expect($reloaded->getLayerSymbol('map:2', 7, 1))->toBe('kk') + ->and($reloaded->getLayerSymbol('map:3', 2, 4))->toBe('cc') + ->and($reloaded->getLayerSymbol('map:5', 5, 3))->toBe('oo') ->and($reloaded->renderPreview(10, 7))->toBe($terminal) ->and(MapCollisionResolver::resolveLayers($reloaded->getLayerSet(), $dictionary))->toBe($collision) ->and($reloaded->getEditableData()['npcs'][0]['dialogue'])->toBe([['text' => 'Read the notice.']]) @@ -114,7 +117,9 @@ function getMixedMaterialCanvasRows(Editor $editor): array $map->save(); $before = sourceHashTree($root . '/assets/Maps'); [$editor, $map] = layeredCanvasEditor($root); - $terminal = ['####i#####', '# #', '# #', '##########', '# #', '# #', '##########']; + $wall = str_repeat('#', 20); + $room = '##' . str_repeat(' ', 16) . '##'; + $terminal = ['########ii##########', $room, $room, $wall, $room, $room, $wall]; expect(getMixedMaterialCanvasRows($editor))->toBe($terminal); foreach (['map:4', 'map:6', 'event', 'map:1'] as $id) { callEditorMethod($editor, 'dispatchInput', ']'); @@ -130,7 +135,7 @@ function getMixedMaterialCanvasRows(Editor $editor): array paintMixedMaterial($editor, 2, 1, '#'); callEditorMethod($editor, 'dispatchInput', "\x13"); - expect($map->getLayerSymbol('map:1', 2, 1))->toBe('#') + expect($map->getLayerSymbol('map:1', 2, 1))->toBe('##') ->and(array_keys(array_diff_assoc(sourceHashTree($root . '/assets/Maps'), $before)))->toBe(['test-map/layers/01.terrain.map.php']); callEditorMethod($editor, 'dispatchInput', "\x1a"); callEditorMethod($editor, 'dispatchInput', "\x13"); @@ -140,13 +145,13 @@ function getMixedMaterialCanvasRows(Editor $editor): array callEditorMethod($editor, 'dispatchInput', "\x13"); callEditorMethod($editor, 'dispatchInput', "\x12"); $loaded = callEditorMethod($editor, 'getSelectedMap'); - expect($loaded->getLayerSymbol('map:1', 2, 1))->toBe('#') - ->and($loaded->getLayerSymbol('map:2', 1, 1))->toBe('w') - ->and($loaded->getLayerSymbol('map:3', 2, 1))->toBe('r') - ->and($loaded->getLayerSymbol('map:5', 1, 0))->toBe('o'); + expect($loaded->getLayerSymbol('map:1', 2, 1))->toBe('##') + ->and($loaded->getLayerSymbol('map:2', 1, 1))->toBe('ww') + ->and($loaded->getLayerSymbol('map:3', 2, 1))->toBe('rr') + ->and($loaded->getLayerSymbol('map:5', 1, 0))->toBe('oo'); callEditorMethod($editor, 'selectCanvasLayer', 'map:6'); callEditorMethod($editor, 'dispatchInput', 'v'); - expect(getMixedMaterialCanvasRows($editor)[0])->toBe('##########'); + expect(getMixedMaterialCanvasRows($editor)[0])->toBe($wall); callEditorMethod($editor, 'dispatchInput', 'v'); - expect(getMixedMaterialCanvasRows($editor)[0])->toBe('####i#####'); + expect(getMixedMaterialCanvasRows($editor)[0])->toBe('########ii##########'); }); diff --git a/tests/__snapshots__/main_shell_100x30.ansi b/tests/__snapshots__/main_shell_100x30.ansi index acff787..78e95ce 100644 --- a/tests/__snapshots__/main_shell_100x30.ansi +++ b/tests/__snapshots__/main_shell_100x30.ansi @@ -1 +1 @@ -[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint []:Layer ?:Help────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > terrain · visible ││ Events · visible ││ ││ ││ │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file +[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ######################## ││ ## ~~~~~~ ## ││ ## ## ││ ## ## ││ ######################## ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint []:Layer ?:Help────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > terrain · visible ││ Events · visible ││ ││ ││ │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file From 44a4a760cbb8dd2fbf3277f550f1c48d303d6501 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 19:12:54 +0200 Subject: [PATCH 044/344] feat(validation)!: validate two-column cells and the project format - Opening a project (and so validating it) refuses an ichiloto.json format older or newer than ProjectFormat::CURRENT with the Engine's message, which points an older project to `ichiloto upgrade`. - A map row that is not whole cells opens read-only with the Engine's row and column, and validation reports it as an error. - Event cells holding two different markers are errors in project validation and warnings before a save. - Event cue symbols may be one character of up to two columns. - An NPC sprite wider than its cell warns that it overhangs. The one-column overhang warnings are removed: the "overhangs the right edge of the map" warning for wide sprites and the "covers the tile of NPC" neighbour warning no longer apply now that a character owns a whole cell. - A map data file with a tiles2d key warns that it is no longer read. Test fixtures declare the current project format. --- src/ProjectWorkspace.php | 7 +- src/Validation/MapValidator.php | 14 ++++ src/Validation/ProjectValidator.php | 75 ++++++++++++--------- tests/Fixtures/phase7-project/ichiloto.json | 3 +- tests/Fixtures/sample-project/ichiloto.json | 3 +- tests/Unit/MapValidatorTest.php | 18 ++--- tests/Unit/NpcAuthoringTest.php | 32 ++++----- tests/Unit/ProjectValidatorTest.php | 2 +- 8 files changed, 96 insertions(+), 58 deletions(-) diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index f71e94e..fdaeacb 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -13,6 +13,7 @@ use Ichiloto\Editor\Storage\FileSetOperations; use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Editor\Storage\FilesystemFileSetOperations; +use Ichiloto\Engine\Core\ProjectFormat; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use RuntimeException; @@ -104,6 +105,10 @@ private static function loadFromProject(string $projectRoot): self throw new RuntimeException("Unable to parse $configPath."); } + // A project in another format places its contents differently; open + // nothing rather than misplace it. `ichiloto upgrade` converts it. + ProjectFormat::assertSupported($config[ProjectFormat::KEY] ?? null); + $projectName = (string) ($config['name'] ?? basename($projectRoot)); $projectId = trim((string) ($config['id'] ?? '')); $mainFile = (string) ($config['main'] ?? ''); @@ -282,7 +287,7 @@ classDatabase: $this->classDatabase, * Returns a preview of the selected map. * * @param int $selectedMapIndex The selected map index. - * @param int $width The preview width. + * @param int $width The preview width, in two-column cells. * @param int $height The preview height. * @param int $offsetX The horizontal preview offset. * @param int $offsetY The vertical preview offset. diff --git a/src/Validation/MapValidator.php b/src/Validation/MapValidator.php index 10af274..fd814c5 100644 --- a/src/Validation/MapValidator.php +++ b/src/Validation/MapValidator.php @@ -28,13 +28,27 @@ private function __construct() public static function validate(ProjectMap $map, array $mapsById): array { return [ + ...$map->describeConflictingEventCells(), ...self::findMarkersWithoutDefinitions($map), ...self::findNonRectangularMarkers($map), ...self::findDanglingDestinations($map, $mapsById), ...self::findOutOfRangeSpawnPoints($map, $mapsById), + ...self::findRetiredData($map), ]; } + /** + * Finds map data the engine no longer reads. + * + * @return string[] + */ + private static function findRetiredData(ProjectMap $map): array + { + return array_key_exists('tiles2d', $map->getEditableData()) + ? ['tiles2d is no longer read; the map shows its terminal glyphs until it has a tileset.'] + : []; + } + /** * Finds marker shapes the runtime cannot turn into one trigger area. * diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 9113ced..23138ad 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -22,6 +22,7 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Core\WorldConditionType; +use Ichiloto\Engine\Field\MapCell; use Ichiloto\Engine\Field\SkitSpeaker; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; use Ichiloto\Engine\Events\Interpreter\MovementRouteRunner; @@ -1572,6 +1573,7 @@ protected function checkMaps(ProjectWorkspace $workspace): array $issues = [ ...$issues, ...$this->checkLayers($map), + ...$this->checkRetiredMapData($map), ...$this->checkEventMarkers($map), ...$this->checkDoors($map, $workspace->mapIds), ...$this->checkEncounters($map, $troops), @@ -1596,7 +1598,7 @@ protected function checkLayers(ProjectMap $map): array try { $map->validateLayerContracts(); } catch (\Throwable $error) { - return [Issue::error($map->mapId, $error->getMessage(), 'Repair the authored layer or its dictionary/crop table before saving.')]; + return [Issue::error($map->mapId, $error->getMessage(), 'Repair the authored layer or the collision dictionary before saving.')]; } return []; } @@ -1614,6 +1616,25 @@ protected function checkLayers(ProjectMap $map): array )]; } + /** + * Checks for map data the Engine no longer reads. + * + * @param ProjectMap $map The map. + * @return Issue[] The issues found. + */ + protected function checkRetiredMapData(ProjectMap $map): array + { + if (! array_key_exists('tiles2d', $map->getEditableData())) { + return []; + } + + return [Issue::warning( + $map->mapId, + 'Its tiles2d crop table is no longer read.', + 'Glyph-keyed crops cannot address two-column cells; the map shows its terminal glyphs until it has a tileset. Remove the tiles2d key.' + )]; + } + /** * Checks that placed markers and event definitions agree. * @@ -1622,7 +1643,14 @@ protected function checkLayers(ProjectMap $map): array */ protected function checkEventMarkers(ProjectMap $map): array { - $issues = []; + $issues = array_map( + static fn(string $conflict): Issue => Issue::error( + $map->mapId, + $conflict, + 'The engine refuses to load the map. Give each event cell one marker.' + ), + $map->describeConflictingEventCells(), + ); $defined = array_keys((array) ($map->data['events'] ?? [])); $placed = $map->getPlacedEventMarkers(); @@ -1949,7 +1977,7 @@ protected function checkNpcs(ProjectMap $map, ProjectWorkspace $workspace): arra $issues = [ ...$issues, ...$this->checkNpcPlacement($entry, $where, $width, $height, $anchors, $name, $index), - ...$this->checkNpcSprite($entry, $where, $width), + ...$this->checkNpcSprite($entry, $where), ...$this->checkNpcMovement($entry, $where, $width, $height), ...$this->checkNpcDirectionalSprites($entry, $where), ...$this->checkNpcInteractionShapes($entry, $where), @@ -1971,7 +1999,7 @@ protected function checkNpcs(ProjectMap $map, ProjectWorkspace $workspace): arra * @param string $where Where it lives. * @param int $width The map width. * @param int $height The map height. - * @param array $anchors Anchors seen so far, appended to. + * @param array $anchors Anchors seen so far, appended to. * @param string $name The NPC's name. * @param int|string $index The entry index. * @return Issue[] The issues found. @@ -2008,14 +2036,12 @@ protected function checkNpcPlacement(array $entry, string $where, int $width, in $x = intval($entry['x']); $y = intval($entry['y']); - $sprite = is_scalar($entry['sprite'] ?? null) ? strval($entry['sprite']) : '@'; $anchors[] = [ 'x' => $x, 'y' => $y, 'name' => $name !== '' ? $name : sprintf('entry %s', is_int($index) ? $index + 1 : $index), 'where' => $where, 'wanders' => strval($entry['movement'] ?? 'fixed') === 'wander', - 'width' => ProjectNpc::glyphWidth($sprite), ]; if ($x < 0 || $y < 0 || $x >= $width || $y >= $height) { @@ -2030,15 +2056,15 @@ protected function checkNpcPlacement(array $entry, string $where, int $width, in } /** - * Checks the base sprite: text and inside the map, with explicit empty - * text reserved for an interaction whose appearance belongs to the map. + * Checks the base sprite: text that fits the NPC's one cell, with + * explicit empty text reserved for an interaction whose appearance + * belongs to the map. * * @param array $entry The NPC entry. * @param string $where Where it lives. - * @param int $width The map width. * @return Issue[] The issues found. */ - protected function checkNpcSprite(array $entry, string $where, int $width): array + protected function checkNpcSprite(array $entry, string $where): array { if (! array_key_exists('sprite', $entry)) { return []; @@ -2068,11 +2094,11 @@ protected function checkNpcSprite(array $entry, string $where, int $width): arra $columns = ProjectNpc::glyphWidth($sprite); - if ($columns > 1 && is_numeric($entry['x'] ?? null) && intval($entry['x']) + $columns > $width) { + if ($columns > MapCell::COLUMNS) { return [Issue::warning( $where, - sprintf('Its %d-column sprite overhangs the right edge of the map.', $columns), - 'The game anchors a wide glyph at its tile and lets it spill right; part of it is drawn off the map.' + sprintf('Its %d-column sprite overhangs its cell.', $columns), + sprintf('A character occupies one cell of %d columns; a wider sprite is drawn over the cells to its right.', MapCell::COLUMNS) )]; } @@ -2403,7 +2429,7 @@ protected function checkNpcUnknownFields(array $entry, string $where): array * * @param ProjectMap $map The map. * @param ProjectWorkspace $workspace The project. - * @param array $anchors The NPC anchors. + * @param array $anchors The NPC anchors. * @return Issue[] The issues found. */ protected function checkNpcCollisions(ProjectMap $map, ProjectWorkspace $workspace, array $anchors): array @@ -2430,23 +2456,12 @@ protected function checkNpcCollisions(ProjectMap $map, ProjectWorkspace $workspa sprintf('It shares tile (%d, %d) with NPC %s.', $anchor['x'], $anchor['y'], $other['name']), 'The game finds the first NPC on a tile; the other can never be spoken to. Move one.' ); - } elseif ( - $anchor['width'] > 1 - && $anchor['y'] === $other['y'] - && $other['x'] > $anchor['x'] - && $other['x'] < $anchor['x'] + $anchor['width'] - ) { - $issues[] = Issue::warning( - $anchor['where'], - sprintf('Its %d-column sprite covers the tile of NPC %s at (%d, %d).', $anchor['width'], $other['name'], $other['x'], $other['y']), - 'The wide glyph is drawn over the neighbour. Leave a column between them.' - ); } } - $marker = $map->getEventSymbol($anchor['x'], $anchor['y']); + $marker = $map->getEventMarkerAt($anchor['x'], $anchor['y']); - if (trim($marker) !== '' && isset($definitions[$marker])) { + if ($marker !== null && isset($definitions[$marker])) { $class = is_array($definitions[$marker]) ? strval($definitions[$marker]['class'] ?? '') : ''; $classLabel = $class !== '' ? sprintf(' (%s)', basename(str_replace('\\', '/', $class))) : ''; $issues[] = $anchor['wanders'] @@ -3986,7 +4001,7 @@ protected function checkEventCue(mixed $cue, string $where, array $known): array $rawSymbol = $cue['symbol'] ?? ''; if (! is_string($rawSymbol)) { - return [...$issues, Issue::error($where, 'Its event cue symbol is malformed.', 'Use a string containing one terminal cell.')]; + return [...$issues, Issue::error($where, 'Its event cue symbol is malformed.', 'Use a string holding one character that fits one map cell.')]; } $symbol = trim($rawSymbol); @@ -3994,8 +4009,8 @@ protected function checkEventCue(mixed $cue, string $where, array $known): array return $issues; } - if (TerminalText::symbolCount($symbol) !== 1 || TerminalText::displayWidth($symbol) !== 1) { - $issues[] = Issue::error($where, 'Its event cue does not occupy exactly one terminal cell.', 'Choose one single-cell symbol.'); + if (TerminalText::symbolCount($symbol) !== 1 || TerminalText::displayWidth($symbol) > MapCell::COLUMNS) { + $issues[] = Issue::error($where, 'Its event cue is not one character that fits one map cell.', sprintf('Choose one character of up to %d columns.', MapCell::COLUMNS)); } $rawColor = $cue['color'] ?? 'bright-yellow'; diff --git a/tests/Fixtures/phase7-project/ichiloto.json b/tests/Fixtures/phase7-project/ichiloto.json index c06ecab..bd6d2ac 100644 --- a/tests/Fixtures/phase7-project/ichiloto.json +++ b/tests/Fixtures/phase7-project/ichiloto.json @@ -1,5 +1,6 @@ { "id": "ichiloto/phase7-technical-fixture", "name": "Phase 7 Technical Fixture", - "main": "main.php" + "main": "main.php", + "format": 2 } diff --git a/tests/Fixtures/sample-project/ichiloto.json b/tests/Fixtures/sample-project/ichiloto.json index ae01ac7..1f136b3 100644 --- a/tests/Fixtures/sample-project/ichiloto.json +++ b/tests/Fixtures/sample-project/ichiloto.json @@ -1,5 +1,6 @@ { "id": "ichiloto/editor-sample-project", "name": "Sample Project", - "main": "main.php" + "main": "main.php", + "format": 2 } diff --git a/tests/Unit/MapValidatorTest.php b/tests/Unit/MapValidatorTest.php index 511acd8..6971e0c 100644 --- a/tests/Unit/MapValidatorTest.php +++ b/tests/Unit/MapValidatorTest.php @@ -8,7 +8,7 @@ /** * Builds an in-memory map with the given data and event rows. */ -function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ']): ProjectMap +function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ']): ProjectMap { return new ProjectMap( mapId: $mapId, @@ -17,7 +17,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' mapPath: "/virtual/Maps/{$mapId}/{$mapId}.map.php", eventPath: "/virtual/Maps/{$mapId}/{$mapId}.event.php", data: $data, - tileLines: ['....', '....'], + tileLines: ['........', '........'], eventLines: $eventLines, ); } @@ -28,13 +28,13 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' 'events' => [ 'E' => ['class' => 'SomeTrigger', 'data' => []], ], - ], ['E ', ' ']); + ], ['EE ', ' ']); expect(MapValidator::validate($map, ['town' => $map]))->toBe([]); }); it('warns about event markers without definitions', function () { - $map = validatorMap('town', ['name' => 'Town', 'events' => []], ['E ', ' ']); + $map = validatorMap('town', ['name' => 'Town', 'events' => []], ['EE ', ' ']); $warnings = MapValidator::validate($map, ['town' => $map]); @@ -49,7 +49,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' 'events' => [ 'E' => ['class' => 'SomeTrigger', 'data' => []], ], - ], [' E ', 'EEE ']); + ], [' EE ', 'EEEEEE ']); $warnings = MapValidator::validate($map, ['town' => $map]); @@ -64,7 +64,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' 'events' => [ 'T' => ['class' => 'Transfer', 'data' => ['destinationMap' => 'vanished-map']], ], - ], ['T ', ' ']); + ], ['TT ', ' ']); $warnings = MapValidator::validate($map, ['town' => $map]); @@ -86,7 +86,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ], ], ], - ], ['T ', ' ']); + ], ['TT ', ' ']); $warnings = MapValidator::validate($map, ['town' => $map, 'cave' => $destination]); @@ -102,7 +102,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' 'S' => ['class' => 'Spawn', 'data' => ['spawnPoint' => ['x' => 1, 'y' => 1]]], 'B' => ['class' => 'Spawn', 'data' => ['spawnPoint' => ['x' => 9, 'y' => 9]]], ], - ], ['SB ', ' ']); + ], ['SSBB ', ' ']); $warnings = MapValidator::validate($map, ['town' => $map]); @@ -117,7 +117,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' 'events' => [ 'T' => ['class' => 'Transfer', 'data' => ['destinationMap' => 'gone']], ], - ], ['TX ', ' ']); + ], ['TTXX ', ' ']); $warnings = MapValidator::validate($map, ['town' => $map]); diff --git a/tests/Unit/NpcAuthoringTest.php b/tests/Unit/NpcAuthoringTest.php index e1d2ec1..4a26c16 100644 --- a/tests/Unit/NpcAuthoringTest.php +++ b/tests/Unit/NpcAuthoringTest.php @@ -343,7 +343,7 @@ function npcIssueLines(string $root, ?Severity $severity = null): array // The author lands in the Inspector to keep authoring. ->and(getEditorProperty($editor, 'focusedPane'))->toBe('inspector') // Nothing was painted: tile and event layers are untouched. - ->and($map->getTileSymbol(4, 2))->toBe(' ') + ->and($map->getTileSymbol(4, 2))->toBe(' ') ->and(trim($map->getEventSymbol(4, 2)))->toBe(''); // Enter with an empty name falls back to a placeholder rather than @@ -391,7 +391,7 @@ function npcIssueLines(string $root, ?Severity $severity = null): array ->and(getEditorProperty($editor, 'cursorX'))->toBe(2); }); -it('draws NPCs as an overlay: wide glyphs own two columns, styled ones one, the selected one bracketed', function () { +it('draws NPCs as an overlay in their cells: wide glyphs fill it, styled ones take its first column, the selected one reversed', function () { [$root] = npcProject([ ['id' => 'cat', 'name' => 'Cat', 'sprite' => '🐈', 'x' => 2, 'y' => 2], ['id' => 'mum', 'name' => 'Mum', 'sprite' => '@', 'x' => 6, 'y' => 2], @@ -403,18 +403,18 @@ function npcIssueLines(string $root, ?Severity $severity = null): array $overlay = $map->renderPreview(12, 5, showNpcOverlay: true, selectedNpcIndex: 2); // Not persisted art: without the overlay flag the rows are the tiles. - expect($plain[2])->toBe('# #') - // The cat's second column is the empty overhang cell, so the - // terminal draws the emoji in the room it needs; Mum is one plain - // @; the selected guard is bracketed. - ->and($overlay[2])->toBe('# 🐈 @ #') - ->and($overlay[3])->toBe('# [G]#') - ->and(mb_strwidth($overlay[2]))->toBe(12); + expect($plain[2])->toBe('## ##') + // The cat fills its two-column cell; Mum is one plain @ in the + // first column of hers; the selected guard is reversed within its + // cell, so neighbouring cells keep their columns. + ->and($overlay[2])->toBe('## 🐈 @ ##') + ->and($overlay[3])->toBe('##' . str_repeat(' ', 16) . "\033[7mG \033[0m" . ' ##') + ->and(mb_strwidth($overlay[2]))->toBe(24); // A directional sprite previewed for the selected NPC. $previewed = $map->renderPreview(12, 5, showNpcOverlay: true, selectedNpcIndex: 2, selectedNpcSprite: '^'); - // Still the selected NPC, so still bracketed. - expect($previewed[3])->toBe('# [^]#'); + // Still the selected NPC, so still reversed. + expect($previewed[3])->toBe('##' . str_repeat(' ', 16) . "\033[7m^ \033[0m" . ' ##'); }); it('leaves map-owned NPC glyphs untouched and highlights only their selected anchor', function (bool $layered, string $prefix, string $suffix) { @@ -448,11 +448,11 @@ function npcIssueLines(string $root, ?Severity $severity = null): array $allHidden = array_fill_keys(array_column($map->getLayers(), 'id'), false); $anchor = $map->renderPreview(4, 2, showNpcOverlay: true, selectedNpcIndex: 0, layerVisibility: $allHidden); - expect($anchor[0])->toContain("\033[7m \033[0m") + expect($anchor[0])->toContain("\033[7m \033[0m") ->and($anchor[0])->not->toContain('@'); $directional = $map->renderPreview(4, 2, showNpcOverlay: true, selectedNpcIndex: 0, selectedNpcSprite: '^'); - expect($directional[0])->toContain('[^]') + expect($directional[0])->toContain("\033[7m^ \033[0m") ->and($map->captureLayerSnapshot())->toBe($before) ->and(npcHashTree($root))->toBe($files); })->with([ @@ -965,7 +965,8 @@ public function __construct() ['id' => 'mixed', 'name' => 'Mixed', 'x' => 5, 'y' => 3, 'dialogue' => [['conditions' => [], 'lines' => [['text' => 'a']]], ['text' => 'plain']]], ['id' => 'empty', 'name' => 'Empty', 'x' => 6, 'y' => 3, 'sprite' => '', 'dialogue' => [['name' => 'x']]], ['id' => 'keys', 'name' => 'Keys', 'x' => 7, 'y' => 3, 'sprites' => ['up' => '^', 'north' => ['x']]], - ['id' => 'wide', 'name' => 'Wide', 'x' => 11, 'y' => 3, 'sprite' => '🐈'], + ['id' => 'wide', 'name' => 'Wide', 'x' => 11, 'y' => 3, 'sprite' => 'cat'], + ['id' => 'emoji', 'name' => 'Emoji', 'x' => 10, 'y' => 3, 'sprite' => '🐈'], ['id' => 'on-event', 'name' => 'Blocker', 'x' => 5, 'y' => 1], ['name' => 'Legacy', 'x' => 8, 'y' => 1], ]); @@ -1001,7 +1002,8 @@ public function __construct() ->toContain('Its sprite draws nothing') ->toContain('Dialogue page 1 has no text') ->toContain('Its directional sprite key "up" is not a heading') - ->toContain('Its 2-column sprite overhangs the right edge of the map') + ->toContain('NPC Wide: Its 3-column sprite overhangs its cell') + ->not->toContain('NPC Emoji:') ->toContain('NPC Legacy: It has no stable id, so a move_route cannot target it'); // Unbounded wander is legal and says nothing. diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index 72cc306..bac8145 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -395,7 +395,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $source = file_get_contents($path); // Drop a stray marker onto the layer, the way a mis-click would. - file_put_contents($path, preg_replace('/\n( +)\n/', "\nZ\n", $source, 1)); + file_put_contents($path, preg_replace('/\n( +)\n/', "\nZZ\n", $source, 1)); $issues = issuesMentioning(validateProject($root), 'places marker "Z"'); From 67839d9add1e4b09c43d2398e5996b8bea518b0c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 19:13:03 +0200 Subject: [PATCH 045/344] fix(cutscenes): preview cinematics on two-column cells PreviewCamera follows the Engine camera's contract: the world and screen are addressed in cells, and the frame is written in console columns. It sizes itself with resizeToConsole, draws each visible cell in two columns, and places sprites at getConsolePosition. Its renderLayeredMap override is removed with the Engine method it overrode. Dialogue boxes are laid out in console columns, and the preview's first open tile is the first blank cell. --- src/Cutscenes/Editing/CutscenePreviewPane.php | 7 +-- src/Cutscenes/Preview/PreviewCamera.php | 55 +++++++++---------- src/Cutscenes/Preview/PreviewPresentation.php | 3 +- tests/Unit/LayeredPreviewTest.php | 6 +- 4 files changed, 35 insertions(+), 36 deletions(-) diff --git a/src/Cutscenes/Editing/CutscenePreviewPane.php b/src/Cutscenes/Editing/CutscenePreviewPane.php index beef63c..2ceecca 100644 --- a/src/Cutscenes/Editing/CutscenePreviewPane.php +++ b/src/Cutscenes/Editing/CutscenePreviewPane.php @@ -17,6 +17,7 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Status\StatusLevel; use Ichiloto\Editor\UI\CutscenesScreen; +use Ichiloto\Engine\Field\MapCell; use Throwable; /** @@ -867,7 +868,7 @@ private function cinematicPreviewOrigin(CutsceneAsset $asset, ?string $startMap) } /** - * The first tile that is not a wall-like glyph, row by row. + * The first blank cell, row by row. * * @return array{0: int, 1: int} */ @@ -875,9 +876,7 @@ private function firstOpenTile(ProjectMap $map): array { for ($y = 0; $y < $map->getHeight(); $y++) { for ($x = 0; $x < $map->getWidth(); $x++) { - $symbol = $map->getTileSymbol($x, $y); - - if ($symbol === ' ') { + if (MapCell::isBlank($map->getTileSymbol($x, $y))) { return [$x, $y]; } } diff --git a/src/Cutscenes/Preview/PreviewCamera.php b/src/Cutscenes/Preview/PreviewCamera.php index e04cb1a..049aed3 100644 --- a/src/Cutscenes/Preview/PreviewCamera.php +++ b/src/Cutscenes/Preview/PreviewCamera.php @@ -6,7 +6,7 @@ use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\Core\Vector2; -use Ichiloto\Engine\Field\MapLayerSet; +use Ichiloto\Engine\Field\MapCell; use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\Rendering\Camera; @@ -18,27 +18,38 @@ * transition covers — reaches the terminal through the camera, so capturing * here gives the preview pane an honest picture of the moment without the * Engine ever writing a byte over the editor's own screen. + * + * As in the Engine, the world is addressed in map cells and the frame is + * written in console columns, MapCell::COLUMNS to a cell. */ final class PreviewCamera extends Camera { /** @var string[] The captured rows, one per screen line. */ private array $frame = []; + /** + * @param int $width The frame width, in console columns. + * @param int $height The frame height, in rows. + */ public function __construct(int $width, int $height) { - $this->screen = new Rect(0, 0, max(1, $width), max(1, $height)); + $this->screen = new Rect(0, 0, 1, 1); $this->position = new Vector2(0, 0); $this->player = null; $this->worldSpace = []; + $this->resizeToConsole($width, $height); $this->clearFrame(); } /** - * Resizes the captured screen. + * Resizes the captured screen to a console area. + * + * @param int $width The frame width, in console columns. + * @param int $height The frame height, in rows. */ public function resize(int $width, int $height): void { - $this->resizeViewport($width, $height); + $this->resizeToConsole($width, $height); $this->clearFrame(); } @@ -47,7 +58,7 @@ public function resize(int $width, int $height): void */ public function clearFrame(): void { - $this->frame = array_fill(0, $this->screen->getHeight(), str_repeat(' ', $this->screen->getWidth())); + $this->frame = array_fill(0, $this->screen->getHeight(), str_repeat(' ', $this->getConsoleColumns())); } /** @@ -61,34 +72,21 @@ public function frame(): array } /** - * Paints the world (the map tiles) into the frame. + * Paints the world (the map cells) into the frame, two columns a cell. */ public function renderMap(): void { $renderOffset = $this->getRenderOffset(); - $visibleWidth = $this->getVisibleWorldWidth(); - $visibleHeight = $this->getVisibleWorldHeight(); - - for ($row = 0; $row < $visibleHeight; $row++) { - $worldRow = $this->worldSpace[(int) $this->position->y + $row] ?? null; - - if ($worldRow === null) { - continue; - } - - $content = is_array($worldRow) - ? implode('', array_slice($worldRow, (int) $this->position->x, $visibleWidth)) - : TerminalText::sliceSymbols((string) $worldRow, (int) $this->position->x, $visibleWidth); - $this->draw(TerminalText::padRight($content, $visibleWidth), (int) $renderOffset->x, (int) $renderOffset->y + $row); + foreach ($this->visibleMapRows() as $worldY => $cells) { + $this->draw( + TerminalText::padRight(implode('', $cells), count($cells) * MapCell::COLUMNS), + (int) $renderOffset->x * MapCell::COLUMNS, + (int) $renderOffset->y + $worldY - (int) $this->position->y, + ); } } - public function renderLayeredMap(MapLayerSet $layers): void - { - $this->renderMap(); - } - public function draw(iterable|string $content, int $x = 0, int $y = 0): void { $lines = []; @@ -102,7 +100,7 @@ public function draw(iterable|string $content, int $x = 0, int $y = 0): void } $height = $this->screen->getHeight(); - $width = $this->screen->getWidth(); + $width = $this->getConsoleColumns(); $index = 0; foreach ($lines as $line) { @@ -119,8 +117,7 @@ public function draw(iterable|string $content, int $x = 0, int $y = 0): void public function renderOnScreen(array $output, Vector2 $worldSpacePosition): void { - $screenSpacePosition = $this->getScreenSpacePosition($worldSpacePosition); - $this->renderAtScreenPosition($output, $screenSpacePosition); + $this->renderAtScreenPosition($output, $this->getConsolePosition($worldSpacePosition)); } public function renderAtScreenPosition(array|string $output, Vector2 $screenSpacePosition): void @@ -166,7 +163,7 @@ public function update(): void */ private function writeRow(int $row, int $x, string $text): void { - $width = $this->screen->getWidth(); + $width = $this->getConsoleColumns(); $plain = TerminalText::stripAnsi(TerminalText::formatStyles($text)); $plain = preg_replace('/\x1b\[[0-9;]*m/', '', $plain) ?? $plain; $cells = TerminalText::visibleSymbols($this->frame[$row] ?? str_repeat(' ', $width)); diff --git a/src/Cutscenes/Preview/PreviewPresentation.php b/src/Cutscenes/Preview/PreviewPresentation.php index 556869d..42fff59 100644 --- a/src/Cutscenes/Preview/PreviewPresentation.php +++ b/src/Cutscenes/Preview/PreviewPresentation.php @@ -68,7 +68,8 @@ public function render(): void return; } - $width = $this->camera->screen->getWidth(); + // Dialogue is text: it is laid out in console columns, not map cells. + $width = $this->camera->getConsoleColumns(); $height = $this->camera->screen->getHeight(); $boxWidth = max(12, min($width, 60)); $inner = $boxWidth - 4; diff --git a/tests/Unit/LayeredPreviewTest.php b/tests/Unit/LayeredPreviewTest.php index 7b71de5..ebff7eb 100644 --- a/tests/Unit/LayeredPreviewTest.php +++ b/tests/Unit/LayeredPreviewTest.php @@ -9,7 +9,7 @@ it('loads layer-aware cinematic collision and clears all geometry on unload', function () { $root = layeredMapProject(); file_put_contents($root . '/assets/Maps/test-map/test-map.event.php', - Ichiloto\Engine\Field\MapGridSource::buildSource(" \n ", 'EVENT')); + Ichiloto\Engine\Field\MapGridSource::buildSource(" \n ", 'EVENT')); file_put_contents($root . '/assets/Maps/collisions.php', <<<'PHP' render(); $output = ob_get_clean(); expect($output)->toBe('') - ->and(implode("\n", $camera->frame()))->toContain('./..', '.xx.'); + ->and(implode("\n", $camera->frame()))->toContain('..//....', '..xxxx..') + // The player's one-column sprite sits in the first column of its cell. + ->and(implode("\n", $preview->frame()))->toContain('@.//....', '..xxxx..'); $manager->unload(); expect($manager->layers)->toBeNull() ->and($manager->tileMap)->toBe([]) From b3ad2f02a923a72a821262b7b603adf61919b4b8 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 19:13:03 +0200 Subject: [PATCH 046/344] test(maps): cover two-column cell editing end to end --- tests/Unit/TwoColumnCellsTest.php | 324 ++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 tests/Unit/TwoColumnCellsTest.php diff --git a/tests/Unit/TwoColumnCellsTest.php b/tests/Unit/TwoColumnCellsTest.php new file mode 100644 index 0000000..a041197 --- /dev/null +++ b/tests/Unit/TwoColumnCellsTest.php @@ -0,0 +1,324 @@ + 120, 'height' => 40]); + setEditorProperty($editor, 'isRunning', true); + setEditorProperty($editor, 'focusedPane', 'canvas'); + + return [$editor, $workspace->getMapByIndex(0), $root]; +} + +/** Writes the sample map's tile rows, a project copy's only map. */ +function writeCellMapRows(string $root, array $rows): string +{ + $path = $root . '/assets/Maps/test-map/test-map.map.php'; + file_put_contents($path, MapGridSource::buildSource(implode("\n", $rows), 'ICHILOTO_MAP')); + + return $path; +} + +/** Replaces the sample map's data entries. */ +function writeCellMapData(string $root, array $entries): void +{ + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = array_replace(require $path, $entries); + file_put_contents($path, "getTileSymbol(1, 2))->toBe('##') + ->and($map->getTileSymbol(2, 2))->toBe('🌲') + ->and($map->getTileSymbol(3, 2))->toBe(' '); +}); + +it('turns a quick second keystroke into a pair that undoes as one step, and starts over on the third', function () { + [$editor, $map] = createCellEditor(); + setEditorProperty($editor, 'cursorX', 1); + setEditorProperty($editor, 'cursorY', 2); + /** @var CommandHistory $history */ + $history = getEditorProperty($editor, 'history'); + + typeCellKeys($editor, 'i', '['); + expect($map->getTileSymbol(1, 2))->toBe('[['); + typeCellKeys($editor, ']'); + expect($map->getTileSymbol(1, 2))->toBe('[]') + ->and($history->count())->toBe(1) + ->and(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('[]'); + + typeCellKeys($editor, 'x'); + expect($map->getTileSymbol(1, 2))->toBe('xx'); + typeCellKeys($editor, 'y'); + expect($map->getTileSymbol(1, 2))->toBe('xy') + ->and($history->count())->toBe(2); + + typeCellKeys($editor, "\x1a"); + expect($map->getTileSymbol(1, 2))->toBe('[]'); + typeCellKeys($editor, "\x1a"); + expect($map->getTileSymbol(1, 2))->toBe(' ') + ->and($map->isDirty())->toBeFalse(); +}); + +it('forgets the first keystroke once the cursor moves', function () { + [$editor, $map] = createCellEditor(); + setEditorProperty($editor, 'cursorX', 1); + setEditorProperty($editor, 'cursorY', 2); + + typeCellKeys($editor, 'i', '[', "\033[C", "\033[D", ']'); + + expect($map->getTileSymbol(1, 2))->toBe(']]'); + + // A key aimed at another cell starts that cell over too. + typeCellKeys($editor, "\033[C", "\033[D", '<'); + setEditorProperty($editor, 'cursorX', 2); + typeCellKeys($editor, '>'); + + expect($map->getTileSymbol(1, 2))->toBe('<<') + ->and($map->getTileSymbol(2, 2))->toBe('>>'); +}); + +it('fills a cell with the brush colour and keeps each character of a styled pair under the default brush', function () { + $root = makeTemporaryProject('ichiloto-cells-'); + $path = writeCellMapRows($root, [ + str_repeat('#', 24), + '##[]' . str_repeat(' ', 16) . '##', + '##' . str_repeat(' ', 20) . '##', + '##' . str_repeat(' ', 20) . '##', + str_repeat('#', 24), + ]); + $original = (string) file_get_contents($path); + [$editor, $map] = createCellEditor($root); + setEditorProperty($editor, 'cursorX', 1); + setEditorProperty($editor, 'cursorY', 1); + $pairStyle = $map->getTileCellStyle(1, 1); + + expect($map->getTileSymbol(1, 1))->toBe('[]') + ->and($pairStyle['styles'])->toBe([ + ['prefix' => '', 'suffix' => ''], ['prefix' => '', 'suffix' => ''], + ]); + + typeCellKeys($editor, 'i', '#', "\033"); + expect($map->getTileSymbol(1, 1))->toBe('##') + ->and($map->getTileCellStyle(1, 1))->toBe($pairStyle); + + typeCellKeys($editor, "\x13"); + expect((string) file_get_contents($path))->toContain('####' . str_repeat(' ', 16) . '##'); + + typeCellKeys($editor, "\x1a", "\x13"); + expect((string) file_get_contents($path))->toBe($original) + ->and($map->getTileSymbol(1, 1))->toBe('[]'); + + setEditorProperty($editor, 'selectedPaintColor', 'green'); + typeCellKeys($editor, 'i', '#'); + expect($map->getTileCellStyle(1, 1))->toBe(['prefix' => '', 'suffix' => '']); +}); + +it('repeats one marker per event cell and never pairs markers', function () { + [$editor, $map] = createCellEditor(); + setEditorProperty($editor, 'cursorX', 2); + setEditorProperty($editor, 'cursorY', 2); + + typeCellKeys($editor, 'e', 'i', 'A'); + expect($map->getEventSymbol(2, 2))->toBe('AA'); + typeCellKeys($editor, 'B'); + + expect($map->getEventSymbol(2, 2))->toBe('BB') + ->and($map->getEventMarkerAt(2, 2))->toBe('B'); + + // A pair carried over from the tile layer paints its first marker. + setEditorProperty($editor, 'selectedPaintSymbol', '[]'); + typeCellKeys($editor, "\r"); + expect($map->getEventSymbol(2, 2))->toBe('[[') + ->and($map->describeConflictingEventCells())->toBe([]); +}); + +it('offers whole cells in the character map and enters a typed pair', function () { + $root = makeTemporaryProject('ichiloto-cells-'); + file_put_contents($root . '/assets/Maps/collisions.php', " 0, 'z' => 1];\n"); + [$editor, $map] = createCellEditor($root); + setEditorProperty($editor, 'cursorX', 3); + setEditorProperty($editor, 'cursorY', 2); + + $palette = callEditorMethod($editor, 'getCharacterPalette'); + expect($palette)->toContain('##', '~~', ' ', '==', 'zz', '??', '@@', '██', '🧍') + ->and(array_filter($palette, static fn(string $cell): bool => ! CellSymbol::isCell($cell)))->toBe([]); + + typeCellKeys($editor, 'c', '<', '>'); + expect(getEditorProperty($editor, 'characterMapCell'))->toBe('<>'); + typeCellKeys($editor, "\r"); + + expect($map->getTileSymbol(3, 2))->toBe('<>') + ->and(getEditorProperty($editor, 'isCharacterMapOpen'))->toBeFalse() + ->and(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('<>'); + + // A third key starts the typed cell over; arrows return to the palette. + typeCellKeys($editor, 'c', 'a', 'b', 'c'); + expect(getEditorProperty($editor, 'characterMapCell'))->toBe('cc'); + typeCellKeys($editor, "\033[C"); + expect(getEditorProperty($editor, 'characterMapCell'))->toBe(''); +}); + +it('maps the cursor, clicks and the wheel to two terminal columns per cell', function () { + [$editor, $map] = createCellEditor(); + $map->resize(100, 50); + $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); + expect($bounds['cells'])->toBe(intdiv($bounds['width'], 2)); + + setEditorProperty($editor, 'canvasOffsetX', 3); + foreach ([0, 1] as $column) { + setEditorProperty($editor, 'cursorX', 0); + typeCellKeys($editor, sprintf("\033[<0;%d;%dM", $bounds['left'] + 8 + $column, $bounds['top'] + 1)); + typeCellKeys($editor, sprintf("\033[<0;%d;%dm", $bounds['left'] + 8 + $column, $bounds['top'] + 1)); + expect(getEditorProperty($editor, 'cursorX'))->toBe(7, "column {$column} of the cell") + ->and(getEditorProperty($editor, 'cursorY'))->toBe(1); + } + + // A column past the last whole cell selects nothing. + typeCellKeys($editor, sprintf("\033[<0;%d;%dM", $bounds['left'] + $bounds['cells'] * 2, $bounds['top'] + 1)); + expect(getEditorProperty($editor, 'cursorX'))->toBe(7); + + $layout = callEditorMethod($editor, 'resolveLayout'); + ob_start(); + callEditorMethod($editor, 'renderCanvasCursor', $layout); + $output = (string) ob_get_clean(); + expect($output)->toContain(sprintf("\033[%d;%dH", $bounds['top'] + 1, $bounds['left'] + 8)); + + // The last cell scrolls into view; the wheel stops where it is last visible. + setEditorProperty($editor, 'cursorX', 99); + callEditorMethod($editor, 'syncViewportToCursor'); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(100 - $bounds['cells']); + setEditorProperty($editor, 'canvasOffsetX', 100 - $bounds['cells'] - 1); + typeCellKeys($editor, sprintf("\033[<67;%d;%dM", $bounds['left'], $bounds['top'])); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(100 - $bounds['cells']); +}); + +it('writes event markers repeated across each cell and reads a marker in either column', function () { + [, $map] = createCellEditor(); + $map->setEventBounds('E', 2, 3, 2, 1); + + expect($map->getEventSymbol(2, 3))->toBe('EE') + ->and($map->getEventSymbol(3, 3))->toBe('EE') + ->and($map->getEventSymbol(5, 1))->toBe(' ') + ->and($map->getEventBounds('E'))->toBe(['x' => 2, 'y' => 3, 'width' => 2, 'height' => 1]); + + $map->setEventSymbol(4, 3, ' E'); + expect($map->getEventMarkerAt(4, 3))->toBe('E') + ->and($map->isEventMarkerSolidRectangle('E'))->toBeTrue(); + + $map->setEventSymbol(0, 0, 'EF'); + expect($map->getEventMarkerAt(0, 0))->toBeNull() + ->and($map->getPlacedEventMarkers())->toBe(['E']) + ->and($map->describeConflictingEventCells())->toBe(['Event cell at row 0, column 0 holds two different markers, E and F.']) + ->and(MapValidator::validate($map, [$map->mapId => $map]))->toContain('Event cell at row 0, column 0 holds two different markers, E and F.'); +}); + +it('overhangs a sprite wider than its cell and keeps the row in whole cells', function () { + $root = makeTemporaryProject('ichiloto-cells-'); + writeCellMapData($root, ['npcs' => [ + ['id' => 'wide', 'name' => 'Wide', 'sprite' => 'abc', 'x' => 2, 'y' => 2], + ['id' => 'tree', 'name' => 'Tree', 'sprite' => '🌲', 'x' => 6, 'y' => 2], + ]]); + [$editor, $map] = createCellEditor($root); + + $row = $map->renderPreview(12, 5, showNpcOverlay: true, selectedNpcIndex: 0)[2]; + + expect(TerminalText::stripAnsi($row))->toBe('## abc 🌲 ##') + ->and($row)->toContain("\033[7mabc \033[0m") + ->and(mb_strwidth(TerminalText::stripAnsi($row)))->toBe(24); + + // The view starting inside the overhang keeps its columns. + expect(TerminalText::stripAnsi($map->renderPreview(4, 5, offsetX: 3, showNpcOverlay: true)[2]))->toBe(' 🌲'); + + // A duplicate stands in the next cell, whatever its sprite's width. + callEditorMethod($editor, 'setEditingMode', 'npc'); + callEditorMethod($editor, 'selectNpc', 1); + typeCellKeys($editor, 'D'); + expect($map->getNpcs()->get(2)?->getX())->toBe(7); +}); + +it('refuses misaligned rows with the Engine row and column, in the editor and validation', function (string $row, string $message) { + $root = makeTemporaryProject('ichiloto-cells-'); + writeCellMapRows($root, [str_repeat('#', 24), $row, str_repeat('#', 24)]); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + + expect($map->getGridSourceIssue())->toContain($message); + + $issues = issuesMentioning(validateProject($root), $message); + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::ERROR); +})->with([ + 'glyph after a lone character' => ['#🌲' . str_repeat(' ', 20) . '#', 'row 1, column 1: a two-column glyph must begin a cell'], + 'lone trailing character' => [str_repeat('#', 23), 'row 1 ends halfway through a cell'], +]); + +it('refuses to open a project in another format with the Engine explanation', function (mixed $format) { + $root = makeTemporaryProject('ichiloto-cells-'); + $config = json_decode((string) file_get_contents($root . '/ichiloto.json'), true); + if ($format === null) { + unset($config[ProjectFormat::KEY]); + } else { + $config[ProjectFormat::KEY] = $format; + } + file_put_contents($root . '/ichiloto.json', json_encode($config)); + try { + ProjectFormat::assertSupported($format); + $expected = null; + } catch (UnsupportedProjectFormatException $refusal) { + $expected = $refusal->getMessage(); + } + + expect($expected)->not->toBeNull() + ->and(fn() => ProjectWorkspace::fromProject($root))->toThrow(UnsupportedProjectFormatException::class, $expected); +})->with([ + 'unrecorded' => [null], + 'older' => [ProjectFormat::CURRENT - 1], + 'newer' => [ProjectFormat::CURRENT + 1], +]); + +it('warns that a tiles2d crop table is no longer read', function () { + $root = makeTemporaryProject('ichiloto-cells-'); + writeCellMapData($root, ['tiles2d' => ['asset' => 'Graphics/Tilesets/shared.png', 'symbols' => []]]); + + $issues = issuesMentioning(validateProject($root), 'tiles2d'); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::WARNING) + ->and($issues[0]->message)->toBe('Its tiles2d crop table is no longer read.') + ->and(MapValidator::validate($map, [$map->mapId => $map]))->toContain('tiles2d is no longer read; the map shows its terminal glyphs until it has a tileset.'); +}); From 8c2ed43b386336e8a030d107835a278fdd208129 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 19:13:03 +0200 Subject: [PATCH 047/344] docs: describe editing two-column map cells Document how the canvas paints, selects and addresses two-column cells, the typed pair and the character map pair entry, NPCs within a cell, and that tiles2d crop tables are no longer read or authored. --- docs/guides/add-an-npc.md | 9 +- docs/guides/make-a-new-map.md | 4 +- docs/manual.md | 94 ++++++++++++------- .../tooling-loop/editing-in-the-editor.md | 2 +- 4 files changed, 69 insertions(+), 40 deletions(-) diff --git a/docs/guides/add-an-npc.md b/docs/guides/add-an-npc.md index e27a20e..c61a08b 100644 --- a/docs/guides/add-an-npc.md +++ b/docs/guides/add-an-npc.md @@ -47,8 +47,8 @@ The `Id` row in the Inspector is read-only for that reason. (type to narrow, `Enter` jumps to one); `[` and `]` step through them. - `M` picks the selected NPC up; move the cursor and press `Enter` to set it down, or `Esc` to leave it where it was. -- `D` duplicates the selected NPC under a fresh id, one column to the right - when that tile is free. +- `D` duplicates the selected NPC under a fresh id, one cell to the right + when that cell is free. - `Del` deletes it — unless something names its id (see [Reference-Safe Deletion](#reference-safe-deletion)). @@ -60,8 +60,9 @@ single undo step. `Tab` to the Inspector. Under `Appearance`: - `Sprite` is the glyph the game draws — one character, an emoji, or a styled - glyph such as `@`. Wide glyphs take the two columns the game - gives them; styled ones draw as their plain glyph on the canvas. + glyph such as `@`. A character occupies one cell of two + columns: an emoji fills it, a one-column glyph sits in its first column, + and styled ones draw as their plain glyph on the canvas. - `Facing North` / `South` / `East` / `West` are optional glyphs shown when the NPC turns that way. Leave a heading blank and the base sprite covers it. Rest the cursor on a `Facing …` row and the canvas previews that glyph in diff --git a/docs/guides/make-a-new-map.md b/docs/guides/make-a-new-map.md index 2ef2e6d..e2e4ae5 100644 --- a/docs/guides/make-a-new-map.md +++ b/docs/guides/make-a-new-map.md @@ -44,7 +44,9 @@ explicit confirmation before it does. 1. `Tab` to the `Canvas`. 2. Press `m` to be sure you are in Map mode, then `i` to enter Paint mode. -3. Move with the arrow keys and type a glyph to paint it. +3. Move with the arrow keys and type a glyph to paint it. Every map cell is + two terminal columns, so `#` paints `##`; type a second key straight away + for a pair such as `[]`, or an emoji to fill the cell with it. For anything larger than a few tiles, use the tools: diff --git a/docs/manual.md b/docs/manual.md index d616d53..3bac467 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -54,11 +54,11 @@ is what the code draws rather than a sketch of it: ┌─Assets [Focus]───────────────┐ ┌─Canvas─────────────────────┐ ┌─Inspector──────────────────────┐ │ Maps │ │ Preview: test-map │ │ Name: Test Map │ │ > test-map │ │ Test Map | | 12 x 5 | vie │ │ Region: │ - │ │ │ ############ │ │ Description: A tiny fixture │ - │ │ │ # ~~~ # │ │ map. │ - │ │ │ # # │ │ Size │ - │ │ │ # # │ │ X: 12 │ - │ │ │ ############ │ │ Y: 5 │ + │ │ │ ######################## │ │ Description: A tiny fixture │ + │ │ │ ## ~~~~~~ ## │ │ map. │ + │ │ │ ## ## │ │ Size │ + │ │ │ ## ## │ │ X: 12 │ + │ │ │ ######################## │ │ Y: 5 │ │ │ │ │ │ Events · 1 │ │ │ │ │ │ Triggers · 0 │ │ │ │ │ │ Audio │ @@ -197,8 +197,7 @@ Use `Ctrl+P` and select a gameplay or event layer by name, or choose a Layers action to create, rename or remove a gameplay layer. The TUI cannot create, select, rename or remove graphical decoration layers. New gameplay layers preserve each row's width, including ragged -maps. Renaming retains its numeric order and changes its local `tiles2d` key -without regenerating the crop table. If the actual resolved collisions would +maps. Renaming retains its numeric order. If the actual resolved collisions would change, the rename dialog names the change and requires `y` confirmation; shared `collisions.php` is never changed. Removal is confirmed and undoable. Creating a layer on a legacy map explicitly moves its terrain into @@ -207,20 +206,17 @@ saves keep the original layout. Gameplay and event layers use the same Paint/Normal modes, tools, mouse strokes, selection, colour, clipboard and undo. The layer inspector lists terminal layer -visibility only. Graphical crop tables, selected-cell Tile art actions, crop -dialogs and crop-painting warnings have been removed from the terminal shell. -Ordinary glyph edits do not rewrite graphical decoration or `tiles2d` data. -An unchanged graphical definition that was already invalid when loaded does -not block terminal glyph, event or unrelated metadata saves, copies or moves. -It is preserved, not repaired or certified valid: explicit graphical validation -still reports it. Changes to graphical definitions, decoration contents or -layer structure/geometry still require graphical validation; canonical source -safety, collision and grid checks remain in force for terminal edits. -Untouched layer files are never written; unchanged rows retain their authored -bytes, and all changed files are saved or rolled back together. +visibility only. Ordinary glyph edits do not rewrite graphical decoration. +Glyph-keyed crop tables (`tiles2d`) are no longer read by the Engine or edited +here; a map that still has one keeps its bytes, and validation warns that it is +no longer read. Canonical source safety, collision and grid checks remain in +force for terminal edits. Untouched layer files are never written; unchanged +rows retain their authored bytes, and all changed files are saved or rolled +back together. Text catalogues in `assets/Graphics/Tilesets/*.txt` appear as Facade brushes in -`Ctrl+P`. Separate multi-row shapes with blank lines. Select a brush to target +`Ctrl+P`. Author them in two-column cells like any map row, and separate +multi-row shapes with blank lines. Select a brush to target the gameplay `buildings` layer, then press `Enter` to stamp it as one undo step. In Paint mode (`i`), a left click also stamps; Normal-mode clicks only move the cursor. Mode and map changes clear the stamp brush; `b` returns to a @@ -238,6 +234,31 @@ Normal. While painting, the Status pane shows `[PAINT]` beside the mode. Control-byte and function-key shortcuts (`Ctrl+S`, `F3`, ...) work in both modes, since they are not glyphs. +### Map Cells + +A map cell is two terminal columns: one two-column glyph (an emoji or CJK +character) or two one-column characters, each with its own style. `##`, `[]` +and `🌲` are each one cell, and a blank cell is two spaces. Coordinates, map +sizes, selections, the clipboard and every tool count cells, so the canvas +shows half as many cells across as it has columns. The cursor rests on a +cell's first column, and a click on either column selects the cell. + +Painting fills whole cells: + +- One typed character paints the cell with it repeated: `#` paints `##`. +- A second one-column key typed straight after, before any other key, click + or cursor move, turns that cell into the pair: `[` then `]` paints `[]`, as + one undo step. A third key starts the next cell over. +- A two-column glyph fills the cell as it is. +- Erase paints a blank cell. +- On the event layer a cell repeats its one marker (`E` paints `EE`); markers + never pair. + +The character map offers whole cells, and typing there enters one: a key +fills the cell, a second makes a pair, and `Enter` places it. A file row that +is not whole cells (a lone trailing character, or a one-column character +followed by a two-column glyph) opens read-only with its row and column. + ### Terminal Editing and Pending Graphical GUI Keep walkable floors and solid walls readable as terminal gameplay glyphs. @@ -256,9 +277,11 @@ must not drive or change the terminal editing experience. **Removed TUI workflows:** painting decoration markers, choosing graphical layers, inspecting graphical crop tables and editing selected-cell crops are -no longer terminal authoring features. Their data is not deleted. The TileArt -model services, low-level decoration APIs and source-preserving round trips -remain available for the graphical editor. +no longer terminal authoring features. Decoration data is not deleted, and +its low-level APIs and source-preserving round trips remain available for the +graphical editor. Glyph-keyed crop authoring (`tiles2d` and its cell +overrides) has been removed along with the Engine's support for it; tilesets +replace it. **Pending, not implemented:** richer renderers will use a separate, RPG Maker-like GUI editor for graphical materials, atlases and crop authoring. @@ -294,8 +317,9 @@ line points to `i` instead. ### Paint mode -Every printable key paints its glyph at the cursor with the brush tool, or -loads it into the brush under a shape or select tool. `Arrows` move, +Every printable key paints a cell of its glyph at the cursor with the brush +tool, or loads that cell into the brush under a shape or select tool (see +[Map Cells](#map-cells) for pairs). `Arrows` move, `Enter` applies the active tool, erase keys erase, and `Esc` returns to Normal mode. Entering NPC mode or moving focus off the canvas also returns to Normal. @@ -326,15 +350,16 @@ shapes, flood fills - and is written as `` tags, exactly the styling authored by hand. - **Keep cell colour** (the default): painting changes the glyph and leaves - each cell's existing styling byte-for-byte, authored options included. + each cell's existing styling byte-for-byte, authored options and a + separate style per character included. - **No colour**: painting strips styling and writes plain glyphs. - **A colour**: painting writes the glyph in that colour. Choosing a colour recolours in place, keeping the glyphs, as one undoable stroke: with a selection active (`s`), every cell in the selection; otherwise, - under the brush tool, the cell at the cursor. Spaces stay uncoloured. + under the brush tool, the cell at the cursor. Blank cells stay uncoloured. The eyedropper (`k` / `Ctrl+K`) picks up a cell's colour along with its -glyph; an uncoloured cell loads an uncoloured brush. A painted space is +glyph; an uncoloured cell loads an uncoloured brush. A painted blank cell is always uncoloured, so erasing never leaves invisible styling behind. Event markers remain authoring geometry, but can now carry colour through the same picker and undo workflow. The Status pane shows the brush colour beside the tool. @@ -342,11 +367,12 @@ picker and undo workflow. The Status pane shows the brush colour beside the tool ### NPC Mode `F3` enters NPC mode: the map's `npcs` collection is drawn over the tiles as -an overlay - sprites at their authored anchor, wide glyphs occupying the two -columns the game gives them, styled sprites as the plain glyph - and nothing -you do here paints a tile or an event marker. The selected NPC is shown in -brackets, except an explicitly empty sprite: selection highlights its existing -map cell without replacing the glyph or neighbouring cells. An unselected empty +an overlay - each sprite in its one cell, as the game draws it: a one-column +sprite in the cell's first column, a two-column glyph filling it, anything +wider overhanging the cells to its right, styled sprites as the plain glyph - +and nothing you do here paints a tile or an event marker. The selected NPC is +shown in reverse video within its cell; an explicitly empty sprite highlights +its existing map cell without replacing the glyph or neighbouring cells. An unselected empty sprite draws no overlay. NPC mode sits on `n` in Normal mode and on `F3` everywhere, so no paintable character is taken from you (and not a control byte, since the terminal driver reserves the remaining ones). @@ -355,7 +381,7 @@ reserves the remaining ones). | --- | --- | | `Enter` | Select the NPC under the cursor; on an empty tile, name and create a new fixed NPC there | | `M` | Pick up the selected NPC; the next `Enter` sets it down at the cursor (`Esc` cancels) | -| `D` | Duplicate the selected NPC under a fresh stable id, one column to the right when free | +| `D` | Duplicate the selected NPC under a fresh stable id, one cell to the right when free | | `L` | List the map's NPCs by name and id; type to narrow, `Enter` selects one and jumps the cursor to it | | `[` / `]` | Select the previous / next NPC in the map's list | | `Del` | Delete the selected NPC - refused, with the list, while anything names its id | @@ -1522,7 +1548,7 @@ fields are preserved and are not errors. | --- | --- | --- | | Help | `?` | Generated from the binding table; scrolls with arrows | | Command palette | `Ctrl+P` | Fuzzy search over actions, tools, maps, categories, and event markers | -| Character map | `@` | Insert glyphs the keyboard reserves; lists the map's symbols, the project's collision vocabulary, and the reserved glyphs | +| Character map | `@` | Insert whole cells the keyboard reserves; lists the map's cells, the project's collision vocabulary and the reserved glyphs as cells, and takes a typed pair | | Status detail | `Ctrl+E` | Full text of the last message, and the log file path | Current limit: the help overlay and command palette cannot open while a picker diff --git a/docs/website/guide/tooling-loop/editing-in-the-editor.md b/docs/website/guide/tooling-loop/editing-in-the-editor.md index 437764c..bf1472d 100644 --- a/docs/website/guide/tooling-loop/editing-in-the-editor.md +++ b/docs/website/guide/tooling-loop/editing-in-the-editor.md @@ -26,7 +26,7 @@ Move between panels with `Tab` and `Shift+Tab`, or directionally with `Shift+Arr ## Painting a map -The canvas is modal, in the vim tradition. `m` switches to Map mode for tiles; `e` switches to Event mode for the letter markers that events hang off. Press `i` to enter Paint mode, where every key paints its glyph (`Esc` returns to Normal mode), or reach for a tool: +The canvas is modal, in the vim tradition. `m` switches to Map mode for tiles; `e` switches to Event mode for the letter markers that events hang off. Press `i` to enter Paint mode, where every key paints its glyph (`Esc` returns to Normal mode). Every map cell is two terminal columns: one key fills a cell with its glyph repeated (`#` paints `##`), a second key typed straight after makes a pair such as `[]`, and an emoji fills the cell as it is. For larger work, reach for a tool: - `Ctrl+N` cycles Brush, Line, Rect, Rect Fill, and Select - `Ctrl+W` cycles brush width through 1, 2, 3, and 5 From f8b88548240c502a8c15b4ecc7c3daeeb440eb37 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 19:34:44 +0200 Subject: [PATCH 048/344] fix(cutscenes): match surrounding style when inserting array entries An empty array after a key, such as 'migrations' => [], now opens onto lines indented one step deeper than the line it starts on, and closes at that line's indentation. The step is read from the nearest enclosing array that writes its entries on lines of their own, so two-space, four-space and tab files each keep their own style. The writer's appended lines share the same rule. An entry added to an array written on one line no longer carries the array's line indentation or a stray comma into that line. Inline appends from insertEntryEdit and from the writer go through one planInlineAppend, which joins entries with ", " and writes a trailing comma only when the array's last entry already has one. Inserting ahead of a neighbour on a shared line is written the same way. insertEntryEdit also appends inline when the last entry shares its line with the closing bracket, instead of splitting the bracket, and inserts into an array holding only a comment after that comment instead of failing. --- src/Cutscenes/Source/ArraySourceWriter.php | 13 +- .../Source/PhpArraySourceDocument.php | 218 +++++++++++++++--- .../Cutscenes/ArrayEntryInsertionTest.php | 96 ++++++++ 3 files changed, 283 insertions(+), 44 deletions(-) create mode 100644 tests/Unit/Cutscenes/ArrayEntryInsertionTest.php diff --git a/src/Cutscenes/Source/ArraySourceWriter.php b/src/Cutscenes/Source/ArraySourceWriter.php index 2ec7b72..599331d 100644 --- a/src/Cutscenes/Source/ArraySourceWriter.php +++ b/src/Cutscenes/Source/ArraySourceWriter.php @@ -455,17 +455,8 @@ private function flushAppends(): void if ($append['inline'] !== []) { // After the last entry this rewrite keeps; right at the body - // start when it keeps none. The comma belongs to whichever - // entry the appended items now follow. - if ($lastSurviving !== null) { - $anchor = $lastSurviving->separatorEnd; - $prefix = $lastSurviving->separatorEnd === $lastSurviving->end ? ', ' : ' '; - } else { - $anchor = (int) $node->bodyStart; - $prefix = ''; - } - - $this->edits[] = [$anchor, $anchor, $prefix . implode(', ', $append['inline'])]; + // start when it keeps none. + $this->edits[] = $this->document->planInlineAppend($node, $append['inline'], $lastSurviving); } if ($append['lines'] === []) { diff --git a/src/Cutscenes/Source/PhpArraySourceDocument.php b/src/Cutscenes/Source/PhpArraySourceDocument.php index 3b71621..899a1d8 100644 --- a/src/Cutscenes/Source/PhpArraySourceDocument.php +++ b/src/Cutscenes/Source/PhpArraySourceDocument.php @@ -278,7 +278,8 @@ public function variableContent(string $name): ?string /** * Returns the indentation the entries of an array are written at, or - * what they would be written at if it has none yet. + * what they would be written at if it has none yet: one indentation + * step deeper than the line the array opens on. */ public function entryIndent(SourceNode $array): string { @@ -290,9 +291,71 @@ public function entryIndent(SourceNode $array): string } } - $ownIndent = $this->lineIndentBefore($array->start) ?? ''; + return $this->getLineIndent($array->start) . $this->getIndentStep($array); + } + + /** + * Returns the whitespace that starts the line holding an offset, whatever + * else precedes the offset on that line: the indentation of `'key' => [` + * for the `[` of that line. + */ + private function getLineIndent(int $offset): string + { + $lineStart = $this->lineStartOf($offset); + $lead = substr($this->source, $lineStart, $offset - $lineStart); - return $ownIndent . ' '; + return substr($lead, 0, strlen($lead) - strlen(ltrim($lead, " \t"))); + } + + /** + * Returns the indentation step the file uses around an array: how much + * deeper the nearest enclosing array writes its entries than the line it + * opens on. Two spaces when no enclosing array shows one. + */ + private function getIndentStep(SourceNode $array): string + { + foreach (array_reverse($this->findAncestors($this->root, $array) ?? []) as $ancestor) { + $opening = $this->getLineIndent($ancestor->start); + + foreach ($ancestor->entries as $entry) { + $indent = $this->lineIndentBefore($entry->start); + + if ($indent === null) { + continue; + } + + if (strlen($indent) > strlen($opening) && str_starts_with($indent, $opening)) { + return substr($indent, strlen($opening)); + } + + break; + } + } + + return ' '; + } + + /** + * Returns the arrays enclosing a node, outermost first, or null when the + * node is not below the one searched. + * + * @return array|null + */ + private function findAncestors(SourceNode $node, SourceNode $target): ?array + { + foreach ($node->entries as $entry) { + if ($entry->value === $target) { + return [$node]; + } + + $below = $this->findAncestors($entry->value, $target); + + if ($below !== null) { + return [$node, ...$below]; + } + } + + return null; } /** @@ -368,13 +431,25 @@ public function blockSpan(SourceEntry $entry): array $end = $this->lineEndAfter(max($entry->end, $entry->separatorEnd) - 1); // An indented first entry may still share its line with siblings or // closing syntax. Its heading belongs to it; those later bytes do not. - $tail = substr($this->source, $entry->separatorEnd, $end - $entry->separatorEnd); - foreach (PhpToken::tokenize('holdsOnlyTrivia($entry->separatorEnd, $end)) { + return [$start, $entry->separatorEnd]; + } + return [$start, $end]; + } + + /** + * Returns whether the bytes between two offsets are only whitespace and + * comments. + */ + private function holdsOnlyTrivia(int $start, int $end): bool + { + foreach (PhpToken::tokenize('source, $start, $end - $start)) as $token) { if (! $token->is([T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { - return [$start, $entry->separatorEnd]; + return false; } } - return [$start, $end]; + + return true; } // -- Planning edits ------------------------------------------------------ @@ -442,30 +517,39 @@ public function insertEntryEdit(array $parentPath, int $position, int|string|nul throw new RuntimeException(sprintf('No array at %s to insert into.', self::describePath($parentPath))); } - $indent = $this->entryIndent($array); - $keyText = $key === null ? '' : var_export($key, true) . ' => '; - $line = $indent . $keyText . self::reindent($literal, $indent) . ",\n"; + $line = $this->renderEntryLine($array, $key, $literal); $entries = $array->entries; if ($entries === []) { - // Empty array: `[]` becomes a multi-line array holding the entry, - // closing at the array's own indentation. - $closing = $this->lineIndentBefore($array->start) ?? ''; - $inner = substr($this->source, $array->bodyStart, $array->bodyEnd - $array->bodyStart); + $expansion = $this->planEmptyArrayExpansion($array, $line); - if (trim($inner) === '') { - return [$array->bodyStart, $array->bodyEnd, "\n" . $line . $closing]; + if ($expansion !== null) { + return $expansion; } + + // Only comments inside: the entry follows them, on a line of its + // own when the closing bracket has one. + if ($this->lineIndentBefore($array->bodyEnd) !== null) { + $at = $this->lineStartOf($array->bodyEnd); + + return [$at, $at, $line]; + } + + $gap = ctype_space($this->source[$array->bodyEnd - 1]) ? '' : ' '; + + return [$array->bodyEnd, $array->bodyEnd, $gap . $this->renderInlineEntry($key, $literal, $array->bodyEnd)]; } if ($position < count($entries)) { - [$blockStart] = $this->blockSpan($entries[$position]); + $neighbour = $entries[$position]; - if ($this->lineIndentBefore($entries[$position]->start) === null) { + if ($this->lineIndentBefore($neighbour->start) === null) { // The neighbour shares its line: go ahead of it on that line. - return [$entries[$position]->start, $entries[$position]->start, trim($line, "\n") . ' ']; + return [$neighbour->start, $neighbour->start, $this->renderInlineEntry($key, $literal, $neighbour->start) . ', ']; } + [$blockStart] = $this->blockSpan($neighbour); + return [$blockStart, $blockStart, $line]; } @@ -474,9 +558,13 @@ public function insertEntryEdit(array $parentPath, int $position, int|string|nul $needsComma = $last->separatorEnd === $last->end; $afterLast = $this->lineEndAfter(max($last->end, $last->separatorEnd) - 1); - if ($this->lineIndentBefore($last->start) === null || $afterLast <= $last->separatorEnd) { - // Entries share lines: append inline after the last one. - return [$last->separatorEnd, $last->separatorEnd, ($needsComma ? ',' : '') . ' ' . trim($line, "\n")]; + if ($this->lineIndentBefore($last->start) === null + || $afterLast <= $last->separatorEnd + || ! $this->holdsOnlyTrivia($last->separatorEnd, $afterLast) + ) { + // The last entry shares its line with a sibling or the closing + // bracket: append inline after it. + return $this->planInlineAppend($array, [$this->renderInlineEntry($key, $literal, $last->start)], $last); } // The rest of the last entry's line -- its comma, a comment, the @@ -493,9 +581,79 @@ public function insertEntryEdit(array $parentPath, int $position, int|string|nul public function renderEntryLine(SourceNode $array, int|string|null $key, string $literal): string { $indent = $this->entryIndent($array); - $keyText = $key === null ? '' : var_export($key, true) . ' => '; - return $indent . $keyText . self::reindent($literal, $indent) . ",\n"; + return $indent . self::renderEntry($key, self::reindent($literal, $indent)) . ",\n"; + } + + /** + * Renders one entry for a line it shares with others: no indentation of + * its own and no separator, continuation lines under the line it joins. + */ + private function renderInlineEntry(int|string|null $key, string $literal, int $at): string + { + return self::renderEntry($key, self::reindent($literal, $this->getLineIndent($at))); + } + + /** + * Renders an entry's key and value, `key => value` or the value alone. + */ + private static function renderEntry(int|string|null $key, string $value): string + { + return ($key === null ? '' : var_export($key, true) . ' => ') . $value; + } + + /** + * Plans appending entries to an array whose entries share their lines, + * joined the way such an array joins its own: after the entry they + * follow, one space after each comma, and a trailing comma only when the + * array's last entry is written with one. + * + * @param SourceNode $array The array node. + * @param array $entries The entries, each `key => value` or a value. + * @param SourceEntry|null $lastSurviving The entry that will precede the + * appended ones, or null when none survives and they start the body. + * @return array{0: int, 1: int, 2: string} The edit. + */ + public function planInlineAppend(SourceNode $array, array $entries, ?SourceEntry $lastSurviving): array + { + if ($array->bodyStart === null) { + throw new RuntimeException('Only an array node takes appended entries.'); + } + + $last = $array->entries === [] ? null : $array->entries[count($array->entries) - 1]; + $trailingComma = $last !== null && $last->separatorEnd !== $last->end; + $text = implode(', ', $entries) . ($trailingComma ? ',' : ''); + + if ($lastSurviving === null) { + return [$array->bodyStart, $array->bodyStart, $text]; + } + + if ($lastSurviving->separatorEnd === $lastSurviving->end) { + // It had no comma because it was last; it needs one now. + return [$lastSurviving->end, $lastSurviving->end, ', ' . $text]; + } + + return [$lastSurviving->separatorEnd, $lastSurviving->separatorEnd, ' ' . $text]; + } + + /** + * Plans writing entry lines into an array whose body is only whitespace: + * `[]` opens onto lines of their own and closes at the indentation of + * the line the array starts on. Null when the body holds anything else. + * + * @return array{0: int, 1: int, 2: string}|null The edit. + */ + private function planEmptyArrayExpansion(SourceNode $array, string $lines): ?array + { + if ($array->entries !== [] || $array->bodyStart === null || $array->bodyEnd === null) { + return null; + } + + if (trim(substr($this->source, $array->bodyStart, $array->bodyEnd - $array->bodyStart)) !== '') { + return null; + } + + return [$array->bodyStart, $array->bodyEnd, "\n" . $lines . $this->getLineIndent($array->start)]; } /** @@ -521,16 +679,10 @@ public function appendEntriesEdit(SourceNode $array, string $lines, ?SourceEntry throw new RuntimeException('Only an array node takes appended entries.'); } - if ($array->entries === []) { - $inner = substr($this->source, $array->bodyStart, $array->bodyEnd - $array->bodyStart); - - if (trim($inner) === '') { - // `[]` becomes a multi-line array holding the entries, - // closing at the array's own indentation. - $closing = $this->lineIndentBefore($array->start) ?? ''; + $expansion = $this->planEmptyArrayExpansion($array, $lines); - return [[$array->bodyStart, $array->bodyEnd, "\n" . $lines . $closing]]; - } + if ($expansion !== null) { + return [$expansion]; } $edits = []; diff --git a/tests/Unit/Cutscenes/ArrayEntryInsertionTest.php b/tests/Unit/Cutscenes/ArrayEntryInsertionTest.php new file mode 100644 index 0000000..711694f --- /dev/null +++ b/tests/Unit/Cutscenes/ArrayEntryInsertionTest.php @@ -0,0 +1,96 @@ + $path + */ +function insertEntry(string $source, array $path, int $position, int|string|null $key, string $literal): string +{ + $document = PhpArraySourceDocument::parse($source); + + return $document->withEdits([$document->insertEntryEdit($path, $position, $key, $literal)])->source; +} + +it('opens an empty array after a key onto lines at the surrounding indentation', function (string $step) { + $source = " 'x',\n{$step}'upgrades' => [\n{$step}{$step}'migrations' => [],\n{$step}],\n];\n"; + + expect(insertEntry($source, ['upgrades', 'migrations'], 0, null, "'v1'")) + ->toBe(" 'x',\n{$step}'upgrades' => [\n{$step}{$step}'migrations' => [\n{$step}{$step}{$step}'v1',\n{$step}{$step}],\n{$step}],\n];\n") + ->and(insertEntry($source, ['upgrades', 'migrations'], 0, '1.1.0', "'Upgrade'")) + ->toContain("{$step}{$step}'migrations' => [\n{$step}{$step}{$step}'1.1.0' => 'Upgrade',\n{$step}{$step}],\n{$step}],"); +})->with([ + 'four spaces' => ' ', + 'two spaces' => ' ', + 'tabs' => "\t", +]); + +it('opens an empty root array and a one-line parent child at the indentation step of the file', function () { + expect(insertEntry("toBe(" 1,\n];\n") + ->and(insertEntry(" ['list' => []],\n];\n", ['box', 'list'], 0, null, '1')) + ->toBe(" ['list' => [\n 1,\n ]],\n];\n"); +}); + +it('appends through the writer into an empty array after a key at the surrounding indentation', function () { + $source = " 'x',\n 'migrations' => [],\n];\n"; + $old = ['name' => 'x', 'migrations' => []]; + $new = ['name' => 'x', 'migrations' => ['v1', 'v2']]; + + expect(ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source) + ->toBe(" 'x',\n 'migrations' => [\n 'v1',\n 'v2',\n ],\n];\n"); +}); + +it('appends to a one-line array inline, following its trailing-comma convention', function (string $before, int|string|null $key, string $literal, string $after) { + $source = " {$before}, // kept\n];\n"; + + expect(insertEntry($source, ['list'], 2, $key, $literal))->toBe(" {$after}, // kept\n];\n"); +})->with([ + 'list without a trailing comma' => ["['a', 'b']", null, "'c'", "['a', 'b', 'c']"], + 'list with a trailing comma' => ["['a', 'b',]", null, "'c'", "['a', 'b', 'c',]"], + 'keyed without a trailing comma' => ["['x' => 1, 'y' => 2]", 'z', '3', "['x' => 1, 'y' => 2, 'z' => 3]"], + 'keyed with a trailing comma' => ["['x' => 1, 'y' => 2, ]", 'z', '3', "['x' => 1, 'y' => 2, 'z' => 3, ]"], +]); + +it('inserts ahead of a one-line neighbour without carrying indentation onto the line', function () { + $source = " ['a', 'b'],\n];\n"; + + expect(insertEntry($source, ['list'], 0, null, "'c'"))->toBe(" ['c', 'a', 'b'],\n];\n") + ->and(insertEntry($source, ['list'], 1, null, "'c'"))->toBe(" ['a', 'c', 'b'],\n];\n"); +}); + +it('appends through the writer to a one-line array following its trailing-comma convention', function (string $before, string $after) { + $source = " {$before},\n];\n"; + + expect(ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), ['list' => ['a', 'b']], ['list' => ['a', 'b', 'c', 'd']])->source) + ->toBe(" {$after},\n];\n"); +})->with([ + 'without a trailing comma' => ["['a', 'b']", "['a', 'b', 'c', 'd']"], + 'with a trailing comma' => ["['a', 'b',]", "['a', 'b', 'c', 'd',]"], +]); + +it('appends after a last entry that shares its line with the closing bracket', function () { + $source = " [\n 'a'],\n 'next' => 1,\n];\n"; + + expect(insertEntry($source, ['list'], 1, null, "'b'"))->toBe(" [\n 'a', 'b'],\n 'next' => 1,\n];\n"); +}); + +it('inserts into an array that holds only a comment, after the comment', function () { + $source = " [\n // None yet.\n ],\n 'inline' => [/* none */],\n];\n"; + + expect(insertEntry($source, ['migrations'], 0, null, "'v1'")) + ->toBe(" [\n // None yet.\n 'v1',\n ],\n 'inline' => [/* none */],\n];\n") + ->and(insertEntry($source, ['inline'], 0, null, "'v1'")) + ->toBe(" [\n // None yet.\n ],\n 'inline' => [/* none */ 'v1'],\n];\n"); +}); From 9bbeece43153d4c9a687daf027292a00165fc28a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 21:47:56 +0200 Subject: [PATCH 049/344] fix(canvas): show lower layers through spaces in an upper cell The canvas replaced a whole cell with any non-blank upper cell, so Home's chest " m" hid the wall beside it. Cells now overlay column by column as the Engine composes them, each column keeping its own style and dimming, and adjacent columns sharing a style still render as one run. --- src/ProjectMap.php | 82 ++++++++++++++++++++++++++++------- tests/Unit/LayeredMapTest.php | 10 +++++ 2 files changed, 77 insertions(+), 15 deletions(-) diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 45fb645..21cc672 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -569,9 +569,13 @@ public function renderPreview( || ($layer['id'] === MapLayers::EVENT && ! $showEventOverlay)) { continue; } + $dim = $dimInactive && $activeLayer !== $layer['id']; foreach ($layer['grid']->cells[$row] ?? [] as $x => $cell) { - if (! MapCell::isBlank($cell['symbol']) || $layer['id'] === $baseLayerId) { - $tileRow[$x] = [...$cell, 'dim' => $dimInactive && $activeLayer !== $layer['id']]; + if ($layer['id'] === $baseLayerId) { + $tileRow[$x] = self::getPreviewColumns($cell, $dim); + } elseif (! MapCell::isBlank($cell['symbol'])) { + $upper = self::getPreviewColumns($cell, $dim); + $tileRow[$x] = isset($tileRow[$x]) ? self::overlayPreviewColumns($tileRow[$x], $upper) : $upper; } } } @@ -587,10 +591,10 @@ public function renderPreview( continue; } - $tileCell = $tileRow[$column] ?? ['symbol' => MapCell::BLANK, 'prefix' => '', 'suffix' => '']; + $tileColumns = $tileRow[$column] ?? self::getPreviewColumns(['symbol' => MapCell::BLANK, 'prefix' => '', 'suffix' => ''], false); // A selected map-owned NPC marks the existing cell, never a replacement sprite. $highlight = array_key_exists($column, $npcCells[$row] ?? []) ? "\033[7m" : ''; - $mergedSymbols[] = $this->renderPreviewCell($tileCell, $highlight); + $mergedSymbols[] = $this->renderPreviewColumns($tileColumns, $highlight); } $lines[] = rtrim(implode('', $mergedSymbols)); @@ -600,27 +604,75 @@ public function renderPreview( } /** - * Renders one cell for the canvas: each styled run of the cell, dimmed - * or highlighted as a whole. + * A cell's characters by column, each with its own style and dimming: two + * for a pair, one spanning both columns for a two-column glyph. * - * @param array{symbol: string, prefix: string, suffix: string, styles?: list, dim?: bool} $cell + * @param array{symbol: string, prefix: string, suffix: string, styles?: list} $cell + * @return list */ - private function renderPreviewCell(array $cell, string $highlight): string + private static function getPreviewColumns(array $cell, bool $dim): array { - $dim = ($cell['dim'] ?? false) ? "\033[2m" : ''; - $text = ''; + $columns = []; foreach (EditableGrid::getCellRuns($cell) as $run) { + preg_match_all('/\X/u', $run['symbol'], $characters); + foreach ($characters[0] as $character) { + $columns[] = ['symbol' => $character, 'prefix' => $run['prefix'], 'suffix' => $run['suffix'], 'dim' => $dim]; + } + } + return $columns; + } + + /** + * Lays an upper layer's cell over the cell below column by column, as the + * Engine composes layers: a space in an upper pair shows the column + * beneath it; a two-column glyph on either side replaces the whole cell. + * + * @param list $lower + * @param list $upper + * @return list + */ + private static function overlayPreviewColumns(array $lower, array $upper): array + { + if (count($lower) !== MapCell::COLUMNS || count($upper) !== MapCell::COLUMNS) { + return $upper; + } + return array_map(static fn(array $top, array $bottom): array => trim($top['symbol']) === '' ? $bottom : $top, $upper, $lower); + } + + /** + * Renders one cell's columns for the canvas, each dimmed as its own layer + * is, and highlighted together. + * + * @param list $columns + */ + private function renderPreviewColumns(array $columns, string $highlight): string + { + // Adjacent columns sharing a style and dimming render as one run. + $runs = []; + foreach ($columns as $column) { + $last = array_key_last($runs); + if ($last !== null && [$runs[$last]['prefix'], $runs[$last]['suffix'], $runs[$last]['dim']] + === [$column['prefix'], $column['suffix'], $column['dim']]) { + $runs[$last]['symbol'] .= $column['symbol']; + continue; + } + $runs[] = $column; + } + $text = ''; + foreach ($runs as $column) { + $dim = $column['dim'] ? "\033[2m" : ''; if (! $this->layers->legacy) { - $text .= TerminalText::formatStyles($run['prefix'] . $highlight . $run['symbol'] . $run['suffix']); + $styled = TerminalText::formatStyles($column['prefix'] . $highlight . $column['symbol'] . $column['suffix']); + $text .= $dim === '' && $highlight === '' ? $styled : $dim . $styled . "\033[0m"; continue; } // Legacy tags are not formatter styles: only their colour is shown. - $open = self::ansiOpenForPrefix($run['prefix']); - $text .= $open === null && $highlight === '' ? $run['symbol'] : $highlight . $open . $run['symbol'] . "\033[0m"; + $open = self::ansiOpenForPrefix($column['prefix']); + $text .= $open === null && $highlight === '' && $dim === '' + ? $column['symbol'] : $dim . $highlight . $open . $column['symbol'] . "\033[0m"; } - return $dim === '' && $highlight === '' ? $text : $dim . $text . "\033[0m"; + return $text; } - /** * The 4-bit ANSI foreground codes for the formatter's colour names. * `gray` is the formatter's name for bright black. diff --git a/tests/Unit/LayeredMapTest.php b/tests/Unit/LayeredMapTest.php index 332e5d3..ff2cb07 100644 --- a/tests/Unit/LayeredMapTest.php +++ b/tests/Unit/LayeredMapTest.php @@ -236,3 +236,13 @@ ["\033[31m", 'red'], ["\033[38;2;12;34;56m", '#0c2238'], ["\033[38;5;196m", '#ff0000'], ["\033[38;5;232m", '#080808'], ]); + +it('shows the lower layer through a space in an upper cell, as the Engine composes', function () { + $map = loadLayeredMap(layeredMapProject()); + // Home's chest " m" over a wall: the space keeps the column beneath it visible. + $map->setLayerCell('map:4', 0, 0, ' m'); + $preview = array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2)); + expect($preview[0])->toStartWith('.m') + ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, terminalPreview: true))[0])->toStartWith('.m') + ->and($map->renderPreview(4, 2, activeLayer: 'map:4', dimInactive: true)[0])->toStartWith("\033[2m"); +}); From d0b14d681586c8f892fecc785027845b5c483cc4 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 22:28:07 +0200 Subject: [PATCH 050/344] feat(maps): keep and validate map graphics in the TUI editor A map's graphics/ tile layers now share the terminal layers' file-set boundary. Resizing a map, including rows of a ragged one, crops or pads every tile layer with empty tiles in the same undo step and transactional save; a resized layer is rewritten as a literal nowdoc keeping its leading comment and marker, and one that reads back as its saved tiles keeps its bytes. A resize is refused, changing nothing, while a tile layer cannot be read or does not match the map. Duplicate, move and delete carry or remove graphics/ inside their transactions and rollback, byte for byte even when a layer is unreadable, and a move removes the emptied graphics/ folder with the map's folder. A tile layer changed or added after opening refuses the save, as terminal layers do. The TUI never paints tiles. Project validation and the pre-save map warnings read graphics as the Engine does, reporting every file: a missing or invalid tileset, graphics without a tileset, misnamed or duplicate-order layers, rows or cells that do not match the map and invalid identities are errors; unusable sheets and tiles from sheets the tileset does not provide are warnings. --- src/Maps/MapLayers.php | 62 ++++- src/Maps/TileLayerSource.php | 129 ++++++++++ src/ProjectMap.php | 77 +++++- src/ProjectWorkspace.php | 14 +- src/Validation/MapGraphicsValidator.php | 230 +++++++++++++++++ src/Validation/MapValidator.php | 1 + src/Validation/ProjectValidator.php | 1 + tests/Pest.php | 1 + tests/Support/MapGraphicsFixtures.php | 81 ++++++ tests/Unit/MapGraphicsTest.php | 328 ++++++++++++++++++++++++ 10 files changed, 901 insertions(+), 23 deletions(-) create mode 100644 src/Maps/TileLayerSource.php create mode 100644 src/Validation/MapGraphicsValidator.php create mode 100644 tests/Support/MapGraphicsFixtures.php create mode 100644 tests/Unit/MapGraphicsTest.php diff --git a/src/Maps/MapLayers.php b/src/Maps/MapLayers.php index b287fdc..7c3b0bd 100644 --- a/src/Maps/MapLayers.php +++ b/src/Maps/MapLayers.php @@ -7,17 +7,24 @@ use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Engine\Field\MapCell; +use Ichiloto\Engine\Field\MapGraphics; use Ichiloto\Engine\Field\MapGridSource; use Ichiloto\Engine\Field\MapLayerSource; use Ichiloto\Engine\Field\MapLayerSet; use Ichiloto\Engine\Field\MapLayer; -/** Editable layers and their file-set boundary. Layer ids survive renaming. */ +/** + * Editable layers and their file-set boundary. Layer ids survive renaming. + * The map's graphical tile layers share the boundary: the TUI never paints + * them, but resizes, relocates and removes them with the map. + */ final class MapLayers { public const string EVENT = 'event'; public const string BASE = 'tile'; private array $layers = []; + /** @var array Tile layer sources in graphics/, by path. */ + private array $tileSources = []; /** Original on-disk members, including layers removed or renamed in memory. */ private array $baselineSources = []; @@ -27,10 +34,12 @@ public function __construct( array $layers, EditableGrid $events, string $eventPath, + array $tileSources = [], ) { foreach ($layers as $layer) { $this->layers[$layer['id']] = $layer; } + $this->tileSources = $tileSources; $this->layers[self::EVENT] = [ 'id' => self::EVENT, 'name' => 'Events', 'order' => null, 'decoration' => false, 'path' => $eventPath, 'grid' => $events, @@ -53,7 +62,17 @@ public static function createFromSource(string $directory, MapLayerSet $set, str ]; } $events = new EditableGrid($eventText, (string) file_get_contents($eventPath), context: $prefix . basename($eventPath)); - return new self($directory, $set->legacy, $layers, $events, $eventPath); + $tileSources = []; + foreach (TileLayerSource::findPaths($directory) as $path) { + $tileSources[$path] = (string) file_get_contents($path); + } + return new self($directory, $set->legacy, $layers, $events, $eventPath, $tileSources); + } + + /** @return array Tile layer sources in graphics/, by path. */ + public function getTileSources(): array + { + return $this->tileSources; } public function getLayers(): array @@ -173,21 +192,43 @@ public function captureSnapshot(): array { return ['legacy' => $this->legacy, 'layers' => array_map( static fn(array $layer): array => [...$layer, 'grid' => $layer['grid']->captureSnapshot()], $this->layers, - )]; + ), 'tiles' => $this->tileSources]; } public function restoreSnapshot(array $snapshot): void { $this->legacy = $snapshot['legacy']; $this->layers = array_map(static fn(array $layer): array => [...$layer, 'grid' => EditableGrid::createFromSnapshot($layer['grid'])], $snapshot['layers']); + $this->tileSources = $snapshot['tiles']; } - /** Resizes every layer to whole cells. */ + /** + * Resizes every layer to whole cells, tile layers included. Every tile + * layer is resized before anything changes, so one the Engine cannot + * read refuses the whole resize. + * + * @throws MapSourceRefusal When a tile layer cannot be read or does not match the map. + */ public function resize(int $width, int $height): void { + $tileSources = []; + if ($this->tileSources !== []) { + $set = $this->getLayerSet(); + foreach ($this->tileSources as $path => $source) { + $tileSources[$path] = TileLayerSource::resize($source, $this->getDisplayPath($path), $set, $width, $height, + $this->baselineSources[$path] ?? null); + } + } foreach ($this->layers as $layer) { $layer['grid']->resize($width, $height); } + $this->tileSources = $tileSources; + } + + /** A tile layer path relative to its map, as refusals name it. */ + private function getDisplayPath(string $path): string + { + return basename($this->directory) . '/' . MapGraphics::DIRECTORY . '/' . basename($path); } public function assertDimensions(): void @@ -217,7 +258,10 @@ public function assertSourcesUnchanged(): void if (! is_file($path)) { throw new MapSourceRefusal("{$path} disappeared after opening; reload before saving."); } - MapGridSource::readFile($path); + if (dirname($path) !== $this->directory . '/' . MapGraphics::DIRECTORY) { + // Tile layers are carried as bytes; validation reports one the Engine cannot read. + MapGridSource::readFile($path); + } if (file_get_contents($path) !== $source) { throw new MapSourceRefusal("{$path} changed after opening; reload before saving. Nothing was written."); } @@ -231,6 +275,11 @@ public function assertSourcesUnchanged(): void } } } + foreach (TileLayerSource::findPaths($this->directory) as $path) { + if (! array_key_exists($path, $this->baselineSources)) { + throw new MapSourceRefusal("{$path} was added after opening; reload before saving."); + } + } } public function getSources(?string $directory = null, ?string $baseName = null): array @@ -244,6 +293,9 @@ public function getSources(?string $directory = null, ?string $baseName = null): } $sources[$path] = $layer['grid']->getSource(); } + foreach ($this->tileSources as $path => $source) { + $sources[$directory === null ? $path : $directory . '/' . MapGraphics::DIRECTORY . '/' . basename($path)] = $source; + } return $sources; } diff --git a/src/Maps/TileLayerSource.php b/src/Maps/TileLayerSource.php new file mode 100644 index 0000000..47506b8 --- /dev/null +++ b/src/Maps/TileLayerSource.php @@ -0,0 +1,129 @@ + + */ + public static function findPaths(string $mapDirectory): array + { + $directory = $mapDirectory . '/' . MapGraphics::DIRECTORY; + $paths = is_dir($directory) ? (glob($directory . '/*.tiles.php') ?: []) : []; + sort($paths); + + return $paths; + } + + /** + * Reads a tile layer as the Engine does. + * + * @throws InvalidArgumentException When the source is not a literal nowdoc of tile identities. + */ + public static function readLayer(string $source, string $displayPath): MapTileLayer + { + $name = preg_match(MapGraphics::FILENAME_PATTERN, basename($displayPath), $matches) === 1 ? $matches['name'] : basename($displayPath); + $order = (int) ($matches['order'] ?? 0); + + return new MapTileLayer($name, $order, $displayPath, MapGridSource::parseSource($source, $displayPath)); + } + + /** + * Resizes a tile layer with its map: cells and rows beyond the new size + * are cropped and new cells are empty (`0`). A layer that reads back as + * its baseline keeps the baseline's bytes; otherwise it is rewritten as a + * canonical literal nowdoc, keeping its leading comment and marker. + * + * @param MapLayerSet $layers The map's terminal layers before the resize. + * @throws MapSourceRefusal When the layer cannot be read or does not match the map; nothing is changed. + */ + public static function resize(string $source, string $displayPath, MapLayerSet $layers, int $width, int $height, ?string $baseline = null): string + { + try { + $layer = self::readLayer($source, $displayPath); + $layer->assertMatches($layers); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal(sprintf( + '%s Repair it before resizing the map; nothing was changed.', + rtrim($error->getMessage(), '.') . '.', + ), previous: $error); + } + + $rows = array_slice($layer->tiles, 0, $height); + foreach ($rows as &$row) { + $row = array_pad(array_slice($row, 0, $width), $width, TileId::EMPTY); + } + unset($row); + $rows = array_pad($rows, $height, array_fill(0, $width, TileId::EMPTY)); + + if ($rows === $layer->tiles) { + return $source; + } + + if ($baseline !== null) { + try { + if (self::readLayer($baseline, $displayPath)->tiles === $rows) { + return $baseline; + } + } catch (InvalidArgumentException) { + // A baseline the Engine cannot read is never restored by a resize. + } + } + + $body = implode("\n", array_map(static fn(array $row): string => implode(' ', $row), $rows)); + + return MapGridSource::buildSource($body, self::getMarker($source), self::getLeadingComment($source)); + } + + /** The nowdoc marker the author chose, or the preferred one. */ + private static function getMarker(string $source): string + { + return preg_match("/<<<[ \\t]*'([A-Za-z_][A-Za-z0-9_]*)'/", $source, $matches) === 1 + ? $matches[1] + : self::PREFERRED_MARKER; + } + + /** Comments between the open tag and `return`, so a rewrite keeps them. */ + private static function getLeadingComment(string $source): string + { + $comment = ''; + foreach (token_get_all($source) as $index => $token) { + if ($index === 0) { + continue; + } + if (! is_array($token) || ! in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + break; + } + $comment .= $token[1]; + } + $comment = trim($comment); + + return $comment === '' ? '' : $comment . "\n"; + } +} diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 21cc672..616a083 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -16,6 +16,7 @@ use Ichiloto\Editor\Storage\FileSetTransactionFailure; use Ichiloto\Editor\Storage\FilesystemFileSetOperations; use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Engine\Field\MapGraphics; use Ichiloto\Engine\Field\MapGridSource; use Ichiloto\Engine\Field\MapLayer; use Ichiloto\Engine\Field\MapLayerSource; @@ -472,6 +473,23 @@ public function getStoredGridPaths(): array return $this->layers->getStoredPaths(); } + /** + * The map's graphical tile layers in `graphics/`, unsaved resizes + * included. The TUI keeps them intact and never paints them. + * + * @return array Source bytes by path. + */ + public function getTileLayerSources(): array + { + return $this->layers->getTileSources(); + } + + /** The project's asset root, where tilesets and their sheets live. */ + public function getAssetRoot(): string + { + return dirname($this->getMapsRoot()); + } + /** Proves the layers compose and resolve collisions as the engine will load them. */ public function validateLayerContracts(): void { @@ -1430,7 +1448,9 @@ public function setMapField(string $field, mixed $value): void } /** - * Resizes the map and its event overlay while preserving existing content. + * Resizes the map, its event overlay and its graphical tile layers while + * preserving existing content. A tile layer the Engine cannot read + * refuses the resize before anything changes. * * @param int $width The new map width. * @param int $height The new map height. @@ -2018,8 +2038,12 @@ public function moveTo(string $newRelativeId): self $transaction->remove($this->dataPath); $expectedGrids = $this->layers->getSources($directory, $baseName); $sourceDirectoryMetadata = self::captureDirectoryMetadata($previousDirectory); - $layerDirectoryMetadata = is_dir($previousDirectory . '/layers') - ? self::captureDirectoryMetadata($previousDirectory . '/layers') : null; + $memberDirectoryMetadata = []; + foreach (['layers', MapGraphics::DIRECTORY] as $member) { + if (is_dir($previousDirectory . '/' . $member)) { + $memberDirectoryMetadata[] = self::captureDirectoryMetadata($previousDirectory . '/' . $member); + } + } $removedSourceDirectories = []; $directoryRestorationFailures = []; @@ -2032,18 +2056,16 @@ public function moveTo(string $newRelativeId): self $movedEventPath, $expectedGrids, $newRelativeId, - $previousDirectory, $mapsRoot, $sourceDirectoryMetadata, - $layerDirectoryMetadata, + $memberDirectoryMetadata, &$removedSourceDirectories, &$directoryRestorationFailures, ): void { - $removedSourceDirectories = self::removeEmptyDirectoryChain( - $layerDirectoryMetadata['path'] ?? $previousDirectory, + $removedSourceDirectories = self::removeEmptySourceDirectories( + $memberDirectoryMetadata, $mapsRoot, - $layerDirectoryMetadata ?? $sourceDirectoryMetadata, - [$previousDirectory => $sourceDirectoryMetadata], + $sourceDirectoryMetadata, ); try { @@ -2069,8 +2091,13 @@ public function moveTo(string $newRelativeId): self )); } + // Terminal grids must read back as literal grids; tile + // layers are carried as bytes, readable or not, so they + // must hold exactly the bytes that were moved. foreach ($expectedGrids as $path => $expectedSource) { - if (MapGridSource::readFile($path) !== MapGridSource::parseSource($expectedSource, $path)) { + if (dirname($path) === dirname($movedDataPath) . '/' . MapGraphics::DIRECTORY + ? @file_get_contents($path) !== $expectedSource + : MapGridSource::readFile($path) !== MapGridSource::parseSource($expectedSource, $path)) { throw new RuntimeException(basename($path) . ' would not read back as this map at ' . $newRelativeId); } } @@ -2223,6 +2250,33 @@ private static function deleteEmptyParentDirectories(string $directory, string $ } } + /** + * Removes a moved map's emptied member directories (`layers/`, + * `graphics/`), then its source directory and empty parents. + * + * @param list $memberDirectoryMetadata Metadata captured before source members are removed. + * @param array{path: string, mode: int, owner: int, group: int, modifiedAt: int, accessedAt: int, device: int, inode: int} $sourceDirectoryMetadata + * @return list The removed directories, leaf first. + */ + private static function removeEmptySourceDirectories(array $memberDirectoryMetadata, string $mapsRoot, array $sourceDirectoryMetadata): array + { + $removed = []; + + foreach ($memberDirectoryMetadata as $metadata) { + $entries = is_dir($metadata['path']) ? scandir($metadata['path']) : false; + + // A member directory holding anything else is its author's. + if (is_array($entries) && array_diff($entries, ['.', '..']) === [] && @rmdir($metadata['path'])) { + $removed[] = $metadata; + } + } + + return [ + ...$removed, + ...self::removeEmptyDirectoryChain($sourceDirectoryMetadata['path'], $mapsRoot, $sourceDirectoryMetadata), + ]; + } + /** * Removes the empty source directory and its empty parents for a move. * @@ -2239,7 +2293,6 @@ private static function removeEmptyDirectoryChain( string $directory, string $mapsRoot, array $sourceDirectoryMetadata, - array $knownMetadata = [], ): array { $candidates = []; $mapsRoot = rtrim($mapsRoot, DIRECTORY_SEPARATOR); @@ -2249,7 +2302,7 @@ private static function removeEmptyDirectoryChain( while ($directory !== '' && $directory !== $mapsRoot && str_starts_with($directory, $mapsRoot . DIRECTORY_SEPARATOR)) { $metadata = $directory === $sourceDirectoryMetadata['path'] ? $sourceDirectoryMetadata - : ($knownMetadata[$directory] ?? self::captureDirectoryMetadata($directory)); + : self::captureDirectoryMetadata($directory); $entries = is_dir($directory) ? scandir($directory) : false; if (! is_array($entries)) { diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index fdaeacb..c392541 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -14,6 +14,7 @@ use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Editor\Storage\FilesystemFileSetOperations; use Ichiloto\Engine\Core\ProjectFormat; +use Ichiloto\Engine\Field\MapGraphics; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use RuntimeException; @@ -423,11 +424,12 @@ public function deleteMap(int $selectedMapIndex, ?FileSetOperations $files = nul throw new MapSourceRefusal("{$selectedMap->mapId} is read-only: {$selectedMap->getGridSourceIssue()}"); } - // Only the split triplet is the map's; deleting a map must not take - // an author's own notes or assets in the same folder with it. The - // three members go as one transaction -- a failure on any of them - // puts the removed ones back, bytes and modification times -- and - // the folder goes only once it is empty. + // Only the split members are the map's -- its data, grid layers and + // graphics/ tile layers; deleting a map must not take an author's + // own notes or assets in the same folder with it. The members go as + // one transaction -- a failure on any of them puts the removed ones + // back, bytes and modification times -- and each folder goes only + // once it is empty. $transaction = new FileSetTransaction($selectedMap->directory, $files ?? new FilesystemFileSetOperations()); $transaction->remove($selectedMap->dataPath); foreach ($selectedMap->getStoredGridPaths() as $path) { @@ -435,7 +437,7 @@ public function deleteMap(int $selectedMapIndex, ?FileSetOperations $files = nul } $transaction->commit(); - foreach ([$selectedMap->directory . '/layers', $selectedMap->directory] as $directory) { + foreach ([$selectedMap->directory . '/layers', $selectedMap->directory . '/' . MapGraphics::DIRECTORY, $selectedMap->directory] as $directory) { if (is_dir($directory) && array_diff(scandir($directory) ?: [], ['.', '..']) === []) { @rmdir($directory); } diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php new file mode 100644 index 0000000..0d75d8d --- /dev/null +++ b/src/Validation/MapGraphicsValidator.php @@ -0,0 +1,230 @@ + The issues found; empty for a map without graphics. + */ + public static function validate(ProjectMap $map): array + { + $sources = $map->getTileLayerSources(); + $tilesetId = $map->getMapDataField(['tileset']); + + if ($tilesetId === null && $sources === [] && ! is_dir($map->directory . '/' . MapGraphics::DIRECTORY)) { + return []; + } + + $issues = []; + $tileset = self::loadTileset($map, $tilesetId, $issues); + $layers = self::readLayers($map, $sources, $issues); + + if ($tileset !== null) { + $issues = [ + ...$issues, + ...self::checkSheets($map, $tileset), + ...self::checkProvidedSheets($map, $tileset, $layers), + ]; + } + + return $issues; + } + + /** @param list $issues */ + private static function loadTileset(ProjectMap $map, mixed $tilesetId, array &$issues): ?Tileset + { + if ($tilesetId === null) { + $issues[] = Issue::error( + $map->mapId, + 'It has graphics/ but names no tileset.', + sprintf("Name one in the map data ('tileset' => '', from assets/%s/.php), or remove graphics/. %s", Tileset::DIRECTORY, self::GLYPH_FALLBACK), + ); + + return null; + } + + if (! is_string($tilesetId)) { + $issues[] = Issue::error( + $map->mapId, + sprintf('Its tileset is %s, not a tileset id.', get_debug_type($tilesetId)), + sprintf('Name the file of a tileset in assets/%s without .php. %s', Tileset::DIRECTORY, self::GLYPH_FALLBACK), + ); + + return null; + } + + try { + return Tileset::load($map->getAssetRoot(), $tilesetId); + } catch (Throwable $error) { + $issues[] = Issue::error( + $map->mapId, + $error->getMessage(), + sprintf('Repair or add assets/%s/%s.php. %s', Tileset::DIRECTORY, $tilesetId, self::GLYPH_FALLBACK), + ); + + return null; + } + } + + /** + * Reads every tile layer, reporting each one the Engine would refuse. + * + * @param array $sources + * @param list $issues + * @return list The readable layers. + */ + private static function readLayers(ProjectMap $map, array $sources, array &$issues): array + { + $hint = 'Repair the file by hand or in the GUI editor; the TUI never paints tiles. ' . self::GLYPH_FALLBACK; + + if (count($sources) > MapGraphics::MAX_LAYERS) { + $issues[] = Issue::error($map->mapId, sprintf('It has more than %d tile layers.', MapGraphics::MAX_LAYERS), $hint); + } + + try { + $layerSet = $map->getLayerSet(); + } catch (Throwable) { + // The terminal layers' own check reports why they disagree. + $layerSet = null; + } + + $layers = $orders = []; + + foreach ($sources as $path => $source) { + $displayPath = $map->mapId . '/' . MapGraphics::DIRECTORY . '/' . basename($path); + + if (preg_match(MapGraphics::FILENAME_PATTERN, basename($path), $matches) !== 1) { + $issues[] = Issue::error($map->mapId, "Tile layer {$displayPath} must be named NN.name.tiles.php.", $hint); + + continue; + } + + if (isset($orders[$matches['order']])) { + $issues[] = Issue::error($map->mapId, "Tile layer {$displayPath} repeats order {$matches['order']}.", $hint); + } + + $orders[$matches['order']] = true; + + try { + $layer = TileLayerSource::readLayer($source, $displayPath); + + if ($layerSet instanceof MapLayerSet) { + $layer->assertMatches($layerSet); + } + + $layers[] = $layer; + } catch (InvalidArgumentException $error) { + $issues[] = Issue::error($map->mapId, $error->getMessage(), $hint); + } + } + + return $layers; + } + + /** + * Warns for each sheet the Engine cannot draw from. + * + * @return list + */ + private static function checkSheets(ProjectMap $map, Tileset $tileset): array + { + $assetRoot = $map->getAssetRoot(); + $usable = $tileset->getUsableSheets($assetRoot)['sheets'] ?? []; + $issues = []; + + foreach (array_diff_key($tileset->sheets, $usable) as $sheet => $asset) { + $layout = TilesetSheet::from((string) $sheet); + + try { + $size = PngAssetPreflight::inspect($assetRoot, $asset); + $problem = sprintf( + '%s is %dx%d, which does not fit an %s sheet of %d x %d tiles in one even tile size shared by every sheet (%dx%d at 48 pixels).', + $asset, + $size['width'], + $size['height'], + $sheet, + $layout->getColumns(), + $layout->getRows(), + $layout->getColumns() * 48, + $layout->getRows() * 48, + ); + } catch (RuntimeException | InvalidArgumentException $error) { + $problem = $error->getMessage(); + } + + $issues[] = Issue::warning( + $map->mapId, + sprintf('Tileset %s sheet %s is unusable: %s', $tileset->id, $sheet, $problem), + 'Tiles from this sheet show their terminal glyphs. Replace the image or correct its path.', + ); + } + + return $issues; + } + + /** + * Warns for tile layers painting from sheets the tileset does not name. + * + * @param list $layers + * @return list + */ + private static function checkProvidedSheets(ProjectMap $map, Tileset $tileset, array $layers): array + { + $issues = []; + + foreach ($layers as $layer) { + $missing = []; + + foreach ($layer->getUsedIds() as $id) { + $sheet = TileId::getSheet($id)?->value; + + if ($sheet !== null && ! isset($tileset->sheets[$sheet])) { + $missing[$sheet] = $sheet; + } + } + + if ($missing !== []) { + sort($missing); + $issues[] = Issue::warning( + $map->mapId, + sprintf('Tile layer %s uses sheet%s %s, which tileset %s does not provide.', + $layer->path, count($missing) === 1 ? '' : 's', implode(', ', $missing), $tileset->id), + sprintf('Those cells show their terminal glyphs. Add the sheet to assets/%s/%s.php, or repaint the cells in the GUI editor.', + Tileset::DIRECTORY, $tileset->id), + ); + } + } + + return $issues; + } +} diff --git a/src/Validation/MapValidator.php b/src/Validation/MapValidator.php index fd814c5..0ad09b7 100644 --- a/src/Validation/MapValidator.php +++ b/src/Validation/MapValidator.php @@ -34,6 +34,7 @@ public static function validate(ProjectMap $map, array $mapsById): array ...self::findDanglingDestinations($map, $mapsById), ...self::findOutOfRangeSpawnPoints($map, $mapsById), ...self::findRetiredData($map), + ...array_map(static fn(Issue $issue): string => $issue->message, MapGraphicsValidator::validate($map)), ]; } diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 23138ad..6fd250e 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -1573,6 +1573,7 @@ protected function checkMaps(ProjectWorkspace $workspace): array $issues = [ ...$issues, ...$this->checkLayers($map), + ...MapGraphicsValidator::validate($map), ...$this->checkRetiredMapData($map), ...$this->checkEventMarkers($map), ...$this->checkDoors($map, $workspace->mapIds), diff --git a/tests/Pest.php b/tests/Pest.php index 6dad86e..bff5e7b 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -367,6 +367,7 @@ function plainTextOfFrame(string $frame, int $width, int $height): string require_once __DIR__ . '/Support/MapFixtures.php'; require_once __DIR__ . '/Support/LayeredMapFixtures.php'; +require_once __DIR__ . '/Support/MapGraphicsFixtures.php'; require_once __DIR__ . '/Support/BattleEntryFixtures.php'; diff --git a/tests/Support/MapGraphicsFixtures.php b/tests/Support/MapGraphicsFixtures.php new file mode 100644 index 0000000..91ed4b8 --- /dev/null +++ b/tests/Support/MapGraphicsFixtures.php @@ -0,0 +1,81 @@ + pack('N', strlen($data)) . $type . $data + . pack('N', crc32($type . $data)); + file_put_contents($path, "\x89PNG\r\n\x1a\n" + . $chunk('IHDR', pack('NNCCCCC', $width, $height, 8, 6, 0, 0, 0)) + . $chunk('IDAT', (string) gzcompress(str_repeat("\0" . str_repeat("\x70\x90\xB0\xFF", $width), $height))) + . $chunk('IEND', '')); +} + +/** + * Writes tileset `home` with an A2 sheet (16 x 12 tiles) and a B sheet + * (16 x 16 tiles) at 2 pixels per tile. + * + * @param array $sheets Asset-relative PNG paths by sheet name. + */ +function writeTestTileset(string $root, string $id = 'home', ?array $sheets = null): void +{ + $sheets ??= ['A2' => 'Graphics/Tilesets/Home_A2.png', 'B' => 'Graphics/Tilesets/Home_B.png']; + writeTilesetTestPng($root . '/assets/Graphics/Tilesets/Home_A2.png', 32, 24); + writeTilesetTestPng($root . '/assets/Graphics/Tilesets/Home_B.png', 32, 32); + if (! is_dir($root . '/assets/Data/Tilesets')) { + mkdir($root . '/assets/Data/Tilesets', 0o777, true); + } + file_put_contents($root . "/assets/Data/Tilesets/{$id}.php", " ucfirst($id), 'sheets' => $sheets], true) . ";\n"); +} + +/** Writes one tile layer in graphics/ as an authored literal nowdoc. */ +function writeTileLayer(string $mapDirectory, string $file, string $body, string $leadingComment = ''): string +{ + if (! is_dir($mapDirectory . '/graphics')) { + mkdir($mapDirectory . '/graphics', 0o777, true); + } + $path = $mapDirectory . '/graphics/' . $file; + file_put_contents($path, MapGridSource::buildSource($body, 'TILES', $leadingComment)); + + return $path; +} + +/** + * The layered test map (4 x 2 cells, or rows of 4 and 2 when ragged) naming + * tileset `home`, with a floor and an aligned decor tile layer. + */ +function mapGraphicsProject(bool $ragged = false): string +{ + $root = layeredMapProject($ragged); + $directory = $root . '/assets/Maps/test-map'; + writeTestTileset($root); + $data = $directory . '/test-map.data.php'; + file_put_contents($data, str_replace("'events' => [],", "'events' => [], 'tileset' => 'home',", (string) file_get_contents($data))); + writeTileLayer($directory, '01.floor.tiles.php', $ragged ? "2816 2816 2816 2816\n2816 2816" : "2816 2816 2816 2816\n2816 2816 2816 2816", + "// Painted in the GUI editor.\n"); + writeTileLayer($directory, '02.decor.tiles.php', $ragged ? "0 5 0 0\n0 5" : "0 5 0 0\n0 0 0 5"); + + return $root; +} + +/** + * The tile identities of one tile layer, read as the Engine reads them. + * + * @return list> + */ +function readTileRows(string $path): array +{ + return new Ichiloto\Engine\Field\MapTileLayer('layer', 0, $path, MapGridSource::readFile($path))->tiles; +} diff --git a/tests/Unit/MapGraphicsTest.php b/tests/Unit/MapGraphicsTest.php new file mode 100644 index 0000000..5a824ae --- /dev/null +++ b/tests/Unit/MapGraphicsTest.php @@ -0,0 +1,328 @@ + The graphics issues reported for the test map. */ +function graphicsIssueLines(string $root, ?Severity $severity = null): array +{ + $issues = array_filter( + MapGraphicsValidator::validate(loadLayeredMap($root)), + static fn(Issue $issue): bool => $issue->where === 'test-map' && ($severity === null || $issue->severity === $severity), + ); + + return array_values(array_map(static fn(Issue $issue): string => $issue->message, $issues)); +} + +// -- Resize ------------------------------------------------------------------- + +it('resizes every tile layer with the map, cropping and padding with empty tiles', function () { + $root = mapGraphicsProject(); + $map = loadLayeredMap($root); + $floor = $map->directory . '/graphics/01.floor.tiles.php'; + $decor = $map->directory . '/graphics/02.decor.tiles.php'; + + $map->resize(6, 3); + $map->save(); + + expect(file_get_contents($floor))->toBe("and(readTileRows($decor))->toBe([[0, 5, 0, 0, 0, 0], [0, 0, 0, 5, 0, 0], [0, 0, 0, 0, 0, 0]]); + + $map->resize(3, 1); + $map->save(); + $reloaded = loadLayeredMap($root); + $graphics = MapGraphics::loadFromDirectory($reloaded->directory, 'test-map', 'home', $reloaded->getLayerSet(), $root . '/assets'); + + expect(readTileRows($floor))->toBe([[2816, 2816, 2816]]) + ->and(readTileRows($decor))->toBe([[0, 5, 0]]) + ->and(array_map(static fn($layer): array => $layer->tiles, $graphics?->layers ?? []))->toBe([[[2816, 2816, 2816]], [[0, 5, 0]]]); +}); + +it('resizes a ragged map\'s tile rows to the widths its terminal rows take', function () { + $root = mapGraphicsProject(ragged: true); + $map = loadLayeredMap($root); + + $map->resize(5, 2); + $map->save(); + $reloaded = loadLayeredMap($root); + + expect(readTileRows($map->directory . '/graphics/01.floor.tiles.php'))->toBe([[2816, 2816, 2816, 2816, 0], [2816, 2816, 0, 0, 0]]) + ->and(readTileRows($map->directory . '/graphics/02.decor.tiles.php'))->toBe([[0, 5, 0, 0, 0], [0, 5, 0, 0, 0]]) + ->and(MapGraphics::loadFromDirectory($reloaded->directory, 'test-map', 'home', $reloaded->getLayerSet(), $root . '/assets')) + ->not->toBeNull(); +}); + +it('undoes a resize back to the authored tile layer bytes', function () { + $root = mapGraphicsProject(); + [$editor, $map] = layeredCanvasEditor($root); + $before = sourceHashTree($map->directory); + $sources = $map->getTileLayerSources(); + callEditorMethod($editor, 'setEditingMode', 'map'); + + callEditorMethod($editor, 'applyInspectorFieldValue', ['label' => ' X', 'value' => '4', 'target' => 'map-size', 'field' => 'width'], '6'); + expect($map->getWidth())->toBe(6) + ->and($map->getTileLayerSources())->not->toBe($sources); + + callEditorMethod($editor, 'performUndo'); + expect($map->getWidth())->toBe(4) + ->and($map->getTileLayerSources())->toBe($sources); + + $map->save(); + expect(sourceHashTree($map->directory))->toBe($before); +}); + +it('keeps a tile layer\'s authored bytes when a resize returns it to its saved size', function () { + $root = mapGraphicsProject(); + $map = loadLayeredMap($root); + $before = sourceHashTree($map->directory . '/graphics'); + + $map->resize(6, 3); + $map->resize(4, 2); + + expect($map->getTileLayerSources())->toBe(array_map(file_get_contents(...), array_combine( + array_keys($map->getTileLayerSources()), array_keys($map->getTileLayerSources()), + ))); + $map->save(); + expect(sourceHashTree($map->directory . '/graphics'))->toBe($before); +}); + +it('refuses a resize while a tile layer cannot be read, changing nothing', function (string $body) { + $root = mapGraphicsProject(); + writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', $body); + $map = loadLayeredMap($root); + $sources = $map->getTileLayerSources(); + + expect(fn() => $map->resize(6, 3))->toThrow(MapSourceRefusal::class, '02.decor.tiles.php'); + expect($map->getWidth())->toBe(4) + ->and($map->getHeight())->toBe(2) + ->and($map->getTileLayerSources())->toBe($sources) + ->and($map->isDirty())->toBeFalse(); +})->with([ + 'invalid identity' => ["0 5 0 abc\n0 0 0 5"], + 'short row' => ["0 5 0\n0 0 0 5"], + 'missing row' => ['0 5 0 0'], +]); + +it('carries graphics untouched through ordinary edits, and never lets an unreadable one block a save', function () { + $root = mapGraphicsProject(); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/graphics/02.decor.tiles.php', "setLayerCell('map:1', 0, 0, 'X'); + $map->setMapField('description', 'Edited in the TUI.'); + $map->save(); + + expect(sourceHashTree($directory . '/graphics'))->toBe($before); +}); + +it('refuses to save over tile layers changed or added after opening', function (bool $added) { + $root = mapGraphicsProject(); + $map = loadLayeredMap($root); + writeTileLayer($map->directory, $added ? '03.shadows.tiles.php' : '02.decor.tiles.php', "0 0 0 0\n0 0 0 0"); + $map->resize(6, 3); + $before = sourceHashTree($map->directory); + + expect(fn() => $map->save())->toThrow(MapSourceRefusal::class, 'after opening') + ->and(sourceHashTree($map->directory))->toBe($before); +})->with(['changed' => false, 'added' => true]); + +// -- Duplicate, move and delete ------------------------------------------------- + +it('duplicates a map with its graphics, including an unsaved resize', function () { + $root = mapGraphicsProject(); + $workspace = ProjectWorkspace::fromProject($root); + $index = array_search('test-map', $workspace->mapIds, true); + $before = sourceHashTree($root . '/assets/Maps/test-map/graphics'); + + $workspace->duplicateMap($index); + expect(sourceHashTree($root . '/assets/Maps/test-map-copy/graphics'))->toBe($before); + + $map = $workspace->getMapByIndex($index); + $map->resize(5, 2); + $map->duplicateTo($root . '/assets/Maps/wider', 'wider', 'Wider'); + expect(readTileRows($root . '/assets/Maps/wider/graphics/01.floor.tiles.php'))->toBe([[2816, 2816, 2816, 2816, 0], [2816, 2816, 2816, 2816, 0]]) + ->and(sourceHashTree($root . '/assets/Maps/test-map/graphics'))->toBe($before, 'the original stays unsaved'); +}); + +it('moves a map with its graphics and leaves no folder behind', function () { + $root = mapGraphicsProject(); + $map = loadLayeredMap($root); + $before = sourceHashTree($map->directory . '/graphics'); + + $moved = $map->moveTo('district/new'); + + expect(sourceHashTree($moved->directory . '/graphics'))->toBe($before) + ->and($moved->getTileLayerSources())->toHaveCount(2) + ->and(is_dir($map->directory))->toBeFalse(); +}); + +it('carries a tile layer the Engine cannot read byte for byte', function () { + $root = mapGraphicsProject(); + file_put_contents($root . '/assets/Maps/test-map/graphics/02.decor.tiles.php', "directory . '/graphics'); + + $map->duplicateTo($root . '/assets/Maps/copy', 'copy', 'Copy'); + $moved = $map->moveTo('district/new'); + + expect(sourceHashTree($root . '/assets/Maps/copy/graphics'))->toBe($before) + ->and(sourceHashTree($moved->directory . '/graphics'))->toBe($before) + ->and(is_dir($map->directory))->toBeFalse(); +}); + +it('rolls a failed move back with its graphics in place', function () { + $root = mapGraphicsProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($path, str_replace("'region' => ''", "'region' => basename(__DIR__)", (string) file_get_contents($path))); + $map = loadLayeredMap($root); + $before = sourceHashTree($root); + + expect(fn() => $map->moveTo('district/new'))->toThrow(RuntimeException::class, 'rolled back') + ->and(sourceHashTree($root))->toBe($before) + ->and(is_dir($root . '/assets/Maps/test-map/graphics'))->toBeTrue() + ->and(is_dir($root . '/assets/Maps/district'))->toBeFalse(); +}); + +it('deletes a map\'s graphics with it, keeping the author\'s other files', function (bool $withNotes) { + $root = mapGraphicsProject(); + $notes = $root . '/assets/Maps/test-map/graphics/palette-notes.txt'; + if ($withNotes) { + file_put_contents($notes, "Warm floor.\n"); + } + $workspace = ProjectWorkspace::fromProject($root); + + $workspace->deleteMap(array_search('test-map', $workspace->mapIds, true)); + + expect(glob($root . '/assets/Maps/test-map/graphics/*.tiles.php') ?: [])->toBe([]) + ->and(is_file($notes))->toBe($withNotes) + ->and(is_dir($root . '/assets/Maps/test-map'))->toBe($withNotes); +})->with(['empty folder' => false, 'author notes' => true]); + +it('restores every member when deleting a tile layer fails', function () { + $root = mapGraphicsProject(); + $workspace = ProjectWorkspace::fromProject($root); + $before = sourceHashTree($root . '/assets/Maps/test-map'); + $failing = new FailingFileSetOperations(failures: ['remove' => [$root . '/assets/Maps/test-map/graphics/02.decor.tiles.php']]); + + expect(fn() => $workspace->deleteMap(array_search('test-map', $workspace->mapIds, true), $failing)) + ->toThrow(FileSetTransactionFailure::class) + ->and(sourceHashTree($root . '/assets/Maps/test-map'))->toBe($before); +}); + +// -- Map data ----------------------------------------------------------------- + +it('names a tileset through the source-preserving map data path', function () { + $root = layeredMapProject(); + writeTestTileset($root); + $map = loadLayeredMap($root); + + $map->setMapDataField(['tileset'], 'home'); + $map->save(); + + expect((string) file_get_contents($map->dataPath))->toContain('// Authored metadata remains authored.') + ->and(loadLayeredMap($root)->getMapDataField(['tileset']))->toBe('home') + ->and(graphicsIssueLines($root))->toBe([]); +}); + +// -- Validation --------------------------------------------------------------- + +it('accepts valid graphics without issues', function () { + expect(graphicsIssueLines(mapGraphicsProject()))->toBe([]); +}); + +it('reports graphics the Engine refuses as errors', function (Closure $breakGraphics, string $expected) { + $root = mapGraphicsProject(); + $breakGraphics($root, $root . '/assets/Maps/test-map'); + + expect(implode("\n", graphicsIssueLines($root, Severity::ERROR)))->toContain($expected); +})->with([ + 'missing tileset file' => [function (string $root): void { + unlink($root . '/assets/Data/Tilesets/home.php'); + }, 'Tileset home was not found at Data/Tilesets/home.php.'], + 'invalid tileset' => [function (string $root): void { + file_put_contents($root . '/assets/Data/Tilesets/home.php', " 'Home', 'sheets' => ['Z9' => 'x.png']];\n"); + }, "Tileset home: 'Z9' is not an RPG Maker sheet"], + 'graphics without a tileset' => [function (string $root, string $directory): void { + $data = $directory . '/test-map.data.php'; + file_put_contents($data, str_replace(" 'tileset' => 'home',", '', (string) file_get_contents($data))); + }, 'It has graphics/ but names no tileset.'], + 'badly named file' => [function (string $root, string $directory): void { + writeTileLayer($directory, 'floor.tiles.php', "0 0 0 0\n0 0 0 0"); + }, 'Tile layer test-map/graphics/floor.tiles.php must be named NN.name.tiles.php.'], + 'duplicate order' => [function (string $root, string $directory): void { + writeTileLayer($directory, '02.shadows.tiles.php', "0 0 0 0\n0 0 0 0"); + }, 'Tile layer test-map/graphics/02.shadows.tiles.php repeats order 02.'], + 'rows' => [function (string $root, string $directory): void { + writeTileLayer($directory, '02.decor.tiles.php', '0 0 0 0'); + }, 'Tile layer test-map/graphics/02.decor.tiles.php must have 2 rows.'], + 'cells' => [function (string $root, string $directory): void { + writeTileLayer($directory, '02.decor.tiles.php', "0 0 0 0\n0 0 0"); + }, 'Tile layer test-map/graphics/02.decor.tiles.php row 1 must be 4 cells wide.'], + 'invalid identity' => [function (string $root, string $directory): void { + writeTileLayer($directory, '02.decor.tiles.php', "0 0 0 0\n0 0 0 9999"); + }, "Tile layer test-map/graphics/02.decor.tiles.php row 1, cell 3: '9999' is not an RPG Maker tile identity."], + 'executable source' => [function (string $root, string $directory): void { + file_put_contents($directory . '/graphics/02.decor.tiles.php', "toBe([ + 'Tileset home sheet A2 is unusable: Graphics/Tilesets/Home_A2.png is 30x24, which does not fit an A2 sheet of 16 x 12 tiles in one even tile size shared by every sheet (768x576 at 48 pixels).', + 'Tileset home sheet B is unusable: Invalid PNG header: Graphics/Tilesets/Home_B.png', + 'Tile layer test-map/graphics/02.decor.tiles.php uses sheets A5, C, which tileset home does not provide.', + ])->and(graphicsIssueLines($root, Severity::ERROR))->toBe([]); +}); + +it('includes graphics problems in the pre-save map warnings, unsaved resizes included', function () { + $root = mapGraphicsProject(); + $map = loadLayeredMap($root); + $map->setMapDataField(['tileset'], 'missing'); + + expect(MapValidator::validate($map, ['test-map' => $map]))->toContain('Tileset missing was not found at Data/Tilesets/missing.php.'); + + $map->setMapDataField(['tileset'], 'home'); + $map->resize(6, 3); + expect(MapGraphicsValidator::validate($map))->toBe([]); +}); + +it('reports graphics through project validation, as ichiloto validate runs it', function () { + $root = mapGraphicsProject(); + unlink($root . '/assets/Data/Tilesets/home.php'); + + $issues = array_filter( + new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), + static fn(Issue $issue): bool => $issue->where === 'test-map' && $issue->severity === Severity::ERROR + && $issue->message === 'Tileset home was not found at Data/Tilesets/home.php.', + ); + + expect($issues)->toHaveCount(1) + ->and(array_values($issues)[0]->hint)->toContain('terminal glyphs'); +}); From 2a5d88aebf9b344f8249a3cda1ab96d4f6402efe Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 22:28:07 +0200 Subject: [PATCH 051/344] docs: describe how the TUI keeps and validates map graphics --- docs/guides/make-a-new-map.md | 3 ++- docs/manual.md | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/guides/make-a-new-map.md b/docs/guides/make-a-new-map.md index e2e4ae5..069b151 100644 --- a/docs/guides/make-a-new-map.md +++ b/docs/guides/make-a-new-map.md @@ -34,7 +34,8 @@ creation is not undoable. `Enter` to type a number. Resizing is undoable. Shrinking a map discards the tiles outside the new bounds, -so `Ctrl+Z` is your friend if you overshoot. +in its terminal layers and in any graphical tile layers in `graphics/`, so +`Ctrl+Z` is your friend if you overshoot. Current behavior: the map id is derived from the region and name. Changing either means the map's *folder* moves on the next save, and the editor asks for diff --git a/docs/manual.md b/docs/manual.md index 3bac467..d785611 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -214,6 +214,31 @@ force for terminal edits. Untouched layer files are never written; unchanged rows retain their authored bytes, and all changed files are saved or rolled back together. +### Map Graphics + +A map may name a tileset in its data file (`'tileset' => 'home'`, read from +`assets/Data/Tilesets/home.php`) and keep RPG Maker tile layers in +`graphics/NN.name.tiles.php`. The TUI never paints or displays tiles; painting +them belongs to the GUI editor. It keeps them intact: + +- Resizing the map crops or pads every tile layer with empty tiles (`0`) in the + same undo step and save as the terminal layers. A resized tile layer is + rewritten as a literal nowdoc that keeps its leading comment; an unchanged + one keeps its bytes. A resize is refused, changing nothing, while a tile + layer cannot be read or does not match the map. +- Duplicating, moving and deleting a map take `graphics/` with it, in the same + transaction and rollback. Other files in `graphics/` are the author's and + stay. +- A tile layer changed or added on disk after opening refuses the save, as + terminal layers do. + +Validation (`Ctrl+E`, or `ichiloto validate`) reads graphics as the Engine does. +A missing or invalid tileset, graphics without a tileset, misnamed or +duplicate-order tile layers, rows or cells that do not match the map, and +invalid tile identities are errors. Unusable sheets and tiles from a sheet the +tileset does not provide are warnings. The game shows terminal glyphs for +anything it cannot draw. + Text catalogues in `assets/Graphics/Tilesets/*.txt` appear as Facade brushes in `Ctrl+P`. Author them in two-column cells like any map row, and separate multi-row shapes with blank lines. Select a brush to target From 0d7a9fc586785990bd6b738d645f4d6c56c24791 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 27 Sep 2026 10:11:07 +0200 Subject: [PATCH 052/344] revert(maps)!: edit one-column map cells again Two-column map cells changed how the terminal plays, so the editor returns to one map cell per terminal column. Reverted: 87569a3, a23f15b, 44a4a76's two-column validation, 67839d9, b3ad2f0, 8c2ed43 and 9bbeece. Removed: two-column grids and CellSymbol/CellEntry typed pairs, cell addressing and the paired paint keystroke, repeated event markers, reverse-video NPC selection and the 24 x 18 cell new-map default (48 x 18 columns again). Kept: the project format refusal (a project whose format differs from the engine's ProjectFormat::CURRENT, now 1, is refused with the engine's message), the warning that a map's retired tiles2d table is no longer read, the removal of graphical marker, crop and tiles2d authoring, RPG Maker NPC sheet validation, and map graphics preservation and validation, where a tile layer row is now as wide as the map in terminal columns. The scratch map test helpers stay in tests/Support/MapFixtures.php. BREAKING CHANGE: map coordinates and the canvas count terminal columns again. --- docs/guides/add-an-npc.md | 9 +- docs/guides/make-a-new-map.md | 4 +- docs/manual.md | 71 +--- .../tooling-loop/editing-in-the-editor.md | 2 +- src/Canvas/CellEntry.php | 77 ---- src/Canvas/Clipboard.php | 8 +- src/Canvas/FacadeCatalogue.php | 4 +- src/Canvas/LayerCanvas.php | 3 +- src/Cutscenes/Editing/CutscenePreviewPane.php | 7 +- src/Cutscenes/Preview/PreviewCamera.php | 55 +-- src/Cutscenes/Preview/PreviewPresentation.php | 3 +- src/Editor.php | 294 +++++--------- src/History/PaintStrokeCommand.php | 65 +-- src/Maps/CellSymbol.php | 69 ---- src/Maps/EditableGrid.php | 185 ++------- src/Maps/MapLayers.php | 18 +- src/ProjectMap.php | 375 +++++++++--------- src/ProjectWorkspace.php | 2 +- src/Validation/MapValidator.php | 25 +- src/Validation/ProjectValidator.php | 55 +-- .../technical-map/technical-map.event.php | 10 +- .../Maps/technical-map/technical-map.map.php | 10 +- tests/Fixtures/phase7-project/ichiloto.json | 2 +- .../assets/Maps/test-map/test-map.event.php | 10 +- .../assets/Maps/test-map/test-map.map.php | 10 +- tests/Fixtures/sample-project/ichiloto.json | 2 +- tests/Support/LayeredMapFixtures.php | 17 +- tests/Support/MapFixtures.php | 10 - tests/Unit/CanvasInputRegressionTest.php | 20 +- tests/Unit/CanvasToolsTest.php | 96 ++--- tests/Unit/CommandHistoryTest.php | 44 +- tests/Unit/EditableGridTest.php | 113 +----- tests/Unit/EditorSafetyTest.php | 6 +- tests/Unit/EditorShellTest.php | 10 +- tests/Unit/EditorUxTest.php | 4 +- tests/Unit/GoldenFrameTest.php | 2 +- tests/Unit/LastLegendMapIntegrityTest.php | 11 +- tests/Unit/LayeredCanvasTest.php | 92 ++--- tests/Unit/LayeredMapTest.php | 45 +-- tests/Unit/LayeredPreviewTest.php | 6 +- tests/Unit/ListFilterTest.php | 2 +- tests/Unit/MapIsolationTest.php | 11 +- tests/Unit/MapValidatorTest.php | 29 +- tests/Unit/MixedMaterialAuthoringTest.php | 39 +- tests/Unit/NpcAuthoringTest.php | 32 +- tests/Unit/ProjectFormatTest.php | 44 ++ tests/Unit/ProjectMapTest.php | 80 ++-- tests/Unit/ProjectValidatorTest.php | 17 +- tests/Unit/TwoColumnCellsTest.php | 324 --------------- tests/__snapshots__/main_shell_100x30.ansi | 2 +- 50 files changed, 848 insertions(+), 1583 deletions(-) delete mode 100644 src/Canvas/CellEntry.php delete mode 100644 src/Maps/CellSymbol.php create mode 100644 tests/Unit/ProjectFormatTest.php delete mode 100644 tests/Unit/TwoColumnCellsTest.php diff --git a/docs/guides/add-an-npc.md b/docs/guides/add-an-npc.md index c61a08b..e27a20e 100644 --- a/docs/guides/add-an-npc.md +++ b/docs/guides/add-an-npc.md @@ -47,8 +47,8 @@ The `Id` row in the Inspector is read-only for that reason. (type to narrow, `Enter` jumps to one); `[` and `]` step through them. - `M` picks the selected NPC up; move the cursor and press `Enter` to set it down, or `Esc` to leave it where it was. -- `D` duplicates the selected NPC under a fresh id, one cell to the right - when that cell is free. +- `D` duplicates the selected NPC under a fresh id, one column to the right + when that tile is free. - `Del` deletes it — unless something names its id (see [Reference-Safe Deletion](#reference-safe-deletion)). @@ -60,9 +60,8 @@ single undo step. `Tab` to the Inspector. Under `Appearance`: - `Sprite` is the glyph the game draws — one character, an emoji, or a styled - glyph such as `@`. A character occupies one cell of two - columns: an emoji fills it, a one-column glyph sits in its first column, - and styled ones draw as their plain glyph on the canvas. + glyph such as `@`. Wide glyphs take the two columns the game + gives them; styled ones draw as their plain glyph on the canvas. - `Facing North` / `South` / `East` / `West` are optional glyphs shown when the NPC turns that way. Leave a heading blank and the base sprite covers it. Rest the cursor on a `Facing …` row and the canvas previews that glyph in diff --git a/docs/guides/make-a-new-map.md b/docs/guides/make-a-new-map.md index 069b151..4df12bb 100644 --- a/docs/guides/make-a-new-map.md +++ b/docs/guides/make-a-new-map.md @@ -45,9 +45,7 @@ explicit confirmation before it does. 1. `Tab` to the `Canvas`. 2. Press `m` to be sure you are in Map mode, then `i` to enter Paint mode. -3. Move with the arrow keys and type a glyph to paint it. Every map cell is - two terminal columns, so `#` paints `##`; type a second key straight away - for a pair such as `[]`, or an emoji to fill the cell with it. +3. Move with the arrow keys and type a glyph to paint it. For anything larger than a few tiles, use the tools: diff --git a/docs/manual.md b/docs/manual.md index d785611..e3d6f1e 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -54,11 +54,11 @@ is what the code draws rather than a sketch of it: ┌─Assets [Focus]───────────────┐ ┌─Canvas─────────────────────┐ ┌─Inspector──────────────────────┐ │ Maps │ │ Preview: test-map │ │ Name: Test Map │ │ > test-map │ │ Test Map | | 12 x 5 | vie │ │ Region: │ - │ │ │ ######################## │ │ Description: A tiny fixture │ - │ │ │ ## ~~~~~~ ## │ │ map. │ - │ │ │ ## ## │ │ Size │ - │ │ │ ## ## │ │ X: 12 │ - │ │ │ ######################## │ │ Y: 5 │ + │ │ │ ############ │ │ Description: A tiny fixture │ + │ │ │ # ~~~ # │ │ map. │ + │ │ │ # # │ │ Size │ + │ │ │ # # │ │ X: 12 │ + │ │ │ ############ │ │ Y: 5 │ │ │ │ │ │ Events · 1 │ │ │ │ │ │ Triggers · 0 │ │ │ │ │ │ Audio │ @@ -218,8 +218,10 @@ back together. A map may name a tileset in its data file (`'tileset' => 'home'`, read from `assets/Data/Tilesets/home.php`) and keep RPG Maker tile layers in -`graphics/NN.name.tiles.php`. The TUI never paints or displays tiles; painting -them belongs to the GUI editor. It keeps them intact: +`graphics/NN.name.tiles.php`. A tile layer holds one tile identity per map +cell, so each of its rows is exactly as wide as the map's row in terminal +columns. The TUI never paints or displays tiles; painting them belongs to the +GUI editor. It keeps them intact: - Resizing the map crops or pads every tile layer with empty tiles (`0`) in the same undo step and save as the terminal layers. A resized tile layer is @@ -240,8 +242,7 @@ tileset does not provide are warnings. The game shows terminal glyphs for anything it cannot draw. Text catalogues in `assets/Graphics/Tilesets/*.txt` appear as Facade brushes in -`Ctrl+P`. Author them in two-column cells like any map row, and separate -multi-row shapes with blank lines. Select a brush to target +`Ctrl+P`. Separate multi-row shapes with blank lines. Select a brush to target the gameplay `buildings` layer, then press `Enter` to stamp it as one undo step. In Paint mode (`i`), a left click also stamps; Normal-mode clicks only move the cursor. Mode and map changes clear the stamp brush; `b` returns to a @@ -259,31 +260,6 @@ Normal. While painting, the Status pane shows `[PAINT]` beside the mode. Control-byte and function-key shortcuts (`Ctrl+S`, `F3`, ...) work in both modes, since they are not glyphs. -### Map Cells - -A map cell is two terminal columns: one two-column glyph (an emoji or CJK -character) or two one-column characters, each with its own style. `##`, `[]` -and `🌲` are each one cell, and a blank cell is two spaces. Coordinates, map -sizes, selections, the clipboard and every tool count cells, so the canvas -shows half as many cells across as it has columns. The cursor rests on a -cell's first column, and a click on either column selects the cell. - -Painting fills whole cells: - -- One typed character paints the cell with it repeated: `#` paints `##`. -- A second one-column key typed straight after, before any other key, click - or cursor move, turns that cell into the pair: `[` then `]` paints `[]`, as - one undo step. A third key starts the next cell over. -- A two-column glyph fills the cell as it is. -- Erase paints a blank cell. -- On the event layer a cell repeats its one marker (`E` paints `EE`); markers - never pair. - -The character map offers whole cells, and typing there enters one: a key -fills the cell, a second makes a pair, and `Enter` places it. A file row that -is not whole cells (a lone trailing character, or a one-column character -followed by a two-column glyph) opens read-only with its row and column. - ### Terminal Editing and Pending Graphical GUI Keep walkable floors and solid walls readable as terminal gameplay glyphs. @@ -342,9 +318,8 @@ line points to `i` instead. ### Paint mode -Every printable key paints a cell of its glyph at the cursor with the brush -tool, or loads that cell into the brush under a shape or select tool (see -[Map Cells](#map-cells) for pairs). `Arrows` move, +Every printable key paints its glyph at the cursor with the brush tool, or +loads it into the brush under a shape or select tool. `Arrows` move, `Enter` applies the active tool, erase keys erase, and `Esc` returns to Normal mode. Entering NPC mode or moving focus off the canvas also returns to Normal. @@ -375,16 +350,15 @@ shapes, flood fills - and is written as `` tags, exactly the styling authored by hand. - **Keep cell colour** (the default): painting changes the glyph and leaves - each cell's existing styling byte-for-byte, authored options and a - separate style per character included. + each cell's existing styling byte-for-byte, authored options included. - **No colour**: painting strips styling and writes plain glyphs. - **A colour**: painting writes the glyph in that colour. Choosing a colour recolours in place, keeping the glyphs, as one undoable stroke: with a selection active (`s`), every cell in the selection; otherwise, - under the brush tool, the cell at the cursor. Blank cells stay uncoloured. + under the brush tool, the cell at the cursor. Spaces stay uncoloured. The eyedropper (`k` / `Ctrl+K`) picks up a cell's colour along with its -glyph; an uncoloured cell loads an uncoloured brush. A painted blank cell is +glyph; an uncoloured cell loads an uncoloured brush. A painted space is always uncoloured, so erasing never leaves invisible styling behind. Event markers remain authoring geometry, but can now carry colour through the same picker and undo workflow. The Status pane shows the brush colour beside the tool. @@ -392,12 +366,11 @@ picker and undo workflow. The Status pane shows the brush colour beside the tool ### NPC Mode `F3` enters NPC mode: the map's `npcs` collection is drawn over the tiles as -an overlay - each sprite in its one cell, as the game draws it: a one-column -sprite in the cell's first column, a two-column glyph filling it, anything -wider overhanging the cells to its right, styled sprites as the plain glyph - -and nothing you do here paints a tile or an event marker. The selected NPC is -shown in reverse video within its cell; an explicitly empty sprite highlights -its existing map cell without replacing the glyph or neighbouring cells. An unselected empty +an overlay - sprites at their authored anchor, wide glyphs occupying the two +columns the game gives them, styled sprites as the plain glyph - and nothing +you do here paints a tile or an event marker. The selected NPC is shown in +brackets, except an explicitly empty sprite: selection highlights its existing +map cell without replacing the glyph or neighbouring cells. An unselected empty sprite draws no overlay. NPC mode sits on `n` in Normal mode and on `F3` everywhere, so no paintable character is taken from you (and not a control byte, since the terminal driver reserves the remaining ones). @@ -406,7 +379,7 @@ reserves the remaining ones). | --- | --- | | `Enter` | Select the NPC under the cursor; on an empty tile, name and create a new fixed NPC there | | `M` | Pick up the selected NPC; the next `Enter` sets it down at the cursor (`Esc` cancels) | -| `D` | Duplicate the selected NPC under a fresh stable id, one cell to the right when free | +| `D` | Duplicate the selected NPC under a fresh stable id, one column to the right when free | | `L` | List the map's NPCs by name and id; type to narrow, `Enter` selects one and jumps the cursor to it | | `[` / `]` | Select the previous / next NPC in the map's list | | `Del` | Delete the selected NPC - refused, with the list, while anything names its id | @@ -1573,7 +1546,7 @@ fields are preserved and are not errors. | --- | --- | --- | | Help | `?` | Generated from the binding table; scrolls with arrows | | Command palette | `Ctrl+P` | Fuzzy search over actions, tools, maps, categories, and event markers | -| Character map | `@` | Insert whole cells the keyboard reserves; lists the map's cells, the project's collision vocabulary and the reserved glyphs as cells, and takes a typed pair | +| Character map | `@` | Insert glyphs the keyboard reserves; lists the map's symbols, the project's collision vocabulary, and the reserved glyphs | | Status detail | `Ctrl+E` | Full text of the last message, and the log file path | Current limit: the help overlay and command palette cannot open while a picker diff --git a/docs/website/guide/tooling-loop/editing-in-the-editor.md b/docs/website/guide/tooling-loop/editing-in-the-editor.md index bf1472d..437764c 100644 --- a/docs/website/guide/tooling-loop/editing-in-the-editor.md +++ b/docs/website/guide/tooling-loop/editing-in-the-editor.md @@ -26,7 +26,7 @@ Move between panels with `Tab` and `Shift+Tab`, or directionally with `Shift+Arr ## Painting a map -The canvas is modal, in the vim tradition. `m` switches to Map mode for tiles; `e` switches to Event mode for the letter markers that events hang off. Press `i` to enter Paint mode, where every key paints its glyph (`Esc` returns to Normal mode). Every map cell is two terminal columns: one key fills a cell with its glyph repeated (`#` paints `##`), a second key typed straight after makes a pair such as `[]`, and an emoji fills the cell as it is. For larger work, reach for a tool: +The canvas is modal, in the vim tradition. `m` switches to Map mode for tiles; `e` switches to Event mode for the letter markers that events hang off. Press `i` to enter Paint mode, where every key paints its glyph (`Esc` returns to Normal mode), or reach for a tool: - `Ctrl+N` cycles Brush, Line, Rect, Rect Fill, and Select - `Ctrl+W` cycles brush width through 1, 2, 3, and 5 diff --git a/src/Canvas/CellEntry.php b/src/Canvas/CellEntry.php deleted file mode 100644 index d3f9e7a..0000000 --- a/src/Canvas/CellEntry.php +++ /dev/null @@ -1,77 +0,0 @@ -entered = false; - } - - /** Forgets the pending character unless this input entered one. */ - public function endInput(): void - { - if (! $this->entered) { - $this->pending = null; - } - } - - /** Forgets the pending character, so the next keystroke starts a cell. */ - public function reset(): void - { - $this->pending = null; - } - - /** - * Enters one typed character and returns the cell it paints. - * - * @param bool $pairs Whether a quick second keystroke may form a pair. - * @param string $target Identifies the cell being entered, so a pair - * only forms where its first character was entered. - */ - public function enterCharacter(string $character, bool $pairs = true, string $target = ''): string - { - $this->entered = true; - if (! $pairs || ! CellSymbol::isHalfCell($character)) { - $this->pending = null; - return CellSymbol::normalize($character); - } - if ($this->completesPair($character, $target)) { - $cell = $this->pending . $character; - $this->pending = null; - return $cell; - } - $this->pending = $character; - $this->pendingTarget = $target; - return $character . $character; - } - - /** Whether entering this character at this target would complete a pair. */ - public function completesPair(string $character, string $target = ''): bool - { - return $this->pending !== null && $this->pendingTarget === $target && CellSymbol::isHalfCell($character); - } -} diff --git a/src/Canvas/Clipboard.php b/src/Canvas/Clipboard.php index ecbdbc7..c88c194 100644 --- a/src/Canvas/Clipboard.php +++ b/src/Canvas/Clipboard.php @@ -5,7 +5,7 @@ namespace Ichiloto\Editor\Canvas; /** - * The canvas clipboard: one rectangular block of whole cells lifted off a map + * The canvas clipboard: one rectangular block of symbols lifted off a map * layer, ready to be stamped anywhere (repeatedly) as a single undoable * paste per stamp. * @@ -18,7 +18,7 @@ final class Clipboard * @var array> Rows of symbols, top-left first. */ private array $rows = []; - /** @var array}>> */ + /** @var array> */ private array $styles = []; /** * The layer the block was lifted from (a PaintStrokeCommand LAYER_* value). @@ -30,7 +30,7 @@ final class Clipboard * * @param array> $rows Rows of symbols, top-left first. * @param string $layer The source layer. - * @param array}>> $styles Optional cell styles, aligned with rows. + * @param array> $styles Optional tile styles, aligned with rows. * @return void */ public function store(array $rows, string $layer, array $styles = []): void @@ -105,7 +105,7 @@ public function getRows(): array * @param int $originY The paste origin y coordinate. * @param int $width The target map width. * @param int $height The target map height. - * @return array}}> The clipped placements. + * @return array The clipped placements. */ public function project(int $originX, int $originY, int $width, int $height): array { diff --git a/src/Canvas/FacadeCatalogue.php b/src/Canvas/FacadeCatalogue.php index a4b5c71..de52e62 100644 --- a/src/Canvas/FacadeCatalogue.php +++ b/src/Canvas/FacadeCatalogue.php @@ -7,7 +7,7 @@ use Ichiloto\Editor\Maps\EditableGrid; use RuntimeException; -/** Blank-line-separated authored facades of two-column cells; no duplicated shape definitions. */ +/** Blank-line-separated authored facades; no duplicated shape definitions. */ final class FacadeCatalogue { public static function load(string $path): array @@ -17,7 +17,7 @@ public static function load(string $path): array throw new RuntimeException('The facade catalogue cannot be read as UTF-8: ' . $path); } $blocks = preg_split('/(?:\r\n|\n|\r)[ \t]*(?:\r\n|\n|\r)+/', rtrim($text, "\r\n")) ?: []; - return array_values(array_map(static fn(string $block): array => new EditableGrid($block, context: basename($path))->cells, + return array_values(array_map(static fn(string $block): array => new EditableGrid($block)->cells, array_filter($blocks, static fn(string $block): bool => trim($block) !== ''), )); } diff --git a/src/Canvas/LayerCanvas.php b/src/Canvas/LayerCanvas.php index ea2042f..fe3a77f 100644 --- a/src/Canvas/LayerCanvas.php +++ b/src/Canvas/LayerCanvas.php @@ -7,7 +7,6 @@ use Ichiloto\Editor\EditorWindow; use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\MapSourceRefusal; -use Ichiloto\Editor\Maps\EditableGrid; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\UI\Modal; use Ichiloto\Editor\UI\PaletteItem; @@ -267,7 +266,7 @@ private function stampFacadeBrush(): void foreach ($rows as $y => $row) { foreach ($row as $x => $cell) { $writes[] = ['x' => $this->cursorX + $x, 'y' => $this->cursorY + $y, - 'symbol' => $cell['symbol'], 'style' => EditableGrid::getCellStyle($cell)]; + 'symbol' => $cell['symbol'], 'style' => ['prefix' => $cell['prefix'], 'suffix' => $cell['suffix']]]; } } $this->applyCanvasWrites($map, $writes, 'Facade stamp'); diff --git a/src/Cutscenes/Editing/CutscenePreviewPane.php b/src/Cutscenes/Editing/CutscenePreviewPane.php index 2ceecca..beef63c 100644 --- a/src/Cutscenes/Editing/CutscenePreviewPane.php +++ b/src/Cutscenes/Editing/CutscenePreviewPane.php @@ -17,7 +17,6 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Status\StatusLevel; use Ichiloto\Editor\UI\CutscenesScreen; -use Ichiloto\Engine\Field\MapCell; use Throwable; /** @@ -868,7 +867,7 @@ private function cinematicPreviewOrigin(CutsceneAsset $asset, ?string $startMap) } /** - * The first blank cell, row by row. + * The first tile that is not a wall-like glyph, row by row. * * @return array{0: int, 1: int} */ @@ -876,7 +875,9 @@ private function firstOpenTile(ProjectMap $map): array { for ($y = 0; $y < $map->getHeight(); $y++) { for ($x = 0; $x < $map->getWidth(); $x++) { - if (MapCell::isBlank($map->getTileSymbol($x, $y))) { + $symbol = $map->getTileSymbol($x, $y); + + if ($symbol === ' ') { return [$x, $y]; } } diff --git a/src/Cutscenes/Preview/PreviewCamera.php b/src/Cutscenes/Preview/PreviewCamera.php index 049aed3..e04cb1a 100644 --- a/src/Cutscenes/Preview/PreviewCamera.php +++ b/src/Cutscenes/Preview/PreviewCamera.php @@ -6,7 +6,7 @@ use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\Core\Vector2; -use Ichiloto\Engine\Field\MapCell; +use Ichiloto\Engine\Field\MapLayerSet; use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\Rendering\Camera; @@ -18,38 +18,27 @@ * transition covers — reaches the terminal through the camera, so capturing * here gives the preview pane an honest picture of the moment without the * Engine ever writing a byte over the editor's own screen. - * - * As in the Engine, the world is addressed in map cells and the frame is - * written in console columns, MapCell::COLUMNS to a cell. */ final class PreviewCamera extends Camera { /** @var string[] The captured rows, one per screen line. */ private array $frame = []; - /** - * @param int $width The frame width, in console columns. - * @param int $height The frame height, in rows. - */ public function __construct(int $width, int $height) { - $this->screen = new Rect(0, 0, 1, 1); + $this->screen = new Rect(0, 0, max(1, $width), max(1, $height)); $this->position = new Vector2(0, 0); $this->player = null; $this->worldSpace = []; - $this->resizeToConsole($width, $height); $this->clearFrame(); } /** - * Resizes the captured screen to a console area. - * - * @param int $width The frame width, in console columns. - * @param int $height The frame height, in rows. + * Resizes the captured screen. */ public function resize(int $width, int $height): void { - $this->resizeToConsole($width, $height); + $this->resizeViewport($width, $height); $this->clearFrame(); } @@ -58,7 +47,7 @@ public function resize(int $width, int $height): void */ public function clearFrame(): void { - $this->frame = array_fill(0, $this->screen->getHeight(), str_repeat(' ', $this->getConsoleColumns())); + $this->frame = array_fill(0, $this->screen->getHeight(), str_repeat(' ', $this->screen->getWidth())); } /** @@ -72,21 +61,34 @@ public function frame(): array } /** - * Paints the world (the map cells) into the frame, two columns a cell. + * Paints the world (the map tiles) into the frame. */ public function renderMap(): void { $renderOffset = $this->getRenderOffset(); + $visibleWidth = $this->getVisibleWorldWidth(); + $visibleHeight = $this->getVisibleWorldHeight(); + + for ($row = 0; $row < $visibleHeight; $row++) { + $worldRow = $this->worldSpace[(int) $this->position->y + $row] ?? null; + + if ($worldRow === null) { + continue; + } + + $content = is_array($worldRow) + ? implode('', array_slice($worldRow, (int) $this->position->x, $visibleWidth)) + : TerminalText::sliceSymbols((string) $worldRow, (int) $this->position->x, $visibleWidth); - foreach ($this->visibleMapRows() as $worldY => $cells) { - $this->draw( - TerminalText::padRight(implode('', $cells), count($cells) * MapCell::COLUMNS), - (int) $renderOffset->x * MapCell::COLUMNS, - (int) $renderOffset->y + $worldY - (int) $this->position->y, - ); + $this->draw(TerminalText::padRight($content, $visibleWidth), (int) $renderOffset->x, (int) $renderOffset->y + $row); } } + public function renderLayeredMap(MapLayerSet $layers): void + { + $this->renderMap(); + } + public function draw(iterable|string $content, int $x = 0, int $y = 0): void { $lines = []; @@ -100,7 +102,7 @@ public function draw(iterable|string $content, int $x = 0, int $y = 0): void } $height = $this->screen->getHeight(); - $width = $this->getConsoleColumns(); + $width = $this->screen->getWidth(); $index = 0; foreach ($lines as $line) { @@ -117,7 +119,8 @@ public function draw(iterable|string $content, int $x = 0, int $y = 0): void public function renderOnScreen(array $output, Vector2 $worldSpacePosition): void { - $this->renderAtScreenPosition($output, $this->getConsolePosition($worldSpacePosition)); + $screenSpacePosition = $this->getScreenSpacePosition($worldSpacePosition); + $this->renderAtScreenPosition($output, $screenSpacePosition); } public function renderAtScreenPosition(array|string $output, Vector2 $screenSpacePosition): void @@ -163,7 +166,7 @@ public function update(): void */ private function writeRow(int $row, int $x, string $text): void { - $width = $this->getConsoleColumns(); + $width = $this->screen->getWidth(); $plain = TerminalText::stripAnsi(TerminalText::formatStyles($text)); $plain = preg_replace('/\x1b\[[0-9;]*m/', '', $plain) ?? $plain; $cells = TerminalText::visibleSymbols($this->frame[$row] ?? str_repeat(' ', $width)); diff --git a/src/Cutscenes/Preview/PreviewPresentation.php b/src/Cutscenes/Preview/PreviewPresentation.php index 42fff59..556869d 100644 --- a/src/Cutscenes/Preview/PreviewPresentation.php +++ b/src/Cutscenes/Preview/PreviewPresentation.php @@ -68,8 +68,7 @@ public function render(): void return; } - // Dialogue is text: it is laid out in console columns, not map cells. - $width = $this->camera->getConsoleColumns(); + $width = $this->camera->screen->getWidth(); $height = $this->camera->screen->getHeight(); $boxWidth = max(12, min($width, 60)); $inner = $boxWidth - 4; diff --git a/src/Editor.php b/src/Editor.php index 72bf996..e978254 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -15,7 +15,6 @@ use Ichiloto\Editor\Actors\ActorIdentityMigrationPlan; use Ichiloto\Editor\Backup\BackupWriter; use Ichiloto\Editor\Canvas\CanvasTool; -use Ichiloto\Editor\Canvas\CellEntry; use Ichiloto\Editor\Canvas\Clipboard; use Ichiloto\Editor\Canvas\ToolGeometry; use Ichiloto\Editor\Cutscenes\CutsceneType; @@ -57,7 +56,6 @@ use Ichiloto\Editor\History\PaintStrokeCommand; use Ichiloto\Editor\Inspector\InputControl; use Ichiloto\Editor\Inspector\InputControlType; -use Ichiloto\Editor\Maps\CellSymbol; use Ichiloto\Editor\IO\InputDecoder; use Ichiloto\Editor\IO\InputRouter; use Ichiloto\Editor\IO\KeyBinding; @@ -99,7 +97,6 @@ use Ichiloto\Engine\Entities\Roles\ParameterCurveGenerator; use Ichiloto\Engine\Events\Enumerations\ChestType; use Ichiloto\Engine\Events\Enumerations\LootType; -use Ichiloto\Engine\Field\MapCell; use Ichiloto\Engine\Quests\QuestObjectiveType; use RuntimeException; if (! class_exists(__NAMESPACE__ . chr(92) . 'Animation', false)) { class_alias('Ichiloto' . chr(92) . 'Engine' . chr(92) . 'Animations' . chr(92) . 'Animation', __NAMESPACE__ . chr(92) . 'Animation'); } @@ -331,7 +328,7 @@ final class Editor * The row the help overlay scrolls to keep visible. */ private int $helpScrollRow = 0; - private string $selectedPaintSymbol = MapCell::BLANK; + private string $selectedPaintSymbol = ' '; /** * The active canvas tool. Every tool commits through one * PaintStrokeCommand, so shapes and fills undo in a single step. @@ -698,20 +695,6 @@ final class Editor * The in-flight mouse-drag stroke; recorded as one command on release. */ private ?PaintStrokeCommand $activeStrokeCommand = null; - /** - * The stroke the last canvas write committed, which a pairing keystroke - * amends so a typed pair undoes in one step. - */ - private ?PaintStrokeCommand $lastCanvasStroke = null; - /** - * Typed characters becoming whole cells on the canvas. - */ - private readonly CellEntry $cellEntry; - /** - * The pair being typed in the character map, and the cell it enters. - */ - private readonly CellEntry $characterMapEntry; - private string $characterMapCell = ''; /** * Which guarded action ('quit'|'reload') the unsaved-changes prompt confirms. */ @@ -775,8 +758,6 @@ public function __construct(private readonly string $projectRoot) $this->toasts = new ToastQueue(); $this->commandPalette = new CommandPalette(); $this->clipboard = new Clipboard(); - $this->cellEntry = new CellEntry(); - $this->characterMapEntry = new CellEntry(); $this->assetFilter = new ListFilter(); $this->databaseFilter = new ListFilter(); $this->dialogFilter = new ListFilter(); @@ -914,7 +895,7 @@ private function boot(): void $this->optionDialogField = null; $this->eventOptionDialogTitle = 'Options'; $this->eventOptionDialogEntries = []; - $this->selectedPaintSymbol = MapCell::BLANK; + $this->selectedPaintSymbol = ' '; $this->activeMousePaintButton = null; $this->lastMousePaintPoint = null; $this->characterPaletteIndex = 0; @@ -1255,11 +1236,7 @@ private function handleInput(): void */ private function dispatchInput(string $input): void { - // A pairing keystroke must be the very next input: anything else - // routed in between forgets the half-typed cell. - $this->cellEntry->beginInput(); $this->inputRouter->route($input); - $this->cellEntry->endInput(); } /** @@ -2900,8 +2877,7 @@ private function duplicateSelectedNpc(): void $id = $collection->uniqueIdFor($npc->getName()); $copy = $npc->asCopyWithId($id); - // A character occupies one cell, so the copy stands in the next one. - $x = $npc->getX() + 1; + $x = $npc->getX() + $npc->getSpriteWidth(); if ($x < $map->getWidth() && $collection->indexAt($x, $npc->getY()) === null) { $copy = $copy->movedTo($x, $npc->getY()); @@ -3919,8 +3895,6 @@ private function openCharacterMap(): void $this->isCharacterMapOpen = true; $this->characterPaletteIndex = 0; - $this->characterMapEntry->reset(); - $this->characterMapCell = ''; $this->statusMessage = 'Character map open.'; $this->requestFullRender(); } @@ -3961,21 +3935,6 @@ private function handleCharacterMapInput(string $input, string $normalizedInput) if ($input === "\033") { $this->closeCharacterMap('Character map closed.'); - return; - } - - if ($input === "\177" || $input === "\010") { - $this->characterMapEntry->reset(); - $this->characterMapCell = ''; - $this->renderOverlays(); - return; - } - - // Typing enters a cell of its own: one character fills the cell, a - // second makes a pair such as [], and a third starts over. - if (! str_contains($input, "\033") && preg_match('/\A\X\z/u', $input) === 1 && preg_match('/\A[\x00-\x1F\x7F]\z/', $input) !== 1) { - $this->characterMapCell = $this->characterMapEntry->enterCharacter($input, $this->editingMode !== self::MODE_EVENT); - $this->renderOverlays(); } } @@ -3988,9 +3947,6 @@ private function handleCharacterMapInput(string $input, string $normalizedInput) */ private function moveCharacterPaletteSelection(int $deltaX, int $deltaY): void { - // Choosing from the palette sets aside a typed cell. - $this->characterMapEntry->reset(); - $this->characterMapCell = ''; $palette = $this->getCharacterPalette(); if ($palette === []) { @@ -4014,15 +3970,14 @@ private function moveCharacterPaletteSelection(int $deltaX, int $deltaY): void } /** - * Applies the typed cell, or else the selected character-map cell, to - * the current cursor location. + * Applies the selected character-map glyph to the current cursor location. * * @return void */ private function applyCharacterPaletteSelection(): void { $palette = $this->getCharacterPalette(); - $symbol = $this->characterMapCell !== '' ? $this->characterMapCell : ($palette[$this->characterPaletteIndex] ?? null); + $symbol = $palette[$this->characterPaletteIndex] ?? null; if ($symbol === null) { $this->closeCharacterMap('Character map closed.'); @@ -4030,9 +3985,9 @@ private function applyCharacterPaletteSelection(): void } $this->selectedPaintSymbol = $symbol; - $this->replaceCurrentSymbol($this->getBrushCell()); + $this->replaceCurrentSymbol($symbol); $this->isCharacterMapOpen = false; - $this->statusMessage = sprintf('Placed %s.', CellSymbol::describe($this->getBrushCell())); + $this->statusMessage = sprintf('Placed %s.', $symbol === ' ' ? 'space' : $symbol); $this->requestFullRender(); } @@ -4230,44 +4185,42 @@ private function handleTypedSymbolInput(string $input): void return; } + if ($symbol === ' ') { + $this->adoptPaintSymbol(' '); + return; + } + // Paint mode has no reserved glyphs: %, ^, @ and ? paint like - // anything else. Commands live in Normal mode. One character fills - // the cell; on a map layer a quick second one makes a pair. An event - // cell repeats its one marker. - $pairs = $this->editingMode !== self::MODE_EVENT; - $target = sprintf('%s|%s|%d|%d', $this->getSelectedMap()?->mapId ?? '', $this->getActiveCanvasLayer(), $this->cursorX, $this->cursorY); - $completesPair = $pairs && $this->cellEntry->completesPair($symbol, $target); - $this->adoptPaintSymbol($this->cellEntry->enterCharacter($symbol, $pairs, $target), $completesPair); + // anything else. Commands live in Normal mode. + $this->adoptPaintSymbol($symbol); } /** - * Makes a typed cell the active paint symbol. + * Makes a typed glyph the active paint symbol. * * Under the brush this also paints it, which is the historic behaviour. * Under a shape or selection tool it only loads the brush: an anchored * gesture must not be interrupted by a stray dab where the cursor * happens to sit. * - * @param string $symbol The whole cell to paint. - * @param bool $completesPair Whether this keystroke turns the previous - * one's cell into a pair, amending its paint into one undo step. + * @param string $symbol The typed symbol. * @return void */ - private function adoptPaintSymbol(string $symbol, bool $completesPair = false): void + private function adoptPaintSymbol(string $symbol): void { - $this->selectedPaintSymbol = CellSymbol::normalize($symbol); + $this->selectedPaintSymbol = $symbol; if ($this->canvasTool !== CanvasTool::BRUSH) { $this->setStatus(sprintf( 'Paint symbol is now %s. %s', - CellSymbol::describe($this->selectedPaintSymbol), + $symbol === ' ' ? 'space' : $symbol, $this->describeCanvasToolUsage(), )); $this->renderCanvasArea(); return; } - $this->replaceCurrentSymbol($this->getBrushCell(), $completesPair); + $this->replaceCurrentSymbol($symbol); $this->renderCanvasArea(); } @@ -4280,7 +4233,7 @@ private function adoptPaintSymbol(string $symbol, bool $completesPair = false): private function handleEraseInput(string $input): bool { if ($input === "\177" || $input === "\010") { - $this->adoptPaintSymbol(MapCell::BLANK); + $this->adoptPaintSymbol(' '); return true; } @@ -4288,14 +4241,12 @@ private function handleEraseInput(string $input): bool } /** - * Replaces the current cell on the active editing layer. + * Replaces the current symbol on the active editing layer. * - * @param string $symbol The replacement cell. - * @param bool $amendsLastStroke Whether the paint joins the previous - * canvas stroke, as a pairing keystroke does. + * @param string $symbol The replacement symbol. * @return void */ - private function replaceCurrentSymbol(string $symbol, bool $amendsLastStroke = false): void + private function replaceCurrentSymbol(string $symbol): void { if (! $this->workspace instanceof ProjectWorkspace) { return; @@ -4316,7 +4267,6 @@ private function replaceCurrentSymbol(string $symbol, bool $amendsLastStroke = f ToolGeometry::brush($this->cursorX, $this->cursorY, $this->canvasBrushSize), $symbol, $isEventLayer ? 'Event edit' : 'Tile edit', - $amendsLastStroke ? $this->lastCanvasStroke : null, ); $this->statusMessage = sprintf( @@ -4334,10 +4284,10 @@ private function replaceCurrentSymbol(string $symbol, bool $amendsLastStroke = f */ private function applySelectedPaintSymbol(): void { - $this->replaceCurrentSymbol($this->getBrushCell()); + $this->replaceCurrentSymbol($this->selectedPaintSymbol); $this->statusMessage = sprintf( 'Painted %s at (%d, %d).', - CellSymbol::describe($this->getBrushCell()), + $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, $this->cursorX, $this->cursorY ); @@ -4430,7 +4380,7 @@ private function handleCanvasWheelScroll(MouseEvent $event): bool return true; } - $viewportWidth = $bounds['cells']; + $viewportWidth = $bounds['width']; $viewportHeight = $bounds['height']; $maxOffsetX = max(0, $selectedMap->getWidth() - $viewportWidth); $maxOffsetY = max(0, $selectedMap->getHeight() - $viewportHeight); @@ -4450,9 +4400,7 @@ private function handleCanvasWheelScroll(MouseEvent $event): bool /** * Shared geometry for rendering, cursor visibility, hit testing and scrolling. - * Width is in terminal columns; a map cell is MapCell::COLUMNS of them, so - * the canvas shows the whole cells that fit. - * @return array{left: int, top: int, right: int, bottom: int, width: int, height: int, cells: int} + * @return array{left: int, top: int, right: int, bottom: int, width: int, height: int} */ private function getCanvasPreviewBounds(?array $layout = null): array { @@ -4470,7 +4418,6 @@ private function getCanvasPreviewBounds(?array $layout = null): array 'bottom' => $mapTop + $previewHeight - 1, 'width' => $contentWidth, 'height' => $previewHeight, - 'cells' => max(1, intdiv($contentWidth, MapCell::COLUMNS)), ]; } @@ -4483,9 +4430,7 @@ private function handleCanvasMouseEdit(MouseEvent $event): bool $mapRight = $bounds['right']; $mapBottom = $bounds['bottom']; - // A trailing column narrower than a cell belongs to no cell. - if ($event->x < $mapLeft || $event->x > $mapRight || $event->y < $mapTop || $event->y > $mapBottom - || $event->x - $mapLeft >= $bounds['cells'] * MapCell::COLUMNS) { + if ($event->x < $mapLeft || $event->x > $mapRight || $event->y < $mapTop || $event->y > $mapBottom) { return true; } @@ -4495,7 +4440,7 @@ private function handleCanvasMouseEdit(MouseEvent $event): bool return true; } - $targetX = $this->canvasOffsetX + intdiv($event->x - $mapLeft, MapCell::COLUMNS); + $targetX = $this->canvasOffsetX + ($event->x - $mapLeft); $targetY = $this->canvasOffsetY + ($event->y - $mapTop); if ($targetX < 0 || $targetX >= $selectedMap->getWidth() || $targetY < 0 || $targetY >= $selectedMap->getHeight()) { @@ -4527,11 +4472,11 @@ private function handleCanvasMouseEdit(MouseEvent $event): bool if ($this->editingMode === self::MODE_EVENT) { $this->handleEventCanvasMouseEdit($selectedMap, $event->button, $targetX, $targetY); } else { - $paintSymbol = $event->button === MouseButton::RIGHT_BUTTON ? MapCell::BLANK : $this->getBrushCell(); + $paintSymbol = $event->button === MouseButton::RIGHT_BUTTON ? ' ' : $this->selectedPaintSymbol; $this->paintCanvasStroke($selectedMap, $paintSymbol, $targetX, $targetY, $event->button); $this->statusMessage = sprintf( 'Painted %s at (%d, %d).', - CellSymbol::describe($paintSymbol), + $paintSymbol === ' ' ? 'space' : $paintSymbol, $this->cursorX, $this->cursorY ); @@ -4570,10 +4515,10 @@ private function handleEventCanvasMouseEdit( $isStartingStroke = $this->activeMousePaintButton !== $button || ! is_array($this->lastMousePaintPoint); if ($button === MouseButton::LEFT_BUTTON && $clickedMarker !== null) { - $this->selectedPaintSymbol = CellSymbol::normalize($clickedMarker); + $this->selectedPaintSymbol = $clickedMarker; } - $paintSymbol = $button === MouseButton::RIGHT_BUTTON ? MapCell::BLANK : $this->getBrushCell(); + $paintSymbol = $button === MouseButton::RIGHT_BUTTON ? ' ' : $this->selectedPaintSymbol; $this->paintCanvasStroke($selectedMap, $paintSymbol, $targetX, $targetY, $button, true); if ($button === MouseButton::LEFT_BUTTON && $clickedMarker !== null && $isStartingStroke) { @@ -4581,11 +4526,10 @@ private function handleEventCanvasMouseEdit( return; } - $marker = MapCell::getMarker($paintSymbol); $this->statusMessage = sprintf( '%s event %s at (%d, %d).', - $marker === null ? 'Cleared' : 'Placed', - $marker ?? 'cell', + $paintSymbol === ' ' ? 'Cleared' : 'Placed', + $paintSymbol === ' ' ? 'cell' : $paintSymbol, $this->cursorX, $this->cursorY, ); @@ -4641,15 +4585,18 @@ private function paintCanvasStroke( $oldSymbol = $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']); $oldStyle = $selectedMap->getLayerCellStyle($this->getActiveCanvasLayer(), $point['x'], $point['y']); - $selectedMap->setStyledLayerCell($this->getActiveCanvasLayer(), $point['x'], $point['y'], $symbol, - $this->resolvePaintStyle($symbol, $this->selectedPaintColor, $oldStyle)); + [$newPrefix, $newSuffix] = $this->resolvePaintStyle($symbol, $this->selectedPaintColor, $oldStyle); + $selectedMap->setLayerCell($this->getActiveCanvasLayer(), $point['x'], $point['y'], $symbol, $newPrefix, $newSuffix); + $newSymbol = $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']); $this->activeStrokeCommand->appendCell( $point['x'], $point['y'], $oldSymbol, - $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']), - $oldStyle, - $selectedMap->getLayerCellStyle($this->getActiveCanvasLayer(), $point['x'], $point['y']), + $newSymbol, + $oldStyle['prefix'], + $oldStyle['suffix'], + $newPrefix, + $newSuffix, ); } @@ -4781,27 +4728,6 @@ private function selectCanvasTool(CanvasTool $tool): void $this->renderCanvasArea(); } - /** - * Returns the cell the brush paints. An event cell holds one marker, so - * on the event layer the brush paints its first marker repeated. - * - * @return string - */ - private function getBrushCell(): string - { - if ($this->editingMode !== self::MODE_EVENT) { - return $this->selectedPaintSymbol; - } - - foreach (MapCell::getCharacters($this->selectedPaintSymbol) as $character) { - if (trim($character) !== '') { - return CellSymbol::normalize($character); - } - } - - return MapCell::BLANK; - } - /** * Returns the layer the canvas tools currently paint on. * @@ -4843,7 +4769,8 @@ private function readCanvasSymbol(ProjectMap $map, int $x, int $y): string */ private function writeCanvasSymbol(ProjectMap $map, int $x, int $y, string $symbol): void { - $map->setStyledLayerCell($this->getActiveCanvasLayer(), $x, $y, $symbol, $map->getLayerCellStyle($this->getActiveCanvasLayer(), $x, $y)); + $style = $map->getLayerCellStyle($this->getActiveCanvasLayer(), $x, $y); + $map->setLayerCell($this->getActiveCanvasLayer(), $x, $y, $symbol, $style['prefix'], $style['suffix']); } /** @@ -4854,21 +4781,18 @@ private function writeCanvasSymbol(ProjectMap $map, int $x, int $y, string $symb * PaintStrokeCommand, so each undoes in exactly one Ctrl+Z. * * @param ProjectMap $map The target map. - * @param array}}> $writes The cells to write. + * @param array $writes The cells to write. * @param string $label The undo/status label. - * @param PaintStrokeCommand|null $amend A recorded stroke of the same map and layer to extend instead of opening a new one. * @return int The number of cells that actually changed. */ - private function applyCanvasWrites(ProjectMap $map, array $writes, string $label, ?PaintStrokeCommand $amend = null): int + private function applyCanvasWrites(ProjectMap $map, array $writes, string $label): int { if ($writes === []) { return 0; } $this->finalizeActiveStroke(); - $amend = $amend?->targets($map) === true ? $amend : null; - $stroke = $amend ?? new PaintStrokeCommand($map, $this->getActiveCanvasLayer(), $label); - $recorded = $amend?->hasChanges() === true; + $stroke = new PaintStrokeCommand($map, $this->getActiveCanvasLayer(), $label); $changed = 0; foreach ($writes as $write) { @@ -4877,24 +4801,36 @@ private function applyCanvasWrites(ProjectMap $map, array $writes, string $label } $oldSymbol = $this->readCanvasSymbol($map, $write['x'], $write['y']); + $oldStyle = $map->getLayerCellStyle($this->getActiveCanvasLayer(), $write['x'], $write['y']); - $map->setStyledLayerCell($this->getActiveCanvasLayer(), $write['x'], $write['y'], $write['symbol'], - $write['style'] ?? $this->resolvePaintStyle($write['symbol'], $write['color'] ?? null, $oldStyle)); + [$newPrefix, $newSuffix] = isset($write['style']) + ? [$write['style']['prefix'], $write['style']['suffix']] + : $this->resolvePaintStyle( + $write['symbol'], + $write['color'] ?? null, + $oldStyle, + ); + $map->setLayerCell($this->getActiveCanvasLayer(), $write['x'], $write['y'], $write['symbol'], $newPrefix, $newSuffix); $newSymbol = $this->readCanvasSymbol($map, $write['x'], $write['y']); - $newStyle = $map->getLayerCellStyle($this->getActiveCanvasLayer(), $write['x'], $write['y']); - $stroke->appendCell($write['x'], $write['y'], $oldSymbol, $newSymbol, $oldStyle, $newStyle); + $stroke->appendCell( + $write['x'], + $write['y'], + $oldSymbol, + $newSymbol, + $oldStyle['prefix'], + $oldStyle['suffix'], + $newPrefix, + $newSuffix, + ); - if ($oldSymbol !== $newSymbol || $oldStyle !== $newStyle) { + if ($oldSymbol !== $newSymbol || $oldStyle['prefix'] !== $newPrefix || $oldStyle['suffix'] !== $newSuffix) { $changed++; } } - // An amended stroke is already in the history, and it now undoes - // to the state before its first keystroke. - if (! $recorded && $stroke->hasChanges()) { + if ($stroke->hasChanges()) { $this->recordCommand($stroke); } - $this->lastCanvasStroke = $stroke; return $changed; } @@ -4903,27 +4839,30 @@ private function applyCanvasWrites(ProjectMap $map, array $writes, string $label * Resolves the styling bytes a tile paint writes. * * The colour directive follows the brush contract: null keeps the - * cell's existing styling byte-for-byte, each character's included, an - * empty string paints without colour, and any other value becomes an - * `fg=` tag over the whole cell. A blank cell is always uncoloured, so - * erasing never leaves invisible styling behind. + * cell's existing styling byte-for-byte, an empty string paints without + * colour, and any other value becomes an `fg=` tag. A space is always + * uncoloured, so erasing never leaves invisible styling behind. * - * @param string $symbol The cell being painted. + * @param string $symbol The symbol being painted. * @param string|null $colorDirective The brush colour directive. - * @param array{prefix: string, suffix: string, styles?: list} $oldStyle The cell's current styling. - * @return array{prefix: string, suffix: string, styles?: list} The style to write. + * @param array{prefix: string, suffix: string} $oldStyle The cell's current styling. + * @return array{0: string, 1: string} The prefix and suffix to write. */ private function resolvePaintStyle(string $symbol, ?string $colorDirective, array $oldStyle): array { - if (MapCell::isBlank($symbol) || $colorDirective === '') { - return ['prefix' => '', 'suffix' => '']; + if ($symbol === ' ') { + return ['', '']; } if ($colorDirective === null) { - return $oldStyle; + return [$oldStyle['prefix'], $oldStyle['suffix']]; + } + + if ($colorDirective === '') { + return ['', '']; } - return ['prefix' => sprintf('', $colorDirective), 'suffix' => '']; + return [sprintf('', $colorDirective), '']; } /** @@ -4933,10 +4872,9 @@ private function resolvePaintStyle(string $symbol, ?string $colorDirective, arra * @param array $cells The cells to paint. * @param string $symbol The symbol to paint. * @param string $label The undo/status label. - * @param PaintStrokeCommand|null $amend The recorded stroke this paint joins, if any. * @return int The number of cells that actually changed. */ - private function paintCanvasCells(ProjectMap $map, array $cells, string $symbol, string $label, ?PaintStrokeCommand $amend = null): int + private function paintCanvasCells(ProjectMap $map, array $cells, string $symbol, string $label): int { // Glyph paints carry the brush colour; the space rule and the event // layer's lack of styling are resolved at the commit path. @@ -4949,7 +4887,6 @@ private function paintCanvasCells(ProjectMap $map, array $cells, string $symbol, $cells, ), $label, - $amend, ); } @@ -5089,7 +5026,7 @@ private function applyCanvasToolAtCursor(): void $changed = $this->paintCanvasCells( $selectedMap, $cells, - $this->getBrushCell(), + $this->selectedPaintSymbol, $this->canvasTool->commandLabel(), ); @@ -5098,7 +5035,7 @@ private function applyCanvasToolAtCursor(): void $this->canvasTool->label(), $changed, $changed === 1 ? '' : 's', - CellSymbol::describe($this->getBrushCell()), + $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, )); $this->renderCanvasArea(); } @@ -5167,7 +5104,7 @@ private function floodFillFromCursor(): void $target = $this->readCanvasSymbol($selectedMap, $this->cursorX, $this->cursorY); - if ($target === $this->getBrushCell()) { + if ($target === $this->selectedPaintSymbol) { $this->setStatus('Flood fill skipped: the region already holds that symbol.', StatusLevel::WARN); $this->renderFooter(); return; @@ -5180,12 +5117,12 @@ private function floodFillFromCursor(): void $this->cursorX, $this->cursorY, ); - $changed = $this->paintCanvasCells($selectedMap, $cells, $this->getBrushCell(), 'flood fill'); + $changed = $this->paintCanvasCells($selectedMap, $cells, $this->selectedPaintSymbol, 'flood fill'); $this->setStatus(sprintf( 'Flood filled %d cell%s with %s.', $changed, $changed === 1 ? '' : 's', - CellSymbol::describe($this->getBrushCell()), + $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, )); $this->renderCanvasArea(); } @@ -5216,7 +5153,7 @@ private function pickSymbolUnderCursor(): void $this->setStatus(sprintf( 'Picked up %s%s from (%d, %d).', - CellSymbol::describe($this->selectedPaintSymbol), + $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, $pickedColor === null ? '' : ' (' . $pickedColor . ')', $this->cursorX, $this->cursorY, @@ -5264,7 +5201,7 @@ private function cutCanvasSelection(): void $selection['x'] + $selection['width'] - 1, $selection['y'] + $selection['height'] - 1, ); - $changed = $this->paintCanvasCells($selectedMap, $cells, MapCell::BLANK, 'cut selection'); + $changed = $this->paintCanvasCells($selectedMap, $cells, ' ', 'cut selection'); $this->setStatus(sprintf( 'Cut %d x %d (%d cell%s cleared).', $this->clipboard->getWidth(), @@ -14140,7 +14077,7 @@ private function clampCanvasOffsets(): void } $bounds = $this->getCanvasPreviewBounds(); - $viewportWidth = $bounds['cells']; + $viewportWidth = $bounds['width']; $viewportHeight = $bounds['height']; $this->canvasOffsetX = max(0, min(max(0, $selectedMap->getWidth() - $viewportWidth), $this->canvasOffsetX)); $this->canvasOffsetY = max(0, min(max(0, $selectedMap->getHeight() - $viewportHeight), $this->canvasOffsetY)); @@ -14179,7 +14116,7 @@ private function clampCursor(): void private function syncViewportToCursor(): void { $bounds = $this->getCanvasPreviewBounds(); - $viewportWidth = $bounds['cells']; + $viewportWidth = $bounds['width']; $viewportHeight = $bounds['height']; if ($this->cursorX < $this->canvasOffsetX) { @@ -14327,19 +14264,19 @@ private function renderCanvasCursor(array $layout): void } $previewRow = $this->cursorY - $this->canvasOffsetY; - $previewCell = $this->cursorX - $this->canvasOffsetX; + $previewColumn = $this->cursorX - $this->canvasOffsetX; $bounds = $this->getCanvasPreviewBounds($layout); $previewHeight = $bounds['height']; + $previewWidth = $bounds['width']; - if ($previewRow < 0 || $previewRow >= $previewHeight || $previewCell < 0 || $previewCell >= $bounds['cells']) { + if ($previewRow < 0 || $previewRow >= $previewHeight || $previewColumn < 0 || $previewColumn >= $previewWidth) { Console::cursor()->hide(); return; } - // The cursor rests on the first of the cell's two columns. Console::cursor()->show(); Console::cursor()->moveTo( - $bounds['left'] + $previewCell * MapCell::COLUMNS, + $bounds['left'] + $previewColumn, $bounds['top'] + $previewRow ); } @@ -14353,16 +14290,16 @@ private function renderCanvasCursor(array $layout): void private function renderCharacterMapOverlay(array $layout): void { $palette = $this->getCharacterPalette(); - $rows = [sprintf('Type a cell: %s', $this->characterMapCell === '' ? '(one key fills it, two make a pair)' : $this->describeCharacterMapCell($this->characterMapCell)), '']; + $rows = []; foreach (array_chunk($palette, self::CHARACTER_MAP_COLUMNS) as $rowIndex => $rowSymbols) { $cells = []; foreach ($rowSymbols as $columnIndex => $symbol) { $index = ($rowIndex * self::CHARACTER_MAP_COLUMNS) + $columnIndex; - $label = $index === $this->characterPaletteIndex && $this->characterMapCell === '' - ? sprintf('[%s]', $this->describeCharacterMapCell($symbol)) - : sprintf(' %s ', $this->describeCharacterMapCell($symbol)); + $label = $index === $this->characterPaletteIndex + ? sprintf('[%s]', $symbol === ' ' ? '␠' : $symbol) + : sprintf(' %s ', $symbol === ' ' ? '␠' : $symbol); $cells[] = $label; } @@ -14376,7 +14313,7 @@ private function renderCharacterMapOverlay(array $layout): void $window = new EditorWindow( title: 'Character Map', - help: 'Enter:Insert Type:Cell Esc:Close', + help: 'Enter:Insert Esc:Close', position: ['x' => $left, 'y' => $top], width: $overlayWidth, height: $overlayHeight, @@ -14387,17 +14324,6 @@ private function renderCharacterMapOverlay(array $layout): void $window->render(); } - /** - * Shows a cell in the character map, spaces made visible. - * - * @param string $cell The cell. - * @return string - */ - private function describeCharacterMapCell(string $cell): string - { - return str_replace(' ', '␠', $cell); - } - /** * Renders the brush colour picker overlay. * @@ -17508,12 +17434,13 @@ private function getCharacterPalette(): array $palette[$symbol] = $symbol; } - // Collision keys and reserved glyphs are single characters; the - // palette offers the whole cell each one paints. $layerName = array_values(array_filter($this->getTerminalCanvasLayers(), fn(array $layer): bool => $layer['id'] === $this->getActiveCanvasLayer()))[0]['name'] ?? null; - foreach ([...$this->workspace->getCollisionGlyphs($layerName), '?', '%', '^', '@'] as $symbol) { - $cell = CellSymbol::normalize($symbol); - $palette[$cell] = $cell; + foreach ($this->workspace->getCollisionGlyphs($layerName) as $symbol) { + $palette[$symbol] = $symbol; + } + + foreach (['?', '%', '^', '@'] as $symbol) { + $palette[$symbol] = $symbol; } return array_values($palette); @@ -18255,7 +18182,6 @@ private function createCanvasWindow(): EditorWindow $bounds = $this->getCanvasPreviewBounds($layout); $contentWidth = $bounds['width']; $contentHeight = $bounds['height'] + ProjectWorkspace::CANVAS_HEADER_ROWS; - $contentCells = $bounds['cells']; return new EditorWindow( title: ($this->focusedPane === self::FOCUS_CANVAS ? 'Canvas [Focus]' : 'Canvas') @@ -18294,7 +18220,7 @@ private function createCanvasWindow(): EditorWindow content: $this->fitLines( $this->workspace?->getCanvasLines( $this->selectedAssetIndex, - $contentCells, + $contentWidth, $contentHeight, $this->canvasOffsetX, $this->canvasOffsetY, diff --git a/src/History/PaintStrokeCommand.php b/src/History/PaintStrokeCommand.php index c8316dc..e58dcd8 100644 --- a/src/History/PaintStrokeCommand.php +++ b/src/History/PaintStrokeCommand.php @@ -20,7 +20,7 @@ final class PaintStrokeCommand implements Command public const string LAYER_EVENT = 'event'; /** - * @var array + * @var array */ private array $cells = []; @@ -37,20 +37,21 @@ public function __construct( } /** - * Records one painted cell. The old cell survives repeated appends so + * Records one painted cell. The old symbol survives repeated appends so * undo restores the pre-stroke state. * - * A cell also carries its raw styling bytes, as ProjectMap reads them, - * so undo restores authored formatter tags byte-for-byte, a separate - * style per character included. The event layer has no styling; its - * styles stay empty. + * On the tile layer a cell also carries its raw styling bytes, so undo + * restores authored formatter tags byte-for-byte. The event layer has no + * styling; its style arguments stay empty. * * @param int $x The cell x coordinate. * @param int $y The cell y coordinate. - * @param string $oldSymbol The cell before the stroke touched it. - * @param string $newSymbol The cell painted. - * @param array{prefix?: string, suffix?: string, styles?: list} $oldStyle The styling before the stroke. - * @param array{prefix?: string, suffix?: string, styles?: list} $newStyle The styling painted. + * @param string $oldSymbol The symbol before the stroke touched the cell. + * @param string $newSymbol The symbol painted onto the cell. + * @param string $oldPrefix The styling prefix before the stroke. + * @param string $oldSuffix The styling suffix before the stroke. + * @param string $newPrefix The styling prefix painted onto the cell. + * @param string $newSuffix The styling suffix painted onto the cell. * @return void */ public function appendCell( @@ -58,15 +59,17 @@ public function appendCell( int $y, string $oldSymbol, string $newSymbol, - array $oldStyle = [], - array $newStyle = [], + string $oldPrefix = '', + string $oldSuffix = '', + string $newPrefix = '', + string $newSuffix = '', ): void { $key = $x . ':' . $y; - $newStyle = self::normalizeStyle($newStyle); if (isset($this->cells[$key])) { $this->cells[$key]['new'] = $newSymbol; - $this->cells[$key]['newStyle'] = $newStyle; + $this->cells[$key]['newPrefix'] = $newPrefix; + $this->cells[$key]['newSuffix'] = $newSuffix; return; } @@ -75,8 +78,10 @@ public function appendCell( 'y' => $y, 'old' => $oldSymbol, 'new' => $newSymbol, - 'oldStyle' => self::normalizeStyle($oldStyle), - 'newStyle' => $newStyle, + 'oldPrefix' => $oldPrefix, + 'oldSuffix' => $oldSuffix, + 'newPrefix' => $newPrefix, + 'newSuffix' => $newSuffix, ]; } @@ -88,7 +93,11 @@ public function appendCell( public function hasChanges(): bool { foreach ($this->cells as $cell) { - if ($cell['old'] !== $cell['new'] || $cell['oldStyle'] !== $cell['newStyle']) { + if ( + $cell['old'] !== $cell['new'] + || $cell['oldPrefix'] !== $cell['newPrefix'] + || $cell['oldSuffix'] !== $cell['newSuffix'] + ) { return true; } } @@ -123,7 +132,7 @@ public function targets(ProjectMap $map): bool public function execute(): void { foreach ($this->cells as $cell) { - $this->applyCell($cell['x'], $cell['y'], $cell['new'], $cell['newStyle']); + $this->applyCell($cell['x'], $cell['y'], $cell['new'], $cell['newPrefix'], $cell['newSuffix']); } } @@ -133,7 +142,7 @@ public function execute(): void public function undo(): void { foreach ($this->cells as $cell) { - $this->applyCell($cell['x'], $cell['y'], $cell['old'], $cell['oldStyle']); + $this->applyCell($cell['x'], $cell['y'], $cell['old'], $cell['oldPrefix'], $cell['oldSuffix']); } } @@ -142,21 +151,13 @@ public function undo(): void * * @param int $x The cell x coordinate. * @param int $y The cell y coordinate. - * @param string $symbol The cell to apply. - * @param array{prefix: string, suffix: string, styles?: list} $style The styling to apply. + * @param string $symbol The symbol to apply. + * @param string $prefix The styling prefix bytes (tile layer only). + * @param string $suffix The styling suffix bytes (tile layer only). * @return void */ - private function applyCell(int $x, int $y, string $symbol, array $style): void + private function applyCell(int $x, int $y, string $symbol, string $prefix, string $suffix): void { - $this->map->setStyledLayerCell($this->layer, $x, $y, $symbol, $style); - } - - /** - * @param array{prefix?: string, suffix?: string, styles?: list} $style - * @return array{prefix: string, suffix: string, styles?: list} - */ - private static function normalizeStyle(array $style): array - { - return ['prefix' => $style['prefix'] ?? '', 'suffix' => $style['suffix'] ?? ''] + $style; + $this->map->setLayerCell($this->layer, $x, $y, $symbol, $prefix, $suffix); } } diff --git a/src/Maps/CellSymbol.php b/src/Maps/CellSymbol.php deleted file mode 100644 index ad3e78f..0000000 --- a/src/Maps/CellSymbol.php +++ /dev/null @@ -1,69 +0,0 @@ - Entered characters, without control characters. */ - private static function getSymbols(string $input): array - { - if ($input === '' || preg_match_all('/\X/u', $input, $matches) === false) { - return []; - } - return array_values(array_filter($matches[0], static fn(string $symbol): bool => preg_match('/\A[\x00-\x1F\x7F]+\z/', $symbol) !== 1)); - } -} diff --git a/src/Maps/EditableGrid.php b/src/Maps/EditableGrid.php index 5961b1b..b72a359 100644 --- a/src/Maps/EditableGrid.php +++ b/src/Maps/EditableGrid.php @@ -5,40 +5,20 @@ namespace Ichiloto\Editor\Maps; use Ichiloto\Editor\MapSourceRefusal; -use Ichiloto\Engine\Field\MapCell; use Ichiloto\Engine\Field\MapGridSource; use Ichiloto\Engine\IO\Console\TerminalText; -use InvalidArgumentException; -/** - * Editable rows of two-column map cells, with the authored source and - * unchanged rows retained. - * - * A cell's symbol is its unstyled text: one two-column glyph or two - * one-column characters. A cell whose characters share one style keeps it - * as its prefix and suffix. A cell whose characters are styled differently - * also lists one style per character, so an edited row writes every - * character back with its own style; its prefix and suffix are then those - * of its first visible character, which is the cell's colour. - */ +/** Editable colour runs, with the authored source and unchanged rows retained. */ final class EditableGrid { - /** @var array}>> */ + /** @var array> */ public array $cells; private array $originalCells; private array $lines; private array $separators; - /** - * @param string $context Names the grid in refusals, for example its file. - * @throws InvalidArgumentException When a row is not whole two-column cells. - */ - public function __construct( - private string $text, - private ?string $source = null, - private bool $legacyTags = false, - private string $context = 'Map', - ) { + public function __construct(private string $text, private ?string $source = null, private bool $legacyTags = false) + { $parts = preg_split('/(\r\n|\n|\r)/', rtrim($text, "\r\n"), -1, PREG_SPLIT_DELIM_CAPTURE) ?: ['']; $this->lines = $this->separators = []; foreach ($parts as $index => $part) { @@ -48,129 +28,38 @@ public function __construct( $this->separators[] = $part; } } - $this->cells = self::parseLines($this->lines, $legacyTags, $context); + $this->cells = self::parseLines($this->lines, $legacyTags); $this->originalCells = $this->cells; } - /** - * Parses authored rows into cells, keeping each character's style. - * - * @param string[] $lines - * @throws InvalidArgumentException When a row is not whole two-column cells. - */ - public static function parseLines(array $lines, bool $legacyTags = false, string $context = 'Map'): array + public static function parseLines(array $lines, bool $legacyTags = false): array { // Equal cells share PHP copy-on-write values, not mutable references. // Keep the pool local to this parse so closed maps retain no cache. $cellValues = []; - $rows = []; - foreach ($lines as $y => $line) { - $characters = self::parseCharacters($line, $legacyTags); - // The Engine owns how characters group into cells and how a - // misaligned row is refused; the editor only keeps the styles. - $texts = MapCell::parseRow(implode('', array_column($characters, 'symbol')), "{$context} row {$y}"); - $row = []; - $offset = 0; - foreach ($texts as $text) { - $count = count(MapCell::getCharacters($text)); - $members = array_slice($characters, $offset, $count); - $offset += $count; - $symbol = implode('', array_column($members, 'symbol')); - if ($symbol !== TerminalText::stripAnsi($text)) { - throw new InvalidArgumentException("{$context} row {$y} does not split into the characters the Engine reads."); - } - $key = serialize($members); - $row[] = $cellValues[$key] ??= self::createCellFromCharacters($symbol, $members); - } - $rows[] = $row; - } - return $rows; - } - - /** - * Splits one authored row into characters with their own style runs. - * - * @return list - */ - private static function parseCharacters(string $line, bool $legacyTags): array - { - preg_match_all('/<[^>]+>|[^<]+|]*>$/u', $segment) === 1 - && ($legacyTags || TerminalText::stripAnsi($segment . 'x') === 'x')) { - $tags[] = $segment; - } elseif (preg_match('/^<\/[^>]*>$/u', $segment) === 1 && ($legacyTags || $tags !== [])) { - array_pop($tags); - } else { - foreach (TerminalText::visibleSymbols($segment) as $symbol) { - preg_match('/\A((?:\x1b\[[0-9;]*m)*)(.*?)((?:\x1b\[[0-9;]*m)*)\z/us', $symbol, $styled); - $characters[] = [ - 'symbol' => $styled[2] ?? $symbol, - 'prefix' => implode('', $tags) . ($styled[1] ?? ''), - 'suffix' => ($styled[3] ?? '') . str_repeat('', count($tags)), - ]; + return array_map(static function (string $line) use ($legacyTags, &$cellValues): array { + preg_match_all('/<[^>]+>|[^<]+|]*>$/u', $segment) === 1 + && ($legacyTags || TerminalText::stripAnsi($segment . 'x') === 'x')) { + $tags[] = $segment; + } elseif (preg_match('/^<\/[^>]*>$/u', $segment) === 1 && ($legacyTags || $tags !== [])) { + array_pop($tags); + } else { + foreach (TerminalText::visibleSymbols($segment) as $symbol) { + preg_match('/\A((?:\x1b\[[0-9;]*m)*)(.*?)((?:\x1b\[[0-9;]*m)*)\z/us', $symbol, $styled); + $glyph = $styled[2] ?? $symbol; + $prefix = implode('', $tags) . ($styled[1] ?? ''); + $suffix = ($styled[3] ?? '') . str_repeat('', count($tags)); + $cells[] = $cellValues[$prefix][$suffix][$glyph] ??= [ + 'symbol' => $glyph, 'prefix' => $prefix, 'suffix' => $suffix, + ]; + } } } - } - return $characters; - } - - /** @param list $members */ - private static function createCellFromCharacters(string $symbol, array $members): array - { - $styles = array_map(static fn(array $member): array => ['prefix' => $member['prefix'], 'suffix' => $member['suffix']], $members); - return self::createCell($symbol, [...($styles[0] ?? ['prefix' => '', 'suffix' => '']), 'styles' => $styles]); - } - - /** - * Builds one cell from its unstyled text and a style. Per-character - * styles apply only when they match the cell's characters and differ; - * otherwise the whole cell takes the style's prefix and suffix. - * - * @param array{prefix: string, suffix: string, styles?: list} $style - * @return array{symbol: string, prefix: string, suffix: string, styles?: list} - */ - public static function createCell(string $symbol, array $style): array - { - $characters = MapCell::getCharacters($symbol); - $styles = array_values($style['styles'] ?? []); - if (count($styles) !== count($characters) || count(array_unique(array_map(serialize(...), $styles))) < 2) { - return ['symbol' => $symbol, 'prefix' => $style['prefix'], 'suffix' => $style['suffix']]; - } - // The cell's colour is its first visible character's. - $shown = array_key_first(array_filter($characters, static fn(string $character): bool => trim($character) !== '')) ?? 0; - return ['symbol' => $symbol, 'prefix' => $styles[$shown]['prefix'], 'suffix' => $styles[$shown]['suffix'], 'styles' => $styles]; - } - - /** - * A cell's style: its prefix and suffix, and one style per character - * when its characters are styled differently. - * - * @return array{prefix: string, suffix: string, styles?: list} - */ - public static function getCellStyle(array $cell): array - { - $style = ['prefix' => (string) ($cell['prefix'] ?? ''), 'suffix' => (string) ($cell['suffix'] ?? '')]; - return isset($cell['styles']) ? [...$style, 'styles' => $cell['styles']] : $style; - } - - /** - * A cell as styled runs of text: the whole cell when its characters share - * one style, one run per character otherwise. - * - * @return list - */ - public static function getCellRuns(array $cell): array - { - if (! isset($cell['styles'])) { - return [['symbol' => $cell['symbol'], 'prefix' => $cell['prefix'], 'suffix' => $cell['suffix']]]; - } - $runs = []; - foreach (MapCell::getCharacters($cell['symbol']) as $index => $character) { - $runs[] = ['symbol' => $character] + $cell['styles'][$index]; - } - return $runs; + return $cells; + }, $lines); } public function getSymbols(): array @@ -180,13 +69,12 @@ public function getSymbols(): array public function captureSnapshot(): array { - return ['text' => $this->text, 'source' => $this->source, 'cells' => $this->cells, - 'legacyTags' => $this->legacyTags, 'context' => $this->context]; + return ['text' => $this->text, 'source' => $this->source, 'cells' => $this->cells, 'legacyTags' => $this->legacyTags]; } public static function createFromSnapshot(array $snapshot): self { - $grid = new self($snapshot['text'], $snapshot['source'], $snapshot['legacyTags'], $snapshot['context'] ?? 'Map'); + $grid = new self($snapshot['text'], $snapshot['source'], $snapshot['legacyTags']); $grid->cells = $snapshot['cells']; return $grid; } @@ -202,14 +90,12 @@ public function getLines(): array $line = ''; $open = null; foreach ($cells as $cell) { - foreach (self::getCellRuns($cell) as $run) { - $style = [$run['prefix'], $run['suffix']]; - if ($style !== $open) { - $line .= ($open[1] ?? '') . $style[0]; - $open = $style; - } - $line .= $run['symbol']; + $style = [$cell['prefix'], $cell['suffix']]; + if ($style !== $open) { + $line .= ($open[1] ?? '') . $style[0]; + $open = $style; } + $line .= $cell['symbol']; } $lines[] = $line . ($open[1] ?? ''); } @@ -259,10 +145,9 @@ public function getSource(): string return $source; } - /** Resizes to whole cells; new cells are blank. */ public function resize(int $width, int $height): void { - $blank = ['symbol' => MapCell::BLANK, 'prefix' => '', 'suffix' => '']; + $blank = ['symbol' => ' ', 'prefix' => '', 'suffix' => '']; $this->cells = array_slice($this->cells, 0, $height); foreach ($this->cells as &$row) { $row = array_pad(array_slice($row, 0, $width), $width, $blank); diff --git a/src/Maps/MapLayers.php b/src/Maps/MapLayers.php index 7c3b0bd..deb65f0 100644 --- a/src/Maps/MapLayers.php +++ b/src/Maps/MapLayers.php @@ -6,7 +6,6 @@ use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Storage\FileSetTransaction; -use Ichiloto\Engine\Field\MapCell; use Ichiloto\Engine\Field\MapGraphics; use Ichiloto\Engine\Field\MapGridSource; use Ichiloto\Engine\Field\MapLayerSource; @@ -47,10 +46,8 @@ public function __construct( $this->captureBaseline(); } - /** @throws \InvalidArgumentException When a grid is not whole two-column cells. */ - public static function createFromSource(string $directory, MapLayerSet $set, string $eventPath, string $eventText, string $mapId = ''): self + public static function createFromSource(string $directory, MapLayerSet $set, string $eventPath, string $eventText): self { - $prefix = $mapId === '' ? '' : $mapId . '/'; $layers = []; foreach ($set->layers as $layer) { $path = $directory . ($set->legacy ? '/' : '/layers/') . basename($layer->path); @@ -58,10 +55,10 @@ public static function createFromSource(string $directory, MapLayerSet $set, str 'id' => $set->legacy ? self::BASE : 'map:' . $layer->order, 'name' => $layer->name, 'order' => $layer->order, 'decoration' => $layer->decoration, 'path' => $path, - 'grid' => new EditableGrid($layer->text, (string) file_get_contents($path), $set->legacy, $prefix . basename($path)), + 'grid' => new EditableGrid($layer->text, (string) file_get_contents($path), $set->legacy), ]; } - $events = new EditableGrid($eventText, (string) file_get_contents($eventPath), context: $prefix . basename($eventPath)); + $events = new EditableGrid($eventText, (string) file_get_contents($eventPath)); $tileSources = []; foreach (TileLayerSource::findPaths($directory) as $path) { $tileSources[$path] = (string) file_get_contents($path); @@ -133,7 +130,7 @@ public function createLayer(string $name, bool $decoration, ?int $order = null): } $id = 'map:' . $order; $grid = new EditableGrid(implode("\n", array_map( - static fn(array $row): string => str_repeat(MapCell::BLANK, count($row)), $this->getBaseGrid()->cells, + static fn(array $row): string => str_repeat(' ', count($row)), $this->getBaseGrid()->cells, ))); $this->layers[$id] = ['id' => $id, 'name' => $name, 'order' => $order, 'decoration' => $decoration, 'path' => $this->buildPath($order, $name, $decoration), 'grid' => $grid]; @@ -203,9 +200,10 @@ public function restoreSnapshot(array $snapshot): void } /** - * Resizes every layer to whole cells, tile layers included. Every tile - * layer is resized before anything changes, so one the Engine cannot - * read refuses the whole resize. + * Resizes every layer, tile layers included: a tile layer holds one tile + * per map cell, so it is cropped or padded like the terminal layers. + * Every tile layer is resized before anything changes, so one the Engine + * cannot read refuses the whole resize. * * @throws MapSourceRefusal When a tile layer cannot be read or does not match the map. */ diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 616a083..09828a7 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -21,11 +21,9 @@ use Ichiloto\Engine\Field\MapLayer; use Ichiloto\Engine\Field\MapLayerSource; use Ichiloto\Engine\Field\MapLayerSet; -use Ichiloto\Engine\Field\MapCell; use Ichiloto\Engine\Field\MapCollisionResolver; use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\IO\Console\SgrStyleState; -use Ichiloto\Editor\Maps\CellSymbol; use Ichiloto\Editor\Maps\EditableGrid; use Ichiloto\Editor\Maps\MapLayers; use InvalidArgumentException; @@ -110,8 +108,8 @@ public function __construct( $this->editableData = $data; $this->layers = $layers ?? new MapLayers($directory, true, [[ 'id' => MapLayers::BASE, 'name' => 'terrain', 'order' => 0, 'decoration' => false, - 'path' => $mapPath, 'grid' => new EditableGrid(implode("\n", $tileLines), legacyTags: true, context: $mapPath), - ]], new EditableGrid(implode("\n", $eventLines), context: $eventPath), $eventPath); + 'path' => $mapPath, 'grid' => new EditableGrid(implode("\n", $tileLines), legacyTags: true), + ]], new EditableGrid(implode("\n", $eventLines)), $eventPath); $this->loadedData = $data; $this->adoptDataSource($dataSource ?? "baselineDataSource = $this->dataDocument?->source ?? (string) $this->unparsedDataSource; @@ -165,11 +163,9 @@ public static function fromDirectory(string $mapsRoot, string $directory): self try { $set = MapLayerSource::loadFromDirectory($directory, $mapId); $eventText = self::readGridSource($eventPath, $mapId); - $eventGrid = MapLayer::parseGrid($eventText, $mapId . '/' . basename($eventPath)); if (! $set->legacy) { - $set->assertMatchingGrid($eventGrid, $mapId . '/' . basename($eventPath)); + $set->assertMatchingGrid(MapLayer::parseGrid($eventText), $mapId . '/' . basename($eventPath)); } - $layers = MapLayers::createFromSource($directory, $set, $eventPath, $eventText, $mapId); } catch (InvalidArgumentException $error) { throw new MapSourceRefusal($error->getMessage(), previous: $error); } @@ -190,7 +186,7 @@ public static function fromDirectory(string $mapsRoot, string $directory): self tileLines: self::splitMapText($mapText), eventLines: self::splitMapText($eventText), dataSource: (string) file_get_contents($dataPath), - layers: $layers, + layers: MapLayers::createFromSource($directory, $set, $eventPath, $eventText), )->withLoadedBaseline(); } @@ -301,7 +297,7 @@ public function getHeight(): int } /** - * Returns the widest tile row, in two-column cells. + * Returns the widest tile row after formatting tags are stripped. * * @return int */ @@ -379,10 +375,9 @@ public function isLegacyMap(): bool return $this->layers->legacy; } - /** The unstyled text of one cell: a two-column glyph or two characters. */ public function getLayerSymbol(string $layer, int $x, int $y): string { - return $this->layers->getGrid($layer)->cells[$y][$x]['symbol'] ?? MapCell::BLANK; + return $this->layers->getGrid($layer)->cells[$y][$x]['symbol'] ?? ' '; } public function hasLayerCell(string $layer, int $x, int $y): bool @@ -390,15 +385,10 @@ public function hasLayerCell(string $layer, int $x, int $y): bool return isset($this->layers->getGrid($layer)->cells[$y][$x]); } - /** - * A cell's raw styling bytes, with one style per character when its - * characters are styled differently. - * - * @return array{prefix: string, suffix: string, styles?: list} - */ public function getLayerCellStyle(string $layer, int $x, int $y): array { - return EditableGrid::getCellStyle($this->layers->getGrid($layer)->cells[$y][$x] ?? []); + $cell = $this->layers->getGrid($layer)->cells[$y][$x] ?? []; + return ['prefix' => $cell['prefix'] ?? '', 'suffix' => $cell['suffix'] ?? '']; } public function getLayerColor(string $layer, int $x, int $y): ?string @@ -406,24 +396,12 @@ public function getLayerColor(string $layer, int $x, int $y): ?string return self::getInnermostForeground($this->getLayerCellStyle($layer, $x, $y)['prefix']); } - /** Replaces one whole cell, styled uniformly; `#` fills the cell as `##`. */ public function setLayerCell(string $layer, int $x, int $y, string $symbol, string $prefix = '', string $suffix = ''): void - { - $this->setStyledLayerCell($layer, $x, $y, $symbol, ['prefix' => $prefix, 'suffix' => $suffix]); - } - - /** - * Replaces one whole cell with a style as getLayerCellStyle() reads it, - * so a cell whose characters are styled differently restores exactly. - * - * @param array{prefix: string, suffix: string, styles?: list} $style - */ - public function setStyledLayerCell(string $layer, int $x, int $y, string $symbol, array $style): void { $this->assertEditable(); $grid = $this->layers->getGrid($layer); if (isset($grid->cells[$y][$x])) { - $grid->cells[$y][$x] = EditableGrid::createCell(CellSymbol::normalize($symbol), $style); + $grid->cells[$y][$x] = ['symbol' => self::normalizeSymbol($symbol), 'prefix' => $prefix, 'suffix' => $suffix]; $this->touchState(); } } @@ -537,10 +515,9 @@ public function restoreLayerSnapshot(array $snapshot): void } /** - * Builds a merged preview where event markers override map tiles. Each - * cell is two terminal columns of the returned lines. + * Builds a merged preview where event markers override map tiles. * - * @param int $width The preview width, in cells. + * @param int $width The preview width. * @param int $height The preview height. * @param int $offsetX The horizontal preview offset. * @param int $offsetY The vertical preview offset. @@ -587,32 +564,37 @@ public function renderPreview( || ($layer['id'] === MapLayers::EVENT && ! $showEventOverlay)) { continue; } - $dim = $dimInactive && $activeLayer !== $layer['id']; foreach ($layer['grid']->cells[$row] ?? [] as $x => $cell) { - if ($layer['id'] === $baseLayerId) { - $tileRow[$x] = self::getPreviewColumns($cell, $dim); - } elseif (! MapCell::isBlank($cell['symbol'])) { - $upper = self::getPreviewColumns($cell, $dim); - $tileRow[$x] = isset($tileRow[$x]) ? self::overlayPreviewColumns($tileRow[$x], $upper) : $upper; + if ($cell['symbol'] !== ' ' || $layer['id'] === $baseLayerId) { + $tileRow[$x] = [...$cell, 'dim' => $dimInactive && $activeLayer !== $layer['id']]; } } } $mergedSymbols = []; for ($column = $offsetX; $column < $offsetX + $width; $column++) { - // An overhang whose sprite starts left of the view leaves - // its cells to the map, so the row keeps its columns. - if (isset($npcCells[$row][$column]) && ($npcCells[$row][$column] !== '' || $column > $offsetX)) { + if (isset($npcCells[$row][$column])) { // NPCs draw over everything, as they do in the game; the // overlay is derived from map data and never painted. $mergedSymbols[] = $npcCells[$row][$column]; continue; } - $tileColumns = $tileRow[$column] ?? self::getPreviewColumns(['symbol' => MapCell::BLANK, 'prefix' => '', 'suffix' => ''], false); + $tileCell = $tileRow[$column] ?? null; + $tileSymbol = is_array($tileCell) ? $tileCell['symbol'] : ' '; // A selected map-owned NPC marks the existing cell, never a replacement sprite. - $highlight = array_key_exists($column, $npcCells[$row] ?? []) ? "\033[7m" : ''; - $mergedSymbols[] = $this->renderPreviewColumns($tileColumns, $highlight); + $anchorHighlight = array_key_exists($column, $npcCells[$row] ?? []) ? "\033[7m" : ''; + if (! $this->layers->legacy && is_array($tileCell)) { + $styled = TerminalText::formatStyles($tileCell['prefix'] . $anchorHighlight . $tileSymbol . $tileCell['suffix']); + $styled = ($tileCell['dim'] ?? false) ? "\033[2m" . $styled . "\033[0m" : $styled; + $mergedSymbols[] = $anchorHighlight === '' ? $styled : $styled . "\033[0m"; + continue; + } + $ansiOpen = is_array($tileCell) + ? self::ansiOpenForPrefix((string) ($tileCell['prefix'] ?? '')) + : null; + $dim = (($tileCell['dim'] ?? false) ? "\033[2m" : '') . $anchorHighlight; + $mergedSymbols[] = $ansiOpen === null && $dim === '' ? $tileSymbol : $dim . $ansiOpen . $tileSymbol . "\033[0m"; } $lines[] = rtrim(implode('', $mergedSymbols)); @@ -621,76 +603,6 @@ public function renderPreview( return array_pad($lines, $height, ''); } - /** - * A cell's characters by column, each with its own style and dimming: two - * for a pair, one spanning both columns for a two-column glyph. - * - * @param array{symbol: string, prefix: string, suffix: string, styles?: list} $cell - * @return list - */ - private static function getPreviewColumns(array $cell, bool $dim): array - { - $columns = []; - foreach (EditableGrid::getCellRuns($cell) as $run) { - preg_match_all('/\X/u', $run['symbol'], $characters); - foreach ($characters[0] as $character) { - $columns[] = ['symbol' => $character, 'prefix' => $run['prefix'], 'suffix' => $run['suffix'], 'dim' => $dim]; - } - } - return $columns; - } - - /** - * Lays an upper layer's cell over the cell below column by column, as the - * Engine composes layers: a space in an upper pair shows the column - * beneath it; a two-column glyph on either side replaces the whole cell. - * - * @param list $lower - * @param list $upper - * @return list - */ - private static function overlayPreviewColumns(array $lower, array $upper): array - { - if (count($lower) !== MapCell::COLUMNS || count($upper) !== MapCell::COLUMNS) { - return $upper; - } - return array_map(static fn(array $top, array $bottom): array => trim($top['symbol']) === '' ? $bottom : $top, $upper, $lower); - } - - /** - * Renders one cell's columns for the canvas, each dimmed as its own layer - * is, and highlighted together. - * - * @param list $columns - */ - private function renderPreviewColumns(array $columns, string $highlight): string - { - // Adjacent columns sharing a style and dimming render as one run. - $runs = []; - foreach ($columns as $column) { - $last = array_key_last($runs); - if ($last !== null && [$runs[$last]['prefix'], $runs[$last]['suffix'], $runs[$last]['dim']] - === [$column['prefix'], $column['suffix'], $column['dim']]) { - $runs[$last]['symbol'] .= $column['symbol']; - continue; - } - $runs[] = $column; - } - $text = ''; - foreach ($runs as $column) { - $dim = $column['dim'] ? "\033[2m" : ''; - if (! $this->layers->legacy) { - $styled = TerminalText::formatStyles($column['prefix'] . $highlight . $column['symbol'] . $column['suffix']); - $text .= $dim === '' && $highlight === '' ? $styled : $dim . $styled . "\033[0m"; - continue; - } - // Legacy tags are not formatter styles: only their colour is shown. - $open = self::ansiOpenForPrefix($column['prefix']); - $text .= $open === null && $highlight === '' && $dim === '' - ? $column['symbol'] : $dim . $highlight . $open . $column['symbol'] . "\033[0m"; - } - return $text; - } /** * The 4-bit ANSI foreground codes for the formatter's colour names. * `gray` is the formatter's name for bright black. @@ -740,16 +652,17 @@ private static function ansiOpenForPrefix(string $prefix): ?string } /** - * Returns the cells the NPC overlay occupies, row => column => text. + * Returns the cells the NPC overlay occupies, row => column => symbol. * - * A character occupies one cell, anchored at its first column as the - * engine draws it: a sprite of up to two columns sits in its cell, and a - * wider one overhangs to the right. Its anchor cell holds the sprite - * padded to whole cells, and every cell it overhangs holds an empty - * string, so the terminal draws the glyph in the space it needs. The - * selected NPC is shown in reverse video within those cells. An - * explicitly empty sprite contributes only a selected anchor (null), - * highlighting the underlying map cell without replacing its glyph. + * The engine anchors a sprite at its tile and lets it overhang to the + * right (NpcManager::eraseNpc clears displayWidth cells), so a + * two-column emoji owns its anchor and the cell after it. The overhang + * cell holds an empty string, so the terminal draws the wide glyph in + * the space it needs rather than a symbol shoved half under it. The + * selected NPC is drawn with brackets around a one-column sprite, or as + * itself when wide, since brackets would misalign the row. An explicitly + * empty sprite contributes only a selected anchor (null), highlighting + * the underlying map cell without replacing its glyph or neighbours. * * @param int|null $selectedNpcIndex The NPC to mark selected. * @param string|null $selectedNpcSprite A glyph to draw for the selected @@ -769,7 +682,7 @@ private function npcOverlayCells(?int $selectedNpcIndex, ?string $selectedNpcSpr if ($index === $selectedNpcIndex && $selectedNpcSprite !== null && trim($selectedNpcSprite) !== '') { $sprite = ProjectNpc::visibleGlyph($selectedNpcSprite); - $columns = ProjectNpc::glyphWidth($selectedNpcSprite); + $columns = max(1, mb_strwidth($sprite)); } if ($sprite === '') { @@ -779,12 +692,16 @@ private function npcOverlayCells(?int $selectedNpcIndex, ?string $selectedNpcSpr continue; } - $span = MapCell::getSpanCells($columns); - $text = $sprite . str_repeat(' ', max(0, $span * MapCell::COLUMNS - $columns)); - $cells[$y][$x] = $index === $selectedNpcIndex ? "\033[7m" . $text . "\033[0m" : $text; + if ($index === $selectedNpcIndex && $columns === 1) { + $sprite = '[' . $sprite . ']'; + $x = max(0, $x - 1); + $columns = 3; + } + + $cells[$y][$x] = $sprite; - for ($cell = 1; $cell < $span; $cell++) { - $cells[$y][$x + $cell] = ''; + for ($column = 1; $column < $columns; $column++) { + $cells[$y][$x + $column] = ''; } } @@ -800,7 +717,7 @@ private function npcOverlayCells(?int $selectedNpcIndex, ?string $selectedNpcSpr */ public function getTileSymbol(int $x, int $y): string { - return $this->getLayerSymbol(MapLayers::BASE, $x, $y); + return $this->layers->getBaseGrid()->cells[$y][$x]['symbol'] ?? ' '; } /** @@ -825,7 +742,13 @@ public function getEventSymbol(int $x, int $y): string */ public function setTileSymbol(int $x, int $y, string $symbol): void { - $this->setStyledLayerCell(MapLayers::BASE, $x, $y, $symbol, $this->getTileCellStyle($x, $y)); + $this->assertEditable(); + if (! isset($this->layers->getBaseGrid()->cells[$y][$x])) { + return; + } + + $this->layers->getBaseGrid()->cells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); + $this->touchState(); } /** @@ -836,11 +759,16 @@ public function setTileSymbol(int $x, int $y, string $symbol): void * * @param int $x The cell x coordinate. * @param int $y The cell y coordinate. - * @return array{prefix: string, suffix: string, styles?: list} + * @return array{prefix: string, suffix: string} */ public function getTileCellStyle(int $x, int $y): array { - return $this->getLayerCellStyle(MapLayers::BASE, $x, $y); + $cell = $this->layers->getBaseGrid()->cells[$y][$x] ?? null; + + return [ + 'prefix' => is_array($cell) ? (string) ($cell['prefix'] ?? '') : '', + 'suffix' => is_array($cell) ? (string) ($cell['suffix'] ?? '') : '', + ]; } /** @@ -912,7 +840,15 @@ private static function getInnermostForeground(string $prefix): ?string */ public function setTileCell(int $x, int $y, string $symbol, string $prefix, string $suffix): void { - $this->setLayerCell(MapLayers::BASE, $x, $y, $symbol, $prefix, $suffix); + $this->assertEditable(); + if (! isset($this->layers->getBaseGrid()->cells[$y][$x])) { + return; + } + + $this->layers->getBaseGrid()->cells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); + $this->layers->getBaseGrid()->cells[$y][$x]['prefix'] = $prefix; + $this->layers->getBaseGrid()->cells[$y][$x]['suffix'] = $suffix; + $this->touchState(); } /** @@ -925,12 +861,17 @@ public function setTileCell(int $x, int $y, string $symbol, string $prefix, stri */ public function setEventSymbol(int $x, int $y, string $symbol): void { - $this->setLayerCell(MapLayers::EVENT, $x, $y, $symbol); + $this->assertEditable(); + if (! $this->hasLayerCell(MapLayers::EVENT, $x, $y)) { + return; + } + + $this->layers->getEventGrid()->cells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); + $this->touchState(); } /** - * Returns every distinct event marker painted on the grid. A cell - * holding two different markers marks nothing; validation reports it. + * Returns every distinct event marker painted on the grid. * * @return string[] */ @@ -938,12 +879,10 @@ public function getPlacedEventMarkers(): array { $markers = []; - foreach ($this->layers->getEventGrid()->getSymbols() as $y => $row) { - foreach (array_keys($row) as $x) { - $marker = $this->getEventMarkerAt($x, $y); - - if ($marker !== null) { - $markers[$marker] = $marker; + foreach ($this->layers->getEventGrid()->getSymbols() as $row) { + foreach ($row as $symbol) { + if (trim($symbol) !== '') { + $markers[$symbol] = $symbol; } } } @@ -951,29 +890,6 @@ public function getPlacedEventMarkers(): array return array_values($markers); } - /** - * Describes every event cell holding two different markers, which the - * Engine refuses to load. - * - * @return string[] The Engine's refusal for each such cell. - */ - public function describeConflictingEventCells(): array - { - $conflicts = []; - - foreach ($this->layers->getEventGrid()->getSymbols() as $y => $row) { - foreach ($row as $x => $cell) { - try { - MapCell::getMarker($cell, "Event cell at row {$y}, column {$x}"); - } catch (InvalidArgumentException $conflict) { - $conflicts[] = $conflict->getMessage(); - } - } - } - - return $conflicts; - } - /** * Captures the editable grid state for undoable whole-grid mutations * (resize, event bounds rewrites). @@ -1342,11 +1258,9 @@ public function getEventField(string $marker, array $path): mixed */ public function getEventMarkerAt(int $x, int $y): ?string { - try { - return MapCell::getMarker($this->getEventSymbol($x, $y)); - } catch (InvalidArgumentException) { - return null; - } + $symbol = trim($this->getEventSymbol($x, $y)); + + return $symbol === '' ? null : $symbol; } /** @@ -1379,8 +1293,8 @@ public function getEventBounds(string $marker): ?array $positions = []; foreach ($this->layers->getEventGrid()->getSymbols() as $rowIndex => $row) { - foreach (array_keys($row) as $columnIndex) { - if ($this->getEventMarkerAt($columnIndex, $rowIndex) === $marker) { + foreach ($row as $columnIndex => $symbol) { + if ($symbol === $marker) { $positions[] = [$columnIndex, $rowIndex]; } } @@ -1423,7 +1337,7 @@ public function isEventMarkerSolidRectangle(string $marker): bool for ($y = $bounds['y']; $y < $maxY; $y++) { for ($x = $bounds['x']; $x < $maxX; $x++) { - if ($this->getEventMarkerAt($x, $y) !== $marker) { + if ($this->getEventSymbol($x, $y) !== $marker) { return false; } } @@ -1562,23 +1476,21 @@ public function removeEventDefinition(string $marker): void public function setEventBounds(string $marker, int $x, int $y, int $width, int $height): void { $this->assertEditable(); - $grid = $this->layers->getEventGrid(); - foreach ($grid->getSymbols() as $rowIndex => $row) { - foreach (array_keys($row) as $columnIndex) { - if ($this->getEventMarkerAt($columnIndex, $rowIndex) === $marker) { - $grid->cells[$rowIndex][$columnIndex] = EditableGrid::createCell(MapCell::BLANK, ['prefix' => '', 'suffix' => '']); + foreach ($this->layers->getEventGrid()->getSymbols() as $rowIndex => $row) { + foreach ($row as $columnIndex => $symbol) { + if ($symbol === $marker) { + $this->layers->getEventGrid()->cells[$rowIndex][$columnIndex]['symbol'] = ' '; } } } $maxX = max(0, min($this->getWidth() - 1, $x + max(1, $width) - 1)); $maxY = max(0, min($this->getHeight() - 1, $y + max(1, $height) - 1)); - $cell = EditableGrid::createCell(CellSymbol::normalize($marker), ['prefix' => '', 'suffix' => '']); for ($row = max(0, $y); $row <= $maxY; $row++) { for ($column = max(0, $x); $column <= $maxX; $column++) { if ($this->hasLayerCell(MapLayers::EVENT, $column, $row)) { - $grid->cells[$row][$column] = $cell; + $this->layers->getEventGrid()->cells[$row][$column]['symbol'] = $marker; } } } @@ -1794,14 +1706,14 @@ private function evaluateFiles(array $paths, ?array &$fingerprints = null): arra * @param string $directory The destination map directory. * @param string $baseName The base filename. * @param string $displayName The map display name. - * @param int $width The map width, in cells. + * @param int $width The map width. * @param int $height The map height. * @return void */ - public static function createBlank(string $directory, string $baseName, string $displayName, int $width = 24, int $height = 18, ?FileSetOperations $files = null): void + public static function createBlank(string $directory, string $baseName, string $displayName, int $width = 48, int $height = 18, ?FileSetOperations $files = null): void { - $blankTileLine = str_repeat(MapCell::BLANK, $width); - $blankEventLine = str_repeat(MapCell::BLANK, $width); + $blankTileLine = str_repeat(' ', $width); + $blankEventLine = str_repeat(' ', $width); $tileText = implode(PHP_EOL, array_fill(0, $height, $blankTileLine)); $eventText = implode(PHP_EOL, array_fill(0, $height, $blankEventLine)); $data = [ @@ -1917,21 +1829,31 @@ public function duplicateTo(string $directory, string $baseName, string $display public function getCharacterPalette(?string $layer = null): array { $symbols = []; - $grids = [$layer === null ? $this->layers->getBaseGrid() : $this->layers->getGrid($layer), $this->layers->getEventGrid()]; - foreach ($grids as $grid) { - foreach ($grid->getSymbols() as $row) { - foreach ($row as $symbol) { - if (! MapCell::isBlank($symbol)) { - $symbols[$symbol] = $symbol; - } + foreach (($layer === null ? $this->layers->getBaseGrid() : $this->layers->getGrid($layer))->cells as $row) { + foreach ($row as $cell) { + $symbol = $cell['symbol']; + + if (trim($symbol) === '') { + continue; } + + $symbols[$symbol] = $symbol; + } + } + + foreach ($this->layers->getEventGrid()->getSymbols() as $row) { + foreach ($row as $symbol) { + if (trim($symbol) === '') { + continue; + } + + $symbols[$symbol] = $symbol; } } foreach ([' ', ';', '~', '+', '-', '=', '|', '/', '\\', '_', '█', '░', '▒', '▓', '🧍', '🏃🏽‍➡️', '@'] as $symbol) { - $cell = CellSymbol::normalize($symbol); - $symbols[$cell] = $cell; + $symbols[$symbol] = $symbol; } return array_values($symbols); @@ -1948,6 +1870,36 @@ private static function splitMapText(string $text): array return preg_split('/\R/u', rtrim($text, "\r\n")) ?: []; } + /** + * Normalizes input down to a single visible symbol. + * + * @param string $symbol The raw input symbol. + * @return string + */ + private static function normalizeSymbol(string $symbol): string + { + $symbols = self::toSymbols($symbol); + + return $symbols[0] ?? ' '; + } + + /** + * Splits a line into Unicode grapheme symbols. + * + * @param string $line The line to split. + * @return string[] + */ + private static function toSymbols(string $line): array + { + if ($line === '') { + return []; + } + + preg_match_all('/\X/u', $line, $matches); + + return $matches[0] ?? []; + } + /** * The tile file the current grid should be saved as. * @@ -2506,6 +2458,37 @@ private static function slugify(string $value, string $fallback = 'new-map'): st return $value !== '' ? $value : $fallback; } + /** + * Creates a blank tile cell for padded map rows. + * + * @return array{symbol: string, prefix: string, suffix: string} + */ + private static function createBlankTileCell(): array + { + return [ + 'symbol' => ' ', + 'prefix' => '', + 'suffix' => '', + ]; + } + + /** + * Creates a blank tile row. + * + * @param int $width The desired row width. + * @return array + */ + private static function createBlankTileRow(int $width): array + { + $row = []; + + for ($column = 0; $column < $width; $column++) { + $row[] = self::createBlankTileCell(); + } + + return $row; + } + /** * Exports a PHP value using the repository's short-array style. * diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index c392541..be338ca 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -288,7 +288,7 @@ classDatabase: $this->classDatabase, * Returns a preview of the selected map. * * @param int $selectedMapIndex The selected map index. - * @param int $width The preview width, in two-column cells. + * @param int $width The preview width. * @param int $height The preview height. * @param int $offsetX The horizontal preview offset. * @param int $offsetY The vertical preview offset. diff --git a/src/Validation/MapValidator.php b/src/Validation/MapValidator.php index 0ad09b7..1601786 100644 --- a/src/Validation/MapValidator.php +++ b/src/Validation/MapValidator.php @@ -28,7 +28,6 @@ private function __construct() public static function validate(ProjectMap $map, array $mapsById): array { return [ - ...$map->describeConflictingEventCells(), ...self::findMarkersWithoutDefinitions($map), ...self::findNonRectangularMarkers($map), ...self::findDanglingDestinations($map, $mapsById), @@ -38,18 +37,6 @@ public static function validate(ProjectMap $map, array $mapsById): array ]; } - /** - * Finds map data the engine no longer reads. - * - * @return string[] - */ - private static function findRetiredData(ProjectMap $map): array - { - return array_key_exists('tiles2d', $map->getEditableData()) - ? ['tiles2d is no longer read; the map shows its terminal glyphs until it has a tileset.'] - : []; - } - /** * Finds marker shapes the runtime cannot turn into one trigger area. * @@ -68,6 +55,18 @@ private static function findNonRectangularMarkers(ProjectMap $map): array return $warnings; } + /** + * Finds map data the engine no longer reads. + * + * @return string[] + */ + private static function findRetiredData(ProjectMap $map): array + { + return array_key_exists('tiles2d', $map->getEditableData()) + ? ['tiles2d is no longer read; the map shows its terminal glyphs until it has a tileset.'] + : []; + } + /** * Finds event markers painted on the grid with no event definition. * diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 6fd250e..79862c1 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -22,7 +22,6 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Core\WorldConditionType; -use Ichiloto\Engine\Field\MapCell; use Ichiloto\Engine\Field\SkitSpeaker; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; use Ichiloto\Engine\Events\Interpreter\MovementRouteRunner; @@ -1632,7 +1631,7 @@ protected function checkRetiredMapData(ProjectMap $map): array return [Issue::warning( $map->mapId, 'Its tiles2d crop table is no longer read.', - 'Glyph-keyed crops cannot address two-column cells; the map shows its terminal glyphs until it has a tileset. Remove the tiles2d key.' + 'Glyph-keyed crops are retired; the map shows its terminal glyphs until it has a tileset. Remove the tiles2d key.' )]; } @@ -1644,14 +1643,7 @@ protected function checkRetiredMapData(ProjectMap $map): array */ protected function checkEventMarkers(ProjectMap $map): array { - $issues = array_map( - static fn(string $conflict): Issue => Issue::error( - $map->mapId, - $conflict, - 'The engine refuses to load the map. Give each event cell one marker.' - ), - $map->describeConflictingEventCells(), - ); + $issues = []; $defined = array_keys((array) ($map->data['events'] ?? [])); $placed = $map->getPlacedEventMarkers(); @@ -1978,7 +1970,7 @@ protected function checkNpcs(ProjectMap $map, ProjectWorkspace $workspace): arra $issues = [ ...$issues, ...$this->checkNpcPlacement($entry, $where, $width, $height, $anchors, $name, $index), - ...$this->checkNpcSprite($entry, $where), + ...$this->checkNpcSprite($entry, $where, $width), ...$this->checkNpcMovement($entry, $where, $width, $height), ...$this->checkNpcDirectionalSprites($entry, $where), ...$this->checkNpcInteractionShapes($entry, $where), @@ -2000,7 +1992,7 @@ protected function checkNpcs(ProjectMap $map, ProjectWorkspace $workspace): arra * @param string $where Where it lives. * @param int $width The map width. * @param int $height The map height. - * @param array $anchors Anchors seen so far, appended to. + * @param array $anchors Anchors seen so far, appended to. * @param string $name The NPC's name. * @param int|string $index The entry index. * @return Issue[] The issues found. @@ -2037,12 +2029,14 @@ protected function checkNpcPlacement(array $entry, string $where, int $width, in $x = intval($entry['x']); $y = intval($entry['y']); + $sprite = is_scalar($entry['sprite'] ?? null) ? strval($entry['sprite']) : '@'; $anchors[] = [ 'x' => $x, 'y' => $y, 'name' => $name !== '' ? $name : sprintf('entry %s', is_int($index) ? $index + 1 : $index), 'where' => $where, 'wanders' => strval($entry['movement'] ?? 'fixed') === 'wander', + 'width' => ProjectNpc::glyphWidth($sprite), ]; if ($x < 0 || $y < 0 || $x >= $width || $y >= $height) { @@ -2057,15 +2051,15 @@ protected function checkNpcPlacement(array $entry, string $where, int $width, in } /** - * Checks the base sprite: text that fits the NPC's one cell, with - * explicit empty text reserved for an interaction whose appearance - * belongs to the map. + * Checks the base sprite: text and inside the map, with explicit empty + * text reserved for an interaction whose appearance belongs to the map. * * @param array $entry The NPC entry. * @param string $where Where it lives. + * @param int $width The map width. * @return Issue[] The issues found. */ - protected function checkNpcSprite(array $entry, string $where): array + protected function checkNpcSprite(array $entry, string $where, int $width): array { if (! array_key_exists('sprite', $entry)) { return []; @@ -2095,11 +2089,11 @@ protected function checkNpcSprite(array $entry, string $where): array $columns = ProjectNpc::glyphWidth($sprite); - if ($columns > MapCell::COLUMNS) { + if ($columns > 1 && is_numeric($entry['x'] ?? null) && intval($entry['x']) + $columns > $width) { return [Issue::warning( $where, - sprintf('Its %d-column sprite overhangs its cell.', $columns), - sprintf('A character occupies one cell of %d columns; a wider sprite is drawn over the cells to its right.', MapCell::COLUMNS) + sprintf('Its %d-column sprite overhangs the right edge of the map.', $columns), + 'The game anchors a wide glyph at its tile and lets it spill right; part of it is drawn off the map.' )]; } @@ -2430,7 +2424,7 @@ protected function checkNpcUnknownFields(array $entry, string $where): array * * @param ProjectMap $map The map. * @param ProjectWorkspace $workspace The project. - * @param array $anchors The NPC anchors. + * @param array $anchors The NPC anchors. * @return Issue[] The issues found. */ protected function checkNpcCollisions(ProjectMap $map, ProjectWorkspace $workspace, array $anchors): array @@ -2457,12 +2451,23 @@ protected function checkNpcCollisions(ProjectMap $map, ProjectWorkspace $workspa sprintf('It shares tile (%d, %d) with NPC %s.', $anchor['x'], $anchor['y'], $other['name']), 'The game finds the first NPC on a tile; the other can never be spoken to. Move one.' ); + } elseif ( + $anchor['width'] > 1 + && $anchor['y'] === $other['y'] + && $other['x'] > $anchor['x'] + && $other['x'] < $anchor['x'] + $anchor['width'] + ) { + $issues[] = Issue::warning( + $anchor['where'], + sprintf('Its %d-column sprite covers the tile of NPC %s at (%d, %d).', $anchor['width'], $other['name'], $other['x'], $other['y']), + 'The wide glyph is drawn over the neighbour. Leave a column between them.' + ); } } - $marker = $map->getEventMarkerAt($anchor['x'], $anchor['y']); + $marker = $map->getEventSymbol($anchor['x'], $anchor['y']); - if ($marker !== null && isset($definitions[$marker])) { + if (trim($marker) !== '' && isset($definitions[$marker])) { $class = is_array($definitions[$marker]) ? strval($definitions[$marker]['class'] ?? '') : ''; $classLabel = $class !== '' ? sprintf(' (%s)', basename(str_replace('\\', '/', $class))) : ''; $issues[] = $anchor['wanders'] @@ -4002,7 +4007,7 @@ protected function checkEventCue(mixed $cue, string $where, array $known): array $rawSymbol = $cue['symbol'] ?? ''; if (! is_string($rawSymbol)) { - return [...$issues, Issue::error($where, 'Its event cue symbol is malformed.', 'Use a string holding one character that fits one map cell.')]; + return [...$issues, Issue::error($where, 'Its event cue symbol is malformed.', 'Use a string containing one terminal cell.')]; } $symbol = trim($rawSymbol); @@ -4010,8 +4015,8 @@ protected function checkEventCue(mixed $cue, string $where, array $known): array return $issues; } - if (TerminalText::symbolCount($symbol) !== 1 || TerminalText::displayWidth($symbol) > MapCell::COLUMNS) { - $issues[] = Issue::error($where, 'Its event cue is not one character that fits one map cell.', sprintf('Choose one character of up to %d columns.', MapCell::COLUMNS)); + if (TerminalText::symbolCount($symbol) !== 1 || TerminalText::displayWidth($symbol) !== 1) { + $issues[] = Issue::error($where, 'Its event cue does not occupy exactly one terminal cell.', 'Choose one single-cell symbol.'); } $rawColor = $cue['color'] ?? 'bright-yellow'; diff --git a/tests/Fixtures/phase7-project/assets/Maps/technical-map/technical-map.event.php b/tests/Fixtures/phase7-project/assets/Maps/technical-map/technical-map.event.php index f518eeb..072afcb 100644 --- a/tests/Fixtures/phase7-project/assets/Maps/technical-map/technical-map.event.php +++ b/tests/Fixtures/phase7-project/assets/Maps/technical-map/technical-map.event.php @@ -1,9 +1,9 @@ ~~~~~~ ## -## ## -## ## -######################## +############ +# ~~~ # +# # +# # +############ ICHILOTO_MAP; diff --git a/tests/Fixtures/sample-project/ichiloto.json b/tests/Fixtures/sample-project/ichiloto.json index 1f136b3..ddbbb07 100644 --- a/tests/Fixtures/sample-project/ichiloto.json +++ b/tests/Fixtures/sample-project/ichiloto.json @@ -2,5 +2,5 @@ "id": "ichiloto/editor-sample-project", "name": "Sample Project", "main": "main.php", - "format": 2 + "format": 1 } diff --git a/tests/Support/LayeredMapFixtures.php b/tests/Support/LayeredMapFixtures.php index a9ccbc5..296c957 100644 --- a/tests/Support/LayeredMapFixtures.php +++ b/tests/Support/LayeredMapFixtures.php @@ -12,16 +12,15 @@ function layeredMapProject(bool $ragged = false): string $directory = $root . '/assets/Maps/test-map'; unlink($directory . '/test-map.map.php'); mkdir($directory . '/layers'); - // Every cell is two terminal columns; each fixture cell repeats one character. - $terrain = "........\n........"; - $building = " // \n xxxx "; - $decoration = "dd \n dd"; - $events = " EE\n "; + $terrain = "....\n...."; + $building = " / \n xx "; + $decoration = "d \n d"; + $events = " E\n "; if ($ragged) { - $terrain = "........\n...."; - $building = " // \nxx "; - $decoration = "dd \n dd"; - $events = " EE\n "; + $terrain = "....\n.."; + $building = " / \nx "; + $decoration = "d \n d"; + $events = " E\n "; } foreach (['01.terrain.map.php' => $terrain, '04.buildings.map.php' => $building, '07.detail.deco.php' => $decoration] as $file => $text) { $source = MapGridSource::buildSource($text, 'AUTHORED', '// keep ' . $file . "\n"); diff --git a/tests/Support/MapFixtures.php b/tests/Support/MapFixtures.php index b1b861b..3b8d9c1 100644 --- a/tests/Support/MapFixtures.php +++ b/tests/Support/MapFixtures.php @@ -127,16 +127,6 @@ function backdateTriplet(ProjectMap $map): void } } -/** - * Loads the fixture map fresh from disk. - */ -function fixtureMap(): ProjectMap -{ - $mapsRoot = fixturePath('sample-project/assets/Maps'); - - return ProjectMap::fromDirectory($mapsRoot, $mapsRoot . '/test-map'); -} - /** * Copies the fixture map into a scratch maps root for save tests. */ diff --git a/tests/Unit/CanvasInputRegressionTest.php b/tests/Unit/CanvasInputRegressionTest.php index 17aac5f..a8b9aeb 100644 --- a/tests/Unit/CanvasInputRegressionTest.php +++ b/tests/Unit/CanvasInputRegressionTest.php @@ -36,18 +36,18 @@ function createCanvasRegressionEditor(): array it('preserves copied tile styles through paste undo and redo', function (bool $cut) { [$editor, $map] = createCanvasRegressionEditor(); $source = [ - ['symbol' => 'AA', 'prefix' => '', 'suffix' => ''], - ['symbol' => 'BB', 'prefix' => '', 'suffix' => ''], - ['symbol' => ' ', 'prefix' => '', 'suffix' => ''], + ['symbol' => 'A', 'prefix' => '', 'suffix' => ''], + ['symbol' => 'B', 'prefix' => '', 'suffix' => ''], + ['symbol' => ' ', 'prefix' => '', 'suffix' => ''], ]; foreach ($source as $index => $cell) { $map->setTileCell(1 + $index, 1, ...array_values($cell)); - $map->setTileCell(5 + $index, 2, 'XX', '', ''); + $map->setTileCell(5 + $index, 2, 'X', '', ''); } setEditorProperty($editor, 'canvasSelection', ['x' => 1, 'y' => 1, 'width' => 3, 'height' => 1]); callEditorMethod($editor, $cut ? 'cutCanvasSelection' : 'captureCanvasSelection'); if ($cut) { - expect($map->getTileSymbol(1, 1))->toBe(' ') + expect($map->getTileSymbol(1, 1))->toBe(' ') ->and($map->getTileCellStyle(1, 1))->toBe(['prefix' => '', 'suffix' => '']); } setEditorProperty($editor, 'cursorX', 5); @@ -60,7 +60,7 @@ function createCanvasRegressionEditor(): array } callEditorMethod($editor, 'dispatchInput', "\x1a"); foreach (array_keys($source) as $index) { - expect($map->getTileSymbol(5 + $index, 2))->toBe('XX') + expect($map->getTileSymbol(5 + $index, 2))->toBe('X') ->and($map->getTileCellStyle(5 + $index, 2))->toBe(['prefix' => '', 'suffix' => '']); } callEditorMethod($editor, 'dispatchInput', "\x19"); @@ -122,12 +122,12 @@ function createCanvasRegressionEditor(): array expect(getEditorProperty($editor, 'canvasOffsetX'))->toBeGreaterThan(10); })->with(['map', 'event']); -it('uses the rendered viewport width in cells through wheel undo tab cursor movement and resize', function (int $terminalWidth) { +it('uses the rendered viewport width through wheel undo tab cursor movement and resize', function (int $terminalWidth) { [$editor, $map] = createCanvasRegressionEditor(); setEditorProperty($editor, 'lastTerminalSize', ['width' => $terminalWidth, 'height' => 40]); $map->resize(200, 100); $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); - $lastOffset = $map->getWidth() - $bounds['cells']; + $lastOffset = $map->getWidth() - $bounds['width']; setEditorProperty($editor, 'cursorX', $map->getWidth() - 1); callEditorMethod($editor, 'syncViewportToCursor'); expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe($lastOffset); @@ -142,7 +142,7 @@ function createCanvasRegressionEditor(): array expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe($lastOffset); setEditorProperty($editor, 'canvasOffsetX', 0); - setEditorProperty($editor, 'cursorX', $bounds['cells']); + setEditorProperty($editor, 'cursorX', $bounds['width']); callEditorMethod($editor, 'syncViewportToCursor'); expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(1); @@ -151,7 +151,7 @@ function createCanvasRegressionEditor(): array setEditorProperty($editor, 'canvasOffsetY', 99); callEditorMethod($editor, 'clampCanvasOffsets'); $resized = callEditorMethod($editor, 'getCanvasPreviewBounds'); - expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(200 - $resized['cells']) + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(200 - $resized['width']) ->and(getEditorProperty($editor, 'canvasOffsetY'))->toBe(100 - $resized['height']); $map->resize(2, 2); callEditorMethod($editor, 'clampCanvasOffsets'); diff --git a/tests/Unit/CanvasToolsTest.php b/tests/Unit/CanvasToolsTest.php index 458e6f6..3a601c5 100644 --- a/tests/Unit/CanvasToolsTest.php +++ b/tests/Unit/CanvasToolsTest.php @@ -33,15 +33,7 @@ function placeCursor(Editor $editor, int $x, int $y): void } /** - * Returns one cell of the fixture map's current tile grid. - */ -function tileCellAt(Editor $editor, int $x, int $y): string -{ - return getEditorProperty($editor, 'workspace')->getMapByIndex(0)->getTileSymbol($x, $y); -} - -/** - * Returns the fixture map's current tile grid as strings, two columns a cell. + * Returns the fixture map's current tile grid as strings. * * @return string[] */ @@ -113,7 +105,7 @@ function tileRows(Editor $editor): array $before = tileRows($editor); setEditorProperty($editor, 'canvasTool', CanvasTool::FILLED_RECTANGLE); - setEditorProperty($editor, 'selectedPaintSymbol', 'XX'); + setEditorProperty($editor, 'selectedPaintSymbol', 'X'); placeCursor($editor, 1, 1); callEditorMethod($editor, 'dispatchInput', "\n"); placeCursor($editor, 4, 3); @@ -122,8 +114,8 @@ function tileRows(Editor $editor): array /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); - expect(tileRows($editor)[1])->toBe('##XXXXXXXX~~ ##') - ->and(tileRows($editor)[3])->toBe('##XXXXXXXX ##') + expect(tileRows($editor)[1])->toBe('#XXXX~ #') + ->and(tileRows($editor)[3])->toBe('#XXXX #') ->and($history->count())->toBe(1); callEditorMethod($editor, 'dispatchInput', "\x1a"); @@ -135,7 +127,7 @@ function tileRows(Editor $editor): array it('draws a rectangle outline as one command and leaves the middle alone', function () { $editor = canvasEditor(); setEditorProperty($editor, 'canvasTool', CanvasTool::RECTANGLE); - setEditorProperty($editor, 'selectedPaintSymbol', 'OO'); + setEditorProperty($editor, 'selectedPaintSymbol', 'O'); placeCursor($editor, 1, 1); callEditorMethod($editor, 'dispatchInput', "\n"); placeCursor($editor, 4, 3); @@ -144,8 +136,8 @@ function tileRows(Editor $editor): array /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); - expect(tileRows($editor)[1])->toBe('##OOOOOOOO~~ ##') - ->and(tileRows($editor)[2])->toBe('##OO OO ##') + expect(tileRows($editor)[1])->toBe('#OOOO~ #') + ->and(tileRows($editor)[2])->toBe('#O O #') ->and($history->count())->toBe(1); }); @@ -153,7 +145,7 @@ function tileRows(Editor $editor): array $editor = canvasEditor(); $before = tileRows($editor); setEditorProperty($editor, 'canvasTool', CanvasTool::LINE); - setEditorProperty($editor, 'selectedPaintSymbol', '=='); + setEditorProperty($editor, 'selectedPaintSymbol', '='); placeCursor($editor, 1, 2); callEditorMethod($editor, 'dispatchInput', "\n"); placeCursor($editor, 5, 2); @@ -162,7 +154,7 @@ function tileRows(Editor $editor): array /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); - expect(tileRows($editor)[2])->toBe('##========== ##') + expect(tileRows($editor)[2])->toBe('#===== #') ->and($history->count())->toBe(1); callEditorMethod($editor, 'dispatchInput', "\x1a"); @@ -173,7 +165,7 @@ function tileRows(Editor $editor): array it('flood fills with Ctrl+F and undoes the whole region in ONE Ctrl+Z', function () { $editor = canvasEditor(); $before = tileRows($editor); - setEditorProperty($editor, 'selectedPaintSymbol', '..'); + setEditorProperty($editor, 'selectedPaintSymbol', '.'); placeCursor($editor, 5, 2); callEditorMethod($editor, 'dispatchInput', "\x06"); @@ -182,8 +174,8 @@ function tileRows(Editor $editor): array $filled = tileRows($editor); // The interior floods; the wall of `#` bounds it. - expect($filled[2])->toBe('##....................##') - ->and($filled[1])->toBe('##....~~~~~~..........##') + expect($filled[2])->toBe('#..........#') + ->and($filled[1])->toBe('#..~~~.....#') ->and($filled[0])->toBe($before[0]) ->and($history->count())->toBe(1); @@ -195,7 +187,7 @@ function tileRows(Editor $editor): array it('refuses a flood fill that would change nothing', function () { $editor = canvasEditor(); - setEditorProperty($editor, 'selectedPaintSymbol', '##'); + setEditorProperty($editor, 'selectedPaintSymbol', '#'); placeCursor($editor, 0, 0); callEditorMethod($editor, 'dispatchInput', "\x06"); @@ -207,11 +199,11 @@ function tileRows(Editor $editor): array it('picks up the symbol under the cursor with Ctrl+K', function () { $editor = canvasEditor(); - setEditorProperty($editor, 'selectedPaintSymbol', 'zz'); + setEditorProperty($editor, 'selectedPaintSymbol', 'z'); placeCursor($editor, 0, 0); callEditorMethod($editor, 'dispatchInput', "\x0b"); - expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('##'); + expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('#'); }); it('cycles tools with Ctrl+N and brush widths with Ctrl+W', function () { @@ -240,8 +232,8 @@ function tileRows(Editor $editor): array /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); - expect(tileRows($editor)[1])->toBe('## ~~WWWWWW ##') - ->and(tileRows($editor)[3])->toBe('## WWWWWW ##') + expect(tileRows($editor)[1])->toBe('# ~WWW #') + ->and(tileRows($editor)[3])->toBe('# WWW #') ->and($history->count())->toBe(1); callEditorMethod($editor, 'dispatchInput', "\x1a"); @@ -254,7 +246,7 @@ function tileRows(Editor $editor): array $before = tileRows($editor); // Author a 2x1 block to lift. - setEditorProperty($editor, 'selectedPaintSymbol', 'AA'); + setEditorProperty($editor, 'selectedPaintSymbol', 'A'); placeCursor($editor, 1, 1); callEditorMethod($editor, 'dispatchInput', "\n"); placeCursor($editor, 2, 1); @@ -273,7 +265,7 @@ function tileRows(Editor $editor): array /** @var Clipboard $clipboard */ $clipboard = getEditorProperty($editor, 'clipboard'); - expect($clipboard->getRows())->toBe([['AA', 'AA']]); + expect($clipboard->getRows())->toBe([['A', 'A']]); placeCursor($editor, 6, 3); callEditorMethod($editor, 'dispatchInput', "\x15"); @@ -281,7 +273,7 @@ function tileRows(Editor $editor): array /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); - expect(tileRows($editor)[3])->toBe('## AAAA ##') + expect(tileRows($editor)[3])->toBe('# AA #') ->and($history->count())->toBe(3); // One Ctrl+Z removes the whole stamp. @@ -307,8 +299,8 @@ function tileRows(Editor $editor): array /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); - expect($clipboard->getRows())->toBe([['~~', '~~', '~~']]) - ->and(tileRows($editor)[1])->toBe('## ##') + expect($clipboard->getRows())->toBe([['~', '~', '~']]) + ->and(tileRows($editor)[1])->toBe('# #') ->and($history->count())->toBe(1); callEditorMethod($editor, 'dispatchInput', "\x1a"); @@ -352,7 +344,7 @@ function tileRows(Editor $editor): array /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); - expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('QQ') + expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('Q') ->and(tileRows($editor))->toBe($before) ->and($history->count())->toBe(0); @@ -360,7 +352,7 @@ function tileRows(Editor $editor): array setEditorProperty($editor, 'canvasTool', CanvasTool::BRUSH); callEditorMethod($editor, 'dispatchInput', 'Q'); - expect(tileCellAt($editor, 5, 2))->toBe('QQ'); + expect(tileRows($editor)[2][5])->toBe('Q'); }); it('paints every reserved glyph in Paint mode instead of running commands', function () { @@ -373,7 +365,7 @@ function tileRows(Editor $editor): array foreach (['?', '%', '^', '@'] as $index => $glyph) { placeCursor($editor, 4 + $index, 2); callEditorMethod($editor, 'dispatchInput', $glyph); - expect(tileCellAt($editor, 4 + $index, 2))->toBe($glyph . $glyph); + expect(tileRows($editor)[2][4 + $index])->toBe($glyph); } expect(getEditorProperty($editor, 'isHelpOpen'))->toBeFalse() @@ -428,7 +420,7 @@ function tileRows(Editor $editor): array placeCursor($editor, 5, 2); callEditorMethod($editor, 'dispatchInput', '?'); - expect(tileCellAt($editor, 5, 2))->toBe('??') + expect(tileRows($editor)[2][5])->toBe('?') ->and(getEditorProperty($editor, 'isHelpOpen'))->toBeFalse(); }); @@ -443,13 +435,13 @@ function tileRows(Editor $editor): array callEditorMethod($editor, 'dispatchInput', 'i'); callEditorMethod($editor, 'dispatchInput', '?'); - expect($map->getTileSymbol(5, 2))->toBe('??') + expect($map->getTileSymbol(5, 2))->toBe('?') ->and($map->getTileColor(5, 2))->toBe('bright-cyan') ->and($map->getTileCellStyle(5, 2))->toBe(['prefix' => '', 'suffix' => '']); callEditorMethod($editor, 'dispatchInput', "\x1a"); - expect($map->getTileSymbol(5, 2))->toBe(' ') + expect($map->getTileSymbol(5, 2))->toBe(' ') ->and($map->getTileColor(5, 2))->toBeNull(); }); @@ -465,13 +457,13 @@ function tileRows(Editor $editor): array callEditorMethod($editor, 'dispatchInput', 'i'); callEditorMethod($editor, 'dispatchInput', 'Q'); - expect($map->getTileSymbol(5, 2))->toBe('QQ') + expect($map->getTileSymbol(5, 2))->toBe('Q') ->and($map->getTileCellStyle(5, 2))->toBe(['prefix' => '', 'suffix' => '']); // Undo restores the original symbol with the original bytes. callEditorMethod($editor, 'dispatchInput', "\x1a"); - expect($map->getTileSymbol(5, 2))->toBe('~~') + expect($map->getTileSymbol(5, 2))->toBe('~') ->and($map->getTileCellStyle(5, 2))->toBe(['prefix' => '', 'suffix' => '']); }); @@ -487,7 +479,7 @@ function tileRows(Editor $editor): array callEditorMethod($editor, 'dispatchInput', 'i'); callEditorMethod($editor, 'dispatchInput', "\177"); // Backspace erases. - expect($map->getTileSymbol(5, 2))->toBe(' ') + expect($map->getTileSymbol(5, 2))->toBe(' ') ->and($map->getTileCellStyle(5, 2))->toBe(['prefix' => '', 'suffix' => '']); }); @@ -501,7 +493,7 @@ function tileRows(Editor $editor): array callEditorMethod($editor, 'dispatchInput', 'k'); - expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('mm') + expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('m') ->and(getEditorProperty($editor, 'selectedPaintColor'))->toBe('yellow'); // An uncoloured cell loads an uncoloured brush. @@ -530,7 +522,7 @@ function tileRows(Editor $editor): array expect(getEditorProperty($editor, 'isColorPickerOpen'))->toBeFalse() ->and(getEditorProperty($editor, 'selectedPaintColor'))->toBe('black') - ->and($map->getTileSymbol(5, 2))->toBe('ii') + ->and($map->getTileSymbol(5, 2))->toBe('i') ->and($map->getTileColor(5, 2))->toBe('black'); }); @@ -551,7 +543,7 @@ function tileRows(Editor $editor): array // In Paint mode, o is a glyph. expect(getEditorProperty($editor, 'isColorPickerOpen'))->toBeFalse() - ->and(tileCellAt($editor, 5, 2))->toBe('oo'); + ->and(tileRows($editor)[2][5])->toBe('o'); }); it('exits NPC mode with Esc, cancelling a pending move first', function () { @@ -605,8 +597,8 @@ function tileRows(Editor $editor): array // Stolen shortcut glyphs and dictionary vocabulary are always pickable, // whether or not the current map still contains them. - foreach (['??', '%%', '^^', '@@', 'zz'] as $cell) { - expect($palette)->toContain($cell); + foreach (['?', '%', '^', '@', 'z'] as $glyph) { + expect($palette)->toContain($glyph); } }); @@ -671,15 +663,15 @@ function tileRows(Editor $editor): array // Named colours use the 4-bit palette; hex values use truecolor. Both // reset immediately so neighbouring cells stay untouched. - expect($lines[2])->toContain("\033[92m;;\033[0m") - ->and($lines[2])->toContain("\033[38;2;184;115;51m~~\033[0m"); + expect($lines[2])->toContain("\033[92m;\033[0m") + ->and($lines[2])->toContain("\033[38;2;184;115;51m~\033[0m"); // An event marker over a coloured tile stays plain authoring geometry. $map->setEventSymbol(2, 2, 'A'); $lines = $map->renderPreview(12, 5); expect($lines[2])->not->toContain("\033[92m") - ->and($lines[2])->toContain('AA'); + ->and($lines[2])->toContain('A'); }); /** Builds an SGR mouse press at 1-based terminal coordinates. */ @@ -688,13 +680,13 @@ function mousePress(int $code, int $column, int $row): string return sprintf("\033[<%d;%d;%dM", $code, $column, $row); } -/** Returns the terminal position of a map cell's first column under the current layout. */ +/** Returns the terminal cell of a map coordinate under the current layout. */ function canvasCellAt(Editor $editor, int $mapX, int $mapY): array { $layout = callEditorMethod($editor, 'resolveLayout'); return [ - 2 + $layout['leftWidth'] + $layout['gutter'] + 1 + 1 + $mapX * 2, + 2 + $layout['leftWidth'] + $layout['gutter'] + 1 + 1 + $mapX, 5 + 3 + $mapY, ]; } @@ -713,11 +705,11 @@ function canvasCellAt(Editor $editor, int $mapX, int $mapY): array ->and(tileRows($editor))->toBe($before); // Paint mode: the same click paints the brush symbol. - setEditorProperty($editor, 'selectedPaintSymbol', 'QQ'); + setEditorProperty($editor, 'selectedPaintSymbol', 'Q'); callEditorMethod($editor, 'dispatchInput', 'i'); callEditorMethod($editor, 'dispatchInput', mousePress(0, $column, $row)); - expect(tileCellAt($editor, 4, 2))->toBe('QQ'); + expect(tileRows($editor)[2][4])->toBe('Q'); }); it('scrolls the viewport with the wheel without moving the cursor, until the cursor reclaims it', function () { @@ -774,7 +766,7 @@ function canvasCellAt(Editor $editor, int $mapX, int $mapY): array callEditorMethod($editor, 'dispatchInput', "\n"); foreach ([2, 3, 4, 5] as $x) { - expect($map->getTileSymbol($x, 2))->toBe('##') + expect($map->getTileSymbol($x, 2))->toBe('#') ->and($map->getTileColor($x, 2))->toBe('black', "cell {$x} of the selection"); } diff --git a/tests/Unit/CommandHistoryTest.php b/tests/Unit/CommandHistoryTest.php index 6a0fd6e..6493b1f 100644 --- a/tests/Unit/CommandHistoryTest.php +++ b/tests/Unit/CommandHistoryTest.php @@ -35,8 +35,8 @@ function strokeTestMap(): ProjectMap mapPath: '/virtual/Maps/stroke-map/stroke-map.map.php', eventPath: '/virtual/Maps/stroke-map/stroke-map.event.php', data: ['name' => 'Stroke Map', 'events' => []], - tileLines: ['........', '........', '........'], - eventLines: [' ', ' ', ' '], + tileLines: ['....', '....', '....'], + eventLines: [' ', ' ', ' '], ); } @@ -149,49 +149,49 @@ static function () use ($refuse): void { foreach ([[0, 0], [1, 0], [2, 0]] as [$x, $y]) { $old = $map->getTileSymbol($x, $y); - $map->setTileSymbol($x, $y, '##'); - $stroke->appendCell($x, $y, $old, '##'); + $map->setTileSymbol($x, $y, '#'); + $stroke->appendCell($x, $y, $old, '#'); } $history->record($stroke); expect($history->count())->toBe(1) - ->and($map->getTileSymbol(1, 0))->toBe('##'); + ->and($map->getTileSymbol(1, 0))->toBe('#'); $history->undo(); - expect($map->getTileSymbol(0, 0))->toBe('..') - ->and($map->getTileSymbol(1, 0))->toBe('..') - ->and($map->getTileSymbol(2, 0))->toBe('..'); + expect($map->getTileSymbol(0, 0))->toBe('.') + ->and($map->getTileSymbol(1, 0))->toBe('.') + ->and($map->getTileSymbol(2, 0))->toBe('.'); $history->redo(); - expect($map->getTileSymbol(2, 0))->toBe('##'); + expect($map->getTileSymbol(2, 0))->toBe('#'); }); -it('keeps the first old cell when a stroke revisits a cell', function () { +it('keeps the first old symbol when a stroke revisits a cell', function () { $map = strokeTestMap(); $stroke = new PaintStrokeCommand($map, PaintStrokeCommand::LAYER_TILE); - $stroke->appendCell(1, 1, '..', '##'); - $stroke->appendCell(1, 1, '##', '@@'); + $stroke->appendCell(1, 1, '.', '#'); + $stroke->appendCell(1, 1, '#', '@'); expect($stroke->getCellCount())->toBe(1); - $map->setTileSymbol(1, 1, '@@'); + $map->setTileSymbol(1, 1, '@'); $stroke->undo(); - expect($map->getTileSymbol(1, 1))->toBe('..'); + expect($map->getTileSymbol(1, 1))->toBe('.'); }); -it('reports no changes when a stroke paints identical cells', function () { +it('reports no changes when a stroke paints identical symbols', function () { $map = strokeTestMap(); $stroke = new PaintStrokeCommand($map, PaintStrokeCommand::LAYER_TILE); - $stroke->appendCell(0, 0, '..', '..'); + $stroke->appendCell(0, 0, '.', '.'); expect($stroke->hasChanges())->toBeFalse(); - $stroke->appendCell(1, 0, '..', '##'); + $stroke->appendCell(1, 0, '.', '#'); expect($stroke->hasChanges())->toBeTrue(); }); @@ -199,15 +199,15 @@ static function () use ($refuse): void { it('paints the event layer when constructed for events', function () { $map = strokeTestMap(); $stroke = new PaintStrokeCommand($map, PaintStrokeCommand::LAYER_EVENT, 'Event stroke'); - $map->setEventSymbol(2, 1, 'EE'); - $stroke->appendCell(2, 1, ' ', 'EE'); + $map->setEventSymbol(2, 1, 'E'); + $stroke->appendCell(2, 1, ' ', 'E'); $stroke->undo(); - expect($map->getEventSymbol(2, 1))->toBe(' ') - ->and($map->getTileSymbol(2, 1))->toBe('..'); + expect($map->getEventSymbol(2, 1))->toBe(' ') + ->and($map->getTileSymbol(2, 1))->toBe('.'); $stroke->execute(); - expect($map->getEventSymbol(2, 1))->toBe('EE'); + expect($map->getEventSymbol(2, 1))->toBe('E'); }); diff --git a/tests/Unit/EditableGridTest.php b/tests/Unit/EditableGridTest.php index 7141434..9ff46fe 100644 --- a/tests/Unit/EditableGridTest.php +++ b/tests/Unit/EditableGridTest.php @@ -5,43 +5,43 @@ use Ichiloto\Editor\Maps\EditableGrid; use Ichiloto\Engine\Field\MapGridSource; -it('keys repeated cells by the exact cell text prefix and suffix without merging styled identities', function () { - $text = "000000xx\n\033[31m0000\033[0m00\n00"; +it('keys repeated cells by the exact glyph prefix and suffix without merging styled identities', function () { + $text = "000x\n\033[31m00\033[0m0\n0"; $source = MapGridSource::buildSource($text, 'IDENTITIES'); $grid = new EditableGrid($text, $source); expect($grid->cells)->toBe([ [ - ['symbol' => '00', 'prefix' => '', 'suffix' => ''], - ['symbol' => '00', 'prefix' => '', 'suffix' => ''], - ['symbol' => '00', 'prefix' => '', 'suffix' => ''], - ['symbol' => 'xx', 'prefix' => '', 'suffix' => ''], + ['symbol' => '0', 'prefix' => '', 'suffix' => ''], + ['symbol' => '0', 'prefix' => '', 'suffix' => ''], + ['symbol' => '0', 'prefix' => '', 'suffix' => ''], + ['symbol' => 'x', 'prefix' => '', 'suffix' => ''], ], [ - ['symbol' => '00', 'prefix' => "\033[31m", 'suffix' => "\033[0m"], - ['symbol' => '00', 'prefix' => "\033[31m", 'suffix' => "\033[0m"], - ['symbol' => '00', 'prefix' => '', 'suffix' => ''], + ['symbol' => '0', 'prefix' => "\033[31m", 'suffix' => "\033[0m"], + ['symbol' => '0', 'prefix' => "\033[31m", 'suffix' => "\033[0m"], + ['symbol' => '0', 'prefix' => '', 'suffix' => ''], ], - [['symbol' => '00', 'prefix' => '', 'suffix' => '']], + [['symbol' => '0', 'prefix' => '', 'suffix' => '']], ])->and($grid->getSource())->toBe($source); }); it('keeps repeated cell values independently editable with exact source and snapshot restoration', function () { - $text = "xxxx\nxxxx\nxx"; + $text = "xx\nxx\nx"; $source = MapGridSource::buildSource($text, 'CELLS', "// Preserve the authored rows.\n"); $grid = new EditableGrid($text, $source); $before = $grid->captureSnapshot(); $cell = &$grid->cells[0][0]; - $cell['symbol'] = 'yy'; + $cell['symbol'] = 'y'; $cell['prefix'] = ''; unset($cell); - expect($grid->cells[0][1])->toBe(['symbol' => 'xx', 'prefix' => '', 'suffix' => '']) + expect($grid->cells[0][1])->toBe(['symbol' => 'x', 'prefix' => '', 'suffix' => '']) ->and($grid->cells[1])->toBe($before['cells'][1]) ->and($grid->cells[2])->toBe($before['cells'][2]) - ->and($before['cells'][0][0]['symbol'])->toBe('xx') + ->and($before['cells'][0][0]['symbol'])->toBe('x') ->and($grid->getSource())->toBe(str_replace( - "xxxx\nxxxx", - "yyxx\nxxxx", + "xx\nxx", + "yx\nxx", $source, )); $edited = $grid->captureSnapshot(); @@ -55,83 +55,12 @@ ->and($redone->cells[1][1]['suffix'])->toBe(''); }); -it('groups pairs, two-column glyphs and blank cells into whole cells', function () { - $grid = new EditableGrid("##[]🌲 \n.. E🧍"); - expect($grid->getSymbols())->toBe([['##', '[]', '🌲', ' '], ['..', ' E', '🧍']]) - ->and($grid->cells[0][2])->toBe(['symbol' => '🌲', 'prefix' => '', 'suffix' => '']); -}); - -it('keeps each character of a differently styled pair and round-trips it byte for byte', function (string $row) { - $text = $row . "\n##.."; - $source = MapGridSource::buildSource($text, 'PAIRS'); - $grid = new EditableGrid($text, $source); - $pair = $grid->cells[0][0]; - expect($pair['symbol'])->toBe('[]') - ->and(EditableGrid::getCellRuns($pair))->toHaveCount(2) - ->and($grid->getSource())->toBe($source); - // Editing another cell regenerates the row; the pair keeps both styles. - $grid->cells[0][1] = EditableGrid::createCell('##', ['prefix' => '', 'suffix' => '']); - $expected = str_replace($row, preg_replace('/\.\.$/', '##', $row), $source); - expect($grid->getSource())->toBe($expected) - ->and(new EditableGrid(MapGridSource::parseSource($expected, 'PAIRS'))->cells[0][0])->toBe($pair); -})->with([ - 'two colours' => ['[]..'], - 'one uncoloured half' => ['[]..'], - 'raw ANSI' => ["\033[31m[\033[0m\033[34m]\033[0m.."], -]); - -it('keeps a nested style pair byte for byte while its row is unchanged', function () { - $text = "[]..\n####"; - $source = MapGridSource::buildSource($text, 'NESTED'); - $grid = new EditableGrid($text, $source); - $grid->cells[1][0] = EditableGrid::createCell('..', ['prefix' => '', 'suffix' => '']); - expect($grid->cells[0][0]['styles'])->toBe([ - ['prefix' => '', 'suffix' => ''], - ['prefix' => '', 'suffix' => ''], - ])->and($grid->getSource())->toBe(str_replace("\n####", "\n..##", $source)); -}); - -it('restores a differently styled pair from its style and colours it by its first visible character', function () { - $grid = new EditableGrid("[]\n x"); - $pair = $grid->cells[0][0]; - $style = EditableGrid::getCellStyle($pair); - expect($style)->toBe(['prefix' => '', 'suffix' => '', 'styles' => [ - ['prefix' => '', 'suffix' => ''], ['prefix' => '', 'suffix' => ''], - ]]) - ->and(EditableGrid::createCell('[]', $style))->toBe($pair) - ->and(EditableGrid::createCell('##', $style))->toBe(['symbol' => '##', ...$style]) - ->and(EditableGrid::createCell('🌲', $style))->toBe(['symbol' => '🌲', 'prefix' => '', 'suffix' => '']) - ->and($grid->cells[1][0]['prefix'])->toBe(''); -}); - -it('refuses misaligned rows with their row and column, as the Engine does', function (string $text, string $message) { - expect(fn() => new EditableGrid($text, context: 'test/grid.map.php'))->toThrow(InvalidArgumentException::class, $message); -})->with([ - 'glyph after a lone character' => ["####\n#🌲", 'test/grid.map.php row 1, column 1: a two-column glyph must begin a cell'], - 'lone trailing character' => ["###", 'test/grid.map.php row 0 ends halfway through a cell'], -]); - -it('keeps legacy tags as styles and writes them back around the cells they cover', function () { - $text = "##~~~~##"; - $grid = new EditableGrid($text, legacyTags: true); - expect($grid->getSymbols())->toBe([['##', '~~', '~~', '##']]) - ->and($grid->cells[0][1]['prefix'])->toBe(''); - $grid->cells[0][0] = EditableGrid::createCell('@@', ['prefix' => '', 'suffix' => '']); - expect($grid->getLines())->toBe(['@@~~~~##']); -}); - -it('resizes to whole blank cells', function () { - $grid = new EditableGrid("##..\n##.."); - $grid->resize(3, 3); - expect($grid->getSymbols())->toBe([['##', '..', ' '], ['##', '..', ' '], [' ', ' ', ' ']]); -}); - -it('holds a multi-map workload of repeated two-column cells within the unchanged 128 MiB CLI budget', function () { +it('holds a multi-map workload of repeated editable cells within the unchanged 128 MiB CLI budget', function () { $probe = <<<'PHP' require $argv[1]; $lines = []; for ($row = 0; $row < 96; $row++) { - $lines[] = '' . str_repeat('.', 80) . '' . str_repeat(' ', 80 - ($row % 2) * 2); + $lines[] = '' . str_repeat('.', 80) . '' . str_repeat(' ', 80 - $row % 2); } $text = implode("\n", $lines); $source = \Ichiloto\Engine\Field\MapGridSource::buildSource($text, 'CELLS'); @@ -142,11 +71,11 @@ $cellCount += array_sum(array_map(count(...), $grid->cells)); $grids[] = $grid; } - $grids[0]->cells[0][0]['symbol'] = '##'; + $grids[0]->cells[0][0]['symbol'] = '#'; echo json_encode([ 'grids' => count($grids), 'cells' => $cellCount, - 'independent' => $grids[1]->cells[0][0]['symbol'] === '..' && $grids[0]->cells[0][1]['symbol'] === '..', + 'independent' => $grids[1]->cells[0][0]['symbol'] === '.' && $grids[0]->cells[0][1]['symbol'] === '.', 'limit' => ini_get('memory_limit'), 'peak' => memory_get_peak_usage(true), ], JSON_THROW_ON_ERROR); @@ -167,7 +96,7 @@ ->and($error)->toBe(''); $result = json_decode($output, true, flags: JSON_THROW_ON_ERROR); expect($result['grids'])->toBe(30) - ->and($result['cells'])->toBe(228960) + ->and($result['cells'])->toBe(459360) ->and($result['independent'])->toBeTrue() ->and($result['limit'])->toBe('128M') ->and($result['peak'])->toBeLessThan(128 * 1024 * 1024); diff --git a/tests/Unit/EditorSafetyTest.php b/tests/Unit/EditorSafetyTest.php index 9affd9b..8cd36ff 100644 --- a/tests/Unit/EditorSafetyTest.php +++ b/tests/Unit/EditorSafetyTest.php @@ -31,7 +31,7 @@ function workspaceEditor(): Editor $workspace = getEditorProperty($editor, 'workspace'); $map = $workspace->getMapByIndex(0); - expect($map->getTileSymbol(1, 2))->toBe('@@') + expect($map->getTileSymbol(1, 2))->toBe('@') ->and($map->isDirty())->toBeTrue(); /** @var CommandHistory $history */ @@ -41,11 +41,11 @@ function workspaceEditor(): Editor callEditorMethod($editor, 'performUndo'); - expect($map->getTileSymbol(1, 2))->toBe(' '); + expect($map->getTileSymbol(1, 2))->toBe(' '); callEditorMethod($editor, 'performRedo'); - expect($map->getTileSymbol(1, 2))->toBe('@@'); + expect($map->getTileSymbol(1, 2))->toBe('@'); }); it('does not record a command when the paint is a no-op', function () { diff --git a/tests/Unit/EditorShellTest.php b/tests/Unit/EditorShellTest.php index 02e94ec..801d1b0 100644 --- a/tests/Unit/EditorShellTest.php +++ b/tests/Unit/EditorShellTest.php @@ -102,14 +102,14 @@ function flushShell(Editor $editor): string /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe(' '); + expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe(' '); // Canvas focused: Paint mode enters and the same key paints. setEditorProperty($editor, 'focusedPane', 'canvas'); callEditorMethod($editor, 'dispatchInput', 'i'); // Paint mode. callEditorMethod($editor, 'dispatchInput', 'x'); - expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe('xx'); + expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe('x'); }); it('opens and closes the Database screen through the router', function () { @@ -175,15 +175,15 @@ function flushShell(Editor $editor): string /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe('##'); + expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe('#'); callEditorMethod($editor, 'dispatchInput', "\x1a"); - expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe(' '); + expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe(' '); callEditorMethod($editor, 'dispatchInput', "\x19"); - expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe('##'); + expect($workspace->getMapByIndex(0)->getTileSymbol(1, 2))->toBe('#'); }); it('repaints the header the tick the unsaved truth changes, a save included', function () { diff --git a/tests/Unit/EditorUxTest.php b/tests/Unit/EditorUxTest.php index 855d04a..5fa7b6b 100644 --- a/tests/Unit/EditorUxTest.php +++ b/tests/Unit/EditorUxTest.php @@ -33,7 +33,7 @@ function uxEditor(): Editor /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - expect($workspace->getMapByIndex(0)->getTileSymbol(1, 1))->toBe('!!') + expect($workspace->getMapByIndex(0)->getTileSymbol(1, 1))->toBe('!') ->and(getEditorProperty($editor, 'isDatabaseOpen'))->toBeFalse(); }); @@ -50,7 +50,7 @@ function uxEditor(): Editor setEditorProperty($editor, 'cursorY', 2); callEditorMethod($editor, 'dispatchInput', $glyph); - expect($workspace->getMapByIndex(0)->getTileSymbol($column, 2))->toBe($glyph . $glyph) + expect($workspace->getMapByIndex(0)->getTileSymbol($column, 2))->toBe($glyph) ->and(getEditorProperty($editor, 'cursorX'))->toBe($column) ->and(getEditorProperty($editor, 'cursorY'))->toBe(2); } diff --git a/tests/Unit/GoldenFrameTest.php b/tests/Unit/GoldenFrameTest.php index 67d97d3..6a9013c 100644 --- a/tests/Unit/GoldenFrameTest.php +++ b/tests/Unit/GoldenFrameTest.php @@ -25,7 +25,7 @@ ->and($plainFrame)->toContain('Project: Sample Project') ->and($plainFrame)->toContain('> test-map') ->and($plainFrame)->toContain('Preview: test-map') - ->and($plainFrame)->toContain('########################') + ->and($plainFrame)->toContain('############') ->and($plainFrame)->toContain('Name: Test Map') ->and($plainFrame)->toContain('Ready.'); }); diff --git a/tests/Unit/LastLegendMapIntegrityTest.php b/tests/Unit/LastLegendMapIntegrityTest.php index 391b269..0f8f9bf 100644 --- a/tests/Unit/LastLegendMapIntegrityTest.php +++ b/tests/Unit/LastLegendMapIntegrityTest.php @@ -9,7 +9,6 @@ use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; -use Ichiloto\Engine\Field\MapCell; use Ichiloto\Engine\Field\MapGridSource; use Ichiloto\Engine\Field\MapLayer; use Ichiloto\Engine\Field\MapLayerSource; @@ -212,7 +211,7 @@ function findIntegrityCell(ProjectMap $map, string $layer): array for ($x = 0; $x < $map->getWidth(); $x++) { if ($map->hasLayerCell($layer, $x, $y)) { $first ??= [$x, $y]; - if (! MapCell::isBlank($map->getLayerSymbol($layer, $x, $y))) { + if ($map->getLayerSymbol($layer, $x, $y) !== ' ') { return [$x, $y]; } } @@ -263,10 +262,10 @@ function assertIntegrityRowEdit(string $before, string $after, int $row, string $symbol = $map->getLayerSymbol($layer['id'], $x, $y); $style = $map->getLayerCellStyle($layer['id'], $x, $y); // Preserve decoration crop keys and event identities; recolouring is still a real edit. - $painted = $layer['decoration'] || $layer['id'] === 'event' ? $symbol : ($symbol === '##' ? '@@' : '##'); + $painted = $layer['decoration'] || $layer['id'] === 'event' ? $symbol : ($symbol === '#' ? '@' : '#'); $prefix = $style['prefix'] === '' ? '' : ''; $command = new PaintStrokeCommand($map, $layer['id']); - $command->appendCell($x, $y, $symbol, $painted, $style, ['prefix' => $prefix, 'suffix' => '']); + $command->appendCell($x, $y, $symbol, $painted, $style['prefix'], $style['suffix'], $prefix, ''); $history = new CommandHistory(); $command->execute(); $history->record($command); @@ -339,9 +338,9 @@ function assertIntegrityRowEdit(string $before, string $after, int $row, string } $top = $gameplay[array_key_last($gameplay)]; [$x, $y] = findIntegrityCell($map, $top); - $symbol = TerminalText::stripAnsi($composed[$y][$x]) === '##' ? '@@' : '##'; + $symbol = TerminalText::stripAnsi($composed[$y][$x]) === '#' ? '@' : '#'; $map->setLayerCell($top, $x, $y, $symbol, '', ''); - $composed[$y][$x] = MapCell::parseRow('' . $symbol . '')[0]; + $composed[$y][$x] = TerminalText::visibleSymbols('' . $symbol . '')[0]; $edited = array_map(static fn(array $row): string => rtrim(implode('', $row)), $composed); expect($edited)->not->toBe($expected) ->and($map->renderPreview($map->getWidth(), $map->getHeight(), terminalPreview: true))->toBe($edited, 'unsaved topmost gameplay edit is visible'); diff --git a/tests/Unit/LayeredCanvasTest.php b/tests/Unit/LayeredCanvasTest.php index f24bd7f..d050b9c 100644 --- a/tests/Unit/LayeredCanvasTest.php +++ b/tests/Unit/LayeredCanvasTest.php @@ -19,11 +19,9 @@ } callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); callEditorMethod($editor, 'dispatchInput', 'i'); - // Each key paints: one fills the cell, a quick second makes a pair and - // a third starts the next cell over. - foreach (['[' => '[[', ']' => '[]', 'v' => 'vv', 'd' => 'vd', 't' => 'tt', 'o' => 'to'] as $glyph => $cell) { + foreach (['[', ']', 'v', 'd', 't', 'o'] as $glyph) { callEditorMethod($editor, 'dispatchInput', $glyph); - expect($map->getLayerSymbol('map:4', 0, 0))->toBe($cell) + expect($map->getLayerSymbol('map:4', 0, 0))->toBe($glyph) ->and(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:4'); } }); @@ -40,7 +38,7 @@ callEditorMethod($editor, 'openColorPicker'); setEditorProperty($editor, 'colorPaletteIndex', 5); // yellow callEditorMethod($editor, 'dispatchInput', "\n"); - expect($map->getLayerSymbol($id, 0, 0))->toBe('QQ') + expect($map->getLayerSymbol($id, 0, 0))->toBe('Q') ->and($map->getLayerColor($id, 0, 0))->toBe('yellow') ->and($map->getLayerColor($id, 1, 0))->toBe('yellow'); callEditorMethod($editor, 'dispatchInput', "\x1a"); @@ -48,10 +46,10 @@ callEditorMethod($editor, 'captureCanvasSelection'); setEditorProperty($editor, 'cursorY', 1); callEditorMethod($editor, 'pasteCanvasClipboard'); - expect($map->getLayerSymbol($id, 0, 1))->toBe('QQ') + expect($map->getLayerSymbol($id, 0, 1))->toBe('Q') ->and($map->getLayerColor($id, 0, 1))->toBe('red'); callEditorMethod($editor, 'pickSymbolUnderCursor'); - expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('QQ') + expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('Q') ->and(getEditorProperty($editor, 'selectedPaintColor'))->toBe('red'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureGridSnapshot())->toBe($before); @@ -60,12 +58,12 @@ callEditorMethod($editor, 'applyCanvasToolAtCursor'); setEditorProperty($editor, 'cursorX', 3); callEditorMethod($editor, 'applyCanvasToolAtCursor'); - expect($map->getLayerSymbol($id, 3, 1))->toBe('QQ'); + expect($map->getLayerSymbol($id, 3, 1))->toBe('Q'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureGridSnapshot())->toBe($before); - setEditorProperty($editor, 'selectedPaintSymbol', 'FF'); + setEditorProperty($editor, 'selectedPaintSymbol', 'F'); callEditorMethod($editor, 'floodFillFromCursor'); - expect($map->getLayerSymbol($id, 3, 1))->toBe('FF'); + expect($map->getLayerSymbol($id, 3, 1))->toBe('F'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureGridSnapshot())->toBe($before); })->with(['map:1', 'map:4', 'event']); @@ -74,42 +72,45 @@ [$editor, $map] = layeredCanvasEditor(); callEditorMethod($editor, 'selectCanvasLayer', $id); callEditorMethod($editor, 'enterPaintMode'); - setEditorProperty($editor, 'selectedPaintSymbol', 'MM'); + setEditorProperty($editor, 'selectedPaintSymbol', 'M'); setEditorProperty($editor, 'selectedPaintColor', 'cyan'); $before = $map->captureGridSnapshot(); $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); - // Cell 2 spans the canvas's fifth and sixth columns. - callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + 4, $bounds['top'])); - callEditorMethod($editor, 'dispatchInput', sprintf("\033[<32;%d;%dM", $bounds['left'] + 5, $bounds['top'] + 1)); - callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + 5, $bounds['top'] + 1)); - expect($map->getLayerSymbol($id, 2, 1))->toBe('MM') + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + 2, $bounds['top'])); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<32;%d;%dM", $bounds['left'] + 2, $bounds['top'] + 1)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + 2, $bounds['top'] + 1)); + expect($map->getLayerSymbol($id, 2, 1))->toBe('M') ->and($map->getLayerColor($id, 2, 1))->toBe('cyan'); callEditorMethod($editor, 'selectCanvasLayer', $id === 'map:1' ? 'map:4' : 'map:1'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureGridSnapshot())->toBe($before); callEditorMethod($editor, 'dispatchInput', "\x19"); - expect($map->getLayerSymbol($id, 2, 1))->toBe('MM'); + expect($map->getLayerSymbol($id, 2, 1))->toBe('M'); })->with(['map:1', 'map:4', 'event']); it('renders readable gameplay immediately and keeps visibility and dimming session-only', function () { [$editor, $map] = layeredCanvasEditor(); $before = $map->captureLayerSnapshot(); $frame = renderEditorPlainFrame($editor, 160, 45); - expect($frame)->toContain('..//....', '..xxxx..') - ->not->toContain('dd//....', '..xxxxdd', 'Terminal preview', 't:Terminal'); + expect($frame)->toContain('./..', '.xx.') + ->not->toContain('d/..', '.xxd', 'Terminal preview', 't:Terminal'); callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); callEditorMethod($editor, 'dispatchInput', 'v'); callEditorMethod($editor, 'dispatchInput', 'd'); - expect(renderEditorPlainFrame($editor, 160, 45))->toContain('........') + expect(renderEditorPlainFrame($editor, 160, 45))->toContain('....') ->and($map->captureLayerSnapshot())->toBe($before) ->and($map->isDirty())->toBeFalse(); callEditorMethod($editor, 'dispatchInput', 'v'); - expect(renderEditorPlainFrame($editor, 160, 45))->toContain('..//....', '..xxxx..') + expect(renderEditorPlainFrame($editor, 160, 45))->toContain('./..', '.xx.') ->and($map->captureLayerSnapshot())->toBe($before); }); -it('edits terminal glyphs immediately through undo redo save and reload without changing graphical bytes', function () { +it('edits terminal glyphs immediately through undo redo save and reload without changing graphical bytes', function (bool $invalidGraphics) { $root = layeredMapProject(); + if ($invalidGraphics) { + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($path, preg_replace("/'width' => 16/", "'width' => 0", file_get_contents($path), 1)); + } [$editor, $map] = layeredCanvasEditor($root); $before = sourceHashTree($map->directory); $graphicalSource = file_get_contents($map->dataPath); @@ -117,7 +118,7 @@ foreach (['i', 'Z', "\033", "\x13"] as $key) { callEditorMethod($editor, 'dispatchInput', $key); } - expect($map->getLayerSymbol('map:1', 0, 0))->toBe('ZZ') + expect($map->getLayerSymbol('map:1', 0, 0))->toBe('Z') ->and(array_keys(array_diff_assoc(sourceHashTree($map->directory), $before)))->toBe(['layers/01.terrain.map.php']); callEditorMethod($editor, 'dispatchInput', "\x1a"); callEditorMethod($editor, 'dispatchInput', "\x13"); @@ -127,12 +128,12 @@ callEditorMethod($editor, 'dispatchInput', "\x12"); $reloaded = callEditorMethod($editor, 'getSelectedMap'); expect($reloaded)->not->toBe($map) - ->and($reloaded->getLayerSymbol('map:1', 0, 0))->toBe('ZZ') - ->and(loadLayeredMap($root)->getLayerSymbol('map:1', 0, 0))->toBe('ZZ') + ->and($reloaded->getLayerSymbol('map:1', 0, 0))->toBe('Z') + ->and(loadLayeredMap($root)->getLayerSymbol('map:1', 0, 0))->toBe('Z') ->and(file_get_contents($reloaded->dataPath))->toBe($graphicalSource) ->and(file_get_contents($reloaded->directory . '/layers/07.detail.deco.php'))->toBe($decorationSource) - ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('ZZ//....'); -}); + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('Z/..'); +})->with(['valid graphical source' => false, 'invalid graphical source' => true]); it('does not expose a read-only terminal toggle or crop painting warnings', function () { [$editor, $map] = layeredCanvasEditor(); @@ -141,10 +142,10 @@ foreach (['i', 'x', "\033"] as $key) { callEditorMethod($editor, 'dispatchInput', $key); } - expect($map->getLayerSymbol('map:4', 0, 0))->toBe('xx') + expect($map->getLayerSymbol('map:4', 0, 0))->toBe('x') ->and(renderEditorPlainFrame($editor, 160, 45))->not->toContain('Terminal preview', 'Crop mapping', 'cell overrides'); callEditorMethod($editor, 'dispatchInput', "\x1a"); - expect($map->getLayerSymbol('map:4', 0, 0))->toBe(' '); + expect($map->getLayerSymbol('map:4', 0, 0))->toBe(' '); }); it('excludes graphical layers and Tile art from the shell palette and inspector', function () { @@ -177,11 +178,11 @@ $before = $map->captureLayerSnapshot(); callEditorMethod($editor, 'dispatchInput', 'n'); expect(getEditorProperty($editor, 'editingMode'))->toBe('npc') - ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('N //....', '..xxxx..', 'Name: Resident'); + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('[N].', '.xx.', 'Name: Resident'); callEditorMethod($editor, 'dispatchInput', "\033"); callEditorMethod($editor, 'dispatchInput', 'e'); expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('event') - ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('..//..EE', '..xxxx..') + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('./.E', '.xx.') ->and($map->captureLayerSnapshot())->toBe($before); }); @@ -209,16 +210,16 @@ foreach (['i', 'Z', "\033"] as $key) { callEditorMethod($editor, 'dispatchInput', $key); } - expect($map->getLayerSymbol('map:1', 0, 0))->toBe('ZZ') - ->and($map->getLayerSymbol('map:7', 0, 0))->toBe('dd') + expect($map->getLayerSymbol('map:1', 0, 0))->toBe('Z') + ->and($map->getLayerSymbol('map:7', 0, 0))->toBe('d') ->and($map->getEditableData())->toBe($before['data']) - ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('ZZ//....') + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('Z/..') ->not->toContain('Terminal preview'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureLayerSnapshot())->toBe($before); }); -it('requires explicit collision-change confirmation before renaming and leaves map data untouched', function (bool $changes) { +it('requires explicit collision-change confirmation before renaming and preserves local crop source bytes', function (bool $changes) { [$editor, $map, $root] = layeredCanvasEditor(); $dictionaryPath = $root . '/assets/Maps/collisions.php'; file_put_contents($dictionaryPath, ' ["x" => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::SOLID], "houses" => ["x" => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::' . ($changes ? 'PASS_THROUGH' : 'SOLID') . '], "." => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::NONE];'); @@ -241,8 +242,7 @@ expect(getEditorProperty($editor, 'layerPrompt'))->toBeNull() ->and(array_column($map->getLayers(), 'name'))->toContain('houses'); $map->save(); - expect(file_get_contents($map->dataPath))->toBe($dataSource) - ->and(is_file($map->directory . '/layers/04.houses.map.php'))->toBeTrue() + expect(file_get_contents($map->dataPath))->toBe(str_replace("'buildings' =>", "'houses' =>", $dataSource)) ->and(hash_file('sha256', $dictionaryPath))->toBe($disk['assets/Maps/collisions.php']); callEditorMethod($editor, 'dispatchInput', "\x1a"); $map->save(); @@ -274,27 +274,27 @@ $directory = $root . '/assets/Graphics/Tilesets'; mkdir($directory, 0777, true); $path = $directory . '/buildings.txt'; - file_put_contents($path, " //\nxxxx\n\naabbcc\n ddeeff\n"); + file_put_contents($path, " /\nxx\n\nabc\n def\n"); expect(FacadeCatalogue::load($path))->toHaveCount(2); $items = callEditorMethod($editor, 'buildLayerPaletteItems'); expect(array_filter($items, static fn($item): bool => str_starts_with($item->label, 'Facade:')))->toHaveCount(2); callEditorMethod($editor, 'selectFacadeBrush', $path, 0); $before = $map->captureGridSnapshot(); callEditorMethod($editor, 'applyCanvasToolAtCursor'); - expect($map->getLayerSymbol('map:4', 0, 1))->toBe('xx'); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('x'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureGridSnapshot())->toBe($before); - file_put_contents($path, " //\n||||\n\naabbcc\n ddeeff\n"); + file_put_contents($path, " /\n||\n\nabc\n def\n"); callEditorMethod($editor, 'applyCanvasToolAtCursor'); - expect($map->getLayerSymbol('map:4', 0, 1))->toBe('||') - ->and($map->getLayerSymbol('map:1', 0, 1))->toBe('..'); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('|') + ->and($map->getLayerSymbol('map:1', 0, 1))->toBe('.'); }); it('keeps facade mouse clicks selection-only in Normal mode and stamps explicitly or in Paint mode', function () { [$editor, $map, $root] = layeredCanvasEditor(); mkdir($root . '/assets/Graphics/Tilesets', 0777, true); $path = $root . '/assets/Graphics/Tilesets/buildings.txt'; - file_put_contents($path, " //\n||||\n"); + file_put_contents($path, " /\n||\n"); callEditorMethod($editor, 'selectFacadeBrush', $path, 0); $before = $map->captureGridSnapshot(); $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); @@ -304,13 +304,13 @@ callEditorMethod($editor, 'dispatchInput', $release); expect($map->captureGridSnapshot())->toBe($before); callEditorMethod($editor, 'dispatchInput', "\r"); - expect($map->getLayerSymbol('map:4', 0, 1))->toBe('||'); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('|'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureGridSnapshot())->toBe($before); callEditorMethod($editor, 'dispatchInput', 'i'); callEditorMethod($editor, 'dispatchInput', $click); callEditorMethod($editor, 'dispatchInput', $release); - expect($map->getLayerSymbol('map:4', 0, 1))->toBe('||'); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('|'); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($map->captureGridSnapshot())->toBe($before); }); @@ -321,7 +321,7 @@ [$editor, $map] = layeredCanvasEditor($root); mkdir($root . '/assets/Graphics/Tilesets', 0777, true); $path = $root . '/assets/Graphics/Tilesets/buildings.txt'; - file_put_contents($path, " //\n||||\n"); + file_put_contents($path, " /\n||\n"); callEditorMethod($editor, 'selectFacadeBrush', $path, 0); $before = $map->captureLayerSnapshot(); if ($command === 'asset') { diff --git a/tests/Unit/LayeredMapTest.php b/tests/Unit/LayeredMapTest.php index ff2cb07..200a8e9 100644 --- a/tests/Unit/LayeredMapTest.php +++ b/tests/Unit/LayeredMapTest.php @@ -14,16 +14,16 @@ $map = loadLayeredMap(layeredMapProject()); expect(array_column($map->getLayers(), 'name'))->toBe(['terrain', 'buildings', 'detail', 'Events']) ->and(array_column($map->getLayers(), 'order'))->toBe([1, 4, 7, null]) - ->and($map->getLayerSymbol('map:4', 1, 0))->toBe('//') - ->and($map->getLayerSymbol('map:7', 0, 0))->toBe('dd'); + ->and($map->getLayerSymbol('map:4', 1, 0))->toBe('/') + ->and($map->getLayerSymbol('map:7', 0, 0))->toBe('d'); $terminal = array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, terminalPreview: true)); - expect($terminal)->toBe(['..//....', '..xxxx..']); - expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2)))->toBe(['..//..EE', '..xxxx..']); + expect($terminal)->toBe(['./..', '.xx.']); + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2)))->toBe(['./.E', '.xx.']); expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showEventOverlay: false)))->toBe($terminal); foreach ([true, false] as $visible) { - expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['map:7' => $visible])))->toBe(['..//..EE', '..xxxx..']); + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['map:7' => $visible])))->toBe(['./.E', '.xx.']); } - expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['map:4' => false])))->toBe(['......EE', '........']) + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['map:4' => false])))->toBe(['...E', '....']) ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['event' => false])))->toBe($terminal); expect(implode('', $map->renderPreview(4, 2, activeLayer: 'map:4', dimInactive: true)))->toContain("\033[2m") ->and(implode('', $map->renderPreview(4, 2, terminalPreview: true, layerVisibility: ['map:4' => false], dimInactive: true)))->not->toContain("\033[2m"); @@ -35,10 +35,9 @@ ['id' => 'resident', 'name' => 'Resident', 'sprite' => 'N', 'x' => 0, 'y' => 0], ])); $before = $map->captureLayerSnapshot(); - // A one-column sprite sits in the first column of its cell. - expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showNpcOverlay: true)))->toBe(['N //..EE', '..xxxx..']) - ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showEventOverlay: false, showNpcOverlay: true)))->toBe(['N //....', '..xxxx..']) - ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showNpcOverlay: true, layerVisibility: ['map:4' => false])))->toBe(['N ....EE', '........']) + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showNpcOverlay: true)))->toBe(['N/.E', '.xx.']) + ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showEventOverlay: false, showNpcOverlay: true)))->toBe(['N/..', '.xx.']) + ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showNpcOverlay: true, layerVisibility: ['map:4' => false])))->toBe(['N..E', '....']) ->and($map->captureLayerSnapshot())->toBe($before); }); @@ -56,7 +55,7 @@ $map->setLayerCell('map:1', 0, 0, 'T', '', ''); $map->setLayerCell('map:4', 0, 0, '|'); $map->save(); - expect(file_get_contents($path))->toContain('// keep 01.terrain.map.php', "return <<<'AUTHORED'", '........') + expect(file_get_contents($path))->toContain('// keep 01.terrain.map.php', "return <<<'AUTHORED'", '....') ->and(filemtime($map->directory . '/layers/07.detail.deco.php'))->toBe(1000000000) ->and(filemtime($map->eventPath))->toBe(1000000000); $map->setLayerCell('map:1', 0, 0, '.', '', ''); @@ -67,12 +66,12 @@ it('retains CRLF source indentation comments and unedited rows', function () { $root = layeredMapProject(); $path = $root . '/assets/Maps/test-map/layers/04.buildings.map.php'; - $source = "setLayerCell('map:4', 2, 0, '|'); $map->save(); - expect(file_get_contents($path))->toBe(str_replace(' // ', ' // || ', $source)); + expect(file_get_contents($path))->toBe(str_replace(' / ', ' / | ', $source)); }); it('preserves ragged per-row dimensions when creating and round-tripping layers', function () { @@ -85,7 +84,7 @@ foreach ($loaded->getLayerSet()->layers as $layer) { expect(array_map(count(...), $layer->grid))->toBe([4, 2]); } - expect($loaded->getLayerSymbol($id, 1, 1))->toBe('ii'); + expect($loaded->getLayerSymbol($id, 1, 1))->toBe('i'); }); it('rolls the complete layered save and rename back after any installation failure', function () { @@ -173,7 +172,7 @@ it('composes terminal preview with the exact shared Engine style and transparency semantics', function () { $root = layeredMapProject(); $path = $root . '/assets/Maps/test-map/layers/04.buildings.map.php'; - file_put_contents($path, MapGridSource::buildSource(" // \n xxxx ", 'STYLE')); + file_put_contents($path, MapGridSource::buildSource(" / \n xx ", 'STYLE')); $map = loadLayeredMap($root); $expected = array_map(static fn(array $row): string => rtrim(implode('', $row)), $map->getLayerSet()->getComposedGrid()); expect($map->renderPreview(4, 2, showNpcOverlay: true, terminalPreview: true))->toBe($expected); @@ -224,25 +223,15 @@ it('keeps raw ANSI styling separate from editable glyphs and preserves untouched source rows', function (string $prefix, string $color) { $root = layeredMapProject(); $path = $root . '/assets/Maps/test-map/layers/04.buildings.map.php'; - $source = MapGridSource::buildSource($prefix . " // \033[0m\n xxxx ", 'ANSI'); + $source = MapGridSource::buildSource($prefix . " / \033[0m\n xx ", 'ANSI'); file_put_contents($path, $source); $map = loadLayeredMap($root); - expect($map->getLayerSymbol('map:4', 1, 0))->toBe('//') + expect($map->getLayerSymbol('map:4', 1, 0))->toBe('/') ->and($map->getLayerColor('map:4', 1, 0))->toBe($color); $map->setLayerCell('map:4', 0, 1, '|'); $map->save(); - expect(file_get_contents($path))->toBe(str_replace(' xxxx ', '||xxxx ', $source)); + expect(file_get_contents($path))->toBe(str_replace(' xx ', '|xx ', $source)); })->with([ ["\033[31m", 'red'], ["\033[38;2;12;34;56m", '#0c2238'], ["\033[38;5;196m", '#ff0000'], ["\033[38;5;232m", '#080808'], ]); - -it('shows the lower layer through a space in an upper cell, as the Engine composes', function () { - $map = loadLayeredMap(layeredMapProject()); - // Home's chest " m" over a wall: the space keeps the column beneath it visible. - $map->setLayerCell('map:4', 0, 0, ' m'); - $preview = array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2)); - expect($preview[0])->toStartWith('.m') - ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, terminalPreview: true))[0])->toStartWith('.m') - ->and($map->renderPreview(4, 2, activeLayer: 'map:4', dimInactive: true)[0])->toStartWith("\033[2m"); -}); diff --git a/tests/Unit/LayeredPreviewTest.php b/tests/Unit/LayeredPreviewTest.php index ebff7eb..7b71de5 100644 --- a/tests/Unit/LayeredPreviewTest.php +++ b/tests/Unit/LayeredPreviewTest.php @@ -9,7 +9,7 @@ it('loads layer-aware cinematic collision and clears all geometry on unload', function () { $root = layeredMapProject(); file_put_contents($root . '/assets/Maps/test-map/test-map.event.php', - Ichiloto\Engine\Field\MapGridSource::buildSource(" \n ", 'EVENT')); + Ichiloto\Engine\Field\MapGridSource::buildSource(" \n ", 'EVENT')); file_put_contents($root . '/assets/Maps/collisions.php', <<<'PHP' render(); $output = ob_get_clean(); expect($output)->toBe('') - ->and(implode("\n", $camera->frame()))->toContain('..//....', '..xxxx..') - // The player's one-column sprite sits in the first column of its cell. - ->and(implode("\n", $preview->frame()))->toContain('@.//....', '..xxxx..'); + ->and(implode("\n", $camera->frame()))->toContain('./..', '.xx.'); $manager->unload(); expect($manager->layers)->toBeNull() ->and($manager->tileMap)->toBe([]) diff --git a/tests/Unit/ListFilterTest.php b/tests/Unit/ListFilterTest.php index 07a2481..948100e 100644 --- a/tests/Unit/ListFilterTest.php +++ b/tests/Unit/ListFilterTest.php @@ -130,7 +130,7 @@ function filterEditor(): Editor /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - expect($workspace->getMapByIndex(0)->getTileSymbol(2, 2))->toBe('//'); + expect($workspace->getMapByIndex(0)->getTileSymbol(2, 2))->toBe('/'); }); it('narrows a Database entry list with / and moves selection inside it', function () { diff --git a/tests/Unit/MapIsolationTest.php b/tests/Unit/MapIsolationTest.php index ec07a62..f479f1f 100644 --- a/tests/Unit/MapIsolationTest.php +++ b/tests/Unit/MapIsolationTest.php @@ -78,14 +78,13 @@ $directory . '/marker-map.map.php', $directory . '/marker-map.event.php', ['name' => 'Marker Map'], - // One trailing space completes each row's last two-column cell. - ['ICHILOTO_MAP; '], - ['ICHILOTO_EVENT_MAP; '], + ['ICHILOTO_MAP;'], + ['ICHILOTO_EVENT_MAP;'], ); $map->save(); - expect(MapGridSource::readFile($map->mapPath))->toBe('ICHILOTO_MAP; ') - ->and(MapGridSource::readFile($map->eventPath))->toBe('ICHILOTO_EVENT_MAP; ') - ->and(ProjectMap::fromDirectory($root . '/assets/Maps', $directory)->tileLines)->toBe(['ICHILOTO_MAP; ']); + expect(MapGridSource::readFile($map->mapPath))->toBe('ICHILOTO_MAP;') + ->and(MapGridSource::readFile($map->eventPath))->toBe('ICHILOTO_EVENT_MAP;') + ->and(ProjectMap::fromDirectory($root . '/assets/Maps', $directory)->tileLines)->toBe(['ICHILOTO_MAP;']); }); diff --git a/tests/Unit/MapValidatorTest.php b/tests/Unit/MapValidatorTest.php index 6971e0c..3bf89c1 100644 --- a/tests/Unit/MapValidatorTest.php +++ b/tests/Unit/MapValidatorTest.php @@ -8,7 +8,7 @@ /** * Builds an in-memory map with the given data and event rows. */ -function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ']): ProjectMap +function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ']): ProjectMap { return new ProjectMap( mapId: $mapId, @@ -17,7 +17,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' mapPath: "/virtual/Maps/{$mapId}/{$mapId}.map.php", eventPath: "/virtual/Maps/{$mapId}/{$mapId}.event.php", data: $data, - tileLines: ['........', '........'], + tileLines: ['....', '....'], eventLines: $eventLines, ); } @@ -28,13 +28,13 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' 'events' => [ 'E' => ['class' => 'SomeTrigger', 'data' => []], ], - ], ['EE ', ' ']); + ], ['E ', ' ']); expect(MapValidator::validate($map, ['town' => $map]))->toBe([]); }); it('warns about event markers without definitions', function () { - $map = validatorMap('town', ['name' => 'Town', 'events' => []], ['EE ', ' ']); + $map = validatorMap('town', ['name' => 'Town', 'events' => []], ['E ', ' ']); $warnings = MapValidator::validate($map, ['town' => $map]); @@ -49,7 +49,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' 'events' => [ 'E' => ['class' => 'SomeTrigger', 'data' => []], ], - ], [' EE ', 'EEEEEE ']); + ], [' E ', 'EEE ']); $warnings = MapValidator::validate($map, ['town' => $map]); @@ -64,7 +64,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' 'events' => [ 'T' => ['class' => 'Transfer', 'data' => ['destinationMap' => 'vanished-map']], ], - ], ['TT ', ' ']); + ], ['T ', ' ']); $warnings = MapValidator::validate($map, ['town' => $map]); @@ -86,7 +86,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ], ], ], - ], ['TT ', ' ']); + ], ['T ', ' ']); $warnings = MapValidator::validate($map, ['town' => $map, 'cave' => $destination]); @@ -102,7 +102,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' 'S' => ['class' => 'Spawn', 'data' => ['spawnPoint' => ['x' => 1, 'y' => 1]]], 'B' => ['class' => 'Spawn', 'data' => ['spawnPoint' => ['x' => 9, 'y' => 9]]], ], - ], ['SSBB ', ' ']); + ], ['SB ', ' ']); $warnings = MapValidator::validate($map, ['town' => $map]); @@ -117,9 +117,20 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' 'events' => [ 'T' => ['class' => 'Transfer', 'data' => ['destinationMap' => 'gone']], ], - ], ['TTXX ', ' ']); + ], ['TX ', ' ']); $warnings = MapValidator::validate($map, ['town' => $map]); expect($warnings)->toHaveCount(2); }); + +it('warns before a save that a tiles2d crop table is no longer read', function () { + $map = validatorMap('town', [ + 'name' => 'Town', + 'events' => [], + 'tiles2d' => ['asset' => 'Graphics/Tilesets/shared.png', 'symbols' => []], + ]); + + expect(MapValidator::validate($map, ['town' => $map])) + ->toBe(['tiles2d is no longer read; the map shows its terminal glyphs until it has a tileset.']); +}); diff --git a/tests/Unit/MixedMaterialAuthoringTest.php b/tests/Unit/MixedMaterialAuthoringTest.php index 32658c0..2334b51 100644 --- a/tests/Unit/MixedMaterialAuthoringTest.php +++ b/tests/Unit/MixedMaterialAuthoringTest.php @@ -15,12 +15,9 @@ function createMixedMaterialProject(): string $directory = $root . '/assets/Maps/test-map'; unlink($directory . '/test-map.map.php'); mkdir($directory . '/layers'); - // Ten cells across, two terminal columns each. - $blank = implode("\n", array_fill(0, 7, str_repeat(' ', 20))); - $wall = str_repeat('#', 20); - $room = '##' . str_repeat(' ', 16) . '##'; - $walls = implode("\n", [$wall, $room, $room, $wall, $room, $room, $wall]); - $fixtures = substr_replace($blank, 'ii', 8, 2); + $blank = implode("\n", array_fill(0, 7, str_repeat(' ', 10))); + $walls = "##########\n# #\n# #\n##########\n# #\n# #\n##########"; + $fixtures = substr_replace($blank, 'i', 4, 1); foreach ([ '01.terrain.map.php' => $blank, '02.floors.deco.php' => $blank, @@ -50,8 +47,8 @@ function paintMixedMaterial(Editor $editor, int $x, int $y, string $symbol): voi { callEditorMethod($editor, 'dispatchInput', "\033"); $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); - callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + $x * 2, $bounds['top'] + $y)); - callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + $x * 2, $bounds['top'] + $y)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + $x, $bounds['top'] + $y)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + $x, $bounds['top'] + $y)); callEditorMethod($editor, 'dispatchInput', 'i'); callEditorMethod($editor, 'dispatchInput', $symbol); callEditorMethod($editor, 'dispatchInput', "\033"); @@ -60,7 +57,7 @@ function paintMixedMaterial(Editor $editor, int $x, int $y, string $symbol): voi function getMixedMaterialCanvasRows(Editor $editor): array { $window = callEditorMethod($editor, 'createCanvasWindow'); - return array_map(static fn(string $line): string => substr(TerminalText::stripAnsi($line), 0, 20), + return array_map(static fn(string $line): string => substr(TerminalText::stripAnsi($line), 0, 10), array_slice($window->content, ProjectWorkspace::CANVAS_HEADER_ROWS, 7)); } @@ -99,9 +96,9 @@ function getMixedMaterialCanvasRows(Editor $editor): array $map->save(); expect(sourceHashTree($root . '/assets/Maps'))->toBe($saved); $reloaded = loadLayeredMap($root); - expect($reloaded->getLayerSymbol('map:2', 7, 1))->toBe('kk') - ->and($reloaded->getLayerSymbol('map:3', 2, 4))->toBe('cc') - ->and($reloaded->getLayerSymbol('map:5', 5, 3))->toBe('oo') + expect($reloaded->getLayerSymbol('map:2', 7, 1))->toBe('k') + ->and($reloaded->getLayerSymbol('map:3', 2, 4))->toBe('c') + ->and($reloaded->getLayerSymbol('map:5', 5, 3))->toBe('o') ->and($reloaded->renderPreview(10, 7))->toBe($terminal) ->and(MapCollisionResolver::resolveLayers($reloaded->getLayerSet(), $dictionary))->toBe($collision) ->and($reloaded->getEditableData()['npcs'][0]['dialogue'])->toBe([['text' => 'Read the notice.']]) @@ -117,9 +114,7 @@ function getMixedMaterialCanvasRows(Editor $editor): array $map->save(); $before = sourceHashTree($root . '/assets/Maps'); [$editor, $map] = layeredCanvasEditor($root); - $wall = str_repeat('#', 20); - $room = '##' . str_repeat(' ', 16) . '##'; - $terminal = ['########ii##########', $room, $room, $wall, $room, $room, $wall]; + $terminal = ['####i#####', '# #', '# #', '##########', '# #', '# #', '##########']; expect(getMixedMaterialCanvasRows($editor))->toBe($terminal); foreach (['map:4', 'map:6', 'event', 'map:1'] as $id) { callEditorMethod($editor, 'dispatchInput', ']'); @@ -135,7 +130,7 @@ function getMixedMaterialCanvasRows(Editor $editor): array paintMixedMaterial($editor, 2, 1, '#'); callEditorMethod($editor, 'dispatchInput', "\x13"); - expect($map->getLayerSymbol('map:1', 2, 1))->toBe('##') + expect($map->getLayerSymbol('map:1', 2, 1))->toBe('#') ->and(array_keys(array_diff_assoc(sourceHashTree($root . '/assets/Maps'), $before)))->toBe(['test-map/layers/01.terrain.map.php']); callEditorMethod($editor, 'dispatchInput', "\x1a"); callEditorMethod($editor, 'dispatchInput', "\x13"); @@ -145,13 +140,13 @@ function getMixedMaterialCanvasRows(Editor $editor): array callEditorMethod($editor, 'dispatchInput', "\x13"); callEditorMethod($editor, 'dispatchInput', "\x12"); $loaded = callEditorMethod($editor, 'getSelectedMap'); - expect($loaded->getLayerSymbol('map:1', 2, 1))->toBe('##') - ->and($loaded->getLayerSymbol('map:2', 1, 1))->toBe('ww') - ->and($loaded->getLayerSymbol('map:3', 2, 1))->toBe('rr') - ->and($loaded->getLayerSymbol('map:5', 1, 0))->toBe('oo'); + expect($loaded->getLayerSymbol('map:1', 2, 1))->toBe('#') + ->and($loaded->getLayerSymbol('map:2', 1, 1))->toBe('w') + ->and($loaded->getLayerSymbol('map:3', 2, 1))->toBe('r') + ->and($loaded->getLayerSymbol('map:5', 1, 0))->toBe('o'); callEditorMethod($editor, 'selectCanvasLayer', 'map:6'); callEditorMethod($editor, 'dispatchInput', 'v'); - expect(getMixedMaterialCanvasRows($editor)[0])->toBe($wall); + expect(getMixedMaterialCanvasRows($editor)[0])->toBe('##########'); callEditorMethod($editor, 'dispatchInput', 'v'); - expect(getMixedMaterialCanvasRows($editor)[0])->toBe('########ii##########'); + expect(getMixedMaterialCanvasRows($editor)[0])->toBe('####i#####'); }); diff --git a/tests/Unit/NpcAuthoringTest.php b/tests/Unit/NpcAuthoringTest.php index 4a26c16..e1d2ec1 100644 --- a/tests/Unit/NpcAuthoringTest.php +++ b/tests/Unit/NpcAuthoringTest.php @@ -343,7 +343,7 @@ function npcIssueLines(string $root, ?Severity $severity = null): array // The author lands in the Inspector to keep authoring. ->and(getEditorProperty($editor, 'focusedPane'))->toBe('inspector') // Nothing was painted: tile and event layers are untouched. - ->and($map->getTileSymbol(4, 2))->toBe(' ') + ->and($map->getTileSymbol(4, 2))->toBe(' ') ->and(trim($map->getEventSymbol(4, 2)))->toBe(''); // Enter with an empty name falls back to a placeholder rather than @@ -391,7 +391,7 @@ function npcIssueLines(string $root, ?Severity $severity = null): array ->and(getEditorProperty($editor, 'cursorX'))->toBe(2); }); -it('draws NPCs as an overlay in their cells: wide glyphs fill it, styled ones take its first column, the selected one reversed', function () { +it('draws NPCs as an overlay: wide glyphs own two columns, styled ones one, the selected one bracketed', function () { [$root] = npcProject([ ['id' => 'cat', 'name' => 'Cat', 'sprite' => '🐈', 'x' => 2, 'y' => 2], ['id' => 'mum', 'name' => 'Mum', 'sprite' => '@', 'x' => 6, 'y' => 2], @@ -403,18 +403,18 @@ function npcIssueLines(string $root, ?Severity $severity = null): array $overlay = $map->renderPreview(12, 5, showNpcOverlay: true, selectedNpcIndex: 2); // Not persisted art: without the overlay flag the rows are the tiles. - expect($plain[2])->toBe('## ##') - // The cat fills its two-column cell; Mum is one plain @ in the - // first column of hers; the selected guard is reversed within its - // cell, so neighbouring cells keep their columns. - ->and($overlay[2])->toBe('## 🐈 @ ##') - ->and($overlay[3])->toBe('##' . str_repeat(' ', 16) . "\033[7mG \033[0m" . ' ##') - ->and(mb_strwidth($overlay[2]))->toBe(24); + expect($plain[2])->toBe('# #') + // The cat's second column is the empty overhang cell, so the + // terminal draws the emoji in the room it needs; Mum is one plain + // @; the selected guard is bracketed. + ->and($overlay[2])->toBe('# 🐈 @ #') + ->and($overlay[3])->toBe('# [G]#') + ->and(mb_strwidth($overlay[2]))->toBe(12); // A directional sprite previewed for the selected NPC. $previewed = $map->renderPreview(12, 5, showNpcOverlay: true, selectedNpcIndex: 2, selectedNpcSprite: '^'); - // Still the selected NPC, so still reversed. - expect($previewed[3])->toBe('##' . str_repeat(' ', 16) . "\033[7m^ \033[0m" . ' ##'); + // Still the selected NPC, so still bracketed. + expect($previewed[3])->toBe('# [^]#'); }); it('leaves map-owned NPC glyphs untouched and highlights only their selected anchor', function (bool $layered, string $prefix, string $suffix) { @@ -448,11 +448,11 @@ function npcIssueLines(string $root, ?Severity $severity = null): array $allHidden = array_fill_keys(array_column($map->getLayers(), 'id'), false); $anchor = $map->renderPreview(4, 2, showNpcOverlay: true, selectedNpcIndex: 0, layerVisibility: $allHidden); - expect($anchor[0])->toContain("\033[7m \033[0m") + expect($anchor[0])->toContain("\033[7m \033[0m") ->and($anchor[0])->not->toContain('@'); $directional = $map->renderPreview(4, 2, showNpcOverlay: true, selectedNpcIndex: 0, selectedNpcSprite: '^'); - expect($directional[0])->toContain("\033[7m^ \033[0m") + expect($directional[0])->toContain('[^]') ->and($map->captureLayerSnapshot())->toBe($before) ->and(npcHashTree($root))->toBe($files); })->with([ @@ -965,8 +965,7 @@ public function __construct() ['id' => 'mixed', 'name' => 'Mixed', 'x' => 5, 'y' => 3, 'dialogue' => [['conditions' => [], 'lines' => [['text' => 'a']]], ['text' => 'plain']]], ['id' => 'empty', 'name' => 'Empty', 'x' => 6, 'y' => 3, 'sprite' => '', 'dialogue' => [['name' => 'x']]], ['id' => 'keys', 'name' => 'Keys', 'x' => 7, 'y' => 3, 'sprites' => ['up' => '^', 'north' => ['x']]], - ['id' => 'wide', 'name' => 'Wide', 'x' => 11, 'y' => 3, 'sprite' => 'cat'], - ['id' => 'emoji', 'name' => 'Emoji', 'x' => 10, 'y' => 3, 'sprite' => '🐈'], + ['id' => 'wide', 'name' => 'Wide', 'x' => 11, 'y' => 3, 'sprite' => '🐈'], ['id' => 'on-event', 'name' => 'Blocker', 'x' => 5, 'y' => 1], ['name' => 'Legacy', 'x' => 8, 'y' => 1], ]); @@ -1002,8 +1001,7 @@ public function __construct() ->toContain('Its sprite draws nothing') ->toContain('Dialogue page 1 has no text') ->toContain('Its directional sprite key "up" is not a heading') - ->toContain('NPC Wide: Its 3-column sprite overhangs its cell') - ->not->toContain('NPC Emoji:') + ->toContain('Its 2-column sprite overhangs the right edge of the map') ->toContain('NPC Legacy: It has no stable id, so a move_route cannot target it'); // Unbounded wander is legal and says nothing. diff --git a/tests/Unit/ProjectFormatTest.php b/tests/Unit/ProjectFormatTest.php new file mode 100644 index 0000000..39c0da7 --- /dev/null +++ b/tests/Unit/ProjectFormatTest.php @@ -0,0 +1,44 @@ +toBe(ProjectFormat::CURRENT) + ->and(ProjectWorkspace::fromProject($root)->maps)->not->toBe([]); +}); + +it('refuses to open a project in another format with the Engine explanation', function (mixed $format) { + $root = makeTemporaryProject('ichiloto-format-'); + $config = json_decode((string) file_get_contents($root . '/ichiloto.json'), true); + if ($format === null) { + unset($config[ProjectFormat::KEY]); + } else { + $config[ProjectFormat::KEY] = $format; + } + file_put_contents($root . '/ichiloto.json', json_encode($config)); + try { + ProjectFormat::assertSupported($format); + $expected = null; + } catch (UnsupportedProjectFormatException $refusal) { + $expected = $refusal->getMessage(); + } + + expect($expected)->not->toBeNull() + ->and(fn() => ProjectWorkspace::fromProject($root))->toThrow(UnsupportedProjectFormatException::class, $expected); +})->with([ + 'unrecorded' => [null], + 'older' => [ProjectFormat::CURRENT - 1], + 'newer' => [ProjectFormat::CURRENT + 1], +]); diff --git a/tests/Unit/ProjectMapTest.php b/tests/Unit/ProjectMapTest.php index f062077..bf93054 100644 --- a/tests/Unit/ProjectMapTest.php +++ b/tests/Unit/ProjectMapTest.php @@ -3,7 +3,16 @@ declare(strict_types=1); use Ichiloto\Editor\ProjectMap; -use Ichiloto\Engine\IO\Console\TerminalText; + +/** + * Loads the fixture map fresh from disk. + */ +function fixtureMap(): ProjectMap +{ + $mapsRoot = fixturePath('sample-project/assets/Maps'); + + return ProjectMap::fromDirectory($mapsRoot, $mapsRoot . '/test-map'); +} it('parses the fixture map dimensions and styled tiles', function () { $map = fixtureMap(); @@ -11,8 +20,8 @@ expect($map->getWidth())->toBe(12) ->and($map->getHeight())->toBe(5) ->and($map->getDisplayName())->toBe('Test Map') - ->and($map->getTileSymbol(0, 0))->toBe('##') - ->and($map->getTileSymbol(3, 1))->toBe('~~') + ->and($map->getTileSymbol(0, 0))->toBe('#') + ->and($map->getTileSymbol(3, 1))->toBe('~') ->and($map->isDirty())->toBeFalse(); }); @@ -32,7 +41,7 @@ $map->setTileSymbol(1, 1, '@'); expect($map->isDirty())->toBeTrue() - ->and($map->getTileSymbol(1, 1))->toBe('@@'); + ->and($map->getTileSymbol(1, 1))->toBe('@'); $map = fixtureMap(); $map->setEventSymbol(2, 2, 'T'); @@ -43,13 +52,12 @@ it('renders a preview with the event overlay merged over tiles', function () { $map = fixtureMap(); - $withOverlay = array_map(TerminalText::stripAnsi(...), $map->renderPreview(12, 5)); - $withoutOverlay = array_map(TerminalText::stripAnsi(...), $map->renderPreview(12, 5, showEventOverlay: false)); + $withOverlay = $map->renderPreview(12, 5); + $withoutOverlay = $map->renderPreview(12, 5, showEventOverlay: false); - // Every cell is two terminal columns of the preview. - expect($withOverlay[0])->toBe('########################') - ->and($withOverlay[1])->toBe('## ~~~~EE ##') - ->and($withoutOverlay[1])->toBe('## ~~~~~~ ##'); + expect($withOverlay[0])->toBe('############') + ->and($withOverlay[1])->toBe('# ~~E #') + ->and($withoutOverlay[1])->toBe('# ~~~ #'); }); it('renders an offset preview window', function () { @@ -57,7 +65,7 @@ $lines = $map->renderPreview(4, 2, offsetX: 3, offsetY: 1); expect($lines)->toHaveCount(2) - ->and(TerminalText::stripAnsi($lines[0]))->toBe('~~~~EE'); + ->and($lines[0])->toBe('~~E'); }); it('resizes the grid preserving existing content', function () { @@ -66,14 +74,14 @@ expect($map->getWidth())->toBe(14) ->and($map->getHeight())->toBe(6) - ->and($map->getTileSymbol(0, 0))->toBe('##') - ->and($map->getTileSymbol(13, 5))->toBe(' '); + ->and($map->getTileSymbol(0, 0))->toBe('#') + ->and($map->getTileSymbol(13, 5))->toBe(' '); $map->resize(6, 3); expect($map->getWidth())->toBe(6) ->and($map->getHeight())->toBe(3) - ->and($map->getTileSymbol(3, 1))->toBe('~~'); + ->and($map->getTileSymbol(3, 1))->toBe('~'); }); it('restores a captured grid snapshot', function () { @@ -86,7 +94,7 @@ expect($map->getWidth())->toBe(12) ->and($map->getHeight())->toBe(5) - ->and($map->getTileSymbol(1, 1))->toBe(' ') + ->and($map->getTileSymbol(1, 1))->toBe(' ') // The snapshot restored the exact loaded content, and dirty is a fact // about content now: back to the baseline is back to pristine. ->and($map->isDirty())->toBeFalse(); @@ -148,8 +156,8 @@ $rawMap = (string) file_get_contents($root . '/assets/Maps/test-map/test-map.map.php'); // The styled water run survives the round trip and the edit lands. - expect($rawMap)->toContain('~~~~~~') - ->and(ProjectMap::fromDirectory($root . '/assets/Maps', $root . '/assets/Maps/test-map')->getTileSymbol(1, 2))->toBe('@@'); + expect($rawMap)->toContain('~~~') + ->and(ProjectMap::fromDirectory($root . '/assets/Maps', $root . '/assets/Maps/test-map')->getTileSymbol(1, 2))->toBe('@'); } finally { removeScratchTree($root); } @@ -238,11 +246,11 @@ function styledScratchMap(string $root, array $rows): array try { [, $map] = styledScratchMap($root, [ - '########################', - '########## ##', - '## ##', - '## ##', - '########################', + '############', + '##### #', + '# #', + '# #', + '############', ]); foreach ([1, 2, 3, 4] as $x) { @@ -250,7 +258,7 @@ function styledScratchMap(string $root, array $rows): array } expect($map->getTileColor(5, 1))->toBeNull() - ->and(substr_count($map->renderPreview(12, 5)[1], "\033[90m##\033[0m"))->toBe(4); + ->and(substr_count($map->renderPreview(12, 5)[1], "\033[90m#\033[0m"))->toBe(4); } finally { removeScratchTree($root); } @@ -261,11 +269,11 @@ function styledScratchMap(string $root, array $rows): array try { $rows = [ - '########################', - '########## ##', - '## ~~~~ ## ##', - '## ##', - '########################', + '############', + '##### #', + '# ~~ # #', + '# #', + '############', ]; [$mapFile, $map] = styledScratchMap($root, $rows); @@ -276,7 +284,7 @@ function styledScratchMap(string $root, array $rows): array $saved = explode("\n", (string) file_get_contents($mapFile)); - expect($saved[4])->toBe('######## ##') + expect($saved[4])->toBe('#### #') ->and($saved[5])->toBe($rows[2]) ->and($saved[3])->toBe($rows[0]); @@ -296,11 +304,11 @@ function styledScratchMap(string $root, array $rows): array try { $rows = [ - '########################', - '########## ##', - '## ##', - '## ##', - '########################', + '############', + '##### #', + '# #', + '# #', + '############', ]; [$mapFile, $map] = styledScratchMap($root, $rows); $original = (string) file_get_contents($mapFile); @@ -314,8 +322,8 @@ function styledScratchMap(string $root, array $rows): array $saved = explode("\n", (string) file_get_contents($mapFile)); expect($saved[4])->toBe($rows[1]) - ->and($saved[6])->toBe('## xx ##') - ->and(str_replace('## xx ##', $rows[3], (string) file_get_contents($mapFile)))->toBe($original); + ->and($saved[6])->toBe('# x #') + ->and(str_replace('# x #', '# #', (string) file_get_contents($mapFile)))->toBe($original); } finally { removeScratchTree($root); } diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index bac8145..5919de6 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -395,7 +395,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $source = file_get_contents($path); // Drop a stray marker onto the layer, the way a mis-click would. - file_put_contents($path, preg_replace('/\n( +)\n/', "\nZZ\n", $source, 1)); + file_put_contents($path, preg_replace('/\n( +)\n/', "\nZ\n", $source, 1)); $issues = issuesMentioning(validateProject($root), 'places marker "Z"'); @@ -724,3 +724,18 @@ function writeValidatorActor(string $root, string $fileId, string $name, string ->and(issuesMentioning($issues, 'impossible order'))->toHaveCount(2) ->and(issuesMentioning($issues, 'Content migration step 2 to 3 is missing'))->toHaveCount(1); }); + +it('warns that a tiles2d crop table is no longer read', function () { + $root = makeTemporaryProject(); + editTestMapData($root, static fn(string $source): string => str_replace( + "'triggers' => [],", + "'triggers' => [],\n 'tiles2d' => ['asset' => 'Graphics/Tilesets/shared.png', 'symbols' => []],", + $source, + )); + + $issues = issuesMentioning(validateProject($root), 'tiles2d'); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::WARNING) + ->and($issues[0]->message)->toBe('Its tiles2d crop table is no longer read.'); +}); diff --git a/tests/Unit/TwoColumnCellsTest.php b/tests/Unit/TwoColumnCellsTest.php deleted file mode 100644 index a041197..0000000 --- a/tests/Unit/TwoColumnCellsTest.php +++ /dev/null @@ -1,324 +0,0 @@ - 120, 'height' => 40]); - setEditorProperty($editor, 'isRunning', true); - setEditorProperty($editor, 'focusedPane', 'canvas'); - - return [$editor, $workspace->getMapByIndex(0), $root]; -} - -/** Writes the sample map's tile rows, a project copy's only map. */ -function writeCellMapRows(string $root, array $rows): string -{ - $path = $root . '/assets/Maps/test-map/test-map.map.php'; - file_put_contents($path, MapGridSource::buildSource(implode("\n", $rows), 'ICHILOTO_MAP')); - - return $path; -} - -/** Replaces the sample map's data entries. */ -function writeCellMapData(string $root, array $entries): void -{ - $path = $root . '/assets/Maps/test-map/test-map.data.php'; - $data = array_replace(require $path, $entries); - file_put_contents($path, "getTileSymbol(1, 2))->toBe('##') - ->and($map->getTileSymbol(2, 2))->toBe('🌲') - ->and($map->getTileSymbol(3, 2))->toBe(' '); -}); - -it('turns a quick second keystroke into a pair that undoes as one step, and starts over on the third', function () { - [$editor, $map] = createCellEditor(); - setEditorProperty($editor, 'cursorX', 1); - setEditorProperty($editor, 'cursorY', 2); - /** @var CommandHistory $history */ - $history = getEditorProperty($editor, 'history'); - - typeCellKeys($editor, 'i', '['); - expect($map->getTileSymbol(1, 2))->toBe('[['); - typeCellKeys($editor, ']'); - expect($map->getTileSymbol(1, 2))->toBe('[]') - ->and($history->count())->toBe(1) - ->and(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('[]'); - - typeCellKeys($editor, 'x'); - expect($map->getTileSymbol(1, 2))->toBe('xx'); - typeCellKeys($editor, 'y'); - expect($map->getTileSymbol(1, 2))->toBe('xy') - ->and($history->count())->toBe(2); - - typeCellKeys($editor, "\x1a"); - expect($map->getTileSymbol(1, 2))->toBe('[]'); - typeCellKeys($editor, "\x1a"); - expect($map->getTileSymbol(1, 2))->toBe(' ') - ->and($map->isDirty())->toBeFalse(); -}); - -it('forgets the first keystroke once the cursor moves', function () { - [$editor, $map] = createCellEditor(); - setEditorProperty($editor, 'cursorX', 1); - setEditorProperty($editor, 'cursorY', 2); - - typeCellKeys($editor, 'i', '[', "\033[C", "\033[D", ']'); - - expect($map->getTileSymbol(1, 2))->toBe(']]'); - - // A key aimed at another cell starts that cell over too. - typeCellKeys($editor, "\033[C", "\033[D", '<'); - setEditorProperty($editor, 'cursorX', 2); - typeCellKeys($editor, '>'); - - expect($map->getTileSymbol(1, 2))->toBe('<<') - ->and($map->getTileSymbol(2, 2))->toBe('>>'); -}); - -it('fills a cell with the brush colour and keeps each character of a styled pair under the default brush', function () { - $root = makeTemporaryProject('ichiloto-cells-'); - $path = writeCellMapRows($root, [ - str_repeat('#', 24), - '##[]' . str_repeat(' ', 16) . '##', - '##' . str_repeat(' ', 20) . '##', - '##' . str_repeat(' ', 20) . '##', - str_repeat('#', 24), - ]); - $original = (string) file_get_contents($path); - [$editor, $map] = createCellEditor($root); - setEditorProperty($editor, 'cursorX', 1); - setEditorProperty($editor, 'cursorY', 1); - $pairStyle = $map->getTileCellStyle(1, 1); - - expect($map->getTileSymbol(1, 1))->toBe('[]') - ->and($pairStyle['styles'])->toBe([ - ['prefix' => '', 'suffix' => ''], ['prefix' => '', 'suffix' => ''], - ]); - - typeCellKeys($editor, 'i', '#', "\033"); - expect($map->getTileSymbol(1, 1))->toBe('##') - ->and($map->getTileCellStyle(1, 1))->toBe($pairStyle); - - typeCellKeys($editor, "\x13"); - expect((string) file_get_contents($path))->toContain('####' . str_repeat(' ', 16) . '##'); - - typeCellKeys($editor, "\x1a", "\x13"); - expect((string) file_get_contents($path))->toBe($original) - ->and($map->getTileSymbol(1, 1))->toBe('[]'); - - setEditorProperty($editor, 'selectedPaintColor', 'green'); - typeCellKeys($editor, 'i', '#'); - expect($map->getTileCellStyle(1, 1))->toBe(['prefix' => '', 'suffix' => '']); -}); - -it('repeats one marker per event cell and never pairs markers', function () { - [$editor, $map] = createCellEditor(); - setEditorProperty($editor, 'cursorX', 2); - setEditorProperty($editor, 'cursorY', 2); - - typeCellKeys($editor, 'e', 'i', 'A'); - expect($map->getEventSymbol(2, 2))->toBe('AA'); - typeCellKeys($editor, 'B'); - - expect($map->getEventSymbol(2, 2))->toBe('BB') - ->and($map->getEventMarkerAt(2, 2))->toBe('B'); - - // A pair carried over from the tile layer paints its first marker. - setEditorProperty($editor, 'selectedPaintSymbol', '[]'); - typeCellKeys($editor, "\r"); - expect($map->getEventSymbol(2, 2))->toBe('[[') - ->and($map->describeConflictingEventCells())->toBe([]); -}); - -it('offers whole cells in the character map and enters a typed pair', function () { - $root = makeTemporaryProject('ichiloto-cells-'); - file_put_contents($root . '/assets/Maps/collisions.php', " 0, 'z' => 1];\n"); - [$editor, $map] = createCellEditor($root); - setEditorProperty($editor, 'cursorX', 3); - setEditorProperty($editor, 'cursorY', 2); - - $palette = callEditorMethod($editor, 'getCharacterPalette'); - expect($palette)->toContain('##', '~~', ' ', '==', 'zz', '??', '@@', '██', '🧍') - ->and(array_filter($palette, static fn(string $cell): bool => ! CellSymbol::isCell($cell)))->toBe([]); - - typeCellKeys($editor, 'c', '<', '>'); - expect(getEditorProperty($editor, 'characterMapCell'))->toBe('<>'); - typeCellKeys($editor, "\r"); - - expect($map->getTileSymbol(3, 2))->toBe('<>') - ->and(getEditorProperty($editor, 'isCharacterMapOpen'))->toBeFalse() - ->and(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('<>'); - - // A third key starts the typed cell over; arrows return to the palette. - typeCellKeys($editor, 'c', 'a', 'b', 'c'); - expect(getEditorProperty($editor, 'characterMapCell'))->toBe('cc'); - typeCellKeys($editor, "\033[C"); - expect(getEditorProperty($editor, 'characterMapCell'))->toBe(''); -}); - -it('maps the cursor, clicks and the wheel to two terminal columns per cell', function () { - [$editor, $map] = createCellEditor(); - $map->resize(100, 50); - $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); - expect($bounds['cells'])->toBe(intdiv($bounds['width'], 2)); - - setEditorProperty($editor, 'canvasOffsetX', 3); - foreach ([0, 1] as $column) { - setEditorProperty($editor, 'cursorX', 0); - typeCellKeys($editor, sprintf("\033[<0;%d;%dM", $bounds['left'] + 8 + $column, $bounds['top'] + 1)); - typeCellKeys($editor, sprintf("\033[<0;%d;%dm", $bounds['left'] + 8 + $column, $bounds['top'] + 1)); - expect(getEditorProperty($editor, 'cursorX'))->toBe(7, "column {$column} of the cell") - ->and(getEditorProperty($editor, 'cursorY'))->toBe(1); - } - - // A column past the last whole cell selects nothing. - typeCellKeys($editor, sprintf("\033[<0;%d;%dM", $bounds['left'] + $bounds['cells'] * 2, $bounds['top'] + 1)); - expect(getEditorProperty($editor, 'cursorX'))->toBe(7); - - $layout = callEditorMethod($editor, 'resolveLayout'); - ob_start(); - callEditorMethod($editor, 'renderCanvasCursor', $layout); - $output = (string) ob_get_clean(); - expect($output)->toContain(sprintf("\033[%d;%dH", $bounds['top'] + 1, $bounds['left'] + 8)); - - // The last cell scrolls into view; the wheel stops where it is last visible. - setEditorProperty($editor, 'cursorX', 99); - callEditorMethod($editor, 'syncViewportToCursor'); - expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(100 - $bounds['cells']); - setEditorProperty($editor, 'canvasOffsetX', 100 - $bounds['cells'] - 1); - typeCellKeys($editor, sprintf("\033[<67;%d;%dM", $bounds['left'], $bounds['top'])); - expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(100 - $bounds['cells']); -}); - -it('writes event markers repeated across each cell and reads a marker in either column', function () { - [, $map] = createCellEditor(); - $map->setEventBounds('E', 2, 3, 2, 1); - - expect($map->getEventSymbol(2, 3))->toBe('EE') - ->and($map->getEventSymbol(3, 3))->toBe('EE') - ->and($map->getEventSymbol(5, 1))->toBe(' ') - ->and($map->getEventBounds('E'))->toBe(['x' => 2, 'y' => 3, 'width' => 2, 'height' => 1]); - - $map->setEventSymbol(4, 3, ' E'); - expect($map->getEventMarkerAt(4, 3))->toBe('E') - ->and($map->isEventMarkerSolidRectangle('E'))->toBeTrue(); - - $map->setEventSymbol(0, 0, 'EF'); - expect($map->getEventMarkerAt(0, 0))->toBeNull() - ->and($map->getPlacedEventMarkers())->toBe(['E']) - ->and($map->describeConflictingEventCells())->toBe(['Event cell at row 0, column 0 holds two different markers, E and F.']) - ->and(MapValidator::validate($map, [$map->mapId => $map]))->toContain('Event cell at row 0, column 0 holds two different markers, E and F.'); -}); - -it('overhangs a sprite wider than its cell and keeps the row in whole cells', function () { - $root = makeTemporaryProject('ichiloto-cells-'); - writeCellMapData($root, ['npcs' => [ - ['id' => 'wide', 'name' => 'Wide', 'sprite' => 'abc', 'x' => 2, 'y' => 2], - ['id' => 'tree', 'name' => 'Tree', 'sprite' => '🌲', 'x' => 6, 'y' => 2], - ]]); - [$editor, $map] = createCellEditor($root); - - $row = $map->renderPreview(12, 5, showNpcOverlay: true, selectedNpcIndex: 0)[2]; - - expect(TerminalText::stripAnsi($row))->toBe('## abc 🌲 ##') - ->and($row)->toContain("\033[7mabc \033[0m") - ->and(mb_strwidth(TerminalText::stripAnsi($row)))->toBe(24); - - // The view starting inside the overhang keeps its columns. - expect(TerminalText::stripAnsi($map->renderPreview(4, 5, offsetX: 3, showNpcOverlay: true)[2]))->toBe(' 🌲'); - - // A duplicate stands in the next cell, whatever its sprite's width. - callEditorMethod($editor, 'setEditingMode', 'npc'); - callEditorMethod($editor, 'selectNpc', 1); - typeCellKeys($editor, 'D'); - expect($map->getNpcs()->get(2)?->getX())->toBe(7); -}); - -it('refuses misaligned rows with the Engine row and column, in the editor and validation', function (string $row, string $message) { - $root = makeTemporaryProject('ichiloto-cells-'); - writeCellMapRows($root, [str_repeat('#', 24), $row, str_repeat('#', 24)]); - $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); - - expect($map->getGridSourceIssue())->toContain($message); - - $issues = issuesMentioning(validateProject($root), $message); - expect($issues)->toHaveCount(1) - ->and($issues[0]->severity)->toBe(Severity::ERROR); -})->with([ - 'glyph after a lone character' => ['#🌲' . str_repeat(' ', 20) . '#', 'row 1, column 1: a two-column glyph must begin a cell'], - 'lone trailing character' => [str_repeat('#', 23), 'row 1 ends halfway through a cell'], -]); - -it('refuses to open a project in another format with the Engine explanation', function (mixed $format) { - $root = makeTemporaryProject('ichiloto-cells-'); - $config = json_decode((string) file_get_contents($root . '/ichiloto.json'), true); - if ($format === null) { - unset($config[ProjectFormat::KEY]); - } else { - $config[ProjectFormat::KEY] = $format; - } - file_put_contents($root . '/ichiloto.json', json_encode($config)); - try { - ProjectFormat::assertSupported($format); - $expected = null; - } catch (UnsupportedProjectFormatException $refusal) { - $expected = $refusal->getMessage(); - } - - expect($expected)->not->toBeNull() - ->and(fn() => ProjectWorkspace::fromProject($root))->toThrow(UnsupportedProjectFormatException::class, $expected); -})->with([ - 'unrecorded' => [null], - 'older' => [ProjectFormat::CURRENT - 1], - 'newer' => [ProjectFormat::CURRENT + 1], -]); - -it('warns that a tiles2d crop table is no longer read', function () { - $root = makeTemporaryProject('ichiloto-cells-'); - writeCellMapData($root, ['tiles2d' => ['asset' => 'Graphics/Tilesets/shared.png', 'symbols' => []]]); - - $issues = issuesMentioning(validateProject($root), 'tiles2d'); - $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); - - expect($issues)->toHaveCount(1) - ->and($issues[0]->severity)->toBe(Severity::WARNING) - ->and($issues[0]->message)->toBe('Its tiles2d crop table is no longer read.') - ->and(MapValidator::validate($map, [$map->mapId => $map]))->toContain('tiles2d is no longer read; the map shows its terminal glyphs until it has a tileset.'); -}); diff --git a/tests/__snapshots__/main_shell_100x30.ansi b/tests/__snapshots__/main_shell_100x30.ansi index 78e95ce..acff787 100644 --- a/tests/__snapshots__/main_shell_100x30.ansi +++ b/tests/__snapshots__/main_shell_100x30.ansi @@ -1 +1 @@ -[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ######################## ││ ## ~~~~~~ ## ││ ## ## ││ ## ## ││ ######################## ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint []:Layer ?:Help────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > terrain · visible ││ Events · visible ││ ││ ││ │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file +[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint []:Layer ?:Help────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > terrain · visible ││ Events · visible ││ ││ ││ │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file From 2f3a5fd8ad8afd4b73fc21235184ea4d19022e22 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 27 Sep 2026 21:54:37 +0200 Subject: [PATCH 053/344] fix(maps): keep named tile halves when a resize rewrites a tile layer Tile layers may name a plain tile's half (42L). A resize rewrote rows from bare tile identities and would drop the suffix; it now crops and pads each cell's authored entry. --- src/Maps/TileLayerSource.php | 15 +++++++++------ tests/Unit/MapGraphicsTest.php | 12 ++++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/Maps/TileLayerSource.php b/src/Maps/TileLayerSource.php index 47506b8..30966a3 100644 --- a/src/Maps/TileLayerSource.php +++ b/src/Maps/TileLayerSource.php @@ -56,7 +56,8 @@ public static function readLayer(string $source, string $displayPath): MapTileLa /** * Resizes a tile layer with its map: cells and rows beyond the new size - * are cropped and new cells are empty (`0`). A layer that reads back as + * are cropped and new cells are empty (`0`); kept cells keep their entry, + * including a named tile half (`42L`). A layer that reads back as * its baseline keeps the baseline's bytes; otherwise it is rewritten as a * canonical literal nowdoc, keeping its leading comment and marker. * @@ -75,20 +76,22 @@ public static function resize(string $source, string $displayPath, MapLayerSet $ ), previous: $error); } - $rows = array_slice($layer->tiles, 0, $height); + $empty = (string)TileId::EMPTY; + $entries = $layer->getEntries(); + $rows = array_slice($entries, 0, $height); foreach ($rows as &$row) { - $row = array_pad(array_slice($row, 0, $width), $width, TileId::EMPTY); + $row = array_pad(array_slice($row, 0, $width), $width, $empty); } unset($row); - $rows = array_pad($rows, $height, array_fill(0, $width, TileId::EMPTY)); + $rows = array_pad($rows, $height, array_fill(0, $width, $empty)); - if ($rows === $layer->tiles) { + if ($rows === $entries) { return $source; } if ($baseline !== null) { try { - if (self::readLayer($baseline, $displayPath)->tiles === $rows) { + if (self::readLayer($baseline, $displayPath)->getEntries() === $rows) { return $baseline; } } catch (InvalidArgumentException) { diff --git a/tests/Unit/MapGraphicsTest.php b/tests/Unit/MapGraphicsTest.php index 5a824ae..4d2da0f 100644 --- a/tests/Unit/MapGraphicsTest.php +++ b/tests/Unit/MapGraphicsTest.php @@ -56,6 +56,18 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array ->and(array_map(static fn($layer): array => $layer->tiles, $graphics?->layers ?? []))->toBe([[[2816, 2816, 2816]], [[0, 5, 0]]]); }); +it('keeps named tile halves when a resize rewrites a tile layer', function () { + $root = mapGraphicsProject(); + $map = loadLayeredMap($root); + $furniture = writeTileLayer($map->directory, '03.furniture.tiles.php', "0 5L 5R 0\n5 0 0 0"); + $map = loadLayeredMap($root); + + $map->resize(5, 1); + $map->save(); + + expect(file_get_contents($furniture))->toContain("0 5L 5R 0 0\nTILES;"); +}); + it('resizes a ragged map\'s tile rows to the widths its terminal rows take', function () { $root = mapGraphicsProject(ragged: true); $map = loadLayeredMap($root); From d94a5cabf1941ee50236d88b78ee9a245efa7dad Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 27 Sep 2026 22:44:52 +0200 Subject: [PATCH 054/344] feat(maps): validate tile layer offsets in the map data Map data may shift a tile layer by half a field cell ('tileLayers' => ['lounge' => ['offset' => [0, -0.5]]]). Graphics validation reports settings the Engine would refuse, such as an unknown layer name or an offset other than -0.5, 0 or 0.5, with the Engine's own message. --- src/Validation/MapGraphicsValidator.php | 27 +++++++++++++++++++++++++ tests/Unit/MapGraphicsTest.php | 12 +++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php index 0d75d8d..330a3aa 100644 --- a/src/Validation/MapGraphicsValidator.php +++ b/src/Validation/MapGraphicsValidator.php @@ -48,6 +48,7 @@ public static function validate(ProjectMap $map): array $issues = []; $tileset = self::loadTileset($map, $tilesetId, $issues); $layers = self::readLayers($map, $sources, $issues); + self::checkLayerOffsets($map, $sources, $issues); if ($tileset !== null) { $issues = [ @@ -60,6 +61,32 @@ public static function validate(ProjectMap $map): array return $issues; } + /** + * Checks the map data's tile layer settings as the Engine reads them. + * + * @param array $sources + * @param list $issues + */ + private static function checkLayerOffsets(ProjectMap $map, array $sources, array &$issues): void + { + $names = []; + foreach (array_keys($sources) as $path) { + if (preg_match(MapGraphics::FILENAME_PATTERN, basename($path), $matches) === 1) { + $names[] = $matches['name']; + } + } + + try { + MapGraphics::readLayerOffsets($map->getMapDataField([MapGraphics::SETTINGS_KEY]), $names, $map->mapId); + } catch (InvalidArgumentException $error) { + $issues[] = Issue::error( + $map->mapId, + $error->getMessage(), + sprintf("Name a tile layer in graphics/ and give it 'offset' => [across, down]. %s", self::GLYPH_FALLBACK), + ); + } + } + /** @param list $issues */ private static function loadTileset(ProjectMap $map, mixed $tilesetId, array &$issues): ?Tileset { diff --git a/tests/Unit/MapGraphicsTest.php b/tests/Unit/MapGraphicsTest.php index 4d2da0f..51f9d96 100644 --- a/tests/Unit/MapGraphicsTest.php +++ b/tests/Unit/MapGraphicsTest.php @@ -56,6 +56,18 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array ->and(array_map(static fn($layer): array => $layer->tiles, $graphics?->layers ?? []))->toBe([[[2816, 2816, 2816]], [[0, 5, 0]]]); }); +it('validates tile layer offsets in the map data as the Engine reads them', function () { + $root = mapGraphicsProject(); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + $source = (string) file_get_contents($data); + + file_put_contents($data, str_replace("'tileset' => 'home',", "'tileset' => 'home', 'tileLayers' => ['decor' => ['offset' => [0, -0.5]]],", $source)); + expect(graphicsIssueLines($root, Severity::ERROR))->toBe([]); + + file_put_contents($data, str_replace("'tileset' => 'home',", "'tileset' => 'home', 'tileLayers' => ['rugs' => ['offset' => [0, 1]]],", $source)); + expect(graphicsIssueLines($root, Severity::ERROR))->toBe(["Map test-map tileLayers names 'rugs', which is not one of its tile layers."]); +}); + it('keeps named tile halves when a resize rewrites a tile layer', function () { $root = mapGraphicsProject(); $map = loadLayeredMap($root); From 25ad5186ec69a23c261933e6ab81b211c2bf171f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 28 Sep 2026 03:53:32 +0200 Subject: [PATCH 055/344] feat(canvas): choose the layer to edit from its own picker In Normal mode on the canvas, L opens a layer picker: the map's gameplay layers and Events, each marked visible or hidden and the one being edited, filterable by name, with arrows or j/k and Enter to edit the highlighted layer and Esc to keep the current one. It uses the shared option picker. [ and ] still step through layers, and L paints like any glyph in Paint mode. Removed from the command palette: the per-layer "Layer:" and "Visibility:" entries, replaced by one "Layers: Choose the layer to edit" entry; layer visibility stays on v. The canvas hint shows L:Layer instead of []:Layer and Ctrl+P:Layers. --- docs/manual.md | 22 +++++++---- src/Canvas/LayerCanvas.php | 43 +++++++++++++++++++--- src/Editor.php | 15 ++++++-- tests/Unit/LayeredCanvasTest.php | 39 +++++++++++++++++++- tests/__snapshots__/main_shell_100x30.ansi | 2 +- 5 files changed, 102 insertions(+), 19 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index e3d6f1e..9f85030 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -65,7 +65,7 @@ is what the code draws rather than a sketch of it: │ │ │ │ │ Background Music: (None) │ │ │ │ │ │ Music Variants · None │ │ │ │ │ │ Encounters · off │ - └─/:Filter Del:Delete─────────┘ └─i:Paint []:Layer ?:Help────┘ └─Enter:Edit─────────────────────┘ + └─/:Filter Del:Delete─────────┘ └─i:Paint L:Layer ?:Help─────┘ └─Enter:Edit─────────────────────┘ ┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐ │ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 │ │ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │ @@ -185,16 +185,21 @@ The TUI edits the terminal experience. Maps with `layers/` show numbered gameplay (`NN.name.map.php`) layers and Events, with NPC overlays in their authoring context. Graphical decoration (`NN.name.deco.php`) never appears on the terminal canvas or in its layer selection, palette or inspector. In Normal -mode, `[` and `]` cycle gameplay and event layers, `v` toggles the selected -layer's visibility, and `d` dims inactive layers. Spaces above the base show -through. Visibility and dimming are session settings, not map data. +mode, `L` opens the layer picker: the map's gameplay layers and Events, each +marked visible or hidden and the one being edited. Type to filter, move with the +arrows or `j` / `k`, and press `Enter` to edit that layer (`Esc` keeps the +current one). The canvas title shows the layer being edited, and only that +layer is painted. `[` and `]` still step to the previous or next layer, `v` +toggles the selected layer's visibility, and `d` dims inactive layers. Spaces +above the base show through. Visibility and dimming are session settings, not +map data. The normal canvas is editable immediately: press `i` and paint terminal glyphs. The separate read-only terminal-preview toggle has been removed; there is no graphical-marker view to switch away from. -Use `Ctrl+P` and select a gameplay or event layer by name, or choose a Layers -action to create, rename or remove a gameplay layer. The TUI cannot create, +Use `Ctrl+P` and choose a Layers action to create, rename or remove a gameplay +layer. The TUI cannot create, select, rename or remove graphical decoration layers. New gameplay layers preserve each row's width, including ragged maps. Renaming retains its numeric order. If the actual resolved collisions would @@ -267,8 +272,9 @@ A readable notice or interactive fixture needs its gameplay glyph and interaction, not a graphical decoration marker. Graphical representations must not drive or change the terminal editing experience. -1. Focus the canvas and choose a gameplay or event layer with `[` / `]` or - `Ctrl+P`. Press `i` to paint; `Esc` returns to Normal mode. Brackets and +1. Focus the canvas and choose a gameplay or event layer with `L`, or step + through them with `[` / `]`. Press `i` to paint; `Esc` returns to Normal + mode. `L`, brackets and other printable characters remain paintable while in Paint mode. 2. Use the existing glyph tools, colours, selections, clipboard and mouse strokes. Continue authoring events and NPCs in their existing modes. diff --git a/src/Canvas/LayerCanvas.php b/src/Canvas/LayerCanvas.php index fe3a77f..8ecb4a3 100644 --- a/src/Canvas/LayerCanvas.php +++ b/src/Canvas/LayerCanvas.php @@ -69,6 +69,40 @@ private function selectCanvasLayer(string $id): void $this->setEditingMode($id === MapLayers::EVENT ? self::MODE_EVENT : self::MODE_MAP); } + /** + * Opens the layer picker: the map's gameplay layers and Events, filterable + * by name, with the active layer selected. Enter makes the highlighted + * layer the one the canvas edits. + */ + private function openCanvasLayerPicker(): void + { + $layers = $this->getTerminalCanvasLayers(); + if ($layers === []) { + return; + } + $visibility = $this->getCanvasLayerState()['visibility']; + $active = $this->getActiveCanvasLayer(); + $entries = array_map(fn(array $layer): array => [ + 'label' => $layer['id'] === MapLayers::EVENT ? 'Events' : $layer['name'], + 'value' => $layer['id'], + 'description' => implode(' · ', array_filter([ + $layer['id'] === MapLayers::EVENT ? 'event markers' : 'gameplay', + ($visibility[$layer['id']] ?? true) ? 'visible' : 'hidden', + $layer['id'] === $active ? 'editing' : null, + ])), + ], $layers); + $this->finalizeActiveStroke(); + $this->optionDialogField = ['canvasLayer' => true]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = 'Layer'; + $this->eventOptionDialogEntries = $entries; + $this->selectedEventOptionIndex = $this->resolveEventOptionSelectionIndex($active); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = 'Choose the layer to edit.'; + $this->renderSelectionDependentArea(); + } + private function cycleCanvasLayer(int $step = 1): void { $ids = array_column($this->getTerminalCanvasLayers(), 'id'); @@ -106,18 +140,15 @@ private function buildLayerPaletteItems(): array if ($map === null || $map->getGridSourceIssue() !== null) { return []; } + // Choosing a layer has its own picker (L); the palette keeps one entry + // for it instead of a Layer and a Visibility entry per layer. $items = [ + new PaletteItem('Layers: Choose the layer to edit', 'L', fn() => $this->openCanvasLayerPicker()), new PaletteItem('Layers: Create gameplay layer', '', fn() => $this->openLayerPrompt('create')), new PaletteItem('Layers: Rename selected layer', '', fn() => $this->openLayerPrompt('rename')), new PaletteItem('Layers: Remove selected layer', '', fn() => $this->openLayerPrompt('remove')), new PaletteItem('Layers: Dim inactive layers', 'd', fn() => $this->toggleCanvasLayerOption('dim')), ]; - foreach ($this->getTerminalCanvasLayers() as $layer) { - $label = sprintf('%s %s (%s)', $layer['order'] === null ? '--' : sprintf('%02d', $layer['order']), - $layer['name'], $layer['id'] === MapLayers::EVENT ? 'events' : 'gameplay'); - $items[] = new PaletteItem('Layer: ' . $label, '', fn() => $this->selectCanvasLayer($layer['id'])); - $items[] = new PaletteItem('Visibility: ' . $label, '', fn() => $this->toggleCanvasLayerVisibility($layer['id'])); - } foreach (FacadeCatalogue::discover($this->workspace->projectRoot) as $path) { try { foreach (FacadeCatalogue::load($path) as $index => $rows) { diff --git a/src/Editor.php b/src/Editor.php index e978254..a314122 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -1369,6 +1369,7 @@ private function buildInputRouter(): InputRouter // glyph. The predicates keep NPC mode's own letters and the // other panes' typing untouched. KeyBinding::when($this->isNormalModeCommand('i'), $this->enterPaintMode(...), 'i', 'Canvas: enter Paint mode (every key paints; Esc returns to Normal)'), + KeyBinding::when($this->isNormalModeCommand('L'), fn() => $this->openCanvasLayerPicker(), 'L', 'Canvas: choose the layer to edit'), KeyBinding::when($this->isNormalModeCommand(']'), fn() => $this->cycleCanvasLayer(), ']', 'Canvas: next layer'), KeyBinding::when($this->isNormalModeCommand('['), fn() => $this->cycleCanvasLayer(-1), '[', 'Canvas: previous layer'), KeyBinding::when($this->isNormalModeCommand('v'), fn() => $this->toggleCanvasLayerVisibility(), 'v', 'Canvas: toggle selected layer visibility'), @@ -7768,6 +7769,14 @@ private function applySelectedEventOption(): void $field = $this->optionDialogField; $title = $this->eventOptionDialogTitle; + if (($field['canvasLayer'] ?? false) === true) { + $this->closeEventOptionDialog(); + $this->selectCanvasLayer((string) $selectedEntry['value']); + $this->setStatus(sprintf('Editing the %s layer.', $selectedEntry['label'])); + $this->renderCanvasArea(); + return; + } + if (($field['actorReferenceMigration'] ?? null) instanceof ActorIdentityMigrationPlan) { if ($selectedEntry['value'] === 'preview') { return; } $this->confirmActorReferenceMigration($field['actorReferenceMigration'], $selectedEntry['value'] === 'migrate'); @@ -18205,9 +18214,9 @@ private function createCanvasWindow(): EditorWindow ), default => $this->fitHelp( $layout['centerWidth'], - 'i:Paint []:Layer v:Hide d:Dim o:Colour Ctrl+P:Layers', - 'i:Paint []:Layer v:Hide Ctrl+P:Layers', - 'i:Paint []:Layer ?:Help', + 'i:Paint L:Layer v:Hide d:Dim o:Colour ?:Help', + 'i:Paint L:Layer v:Hide d:Dim', + 'i:Paint L:Layer ?:Help', 'i:Paint m:Map e:Event n:NPC c:Chars o:Colour ?:Help', 'i:Paint m:Map e:Event n:NPC c:Chars o:Colour', 'i:Paint m:Map e:Event', diff --git a/tests/Unit/LayeredCanvasTest.php b/tests/Unit/LayeredCanvasTest.php index d050b9c..1cef751 100644 --- a/tests/Unit/LayeredCanvasTest.php +++ b/tests/Unit/LayeredCanvasTest.php @@ -26,6 +26,38 @@ } }); +it('chooses the layer to edit from its own picker with L, and L still paints in Paint mode', function () { + [$editor, $map] = layeredCanvasEditor(); + callEditorMethod($editor, 'dispatchInput', 'L'); + $entries = getEditorProperty($editor, 'eventOptionDialogEntries'); + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Layer') + ->and(array_column($entries, 'value'))->toBe(array_column(callEditorMethod($editor, 'getTerminalCanvasLayers'), 'id')) + ->and(array_column($entries, 'label'))->toContain('Events') + ->and($entries[0]['description'])->toContain('editing'); + // Down to the next layer, then Enter edits it. + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\n"); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe($entries[1]['value']) + ->and(getEditorProperty($editor, 'eventOptionDialogEntries'))->toBe([]); + // Esc leaves the active layer alone. + callEditorMethod($editor, 'dispatchInput', 'L'); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\033"); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe($entries[1]['value']); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + callEditorMethod($editor, 'dispatchInput', 'i'); + callEditorMethod($editor, 'dispatchInput', 'L'); + expect($map->getLayerSymbol('map:4', 0, 0))->toBe('L') + ->and(getEditorProperty($editor, 'eventOptionDialogEntries'))->toBe([]); +}); + +it('offers one layer picker entry in the command palette instead of an entry per layer', function () { + [$editor] = layeredCanvasEditor(); + $labels = array_map(static fn($item): string => $item->label, callEditorMethod($editor, 'buildLayerPaletteItems')); + expect($labels)->toContain('Layers: Choose the layer to edit') + ->and(array_filter($labels, static fn(string $label): bool => str_starts_with($label, 'Layer: ') || str_starts_with($label, 'Visibility: ')))->toBe([]); +}); + it('uses colour selection clipboard shapes fill eyedropper and undo on gameplay and event layers', function (string $id) { [$editor, $map] = layeredCanvasEditor(); callEditorMethod($editor, 'selectCanvasLayer', $id); @@ -152,8 +184,13 @@ [$editor, $map] = layeredCanvasEditor(); $before = $map->captureLayerSnapshot(); $labels = implode('\n', array_map(static fn($item): string => $item->label, callEditorMethod($editor, 'buildLayerPaletteItems'))); - expect($labels)->toContain('terrain', 'buildings', 'Events', 'Create gameplay layer') + expect($labels)->toContain('Create gameplay layer') ->not->toContain('detail', 'decoration', 'Tile art', 'Terminal preview'); + // Layers are chosen in their own picker, which offers no graphical layer either. + callEditorMethod($editor, 'openCanvasLayerPicker'); + $picked = implode('\n', array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label')); + expect($picked)->toContain('terrain', 'buildings', 'Events')->not->toContain('detail', 'decoration'); + callEditorMethod($editor, 'dispatchInput', "\033"); foreach (['map:1', 'map:4', 'event'] as $id) { callEditorMethod($editor, 'selectCanvasLayer', $id); $fields = json_encode(callEditorMethod($editor, 'getInspectorFields')); diff --git a/tests/__snapshots__/main_shell_100x30.ansi b/tests/__snapshots__/main_shell_100x30.ansi index acff787..806fadd 100644 --- a/tests/__snapshots__/main_shell_100x30.ansi +++ b/tests/__snapshots__/main_shell_100x30.ansi @@ -1 +1 @@ -[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint []:Layer ?:Help────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > terrain · visible ││ Events · visible ││ ││ ││ │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file +[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint L:Layer ?:Help─────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > terrain · visible ││ Events · visible ││ ││ ││ │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file From 5042b70e8f73b8a3bff39c9e5a7c6deb95a1d15f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 28 Sep 2026 04:23:37 +0200 Subject: [PATCH 056/344] feat(canvas): build maps from tileset pieces In Normal mode on the canvas, P opens a piece picker listing the pieces of the tileset the map's data names, each with its footprint, gameplay layer and tile layers, in the shared option picker. The command palette offers the same picker as "Pieces: Choose a piece to place". A map without a tileset, a tileset without pieces or one that cannot be loaded is reported in the status and nothing opens. Choosing a piece starts placing it in Map mode: the canvas previews its glyphs in reverse video with the top-left cell at the cursor and the border shows PIECE Enter:Stamp Esc:Done. Enter stamps and keeps the piece; Esc ends placement, as do Paint mode, a tool or layer choice, Event or NPC mode and another map. A Normal-mode click moves the cursor and the preview. A stamp is one undo step. It writes the glyphs on the gameplay layer the piece names, not the layer being edited, in the brush colour, and the tile entries, halves included, on the tile layers it names, creating a missing one as graphics/NN.name.tiles.php after the map's tile layers. Space glyphs and 0 tiles leave cells alone. A missing gameplay layer, a footprint that does not fit (ragged rows included) or an unreadable tile layer refuses the stamp and changes nothing. Saving writes only the changed terminal and tile layer files in the map's transaction; a changed tile layer is rewritten as a literal nowdoc, and one that reads back as its saved entries keeps its bytes. The normal canvas hint gains P:Piece where it fits. --- docs/manual.md | 58 ++++- src/Canvas/LayerCanvas.php | 1 + src/Canvas/PieceCanvas.php | 274 ++++++++++++++++++++++ src/Editor.php | 78 +++++-- src/Maps/MapLayers.php | 81 ++++++- src/Maps/TileLayerSource.php | 98 +++++++- src/ProjectMap.php | 68 +++++- src/ProjectWorkspace.php | 4 +- src/Validation/MapGraphicsValidator.php | 2 +- tests/Support/MapGraphicsFixtures.php | 8 +- tests/Unit/MapGraphicsTest.php | 5 +- tests/Unit/TilesetPiecesTest.php | 298 ++++++++++++++++++++++++ 12 files changed, 933 insertions(+), 42 deletions(-) create mode 100644 src/Canvas/PieceCanvas.php create mode 100644 tests/Unit/TilesetPiecesTest.php diff --git a/docs/manual.md b/docs/manual.md index 9f85030..c97541e 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -148,7 +148,8 @@ exist. `Esc` backs out exactly one level, everywhere: - a field edit returns to the pane -- a pending canvas anchor is dropped, then a selection is cleared +- piece placement ends, a pending canvas anchor is dropped, then a selection + is cleared - a filter query is cleared before its list closes - an overlay or dialog closes - the Database screen closes @@ -219,14 +220,60 @@ force for terminal edits. Untouched layer files are never written; unchanged rows retain their authored bytes, and all changed files are saved or rolled back together. +### Pieces + +A map offers the pieces of the tileset its data names (see +[Map Graphics](#map-graphics)): whole items such as a bed or a table, so a map +is built from items instead of single glyphs. In Normal mode on the canvas, `P` +opens the piece picker, one entry per piece with its footprint in cells, the +gameplay layer its glyphs go on and the tile layers it writes, for example +`1 x 2 · fixtures · tiles: furniture`. Move with the arrows or `j` / `k`, press +`/` to filter by name, `Enter` to choose and `Esc` to cancel. The command +palette offers the same picker as `Pieces: Choose a piece to place`. When the +map names no tileset, its tileset has no pieces, or the tileset cannot be +loaded, the status line says so and nothing opens. + +Choosing a piece starts placing it, in Map mode. The canvas previews the +piece's glyphs in reverse video with its top-left cell at the cursor, and the +canvas border shows `PIECE Enter:Stamp Esc:Done`. The arrows move the +piece; a Normal-mode click moves the cursor, and the piece with it, to the +clicked cell. `Enter` stamps the piece and keeps it for the next stamp; `Esc` +ends placement. Entering Paint mode, choosing a tool or a layer, switching to +Event or NPC mode and selecting another map also end it. + +A stamp is one undo step. It writes the piece's glyphs on the gameplay layer +the piece names, whichever layer the canvas is editing, in the brush colour as +painting does, and its tiles on the tile layers it names. A space glyph and a +`0` tile leave their cells as they are; tile halves such as `42L` are written +as authored. A tile layer the map does not have yet is created as +`graphics/NN.name.tiles.php`, ordered after the map's tile layers and empty +elsewhere. The terminal never shows the tiles, but a map built from pieces +draws correctly graphically without a second pass. A stamp that cannot be made +whole changes nothing: the map has no gameplay layer with the piece's name, +the footprint does not fit inside the map at the cursor (ragged rows +included), or a tile layer it names cannot be read or does not match the map. +Undo and redo restore glyphs and tiles exactly. `Ctrl+S` saves the changed +terminal and tile layer files in the map's one transaction; untouched files +are not written, and a changed tile layer is rewritten as a literal nowdoc +that keeps its leading comment. + +A project adds pieces to its tileset (`assets/Data/Tilesets/.php`) under +`pieces`, keyed by id, each with a `name`, the gameplay `layer` for its +`glyphs` (rows of one-cell characters) and optional `tiles` keyed by tile +layer name, with rows over the same footprint: +`'bed' => ['name' => 'Bed', 'layer' => 'fixtures', 'glyphs' => ['=', '='], 'tiles' => ['furniture' => ['32', '40']]]`. +The Engine's `docs/graphical-field.md` (Pieces) holds the contract. Every +stamp reads the tileset again, so the tileset stays the one source of pieces. + ### Map Graphics A map may name a tileset in its data file (`'tileset' => 'home'`, read from `assets/Data/Tilesets/home.php`) and keep RPG Maker tile layers in `graphics/NN.name.tiles.php`. A tile layer holds one tile identity per map cell, so each of its rows is exactly as wide as the map's row in terminal -columns. The TUI never paints or displays tiles; painting them belongs to the -GUI editor. It keeps them intact: +columns. The TUI never displays tiles or paints single tiles; painting them +belongs to the GUI editor. It writes tiles only when it stamps a +[piece](#pieces), and otherwise keeps them intact: - Resizing the map crops or pads every tile layer with empty tiles (`0`) in the same undo step and save as the terminal layers. A resized tile layer is @@ -305,6 +352,7 @@ and the GUI itself are not delivered by this TUI boundary correction. See the | `e` | Switch to Event mode (paint event markers) | | `n` / `F3` | Toggle NPC mode (place and edit the map's NPCs) | | `c` | Open the character map | +| `P` | Choose a tileset piece to place (see [Pieces](#pieces)) | | `o` | Open the brush colour picker (see [Colour](#colour)) | | `b` / `l` / `r` / `R` / `s` | Choose a tool: Brush, Line, Rectangle, Filled Rectangle, Select | | `f` | Flood fill from the cursor (same as `Ctrl+F`) | @@ -316,8 +364,8 @@ and the GUI itself are not delivered by this TUI boundary correction. See the | `u` / `U` | Undo / redo (same as `Ctrl+Z` / `Ctrl+Y`) | | `?` | Open the help overlay | | `Arrows` | Move the cursor | -| `Enter` | Apply the active tool | -| `Esc` | Pop one canvas level: a pending tool anchor, then the selection | +| `Enter` | Apply the active tool, or stamp the piece being placed | +| `Esc` | Pop one canvas level: piece placement, a pending tool anchor, then the selection | Typing an unassigned printable key in Normal mode paints nothing; the status line points to `i` instead. diff --git a/src/Canvas/LayerCanvas.php b/src/Canvas/LayerCanvas.php index 8ecb4a3..3e93fc0 100644 --- a/src/Canvas/LayerCanvas.php +++ b/src/Canvas/LayerCanvas.php @@ -65,6 +65,7 @@ private function selectCanvasLayer(string $id): void $this->canvasToolAnchor = null; $this->canvasSelection = null; $this->facadeBrush = null; + $this->piecePlacement = null; $this->setCanvasLayerState('selected', $id); $this->setEditingMode($id === MapLayers::EVENT ? self::MODE_EVENT : self::MODE_MAP); } diff --git a/src/Canvas/PieceCanvas.php b/src/Canvas/PieceCanvas.php new file mode 100644 index 0000000..1d5dfbc --- /dev/null +++ b/src/Canvas/PieceCanvas.php @@ -0,0 +1,274 @@ +getSelectedMap(); + if (! $map instanceof ProjectMap || $map->getGridSourceIssue() !== null) { + return; + } + $pieces = $this->loadCanvasPieces($map); + if ($pieces === null) { + return; + } + $this->finalizeActiveStroke(); + $this->optionDialogField = ['canvasPiece' => true]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = 'Piece'; + $this->eventOptionDialogEntries = array_map(static fn(TilesetPiece $piece): array => [ + 'label' => $piece->name, + 'value' => $piece->id, + 'description' => implode(' · ', array_filter([ + sprintf('%d x %d', $piece->width, $piece->height), + $piece->layer, + $piece->tiles === [] ? null : 'tiles: ' . implode(', ', array_keys($piece->tiles)), + ])), + ], array_values($pieces)); + $this->selectedEventOptionIndex = $this->resolveEventOptionSelectionIndex($this->piecePlacement['piece']->id ?? ''); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = 'Choose a piece to place.'; + $this->renderSelectionDependentArea(); + } + + /** + * The pieces of the map's tileset, or null after saying in the status + * why there are none to offer. + * + * @return array|null Pieces by id, in authored order. + */ + private function loadCanvasPieces(ProjectMap $map): ?array + { + try { + $tileset = $map->loadTileset(); + } catch (\Throwable $error) { + $this->setStatus('Pieces are unavailable: ' . $error->getMessage(), StatusLevel::WARN); + return null; + } + if ($tileset === null) { + $this->setStatus("This map names no tileset, so it has no pieces. Name one in its data file ('tileset' => '').", StatusLevel::WARN); + return null; + } + if ($tileset->pieces === []) { + $this->setStatus(sprintf('Tileset %s has no pieces. Add them to assets/%s/%s.php.', $tileset->id, Tileset::DIRECTORY, $tileset->id), StatusLevel::WARN); + return null; + } + return $tileset->pieces; + } + + /** Starts placing the chosen piece on the selected map, in Map mode. */ + private function choosePiece(string $id): void + { + $map = $this->getSelectedMap(); + $pieces = $map instanceof ProjectMap ? $this->loadCanvasPieces($map) : null; + if ($pieces === null) { + return; + } + $piece = $pieces[$id] ?? null; + if ($piece === null) { + $this->setStatus("Piece {$id} is no longer in the map's tileset. Press P to choose again.", StatusLevel::WARN); + return; + } + if ($this->editingMode !== self::MODE_MAP) { + $this->setEditingMode(self::MODE_MAP); + } + $this->facadeBrush = null; + $this->canvasToolAnchor = null; + $this->inputMode = self::INPUT_NORMAL; + $this->setFocusedPane(self::FOCUS_CANVAS, false); + $this->piecePlacement = ['map' => $map, 'piece' => $piece]; + $this->setStatus(sprintf('Placing %s. Arrows move it, Enter stamps, Esc when done.', $piece->name)); + $this->renderFocusDependentArea(); + } + + /** + * The placement in force: the piece being placed, while its map is the + * selected one and the canvas is in Map mode's Normal input. + * + * @return array{map: ProjectMap, piece: TilesetPiece}|null + */ + private function getActivePiecePlacement(): ?array + { + $placement = $this->piecePlacement; + return $placement !== null && $placement['map'] === $this->getSelectedMap() + && $this->editingMode === self::MODE_MAP && $this->inputMode === self::INPUT_NORMAL + ? $placement : null; + } + + private function endPiecePlacement(string $statusMessage = ''): void + { + $this->piecePlacement = null; + if ($statusMessage !== '') { + $this->setStatus($statusMessage); + } + $this->renderCanvasArea(); + } + + /** + * The piece's footprint with its top-left cell at the cursor, for the + * canvas preview: a glyph where the piece writes one, null where a space + * glyph leaves the map's cell as it is. + * + * @return array> Cells by row and column. + */ + private function getPiecePreviewCells(): array + { + $piece = $this->getActivePiecePlacement()['piece'] ?? null; + if (! $piece instanceof TilesetPiece) { + return []; + } + $cells = []; + foreach ($piece->glyphs as $row => $symbols) { + foreach ($symbols as $column => $symbol) { + $cells[$this->cursorY + $row][$this->cursorX + $column] = $symbol === ' ' ? null : $symbol; + } + } + return $cells; + } + + /** + * Stamps the piece at the cursor as one undo step: its glyphs on the + * gameplay layer it names, in the brush colour, and its tiles on the tile + * layers it names, creating any the map does not have yet. Space glyphs + * and `0` tiles leave their cells as they are. The piece is read from + * the tileset again, so the tileset stays the one source of pieces. A + * stamp that cannot be made whole changes nothing. + */ + private function stampPiece(): void + { + $placement = $this->getActivePiecePlacement(); + if ($placement === null) { + return; + } + $map = $placement['map']; + $pieces = $this->loadCanvasPieces($map); + $piece = $pieces[$placement['piece']->id] ?? null; + if ($piece === null) { + if ($pieces !== null) { + $this->setStatus("{$placement['piece']->name} is no longer in the map's tileset. Press P to choose a piece.", StatusLevel::WARN); + } + $this->endPiecePlacement(); + return; + } + $this->piecePlacement['piece'] = $piece; + $x = $this->cursorX; + $y = $this->cursorY; + try { + $layer = $this->findPieceLayer($map, $piece); + foreach ($piece->glyphs as $row => $symbols) { + foreach (array_keys($symbols) as $column) { + if (! $map->hasLayerCell($layer, $x + $column, $y + $row)) { + throw new MapSourceRefusal(sprintf('%s (%d x %d) does not fit at (%d, %d): the map has no cell at (%d, %d). Nothing was changed.', + $piece->name, $piece->width, $piece->height, $x, $y, $x + $column, $y + $row)); + } + } + } + $this->finalizeActiveStroke(); + $tilesBefore = $map->getTileLayerSources(); + $map->writeTileEntries($piece->tiles, $x, $y); + $tilesAfter = $map->getTileLayerSources(); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderCanvasArea(); + return; + } + $writes = []; + foreach ($piece->glyphs as $row => $symbols) { + foreach ($symbols as $column => $symbol) { + if ($symbol !== ' ') { + $writes[] = ['x' => $x + $column, 'y' => $y + $row, 'symbol' => $symbol, 'color' => $this->selectedPaintColor]; + } + } + } + [$stroke] = $this->writeCanvasCells($map, $layer, $writes, 'Piece stamp'); + if (! $stroke->hasChanges() && $tilesAfter === $tilesBefore) { + $this->setStatus(sprintf('%s is already at (%d, %d).', $piece->name, $x, $y)); + $this->renderCanvasArea(); + return; + } + $this->recordCommand(new GenericCommand('Piece stamp', + static function () use ($stroke, $map, $tilesAfter): void { + $stroke->execute(); + $map->restoreTileLayerSources($tilesAfter); + }, + static function () use ($stroke, $map, $tilesBefore): void { + $stroke->undo(); + $map->restoreTileLayerSources($tilesBefore); + }, + )); + $this->setStatus(sprintf('Stamped %s at (%d, %d). Enter stamps again; Esc when done.', $piece->name, $x, $y)); + $this->renderCanvasArea(); + } + + /** + * The id of the gameplay layer the piece's glyphs go on. + * + * @throws MapSourceRefusal When the map has no gameplay layer with that name. + */ + private function findPieceLayer(ProjectMap $map, TilesetPiece $piece): string + { + foreach ($map->getLayers() as $layer) { + if ($layer['id'] !== MapLayers::EVENT && ! $layer['decoration'] && $layer['name'] === $piece->layer) { + return $layer['id']; + } + } + throw new MapSourceRefusal(sprintf('%s goes on the %s layer, which this map does not have. Create it (Ctrl+P, Layers: Create gameplay layer) first. Nothing was changed.', + $piece->name, $piece->layer)); + } + + /** The canvas border hint while a piece is being placed, or null when none is. */ + private function getPiecePlacementHelp(int $windowWidth): ?string + { + $piece = $this->getActivePiecePlacement()['piece'] ?? null; + return $piece instanceof TilesetPiece ? $this->fitHelp( + $windowWidth, + sprintf('PIECE %s Enter:Stamp Esc:Done', $piece->name), + sprintf('PIECE %s Enter:Stamp Esc:Done', $piece->name), + 'PIECE Enter:Stamp Esc:Done', + 'Enter:Stamp Esc:Done', + 'Esc:Done', + ) : null; + } + + /** @return list */ + private function buildPiecePaletteItems(): array + { + $map = $this->getSelectedMap(); + if (! $map instanceof ProjectMap || $map->getGridSourceIssue() !== null) { + return []; + } + return [new PaletteItem('Pieces: Choose a piece to place', 'P', function (): void { + $this->closeDatabaseIfOpen(); + $this->openPiecePicker(); + })]; + } +} diff --git a/src/Editor.php b/src/Editor.php index a314122..c457c00 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -110,6 +110,7 @@ final class Editor { use \Ichiloto\Editor\Canvas\LayerCanvas; + use \Ichiloto\Editor\Canvas\PieceCanvas; use CutscenesWorkspace; use CutsceneOutlinePane; use CutscenePreviewPane; @@ -1370,6 +1371,7 @@ private function buildInputRouter(): InputRouter // other panes' typing untouched. KeyBinding::when($this->isNormalModeCommand('i'), $this->enterPaintMode(...), 'i', 'Canvas: enter Paint mode (every key paints; Esc returns to Normal)'), KeyBinding::when($this->isNormalModeCommand('L'), fn() => $this->openCanvasLayerPicker(), 'L', 'Canvas: choose the layer to edit'), + KeyBinding::when($this->isNormalModeCommand('P'), fn() => $this->openPiecePicker(), 'P', 'Canvas: choose a tileset piece to place (Enter stamps it, Esc when done)'), KeyBinding::when($this->isNormalModeCommand(']'), fn() => $this->cycleCanvasLayer(), ']', 'Canvas: next layer'), KeyBinding::when($this->isNormalModeCommand('['), fn() => $this->cycleCanvasLayer(-1), '[', 'Canvas: previous layer'), KeyBinding::when($this->isNormalModeCommand('v'), fn() => $this->toggleCanvasLayerVisibility(), 'v', 'Canvas: toggle selected layer visibility'), @@ -1763,6 +1765,11 @@ private function handleCanvasPaneInput(string $input, string $normalizedInput): return; } + if ($this->getActivePiecePlacement() !== null) { + $this->endPiecePlacement('Piece placement ended.'); + return; + } + if ($this->cancelCanvasToolState()) { return; } @@ -3774,6 +3781,8 @@ private function selectAsset(int $selectedIndex): void return; } + $this->piecePlacement = null; + $this->selectedAssetIndex = $selectedIndex; $this->cursorX = 0; $this->cursorY = 0; @@ -3818,7 +3827,9 @@ private function moveCursor(int $deltaX, int $deltaY): void $this->cursorY = $nextCursorY; $this->syncViewportToCursor(); - if ($previousOffsetX !== $this->canvasOffsetX || $previousOffsetY !== $this->canvasOffsetY) { + // A piece's preview follows the cursor, so the canvas repaints. + if ($previousOffsetX !== $this->canvasOffsetX || $previousOffsetY !== $this->canvasOffsetY + || $this->getActivePiecePlacement() !== null) { $this->renderCanvasArea(); return; } @@ -3849,6 +3860,9 @@ private function setEditingMode(string $mode): void { $this->finalizeActiveStroke(); $this->facadeBrush = null; + if ($mode !== self::MODE_MAP) { + $this->piecePlacement = null; + } $this->editingMode = $mode; $this->showEventOverlay = $mode === self::MODE_EVENT; @@ -4687,6 +4701,7 @@ private function enterPaintMode(): void return; } + $this->piecePlacement = null; $this->inputMode = self::INPUT_PAINT; $this->statusMessage = 'Paint mode: every key paints its glyph. Esc returns to Normal.'; $this->renderFocusDependentArea(); @@ -4718,6 +4733,7 @@ private function leavePaintMode(string $statusMessage = 'Normal mode.'): void private function selectCanvasTool(CanvasTool $tool): void { $this->facadeBrush = null; + $this->piecePlacement = null; $this->canvasTool = $tool; $this->canvasToolAnchor = null; @@ -4793,17 +4809,39 @@ private function applyCanvasWrites(ProjectMap $map, array $writes, string $label } $this->finalizeActiveStroke(); - $stroke = new PaintStrokeCommand($map, $this->getActiveCanvasLayer(), $label); + [$stroke, $changed] = $this->writeCanvasCells($map, $this->getActiveCanvasLayer(), $writes, $label); + + if ($stroke->hasChanges()) { + $this->recordCommand($stroke); + } + + return $changed; + } + + /** + * Writes cells onto one layer and returns the stroke that undoes them, + * unrecorded, so a caller can record it alone or as part of a larger + * step. Cells outside the layer are skipped. + * + * @param ProjectMap $map The target map. + * @param string $layer The layer id to write. + * @param array $writes The cells to write. + * @param string $label The undo/status label. + * @return array{0: PaintStrokeCommand, 1: int} The stroke and the number of cells that actually changed. + */ + private function writeCanvasCells(ProjectMap $map, string $layer, array $writes, string $label): array + { + $stroke = new PaintStrokeCommand($map, $layer, $label); $changed = 0; foreach ($writes as $write) { - if (! $map->hasLayerCell($this->getActiveCanvasLayer(), $write['x'], $write['y'])) { + if (! $map->hasLayerCell($layer, $write['x'], $write['y'])) { continue; } - $oldSymbol = $this->readCanvasSymbol($map, $write['x'], $write['y']); + $oldSymbol = $map->getLayerSymbol($layer, $write['x'], $write['y']); - $oldStyle = $map->getLayerCellStyle($this->getActiveCanvasLayer(), $write['x'], $write['y']); + $oldStyle = $map->getLayerCellStyle($layer, $write['x'], $write['y']); [$newPrefix, $newSuffix] = isset($write['style']) ? [$write['style']['prefix'], $write['style']['suffix']] : $this->resolvePaintStyle( @@ -4811,8 +4849,8 @@ private function applyCanvasWrites(ProjectMap $map, array $writes, string $label $write['color'] ?? null, $oldStyle, ); - $map->setLayerCell($this->getActiveCanvasLayer(), $write['x'], $write['y'], $write['symbol'], $newPrefix, $newSuffix); - $newSymbol = $this->readCanvasSymbol($map, $write['x'], $write['y']); + $map->setLayerCell($layer, $write['x'], $write['y'], $write['symbol'], $newPrefix, $newSuffix); + $newSymbol = $map->getLayerSymbol($layer, $write['x'], $write['y']); $stroke->appendCell( $write['x'], $write['y'], @@ -4829,11 +4867,7 @@ private function applyCanvasWrites(ProjectMap $map, array $writes, string $label } } - if ($stroke->hasChanges()) { - $this->recordCommand($stroke); - } - - return $changed; + return [$stroke, $changed]; } /** @@ -4899,6 +4933,7 @@ private function paintCanvasCells(ProjectMap $map, array $cells, string $symbol, */ private function cycleCanvasTool(int $step): void { + $this->piecePlacement = null; $this->canvasTool = $this->canvasTool->cycle($step); $this->canvasToolAnchor = null; @@ -4976,6 +5011,11 @@ private function describeCanvasToolState(): string */ private function applyCanvasToolAtCursor(): void { + if ($this->getActivePiecePlacement() !== null) { + $this->stampPiece(); + return; + } + if ($this->facadeBrush !== null) { $this->stampFacadeBrush(); return; @@ -5415,6 +5455,7 @@ private function performReload(): void $this->clipboard->clear(); $this->canvasToolAnchor = null; $this->canvasSelection = null; + $this->piecePlacement = null; $this->setStatus('Workspace refreshed.', StatusLevel::SUCCESS); $this->requestFullRender(); } @@ -5767,6 +5808,7 @@ private function buildPaletteItems(): array { $items = [ ...$this->buildLayerPaletteItems(), + ...$this->buildPiecePaletteItems(), new PaletteItem('Save Map', 'Ctrl+S', fn() => $this->saveSelectedMap()), new PaletteItem('Move Map to Derived Path', '', fn() => $this->beginExplicitMapMove()), new PaletteItem('Save All', 'Ctrl+A', fn() => $this->saveAllAssets()), @@ -7777,6 +7819,12 @@ private function applySelectedEventOption(): void return; } + if (($field['canvasPiece'] ?? false) === true) { + $this->closeEventOptionDialog(); + $this->choosePiece((string) $selectedEntry['value']); + return; + } + if (($field['actorReferenceMigration'] ?? null) instanceof ActorIdentityMigrationPlan) { if ($selectedEntry['value'] === 'preview') { return; } $this->confirmActorReferenceMigration($field['actorReferenceMigration'], $selectedEntry['value'] === 'migrate'); @@ -18212,9 +18260,12 @@ private function createCanvasWindow(): EditorWindow 'PAINT: every key paints its glyph Esc:Normal', 'PAINT Esc:Normal', ), + $this->getActivePiecePlacement() !== null => $this->getPiecePlacementHelp($layout['centerWidth']) ?? '', default => $this->fitHelp( $layout['centerWidth'], - 'i:Paint L:Layer v:Hide d:Dim o:Colour ?:Help', + 'i:Paint L:Layer P:Piece v:Hide d:Dim o:Colour ?:Help', + 'i:Paint L:Layer P:Piece v:Hide d:Dim', + 'i:Paint L:Layer P:Piece ?:Help', 'i:Paint L:Layer v:Hide d:Dim', 'i:Paint L:Layer ?:Help', 'i:Paint m:Map e:Event n:NPC c:Chars o:Colour ?:Help', @@ -18242,6 +18293,7 @@ private function createCanvasWindow(): EditorWindow $this->getActiveCanvasLayer(), false, $this->getCanvasLayerState()['dim'], + $this->getPiecePreviewCells(), ) ?? [], $contentWidth, $contentHeight diff --git a/src/Maps/MapLayers.php b/src/Maps/MapLayers.php index deb65f0..901e52a 100644 --- a/src/Maps/MapLayers.php +++ b/src/Maps/MapLayers.php @@ -15,7 +15,8 @@ /** * Editable layers and their file-set boundary. Layer ids survive renaming. * The map's graphical tile layers share the boundary: the TUI never paints - * them, but resizes, relocates and removes them with the map. + * single tiles, but writes a stamped piece's tiles and resizes, relocates and + * removes them with the map. */ final class MapLayers { @@ -223,6 +224,84 @@ public function resize(int $width, int $height): void $this->tileSources = $tileSources; } + /** + * Writes tile entries into the named tile layers with their top-left + * cell at (x, y), as one stamped piece: a `0` entry leaves its cell as it + * was. A layer the map does not have yet is created in `graphics/` with + * the next order after its tile layers, every cell empty. Every layer is + * written before anything changes, so one that cannot take the entries + * refuses them all. + * + * @param array>> $tiles Entries by row, keyed by tile layer name. + * @throws MapSourceRefusal When a layer cannot be read, does not match the map, cannot be created, or the entries fall outside it. + */ + public function writeTileEntries(array $tiles, int $x, int $y): void + { + if ($tiles === []) { + return; + } + $set = $this->getLayerSet(); + $sources = $this->tileSources; + foreach ($tiles as $name => $rows) { + $path = $this->findTileLayerPath((string) $name, $sources) ?? $this->buildTileLayerPath((string) $name, $sources); + $sources[$path] = TileLayerSource::writeEntries($sources[$path] ?? TileLayerSource::createEmpty($set), + $this->getDisplayPath($path), $set, $x, $y, $rows, $this->baselineSources[$path] ?? null); + } + ksort($sources, SORT_STRING); + $this->tileSources = $sources; + } + + /** @param array $tileSources Tile layer sources by path, as {@see getTileSources()} returns them. */ + public function restoreTileSources(array $tileSources): void + { + $this->tileSources = $tileSources; + } + + /** + * The path of the tile layer with this name, or null when there is none. + * + * @param array $sources Tile layer sources by path. + * @throws MapSourceRefusal When more than one tile layer has the name. + */ + private function findTileLayerPath(string $name, array $sources): ?string + { + $paths = array_values(array_filter(array_keys($sources), static fn(string $path): bool => + preg_match(MapGraphics::FILENAME_PATTERN, basename($path), $matches) === 1 && $matches['name'] === $name)); + if (count($paths) > 1) { + throw new MapSourceRefusal(sprintf('Tile layers %s share the name %s; rename one so a piece can name it. Nothing was changed.', + implode(' and ', array_map($this->getDisplayPath(...), $paths)), $name)); + } + + return $paths[0] ?? null; + } + + /** + * The path for a new tile layer, ordered after the map's tile layers. + * + * @param array $sources Tile layer sources by path. + * @throws MapSourceRefusal When the map cannot take another tile layer. + */ + private function buildTileLayerPath(string $name, array $sources): string + { + $orders = []; + foreach (array_keys($sources) as $path) { + if (preg_match(MapGraphics::FILENAME_PATTERN, basename($path), $matches) === 1) { + $orders[] = (int) $matches['order']; + } + } + $order = $orders === [] ? 0 : max($orders) + 1; + if (count($sources) >= MapGraphics::MAX_LAYERS || $order > 99) { + throw new MapSourceRefusal(sprintf('Tile layer %s cannot be added: a map holds up to %d tile layers, ordered 00 to 99. Nothing was changed.', + $name, MapGraphics::MAX_LAYERS)); + } + $path = sprintf('%s/%s/%02d.%s.tiles.php', $this->directory, MapGraphics::DIRECTORY, $order, $name); + if (preg_match(MapGraphics::FILENAME_PATTERN, basename($path)) !== 1) { + throw new MapSourceRefusal("'{$name}' is not a tile layer name. Nothing was changed."); + } + + return $path; + } + /** A tile layer path relative to its map, as refusals name it. */ private function getDisplayPath(string $path): string { diff --git a/src/Maps/TileLayerSource.php b/src/Maps/TileLayerSource.php index 30966a3..5990c73 100644 --- a/src/Maps/TileLayerSource.php +++ b/src/Maps/TileLayerSource.php @@ -15,9 +15,10 @@ /** * A map's graphical tile layer files in `graphics/`, as the TUI keeps them. * - * The TUI never paints or displays tiles; painting belongs to the GUI - * editor. It carries these files with their map and changes one only when - * the map's dimensions change, reading it exactly as the Engine does. + * The TUI never displays tiles or paints single tiles; painting belongs to + * the GUI editor. It carries these files with their map and changes one only + * when the map's dimensions change or a tileset piece is stamped, reading it + * exactly as the Engine does. */ final class TileLayerSource { @@ -66,15 +67,7 @@ public static function readLayer(string $source, string $displayPath): MapTileLa */ public static function resize(string $source, string $displayPath, MapLayerSet $layers, int $width, int $height, ?string $baseline = null): string { - try { - $layer = self::readLayer($source, $displayPath); - $layer->assertMatches($layers); - } catch (InvalidArgumentException $error) { - throw new MapSourceRefusal(sprintf( - '%s Repair it before resizing the map; nothing was changed.', - rtrim($error->getMessage(), '.') . '.', - ), previous: $error); - } + $layer = self::readMatchingLayer($source, $displayPath, $layers, 'resizing the map'); $empty = (string)TileId::EMPTY; $entries = $layer->getEntries(); @@ -85,6 +78,87 @@ public static function resize(string $source, string $displayPath, MapLayerSet $ unset($row); $rows = array_pad($rows, $height, array_fill(0, $width, $empty)); + return self::rewrite($rows, $entries, $source, $displayPath, $baseline); + } + + /** + * Writes a piece's tile entries into a tile layer with its top-left cell + * at (x, y). A `0` entry leaves its cell as it was; every other entry, + * a named tile half included, replaces the cell's entry. The result is + * rewritten as {@see resize()} rewrites a layer. + * + * @param list> $rows Tile entries by row, as a piece holds them. + * @param MapLayerSet $layers The map's terminal layers. + * @throws MapSourceRefusal When the layer cannot be read, does not match the map, or the entries fall outside it; nothing is changed. + */ + public static function writeEntries(string $source, string $displayPath, MapLayerSet $layers, int $x, int $y, array $rows, ?string $baseline = null): string + { + $layer = self::readMatchingLayer($source, $displayPath, $layers, 'stamping a piece'); + + $entries = $layer->getEntries(); + $written = $entries; + foreach ($rows as $row => $cells) { + foreach ($cells as $column => $entry) { + if (! isset($entries[$y + $row][$x + $column])) { + throw new MapSourceRefusal(sprintf('%s has no cell at (%d, %d); nothing was changed.', $displayPath, $x + $column, $y + $row)); + } + if ($entry !== (string)TileId::EMPTY) { + $written[$y + $row][$x + $column] = $entry; + } + } + } + + return self::rewrite($written, $entries, $source, $displayPath, $baseline); + } + + /** + * Reads a tile layer that must match the map before it changes. + * + * @throws MapSourceRefusal When it cannot be read or does not match the map. + */ + private static function readMatchingLayer(string $source, string $displayPath, MapLayerSet $layers, string $action): MapTileLayer + { + try { + $layer = self::readLayer($source, $displayPath); + $layer->assertMatches($layers); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal(sprintf( + '%s Repair it before %s; nothing was changed.', + rtrim($error->getMessage(), '.') . '.', + $action, + ), previous: $error); + } + + return $layer; + } + + /** + * A new tile layer for the map: every cell empty (`0`), each row as wide + * as the map's row. + * + * @param MapLayerSet $layers The map's terminal layers. + */ + public static function createEmpty(MapLayerSet $layers): string + { + $body = implode("\n", array_map( + static fn(array $row): string => implode(' ', array_fill(0, count($row), (string)TileId::EMPTY)), + $layers->getComposedGrid(), + )); + + return MapGridSource::buildSource($body, self::PREFERRED_MARKER); + } + + /** + * The source for a layer's new entries. Unchanged entries keep the + * source's bytes and entries that read back as the baseline keep the + * baseline's; otherwise the layer is rewritten as a canonical literal + * nowdoc, keeping its leading comment and marker. + * + * @param list> $rows The entries the layer should hold. + * @param list> $entries The entries the source holds. + */ + private static function rewrite(array $rows, array $entries, string $source, string $displayPath, ?string $baseline): string + { if ($rows === $entries) { return $source; } diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 09828a7..81969fa 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -24,6 +24,7 @@ use Ichiloto\Engine\Field\MapCollisionResolver; use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\IO\Console\SgrStyleState; +use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Ichiloto\Editor\Maps\EditableGrid; use Ichiloto\Editor\Maps\MapLayers; use InvalidArgumentException; @@ -452,8 +453,8 @@ public function getStoredGridPaths(): array } /** - * The map's graphical tile layers in `graphics/`, unsaved resizes - * included. The TUI keeps them intact and never paints them. + * The map's graphical tile layers in `graphics/`, unsaved resizes and + * stamped pieces included. The TUI never paints single tiles. * * @return array Source bytes by path. */ @@ -462,6 +463,52 @@ public function getTileLayerSources(): array return $this->layers->getTileSources(); } + /** + * Writes a stamped piece's tile entries into the named tile layers with + * their top-left cell at (x, y), creating a tile layer the map does not + * have yet. A `0` entry leaves its cell as it was. + * + * @param array>> $tiles Entries by row, keyed by tile layer name. + * @throws MapSourceRefusal When a layer cannot take the entries; nothing is changed. + */ + public function writeTileEntries(array $tiles, int $x, int $y): void + { + $this->assertEditable(); + $this->layers->writeTileEntries($tiles, $x, $y); + $this->touchState(); + } + + /** + * Restores the tile layers {@see getTileLayerSources()} returned, for + * undo and redo. + * + * @param array $sources Source bytes by path. + */ + public function restoreTileLayerSources(array $sources): void + { + $this->assertEditable(); + $this->layers->restoreTileSources($sources); + $this->touchState(); + } + + /** + * Loads the tileset the map data names, or null when it names none. + * + * @throws InvalidArgumentException When the named tileset cannot be loaded. + */ + public function loadTileset(): ?Tileset + { + $id = $this->getMapDataField(['tileset']); + if ($id === null) { + return null; + } + if (! is_string($id)) { + throw new InvalidArgumentException(sprintf('Its tileset is %s, not a tileset id.', get_debug_type($id))); + } + + return Tileset::load($this->getAssetRoot(), $id); + } + /** The project's asset root, where tilesets and their sheets live. */ public function getAssetRoot(): string { @@ -522,6 +569,9 @@ public function restoreLayerSnapshot(array $snapshot): void * @param int $offsetX The horizontal preview offset. * @param int $offsetY The vertical preview offset. * @param bool $showEventOverlay Whether event markers should be rendered. + * @param array> $stampPreview A stamp's footprint by row and column, + * drawn highlighted over the map and never painted: the glyph it would write, or null where it + * leaves the map's cell as it is. * @return string[] */ public function renderPreview( @@ -537,6 +587,7 @@ public function renderPreview( ?string $activeLayer = null, bool $terminalPreview = false, bool $dimInactive = false, + array $stampPreview = [], ): array { if ($width < 1 || $height < 1) { @@ -580,10 +631,19 @@ public function renderPreview( continue; } + if (isset($stampPreview[$row][$column])) { + // A stamp's footprint shows what it would write, in + // reverse video, over whatever the map holds there. + $mergedSymbols[] = "\033[7m" . $stampPreview[$row][$column] . "\033[0m"; + continue; + } + $tileCell = $tileRow[$column] ?? null; $tileSymbol = is_array($tileCell) ? $tileCell['symbol'] : ' '; - // A selected map-owned NPC marks the existing cell, never a replacement sprite. - $anchorHighlight = array_key_exists($column, $npcCells[$row] ?? []) ? "\033[7m" : ''; + // A selected map-owned NPC, and a stamp's cell that keeps the map's + // glyph, mark the existing cell, never a replacement. + $anchorHighlight = array_key_exists($column, $npcCells[$row] ?? []) || array_key_exists($column, $stampPreview[$row] ?? []) + ? "\033[7m" : ''; if (! $this->layers->legacy && is_array($tileCell)) { $styled = TerminalText::formatStyles($tileCell['prefix'] . $anchorHighlight . $tileSymbol . $tileCell['suffix']); $styled = ($tileCell['dim'] ?? false) ? "\033[2m" . $styled . "\033[0m" : $styled; diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index be338ca..5e4c3c8 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -293,6 +293,7 @@ classDatabase: $this->classDatabase, * @param int $offsetX The horizontal preview offset. * @param int $offsetY The vertical preview offset. * @param bool $showEventOverlay Whether event markers should be rendered. + * @param array> $stampPreview A stamp's footprint, as ProjectMap::renderPreview() takes it. * @return string[] */ public function getCanvasLines( @@ -310,6 +311,7 @@ public function getCanvasLines( ?string $activeLayer = null, bool $terminalPreview = false, bool $dimInactive = false, + array $stampPreview = [], ): array { $selectedMap = $this->getMapByIndex($selectedMapIndex); @@ -331,7 +333,7 @@ public function getCanvasLines( } $previewHeight = max(0, $height - self::CANVAS_HEADER_ROWS); - $previewLines = $selectedMap->renderPreview($width, $previewHeight, $offsetX, $offsetY, $showEventOverlay, $showNpcOverlay, $selectedNpcIndex, $selectedNpcSprite, $layerVisibility, $activeLayer, $terminalPreview, $dimInactive); + $previewLines = $selectedMap->renderPreview($width, $previewHeight, $offsetX, $offsetY, $showEventOverlay, $showNpcOverlay, $selectedNpcIndex, $selectedNpcSprite, $layerVisibility, $activeLayer, $terminalPreview, $dimInactive, $stampPreview); return [ sprintf('Preview: %s', $selectedMap->mapId), diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php index 330a3aa..9e69e22 100644 --- a/src/Validation/MapGraphicsValidator.php +++ b/src/Validation/MapGraphicsValidator.php @@ -132,7 +132,7 @@ private static function loadTileset(ProjectMap $map, mixed $tilesetId, array &$i */ private static function readLayers(ProjectMap $map, array $sources, array &$issues): array { - $hint = 'Repair the file by hand or in the GUI editor; the TUI never paints tiles. ' . self::GLYPH_FALLBACK; + $hint = 'Repair the file by hand or in the GUI editor; the TUI does not repair tile layers. ' . self::GLYPH_FALLBACK; if (count($sources) > MapGraphics::MAX_LAYERS) { $issues[] = Issue::error($map->mapId, sprintf('It has more than %d tile layers.', MapGraphics::MAX_LAYERS), $hint); diff --git a/tests/Support/MapGraphicsFixtures.php b/tests/Support/MapGraphicsFixtures.php index 91ed4b8..3882147 100644 --- a/tests/Support/MapGraphicsFixtures.php +++ b/tests/Support/MapGraphicsFixtures.php @@ -25,11 +25,12 @@ function writeTilesetTestPng(string $path, int $width, int $height): void /** * Writes tileset `home` with an A2 sheet (16 x 12 tiles) and a B sheet - * (16 x 16 tiles) at 2 pixels per tile. + * (16 x 16 tiles) at 2 pixels per tile, and any pieces given. * * @param array $sheets Asset-relative PNG paths by sheet name. + * @param array> $pieces Pieces by id, as a tileset file lists them. */ -function writeTestTileset(string $root, string $id = 'home', ?array $sheets = null): void +function writeTestTileset(string $root, string $id = 'home', ?array $sheets = null, array $pieces = []): void { $sheets ??= ['A2' => 'Graphics/Tilesets/Home_A2.png', 'B' => 'Graphics/Tilesets/Home_B.png']; writeTilesetTestPng($root . '/assets/Graphics/Tilesets/Home_A2.png', 32, 24); @@ -37,7 +38,8 @@ function writeTestTileset(string $root, string $id = 'home', ?array $sheets = nu if (! is_dir($root . '/assets/Data/Tilesets')) { mkdir($root . '/assets/Data/Tilesets', 0o777, true); } - file_put_contents($root . "/assets/Data/Tilesets/{$id}.php", " ucfirst($id), 'sheets' => $sheets], true) . ";\n"); + $data = ['name' => ucfirst($id), 'sheets' => $sheets] + ($pieces === [] ? [] : ['pieces' => $pieces]); + file_put_contents($root . "/assets/Data/Tilesets/{$id}.php", " The graphics issues reported for the test map. */ diff --git a/tests/Unit/TilesetPiecesTest.php b/tests/Unit/TilesetPiecesTest.php new file mode 100644 index 0000000..e3def52 --- /dev/null +++ b/tests/Unit/TilesetPiecesTest.php @@ -0,0 +1,298 @@ +> Synthetic pieces for tileset `home`. */ +function buildTestPieces(): array +{ + return [ + 'bed' => ['name' => 'Bed', 'layer' => 'buildings', 'glyphs' => ['= ', '=H'], + 'tiles' => ['decor' => ['5L 5R', '6 0'], 'furniture' => ['7 0', '0 8R']]], + 'lamp' => ['name' => 'Lamp', 'layer' => 'fixtures', 'glyphs' => ['i']], + 'rug' => ['name' => 'Rug', 'layer' => 'terrain', 'glyphs' => ['~~'], 'tiles' => ['floor' => ['2816 2816']]], + ]; +} + +/** + * The editor on the graphics test map, its tileset listing the test pieces. + * + * @return array{Editor, ProjectMap, string} + */ +function createPieceCanvasEditor(bool $ragged = false): array +{ + $root = mapGraphicsProject($ragged); + writeTestTileset($root, pieces: buildTestPieces()); + + return layeredCanvasEditor($root); +} + +function getCurrentToast(Editor $editor): ?Ichiloto\Editor\Status\Toast +{ + return getEditorProperty($editor, 'toasts')->current(); +} + +function choosePieceByKeys(Editor $editor, int $downs = 0): void +{ + callEditorMethod($editor, 'dispatchInput', 'P'); + for ($i = 0; $i < $downs; $i++) { + callEditorMethod($editor, 'dispatchInput', "\033[B"); + } + callEditorMethod($editor, 'dispatchInput', "\n"); +} + +it('lists the pieces of the map\'s tileset in the shared picker with P', function () { + [$editor] = createPieceCanvasEditor(); + callEditorMethod($editor, 'dispatchInput', 'P'); + + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Piece') + ->and(getEditorProperty($editor, 'eventOptionDialogEntries'))->toBe([ + ['label' => 'Bed', 'value' => 'bed', 'description' => '2 x 2 · buildings · tiles: decor, furniture'], + ['label' => 'Lamp', 'value' => 'lamp', 'description' => '1 x 1 · fixtures'], + ['label' => 'Rug', 'value' => 'rug', 'description' => '2 x 1 · terrain · tiles: floor'], + ]); + + // Esc cancels without placing anything. + callEditorMethod($editor, 'dispatchInput', "\033"); + expect(getEditorProperty($editor, 'eventOptionDialogEntries'))->toBe([]) + ->and(getEditorProperty($editor, 'piecePlacement'))->toBeNull(); + + // A filter narrows the list; Enter chooses the match. + foreach (['P', '/', 'L', 'a', "\n", "\n"] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + expect(getEditorProperty($editor, 'piecePlacement')['piece']->id)->toBe('lamp'); +}); + +it('offers one piece entry in the command palette', function () { + [$editor] = createPieceCanvasEditor(); + $labels = array_map(static fn($item): string => $item->label . ' ' . $item->hint, callEditorMethod($editor, 'buildPaletteItems')); + + expect(array_values(array_filter($labels, static fn(string $label): bool => str_starts_with($label, 'Pieces:')))) + ->toBe(['Pieces: Choose a piece to place P']); +}); + +it('says why a map has no pieces and opens nothing', function (string $case, string $message) { + $root = $case === 'no tileset' ? layeredMapProject() : mapGraphicsProject(); + if ($case === 'broken tileset') { + file_put_contents($root . '/assets/Data/Tilesets/home.php', " 'Home'];\n"); + } + [$editor, $map] = layeredCanvasEditor($root); + callEditorMethod($editor, 'dispatchInput', 'P'); + + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and(getCurrentToast($editor)?->level)->toBe(StatusLevel::WARN) + ->and(getCurrentToast($editor)?->message)->toContain($message) + ->and($map->isDirty())->toBeFalse(); +})->with([ + 'no tileset' => ['no tileset', 'names no tileset'], + 'no pieces' => ['no pieces', 'Tileset home has no pieces'], + 'broken tileset' => ['broken tileset', 'Pieces are unavailable: Tileset home needs at least one sheet.'], +]); + +it('previews the piece\'s footprint at the cursor while placing it', function () { + [$editor, $map] = createPieceCanvasEditor(); + choosePieceByKeys($editor); + callEditorMethod($editor, 'dispatchInput', "\033[C"); + + expect(callEditorMethod($editor, 'getPiecePreviewCells'))->toBe([0 => [1 => '=', 2 => null], 1 => [1 => '=', 2 => 'H']]) + ->and($map->renderPreview(4, 2, stampPreview: callEditorMethod($editor, 'getPiecePreviewCells'))[1]) + ->toContain("\033[7m=\033[0m", "\033[7mH\033[0m"); + + $frame = renderEditorPlainFrame($editor, 160, 45); + expect($frame)->toContain('.=..', '.=H.', 'PIECE Bed Enter:Stamp Esc:Done') + ->and($map->isDirty())->toBeFalse(); + + // A Normal-mode click only moves the cursor, and the preview with it. + $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + 2, $bounds['top'])); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + 2, $bounds['top'])); + expect(array_keys(callEditorMethod($editor, 'getPiecePreviewCells')[0]))->toBe([2, 3]) + ->and($map->isDirty())->toBeFalse(); + + // Esc ends placement and the preview goes with it. + callEditorMethod($editor, 'dispatchInput', "\033"); + expect(getEditorProperty($editor, 'piecePlacement'))->toBeNull() + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('./..', '.xx.')->not->toContain('PIECE'); +}); + +it('fits the placement hint to narrow canvases and shows P:Piece in Normal mode', function () { + [$editor] = createPieceCanvasEditor(); + expect(callEditorMethod($editor, 'getPiecePlacementHelp', 40))->toBeNull(); + choosePieceByKeys($editor); + + expect(callEditorMethod($editor, 'getPiecePlacementHelp', 40))->toBe('PIECE Bed Enter:Stamp Esc:Done') + ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 33))->toBe('PIECE Bed Enter:Stamp Esc:Done') + ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 29))->toBe('PIECE Enter:Stamp Esc:Done') + ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 24))->toBe('Enter:Stamp Esc:Done') + ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 12))->toBe('Esc:Done'); + callEditorMethod($editor, 'dispatchInput', "\033"); + expect(renderEditorPlainFrame($editor, 200, 45))->toContain('i:Paint L:Layer P:Piece'); +}); + +it('stamps glyphs on the piece\'s layer and its tiles in one undo step', function () { + [$editor, $map, $root] = createPieceCanvasEditor(); + $graphics = $map->directory . '/graphics'; + $before = $map->captureLayerSnapshot(); + $tilesBefore = $map->getTileLayerSources(); + $disk = sourceHashTree($map->directory); + setEditorProperty($editor, 'selectedPaintColor', 'cyan'); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:1'); + + choosePieceByKeys($editor); + setEditorProperty($editor, 'cursorX', 2); + callEditorMethod($editor, 'dispatchInput', "\n"); + + // The glyphs land on buildings, not the terrain layer being edited; a + // space glyph and a 0 tile leave their cells alone. + expect($map->getLayerSymbol('map:4', 2, 0))->toBe('=') + ->and($map->getLayerSymbol('map:4', 3, 0))->toBe(' ') + ->and($map->getLayerSymbol('map:4', 2, 1))->toBe('=') + ->and($map->getLayerSymbol('map:4', 3, 1))->toBe('H') + ->and($map->getLayerColor('map:4', 3, 1))->toBe('cyan') + ->and($map->getLayerSymbol('map:1', 2, 0))->toBe('.') + ->and(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:1') + ->and($map->isDirty())->toBeTrue(); + $tiles = $map->getTileLayerSources(); + expect(readTileEntries($tiles[$graphics . '/02.decor.tiles.php']))->toBe([['0', '5', '5L', '5R'], ['0', '0', '6', '5']]) + ->and(readTileEntries($tiles[$graphics . '/03.furniture.tiles.php']))->toBe([['0', '0', '7', '0'], ['0', '0', '0', '8R']]) + ->and($tiles[$graphics . '/01.floor.tiles.php'])->toBe($tilesBefore[$graphics . '/01.floor.tiles.php']); + + // Placement stays for the next stamp; one undo takes the whole stamp back. + expect(getEditorProperty($editor, 'piecePlacement'))->not->toBeNull(); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureLayerSnapshot())->toBe($before) + ->and($map->getTileLayerSources())->toBe($tilesBefore) + ->and($map->isDirty())->toBeFalse(); + callEditorMethod($editor, 'dispatchInput', "\x19"); + expect($map->getTileLayerSources())->toBe($tiles) + ->and($map->getLayerSymbol('map:4', 3, 1))->toBe('H'); + + // Saving writes the changed terminal layer and tile layers, and nothing else. + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(array_keys(array_diff_assoc(sourceHashTree($map->directory), $disk)))->toBe([ + 'graphics/02.decor.tiles.php', 'graphics/03.furniture.tiles.php', 'layers/04.buildings.map.php', + ]) + ->and(file_get_contents($graphics . '/02.decor.tiles.php'))->toBe("and(file_get_contents($graphics . '/03.furniture.tiles.php'))->toBe("and(array_map(static fn($layer): string => $layer->name, MapGraphics::loadFromDirectory( + $map->directory, 'test-map', 'home', loadLayeredMap($root)->getLayerSet(), $root . '/assets')->layers)) + ->toBe(['floor', 'decor', 'furniture']); + + // Undoing after the save and saving again restores every authored byte. + callEditorMethod($editor, 'dispatchInput', "\x1a"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(sourceHashTree($map->directory))->toBe($disk); +}); + +it('creates graphics/ for a map whose tileset pieces name tile layers it lacks', function () { + $root = layeredMapProject(); + writeTestTileset($root, pieces: buildTestPieces()); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace("'events' => [],", "'events' => [], 'tileset' => 'home',", (string) file_get_contents($data))); + [$editor, $map] = layeredCanvasEditor($root); + + choosePieceByKeys($editor, 2); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\n"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + + expect($map->getLayerSymbol('map:1', 0, 1))->toBe('~') + ->and(file_get_contents($map->directory . '/graphics/00.floor.tiles.php')) + ->toBe("and(MapGraphics::loadFromDirectory($map->directory, 'test-map', 'home', loadLayeredMap($root)->getLayerSet(), $root . '/assets')) + ->not->toBeNull(); +}); + +it('refuses a stamp that cannot be made whole and changes nothing', function (string $case, string $message) { + [$editor, $map, $root] = createPieceCanvasEditor($case === 'ragged'); + if ($case === 'unreadable tile layer') { + writeTileLayer($map->directory, '02.decor.tiles.php', "0 5 0 0\n0 0 0 x"); + [$editor, $map] = layeredCanvasEditor($root); + } + $before = $map->captureLayerSnapshot(); + $tiles = $map->getTileLayerSources(); + choosePieceByKeys($editor, $case === 'missing layer' ? 1 : 0); + setEditorProperty($editor, 'cursorX', match ($case) { 'off the edge' => 3, 'ragged' => 1, default => 0 }); + getEditorProperty($editor, 'toasts')->clear(); + callEditorMethod($editor, 'dispatchInput', "\n"); + + expect(getCurrentToast($editor)?->level)->toBe(StatusLevel::WARN) + ->and(getCurrentToast($editor)?->message)->toContain($message) + ->and(strtolower((string) getCurrentToast($editor)?->message))->toContain('nothing was changed.') + ->and($map->captureLayerSnapshot())->toBe($before) + ->and($map->getTileLayerSources())->toBe($tiles) + ->and($map->isDirty())->toBeFalse(); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureLayerSnapshot())->toBe($before); +})->with([ + 'missing layer' => ['missing layer', 'Lamp goes on the fixtures layer, which this map does not have.'], + 'off the edge' => ['off the edge', 'Bed (2 x 2) does not fit at (3, 0): the map has no cell at (4, 0).'], + 'ragged' => ['ragged', 'Bed (2 x 2) does not fit at (1, 0): the map has no cell at (2, 1).'], + 'unreadable tile layer' => ['unreadable tile layer', "'x' is not an RPG Maker tile identity"], +]); + +it('ends placement when the mode, map, layer or tool changes', function (string $key) { + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: buildTestPieces()); + loadLayeredMap($root)->duplicateTo($root . '/assets/Maps/zz-copy', 'zz-copy', 'Copy'); + [$editor, $map] = layeredCanvasEditor($root); + choosePieceByKeys($editor); + $before = $map->captureLayerSnapshot(); + if ($key === 'asset') { + setEditorProperty($editor, 'focusedPane', 'assets'); + callEditorMethod($editor, 'dispatchInput', 'j'); + setEditorProperty($editor, 'focusedPane', 'canvas'); + } elseif ($key === 'layer') { + callEditorMethod($editor, 'dispatchInput', 'L'); + callEditorMethod($editor, 'dispatchInput', "\n"); + } else { + callEditorMethod($editor, 'dispatchInput', $key); + } + expect(getEditorProperty($editor, 'piecePlacement'))->toBeNull(); + callEditorMethod($editor, 'stampPiece'); + expect($map->captureLayerSnapshot())->toBe($before); +})->with(['e', 'n', 'i', 'b', 's', 'layer', 'asset']); + +it('switches to Map mode when a piece is chosen from Event mode', function () { + [$editor] = createPieceCanvasEditor(); + callEditorMethod($editor, 'dispatchInput', 'e'); + choosePieceByKeys($editor); + + expect(getEditorProperty($editor, 'editingMode'))->toBe('map') + ->and(callEditorMethod($editor, 'getActivePiecePlacement')['piece']->id)->toBe('bed'); +}); + +it('keeps P a glyph in Paint mode', function () { + [$editor, $map] = createPieceCanvasEditor(); + foreach (['i', 'P'] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + + expect($map->getLayerSymbol('map:1', 0, 0))->toBe('P') + ->and(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and(getEditorProperty($editor, 'piecePlacement'))->toBeNull(); +}); + +it('lists P in the help overlay', function () { + [$editor] = createPieceCanvasEditor(); + $keys = array_column(getEditorProperty($editor, 'inputRouter')->describeBindings(), 'description', 'key'); + + expect($keys['P'] ?? null)->toContain('tileset piece'); +}); + +/** @return list> A tile layer source's entries, read as the Engine reads them. */ +function readTileEntries(string $source): array +{ + return Ichiloto\Editor\Maps\TileLayerSource::readLayer($source, 'layer.tiles.php')->getEntries(); +} From cfbb5619d1f434a6d565e145e78ac1f7515c3f1d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 28 Sep 2026 05:06:21 +0200 Subject: [PATCH 057/344] fix(canvas): paint what you type in Paint mode, whatever tool was left active With the Select tool left active from Normal mode, Paint mode loaded each typed glyph into the brush and painted nothing, while the canvas border still read "every key paints its glyph". Entering Paint mode with Select active now switches to the brush, and choosing Select while painting returns to Normal mode, where selecting happens. Under the Line and Rectangle tools, typing still chooses the glyph; the border and status now say so and that Enter sets the anchor, then draws. --- docs/manual.md | 10 ++++++---- src/Editor.php | 22 +++++++++++++++++++--- tests/Unit/LayeredCanvasTest.php | 27 +++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index c97541e..74af30f 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -372,10 +372,12 @@ line points to `i` instead. ### Paint mode -Every printable key paints its glyph at the cursor with the brush tool, or -loads it into the brush under a shape or select tool. `Arrows` move, -`Enter` applies the active tool, erase keys erase, and `Esc` returns to -Normal mode. Entering NPC mode or moving focus off the canvas also returns +Every printable key paints its glyph at the cursor with the brush tool. Under +the Line or Rectangle tools a key chooses the glyph instead, and `Enter` sets +the anchor and then draws; the canvas border says which. Selecting is a Normal +mode job: entering Paint mode with the Select tool active switches to the +brush, and choosing Select returns to Normal mode. `Arrows` move, `Enter` +applies the active tool, erase keys erase, and `Esc` returns to Normal mode. Entering NPC mode or moving focus off the canvas also returns to Normal. ### Mouse diff --git a/src/Editor.php b/src/Editor.php index c457c00..3d20a06 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -4702,8 +4702,19 @@ private function enterPaintMode(): void } $this->piecePlacement = null; + // Paint mode paints. Selecting is a Normal-mode job, so a Select tool + // left active from Normal mode must not turn typed glyphs into + // silent brush changes. + if ($this->canvasTool === CanvasTool::SELECT) { + $this->canvasTool = CanvasTool::BRUSH; + $this->canvasToolAnchor = null; + $this->canvasSelection = null; + } $this->inputMode = self::INPUT_PAINT; - $this->statusMessage = 'Paint mode: every key paints its glyph. Esc returns to Normal.'; + $this->statusMessage = $this->canvasTool === CanvasTool::BRUSH + ? 'Paint mode: every key paints its glyph. Esc returns to Normal.' + : sprintf('Paint mode, %s tool: keys choose the glyph. %s Esc returns to Normal.', + $this->canvasTool->label(), $this->describeCanvasToolUsage()); $this->renderFocusDependentArea(); } @@ -4739,6 +4750,9 @@ private function selectCanvasTool(CanvasTool $tool): void if ($tool !== CanvasTool::SELECT) { $this->canvasSelection = null; + } elseif ($this->inputMode === self::INPUT_PAINT) { + // Selecting happens in Normal mode, where keys are commands. + $this->leavePaintMode(); } $this->setStatus(sprintf('%s tool. %s', $tool->label(), $this->describeCanvasToolUsage())); @@ -18257,8 +18271,10 @@ private function createCanvasWindow(): EditorWindow ), $this->inputMode === self::INPUT_PAINT => $this->fitHelp( $layout['centerWidth'], - 'PAINT: every key paints its glyph Esc:Normal', - 'PAINT Esc:Normal', + ...($this->canvasTool === CanvasTool::BRUSH + ? ['PAINT: every key paints its glyph Esc:Normal', 'PAINT Esc:Normal'] + : [sprintf('PAINT %s: keys choose the glyph Enter:Anchor, then Draw Esc:Normal', $this->canvasTool->label()), + sprintf('PAINT %s Enter:Anchor/Draw Esc:Normal', $this->canvasTool->label()), 'PAINT Esc:Normal']), ), $this->getActivePiecePlacement() !== null => $this->getPiecePlacementHelp($layout['centerWidth']) ?? '', default => $this->fitHelp( diff --git a/tests/Unit/LayeredCanvasTest.php b/tests/Unit/LayeredCanvasTest.php index 1cef751..e086b49 100644 --- a/tests/Unit/LayeredCanvasTest.php +++ b/tests/Unit/LayeredCanvasTest.php @@ -58,6 +58,33 @@ ->and(array_filter($labels, static fn(string $label): bool => str_starts_with($label, 'Layer: ') || str_starts_with($label, 'Visibility: ')))->toBe([]); }); +it('paints typed glyphs after entering Paint mode with the Select tool left active', function () { + [$editor, $map] = layeredCanvasEditor(); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + // s picks Select in Normal mode; entering Paint mode must still paint, + // not silently change the brush symbol. + callEditorMethod($editor, 'dispatchInput', 's'); + callEditorMethod($editor, 'dispatchInput', 'i'); + callEditorMethod($editor, 'dispatchInput', '-'); + expect($map->getLayerSymbol('map:4', 0, 0))->toBe('-') + ->and(getEditorProperty($editor, 'canvasTool'))->toBe(CanvasTool::BRUSH) + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('PAINT: every key paints its glyph'); +}); + +it('says what typing does in Paint mode under a shape tool, and leaves Paint mode for Select', function () { + [$editor, $map] = layeredCanvasEditor(); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + callEditorMethod($editor, 'dispatchInput', 'l'); + callEditorMethod($editor, 'dispatchInput', 'i'); + $before = $map->getLayerSymbol('map:4', 0, 0); + callEditorMethod($editor, 'dispatchInput', '-'); + expect($map->getLayerSymbol('map:4', 0, 0))->toBe($before) + ->and(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('-') + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('PAINT Line: keys choose the glyph'); + callEditorMethod($editor, 'selectCanvasTool', CanvasTool::SELECT); + expect(getEditorProperty($editor, 'inputMode'))->toBe('normal'); +}); + it('uses colour selection clipboard shapes fill eyedropper and undo on gameplay and event layers', function (string $id) { [$editor, $map] = layeredCanvasEditor(); callEditorMethod($editor, 'selectCanvasLayer', $id); From 0aa238ba4bf671211236d2cca85269604760cf6a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 28 Sep 2026 05:25:39 +0200 Subject: [PATCH 058/344] feat(canvas): draw connected pieces such as walls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tileset piece with 'connects' => 'lines' is now drawn rather than stamped. The P picker lists it as "connected · · tiles: ...". While placing it, Enter draws the cell at the cursor and anchors there; the next Enter draws a line (same row or column) or the outline of a room from the anchor to the cursor and moves the anchor to the cursor. Backspace, and Delete, erase the piece's cell under the cursor; a cell that is not part of the piece is left alone with a status message. Esc drops the anchor before it ends placement. Every draw or erase is one undo step: it writes the drawn cells, then gives every drawn cell and every member beside a drawn or erased cell the glyph and tile entries of the shape its neighbours give it. Walls typed by hand join; other glyphs do not. A cell beyond the map refuses the whole change. The preview shows each pending cell with its shape glyph, and the canvas border shows the piece's Enter:Draw Del:Erase hint. Tile layers gain an exact cell-by-cell write (writeTileCells) that can set `0`, sharing its reader and canonical rewrite with piece stamps. An erase that leaves only empty tiles creates no tile layer. --- docs/manual.md | 58 ++++- src/Canvas/ConnectedPieceShaper.php | 76 ++++++ src/Canvas/PieceCanvas.php | 328 +++++++++++++++++++++++--- src/Editor.php | 14 +- src/Maps/MapLayers.php | 35 ++- src/Maps/TileLayerSource.php | 54 ++++- src/ProjectMap.php | 15 ++ tests/Unit/TilesetPiecesTest.php | 353 +++++++++++++++++++++++++++- 8 files changed, 871 insertions(+), 62 deletions(-) create mode 100644 src/Canvas/ConnectedPieceShaper.php diff --git a/docs/manual.md b/docs/manual.md index 74af30f..061c738 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -148,8 +148,8 @@ exist. `Esc` backs out exactly one level, everywhere: - a field edit returns to the pane -- piece placement ends, a pending canvas anchor is dropped, then a selection - is cleared +- a connected piece's anchor is dropped, then piece placement ends, a pending + canvas anchor is dropped, then a selection is cleared - a filter query is cleared before its list closes - an overlay or dialog closes - the Database screen closes @@ -227,8 +227,10 @@ A map offers the pieces of the tileset its data names (see is built from items instead of single glyphs. In Normal mode on the canvas, `P` opens the piece picker, one entry per piece with its footprint in cells, the gameplay layer its glyphs go on and the tile layers it writes, for example -`1 x 2 · fixtures · tiles: furniture`. Move with the arrows or `j` / `k`, press -`/` to filter by name, `Enter` to choose and `Esc` to cancel. The command +`1 x 2 · fixtures · tiles: furniture`, or `connected` in place of the +footprint for a [connected piece](#connected-pieces) such as a wall +(`connected · buildings · tiles: walls`). Move with the arrows or `j` / `k`, +press `/` to filter by name, `Enter` to choose and `Esc` to cancel. The command palette offers the same picker as `Pieces: Choose a piece to place`. When the map names no tileset, its tileset has no pieces, or the tileset cannot be loaded, the status line says so and nothing opens. @@ -265,6 +267,50 @@ layer name, with rows over the same footprint: The Engine's `docs/graphical-field.md` (Pieces) holds the contract. Every stamp reads the tileset again, so the tileset stays the one source of pieces. +#### Connected pieces + +A connected piece (`'connects' => 'lines'`), such as a wall or a fence, is +drawn rather than stamped, one cell per map cell, and joins the cells of the +same piece beside it. A cell belongs to the piece when its glyph on the +piece's gameplay layer is one of the piece's shape glyphs, so walls typed by +hand join too, while other glyphs (a `_`, say) are left alone and do not join. +Each cell takes a shape from the member cells beside it: joined only across it +is `horizontal`, only down it is `vertical`, and anything else (a corner, a +junction, a lone post) is a `corner`. A line's end cells are joined on one +side only, so a wall drawn across reads `----`, not `+--+`. + +While a connected piece is placed, the canvas border shows +`PIECE Enter:Draw Del:Erase Esc:Done`, and `Esc:Unanchor` while an +anchor is set. + +- `Enter` with no anchor draws the cell at the cursor and anchors there. +- `Enter` with an anchor draws from the anchor to the cursor: a straight line + when they share a row or a column, otherwise the outline of the rectangle + with the anchor and the cursor as opposite corners, which is a room. The + anchor then moves to the cursor, so the next `Enter` carries on from there. +- The erase keys (`Backspace`, and `Delete`) erase the piece's cell under the + cursor. A cell that is not part of the piece is left alone, and the status + line says so. +- `Esc` drops the anchor when one is set; otherwise it ends placement. + +The canvas previews the cells `Enter` would draw, each with the glyph it would +take, in reverse video. Every draw or erase is one undo step. It writes the +drawn cells, then reshapes every drawn cell and every member cell beside a +drawn or erased cell: its glyph becomes its shape's glyph and its entry on each +of the piece's tile layers becomes that shape's tile. So drawing a wall that +meets another turns the meeting cell into a corner, and erasing a cell +straightens the corners beside it. Drawn cells take the brush colour; reshaped +cells keep theirs. An erased cell becomes a space on the gameplay layer and +`0` on the piece's tile layers. A draw that reaches a cell beyond the map +(ragged rows included) changes nothing, and neither does a piece whose +gameplay layer the map lacks. A missing tile layer is created as for a stamp, +and undo, redo and `Ctrl+S` work exactly as they do for stamps. The tileset +lists a connected piece with one glyph per shape and one tile entry per tile +layer, or one per shape: +`'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'connects' => 'lines', 'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'], 'tiles' => ['walls' => '5888']]`. +An A4 wall top can be the one entry for every shape, since the autotile shapes +its own edges. + ### Map Graphics A map may name a tileset in its data file (`'tileset' => 'home'`, read from @@ -364,8 +410,8 @@ and the GUI itself are not delivered by this TUI boundary correction. See the | `u` / `U` | Undo / redo (same as `Ctrl+Z` / `Ctrl+Y`) | | `?` | Open the help overlay | | `Arrows` | Move the cursor | -| `Enter` | Apply the active tool, or stamp the piece being placed | -| `Esc` | Pop one canvas level: piece placement, a pending tool anchor, then the selection | +| `Enter` | Apply the active tool, or stamp or draw the piece being placed | +| `Esc` | Pop one canvas level: a connected piece's anchor, piece placement, a pending tool anchor, then the selection | Typing an unassigned printable key in Normal mode paints nothing; the status line points to `i` instead. diff --git a/src/Canvas/ConnectedPieceShaper.php b/src/Canvas/ConnectedPieceShaper.php new file mode 100644 index 0000000..e99a147 --- /dev/null +++ b/src/Canvas/ConnectedPieceShaper.php @@ -0,0 +1,76 @@ + Neighbour offsets, in the order {@see TilesetPiece::getLineShape()} takes them. */ + private const array NEIGHBOURS = ['north' => [0, -1], 'east' => [1, 0], 'south' => [0, 1], 'west' => [-1, 0]]; + + private function __construct() + { + } + + /** + * @param list $drawn The cells that join the piece. + * @param list $erased The cells that leave it. + * @param Closure(int, int): bool $isMemberCell Whether a cell belongs to the piece before the change; false beyond the map. + * @return list Each cell to write with the shape it takes, or null for an erased + * cell: the drawn cells first, in order, then the erased cells, then the neighbours they reshape. + */ + public static function reshapeCells(TilesetPiece $piece, array $drawn, array $erased, Closure $isMemberCell): array + { + $overrides = []; + foreach ($erased as $cell) { + $overrides[self::getCellKey($cell['x'], $cell['y'])] = false; + } + foreach ($drawn as $cell) { + $overrides[self::getCellKey($cell['x'], $cell['y'])] = true; + } + $isMember = static fn(int $x, int $y): bool => $overrides[self::getCellKey($x, $y)] ?? $isMemberCell($x, $y); + + $cells = []; + foreach ([...$drawn, ...$erased] as $cell) { + $cells[self::getCellKey($cell['x'], $cell['y'])] ??= ['x' => $cell['x'], 'y' => $cell['y']]; + } + foreach ([...$drawn, ...$erased] as $cell) { + foreach (self::NEIGHBOURS as [$deltaX, $deltaY]) { + $x = $cell['x'] + $deltaX; + $y = $cell['y'] + $deltaY; + if (! isset($cells[self::getCellKey($x, $y)]) && $isMember($x, $y)) { + $cells[self::getCellKey($x, $y)] = ['x' => $x, 'y' => $y]; + } + } + } + + return array_values(array_map(static function (array $cell) use ($piece, $isMember): array { + if (! $isMember($cell['x'], $cell['y'])) { + return $cell + ['shape' => null]; + } + $joined = array_map(static fn(array $offset): bool => $isMember($cell['x'] + $offset[0], $cell['y'] + $offset[1]), self::NEIGHBOURS); + + return $cell + ['shape' => $piece->getLineShape(...$joined)]; + }, $cells)); + } + + private static function getCellKey(int $x, int $y): string + { + return "{$x},{$y}"; + } +} diff --git a/src/Canvas/PieceCanvas.php b/src/Canvas/PieceCanvas.php index 1d5dfbc..92ee364 100644 --- a/src/Canvas/PieceCanvas.php +++ b/src/Canvas/PieceCanvas.php @@ -10,6 +10,8 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\Status\StatusLevel; use Ichiloto\Editor\UI\PaletteItem; +use Ichiloto\Editor\History\PaintStrokeCommand; +use Ichiloto\Engine\Rendering\Tilesets\TileId; use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Ichiloto\Engine\Rendering\Tilesets\TilesetPiece; @@ -19,10 +21,18 @@ * the gameplay layer it names and its tiles on the tile layers it names, as * one undo step. The canvas shows and previews only the glyphs; the tiles * are never shown or asked for here. + * + * A connected piece, such as a wall, is drawn instead: Enter draws the cell + * at the cursor and anchors there, and the next Enter draws a line or a + * room's outline from the anchor to the cursor. Every draw or erase reshapes + * the piece's cells it touches (see {@see ConnectedPieceShaper}). */ trait PieceCanvas { - /** @var array{map: ProjectMap, piece: TilesetPiece}|null The piece being placed, on the map it was chosen for. */ + /** + * @var array{map: ProjectMap, piece: TilesetPiece, anchor: array{x: int, y: int}|null}|null The piece being placed, on the + * map it was chosen for, with the cell a connected piece's next draw starts from. + */ private ?array $piecePlacement = null; /** @@ -45,15 +55,18 @@ private function openPiecePicker(): void $this->eventOptionDialogMarker = null; $this->eventOptionDialogPath = null; $this->eventOptionDialogTitle = 'Piece'; - $this->eventOptionDialogEntries = array_map(static fn(TilesetPiece $piece): array => [ - 'label' => $piece->name, - 'value' => $piece->id, - 'description' => implode(' · ', array_filter([ - sprintf('%d x %d', $piece->width, $piece->height), - $piece->layer, - $piece->tiles === [] ? null : 'tiles: ' . implode(', ', array_keys($piece->tiles)), - ])), - ], array_values($pieces)); + $this->eventOptionDialogEntries = array_map(static function (TilesetPiece $piece): array { + $tileLayers = array_keys($piece->connects === null ? $piece->tiles : $piece->shapeTiles); + return [ + 'label' => $piece->name, + 'value' => $piece->id, + 'description' => implode(' · ', array_filter([ + $piece->connects === null ? sprintf('%d x %d', $piece->width, $piece->height) : 'connected', + $piece->layer, + $tileLayers === [] ? null : 'tiles: ' . implode(', ', $tileLayers), + ])), + ]; + }, array_values($pieces)); $this->selectedEventOptionIndex = $this->resolveEventOptionSelectionIndex($this->piecePlacement['piece']->id ?? ''); $this->isEventOptionDialogOpen = true; $this->statusMessage = 'Choose a piece to place.'; @@ -105,8 +118,10 @@ private function choosePiece(string $id): void $this->canvasToolAnchor = null; $this->inputMode = self::INPUT_NORMAL; $this->setFocusedPane(self::FOCUS_CANVAS, false); - $this->piecePlacement = ['map' => $map, 'piece' => $piece]; - $this->setStatus(sprintf('Placing %s. Arrows move it, Enter stamps, Esc when done.', $piece->name)); + $this->piecePlacement = ['map' => $map, 'piece' => $piece, 'anchor' => null]; + $this->setStatus(sprintf($piece->connects === null + ? 'Placing %s. Arrows move it, Enter stamps, Esc when done.' + : 'Drawing %s. Enter draws at the cursor and anchors there, Del erases, Esc when done.', $piece->name)); $this->renderFocusDependentArea(); } @@ -114,7 +129,7 @@ private function choosePiece(string $id): void * The placement in force: the piece being placed, while its map is the * selected one and the canvas is in Map mode's Normal input. * - * @return array{map: ProjectMap, piece: TilesetPiece}|null + * @return array{map: ProjectMap, piece: TilesetPiece, anchor: array{x: int, y: int}|null}|null */ private function getActivePiecePlacement(): ?array { @@ -124,6 +139,22 @@ private function getActivePiecePlacement(): ?array ? $placement : null; } + /** + * Esc while placing: drops a connected piece's anchor when one is set, + * and otherwise ends placement. + */ + private function backOutOfPiecePlacement(): void + { + $placement = $this->getActivePiecePlacement(); + if ($placement !== null && $placement['anchor'] !== null) { + $this->piecePlacement['anchor'] = null; + $this->setStatus(sprintf('Anchor dropped. Enter draws %s at the cursor; Esc when done.', $placement['piece']->name)); + $this->renderCanvasArea(); + return; + } + $this->endPiecePlacement('Piece placement ended.'); + } + private function endPiecePlacement(string $statusMessage = ''): void { $this->piecePlacement = null; @@ -136,16 +167,21 @@ private function endPiecePlacement(string $statusMessage = ''): void /** * The piece's footprint with its top-left cell at the cursor, for the * canvas preview: a glyph where the piece writes one, null where a space - * glyph leaves the map's cell as it is. + * glyph leaves the map's cell as it is. A connected piece previews the + * cells Enter would draw, each with the glyph of the shape it would take. * * @return array> Cells by row and column. */ private function getPiecePreviewCells(): array { - $piece = $this->getActivePiecePlacement()['piece'] ?? null; - if (! $piece instanceof TilesetPiece) { + $placement = $this->getActivePiecePlacement(); + if ($placement === null) { return []; } + $piece = $placement['piece']; + if ($piece->connects !== null) { + return $this->getConnectedPreviewCells($placement['map'], $piece, $placement['anchor']); + } $cells = []; foreach ($piece->glyphs as $row => $symbols) { foreach ($symbols as $column => $symbol) { @@ -156,30 +192,61 @@ private function getPiecePreviewCells(): array } /** - * Stamps the piece at the cursor as one undo step: its glyphs on the - * gameplay layer it names, in the brush colour, and its tiles on the tile - * layers it names, creating any the map does not have yet. Space glyphs - * and `0` tiles leave their cells as they are. The piece is read from - * the tileset again, so the tileset stays the one source of pieces. A - * stamp that cannot be made whole changes nothing. + * Enter while placing: stamps an item piece, or draws a connected one. + * The piece is read from the tileset again, so the tileset stays the one + * source of pieces. */ - private function stampPiece(): void + private function applyPieceAtCursor(): void { - $placement = $this->getActivePiecePlacement(); + $placement = $this->reloadPiecePlacement(); if ($placement === null) { return; } - $map = $placement['map']; - $pieces = $this->loadCanvasPieces($map); + if ($placement['piece']->connects === null) { + $this->stampPiece($placement['map'], $placement['piece']); + return; + } + $this->drawConnectedPiece($placement['map'], $placement['piece'], $placement['anchor']); + } + + /** + * The placement in force with its piece read from the tileset again, or + * null after ending placement when the piece is gone. + * + * @return array{map: ProjectMap, piece: TilesetPiece, anchor: array{x: int, y: int}|null}|null + */ + private function reloadPiecePlacement(): ?array + { + $placement = $this->getActivePiecePlacement(); + if ($placement === null) { + return null; + } + $pieces = $this->loadCanvasPieces($placement['map']); $piece = $pieces[$placement['piece']->id] ?? null; if ($piece === null) { if ($pieces !== null) { $this->setStatus("{$placement['piece']->name} is no longer in the map's tileset. Press P to choose a piece.", StatusLevel::WARN); } $this->endPiecePlacement(); - return; + return null; } $this->piecePlacement['piece'] = $piece; + if ($piece->connects === null) { + $this->piecePlacement['anchor'] = null; + } + + return $this->piecePlacement; + } + + /** + * Stamps the piece at the cursor as one undo step: its glyphs on the + * gameplay layer it names, in the brush colour, and its tiles on the tile + * layers it names, creating any the map does not have yet. Space glyphs + * and `0` tiles leave their cells as they are. A stamp that cannot be + * made whole changes nothing. + */ + private function stampPiece(ProjectMap $map, TilesetPiece $piece): void + { $x = $this->cursorX; $y = $this->cursorY; try { @@ -215,7 +282,20 @@ private function stampPiece(): void $this->renderCanvasArea(); return; } - $this->recordCommand(new GenericCommand('Piece stamp', + $this->recordPieceCommand('Piece stamp', $map, $stroke, $tilesBefore, $tilesAfter); + $this->setStatus(sprintf('Stamped %s at (%d, %d). Enter stamps again; Esc when done.', $piece->name, $x, $y)); + $this->renderCanvasArea(); + } + + /** + * Records a piece's glyph stroke and tile layer change as one undo step. + * + * @param array $tilesBefore Tile layer sources before the change. + * @param array $tilesAfter Tile layer sources after it. + */ + private function recordPieceCommand(string $label, ProjectMap $map, PaintStrokeCommand $stroke, array $tilesBefore, array $tilesAfter): void + { + $this->recordCommand(new GenericCommand($label, static function () use ($stroke, $map, $tilesAfter): void { $stroke->execute(); $map->restoreTileLayerSources($tilesAfter); @@ -225,10 +305,169 @@ static function () use ($stroke, $map, $tilesBefore): void { $map->restoreTileLayerSources($tilesBefore); }, )); - $this->setStatus(sprintf('Stamped %s at (%d, %d). Enter stamps again; Esc when done.', $piece->name, $x, $y)); + } + + /** + * Draws a connected piece: the cell at the cursor without an anchor, + * otherwise the line or room outline from the anchor to the cursor. The + * anchor then moves to the cursor, so the next draw continues from there. + * + * @param array{x: int, y: int}|null $anchor + */ + private function drawConnectedPiece(ProjectMap $map, TilesetPiece $piece, ?array $anchor): void + { + $cells = $this->getConnectedDrawCells($anchor); + $changed = $this->applyConnectedPiece($map, $piece, $cells, [], 'Piece draw'); + if ($changed === null) { + return; + } + $this->piecePlacement['anchor'] = ['x' => $this->cursorX, 'y' => $this->cursorY]; + $drew = $changed + ? sprintf('Drew %d %s %s.', count($cells), mb_strtolower($piece->name), count($cells) === 1 ? 'cell' : 'cells') + : sprintf('%s is already drawn there.', $piece->name); + $this->setStatus($drew . ($anchor === null + ? ' Anchor set; move and press Enter to draw a line or a room.' + : ' Enter draws on from here; Esc drops the anchor.')); $this->renderCanvasArea(); } + /** + * Erases the connected piece's cell under the cursor and reshapes the + * cells beside it. A cell that is not part of the piece is left alone. + */ + private function eraseConnectedPieceCell(): void + { + $placement = $this->reloadPiecePlacement(); + if ($placement === null || $placement['piece']->connects === null) { + return; + } + $map = $placement['map']; + $piece = $placement['piece']; + $x = $this->cursorX; + $y = $this->cursorY; + try { + $layer = $this->findPieceLayer($map, $piece); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderCanvasArea(); + return; + } + if (! $this->resolveConnectedMemberLookup($map, $layer, $piece)($x, $y)) { + $this->setStatus(sprintf('(%d, %d) is not part of a %s, so nothing was erased.', $x, $y, mb_strtolower($piece->name))); + $this->renderCanvasArea(); + return; + } + if ($this->applyConnectedPiece($map, $piece, [], [['x' => $x, 'y' => $y]], 'Piece erase') !== null) { + $this->setStatus(sprintf('Erased the %s cell at (%d, %d).', mb_strtolower($piece->name), $x, $y)); + $this->renderCanvasArea(); + } + } + + /** + * Draws and erases a connected piece's cells as one undo step: writes + * them, then gives every drawn cell and every member beside a drawn or + * erased cell the glyph and tiles of its shape. Glyphs go on the piece's + * gameplay layer, drawn cells in the brush colour and reshaped cells + * keeping theirs; tiles go on the piece's tile layers, `0` where a cell + * was erased. A cell beyond the map refuses the whole change. + * + * @param list $drawn + * @param list $erased + * @return bool|null Whether anything changed, or null when refused. + */ + private function applyConnectedPiece(ProjectMap $map, TilesetPiece $piece, array $drawn, array $erased, string $label): ?bool + { + try { + $layer = $this->findPieceLayer($map, $piece); + foreach ([...$drawn, ...$erased] as $cell) { + if (! $map->hasLayerCell($layer, $cell['x'], $cell['y'])) { + throw new MapSourceRefusal(sprintf('%s cannot reach (%d, %d): the map has no cell there. Nothing was changed.', + $piece->name, $cell['x'], $cell['y'])); + } + } + $cells = ConnectedPieceShaper::reshapeCells($piece, $drawn, $erased, $this->resolveConnectedMemberLookup($map, $layer, $piece)); + $tiles = []; + foreach ($piece->shapeTiles as $tileLayer => $entries) { + foreach ($cells as $cell) { + $tiles[$tileLayer][] = ['x' => $cell['x'], 'y' => $cell['y'], + 'entry' => $cell['shape'] === null ? (string) TileId::EMPTY : $entries[$cell['shape']]]; + } + } + $this->finalizeActiveStroke(); + $tilesBefore = $map->getTileLayerSources(); + $map->writeTileCells($tiles); + $tilesAfter = $map->getTileLayerSources(); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderCanvasArea(); + return null; + } + $drawnKeys = array_flip(array_map(static fn(array $cell): string => "{$cell['x']},{$cell['y']}", $drawn)); + $writes = array_map(fn(array $cell): array => [ + 'x' => $cell['x'], + 'y' => $cell['y'], + 'symbol' => $cell['shape'] === null ? ' ' : $piece->shapes[$cell['shape']], + 'color' => isset($drawnKeys["{$cell['x']},{$cell['y']}"]) ? $this->selectedPaintColor : null, + ], $cells); + [$stroke] = $this->writeCanvasCells($map, $layer, $writes, $label); + if (! $stroke->hasChanges() && $tilesAfter === $tilesBefore) { + return false; + } + $this->recordPieceCommand($label, $map, $stroke, $tilesBefore, $tilesAfter); + + return true; + } + + /** + * The cells Enter would draw: the cursor's cell without an anchor, + * otherwise the outline of the rectangle with the anchor and the cursor + * as opposite corners, which is a straight line when they share a row or + * a column. + * + * @param array{x: int, y: int}|null $anchor + * @return list + */ + private function getConnectedDrawCells(?array $anchor): array + { + $anchor ??= ['x' => $this->cursorX, 'y' => $this->cursorY]; + + return array_values(ToolGeometry::rectangleOutline($anchor['x'], $anchor['y'], $this->cursorX, $this->cursorY)); + } + + /** + * The cells Enter would draw, each with the glyph of the shape it would + * take, for the canvas preview. + * + * @param array{x: int, y: int}|null $anchor + * @return array> Cells by row and column. + */ + private function getConnectedPreviewCells(ProjectMap $map, TilesetPiece $piece, ?array $anchor): array + { + $drawn = $this->getConnectedDrawCells($anchor); + try { + $isMemberCell = $this->resolveConnectedMemberLookup($map, $this->findPieceLayer($map, $piece), $piece); + } catch (MapSourceRefusal) { + $isMemberCell = static fn(int $x, int $y): bool => false; + } + $preview = []; + foreach (array_slice(ConnectedPieceShaper::reshapeCells($piece, $drawn, [], $isMemberCell), 0, count($drawn)) as $cell) { + $preview[$cell['y']][$cell['x']] = $piece->shapes[(string) $cell['shape']]; + } + + return $preview; + } + + /** + * Whether a cell of the layer belongs to the connected piece now: it is + * on the map and holds one of the piece's glyphs. + * + * @return \Closure(int, int): bool + */ + private function resolveConnectedMemberLookup(ProjectMap $map, string $layer, TilesetPiece $piece): \Closure + { + return static fn(int $x, int $y): bool => $map->hasLayerCell($layer, $x, $y) && $piece->isMember($map->getLayerSymbol($layer, $x, $y)); + } + /** * The id of the gameplay layer the piece's glyphs go on. * @@ -248,15 +487,30 @@ private function findPieceLayer(ProjectMap $map, TilesetPiece $piece): string /** The canvas border hint while a piece is being placed, or null when none is. */ private function getPiecePlacementHelp(int $windowWidth): ?string { - $piece = $this->getActivePiecePlacement()['piece'] ?? null; - return $piece instanceof TilesetPiece ? $this->fitHelp( + $placement = $this->getActivePiecePlacement(); + if ($placement === null) { + return null; + } + $piece = $placement['piece']; + if ($piece->connects === null) { + return $this->fitHelp( + $windowWidth, + sprintf('PIECE %s Enter:Stamp Esc:Done', $piece->name), + sprintf('PIECE %s Enter:Stamp Esc:Done', $piece->name), + 'PIECE Enter:Stamp Esc:Done', + 'Enter:Stamp Esc:Done', + 'Esc:Done', + ); + } + $escape = $placement['anchor'] === null ? 'Esc:Done' : 'Esc:Unanchor'; + return $this->fitHelp( $windowWidth, - sprintf('PIECE %s Enter:Stamp Esc:Done', $piece->name), - sprintf('PIECE %s Enter:Stamp Esc:Done', $piece->name), - 'PIECE Enter:Stamp Esc:Done', - 'Enter:Stamp Esc:Done', - 'Esc:Done', - ) : null; + sprintf('PIECE %s Enter:Draw Del:Erase %s', $piece->name, $escape), + sprintf('PIECE %s Enter:Draw Del:Erase %s', $piece->name, $escape), + "PIECE Enter:Draw Del:Erase {$escape}", + "Enter:Draw Del:Erase {$escape}", + $escape, + ); } /** @return list */ diff --git a/src/Editor.php b/src/Editor.php index 3d20a06..f891c16 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -1371,7 +1371,7 @@ private function buildInputRouter(): InputRouter // other panes' typing untouched. KeyBinding::when($this->isNormalModeCommand('i'), $this->enterPaintMode(...), 'i', 'Canvas: enter Paint mode (every key paints; Esc returns to Normal)'), KeyBinding::when($this->isNormalModeCommand('L'), fn() => $this->openCanvasLayerPicker(), 'L', 'Canvas: choose the layer to edit'), - KeyBinding::when($this->isNormalModeCommand('P'), fn() => $this->openPiecePicker(), 'P', 'Canvas: choose a tileset piece to place (Enter stamps it, Esc when done)'), + KeyBinding::when($this->isNormalModeCommand('P'), fn() => $this->openPiecePicker(), 'P', 'Canvas: choose a tileset piece to place (Enter stamps or draws it, Esc when done)'), KeyBinding::when($this->isNormalModeCommand(']'), fn() => $this->cycleCanvasLayer(), ']', 'Canvas: next layer'), KeyBinding::when($this->isNormalModeCommand('['), fn() => $this->cycleCanvasLayer(-1), '[', 'Canvas: previous layer'), KeyBinding::when($this->isNormalModeCommand('v'), fn() => $this->toggleCanvasLayerVisibility(), 'v', 'Canvas: toggle selected layer visibility'), @@ -1766,7 +1766,7 @@ private function handleCanvasPaneInput(string $input, string $normalizedInput): } if ($this->getActivePiecePlacement() !== null) { - $this->endPiecePlacement('Piece placement ended.'); + $this->backOutOfPiecePlacement(); return; } @@ -4247,6 +4247,14 @@ private function adoptPaintSymbol(string $symbol): void */ private function handleEraseInput(string $input): bool { + // A connected piece being drawn erases its own cell, reshaping the + // cells beside it; its hint names the key Del, so Delete works too. + if (($this->getActivePiecePlacement()['piece']->connects ?? null) !== null + && in_array($input, ["\177", "\010", "\033[3~"], true)) { + $this->eraseConnectedPieceCell(); + return true; + } + if ($input === "\177" || $input === "\010") { $this->adoptPaintSymbol(' '); return true; @@ -5026,7 +5034,7 @@ private function describeCanvasToolState(): string private function applyCanvasToolAtCursor(): void { if ($this->getActivePiecePlacement() !== null) { - $this->stampPiece(); + $this->applyPieceAtCursor(); return; } diff --git a/src/Maps/MapLayers.php b/src/Maps/MapLayers.php index 901e52a..98ce7d3 100644 --- a/src/Maps/MapLayers.php +++ b/src/Maps/MapLayers.php @@ -11,12 +11,13 @@ use Ichiloto\Engine\Field\MapLayerSource; use Ichiloto\Engine\Field\MapLayerSet; use Ichiloto\Engine\Field\MapLayer; +use Ichiloto\Engine\Rendering\Tilesets\TileId; /** * Editable layers and their file-set boundary. Layer ids survive renaming. * The map's graphical tile layers share the boundary: the TUI never paints - * single tiles, but writes a stamped piece's tiles and resizes, relocates and - * removes them with the map. + * single tiles, but writes the tiles of stamped and drawn pieces and resizes, + * relocates and removes them with the map. */ final class MapLayers { @@ -251,6 +252,36 @@ public function writeTileEntries(array $tiles, int $x, int $y): void $this->tileSources = $sources; } + /** + * Sets tile entries cell by cell in the named tile layers, `0` included, + * for a drawn or erased connected piece. A layer the map does not have + * yet is created as {@see writeTileEntries()} creates one, unless every + * entry for it is `0`, which an empty layer already holds. Every layer is + * written before anything changes, so one that cannot take its entries + * refuses them all. + * + * @param array> $cells The entry for each cell, keyed by tile layer name. + * @throws MapSourceRefusal When a layer cannot be read, does not match the map, cannot be created, or a cell falls outside it. + */ + public function writeTileCells(array $cells): void + { + $set = $this->getLayerSet(); + $sources = $this->tileSources; + foreach ($cells as $name => $entries) { + $path = $this->findTileLayerPath((string) $name, $sources); + if ($path === null) { + if (array_filter($entries, static fn(array $cell): bool => $cell['entry'] !== (string)TileId::EMPTY) === []) { + continue; + } + $path = $this->buildTileLayerPath((string) $name, $sources); + } + $sources[$path] = TileLayerSource::setCellEntries($sources[$path] ?? TileLayerSource::createEmpty($set), + $this->getDisplayPath($path), $set, $entries, $this->baselineSources[$path] ?? null); + } + ksort($sources, SORT_STRING); + $this->tileSources = $sources; + } + /** @param array $tileSources Tile layer sources by path, as {@see getTileSources()} returns them. */ public function restoreTileSources(array $tileSources): void { diff --git a/src/Maps/TileLayerSource.php b/src/Maps/TileLayerSource.php index 5990c73..7a9ea18 100644 --- a/src/Maps/TileLayerSource.php +++ b/src/Maps/TileLayerSource.php @@ -17,8 +17,8 @@ * * The TUI never displays tiles or paints single tiles; painting belongs to * the GUI editor. It carries these files with their map and changes one only - * when the map's dimensions change or a tileset piece is stamped, reading it - * exactly as the Engine does. + * when the map's dimensions change or a tileset piece is stamped or drawn, + * reading it exactly as the Engine does. */ final class TileLayerSource { @@ -93,18 +93,50 @@ public static function resize(string $source, string $displayPath, MapLayerSet $ */ public static function writeEntries(string $source, string $displayPath, MapLayerSet $layers, int $x, int $y, array $rows, ?string $baseline = null): string { - $layer = self::readMatchingLayer($source, $displayPath, $layers, 'stamping a piece'); + $cells = []; + foreach ($rows as $row => $entries) { + foreach ($entries as $column => $entry) { + $cells[] = ['x' => $x + $column, 'y' => $y + $row, 'entry' => $entry]; + } + } + + return self::writeCellEntries($source, $displayPath, $layers, $cells, 'stamping a piece', $baseline, keepsEmptyCells: true); + } + + /** + * Sets each listed cell's entry, `0` included, so a connected piece can + * give every cell it draws or reshapes its shape's tile and clear the + * cells it erases. The result is rewritten as {@see resize()} rewrites a + * layer. + * + * @param list $cells The entry for each cell. + * @param MapLayerSet $layers The map's terminal layers. + * @throws MapSourceRefusal When the layer cannot be read, does not match the map, or a cell falls outside it; nothing is changed. + */ + public static function setCellEntries(string $source, string $displayPath, MapLayerSet $layers, array $cells, ?string $baseline = null): string + { + return self::writeCellEntries($source, $displayPath, $layers, $cells, 'drawing a piece', $baseline, keepsEmptyCells: false); + } + + /** + * Writes entries into a layer's cells, refusing them all when one cell + * is outside the layer. + * + * @param list $cells The entry for each cell. + * @param bool $keepsEmptyCells Whether a `0` entry leaves its cell as it was instead of emptying it. + */ + private static function writeCellEntries(string $source, string $displayPath, MapLayerSet $layers, array $cells, string $action, ?string $baseline, bool $keepsEmptyCells): string + { + $layer = self::readMatchingLayer($source, $displayPath, $layers, $action); $entries = $layer->getEntries(); $written = $entries; - foreach ($rows as $row => $cells) { - foreach ($cells as $column => $entry) { - if (! isset($entries[$y + $row][$x + $column])) { - throw new MapSourceRefusal(sprintf('%s has no cell at (%d, %d); nothing was changed.', $displayPath, $x + $column, $y + $row)); - } - if ($entry !== (string)TileId::EMPTY) { - $written[$y + $row][$x + $column] = $entry; - } + foreach ($cells as $cell) { + if (! isset($entries[$cell['y']][$cell['x']])) { + throw new MapSourceRefusal(sprintf('%s has no cell at (%d, %d); nothing was changed.', $displayPath, $cell['x'], $cell['y'])); + } + if (! $keepsEmptyCells || $cell['entry'] !== (string)TileId::EMPTY) { + $written[$cell['y']][$cell['x']] = $cell['entry']; } } diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 81969fa..acb9ea8 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -478,6 +478,21 @@ public function writeTileEntries(array $tiles, int $x, int $y): void $this->touchState(); } + /** + * Sets tile entries cell by cell in the named tile layers, `0` included, + * for a drawn or erased connected piece, creating a tile layer the map + * does not have yet when it gets a tile. + * + * @param array> $cells The entry for each cell, keyed by tile layer name. + * @throws MapSourceRefusal When a layer cannot take the entries; nothing is changed. + */ + public function writeTileCells(array $cells): void + { + $this->assertEditable(); + $this->layers->writeTileCells($cells); + $this->touchState(); + } + /** * Restores the tile layers {@see getTileLayerSources()} returned, for * undo and redo. diff --git a/tests/Unit/TilesetPiecesTest.php b/tests/Unit/TilesetPiecesTest.php index e3def52..c9f1dad 100644 --- a/tests/Unit/TilesetPiecesTest.php +++ b/tests/Unit/TilesetPiecesTest.php @@ -9,8 +9,9 @@ /** * Tileset pieces on the terminal canvas: P chooses a piece of the map's - * tileset and Enter stamps its glyphs and tiles as one undo step. The layered - * test map is 4 x 2 cells: terrain `./..` over `.xx.` from buildings. + * tileset and Enter stamps its glyphs and tiles as one undo step, or draws a + * connected piece such as a wall. The layered test map is 4 x 2 cells: + * terrain `./..` over `.xx.` from buildings; walls are drawn on a 6 x 5 map. */ /** @return array> Synthetic pieces for tileset `home`. */ @@ -21,6 +22,9 @@ function buildTestPieces(): array 'tiles' => ['decor' => ['5L 5R', '6 0'], 'furniture' => ['7 0', '0 8R']]], 'lamp' => ['name' => 'Lamp', 'layer' => 'fixtures', 'glyphs' => ['i']], 'rug' => ['name' => 'Rug', 'layer' => 'terrain', 'glyphs' => ['~~'], 'tiles' => ['floor' => ['2816 2816']]], + 'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'connects' => 'lines', + 'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'], + 'tiles' => ['walls' => ['horizontal' => '10', 'vertical' => '11', 'corner' => '12']]], ]; } @@ -60,6 +64,7 @@ function choosePieceByKeys(Editor $editor, int $downs = 0): void ['label' => 'Bed', 'value' => 'bed', 'description' => '2 x 2 · buildings · tiles: decor, furniture'], ['label' => 'Lamp', 'value' => 'lamp', 'description' => '1 x 1 · fixtures'], ['label' => 'Rug', 'value' => 'rug', 'description' => '2 x 1 · terrain · tiles: floor'], + ['label' => 'Wall', 'value' => 'wall', 'description' => 'connected · buildings · tiles: walls'], ]); // Esc cancels without placing anything. @@ -260,7 +265,7 @@ function choosePieceByKeys(Editor $editor, int $downs = 0): void callEditorMethod($editor, 'dispatchInput', $key); } expect(getEditorProperty($editor, 'piecePlacement'))->toBeNull(); - callEditorMethod($editor, 'stampPiece'); + callEditorMethod($editor, 'applyPieceAtCursor'); expect($map->captureLayerSnapshot())->toBe($before); })->with(['e', 'n', 'i', 'b', 's', 'layer', 'asset']); @@ -291,6 +296,348 @@ function choosePieceByKeys(Editor $editor, int $downs = 0): void expect($keys['P'] ?? null)->toContain('tileset piece'); }); +/** + * The editor on a 6 x 5 map naming tileset `home`, for drawing walls: dots on + * terrain under a buildings layer holding the given rows (spaces by default), + * with a floor tile layer and no walls tile layer yet. A ragged map's last + * row is 3 cells wide. + * + * @param list|null $buildings The buildings layer's rows, as authored. + * @param array>|null $pieces The tileset's pieces, the test pieces by default. + * @return array{Editor, ProjectMap, string} + */ +function createWallCanvasEditor(?array $buildings = null, bool $ragged = false, ?array $pieces = null): array +{ + $root = layeredMapProject(); + $directory = $root . '/assets/Maps/test-map'; + $widths = $ragged ? [6, 6, 6, 6, 3] : [6, 6, 6, 6, 6]; + $fill = static fn(string $cell): string => implode("\n", array_map(static fn(int $width): string => implode($cell === '2816' ? ' ' : '', array_fill(0, $width, $cell)), $widths)); + $layers = ['01.terrain.map.php' => $fill('.'), '04.buildings.map.php' => $buildings === null ? $fill(' ') : implode("\n", $buildings), '07.detail.deco.php' => $fill(' ')]; + foreach ($layers as $file => $text) { + file_put_contents($directory . '/layers/' . $file, Ichiloto\Engine\Field\MapGridSource::buildSource($text, 'AUTHORED', '// keep ' . $file . "\n")); + } + file_put_contents($directory . '/test-map.event.php', Ichiloto\Engine\Field\MapGridSource::buildSource($fill(' '), 'EVENTS')); + $data = $directory . '/test-map.data.php'; + file_put_contents($data, str_replace("'events' => [],", "'events' => [], 'tileset' => 'home',", (string) file_get_contents($data))); + writeTestTileset($root, pieces: $pieces ?? buildTestPieces()); + writeTileLayer($directory, '01.floor.tiles.php', $fill('2816')); + + return layeredCanvasEditor($root); +} + +/** Starts drawing the wall, the fourth piece in the picker. */ +function chooseWallByKeys(Editor $editor): void +{ + choosePieceByKeys($editor, 3); +} + +/** Walks the canvas cursor to a cell with the arrow keys. */ +function moveCanvasCursorTo(Editor $editor, int $x, int $y): void +{ + foreach (['cursorX' => [$x, "\033[C", "\033[D"], 'cursorY' => [$y, "\033[B", "\033[A"]] as $property => [$target, $forward, $back]) { + for ($guard = 0; getEditorProperty($editor, $property) !== $target && $guard < 20; $guard++) { + callEditorMethod($editor, 'dispatchInput', getEditorProperty($editor, $property) < $target ? $forward : $back); + } + } +} + +/** Presses Enter at each cell in turn, as a keyboard author draws. */ +function drawWallThrough(Editor $editor, array ...$cells): void +{ + foreach ($cells as [$x, $y]) { + moveCanvasCursorTo($editor, $x, $y); + callEditorMethod($editor, 'dispatchInput', "\n"); + } +} + +/** @return list The buildings layer's rows, one glyph per cell. */ +function readBuildingRows(ProjectMap $map): array +{ + $rows = []; + for ($y = 0; $y < $map->getHeight(); $y++) { + $row = ''; + for ($x = 0; $map->hasLayerCell('map:4', $x, $y); $x++) { + $row .= $map->getLayerSymbol('map:4', $x, $y); + } + $rows[] = $row; + } + + return $rows; +} + +/** @return list>|null The walls tile layer's entries, or null when the map has none. */ +function readWallTiles(ProjectMap $map, string $file = '02.walls.tiles.php'): ?array +{ + $source = $map->getTileLayerSources()[$map->directory . '/graphics/' . $file] ?? null; + + return $source === null ? null : readTileEntries($source); +} + +/** + * The walls tile entries a map's wall glyphs should have: the test wall's + * tile for each shape glyph, `0` elsewhere. + * + * @param list $rows + * @return list> + */ +function buildWallTiles(array $rows): array +{ + return array_map(static fn(string $row): array => array_map( + static fn(string $glyph): string => ['-' => '10', '|' => '11', '+' => '12'][$glyph] ?? '0', + mb_str_split($row), + ), $rows); +} + +it('draws a lone wall post at the cursor and anchors there', function () { + [$editor, $map] = createWallCanvasEditor(); + chooseWallByKeys($editor); + expect(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBeNull(); + + moveCanvasCursorTo($editor, 2, 1); + getEditorProperty($editor, 'toasts')->clear(); + callEditorMethod($editor, 'dispatchInput', "\n"); + + $rows = [' ', ' + ', ' ', ' ', ' ']; + expect(readBuildingRows($map))->toBe($rows) + ->and(readWallTiles($map))->toBe(buildWallTiles($rows)) + ->and(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBe(['x' => 2, 'y' => 1]) + ->and(getCurrentToast($editor)?->message)->toBe('Drew 1 wall cell. Anchor set; move and press Enter to draw a line or a room.') + ->and($map->getLayerSymbol('map:1', 2, 1))->toBe('.') + ->and($map->isDirty())->toBeTrue(); +}); + +it('draws straight wall lines whose ends take the line\'s shape and turn where they meet, one undo step each', function () { + [$editor, $map] = createWallCanvasEditor(); + $before = $map->captureLayerSnapshot(); + $tilesBefore = $map->getTileLayerSources(); + chooseWallByKeys($editor); + + drawWallThrough($editor, [1, 1]); + $post = [$map->captureLayerSnapshot(), $map->getTileLayerSources()]; + getEditorProperty($editor, 'toasts')->clear(); + drawWallThrough($editor, [4, 1]); + $across = [' ', ' ---- ', ' ', ' ', ' ']; + expect(readBuildingRows($map))->toBe($across) + ->and(readWallTiles($map))->toBe(buildWallTiles($across)) + ->and(getCurrentToast($editor)?->message)->toBe('Drew 4 wall cells. Enter draws on from here; Esc drops the anchor.') + ->and(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBe(['x' => 4, 'y' => 1]); + $line = [$map->captureLayerSnapshot(), $map->getTileLayerSources()]; + + // The next line starts at the anchor; the cell where the lines meet turns. + drawWallThrough($editor, [4, 3]); + $turned = [' ', ' ---+ ', ' | ', ' | ', ' ']; + expect(readBuildingRows($map))->toBe($turned) + ->and(readWallTiles($map))->toBe(buildWallTiles($turned)); + $after = [$map->captureLayerSnapshot(), $map->getTileLayerSources()]; + + foreach ([$line, $post, [$before, $tilesBefore]] as [$layers, $tiles]) { + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureLayerSnapshot())->toBe($layers) + ->and($map->getTileLayerSources())->toBe($tiles); + } + expect($map->isDirty())->toBeFalse(); + foreach ([$post, $line, $after] as [$layers, $tiles]) { + callEditorMethod($editor, 'dispatchInput', "\x19"); + expect($map->captureLayerSnapshot())->toBe($layers) + ->and($map->getTileLayerSources())->toBe($tiles); + } +}); + +it('draws a room as the outline of the rectangle from the anchor to the cursor', function () { + [$editor, $map] = createWallCanvasEditor(); + chooseWallByKeys($editor); + drawWallThrough($editor, [0, 0]); + getEditorProperty($editor, 'toasts')->clear(); + drawWallThrough($editor, [3, 2]); + + $room = ['+--+ ', '| | ', '+--+ ', ' ', ' ']; + expect(readBuildingRows($map))->toBe($room) + ->and(readWallTiles($map))->toBe(buildWallTiles($room)) + ->and(getCurrentToast($editor)?->message)->toBe('Drew 10 wall cells. Enter draws on from here; Esc drops the anchor.'); +}); + +it('joins walls typed by hand, keeping their colour, and leaves other glyphs alone', function () { + [$editor, $map] = createWallCanvasEditor([' | ', ' | ', '_ | ', ' ', ' ']); + setEditorProperty($editor, 'selectedPaintColor', 'cyan'); + chooseWallByKeys($editor); + drawWallThrough($editor, [0, 1], [2, 1]); + + $rows = [' | ', '---+ ', '_ | ', ' ', ' ']; + expect(readBuildingRows($map))->toBe($rows) + // The hand-typed wall beside the line joins it and gets its tile; + // the walls further along are not touched. + ->and(readWallTiles($map))->toBe(buildWallTiles([' ', '---+ ', ' ', ' ', ' '])) + ->and($map->getLayerColor('map:4', 3, 1))->toBe('red') + ->and($map->getLayerColor('map:4', 0, 1))->toBe('cyan') + ->and($map->getLayerSymbol('map:4', 0, 2))->toBe('_'); +}); + +it('erases a wall cell with the erase keys and reshapes the walls beside it', function () { + [$editor, $map] = createWallCanvasEditor(); + chooseWallByKeys($editor); + drawWallThrough($editor, [0, 0], [3, 2]); + callEditorMethod($editor, 'dispatchInput', "\033"); + $room = [$map->captureLayerSnapshot(), $map->getTileLayerSources()]; + + moveCanvasCursorTo($editor, 1, 0); + getEditorProperty($editor, 'toasts')->clear(); + callEditorMethod($editor, 'dispatchInput', "\177"); + $opened = ['| -+ ', '| | ', '+--+ ', ' ', ' ']; + expect(readBuildingRows($map))->toBe($opened) + ->and(readWallTiles($map))->toBe(buildWallTiles($opened)) + ->and(getCurrentToast($editor)?->message)->toBe('Erased the wall cell at (1, 0).') + ->and(getEditorProperty($editor, 'piecePlacement'))->not->toBeNull(); + + // One undo puts the cell and the reshaped walls back exactly. + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureLayerSnapshot())->toBe($room[0]) + ->and($map->getTileLayerSources())->toBe($room[1]); + + // Delete erases too; the corners it leaves straighten into lines. + moveCanvasCursorTo($editor, 3, 1); + callEditorMethod($editor, 'dispatchInput', "\033[3~"); + $side = ['+--- ', '| ', '+--- ', ' ', ' ']; + expect(readBuildingRows($map))->toBe($side) + ->and(readWallTiles($map))->toBe(buildWallTiles($side)); + + // A cell that is not a wall is left alone, with a word why. + $layers = $map->captureLayerSnapshot(); + $tiles = $map->getTileLayerSources(); + moveCanvasCursorTo($editor, 1, 1); + getEditorProperty($editor, 'toasts')->clear(); + callEditorMethod($editor, 'dispatchInput', "\177"); + expect(getCurrentToast($editor)?->message)->toBe('(1, 1) is not part of a wall, so nothing was erased.') + ->and($map->captureLayerSnapshot())->toBe($layers) + ->and($map->getTileLayerSources())->toBe($tiles); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureLayerSnapshot())->toBe($room[0]); +}); + +it('creates no tile layer for an erase that leaves only empty tiles', function () { + [$editor, $map] = createWallCanvasEditor([' ', ' + ', ' ', ' ', ' ']); + $tiles = $map->getTileLayerSources(); + chooseWallByKeys($editor); + moveCanvasCursorTo($editor, 2, 1); + callEditorMethod($editor, 'dispatchInput', "\177"); + + expect(readBuildingRows($map)[1])->toBe(' ') + ->and($map->getTileLayerSources())->toBe($tiles) + ->and(readWallTiles($map))->toBeNull() + ->and($map->isDirty())->toBeTrue(); +}); + +it('refuses a wall draw or erase that cannot be made whole and changes nothing', function () { + [$editor, $map] = createWallCanvasEditor(ragged: true); + chooseWallByKeys($editor); + + // A cell past a short row is not on the map. + moveCanvasCursorTo($editor, 5, 4); + getEditorProperty($editor, 'toasts')->clear(); + callEditorMethod($editor, 'dispatchInput', "\n"); + expect(getCurrentToast($editor)?->level)->toBe(StatusLevel::WARN) + ->and(getCurrentToast($editor)?->message)->toBe('Wall cannot reach (5, 4): the map has no cell there. Nothing was changed.') + ->and(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBeNull() + ->and($map->isDirty())->toBeFalse(); + + // A room reaching past it is refused whole; the anchor stays put. + drawWallThrough($editor, [0, 3]); + $layers = $map->captureLayerSnapshot(); + $tiles = $map->getTileLayerSources(); + moveCanvasCursorTo($editor, 5, 4); + getEditorProperty($editor, 'toasts')->clear(); + callEditorMethod($editor, 'dispatchInput', "\n"); + expect(getCurrentToast($editor)?->message)->toBe('Wall cannot reach (3, 4): the map has no cell there. Nothing was changed.') + ->and($map->captureLayerSnapshot())->toBe($layers) + ->and($map->getTileLayerSources())->toBe($tiles) + ->and(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBe(['x' => 0, 'y' => 3]); + + // A wall whose gameplay layer the map lacks is refused as a stamp is. + $pieces = buildTestPieces(); + $pieces['wall']['layer'] = 'fixtures'; + [$editor, $map] = createWallCanvasEditor(pieces: $pieces); + $layers = $map->captureLayerSnapshot(); + chooseWallByKeys($editor); + foreach (["\n", "\177"] as $key) { + getEditorProperty($editor, 'toasts')->clear(); + callEditorMethod($editor, 'dispatchInput', $key); + expect(getCurrentToast($editor)?->message)->toContain('Wall goes on the fixtures layer, which this map does not have.') + ->and($map->captureLayerSnapshot())->toBe($layers) + ->and($map->isDirty())->toBeFalse(); + } +}); + +it('saves only the wall\'s changed files, rewriting its tile layer only when it changed', function () { + [$editor, $map, $root] = createWallCanvasEditor(); + writeTileLayer($map->directory, '03.walls.tiles.php', implode("\n", array_fill(0, 5, '0 0 0 0 0 0')), "// Walls drawn in the TUI.\n"); + [$editor, $map] = layeredCanvasEditor($root); + $disk = sourceHashTree($map->directory); + chooseWallByKeys($editor); + drawWallThrough($editor, [0, 0], [3, 2]); + callEditorMethod($editor, 'dispatchInput', "\x13"); + + expect(array_keys(array_diff_assoc(sourceHashTree($map->directory), $disk)))->toBe(['graphics/03.walls.tiles.php', 'layers/04.buildings.map.php']) + ->and(file_get_contents($map->directory . '/graphics/03.walls.tiles.php')) + ->toBe("and($map->isDirty())->toBeFalse(); + + // Undoing both draws and saving restores every authored byte. + callEditorMethod($editor, 'dispatchInput', "\x1a"); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(sourceHashTree($map->directory))->toBe($disk); +}); + +it('previews the cells Enter would draw with the shapes they would take', function () { + [$editor, $map] = createWallCanvasEditor([' ', ' ', ' | ', ' ', ' ']); + chooseWallByKeys($editor); + moveCanvasCursorTo($editor, 1, 1); + expect(callEditorMethod($editor, 'getPiecePreviewCells'))->toBe([1 => [1 => '+']]); + + drawWallThrough($editor, [1, 1]); + moveCanvasCursorTo($editor, 4, 1); + // The line's far end meets the wall below it, so it would turn there. + expect(callEditorMethod($editor, 'getPiecePreviewCells'))->toBe([1 => [1 => '-', 2 => '-', 3 => '-', 4 => '+']]) + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('.---+.', 'PIECE Wall Enter:Draw Del:Erase Esc:Unanchor') + ->and(readBuildingRows($map)[1])->toBe(' + '); +}); + +it('drops the anchor with Esc before ending wall placement, and fits the hint to the canvas', function () { + [$editor] = createWallCanvasEditor(); + chooseWallByKeys($editor); + expect(callEditorMethod($editor, 'getPiecePlacementHelp', 46))->toBe('PIECE Wall Enter:Draw Del:Erase Esc:Done') + ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 45))->toBe('PIECE Wall Enter:Draw Del:Erase Esc:Done') + ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 42))->toBe('PIECE Enter:Draw Del:Erase Esc:Done') + ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 37))->toBe('Enter:Draw Del:Erase Esc:Done') + ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 31))->toBe('Esc:Done'); + + callEditorMethod($editor, 'dispatchInput', "\n"); + expect(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBe(['x' => 0, 'y' => 0]) + ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 50))->toBe('PIECE Wall Enter:Draw Del:Erase Esc:Unanchor') + ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 49))->toBe('PIECE Wall Enter:Draw Del:Erase Esc:Unanchor') + ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 46))->toBe('PIECE Enter:Draw Del:Erase Esc:Unanchor') + ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 41))->toBe('Enter:Draw Del:Erase Esc:Unanchor') + ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 35))->toBe('Esc:Unanchor'); + + getEditorProperty($editor, 'toasts')->clear(); + callEditorMethod($editor, 'dispatchInput', "\033"); + expect(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBeNull() + ->and(getEditorProperty($editor, 'piecePlacement')['piece']->id)->toBe('wall') + ->and(getCurrentToast($editor)?->message)->toBe('Anchor dropped. Enter draws Wall at the cursor; Esc when done.'); + + callEditorMethod($editor, 'dispatchInput', "\033"); + expect(getEditorProperty($editor, 'piecePlacement'))->toBeNull(); +}); + +it('keeps the erase keys erasing with the brush while an item piece is placed', function () { + [$editor, $map] = createPieceCanvasEditor(); + choosePieceByKeys($editor); + setEditorProperty($editor, 'cursorX', 1); + callEditorMethod($editor, 'dispatchInput', "\177"); + + expect($map->getLayerSymbol('map:1', 1, 0))->toBe(' ') + ->and(getEditorProperty($editor, 'piecePlacement')['piece']->id)->toBe('bed'); +}); + /** @return list> A tile layer source's entries, read as the Engine reads them. */ function readTileEntries(string $source): array { From cc2a11d45dddb30f7b3f88f483d1b193e78186be Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 28 Sep 2026 08:48:55 +0200 Subject: [PATCH 059/344] fix(canvas): show every layer name in the same form Gameplay layers showed their file names (terrain, buildings) beside the fixed Events label. The layer picker, canvas title, inspector and piece picker now format every layer name alike (Terrain, Upper Floor, Events), leaving the names pieces and files use unchanged. The clipboard warning names the source layer instead of its internal id. --- src/Canvas/LayerCanvas.php | 6 +++--- src/Canvas/PieceCanvas.php | 2 +- src/Editor.php | 8 +++++++- src/Maps/MapLayers.php | 11 +++++++++++ tests/Unit/LayeredCanvasTest.php | 2 +- tests/Unit/LayeredMapTest.php | 6 ++++++ tests/Unit/TilesetPiecesTest.php | 8 ++++---- tests/__snapshots__/main_shell_100x30.ansi | 2 +- 8 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/Canvas/LayerCanvas.php b/src/Canvas/LayerCanvas.php index 3e93fc0..8501411 100644 --- a/src/Canvas/LayerCanvas.php +++ b/src/Canvas/LayerCanvas.php @@ -41,7 +41,7 @@ private function getCanvasLayerTitle(): string } foreach ($this->getTerminalCanvasLayers() as $layer) { if ($layer['id'] === $this->getActiveCanvasLayer()) { - return ' [' . $layer['name'] . ']'; + return ' [' . MapLayers::formatLabel($layer['name']) . ']'; } } return ''; @@ -84,7 +84,7 @@ private function openCanvasLayerPicker(): void $visibility = $this->getCanvasLayerState()['visibility']; $active = $this->getActiveCanvasLayer(); $entries = array_map(fn(array $layer): array => [ - 'label' => $layer['id'] === MapLayers::EVENT ? 'Events' : $layer['name'], + 'label' => MapLayers::formatLabel($layer['name']), 'value' => $layer['id'], 'description' => implode(' · ', array_filter([ $layer['id'] === MapLayers::EVENT ? 'event markers' : 'gameplay', @@ -317,7 +317,7 @@ private function getLayerInspectorFields(): array $id = $this->getActiveCanvasLayer(); $fields = []; foreach ($this->getTerminalCanvasLayers() as $layer) { - $fields[] = ['label' => ($id === $layer['id'] ? '> ' : ' ') . $layer['name'], + $fields[] = ['label' => ($id === $layer['id'] ? '> ' : ' ') . MapLayers::formatLabel($layer['name']), 'value' => ($this->getCanvasLayerState()['visibility'][$layer['id']] ?? true) ? 'visible' : 'hidden', 'editable' => false]; } return $fields; diff --git a/src/Canvas/PieceCanvas.php b/src/Canvas/PieceCanvas.php index 92ee364..33c8ef0 100644 --- a/src/Canvas/PieceCanvas.php +++ b/src/Canvas/PieceCanvas.php @@ -62,7 +62,7 @@ private function openPiecePicker(): void 'value' => $piece->id, 'description' => implode(' · ', array_filter([ $piece->connects === null ? sprintf('%d x %d', $piece->width, $piece->height) : 'connected', - $piece->layer, + MapLayers::formatLabel($piece->layer), $tileLayers === [] ? null : 'tiles: ' . implode(', ', $tileLayers), ])), ]; diff --git a/src/Editor.php b/src/Editor.php index f891c16..6515182 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -49,6 +49,7 @@ use Ichiloto\Editor\Database\RecordSubList; use Ichiloto\Editor\Database\ReferencePicker; use Ichiloto\Editor\Debug\Debug; +use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Events\EventTypeCatalog; use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\CommandHistory; @@ -5337,8 +5338,13 @@ private function pasteCanvasClipboard(): void } if ($this->clipboard->layer !== $this->getActiveCanvasLayer()) { + $source = array_find($this->getSelectedMap()?->getLayers() ?? [], + fn(array $layer): bool => $layer['id'] === $this->clipboard->layer); $this->setStatus( - sprintf('The clipboard holds a %s-layer block; switch layers before pasting.', $this->clipboard->layer), + $source === null + ? 'The clipboard holds a block from another layer; switch layers before pasting.' + : sprintf('The clipboard holds a block from the %s layer; switch to it before pasting.', + MapLayers::formatLabel($source['name'])), StatusLevel::WARN, ); $this->renderFooter(); diff --git a/src/Maps/MapLayers.php b/src/Maps/MapLayers.php index 98ce7d3..604ccc0 100644 --- a/src/Maps/MapLayers.php +++ b/src/Maps/MapLayers.php @@ -48,6 +48,17 @@ public function __construct( $this->captureBaseline(); } + /** + * Formats a layer's name for display, so every layer reads alike: the + * Events layer and gameplay layers named by their files (fixtures, + * upper-floor) show as Events, Fixtures and Upper Floor. The name itself, + * which pieces and files use, is unchanged. + */ + public static function formatLabel(string $name): string + { + return ucwords(str_replace(['-', '_'], ' ', $name)); + } + public static function createFromSource(string $directory, MapLayerSet $set, string $eventPath, string $eventText): self { $layers = []; diff --git a/tests/Unit/LayeredCanvasTest.php b/tests/Unit/LayeredCanvasTest.php index e086b49..6d11435 100644 --- a/tests/Unit/LayeredCanvasTest.php +++ b/tests/Unit/LayeredCanvasTest.php @@ -216,7 +216,7 @@ // Layers are chosen in their own picker, which offers no graphical layer either. callEditorMethod($editor, 'openCanvasLayerPicker'); $picked = implode('\n', array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label')); - expect($picked)->toContain('terrain', 'buildings', 'Events')->not->toContain('detail', 'decoration'); + expect($picked)->toContain('Terrain', 'Buildings', 'Events')->not->toContain('Detail', 'decoration'); callEditorMethod($editor, 'dispatchInput', "\033"); foreach (['map:1', 'map:4', 'event'] as $id) { callEditorMethod($editor, 'selectCanvasLayer', $id); diff --git a/tests/Unit/LayeredMapTest.php b/tests/Unit/LayeredMapTest.php index 200a8e9..669cfbc 100644 --- a/tests/Unit/LayeredMapTest.php +++ b/tests/Unit/LayeredMapTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Ichiloto\Editor\MapSourceRefusal; +use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Field\NpcCollection; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; @@ -10,6 +11,11 @@ use Ichiloto\Engine\Field\MapGridSource; use Ichiloto\Engine\IO\Console\TerminalText; +it('shows every layer name in the same form', function () { + expect(array_map(MapLayers::formatLabel(...), ['terrain', 'fixtures', 'upper-floor', 'north_wing', 'Events'])) + ->toBe(['Terrain', 'Fixtures', 'Upper Floor', 'North Wing', 'Events']); +}); + it('loads ordered gameplay decoration and event layers through the Engine source contract', function () { $map = loadLayeredMap(layeredMapProject()); expect(array_column($map->getLayers(), 'name'))->toBe(['terrain', 'buildings', 'detail', 'Events']) diff --git a/tests/Unit/TilesetPiecesTest.php b/tests/Unit/TilesetPiecesTest.php index c9f1dad..032ce9d 100644 --- a/tests/Unit/TilesetPiecesTest.php +++ b/tests/Unit/TilesetPiecesTest.php @@ -61,10 +61,10 @@ function choosePieceByKeys(Editor $editor, int $downs = 0): void expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Piece') ->and(getEditorProperty($editor, 'eventOptionDialogEntries'))->toBe([ - ['label' => 'Bed', 'value' => 'bed', 'description' => '2 x 2 · buildings · tiles: decor, furniture'], - ['label' => 'Lamp', 'value' => 'lamp', 'description' => '1 x 1 · fixtures'], - ['label' => 'Rug', 'value' => 'rug', 'description' => '2 x 1 · terrain · tiles: floor'], - ['label' => 'Wall', 'value' => 'wall', 'description' => 'connected · buildings · tiles: walls'], + ['label' => 'Bed', 'value' => 'bed', 'description' => '2 x 2 · Buildings · tiles: decor, furniture'], + ['label' => 'Lamp', 'value' => 'lamp', 'description' => '1 x 1 · Fixtures'], + ['label' => 'Rug', 'value' => 'rug', 'description' => '2 x 1 · Terrain · tiles: floor'], + ['label' => 'Wall', 'value' => 'wall', 'description' => 'connected · Buildings · tiles: walls'], ]); // Esc cancels without placing anything. diff --git a/tests/__snapshots__/main_shell_100x30.ansi b/tests/__snapshots__/main_shell_100x30.ansi index 806fadd..6f51ae3 100644 --- a/tests/__snapshots__/main_shell_100x30.ansi +++ b/tests/__snapshots__/main_shell_100x30.ansi @@ -1 +1 @@ -[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint L:Layer ?:Help─────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > terrain · visible ││ Events · visible ││ ││ ││ │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file +[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint L:Layer ?:Help─────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > Terrain · visible ││ Events · visible ││ ││ ││ │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file From ef7d9b97d4b509978d2d67f63b01ba98829acaef Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 28 Sep 2026 14:35:03 +0200 Subject: [PATCH 060/344] feat(canvas): offer NPCs in the layer picker NPC mode was only reachable with n or F3, which no canvas hint named. The L picker now lists NPCs after the map's layers; choosing it enters NPC mode and the canvas title shows [NPCs]. Choosing a layer again returns to map editing. --- docs/manual.md | 3 ++- src/Canvas/LayerCanvas.php | 24 ++++++++++++++++++++---- src/Editor.php | 6 ++++++ tests/Unit/LayeredCanvasTest.php | 20 +++++++++++++++++++- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 061c738..4b0fae3 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -187,7 +187,8 @@ gameplay (`NN.name.map.php`) layers and Events, with NPC overlays in their authoring context. Graphical decoration (`NN.name.deco.php`) never appears on the terminal canvas or in its layer selection, palette or inspector. In Normal mode, `L` opens the layer picker: the map's gameplay layers and Events, each -marked visible or hidden and the one being edited. Type to filter, move with the +marked visible or hidden and the one being edited, and last NPCs, which enters +NPC mode. Type to filter, move with the arrows or `j` / `k`, and press `Enter` to edit that layer (`Esc` keeps the current one). The canvas title shows the layer being edited, and only that layer is painted. `[` and `]` still step to the previous or next layer, `v` diff --git a/src/Canvas/LayerCanvas.php b/src/Canvas/LayerCanvas.php index 8501411..bd08404 100644 --- a/src/Canvas/LayerCanvas.php +++ b/src/Canvas/LayerCanvas.php @@ -36,9 +36,12 @@ private function getCanvasLayerState(): array private function getCanvasLayerTitle(): string { $map = $this->getSelectedMap(); - if ($map === null || $map->isLegacyMap() || $this->editingMode === self::MODE_NPC) { + if ($map === null || $map->isLegacyMap()) { return ''; } + if ($this->editingMode === self::MODE_NPC) { + return ' [NPCs]'; + } foreach ($this->getTerminalCanvasLayers() as $layer) { if ($layer['id'] === $this->getActiveCanvasLayer()) { return ' [' . MapLayers::formatLabel($layer['name']) . ']'; @@ -71,9 +74,10 @@ private function selectCanvasLayer(string $id): void } /** - * Opens the layer picker: the map's gameplay layers and Events, filterable - * by name, with the active layer selected. Enter makes the highlighted - * layer the one the canvas edits. + * Opens the layer picker: the map's gameplay layers, Events and NPCs, + * filterable by name, with what the canvas edits selected. Enter edits + * the highlighted layer, or enters NPC mode for NPCs, which are placed on + * the map like a layer though they are stored as the map's NPC list. */ private function openCanvasLayerPicker(): void { @@ -92,6 +96,18 @@ private function openCanvasLayerPicker(): void $layer['id'] === $active ? 'editing' : null, ])), ], $layers); + $npcs = $this->getSelectedMap()?->getNpcs()->count() ?? 0; + $entries[] = [ + 'label' => 'NPCs', + 'value' => self::NPC_LAYER_ENTRY, + 'description' => implode(' · ', array_filter([ + sprintf('%d placed', $npcs), + $this->editingMode === self::MODE_NPC ? 'editing' : null, + ])), + ]; + if ($this->editingMode === self::MODE_NPC) { + $active = self::NPC_LAYER_ENTRY; + } $this->finalizeActiveStroke(); $this->optionDialogField = ['canvasLayer' => true]; $this->eventOptionDialogMarker = null; diff --git a/src/Editor.php b/src/Editor.php index 6515182..575b59e 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -138,6 +138,8 @@ final class Editor private const string MODE_MAP = 'map'; private const string MODE_EVENT = 'event'; private const string MODE_NPC = 'npc'; + /** The layer picker's NPCs entry, which enters NPC mode. */ + private const string NPC_LAYER_ENTRY = 'npcs'; private const string DATABASE_CATEGORY_ACTORS = 'actors'; private const string DATABASE_CATEGORY_CLASSES = 'classes'; private const string DATABASE_CATEGORY_SKILLS = 'skills'; @@ -7841,6 +7843,10 @@ private function applySelectedEventOption(): void if (($field['canvasLayer'] ?? false) === true) { $this->closeEventOptionDialog(); + if ($selectedEntry['value'] === self::NPC_LAYER_ENTRY) { + $this->setEditingMode(self::MODE_NPC); + return; + } $this->selectCanvasLayer((string) $selectedEntry['value']); $this->setStatus(sprintf('Editing the %s layer.', $selectedEntry['label'])); $this->renderCanvasArea(); diff --git a/tests/Unit/LayeredCanvasTest.php b/tests/Unit/LayeredCanvasTest.php index 6d11435..0c2738e 100644 --- a/tests/Unit/LayeredCanvasTest.php +++ b/tests/Unit/LayeredCanvasTest.php @@ -26,12 +26,30 @@ } }); +it('offers NPCs in the layer picker, entering NPC mode', function () { + [$editor] = layeredCanvasEditor(); + callEditorMethod($editor, 'dispatchInput', 'L'); + $entries = getEditorProperty($editor, 'eventOptionDialogEntries'); + expect(end($entries))->toMatchArray(['label' => 'NPCs', 'value' => 'npcs']); + callEditorMethod($editor, 'dispatchInput', '/'); + foreach (['N', 'P', 'C', "\n", "\n"] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + expect(getEditorProperty($editor, 'editingMode'))->toBe('npc') + ->and(callEditorMethod($editor, 'getCanvasLayerTitle'))->toBe(' [NPCs]'); + // Choosing a layer again leaves NPC mode. + callEditorMethod($editor, 'dispatchInput', "\033"); + callEditorMethod($editor, 'dispatchInput', 'L'); + callEditorMethod($editor, 'dispatchInput', "\n"); + expect(getEditorProperty($editor, 'editingMode'))->not->toBe('npc'); +}); + it('chooses the layer to edit from its own picker with L, and L still paints in Paint mode', function () { [$editor, $map] = layeredCanvasEditor(); callEditorMethod($editor, 'dispatchInput', 'L'); $entries = getEditorProperty($editor, 'eventOptionDialogEntries'); expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Layer') - ->and(array_column($entries, 'value'))->toBe(array_column(callEditorMethod($editor, 'getTerminalCanvasLayers'), 'id')) + ->and(array_column($entries, 'value'))->toBe([...array_column(callEditorMethod($editor, 'getTerminalCanvasLayers'), 'id'), 'npcs']) ->and(array_column($entries, 'label'))->toContain('Events') ->and($entries[0]['description'])->toContain('editing'); // Down to the next layer, then Enter edits it. From 8a6067a810838daa20bc94e8075e75765bead6ee Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 28 Sep 2026 14:51:16 +0200 Subject: [PATCH 061/344] feat(canvas): move tiles with the glyphs they belong to Copying, cutting and pasting a block on a gameplay layer now carries the tiles that move with that layer, cell for cell, in the same undo step, so a moved chest or room keeps its art. A tile layer moves with the gameplay layer the map data names (tileLayers movesWith), or else the one whose tileset pieces write it, when only one does. Blocks on the event layer and other tile layers leave tiles where they are. The validator checks movesWith names a gameplay layer. --- docs/manual.md | 13 ++- src/Canvas/Clipboard.php | 36 +++++++- src/Canvas/PieceCanvas.php | 9 +- src/Editor.php | 88 ++++++++++++++++++- src/Maps/MapLayers.php | 50 +++++++++++ src/Maps/TileLayerSource.php | 12 +++ src/ProjectMap.php | 50 +++++++++++ src/Validation/MapGraphicsValidator.php | 7 +- tests/Unit/TilesMoveWithGlyphsTest.php | 107 ++++++++++++++++++++++++ 9 files changed, 359 insertions(+), 13 deletions(-) create mode 100644 tests/Unit/TilesMoveWithGlyphsTest.php diff --git a/docs/manual.md b/docs/manual.md index 4b0fae3..3373812 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -320,7 +320,15 @@ A map may name a tileset in its data file (`'tileset' => 'home'`, read from cell, so each of its rows is exactly as wide as the map's row in terminal columns. The TUI never displays tiles or paints single tiles; painting them belongs to the GUI editor. It writes tiles only when it stamps a -[piece](#pieces), and otherwise keeps them intact: +[piece](#pieces) or moves glyphs the tiles move with, and otherwise keeps them +intact: + +- Each tile layer moves with one gameplay layer: the one the map data names + (`'tileLayers' => ['floor' => ['movesWith' => 'buildings']]`), or else the + one whose tileset pieces write it, when only one does. Copying, cutting and + pasting a block on that gameplay layer carries its tiles cell for cell, in + the same undo step, so a moved chest or room keeps its art. Other tile + layers, and blocks on the event layer, leave tiles where they are. - Resizing the map crops or pads every tile layer with empty tiles (`0`) in the same undo step and save as the terminal layers. A resized tile layer is @@ -608,7 +616,8 @@ a cut, and a paste each undo in a single `Ctrl+Z`. Repeated pastes are separate steps, so you can stamp freely. The clipboard is layer-tagged: a block lifted from the event layer refuses to -land on tiles. +land on tiles. A block lifted from a gameplay layer carries the graphical +tiles that move with that layer, and a paste replaces the tiles under it. Current limit: the canvas draws no on-screen preview of a pending line, rectangle, or selection rectangle. The footer reports the anchor and selection diff --git a/src/Canvas/Clipboard.php b/src/Canvas/Clipboard.php index c88c194..a567730 100644 --- a/src/Canvas/Clipboard.php +++ b/src/Canvas/Clipboard.php @@ -10,7 +10,8 @@ * paste per stamp. * * The clipboard is session-scoped and layer-tagged so a block copied from - * the event layer never lands silently on the tile layer. + * the event layer never lands silently on the tile layer. A block lifted + * off a gameplay layer carries the tiles that move with it, cell for cell. */ final class Clipboard { @@ -24,6 +25,8 @@ final class Clipboard * The layer the block was lifted from (a PaintStrokeCommand LAYER_* value). */ public private(set) string $layer = ''; + /** @var array>> Tile entries by row, keyed by tile layer name. */ + private array $tiles = []; /** * Stores a block of symbols. @@ -31,13 +34,15 @@ final class Clipboard * @param array> $rows Rows of symbols, top-left first. * @param string $layer The source layer. * @param array> $styles Optional tile styles, aligned with rows. + * @param array>> $tiles The tile entries that move with the block, by row, keyed by tile layer name. * @return void */ - public function store(array $rows, string $layer, array $styles = []): void + public function store(array $rows, string $layer, array $styles = [], array $tiles = []): void { $this->rows = array_values(array_map(static fn(array $row): array => array_values($row), $rows)); $this->styles = array_values(array_map(static fn(array $row): array => array_values($row), $styles)); $this->layer = $layer; + $this->tiles = $tiles; } /** @@ -50,6 +55,7 @@ public function clear(): void $this->rows = []; $this->styles = []; $this->layer = ''; + $this->tiles = []; } /** @@ -132,4 +138,30 @@ public function project(int $originX, int $originY, int $width, int $height): ar return $placements; } + + /** + * Projects the block's tiles onto a map at the given top-left origin, + * every cell included, so a paste replaces the tiles under the block as + * it replaces the glyphs. + * + * @return array> The clipped cells, keyed by tile layer name. + */ + public function projectTiles(int $originX, int $originY, int $width, int $height): array + { + $cells = []; + foreach ($this->tiles as $name => $rows) { + $cells[$name] = []; + foreach ($rows as $rowIndex => $row) { + foreach ($row as $columnIndex => $entry) { + $x = $originX + $columnIndex; + $y = $originY + $rowIndex; + if ($x >= 0 && $y >= 0 && $x < $width && $y < $height) { + $cells[$name][] = ['x' => $x, 'y' => $y, 'entry' => $entry]; + } + } + } + } + + return $cells; + } } diff --git a/src/Canvas/PieceCanvas.php b/src/Canvas/PieceCanvas.php index 33c8ef0..debc76f 100644 --- a/src/Canvas/PieceCanvas.php +++ b/src/Canvas/PieceCanvas.php @@ -282,18 +282,19 @@ private function stampPiece(ProjectMap $map, TilesetPiece $piece): void $this->renderCanvasArea(); return; } - $this->recordPieceCommand('Piece stamp', $map, $stroke, $tilesBefore, $tilesAfter); + $this->recordStrokeWithTiles('Piece stamp', $map, $stroke, $tilesBefore, $tilesAfter); $this->setStatus(sprintf('Stamped %s at (%d, %d). Enter stamps again; Esc when done.', $piece->name, $x, $y)); $this->renderCanvasArea(); } /** - * Records a piece's glyph stroke and tile layer change as one undo step. + * Records a glyph stroke and the tile layer change made with it, such as + * a stamped piece or a pasted block, as one undo step. * * @param array $tilesBefore Tile layer sources before the change. * @param array $tilesAfter Tile layer sources after it. */ - private function recordPieceCommand(string $label, ProjectMap $map, PaintStrokeCommand $stroke, array $tilesBefore, array $tilesAfter): void + private function recordStrokeWithTiles(string $label, ProjectMap $map, PaintStrokeCommand $stroke, array $tilesBefore, array $tilesAfter): void { $this->recordCommand(new GenericCommand($label, static function () use ($stroke, $map, $tilesAfter): void { @@ -413,7 +414,7 @@ private function applyConnectedPiece(ProjectMap $map, TilesetPiece $piece, array if (! $stroke->hasChanges() && $tilesAfter === $tilesBefore) { return false; } - $this->recordPieceCommand($label, $map, $stroke, $tilesBefore, $tilesAfter); + $this->recordStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter); return true; } diff --git a/src/Editor.php b/src/Editor.php index 575b59e..d0212ae 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -99,6 +99,7 @@ use Ichiloto\Engine\Events\Enumerations\ChestType; use Ichiloto\Engine\Events\Enumerations\LootType; use Ichiloto\Engine\Quests\QuestObjectiveType; +use Ichiloto\Engine\Rendering\Tilesets\TileId; use RuntimeException; if (! class_exists(__NAMESPACE__ . chr(92) . 'Animation', false)) { class_alias('Ichiloto' . chr(92) . 'Engine' . chr(92) . 'Animations' . chr(92) . 'Animation', __NAMESPACE__ . chr(92) . 'Animation'); } if (! class_exists(__NAMESPACE__ . chr(92) . 'AnimationCue', false)) { class_alias('Ichiloto' . chr(92) . 'Engine' . chr(92) . 'Animations' . chr(92) . 'AnimationCue', __NAMESPACE__ . chr(92) . 'AnimationCue'); } @@ -4843,6 +4844,41 @@ private function applyCanvasWrites(ProjectMap $map, array $writes, string $label return $changed; } + /** + * Writes cells onto the active layer and the tile cells that move with + * them as one undo step. A tile layer that cannot take its cells refuses + * the whole change. + * + * @param array $writes The cells to write. + * @param array> $tiles The tile entry for each cell, keyed by tile layer name. + * @return int|null The number of glyph cells that changed, or null when nothing was changed. + */ + private function applyCanvasWritesWithTiles(ProjectMap $map, array $writes, array $tiles, string $label): ?int + { + if ($tiles === []) { + return $this->applyCanvasWrites($map, $writes, $label); + } + + $this->finalizeActiveStroke(); + $tilesBefore = $map->getTileLayerSources(); + + try { + $map->writeTileCells($tiles); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + return null; + } + + $tilesAfter = $map->getTileLayerSources(); + [$stroke, $changed] = $this->writeCanvasCells($map, $this->getActiveCanvasLayer(), $writes, $label); + + if ($stroke->hasChanges() || $tilesAfter !== $tilesBefore) { + $this->recordStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter); + } + + return $changed; + } + /** * Writes cells onto one layer and returns the stroke that undoes them, * unrecorded, so a caller can record it alone or as part of a larger @@ -5267,7 +5303,28 @@ private function cutCanvasSelection(): void $selection['x'] + $selection['width'] - 1, $selection['y'] + $selection['height'] - 1, ); - $changed = $this->paintCanvasCells($selectedMap, $cells, ' ', 'cut selection'); + // The tiles that moved into the clipboard with the glyphs leave too. + $clears = array_map( + static fn(array $tiles): array => array_map( + static fn(array $cell): array => ['entry' => (string) TileId::EMPTY] + $cell, + $tiles, + ), + $this->clipboard->projectTiles($selection['x'], $selection['y'], $selectedMap->getWidth(), $selectedMap->getHeight()), + ); + $changed = $this->applyCanvasWritesWithTiles( + $selectedMap, + array_map( + fn(array $cell): array => ['x' => $cell['x'], 'y' => $cell['y'], 'symbol' => ' ', 'color' => $this->selectedPaintColor], + $cells, + ), + $clears, + 'cut selection', + ); + + if ($changed === null) { + $this->renderCanvasArea(); + return; + } $this->setStatus(sprintf( 'Cut %d x %d (%d cell%s cleared).', $this->clipboard->getWidth(), @@ -5315,7 +5372,22 @@ private function captureCanvasSelection(): int $styles[] = $styleRow; } - $this->clipboard->store($rows, $this->getActiveCanvasLayer(), $styles); + // The tiles that move with this layer's glyphs travel with the block. + try { + $tiles = $this->editingMode === self::MODE_NPC ? [] : $selectedMap->readTileEntries( + $selectedMap->getTileLayersMovingWith($this->getActiveCanvasLayer()), + $selection['x'], + $selection['y'], + $selection['width'], + $selection['height'], + ); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderFooter(); + return 0; + } + + $this->clipboard->store($rows, $this->getActiveCanvasLayer(), $styles, $tiles); return $selection['width'] * $selection['height']; } @@ -5359,7 +5431,17 @@ private function pasteCanvasClipboard(): void $selectedMap->getWidth(), $selectedMap->getHeight(), ); - $changed = $this->applyCanvasWrites($selectedMap, $writes, 'pasted selection'); + // Tiles land only on the layers that move with this map's layer. + $tiles = array_intersect_key( + $this->clipboard->projectTiles($this->cursorX, $this->cursorY, $selectedMap->getWidth(), $selectedMap->getHeight()), + array_flip($selectedMap->getTileLayersMovingWith($this->getActiveCanvasLayer())), + ); + $changed = $this->applyCanvasWritesWithTiles($selectedMap, $writes, $tiles, 'pasted selection'); + + if ($changed === null) { + $this->renderCanvasArea(); + return; + } $this->setStatus(sprintf( 'Stamped %d x %d at (%d, %d): %d cell%s changed.', $this->clipboard->getWidth(), diff --git a/src/Maps/MapLayers.php b/src/Maps/MapLayers.php index 604ccc0..2bdeae4 100644 --- a/src/Maps/MapLayers.php +++ b/src/Maps/MapLayers.php @@ -293,6 +293,56 @@ public function writeTileCells(array $cells): void $this->tileSources = $sources; } + /** + * The names of the map's tile layers, in order. + * + * @return list + */ + public function getTileLayerNames(): array + { + $names = []; + foreach (array_keys($this->tileSources) as $path) { + if (preg_match(MapGraphics::FILENAME_PATTERN, basename($path), $matches) === 1) { + $names[] = $matches['name']; + } + } + + return $names; + } + + /** + * Reads the entries of the named tile layers over a rectangle, by row, + * so they can move with the glyphs above them. A layer the map does not + * have is left out; a cell past the map's edge reads `0`. + * + * @param list $names Tile layer names. + * @return array>> Entries by row, keyed by tile layer name. + * @throws MapSourceRefusal When a layer cannot be read or does not match the map. + */ + public function readTileEntries(array $names, int $x, int $y, int $width, int $height): array + { + $set = $this->getLayerSet(); + $read = []; + foreach ($names as $name) { + $path = $this->findTileLayerPath($name, $this->tileSources); + if ($path === null) { + continue; + } + $entries = TileLayerSource::readEntries($this->tileSources[$path], $this->getDisplayPath($path), $set, 'moving its tiles'); + $rows = []; + for ($row = 0; $row < $height; $row++) { + $cells = []; + for ($column = 0; $column < $width; $column++) { + $cells[] = $entries[$y + $row][$x + $column] ?? (string) TileId::EMPTY; + } + $rows[] = $cells; + } + $read[$name] = $rows; + } + + return $read; + } + /** @param array $tileSources Tile layer sources by path, as {@see getTileSources()} returns them. */ public function restoreTileSources(array $tileSources): void { diff --git a/src/Maps/TileLayerSource.php b/src/Maps/TileLayerSource.php index 7a9ea18..b7e9c44 100644 --- a/src/Maps/TileLayerSource.php +++ b/src/Maps/TileLayerSource.php @@ -143,6 +143,18 @@ private static function writeCellEntries(string $source, string $displayPath, Ma return self::rewrite($written, $entries, $source, $displayPath, $baseline); } + /** + * Reads a tile layer's entries by row, as a layer that must match the map. + * + * @param MapLayerSet $layers The map's terminal layers. + * @return list> + * @throws MapSourceRefusal When it cannot be read or does not match the map. + */ + public static function readEntries(string $source, string $displayPath, MapLayerSet $layers, string $action): array + { + return self::readMatchingLayer($source, $displayPath, $layers, $action)->getEntries(); + } + /** * Reads a tile layer that must match the map before it changes. * diff --git a/src/ProjectMap.php b/src/ProjectMap.php index acb9ea8..6426e63 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -493,6 +493,56 @@ public function writeTileCells(array $cells): void $this->touchState(); } + /** + * The tile layers whose tiles move with a gameplay layer's glyphs: those + * the map data names it for (`'tileLayers' => ['floor' => ['movesWith' => + * 'buildings']]`), and otherwise those that only this layer's tileset + * pieces write. The event layer moves no tiles, and neither does any + * layer while the settings are invalid, which validation reports. + * + * @return list Tile layer names. + */ + public function getTileLayersMovingWith(string $layerId): array + { + $layer = $this->layers->getLayer($layerId); + $names = $this->layers->getTileLayerNames(); + if ($layer['id'] === MapLayers::EVENT || $layer['decoration'] || $names === []) { + return []; + } + $gameplay = array_column(array_filter($this->layers->getLayers(), static fn(array $candidate): bool => + $candidate['id'] !== MapLayers::EVENT && ! $candidate['decoration']), 'name'); + try { + $named = MapGraphics::readLayersMovingWith($this->getMapDataField([MapGraphics::SETTINGS_KEY]), $names, $gameplay, $this->mapId); + } catch (InvalidArgumentException) { + return []; + } + $writers = []; + try { + foreach ($this->loadTileset()?->pieces ?? [] as $piece) { + foreach (array_keys($piece->connects === null ? $piece->tiles : $piece->shapeTiles) as $name) { + $writers[$name][$piece->layer] = true; + } + } + } catch (InvalidArgumentException | RuntimeException) { + // A tileset that cannot load says nothing about its pieces. + } + + return array_values(array_filter($names, static fn(string $name): bool => + ($named[$name] ?? (count($writers[$name] ?? []) === 1 ? array_key_first($writers[$name]) : null)) === $layer['name'])); + } + + /** + * Reads the entries of the named tile layers over a rectangle, by row. + * + * @param list $names Tile layer names. + * @return array>> Entries by row, keyed by tile layer name. + * @throws MapSourceRefusal When a layer cannot be read or does not match the map. + */ + public function readTileEntries(array $names, int $x, int $y, int $width, int $height): array + { + return $this->layers->readTileEntries($names, $x, $y, $width, $height); + } + /** * Restores the tile layers {@see getTileLayerSources()} returned, for * undo and redo. diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php index 9e69e22..81fc613 100644 --- a/src/Validation/MapGraphicsValidator.php +++ b/src/Validation/MapGraphicsValidator.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor\Validation; +use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Maps\TileLayerSource; use Ichiloto\Editor\ProjectMap; use Ichiloto\Engine\Field\MapGraphics; @@ -77,12 +78,14 @@ private static function checkLayerOffsets(ProjectMap $map, array $sources, array } try { - MapGraphics::readLayerOffsets($map->getMapDataField([MapGraphics::SETTINGS_KEY]), $names, $map->mapId); + MapGraphics::readLayersMovingWith($map->getMapDataField([MapGraphics::SETTINGS_KEY]), $names, + array_column(array_filter($map->getLayers(), static fn(array $layer): bool => + $layer['id'] !== MapLayers::EVENT && ! $layer['decoration']), 'name'), $map->mapId); } catch (InvalidArgumentException $error) { $issues[] = Issue::error( $map->mapId, $error->getMessage(), - sprintf("Name a tile layer in graphics/ and give it 'offset' => [across, down]. %s", self::GLYPH_FALLBACK), + sprintf("Name a tile layer in graphics/ and give it 'offset' => [across, down] or 'movesWith' => a gameplay layer. %s", self::GLYPH_FALLBACK), ); } } diff --git a/tests/Unit/TilesMoveWithGlyphsTest.php b/tests/Unit/TilesMoveWithGlyphsTest.php new file mode 100644 index 0000000..af900db --- /dev/null +++ b/tests/Unit/TilesMoveWithGlyphsTest.php @@ -0,0 +1,107 @@ + 'home',", "'tileset' => 'home', {$settings}", (string) file_get_contents($data))); + } + [$editor, $map] = layeredCanvasEditor($root); + + return [$editor, $map]; +} + +/** @return array> */ +function buildMovingTilePieces(): array +{ + return [ + 'bed' => ['name' => 'Bed', 'layer' => 'buildings', 'glyphs' => ['='], 'tiles' => ['decor' => ['5']]], + 'rug' => ['name' => 'Rug', 'layer' => 'terrain', 'glyphs' => ['~'], 'tiles' => ['floor' => ['2816']]], + ]; +} + +/** @return list> */ +function readMovingTileRows(ProjectMap $map, string $file): array +{ + return TileLayerSource::readLayer($map->getTileLayerSources()[$map->directory . '/graphics/' . $file], $file)->getEntries(); +} + +function selectCanvasBlock(Editor $editor, string $layer, int $x, int $y, int $width, int $height): void +{ + callEditorMethod($editor, 'selectCanvasLayer', $layer); + setEditorProperty($editor, 'canvasSelection', ['x' => $x, 'y' => $y, 'width' => $width, 'height' => $height]); +} + +it('finds the tile layers that move with a gameplay layer from its tileset pieces', function () { + [, $map] = createMovingTilesEditor(); + + expect($map->getTileLayersMovingWith('map:4'))->toBe(['decor']) + ->and($map->getTileLayersMovingWith('map:1'))->toBe(['floor']) + ->and($map->getTileLayersMovingWith('event'))->toBe([]); +}); + +it('lets the map data name the gameplay layer a tile layer moves with', function () { + [, $map] = createMovingTilesEditor("'tileLayers' => ['floor' => ['movesWith' => 'buildings']],"); + + expect($map->getTileLayersMovingWith('map:4'))->toBe(['floor', 'decor']) + ->and($map->getTileLayersMovingWith('map:1'))->toBe([]); +}); + +it('cuts and pastes a block with its tiles, one undo step each, leaving other tile layers alone', function () { + [$editor, $map] = createMovingTilesEditor(); + $floor = readMovingTileRows($map, '01.floor.tiles.php'); + $original = $map->getTileLayerSources(); + $glyphs = [$map->getLayerSymbol('map:4', 1, 0), $map->getLayerSymbol('map:4', 1, 1)]; + expect(readMovingTileRows($map, '02.decor.tiles.php'))->toBe([['0', '5', '0', '0'], ['0', '0', '0', '5']]); + + selectCanvasBlock($editor, 'map:4', 1, 0, 1, 2); + callEditorMethod($editor, 'cutCanvasSelection'); + expect(readMovingTileRows($map, '02.decor.tiles.php'))->toBe([['0', '0', '0', '0'], ['0', '0', '0', '5']]) + ->and($map->getLayerSymbol('map:4', 1, 0))->toBe(' '); + + setEditorProperty($editor, 'cursorX', 2); + setEditorProperty($editor, 'cursorY', 0); + callEditorMethod($editor, 'pasteCanvasClipboard'); + // The block replaces the tiles under it as it replaces the glyphs. + expect(readMovingTileRows($map, '02.decor.tiles.php'))->toBe([['0', '0', '5', '0'], ['0', '0', '0', '5']]) + ->and([$map->getLayerSymbol('map:4', 2, 0), $map->getLayerSymbol('map:4', 2, 1)])->toBe($glyphs) + ->and(readMovingTileRows($map, '01.floor.tiles.php'))->toBe($floor); + + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect(readMovingTileRows($map, '02.decor.tiles.php'))->toBe([['0', '0', '0', '0'], ['0', '0', '0', '5']]); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->getTileLayerSources())->toBe($original) + ->and([$map->getLayerSymbol('map:4', 1, 0), $map->getLayerSymbol('map:4', 1, 1)])->toBe($glyphs); +}); + +it('copies the tiles of the layers the map data names, and none from the event layer', function () { + [$editor, $map] = createMovingTilesEditor("'tileLayers' => ['floor' => ['movesWith' => 'buildings']],"); + + selectCanvasBlock($editor, 'map:4', 0, 0, 2, 1); + callEditorMethod($editor, 'copyCanvasSelection'); + expect(getEditorProperty($editor, 'clipboard')->projectTiles(0, 1, 4, 2))->toBe([ + 'floor' => [['x' => 0, 'y' => 1, 'entry' => '2816'], ['x' => 1, 'y' => 1, 'entry' => '2816']], + 'decor' => [['x' => 0, 'y' => 1, 'entry' => '0'], ['x' => 1, 'y' => 1, 'entry' => '5']], + ]); + + selectCanvasBlock($editor, 'event', 0, 0, 2, 1); + callEditorMethod($editor, 'copyCanvasSelection'); + expect(getEditorProperty($editor, 'clipboard')->projectTiles(0, 1, 4, 2))->toBe([]); +}); From dbac548d0dd970acb4c1db7a82df60c9885dc8f6 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 28 Sep 2026 16:39:00 +0200 Subject: [PATCH 062/344] feat(npcs): let an NPC's id follow its name until something names it An NPC created without a name got the id new-npc and kept it after being named, because ids never changed after creation. Renaming an NPC now re-derives its id from the new name while nothing refers to it, in the same undo step. Once an event, script, route or cinematic starting on the map names the id, a rename keeps it and the status says what names it. The reference check also finds dialogue events' npcId, NPC subjects and targets nested anywhere in a script, and cinematics starting on the map, which also makes deleting an NPC safer. --- docs/manual.md | 11 ++++-- src/Editor.php | 44 +++++++++++++++++++++- src/Field/NpcReferences.php | 65 ++++++++++++++++++++------------- src/Field/ProjectNpc.php | 22 +++++++++-- tests/Unit/NpcAuthoringTest.php | 52 ++++++++++++++++++++++++-- 5 files changed, 158 insertions(+), 36 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 3373812..8049b01 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -506,10 +506,13 @@ with a hint rather than painted under an NPC. `Enter` on an empty tile asks for the NPC's name first, and derives its stable `id` from that name - `Gate Guard` becomes `gate-guard`, numbered if the map -already has one - because the id is what `move_route` and script diagnostics -name, and it is **immutable after creation**: renaming the NPC, moving it, or -changing its sprite never touches it. Duplicating assigns a fresh id from the -name. An NPC authored without an id loads and edits normally, shows a +already has one - because the id is what routes, dialogue events, cinematics +and script diagnostics name. Renaming the NPC re-derives its id from the new +name **while nothing refers to it**, so an NPC created as `New NPC` and named +later gets the id its name suggests. Once an event, script, route or cinematic +starting on the map names the id, it stays: a rename keeps it and the status +says what names it. Moving the NPC or changing its sprite never touches it. +Duplicating assigns a fresh id from the name. An NPC authored without an id loads and edits normally, shows a `! No stable id` row (`Enter` there assigns one from its name, the one time an id is ever written after creation, since nothing can yet name it), and validates with a warning that scripted movement cannot target it. Changing an diff --git a/src/Editor.php b/src/Editor.php index d0212ae..b310ac3 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -37,6 +37,7 @@ use Ichiloto\Editor\Field\NpcInspector; use Ichiloto\Editor\Field\MapBgmVariants; use Ichiloto\Editor\Field\MapEncounters; +use Ichiloto\Editor\Field\NpcCollection; use Ichiloto\Editor\Field\NpcReferences; use Ichiloto\Editor\Field\ProjectNpc; use Ichiloto\Editor\Database\WorldWriteEditor; @@ -2273,6 +2274,10 @@ private function applyNpcFieldValueRecorded(array $field, string $rawValue): voi $this->npcInspector->commit(); $after = $map->getNpcs(); + if ($fieldId === 'name') { + $after = $this->followNpcNameWithId($map, $after, $index); + } + if ($after->toMapData() === $before->toMapData()) { // A same-value edit: no history, no dirt. $this->refreshNpcInspector(); @@ -2294,6 +2299,43 @@ function () use ($map, $before, $index): void { )); } + /** + * Gives a renamed NPC the id its new name derives, as long as nothing + * refers to it yet: an id something names stays, so doors, routes and + * cinematics keep finding the NPC, and the status says what names it. + * + * @return NpcCollection The map's NPCs after the rename. + */ + private function followNpcNameWithId(ProjectMap $map, NpcCollection $npcs, int $index): NpcCollection + { + $npc = $npcs->get($index); + $id = $npc?->getId(); + + if ($npc === null || $id === null || $id === '') { + return $npcs; + } + + $derived = $npcs->withRemoved($index)->uniqueIdFor($npc->getName()); + + if ($derived === $id) { + return $npcs; + } + + $references = $this->workspace instanceof ProjectWorkspace ? new NpcReferences($this->workspace)->describe($map, $id) : []; + + if ($references !== []) { + $this->setStatus(sprintf('Renamed. Its id stays %s: %s names it.', $id, implode(', ', $references))); + + return $npcs; + } + + $renamed = $npcs->withReplaced($index, $npc->withId($derived)); + $map->setNpcs($renamed); + $this->setStatus(sprintf('Renamed. Its id is now %s.', $derived), StatusLevel::SUCCESS); + + return $renamed; + } + /** * Handles the keys NPC mode owns on the canvas. * @@ -2724,7 +2766,7 @@ private function buildNpcNamePromptRows(): array sprintf('New NPC at (%d, %d)', $tile['x'], $tile['y']), '', sprintf('Name: %s', $this->npcNameBuffer), - $preview !== '' ? sprintf('Id: %s', $preview) : 'Id: (derived from the name, once)', + $preview !== '' ? sprintf('Id: %s', $preview) : 'Id: (derived from the name)', '', ' Enter creates it, Esc cancels.', ]; diff --git a/src/Field/NpcReferences.php b/src/Field/NpcReferences.php index 071b716..8029b59 100644 --- a/src/Field/NpcReferences.php +++ b/src/Field/NpcReferences.php @@ -4,20 +4,25 @@ namespace Ichiloto\Editor\Field; +use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; +use Throwable; /** * Finding what names an NPC's stable id. * - * A `move_route` whose subject is `npc` names it by `npcId`; a script's - * diagnostics name it too. Knowing what points at an id is what lets the - * editor refuse to delete an NPC something still expects, and say what. + * A `move_route` whose subject is `npc` names it by `npcId`, a camera or + * cinematic subject of kind `npc` by `npcId` or `id`, and a dialogue event by + * its data's `npcId`. Knowing what points at an id is what lets the editor + * refuse to delete an NPC something still expects, and keep the id of one + * something already names when it is renamed. * * NPC ids are map-local, so a reference is only a reference from that map's - * own context: its map-authored event scripts, its NPCs' inline scripts and - * variant scripts, and the reusable event scripts those trigger. + * own context: its map-authored events and their scripts, its NPCs' inline + * scripts and variant scripts, the reusable event scripts those trigger, and + * the cinematics that start on the map. * * @package Ichiloto\Editor\Field */ @@ -53,7 +58,7 @@ public function describe(ProjectMap $map, string $npcId): array $data = (array) ($definition['data'] ?? []); - if ($this->commandsName((array) ($data['script'] ?? []), $npcId)) { + if (trim(strval($data['npcId'] ?? '')) === $npcId || $this->commandsName((array) ($data['script'] ?? []), $npcId)) { $found[] = sprintf('%s event %s', $map->mapId, strval($marker)); } @@ -97,41 +102,51 @@ public function describe(ProjectMap $map, string $npcId): array } } + // Cinematics that start on this map: their cast and commands. + foreach ($this->workspace->cutscenes?->assets(CutsceneType::CINEMATIC) ?? [] as $asset) { + try { + $data = $asset->data(); + $commands = $asset->commands(); + } catch (Throwable) { + continue; + } + + if (trim(strval($data['startMap'] ?? '')) !== $map->mapId) { + continue; + } + + if ($this->commandsName([...(array) ($data['cast'] ?? []), ...(array) ($data['finalizer'] ?? []), ...$commands], $npcId)) { + $found[] = sprintf('cinematic %s', $asset->id); + } + } + return array_values(array_unique($found)); } /** - * Determines whether a command list routes the NPC, however deeply. + * Determines whether commands name the NPC, however deeply nested: a + * `move_route` for subject `npc`, or any subject, target or cast entry + * of kind `npc`, by `npcId` or `id`. * - * @param array $commands The commands. + * @param array $commands The commands. * @param string $npcId The id. * @return bool True when one does. */ private function commandsName(array $commands, string $npcId): bool { - foreach ($commands as $command) { - if (! is_array($command)) { + foreach ($commands as $node) { + if (! is_array($node)) { continue; } - if ( - strval($command['type'] ?? '') === 'move_route' - && strtolower(trim(strval($command['subject'] ?? 'player'))) === 'npc' - && trim(strval($command['npcId'] ?? '')) === $npcId - ) { - return true; - } + $namesNpc = strtolower(trim(strval($node['kind'] ?? $node['subject'] ?? ''))) === 'npc'; - foreach ((array) ($command['options'] ?? []) as $option) { - if (is_array($option) && $this->commandsName((array) ($option['then'] ?? []), $npcId)) { - return true; - } + if ($namesNpc && trim(strval($node['npcId'] ?? $node['id'] ?? '')) === $npcId) { + return true; } - foreach (['then', 'else'] as $arm) { - if ($this->commandsName((array) ($command[$arm] ?? []), $npcId)) { - return true; - } + if ($this->commandsName($node, $npcId)) { + return true; } } diff --git a/src/Field/ProjectNpc.php b/src/Field/ProjectNpc.php index 47dca5b..4d05a87 100644 --- a/src/Field/ProjectNpc.php +++ b/src/Field/ProjectNpc.php @@ -17,9 +17,10 @@ * (directional glyphs, either case of key accepted on load). * * The payload is kept verbatim: a key this class does not know survives - * load, edit, save, and reload untouched. Identity is immutable after - * creation — nothing here renames an id, because doors and routes that name - * it would not follow. + * load, edit, save, and reload untouched. A field edit never changes the id: + * the editor gives an NPC a new id only through {@see withId()}, while + * nothing refers to it yet, because doors and routes that name an id would + * not follow. * * @package Ichiloto\Editor\Field */ @@ -397,6 +398,21 @@ public function movedTo(int $x, int $y): self * @param string $id The new id. * @return self The copy. */ + /** + * Returns a copy under another id, in the id's place, for an NPC whose + * name changed while nothing refers to it yet. + * + * @param string $id The new id. + * @return self The NPC under that id. + */ + public function withId(string $id): self + { + $payload = $this->payload; + $payload['id'] = $id; + + return new self($payload); + } + public function asCopyWithId(string $id): self { $payload = $this->payload; diff --git a/tests/Unit/NpcAuthoringTest.php b/tests/Unit/NpcAuthoringTest.php index e1d2ec1..dba3916 100644 --- a/tests/Unit/NpcAuthoringTest.php +++ b/tests/Unit/NpcAuthoringTest.php @@ -546,7 +546,7 @@ function npcIssueLines(string $root, ?Severity $severity = null): array // -- Inspector: every field through the shared pane ---------------------- -it('edits name, sprite, coordinates and movement without touching the id, hiding wander bounds while fixed', function () { +it('edits name, sprite, coordinates and movement, hiding wander bounds while fixed', function () { [$root, $path] = npcProject([['id' => 'a', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1]]); $editor = npcEditor($root); $map = npcMap($editor); @@ -562,7 +562,8 @@ function npcIssueLines(string $root, ?Severity $severity = null): array setNpcField($editor, 'movement', 'wander'); $npc = $map->getNpcs()->get(0); - expect($npc?->getId())->toBe('a') + // Nothing names Ann, so her id follows her new name. + expect($npc?->getId())->toBe('annabel') ->and($npc?->getName())->toBe('Annabel') ->and($npc?->getSprite())->toBe('a') ->and($npc?->getX())->toBe(5) @@ -595,6 +596,51 @@ function npcIssueLines(string $root, ?Severity $severity = null): array expect(getEditorProperty($editor, 'history')->count())->toBe($before); }); +it('re-derives an NPC\'s id from its new name while nothing refers to it, in the same undo step', function () { + [$root] = npcProject([['id' => 'gate-guard', 'name' => 'Gate Guard', 'sprite' => 'G', 'x' => 6, 'y' => 3]]); + $editor = npcEditor($root); + $map = npcMap($editor); + + createNpcThroughCanvas($editor, 2, 1, ''); + expect($map->getNpcs()->get(1)?->getId())->toBe('new-npc'); + callEditorMethod($editor, 'selectNpc', 1); + getEditorProperty($editor, 'toasts')->clear(); + setNpcField($editor, 'name', 'Old Mara'); + expect($map->getNpcs()->get(1)?->getId())->toBe('old-mara') + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('old-mara'); + + // A name another NPC's id already takes is numbered, as at creation. + setNpcField($editor, 'name', 'Gate Guard'); + expect($map->getNpcs()->get(1)?->getId())->toBe('gate-guard-2'); + + callEditorMethod($editor, 'performUndo'); + expect($map->getNpcs()->get(1)?->getId())->toBe('old-mara') + ->and($map->getNpcs()->get(1)?->getName())->toBe('Old Mara'); + callEditorMethod($editor, 'performUndo'); + expect($map->getNpcs()->get(1)?->getId())->toBe('new-npc'); +}); + +it('keeps the id of an NPC something already names when it is renamed, and says what', function (array $events, array $scriptOwner, string $names) { + [$root] = npcProject([ + ['id' => 'mara', 'name' => 'Mara', 'sprite' => 'M', 'x' => 2, 'y' => 1], + ['id' => 'bob', 'name' => 'Bob', 'sprite' => 'B', 'x' => 6, 'y' => 3, ...$scriptOwner], + ], $events); + $editor = npcEditor($root); + $map = npcMap($editor); + + callEditorMethod($editor, 'selectNpc', 0); + setNpcField($editor, 'name', 'Old Mara'); + + expect($map->getNpcs()->get(0)?->getId())->toBe('mara') + ->and($map->getNpcs()->get(0)?->getName())->toBe('Old Mara') + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Its id stays mara')->toContain($names); +})->with([ + 'a dialogue event' => [['T' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\DialogueEventTrigger', 'data' => ['npcId' => 'mara']]], [], 'test-map event T'], + 'a camera target nested in a sequence' => [[], ['script' => [['type' => 'sequence', 'commands' => [ + ['type' => 'camera', 'target' => ['kind' => 'npc', 'id' => 'mara']], + ]]]], 'NPC Bob script'], +]); + it('reads Movement and Sprite as their runtime defaults when unset', function () { [$root] = npcProject([['name' => 'Legacy', 'x' => 2, 'y' => 1]]); $editor = npcEditor($root); @@ -883,7 +929,7 @@ public function __construct() callEditorMethod($editor, 'dispatchInput', "\033[3~"); callEditorMethod($editor, 'performUndo'); callEditorMethod($editor, 'saveSelectedMap'); - expect(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getNpcs()->ids())->toBe(['a']); + expect(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getNpcs()->ids())->toBe(['three']); }); it('duplicates a map with its NPC collection and internal routes intact', function () { From e6915d0e2336ffa40ebcf30b24fa43c2cf120c65 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 28 Sep 2026 17:00:07 +0200 Subject: [PATCH 063/344] fix(status): keep green for what reached disk and show updates in blue Green reads as saved, yet about sixty messages for edits held in memory (field updates, added and removed entries, NPC changes, undo and redo) were green, so renaming an NPC looked saved. Green now marks only saves, commands that write files at once, and passed checks; every other update is info, which the footer now draws in blue while the idle message stays plain. The rule is documented on StatusLevel and in the manual. --- docs/manual.md | 6 ++ src/Cutscenes/Editing/CutsceneOutlinePane.php | 14 +-- src/Cutscenes/Editing/CutscenePreviewPane.php | 12 +-- src/Cutscenes/Editing/CutscenesWorkspace.php | 16 ++-- src/Editor.php | 95 ++++++++++--------- src/Status/StatusLevel.php | 10 +- 6 files changed, 84 insertions(+), 69 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 8049b01..345b5dc 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -79,6 +79,12 @@ is what the code draws rather than a sketch of it: and, on its second line, the cursor, the viewport, and the most recent message. +A message's colour says what happened: blue for an update, including every +edit, which stays in memory until you save; green only when something reached +disk (a save, or a command that writes files at once) or a check passed; +yellow for a warning; red for an error. Edits never show green, so green +always means saved, and the header's `*` shows what is still unsaved. + Each panel writes its own keys into its bottom border, and shortens them on a narrow terminal rather than cutting one in half - so what a panel offers is always on the panel. `?` lists everything at any width. diff --git a/src/Cutscenes/Editing/CutsceneOutlinePane.php b/src/Cutscenes/Editing/CutsceneOutlinePane.php index 5e2695d..f17ea66 100644 --- a/src/Cutscenes/Editing/CutsceneOutlinePane.php +++ b/src/Cutscenes/Editing/CutsceneOutlinePane.php @@ -207,7 +207,7 @@ private function reorderCutsceneTreeRow(int $delta): void $delta < 0 ? 'Move up' : 'Move down', static fn(array $payload): array => CutsceneOutline::withValueAt($payload, $entry['listPath'], $list), )) { - $this->setStatus(sprintf('Moved %s.', $delta < 0 ? 'up' : 'down'), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Moved %s.', $delta < 0 ? 'up' : 'down'), StatusLevel::INFO); $this->focusCutsceneTreeKey($targetKey); } } @@ -245,7 +245,7 @@ private function insertCutsceneTreeRow(): void 'Add ' . $entry['row']['kind'], static fn(array $payload): array => CutsceneOutline::withValueAt($payload, $entry['listPath'], $list), )) { - $this->setStatus(sprintf('Added a %s after the selected one.', $entry['row']['kind']), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Added a %s after the selected one.', $entry['row']['kind']), StatusLevel::INFO); $this->focusCutsceneTreeKey($targetKey); } } @@ -283,7 +283,7 @@ private function duplicateCutsceneTreeRow(): void 'Duplicate ' . $entry['row']['kind'], static fn(array $payload): array => CutsceneOutline::withValueAt($payload, $entry['listPath'], $list), )) { - $this->setStatus(sprintf('Duplicated the %s.', $entry['row']['kind']), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Duplicated the %s.', $entry['row']['kind']), StatusLevel::INFO); $this->focusCutsceneTreeKey($targetKey); } } @@ -314,7 +314,7 @@ private function removeCutsceneTreeRow(): void 'Remove ' . $kind, static fn(array $payload): array => CutsceneOutline::withValueAt($payload, $entry['listPath'], $list), )) { - $this->setStatus(sprintf('Removed the %s (Ctrl+Z restores it).', $kind), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Removed the %s (Ctrl+Z restores it).', $kind), StatusLevel::INFO); $this->cutsceneTreeCursor = max(0, min($this->cutsceneTreeCursor, count($this->visibleCutsceneTreeRows()) - 1)); $this->renderCutscenesArea(); } @@ -370,7 +370,7 @@ private function nestCutsceneTreeRow(): void return CutsceneOutline::withValueAt($payload, $target, $targetList); })) { - $this->setStatus(sprintf('Nested into the %s above.', strval($previous['type'] ?? 'block')), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Nested into the %s above.', strval($previous['type'] ?? 'block')), StatusLevel::INFO); $this->focusCutsceneTreeKey($this->keyForPath([...$target, (int) $targetKey])); } } @@ -438,7 +438,7 @@ private function unnestCutsceneTreeRow(): void return CutsceneOutline::withValueAt($payload, $ownerList, $parent); })) { - $this->setStatus('Moved out of its block.', StatusLevel::SUCCESS); + $this->setStatus('Moved out of its block.', StatusLevel::INFO); $this->focusCutsceneTreeKey($this->keyForPath([...$ownerList, $ownerIndex + 1])); } } @@ -476,7 +476,7 @@ private function nudgeCutsceneTreeFrame(int $delta): bool 'Nudge frame', static fn(array $payload): array => CutsceneOutline::withValueAt($payload, $path, $next), )) { - $this->setStatus(sprintf('Frame %d.', $next), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Frame %d.', $next), StatusLevel::INFO); $this->renderCutscenesArea(); } diff --git a/src/Cutscenes/Editing/CutscenePreviewPane.php b/src/Cutscenes/Editing/CutscenePreviewPane.php index beef63c..647048b 100644 --- a/src/Cutscenes/Editing/CutscenePreviewPane.php +++ b/src/Cutscenes/Editing/CutscenePreviewPane.php @@ -252,7 +252,7 @@ private function startCinematicPreview(bool $play): void $origin['mapId'] !== null ? sprintf(' on %s at %d,%d', $origin['mapId'], $origin['x'], $origin['y']) : ' without a map', $play ? 'playing' : 'paused', ), - $failure !== null ? StatusLevel::ERROR : StatusLevel::SUCCESS, + $failure !== null ? StatusLevel::ERROR : StatusLevel::INFO, ); $this->renderDatabasePanes(['preview', 'tree']); } @@ -329,7 +329,7 @@ private function skipCinematicPreview(): void } if ($preview->skip()) { - $this->setStatus('Skip accepted: the finalizer is running.', StatusLevel::SUCCESS); + $this->setStatus('Skip accepted: the finalizer is running.', StatusLevel::INFO); $preview->play(); $this->cutscenePreviewLastTickAt = microtime(true); } else { @@ -525,7 +525,7 @@ private function tickCutscenePreview(): void $failure !== null ? sprintf('Preview failed: %s (J jumps to it)', $failure['message']) : sprintf('Preview %s after %.1fs.', $preview->status(), $preview->elapsed()), - $failure !== null ? StatusLevel::ERROR : StatusLevel::SUCCESS, + $failure !== null ? StatusLevel::ERROR : StatusLevel::INFO, ); } } @@ -668,7 +668,7 @@ private function startSummonPreview(bool $play): void $this->summonPreview->totalFrames(), $this->summonPreview->fps(), $play ? 'playing' : 'paused', - ), StatusLevel::SUCCESS); + ), StatusLevel::INFO); $this->renderDatabasePanes(['preview', 'tree']); } @@ -692,7 +692,7 @@ private function tickSummonPreview(): void $this->renderDatabasePanes(['preview', 'tree']); if ($preview->isCompleted()) { - $this->setStatus(sprintf('Summon preview finished: %d frames, %d cue%s fired.', $preview->totalFrames(), count($preview->cueLog()), count($preview->cueLog()) === 1 ? '' : 's'), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Summon preview finished: %d frames, %d cue%s fired.', $preview->totalFrames(), count($preview->cueLog()), count($preview->cueLog()) === 1 ? '' : 's'), StatusLevel::INFO); } } @@ -802,7 +802,7 @@ private function playtestSelectedCinematic(): void $this->terminal->resumeAfterChildProcess($this->lastTerminalSize); } - $this->setStatus(sprintf('Playtest finished (%s on %s at %d,%d).', $asset->id, $origin['mapId'], $origin['x'], $origin['y']), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Playtest finished (%s on %s at %d,%d).', $asset->id, $origin['mapId'], $origin['x'], $origin['y']), StatusLevel::INFO); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, 'Cinematic playtest'); } finally { diff --git a/src/Cutscenes/Editing/CutscenesWorkspace.php b/src/Cutscenes/Editing/CutscenesWorkspace.php index a7c69eb..75b7bf8 100644 --- a/src/Cutscenes/Editing/CutscenesWorkspace.php +++ b/src/Cutscenes/Editing/CutscenesWorkspace.php @@ -1189,7 +1189,7 @@ function () use ($library, $type, $oldId, $newId): void { $this->renderCutscenesArea(); }, )); - $this->setStatus(sprintf('Renamed to "%s"; the folder takes this name on save.', $newId), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Renamed to "%s"; the folder takes this name on save.', $newId), StatusLevel::INFO); $this->renderCutscenesArea(includeRoot: true); } @@ -1297,7 +1297,7 @@ private function addCutsceneSubItem(): void } $this->cutsceneFocus = CutscenesScreen::PANE_SETTINGS; - $this->setStatus('Added.', StatusLevel::SUCCESS); + $this->setStatus('Added.', StatusLevel::INFO); $this->renderCutscenesArea(); } @@ -1388,7 +1388,7 @@ private function removeCutsceneSubItem(bool $last): void } $this->clampDatabaseSettingSelection(); - $this->setStatus('Removed. Ctrl+Z restores it.', StatusLevel::SUCCESS); + $this->setStatus('Removed. Ctrl+Z restores it.', StatusLevel::INFO); $this->renderCutscenesArea(); } @@ -1434,7 +1434,7 @@ private function createCutscene(): void $this->databaseSelectedSettingIndex = 0; $this->databaseCommandFramePath = []; $this->cutsceneFocus = CutscenesScreen::PANE_SETTINGS; - $this->setStatus(sprintf('Created %s "%s". It reaches disk on save.', $type->noun(), $id), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Created %s "%s". It reaches disk on save.', $type->noun(), $id), StatusLevel::INFO); $this->renderCutscenesArea(includeRoot: true); } @@ -1473,7 +1473,7 @@ private function duplicateSelectedCutscene(): void $this->selectCutsceneById($newId); $this->databaseSelectedSettingIndex = 0; $this->databaseCommandFramePath = []; - $this->setStatus(sprintf('Duplicated as "%s". It reaches disk on save.', $newId), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Duplicated as "%s". It reaches disk on save.', $newId), StatusLevel::INFO); $this->renderCutscenesArea(includeRoot: true); } @@ -1557,7 +1557,7 @@ static function (ProjectRecordDatabase $records, int $index): void { $this->clampCutsceneSelection(); $this->databaseSelectedSettingIndex = 0; $this->databaseCommandFramePath = []; - $this->setStatus(sprintf('Deleted %s. Ctrl+Z restores it; the folder is removed on save.', $asset->name()), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Deleted %s. Ctrl+Z restores it; the folder is removed on save.', $asset->name()), StatusLevel::INFO); } $this->renderCutscenesArea(includeRoot: true); @@ -1584,7 +1584,7 @@ private function saveSelectedCutscene(): void $written ? sprintf('%s "%s" saved.', ucfirst($asset->type->noun()), $asset->id) : sprintf('%s "%s" is clean; nothing written.', ucfirst($asset->type->noun()), $asset->id), - StatusLevel::SUCCESS, + $written ? StatusLevel::SUCCESS : StatusLevel::INFO, ); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, sprintf('%s save', ucfirst($asset->type->noun()))); @@ -1685,7 +1685,7 @@ private function handleMultilineEditorInput(string $input): void if (is_array($field)) { try { $this->applyDatabaseFieldValueRecorded($field, $text); - $this->setStatus(sprintf('%s applied.', $label), StatusLevel::SUCCESS); + $this->setStatus(sprintf('%s applied.', $label), StatusLevel::INFO); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, $label); } diff --git a/src/Editor.php b/src/Editor.php index b310ac3..c1c304a 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -1106,7 +1106,7 @@ private function performUndo(): void $this->clampCursor(); $this->clampCanvasOffsets(); $this->clampInspectorSelection(); - $this->setStatus(sprintf('Undid %s.', self::asPhrase($command->label)), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Undid %s.', self::asPhrase($command->label)), StatusLevel::INFO); $this->requestFullRender(); } @@ -1133,7 +1133,7 @@ private function performRedo(): void $this->clampCursor(); $this->clampCanvasOffsets(); $this->clampInspectorSelection(); - $this->setStatus(sprintf('Redid %s.', self::asPhrase($command->label)), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Redid %s.', self::asPhrase($command->label)), StatusLevel::INFO); $this->requestFullRender(); } @@ -2331,7 +2331,7 @@ private function followNpcNameWithId(ProjectMap $map, NpcCollection $npcs, int $ $renamed = $npcs->withReplaced($index, $npc->withId($derived)); $map->setNpcs($renamed); - $this->setStatus(sprintf('Renamed. Its id is now %s.', $derived), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Renamed. Its id is now %s.', $derived), StatusLevel::INFO); return $renamed; } @@ -2486,7 +2486,7 @@ private function assignIdToSelectedNpc(): void $id = $before->uniqueIdFor($npc->getName()); $map->setNpcs($before->withReplaced($index, $npc->asCopyWithId($id))); $this->recordNpcCollectionChange($map, $index, $before, sprintf('Assign NPC id %s', $id)); - $this->setStatus(sprintf('Assigned the stable id "%s" to %s.', $id, $npc->getName()), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Assigned the stable id "%s" to %s.', $id, $npc->getName()), StatusLevel::INFO); } /** @@ -2808,7 +2808,7 @@ function () use ($map, $before): void { $this->selectNpc(null); }, )); - $this->setStatus(sprintf('Created %s. Its id is %s.', $name, $id), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Created %s. Its id is %s.', $name, $id), StatusLevel::INFO); $this->focusedPane = self::FOCUS_INSPECTOR; $this->requestFullRender(); } @@ -2906,7 +2906,7 @@ function () use ($map, $before, $index): void { $this->selectNpc($index); }, )); - $this->setStatus(sprintf('Moved %s to %d,%d.', $npc->getName(), $x, $y), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Moved %s to %d,%d.', $npc->getName(), $x, $y), StatusLevel::INFO); $this->requestFullRender(); } @@ -2953,7 +2953,7 @@ function () use ($map, $before): void { $this->selectNpc(null); }, )); - $this->setStatus(sprintf('Duplicated as %s (id %s).', $copy->getName(), $id), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Duplicated as %s (id %s).', $copy->getName(), $id), StatusLevel::INFO); $this->requestFullRender(); } @@ -3008,7 +3008,7 @@ function () use ($map, $before, $index): void { $this->selectNpc($index); }, )); - $this->setStatus(sprintf('Deleted %s.', $npc->getName()), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Deleted %s.', $npc->getName()), StatusLevel::INFO); $this->requestFullRender(); } @@ -5546,7 +5546,7 @@ private function startPlaytest(): void $this->setStatus( sprintf('Playtest finished (%s at %d,%d).', $selectedMap->mapId, $this->cursorX, $this->cursorY), - StatusLevel::SUCCESS, + StatusLevel::INFO, ); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, 'Playtest'); @@ -5610,7 +5610,7 @@ private function performReload(): void $this->canvasToolAnchor = null; $this->canvasSelection = null; $this->piecePlacement = null; - $this->setStatus('Workspace refreshed.', StatusLevel::SUCCESS); + $this->setStatus('Workspace refreshed.', StatusLevel::INFO); $this->requestFullRender(); } @@ -6682,7 +6682,7 @@ private function goToActorClass(): void $this->databaseSelectedClassIndex = $classIndex; $this->databaseFocus = self::DATABASE_FOCUS_LIST; $this->databaseSelectedSettingIndex = 0; - $this->setStatus(sprintf('Went to class %s (Ctrl+B goes back).', $className), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Went to class %s (Ctrl+B goes back).', $className), StatusLevel::INFO); $this->renderDatabaseArea(includeRoot: true); } @@ -6737,7 +6737,7 @@ private function goToSkillAnimation(): void $this->databaseSelectedAnimationIndex = $animationIndex; $this->databaseFocus = self::DATABASE_FOCUS_LIST; $this->databaseSelectedSettingIndex = 0; - $this->setStatus(sprintf('Went to animation %s (Ctrl+B goes back).', $matchedName), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Went to animation %s (Ctrl+B goes back).', $matchedName), StatusLevel::INFO); $this->renderDatabaseArea(includeRoot: true); } @@ -6789,7 +6789,7 @@ private function goToMapDefinition(): void $this->clampCursor(); $this->syncViewportToCursor(); $this->clampInspectorSelection(); - $this->setStatus(sprintf('Went to %s (Ctrl+B goes back).', $destination), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Went to %s (Ctrl+B goes back).', $destination), StatusLevel::INFO); $this->renderSelectionDependentArea(); } @@ -8783,7 +8783,7 @@ private function applyInspectorAdjustment(array $field, string $rawValue): void $this->clampCursor(); $this->clampCanvasOffsets(); $this->clampInspectorSelection(); - $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::SUCCESS); + $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::INFO); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, sprintf('%s edit', $field['label'] ?? 'Field')); } @@ -8951,7 +8951,7 @@ private function commitInspectorEdit(): void $this->clampCursor(); $this->clampCanvasOffsets(); $this->clampInspectorSelection(); - $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::SUCCESS); + $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::INFO); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, sprintf('%s edit', $field['label'] ?? 'Field')); } @@ -9290,7 +9290,7 @@ private function createDatabaseRecord(): void $this->setSelectedRecordIndex($index); $this->databaseSelectedSettingIndex = 0; $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; - $this->setStatus(sprintf('Created a new %s.', $database->schema->entryNoun), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Created a new %s.', $database->schema->entryNoun), StatusLevel::INFO); $this->renderDatabaseArea(); if ($database->schema->key === 'skits') { $this->selectNewSkitActor(0); @@ -9341,7 +9341,7 @@ private function duplicateDatabaseRecord(): void } $this->setSelectedRecordIndex($copyIndex); - $this->setStatus(sprintf('Duplicated the %s.', $database->schema->entryNoun), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Duplicated the %s.', $database->schema->entryNoun), StatusLevel::INFO); $this->renderDatabaseArea(); } @@ -9394,7 +9394,7 @@ private function moveDatabaseRecord(int $step): void 'Moved the %s %s.', $database->schema->entryNoun, $step < 0 ? 'up' : 'down', - ), StatusLevel::SUCCESS); + ), StatusLevel::INFO); $this->renderDatabaseArea(); } @@ -9434,7 +9434,7 @@ private function addDatabaseFrameItem(ProjectRecordDatabase $database, RecordSub static fn() => $database->removeChoiceOption($recordIndex, $framePath, $commandIndex, $optionIndex), )); $this->selectDatabaseFieldById(sprintf('%s%dOption%dText', $subList->prefix, $commandIndex, $optionIndex)); - $this->setStatus(sprintf('Added option %d.', $optionIndex + 1), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Added option %d.', $optionIndex + 1), StatusLevel::INFO); $this->renderDatabaseArea(); $this->beginDatabaseEdit(); @@ -9455,7 +9455,7 @@ private function addDatabaseFrameItem(ProjectRecordDatabase $database, RecordSub static fn() => $database->removeFrameCommand($recordIndex, $framePath, $commandIndex), )); $this->selectDatabaseFieldById(sprintf('%s%dType', $subList->prefix, $commandIndex)); - $this->setStatus(sprintf('Added %s %d.', $subList->singular, $commandIndex + 1), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Added %s %d.', $subList->singular, $commandIndex + 1), StatusLevel::INFO); $this->renderDatabaseArea(); } @@ -9494,7 +9494,7 @@ private function removeDatabaseFrameItem(ProjectRecordDatabase $database, Record $armCount > 0 ? sprintf('Removed option %d and its %d commands.', $optionIndex + 1, $armCount) : sprintf('Removed option %d.', $optionIndex + 1), - StatusLevel::SUCCESS, + StatusLevel::INFO, ); $this->renderDatabaseArea(); @@ -9517,7 +9517,7 @@ private function removeDatabaseFrameItem(ProjectRecordDatabase $database, Record static fn() => $database->removeFrameCommand($recordIndex, $framePath, $commandIndex), static fn() => $database->insertFrameCommand($recordIndex, $framePath, $commandIndex, $removed), )); - $this->setStatus(sprintf('Removed %s %d.', $subList->singular, $commandIndex + 1), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Removed %s %d.', $subList->singular, $commandIndex + 1), StatusLevel::INFO); $this->renderDatabaseArea(); } @@ -9679,7 +9679,7 @@ private function addDatabaseRecordSubItem(): void static fn() => $database->removeSubItem($recordIndex, $entryIndex), )); - $this->setStatus(sprintf('Added %s %d.', $subList->singular, $entryIndex + 1), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Added %s %d.', $subList->singular, $entryIndex + 1), StatusLevel::INFO); $this->renderDatabaseArea(); if ($database->schema->key === 'skits') { $this->selectNewSkitActor($entryIndex); @@ -9745,7 +9745,7 @@ private function removeDatabaseRecordSubItem(): void )); $this->databaseSelectedSettingIndex = 0; - $this->setStatus(sprintf('Removed %s %d.', $subList->singular, $entryIndex + 1), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Removed %s %d.', $subList->singular, $entryIndex + 1), StatusLevel::INFO); $this->renderDatabaseArea(); } @@ -9797,7 +9797,7 @@ private function addDatabaseNestedSubItem(): void )); $this->setStatus( sprintf('Added %s %d.', $context['list']->singular, $nestedIndex + 1), - StatusLevel::SUCCESS, + StatusLevel::INFO, ); $this->renderDatabaseArea(); } @@ -9840,7 +9840,7 @@ private function removeDatabaseNestedSubItem(): void ); $this->setStatus( sprintf('Removed %s %d.', $context['list']->singular, $nestedIndex + 1), - StatusLevel::SUCCESS, + StatusLevel::INFO, ); $this->renderDatabaseArea(); } @@ -10124,7 +10124,7 @@ private function addDatabaseQuestRewardItem(): void } } - $this->setStatus(sprintf('Reward item %d added.', $slot + 1), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Reward item %d added.', $slot + 1), StatusLevel::INFO); $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); $this->beginDatabaseEdit(); } @@ -10159,7 +10159,7 @@ private function removeDatabaseQuestRewardItem(): void static fn() => $questDatabase->removeRewardItem($questIndex, $slot), static fn() => $questDatabase->insertRewardItem($questIndex, $slot, $removed), )); - $this->setStatus(sprintf('Reward item %d removed (%s).', $slot + 1, $removed), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Reward item %d removed (%s).', $slot + 1, $removed), StatusLevel::INFO); $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); } @@ -10189,7 +10189,7 @@ private function addDatabaseQuestObjective(): void static fn() => $questDatabase->insertObjective($questIndex, $objectiveIndex, $objective), static fn() => $questDatabase->removeObjective($questIndex, $objectiveIndex), )); - $this->setStatus(sprintf('Objective %d added.', $objectiveIndex + 1), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Objective %d added.', $objectiveIndex + 1), StatusLevel::INFO); $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); } @@ -10246,7 +10246,7 @@ private function removeDatabaseQuestObjective(): void static fn() => $questDatabase->removeObjective($questIndex, $objectiveIndex), static fn() => $questDatabase->insertObjective($questIndex, $objectiveIndex, $removed), )); - $this->setStatus(sprintf('Objective %d removed.', $objectiveIndex + 1), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Objective %d removed.', $objectiveIndex + 1), StatusLevel::INFO); $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); } @@ -11517,7 +11517,7 @@ static function () use ($questDatabase, $questIndex, $previousName, $previousId) )); if (is_string($newId)) { - $this->setStatus(sprintf('Renamed. Its id is now %s.', $newId), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Renamed. Its id is now %s.', $newId), StatusLevel::INFO); return; } @@ -11716,7 +11716,7 @@ private function commitWorldWrites(): void try { $this->applyDatabaseFieldValueRecorded($field, $encoded); - $this->setStatus(sprintf('%s updated.', $label), StatusLevel::SUCCESS); + $this->setStatus(sprintf('%s updated.', $label), StatusLevel::INFO); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, sprintf('%s edit', $label)); } @@ -11866,7 +11866,7 @@ private function commitBattleEntryPredicates(): void try { $this->applyDatabaseFieldValueRecorded($field, $encoded); - $this->setStatus(sprintf('%s updated.', $label), StatusLevel::SUCCESS); + $this->setStatus(sprintf('%s updated.', $label), StatusLevel::INFO); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, sprintf('%s edit', $label)); } @@ -12028,7 +12028,7 @@ private function commitAffinities(): void try { $this->applyDatabaseFieldValueRecorded($field, $encoded); - $this->setStatus(sprintf('%s updated.', $label), StatusLevel::SUCCESS); + $this->setStatus(sprintf('%s updated.', $label), StatusLevel::INFO); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, sprintf('%s edit', $label)); } @@ -12234,7 +12234,7 @@ private function commitConditions(): void if ($selectedMap instanceof ProjectMap) { $this->applyMapBgmVariantValue($selectedMap, $field, ConditionCodec::decodeAll($encoded)); - $this->setStatus(sprintf('%s updated.', $label), StatusLevel::SUCCESS); + $this->setStatus(sprintf('%s updated.', $label), StatusLevel::INFO); } $this->renderSelectionDependentArea(); @@ -12261,7 +12261,7 @@ private function commitConditions(): void try { $this->applyDatabaseFieldValueRecorded($field, $encoded); - $this->setStatus(sprintf('%s updated.', $label), StatusLevel::SUCCESS); + $this->setStatus(sprintf('%s updated.', $label), StatusLevel::INFO); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, sprintf('%s edit', $label)); } @@ -12548,7 +12548,7 @@ private function commitReferenceSelection(): void $this->applyDatabaseFieldValueRecorded($field, implode(', ', $members)); $this->setStatus( $position === false ? sprintf('%s: added %s.', $label, $selected) : sprintf('%s: removed %s.', $label, $selected), - StatusLevel::SUCCESS, + StatusLevel::INFO, ); $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); @@ -12561,7 +12561,7 @@ private function commitReferenceSelection(): void $selected === (string) ($field['noneLabel'] ?? '(None)') ? sprintf('%s cleared.', $label) : sprintf('%s set to %s.', $label, $selected), - StatusLevel::SUCCESS, + StatusLevel::INFO, ); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, sprintf('%s selection', $label)); @@ -12590,7 +12590,7 @@ private function commitDatabaseEdit(): void try { $this->applyDatabaseFieldValueRecorded($field, $this->databaseEditBuffer); - $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::SUCCESS); + $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::INFO); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, sprintf('%s edit', $field['label'] ?? 'Field')); } @@ -13532,7 +13532,7 @@ private function addMapBgmVariant(): bool } $this->applyMapDataValue($context['map'], [MapBgmVariants::KEY], $variants->withVariantAdded(), 'Music variant add'); - $this->setStatus(sprintf('Added variant %d. Pick its track; it stays inactive until one is chosen.', $variants->count() + 1), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Added variant %d. Pick its track; it stays inactive until one is chosen.', $variants->count() + 1), StatusLevel::INFO); $this->clampInspectorSelection(); $this->renderSelectionDependentArea(); @@ -13570,7 +13570,7 @@ private function removeMapBgmVariant(): bool } $this->applyMapDataValue($context['map'], [MapBgmVariants::KEY], $variants->withVariantRemovedAt($context['index']), 'Music variant remove'); - $this->setStatus(sprintf('Removed variant %d.', $context['index'] + 1), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Removed variant %d.', $context['index'] + 1), StatusLevel::INFO); $this->clampInspectorSelection(); $this->renderSelectionDependentArea(); @@ -13619,7 +13619,7 @@ private function moveMapBgmVariant(int $step): void } } - $this->setStatus(sprintf('Moved variant %s. The first matching variant wins.', $step < 0 ? 'up' : 'down'), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Moved variant %s. The first matching variant wins.', $step < 0 ? 'up' : 'down'), StatusLevel::INFO); $this->renderSelectionDependentArea(); } @@ -15273,7 +15273,7 @@ private function addMapEncounterTroop(): bool } $this->applyMapDataValue($context['map'], [MapEncounters::KEY], $block, 'Encounter troop add'); - $this->setStatus(sprintf('Added %s to the encounter table.', $available[0]), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Added %s to the encounter table.', $available[0]), StatusLevel::INFO); $this->clampInspectorSelection(); $this->renderSelectionDependentArea(); @@ -15319,7 +15319,7 @@ private function removeMapEncounterTroop(): bool $block === null ? sprintf('Removed %s; this map no longer has random encounters.', $removed) : sprintf('Removed %s from the encounter table.', $removed), - StatusLevel::SUCCESS, + StatusLevel::INFO, ); $this->clampInspectorSelection(); $this->renderSelectionDependentArea(); @@ -15440,7 +15440,7 @@ private function addInspectorListItem(): void array_splice($entries, $position, 0, [self::blankLike($template)]); $this->applyInspectorListChange($selectedMap, $marker, $list['path'], $entries, 'Add list entry'); - $this->setStatus(sprintf('Added %s %d.', $this->describeListPath($list['path']), $position + 1), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Added %s %d.', $this->describeListPath($list['path']), $position + 1), StatusLevel::INFO); } /** @@ -15479,7 +15479,7 @@ private function removeInspectorListItem(): void $this->applyInspectorListChange($selectedMap, $marker, $list['path'], $entries, 'Remove list entry'); $this->clampInspectorSelection(); - $this->setStatus(sprintf('Removed %s %d.', $this->describeListPath($list['path']), $list['index'] + 1), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Removed %s %d.', $this->describeListPath($list['path']), $list['index'] + 1), StatusLevel::INFO); } /** @@ -17824,7 +17824,8 @@ private function drawFooter(): void */ private function renderFooterStatusColor(): void { - if ($this->statusLevel === StatusLevel::INFO) { + // The idle message stays plain; every message shown takes its level's colour. + if ($this->toasts->current() === null) { return; } diff --git a/src/Status/StatusLevel.php b/src/Status/StatusLevel.php index ff2114d..5f36ae4 100644 --- a/src/Status/StatusLevel.php +++ b/src/Status/StatusLevel.php @@ -9,10 +9,18 @@ /** * Classifies footer status messages so feedback severities are visually * distinct and expire on their own schedule. + * + * Colour carries meaning as much as the words do. Green reads as saved, so + * SUCCESS is only for something that reached disk (a save, or a command that + * writes files at once) or a check that passed. An edit held in memory until + * the author saves is INFO, however complete it is: the unsaved-changes + * marker says it is not yet saved. */ enum StatusLevel: string { + /** Blue: edits held until saved, navigation, previews and other updates. */ case INFO = 'info'; + /** Green: written to disk, or a check passed. */ case SUCCESS = 'success'; case WARN = 'warn'; case ERROR = 'error'; @@ -25,7 +33,7 @@ enum StatusLevel: string public function color(): Color { return match ($this) { - self::INFO => Color::WHITE, + self::INFO => Color::LIGHT_BLUE, self::SUCCESS => Color::LIGHT_GREEN, self::WARN => Color::YELLOW, self::ERROR => Color::LIGHT_RED, From 1f0f9f0cc66a1c25082820e0a38f801a98defddd Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 28 Sep 2026 17:11:58 +0200 Subject: [PATCH 064/344] fix(source): treat an array with a spread as having no addressable positions A spread such as `...array_map(...)` was read as one list entry, so every later position in the file pointed at the wrong entry: a list-aligned rewrite could touch the wrong literal or fail on a missing entry. A spread now marks its array as having an opaque key, so the writer refuses the array by path and entryAt() no longer resolves a position inside it. This narrows editing: an edit that goes through an array built with a spread is now refused instead of risking the wrong literal. On Last Legend that includes NPC edits on overworld/garden-of-roads, whose NPC list is built with spreads. --- src/Cutscenes/Source/ArraySourceWriter.php | 2 +- src/Cutscenes/Source/PhpArraySourceDocument.php | 7 +++++++ src/Cutscenes/Source/SourceNode.php | 7 ++++--- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Cutscenes/Source/ArraySourceWriter.php b/src/Cutscenes/Source/ArraySourceWriter.php index 599331d..7ebb8f7 100644 --- a/src/Cutscenes/Source/ArraySourceWriter.php +++ b/src/Cutscenes/Source/ArraySourceWriter.php @@ -153,7 +153,7 @@ private function diffArray(SourceNode $node, ?array $old, array $new, array $pat if ($node->hasOpaqueKey) { throw new SourcePreservationRefusal(sprintf( - 'The array at %s has a key the editor cannot read, so it will not rewrite the array.', + 'The array at %s has a key or spread the editor cannot read, so it will not rewrite the array.', PhpArraySourceDocument::describePath($path), )); } diff --git a/src/Cutscenes/Source/PhpArraySourceDocument.php b/src/Cutscenes/Source/PhpArraySourceDocument.php index 899a1d8..ca4f812 100644 --- a/src/Cutscenes/Source/PhpArraySourceDocument.php +++ b/src/Cutscenes/Source/PhpArraySourceDocument.php @@ -1139,6 +1139,13 @@ private static function readArray(array $tokens, int $index, string $source): ?a } } else { $sawImplicit = true; + + if ($tokens[$cursor]->is(T_ELLIPSIS)) { + // A spread adds entries only PHP can count, so no later + // position or key in this array is the file's to address. + $keyIsOpaque = true; + $hasOpaqueKey = true; + } } $value = self::readValue($tokens, $valueAt, $source, [',']); diff --git a/src/Cutscenes/Source/SourceNode.php b/src/Cutscenes/Source/SourceNode.php index f60f21e..3d3d157 100644 --- a/src/Cutscenes/Source/SourceNode.php +++ b/src/Cutscenes/Source/SourceNode.php @@ -27,7 +27,7 @@ * @param int $end The offset just past the node's last byte. * @param SourceEntry[] $entries The array's entries, in source order, for an array node. * @param bool $isList Whether an array node has no explicit keys at all. - * @param bool $hasOpaqueKey Whether an array node holds an entry whose key the parser could not read. + * @param bool $hasOpaqueKey Whether an array node holds an entry whose key the parser could not read, or a spread. * @param string|null $variable The variable name, without `$`, for a variable node. * @param int|null $bodyStart The offset just past `[` or `array(`, for an array node. * @param int|null $bodyEnd The offset of the closing `]` or `)`, for an array node. @@ -46,7 +46,8 @@ public function __construct( } /** - * Returns the entry holding a key, or the entry at a list position. + * Returns the entry holding a key, or the entry at a list position. A + * list with a spread has no position the file can address. * * @param int|string $key The key, or the position for a list. * @return SourceEntry|null The entry. @@ -54,7 +55,7 @@ public function __construct( public function entryFor(int|string $key): ?SourceEntry { if ($this->isList) { - return is_int($key) ? ($this->entries[$key] ?? null) : null; + return is_int($key) && ! $this->hasOpaqueKey ? ($this->entries[$key] ?? null) : null; } foreach ($this->entries as $entry) { From 2de57ce9b1c3da8b8d7ad6114e67da3005c6ebac Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 28 Sep 2026 17:07:19 +0200 Subject: [PATCH 065/344] refactor(migrations): share the reversible source-set plan and command The actor migration's transaction (watch every source read, stage the changed files over assets/, prove each reads back as planned, commit or roll back) and its history command (write, reload the workspace, undo restores files and workspace) are what any project-wide rewrite needs. They move to Storage\SourceSetPlan and History\SourceSetCommand; ActorIdentityMigrationPlan keeps its API and delegates. ActorIdentityMigrationCommand is replaced by SourceSetCommand, whose refusals name the operation. The comparable-value helper moves to PhpDataFile, and the rule for which map a script's alternative arms start on moves from ProjectValidator to Events\CommandMapContext so map edits can follow scripts the way validation does. --- src/Actors/ActorIdentityMigrationPlan.php | 90 ++++---------- src/Actors/ActorReferenceInventory.php | 10 +- src/Database/PhpDataFile.php | 16 +++ src/Editor.php | 4 +- src/Events/CommandMapContext.php | 61 ++++++++++ .../SourceSetCommand.php} | 25 ++-- src/Storage/SourceSetPlan.php | 115 ++++++++++++++++++ src/Validation/ProjectValidator.php | 26 +--- 8 files changed, 238 insertions(+), 109 deletions(-) create mode 100644 src/Events/CommandMapContext.php rename src/{Actors/ActorIdentityMigrationCommand.php => History/SourceSetCommand.php} (60%) create mode 100644 src/Storage/SourceSetPlan.php diff --git a/src/Actors/ActorIdentityMigrationPlan.php b/src/Actors/ActorIdentityMigrationPlan.php index 672eee9..1bb6bd2 100644 --- a/src/Actors/ActorIdentityMigrationPlan.php +++ b/src/Actors/ActorIdentityMigrationPlan.php @@ -5,81 +5,33 @@ namespace Ichiloto\Editor\Actors; use Ichiloto\Editor\Database\PhpDataFile; -use Ichiloto\Editor\Storage\FileSetTransaction; -use RuntimeException; -use Throwable; +use Ichiloto\Editor\Storage\SourceSetPlan; /** An explicit, reversible source transaction shared by the CLI and TUI. */ final class ActorIdentityMigrationPlan { - private bool $applied = false; + private readonly SourceSetPlan $sources; public function __construct( - private readonly string $root, - private readonly array $watched, - private readonly array $originals, - private readonly array $proposals, - private readonly array $beforeValues, - private readonly array $afterValues, - ) {} - - public function getChangedPaths(): array { return array_keys($this->proposals); } - public function getOriginalSources(): array { return $this->originals; } - public function getProposedSources(): array { return $this->proposals; } - - public function apply(): array - { - if (! $this->applied) { - $this->installSources($this->proposals, $this->afterValues); - $this->applied = true; - } - return $this->getChangedPaths(); - } - - public function revert(): void - { - if ($this->applied) { - $this->installSources($this->originals, $this->beforeValues); - $this->applied = false; - } + string $root, + array $watched, + array $originals, + array $proposals, + array $beforeValues, + array $afterValues, + ) { + $this->sources = new SourceSetPlan($root, $watched, $originals, $proposals, $beforeValues, $afterValues, + static fn(): array => [...(glob($root . '/assets/Data/Actors/*.php') ?: []), ...ActorReferenceInventory::getSourcePaths($root)], + static fn(string $path, string $staged): mixed => PhpDataFile::getComparableValue(PhpDataFile::evaluateIsolated($staged, $root)), + 'actor migration', + 'Project actor/reference files', + ); } - private function installSources(array $sources, array $expectedValues): void - { - $this->assertSourcesUnchanged(); - if ($sources === []) { return; } - $transaction = new FileSetTransaction($this->root . '/assets'); - foreach ($sources as $path => $source) { $transaction->write($path, $source); } - try { - $staged = $transaction->stage(); - $this->assertSourcesUnchanged(); - foreach ($staged as $path => $temporary) { - $value = ActorReferenceInventory::getComparableValue(PhpDataFile::evaluateIsolated($temporary, $this->root)); - if (serialize($value) !== serialize($expectedValues[$path])) { - throw new RuntimeException("{$path} would not read back as the planned actor migration; nothing was written."); - } - } - $transaction->commit(); - } catch (Throwable $failure) { - $transaction->rollBack(); - throw $failure; - } - } - - private function assertSourcesUnchanged(): void - { - foreach ($this->watched as $path => $source) { - $expected = $this->applied ? ($this->proposals[$path] ?? $source) : $source; - if (! is_file($path) || file_get_contents($path) !== $expected) { - throw new RuntimeException("{$path} changed outside the migration; reload and plan again."); - } - } - $current = [...(glob($this->root . '/assets/Data/Actors/*.php') ?: []), ...ActorReferenceInventory::getSourcePaths($this->root)]; - $expected = array_keys($this->watched); - sort($current, SORT_STRING); - sort($expected, SORT_STRING); - if ($current !== $expected) { - throw new RuntimeException('Project actor/reference files changed outside the migration; reload and plan again.'); - } - } + public function getSourceSet(): SourceSetPlan { return $this->sources; } + public function getChangedPaths(): array { return $this->sources->getChangedPaths(); } + public function getOriginalSources(): array { return $this->sources->getOriginalSources(); } + public function getProposedSources(): array { return $this->sources->getProposedSources(); } + public function apply(): array { return $this->sources->apply(); } + public function revert(): void { $this->sources->revert(); } } diff --git a/src/Actors/ActorReferenceInventory.php b/src/Actors/ActorReferenceInventory.php index 65de5db..4ccef9a 100644 --- a/src/Actors/ActorReferenceInventory.php +++ b/src/Actors/ActorReferenceInventory.php @@ -4,6 +4,8 @@ namespace Ichiloto\Editor\Actors; +use Ichiloto\Editor\Database\PhpDataFile; + /** Catalogue actor references, not NPC/staged identities or ordinary speaker text. */ final class ActorReferenceInventory { @@ -60,13 +62,7 @@ public static function getReferences(mixed $payload, string $relativePath): arra /** Strip object identity, not authored values, for isolated readback comparisons. */ public static function getComparableValue(mixed $value): mixed { - if (is_object($value)) { - $fields = get_object_vars($value); - $class = $fields['__PHP_Incomplete_Class_Name'] ?? $value::class; - unset($fields['__PHP_Incomplete_Class_Name']); - return ['__class' => $class, ...array_map(self::getComparableValue(...), $fields)]; - } - return is_array($value) ? array_map(self::getComparableValue(...), $value) : $value; + return PhpDataFile::getComparableValue($value); } /** Only runtime content locations, never saves, unrelated PHP or map glyph layers. */ diff --git a/src/Database/PhpDataFile.php b/src/Database/PhpDataFile.php index 888549c..e4ea88a 100644 --- a/src/Database/PhpDataFile.php +++ b/src/Database/PhpDataFile.php @@ -118,6 +118,22 @@ public static function evaluateIsolatedFiles( return self::evaluateIsolatedPaths($paths, $workingDirectory, $fingerprints); } + /** + * An isolated payload with object identity stripped but authored values + * kept, so a staged file can be compared with the value it was planned + * to read back as. + */ + public static function getComparableValue(mixed $value): mixed + { + if (is_object($value)) { + $fields = get_object_vars($value); + $class = $fields['__PHP_Incomplete_Class_Name'] ?? $value::class; + unset($fields['__PHP_Incomplete_Class_Name']); + return ['__class' => $class, ...array_map(self::getComparableValue(...), $fields)]; + } + return is_array($value) ? array_map(self::getComparableValue(...), $value) : $value; + } + /** * A stable comparison value that includes an object's class and state. * diff --git a/src/Editor.php b/src/Editor.php index c1c304a..1284c17 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -11,7 +11,6 @@ use Atatusoft\Termutil\IO\Mouse\Enumerations\MouseButton; use Ichiloto\Editor\Backup\BackupSettings; use Ichiloto\Editor\Actors\ActorIdentityMigration; -use Ichiloto\Editor\Actors\ActorIdentityMigrationCommand; use Ichiloto\Editor\Actors\ActorIdentityMigrationPlan; use Ichiloto\Editor\Backup\BackupWriter; use Ichiloto\Editor\Canvas\CanvasTool; @@ -56,6 +55,7 @@ use Ichiloto\Editor\History\CommandHistory; use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\History\PaintStrokeCommand; +use Ichiloto\Editor\History\SourceSetCommand; use Ichiloto\Editor\Inspector\InputControl; use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Editor\IO\InputDecoder; @@ -11381,7 +11381,7 @@ private function confirmActorReferenceMigration(ActorIdentityMigrationPlan $plan return; } try { - $command = new ActorIdentityMigrationCommand($plan, $this->workspace, + $command = new SourceSetCommand('Migrate actor identities and references', 'this actor migration', $plan->getSourceSet(), $this->workspace, fn(): ?ProjectWorkspace => $this->workspace, function (ProjectWorkspace $workspace): void { $this->workspace = $workspace; }, ); diff --git a/src/Events/CommandMapContext.php b/src/Events/CommandMapContext.php new file mode 100644 index 0000000..78408c0 --- /dev/null +++ b/src/Events/CommandMapContext.php @@ -0,0 +1,61 @@ + $command The command. + * @return list, commands: array}> Each arm and its path from the command. + */ + public static function getArms(array $command): array + { + $arms = []; + foreach ((array) ($command['options'] ?? []) as $index => $option) { + if (is_array($option)) { + $arms[] = ['path' => ['options', $index, 'then'], 'commands' => (array) ($option['then'] ?? [])]; + } + } + foreach (['then', 'else', 'cancel'] as $arm) { + if (array_key_exists($arm, $command)) { + $arms[] = ['path' => [$arm], 'commands' => (array) ($command[$arm] ?? [])]; + } + } + + return $arms; + } + + /** + * Whether the command can finish without running any of its arms: a + * branch missing `then` or `else`, or a choice without `cancel`. + * + * @param array $command The command. + */ + public static function canSkipArms(array $command): bool + { + $type = strval($command['type'] ?? ''); + + return ($type === 'branch' && (! isset($command['then']) || ! isset($command['else']))) + || ($type === 'choice' && ! isset($command['cancel'])); + } +} diff --git a/src/Actors/ActorIdentityMigrationCommand.php b/src/History/SourceSetCommand.php similarity index 60% rename from src/Actors/ActorIdentityMigrationCommand.php rename to src/History/SourceSetCommand.php index d2c8716..ca64dd7 100644 --- a/src/Actors/ActorIdentityMigrationCommand.php +++ b/src/History/SourceSetCommand.php @@ -2,31 +2,38 @@ declare(strict_types=1); -namespace Ichiloto\Editor\Actors; +namespace Ichiloto\Editor\History; use Closure; -use Ichiloto\Editor\History\Command; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Editor\Storage\SourceSetPlan; use RuntimeException; use Throwable; -/** Keeps source migration and the corresponding editor models together through history. */ -final class ActorIdentityMigrationCommand implements Command +/** + * Keeps a written source set and the editor models read from it together + * through history: executing writes the planned files and reloads the + * workspace from them; undoing restores every file and the workspace as it + * was. + */ +final class SourceSetCommand implements Command { - public readonly string $label; private ?ProjectWorkspace $after = null; /** + * @param string $label The status-line action label. + * @param string $subject What the plan does, for refusals (this actor migration). * @param Closure(): ?ProjectWorkspace $getWorkspace * @param Closure(ProjectWorkspace): void $replaceWorkspace */ public function __construct( - private readonly ActorIdentityMigrationPlan $plan, + public readonly string $label, + private readonly string $subject, + private readonly SourceSetPlan $plan, private readonly ProjectWorkspace $before, private readonly Closure $getWorkspace, private readonly Closure $replaceWorkspace, ) { - $this->label = 'Migrate actor identities and references'; } public function execute(): void @@ -53,10 +60,10 @@ private function assertWorkspace(?ProjectWorkspace $expected): void { $current = ($this->getWorkspace)(); if ($current === null || $current !== $expected) { - throw new RuntimeException('The workspace changed since this actor migration. Reload before migrating again.'); + throw new RuntimeException("The workspace changed since {$this->subject}. Reload before trying again."); } if ($current->hasUnsavedChanges()) { - throw new RuntimeException('Save or undo pending edits before changing the actor migration. No files were changed.'); + throw new RuntimeException("Save or undo pending edits before changing {$this->subject}. No files were changed."); } } } diff --git a/src/Storage/SourceSetPlan.php b/src/Storage/SourceSetPlan.php new file mode 100644 index 0000000..918e04e --- /dev/null +++ b/src/Storage/SourceSetPlan.php @@ -0,0 +1,115 @@ + $watched Every source the plan read, by path. + * @param array $originals The bytes of each changed file. + * @param array $proposals The planned bytes of each changed file. + * @param array $beforeValues What each changed file reads back as now. + * @param array $afterValues What each changed file must read back as once written. + * @param Closure(): list $findWatchedPaths The paths of the file family the plan read, as they are on disk now. + * @param Closure(string, string): mixed $readBack Reads a staged file (destination path, staged path) as a comparable value. + * @param string $subject What the plan does, for refusals (an actor migration). + * @param string $family The files it reads, for refusals (Project actor/reference files). + */ + public function __construct( + private readonly string $root, + private readonly array $watched, + private readonly array $originals, + private readonly array $proposals, + private readonly array $beforeValues, + private readonly array $afterValues, + private readonly Closure $findWatchedPaths, + private readonly Closure $readBack, + private readonly string $subject, + private readonly string $family, + ) {} + + /** @return list */ + public function getChangedPaths(): array { return array_keys($this->proposals); } + + /** @return array */ + public function getOriginalSources(): array { return $this->originals; } + + /** @return array */ + public function getProposedSources(): array { return $this->proposals; } + + /** @return list Every changed path. */ + public function apply(): array + { + if (! $this->applied) { + $this->installSources($this->proposals, $this->afterValues); + $this->applied = true; + } + return $this->getChangedPaths(); + } + + public function revert(): void + { + if ($this->applied) { + $this->installSources($this->originals, $this->beforeValues); + $this->applied = false; + } + } + + private function installSources(array $sources, array $expectedValues): void + { + $this->assertSourcesUnchanged(); + if ($sources === []) { return; } + $transaction = new FileSetTransaction($this->root . '/assets'); + foreach ($sources as $path => $source) { $transaction->write($path, $source); } + try { + $staged = $transaction->stage(); + $this->assertSourcesUnchanged(); + foreach ($staged as $path => $temporary) { + $value = ($this->readBack)($path, $temporary); + if (serialize($value) !== serialize($expectedValues[$path])) { + throw new RuntimeException("{$path} would not read back as the planned {$this->subject}; nothing was written."); + } + } + $transaction->commit(); + } catch (Throwable $failure) { + $transaction->rollBack(); + throw $failure; + } + } + + private function assertSourcesUnchanged(): void + { + foreach ($this->watched as $path => $source) { + $expected = $this->applied ? ($this->proposals[$path] ?? $source) : $source; + if (! is_file($path) || file_get_contents($path) !== $expected) { + throw new RuntimeException("{$path} changed outside the {$this->subject}; reload and plan again."); + } + } + $current = ($this->findWatchedPaths)(); + $expected = array_keys($this->watched); + sort($current, SORT_STRING); + sort($expected, SORT_STRING); + if ($current !== $expected) { + throw new RuntimeException("{$this->family} changed outside the {$this->subject}; reload and plan again."); + } + } +} diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 79862c1..e9c2cc5 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -12,6 +12,7 @@ use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Database\SummonAssignmentDiagnostics; +use Ichiloto\Editor\Events\CommandMapContext; use Ichiloto\Editor\Field\MapEncounters; use Ichiloto\Editor\Field\ProjectNpc; use Ichiloto\Editor\ActorStatPreview; @@ -3297,28 +3298,10 @@ protected function walkEventCommands(array $commands, string $where, array $know // Alternative arms start on the same map, not at the end of a sibling arm. $branchContexts = []; - foreach ((array) ($command['options'] ?? []) as $option) { - if (is_array($option)) { - $branchContext = $context; - $issues = [...$issues, ...$this->walkEventCommands( - (array) ($option['then'] ?? []), - $where, - $known, - $branchContext, - $commonEventStack, - )]; - $branchContexts[] = $branchContext; - } - } - - foreach (['then', 'else', 'cancel'] as $arm) { - if (! array_key_exists($arm, $command)) { - continue; - } - + foreach (CommandMapContext::getArms($command) as $arm) { $branchContext = $context; $issues = [...$issues, ...$this->walkEventCommands( - (array) ($command[$arm] ?? []), + $arm['commands'], $where, $known, $branchContext, @@ -3329,8 +3312,7 @@ protected function walkEventCommands(array $commands, string $where, array $know if ($branchContexts !== []) { // An omitted alternative can leave the current map unchanged. - if (($type === 'branch' && (! isset($command['then']) || ! isset($command['else']))) - || ($type === 'choice' && ! isset($command['cancel']))) { + if (CommandMapContext::canSkipArms($command)) { $branchContexts[] = $context; } $this->mergeCommandMapContexts($context, $branchContexts); From 3b8de0236ca38c1ebf033a349668c51c2475642c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 28 Sep 2026 17:07:41 +0200 Subject: [PATCH 066/344] feat(canvas): insert rows and columns into a map, moving the whole project In Map mode the palette offers "Map: Insert rows above the cursor" and "Map: Insert columns left of the cursor". After a count, the Editor plans one axis-generic insertion and lists every file it writes and every coordinate it could not rewrite; Enter writes them all at once (not on Save) and one undo restores every file and reloads the workspace. It refuses while the workspace has unsaved changes. The map's terminal, event and tile layers take the new blank lines, with styled cells kept and moved rows keeping their bytes. Coordinates in the map's space at or beyond the line move in every file: NPCs and wander areas (a straddling area stretches), bed spawns, trigger and event areas, transfer spawns into the map from other maps, starting positions, and script coordinates, following each script's current map across transfers, into reusable scripts started only from this map and cutscenes starting on it. Station anchors and tile-layer offsets stay. Coordinates written as expressions, inside spreads, or in scripts that may run on several maps are reported for a hand edit, never flattened. With a save compatibility manifest, the plan raises contentVersion with a declarative mapShifts step; validation accepts that step kind. --- docs/manual.md | 48 ++ src/Canvas/LineInsertCanvas.php | 202 ++++++++ src/Editor.php | 15 + src/Maps/EditableGrid.php | 28 ++ src/Maps/LineInsertion.php | 74 +++ src/Maps/LineInsertionInventory.php | 444 ++++++++++++++++++ src/Maps/LineInsertionPlan.php | 40 ++ src/Maps/LineInsertionPlanner.php | 341 ++++++++++++++ src/Maps/MapLayers.php | 25 + src/Maps/TileLayerSource.php | 31 ++ src/ProjectMap.php | 39 ++ src/UI/Modal.php | 4 + src/Validation/SaveCompatibilityValidator.php | 34 +- tests/Unit/MapLineInsertionTest.php | 433 +++++++++++++++++ 14 files changed, 1757 insertions(+), 1 deletion(-) create mode 100644 src/Canvas/LineInsertCanvas.php create mode 100644 src/Maps/LineInsertion.php create mode 100644 src/Maps/LineInsertionInventory.php create mode 100644 src/Maps/LineInsertionPlan.php create mode 100644 src/Maps/LineInsertionPlanner.php create mode 100644 tests/Unit/MapLineInsertionTest.php diff --git a/docs/manual.md b/docs/manual.md index 345b5dc..967dd1a 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -227,6 +227,54 @@ force for terminal edits. Untouched layer files are never written; unchanged rows retain their authored bytes, and all changed files are saved or rolled back together. +### Inserting rows and columns + +In Map mode, `Ctrl+P` offers **Map: Insert rows above the cursor** and +**Map: Insert columns left of the cursor**. Both first refuse while the project +has unsaved changes: save or undo them first. A prompt asks how many to insert +(a whole number of at least 1, `1` by default; `Up` / `Down` step it, `Enter` +plans, `Esc` cancels). Inserting `N` rows at the cursor's row moves everything +from that row down by `N`; inserting columns moves everything from the cursor's +column right. The map grows by `N`. + +Nothing is written until you confirm. The confirmation lists every file the +insertion writes, then every coordinate it could not rewrite, as +`Hand edit: file path` with the reason, then whether saves follow. `Enter` on +**Write N files now** writes them all at once, not on Save; `Esc` or **Cancel** +leaves every file unchanged. One `Ctrl+Z` restores every written file byte for +byte and reloads the workspace; `Ctrl+Y` writes them again. + +What moves, when it is in the map's space and at or beyond the line: + +- every grid: gameplay and decoration layers, the event layer (so event areas + follow) and the tile layers, whose new cells are empty (`0`); styled cells + keep their colour and rows that only move keep their bytes; +- the map's NPC positions and wander areas (an area straddling the line + stretches), bed spawn points, legacy trigger areas and explicit event areas; +- spawn points of transfers into the map from any map, legacy triggers into + it, and `startingPositions` in `assets/Data/system.php` that start on it; +- script coordinates on the map: `move_player`, `move_route` waypoints (only + the axes a waypoint names; `steps` and `retrace` are relative), `camera` and + `field_animation` position targets, `stage_actor`, and `transfer` into the + map. Scripts start on their map; after a `transfer` elsewhere, later commands + are in that map's space and stay; +- reusable scripts in `assets/Events/` started only from this map, and + cutscenes whose start map it is (their cast, script and finalizer). + +A regional `station` and tile-layer `offset` values are not map cells and never +move. + +What is reported instead of rewritten: a coordinate written as a PHP expression +or variable, or inside a list built with a spread (such as generated NPCs); a +file that is not one returned array literal; a coordinate in a reusable script +or cutscene that may run on this map or another one. The editor never guesses +or flattens authored PHP; move those by hand after writing. + +Saves follow through the project's `assets/Data/save-compatibility.php`: the +insertion raises `contentVersion` by one and appends a `mapShifts` step, so a +saved player position on the map moves too. A project without that manifest is +told that existing saves are not migrated. + ### Pieces A map offers the pieces of the tileset its data names (see diff --git a/src/Canvas/LineInsertCanvas.php b/src/Canvas/LineInsertCanvas.php new file mode 100644 index 0000000..42bb7cb --- /dev/null +++ b/src/Canvas/LineInsertCanvas.php @@ -0,0 +1,202 @@ +getSelectedMap(); + if ($map === null || $map->getGridSourceIssue() !== null || $this->editingMode !== self::MODE_MAP) { + return []; + } + + return [ + new PaletteItem('Map: Insert rows above the cursor', '', fn() => $this->openLineInsertPrompt('y')), + new PaletteItem('Map: Insert columns left of the cursor', '', fn() => $this->openLineInsertPrompt('x')), + ]; + } + + private function openLineInsertPrompt(string $axis): void + { + $map = $this->getSelectedMap(); + if ($map === null || ! $this->canInsertLines()) { + return; + } + $this->finalizeActiveStroke(); + $this->lineInsertPrompt = ['axis' => $axis, 'at' => $axis === 'y' ? $this->cursorY : $this->cursorX, 'mapId' => $map->mapId]; + $this->lineInsertCount = new TextFieldEditor(); + $this->lineInsertCount->open('1'); + $this->modals->push(Modal::LINE_INSERT); + $this->requestFullRender(); + } + + /** Refuses while other edits are unsaved: the insertion writes now, and undo restores files. */ + private function canInsertLines(): bool + { + if (! $this->workspace instanceof ProjectWorkspace) { + return false; + } + if ($this->workspace->hasUnsavedChanges()) { + $this->setStatus('Save or undo pending edits before inserting rows or columns. No files were changed.', StatusLevel::WARN); + return false; + } + + return true; + } + + private function closeLineInsertPrompt(): void + { + $this->lineInsertPrompt = null; + $this->lineInsertCount = null; + $this->modals->remove(Modal::LINE_INSERT); + $this->requestFullRender(); + } + + private function handleLineInsertPromptInput(string $input): void + { + $prompt = $this->lineInsertPrompt; + if ($prompt === null || $this->lineInsertCount === null) { + $this->closeLineInsertPrompt(); + return; + } + $control = new InputControl(InputControlType::INTEGER, $this->lineInsertCount->value); + switch ($this->lineInsertCount->handleKey($input, $control)) { + case TextFieldKeyResult::CANCELLED: + $this->closeLineInsertPrompt(); + $this->setStatus('Insertion cancelled. No files were changed.'); + return; + case TextFieldKeyResult::SUBMITTED: + $count = trim($this->lineInsertCount->value); + if (! ctype_digit($count) || (int) $count < 1) { + $this->setStatus('Enter a whole number of at least 1.', StatusLevel::WARN); + return; + } + $this->closeLineInsertPrompt(); + $this->openLineInsertConfirmation($prompt['axis'], $prompt['at'], (int) $count); + return; + default: + $this->requestFullRender(); + } + } + + private function renderLineInsertPrompt(array $layout): void + { + $prompt = $this->lineInsertPrompt; + if ($prompt === null || $this->lineInsertCount === null) { + return; + } + $width = min(68, $layout['width'] - 4); + $rows = $prompt['axis'] === 'y'; + $lines = [ + sprintf('%s to insert %s %s %d: %s', $rows ? 'Rows' : 'Columns', $rows ? 'above' : 'left of', + $rows ? 'row' : 'column', $prompt['at'] + 1, $this->lineInsertCount->value), + sprintf('Everything from %s %d %s moves and the map grows.', $rows ? 'row' : 'column', $prompt['at'] + 1, $rows ? 'down' : 'right'), + 'Spawn points, scripts and saves that name this map follow.', + 'The next step lists every file before anything is written.', + ]; + new EditorWindow( + title: $rows ? 'Insert rows' : 'Insert columns', + help: 'Enter:Plan Up/Down:Count Esc:Cancel', + position: ['x' => max(2, intdiv($layout['width'] - $width, 2)), 'y' => 6], + width: $width, height: count($lines) + 2, + content: $this->fitLines($lines, $this->getWindowContentWidth($width), count($lines)), + )->render(); + } + + /** + * Plans the insertion and lists what it writes, and what it could not + * rewrite, for confirmation. Nothing is written yet. + */ + private function openLineInsertConfirmation(string $axis, int $at, int $count): void + { + $map = $this->getSelectedMap(); + if ($map === null || ! $this->canInsertLines()) { + return; + } + try { + $plan = LineInsertionPlanner::planProject($this->projectRoot, $map->mapId, $axis, $at, $count); + } catch (Throwable $failure) { + $this->setErrorStatus($failure, 'Insertion'); + return; + } + $paths = array_map(fn(string $path): string => substr($path, strlen($this->projectRoot) + 1), $plan->getChangedPaths()); + $this->optionDialogField = ['lineInsertion' => $plan]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = 'Insert ' . $plan->insertion->description; + $this->eventOptionDialogEntries = [ + ['label' => 'Cancel', 'value' => 'cancel', 'description' => 'Leave all files unchanged.'], + ['label' => sprintf('Write %d files now', count($paths)), 'value' => 'insert', + 'description' => 'Writes now, not on Save. Ctrl+Z restores files.'], + ...array_map(static fn(string $path): array => [ + 'label' => $path, 'value' => 'preview', + 'description' => 'Written by the insertion. Select Write to apply all files.', + ], $paths), + ...array_map(static fn(array $edit): array => [ + 'label' => sprintf('Hand edit: %s %s', $edit['file'], $edit['where']), 'value' => 'preview', + 'description' => sprintf('Not rewritten: %s. Move it by hand after writing.', $edit['reason']), + ], $plan->handEdits), + ...array_map(static fn(string $note): array => [ + 'label' => $note, 'value' => 'preview', 'description' => $note, + ], $plan->notes), + ]; + $this->selectedEventOptionIndex = 0; + $this->dialogFilter->clear(); + $this->isEventOptionDialogOpen = true; + $this->setStatus(sprintf('Confirm inserting %s. Writes now, not on Save. Ctrl+Z restores files.', $plan->insertion->description)); + $this->renderSelectionDependentArea(); + } + + private function confirmLineInsertion(LineInsertionPlan $plan, bool $confirmed): void + { + if (! $confirmed || ! $this->workspace instanceof ProjectWorkspace) { + $this->closeEventOptionDialog(); + $this->setStatus('Insertion cancelled. No files were changed.'); + return; + } + try { + $command = new SourceSetCommand('Insert ' . $plan->insertion->noun, 'this insertion', $plan->getSourceSet(), $this->workspace, + fn(): ?ProjectWorkspace => $this->workspace, + function (ProjectWorkspace $workspace): void { $this->workspace = $workspace; }, + ); + $command->execute(); + $this->recordCommand($command); + $handEdits = count($plan->handEdits); + $this->closeEventOptionDialog(); + // The files are on disk now, so the result reads as saved. + $this->setStatus(sprintf('Inserted %s.%s Ctrl+Z restores the files.', $plan->insertion->description, + $handEdits === 0 ? '' : sprintf(' %d %s still need a hand edit.', $handEdits, $handEdits === 1 ? 'coordinate' : 'coordinates')), StatusLevel::SUCCESS); + $this->requestFullRender(); + } catch (Throwable $failure) { + $this->closeEventOptionDialog(''); + $this->setErrorStatus($failure, 'Insertion'); + } + } +} diff --git a/src/Editor.php b/src/Editor.php index 1284c17..a608c73 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -49,6 +49,7 @@ use Ichiloto\Editor\Database\RecordSubList; use Ichiloto\Editor\Database\ReferencePicker; use Ichiloto\Editor\Debug\Debug; +use Ichiloto\Editor\Maps\LineInsertionPlan; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Events\EventTypeCatalog; use Ichiloto\Editor\History\Command; @@ -114,6 +115,7 @@ final class Editor { use \Ichiloto\Editor\Canvas\LayerCanvas; use \Ichiloto\Editor\Canvas\PieceCanvas; + use \Ichiloto\Editor\Canvas\LineInsertCanvas; use CutscenesWorkspace; use CutsceneOutlinePane; use CutscenePreviewPane; @@ -1260,6 +1262,7 @@ private function buildInputRouter(): InputRouter $router->bindModal(Modal::DATABASE_ENTRY_DELETE_CONFIRMATION, $this->handleDatabaseEntryDeleteConfirmationInput(...)); $router->bindModal(Modal::RENAME_CONFIRMATION, $this->handleRenameConfirmationInput(...)); $router->bindModal(Modal::LAYER_EDIT, $this->handleLayerPromptInput(...)); + $router->bindModal(Modal::LINE_INSERT, $this->handleLineInsertPromptInput(...)); $router->bindModal(Modal::COMMAND_PALETTE, $this->handleCommandPaletteInput(...)); $router->bindModal(Modal::HELP, $this->handleHelpInput(...)); $router->bindModal(Modal::DATABASE, $this->handleDatabaseInput(...)); @@ -5963,6 +5966,7 @@ private function buildPaletteItems(): array $items = [ ...$this->buildLayerPaletteItems(), ...$this->buildPiecePaletteItems(), + ...$this->buildLineInsertPaletteItems(), new PaletteItem('Save Map', 'Ctrl+S', fn() => $this->saveSelectedMap()), new PaletteItem('Move Map to Derived Path', '', fn() => $this->beginExplicitMapMove()), new PaletteItem('Save All', 'Ctrl+A', fn() => $this->saveAllAssets()), @@ -7983,6 +7987,12 @@ private function applySelectedEventOption(): void return; } + if (($field['lineInsertion'] ?? null) instanceof LineInsertionPlan) { + if ($selectedEntry['value'] === 'preview') { return; } + $this->confirmLineInsertion($field['lineInsertion'], $selectedEntry['value'] === 'insert'); + return; + } + if (($field['actorReferenceMigration'] ?? null) instanceof ActorIdentityMigrationPlan) { if ($selectedEntry['value'] === 'preview') { return; } $this->confirmActorReferenceMigration($field['actorReferenceMigration'], $selectedEntry['value'] === 'migrate'); @@ -17938,6 +17948,11 @@ private function renderModalOverlays(array $layout): bool return true; } + if ($this->modals->has(Modal::LINE_INSERT)) { + $this->renderLineInsertPrompt($layout); + return true; + } + if ($this->isCommandPaletteOpen) { $this->renderCommandPaletteOverlay($layout); return true; diff --git a/src/Maps/EditableGrid.php b/src/Maps/EditableGrid.php index b72a359..60e0658 100644 --- a/src/Maps/EditableGrid.php +++ b/src/Maps/EditableGrid.php @@ -155,4 +155,32 @@ public function resize(int $width, int $height): void unset($row); $this->cells = array_pad($this->cells, $height, array_fill(0, $width, $blank)); } + + /** + * Inserts blank rows before row `$at` (axis `y`), each as wide as the + * row it is inserted beside, or blank cells before column `$at` of every + * row that reaches it (axis `x`). Every other cell keeps its glyph and + * style, and rows that only move keep their authored bytes. + */ + public function insertLines(string $axis, int $at, int $count): void + { + $blank = ['symbol' => ' ', 'prefix' => '', 'suffix' => '']; + if ($axis === 'y') { + $beside = $this->cells[$at] ?? $this->cells[$at - 1] ?? []; + $rows = array_fill(0, $count, array_fill(0, count($beside), $blank)); + array_splice($this->cells, $at, 0, $rows); + // New rows have no authored line, so they are written from their cells. + array_splice($this->originalCells, $at, 0, array_fill(0, $count, null)); + array_splice($this->lines, $at, 0, array_fill(0, $count, '')); + $separator = $this->separators[max(0, $at - 1)] ?? $this->separators[0] ?? "\n"; + array_splice($this->separators, min($at, count($this->separators)), 0, array_fill(0, $count, $separator)); + return; + } + foreach ($this->cells as &$row) { + if (count($row) >= $at) { + array_splice($row, $at, 0, array_fill(0, $count, $blank)); + } + } + unset($row); + } } diff --git a/src/Maps/LineInsertion.php b/src/Maps/LineInsertion.php new file mode 100644 index 0000000..4efad21 --- /dev/null +++ b/src/Maps/LineInsertion.php @@ -0,0 +1,74 @@ + ($this->axis === 'y' ? 'row' : 'column') . ($this->count === 1 ? '' : 's'); + } + + /** What is inserted where, in words: 3 rows above row 5 of town. */ + public string $description { + get => sprintf('%d %s %s %s %d of %s', $this->count, $this->noun, + $this->axis === 'y' ? 'above' : 'left of', $this->axis === 'y' ? 'row' : 'column', $this->at + 1, $this->mapId); + } + + /** Where a coordinate on the insertion axis moves to. */ + public function getShiftedCoordinate(int $value): int + { + return $value >= $this->at ? $value + $this->count : $value; + } + + /** + * Where a span on the insertion axis moves to: one starting at or beyond + * the line moves, one straddling it stretches, one before it stays. + * + * @return array{int, int} The new start and size. + */ + public function getShiftedSpan(int $start, int $size): array + { + if ($start >= $this->at) { + return [$start + $this->count, $size]; + } + + return $start + $size > $this->at ? [$start, $size + $this->count] : [$start, $size]; + } + + /** + * The declarative save migration step entry the Engine applies to a + * saved player position. + * + * @return array{map: string, axis: string, at: int, by: int} + */ + public function getManifestEntry(): array + { + return ['map' => $this->mapId, 'axis' => $this->axis, 'at' => $this->at, 'by' => $this->count]; + } +} diff --git a/src/Maps/LineInsertionInventory.php b/src/Maps/LineInsertionInventory.php new file mode 100644 index 0000000..173397f --- /dev/null +++ b/src/Maps/LineInsertionInventory.php @@ -0,0 +1,444 @@ +, value: int}>> Moves by file, keyed by path. */ + private array $shifts = []; + + /** @var array, reason: string}>> Hand edits by file, keyed by path. */ + private array $handEdits = []; + + /** @var array|null>> The contexts each reusable event script is started from. */ + private array $commonEventStarts = []; + + /** @var array> The maps whose events start each cutscene. */ + private array $cinematicTriggerMaps = []; + + /** @var list|null> Every context a walk reached through a transfer. */ + private array $transferContexts = []; + + /** + * @param LineInsertion $insertion The insertion. + * @param array $commonEvents Reusable event scripts by id. + */ + public function __construct( + private readonly LineInsertion $insertion, + private readonly array $commonEvents, + ) { + } + + /** + * Moves by file, in the order found. + * + * @return array, value: int}>> + */ + public function getShifts(): array + { + return array_map(array_values(...), $this->shifts); + } + + /** + * Coordinates that must be moved by hand, by file. + * + * @return array, reason: string}>> + */ + public function getHandEdits(): array + { + return array_map(array_values(...), $this->handEdits); + } + + /** + * Adds one map's data file: its own coordinates when it is the edited + * map, and everything in it that leads into the edited map. + * + * @param array $data The evaluated data file. + */ + public function addMapData(string $file, string $mapId, array $data): void + { + $isTarget = $mapId === $this->insertion->mapId; + $here = [$mapId]; + + foreach ((array) ($data['npcs'] ?? []) as $index => $npc) { + if (! is_array($npc)) { + continue; + } + if ($isTarget) { + $this->shiftPoint($file, ['npcs', $index], $npc); + if (is_array($npc['wanderArea'] ?? null)) { + $this->shiftArea($file, ['npcs', $index, 'wanderArea'], $npc['wanderArea']); + } + } + $this->walkCommands($npc['script'] ?? [], ['npcs', $index, 'script'], $here, $file); + foreach ((array) ($npc['dialogue'] ?? []) as $variantIndex => $variant) { + if (is_array($variant) && is_array($variant['script'] ?? null)) { + $this->walkCommands($variant['script'], ['npcs', $index, 'dialogue', $variantIndex, 'script'], $here, $file); + } + } + } + + foreach ((array) ($data['events'] ?? []) as $marker => $event) { + if (! is_array($event)) { + continue; + } + if ($isTarget && is_array($event['area'] ?? null)) { + $this->shiftArea($file, ['events', $marker, 'area'], $event['area']); + } + $eventData = is_array($event['data'] ?? null) ? $event['data'] : []; + $destination = trim(strval($eventData['destinationMap'] ?? '')); + $isBed = $destination === '' && $isTarget + && ltrim(strval($event['class'] ?? ''), '\\') === SleepEventTrigger::class; + if (is_array($eventData['spawnPoint'] ?? null) && ($destination === $this->insertion->mapId || $isBed)) { + $this->shiftPoint($file, ['events', $marker, 'data', 'spawnPoint'], $eventData['spawnPoint']); + } + if (is_array($eventData['script'] ?? null) && $eventData['script'] !== []) { + $this->walkCommands($eventData['script'], ['events', $marker, 'data', 'script'], $here, $file); + } elseif (trim(strval($eventData['scriptId'] ?? '')) !== '') { + $this->commonEventStarts[trim(strval($eventData['scriptId']))][] = $here; + } + $cinematicId = trim(strval($eventData['cinematicId'] ?? '')); + if ($cinematicId !== '') { + $this->cinematicTriggerMaps[$cinematicId][] = $mapId; + } + } + + foreach ((array) ($data['triggers'] ?? []) as $index => $trigger) { + if (! is_array($trigger)) { + continue; + } + if ($isTarget && is_array($trigger['trigger_area'] ?? null)) { + $this->shiftArea($file, ['triggers', $index, 'trigger_area'], $trigger['trigger_area']); + } + if (trim(strval($trigger['destinationMap'] ?? '')) === $this->insertion->mapId && is_array($trigger['spawn_point'] ?? null)) { + $this->shiftPoint($file, ['triggers', $index, 'spawn_point'], $trigger['spawn_point']); + } + } + } + + /** + * Adds the system data file's starting positions on the edited map. + * + * @param array $system The evaluated system data. + */ + public function addSystemData(string $file, array $system): void + { + foreach ((array) ($system['startingPositions'] ?? []) as $key => $position) { + if (is_array($position) && trim(strval($position['destinationMap'] ?? '')) === $this->insertion->mapId + && is_array($position['spawnPoint'] ?? null)) { + $this->shiftPoint($file, ['startingPositions', $key, 'spawnPoint'], $position['spawnPoint']); + } + } + } + + /** + * Adds one cutscene: its cast and script start on its start map (or on + * the maps whose events start it), and its finalizer may run from any + * map the script reaches, since a skip can end the script anywhere. + * Add every map first, so the maps that start it are known. + * + * @param array $data The evaluated data file. + */ + public function addCutscene(string $dataFile, string $scriptFile, string $id, array $data, mixed $commands): void + { + $startMap = trim(strval($data['startMap'] ?? '')); + $start = $startMap !== '' + ? [$startMap] + : self::mergeContexts(array_map(static fn(string $map): array => [$map], $this->cinematicTriggerMaps[$id] ?? [])); + foreach ((array) ($data['cast'] ?? []) as $index => $entry) { + if (is_array($entry)) { + $this->shiftPointIn($dataFile, ['cast', $index], $entry, $start); + } + } + $this->transferContexts = []; + $end = $this->walkCommands($commands, [], $start, $scriptFile); + $this->walkCommands($data['finalizer'] ?? [], ['finalizer'], self::mergeContexts([$start, $end, ...$this->transferContexts]), $dataFile); + } + + /** + * Adds every reusable event script, each starting on every map that + * runs it. Add every map and cutscene first, so those starts are known. + */ + public function addCommonEvents(): void + { + $starts = $this->commonEventStarts; + foreach ($this->commonEvents as $id => $event) { + $start = isset($starts[$id]) ? self::mergeContexts($starts[$id]) : []; + $this->walkCommands($event['commands'], [], $start, $event['file'], [$id]); + } + } + + /** + * Walks a command list from a context, moving coordinates in the edited + * map's space when `$file` is given, and returns the context it ends in. + * A reusable event script it runs is followed inline, without moving + * anything, for the map it leaves the caller on and the scripts it runs. + * + * @param list $path The list's path in its file. + * @param list|null $context + * @param list $stack Reusable event scripts being followed. + * @return list|null + */ + private function walkCommands(mixed $commands, array $path, ?array $context, ?string $file, array $stack = []): ?array + { + if (! is_array($commands)) { + return $context; + } + + foreach ($commands as $index => $command) { + if (! is_array($command)) { + continue; + } + $at = [...$path, $index]; + $type = strval($command['type'] ?? ''); + if ($file !== null) { + $this->shiftCommand($file, $type, $command, $at, $context); + } + + if ($type === 'sequence') { + $context = $this->walkCommands($command['commands'] ?? [], [...$at, 'commands'], $context, $file, $stack); + } + + if ($type === 'parallel') { + $ends = []; + foreach ((array) ($command['lanes'] ?? []) as $laneIndex => $lane) { + if (! is_array($lane)) { + continue; + } + [$laneCommands, $lanePath] = array_is_list($lane) + ? [$lane, [...$at, 'lanes', $laneIndex]] + : [$lane['commands'] ?? [], [...$at, 'lanes', $laneIndex, 'commands']]; + $ends[] = $this->walkCommands($laneCommands, $lanePath, $context, $file, $stack); + } + if ($ends !== []) { + $context = self::mergeContexts($ends); + } + } + + if ($type === 'common_event') { + $id = trim(strval($command['id'] ?? '')); + $this->commonEventStarts[$id][] = $context; + if (isset($this->commonEvents[$id]) && ! in_array($id, $stack, true)) { + $context = $this->walkCommands($this->commonEvents[$id]['commands'], [], $context, null, [...$stack, $id]); + } + } + + $arms = CommandMapContext::getArms($command); + if ($arms !== []) { + $ends = []; + foreach ($arms as $arm) { + $ends[] = $this->walkCommands($arm['commands'], [...$at, ...$arm['path']], $context, $file, $stack); + } + if (CommandMapContext::canSkipArms($command)) { + $ends[] = $context; + } + $context = self::mergeContexts($ends); + } + + if ($type === 'transfer') { + $destination = trim(strval($command['map'] ?? '')); + if ($file !== null && $destination === $this->insertion->mapId) { + $this->shiftPoint($file, $at, $command); + } + $context = $destination !== '' ? [$destination] : null; + $this->transferContexts[] = $context; + } + } + + return $context; + } + + /** + * Moves the map-space coordinates one command names. Relative movement + * (route steps, retracing) and screen positions are not map cells. + * + * @param array $command + * @param list $at The command's path. + * @param list|null $context + */ + private function shiftCommand(string $file, string $type, array $command, array $at, ?array $context): void + { + switch ($type) { + case 'move_player': + $this->shiftPointIn($file, $at, $command, $context); + break; + case 'move_route': + foreach ((array) ($command['waypoints'] ?? []) as $index => $waypoint) { + if (is_array($waypoint)) { + $this->shiftPointIn($file, [...$at, 'waypoints', $index], $waypoint, $context); + } + } + break; + case 'camera': + $this->shiftTargetIn($file, $at, $command, $context); + foreach ((array) ($command['points'] ?? []) as $index => $point) { + if (is_array($point)) { + $this->shiftTargetIn($file, [...$at, 'points', $index], $point, $context); + } + } + break; + case 'field_animation': + $this->shiftTargetIn($file, $at, $command, $context); + break; + case 'stage_actor': + is_array($command['actor'] ?? null) + ? $this->shiftPointIn($file, [...$at, 'actor'], $command['actor'], $context) + : $this->shiftPointIn($file, $at, $command, $context); + break; + } + } + + /** + * Moves a subject reference that names a map position, as the Engine + * resolves one: the source's `target`, or the source itself. + * + * @param list $path + * @param array $source + * @param list|null $context + */ + private function shiftTargetIn(string $file, array $path, array $source, ?array $context): void + { + [$reference, $referencePath] = is_array($source['target'] ?? null) + ? [$source['target'], [...$path, 'target']] + : [$source, $path]; + $kind = strtolower(trim(strval($reference['kind'] ?? $reference['subject'] ?? ''))); + if ($kind === 'position') { + $this->shiftPointIn($file, $referencePath, $reference, $context); + } + } + + /** + * Moves a point whose map is the script's current map: surely when that + * is the edited map, never when it is surely another, and otherwise + * reports it for a hand edit when it would move. + * + * @param list $path + * @param array $point + * @param list|null $context + */ + private function shiftPointIn(string $file, array $path, array $point, ?array $context): void + { + if ($context === [$this->insertion->mapId]) { + $this->shiftPoint($file, $path, $point); + return; + } + if ($context !== null && ! in_array($this->insertion->mapId, $context, true)) { + return; + } + $key = $this->insertion->axis; + if (array_key_exists($key, $point) && (! is_int($point[$key]) || $point[$key] >= $this->insertion->at)) { + $this->reportHandEdit($file, [...$path, $key], $context === null + ? 'it runs on a map that is not known here' + : sprintf('it may run on %s', implode(' or ', $context))); + } + } + + /** + * Moves a point surely in the edited map's space. Only the insertion + * axis moves; a point naming only the other axis stays. + * + * @param list $path + * @param array $point + */ + private function shiftPoint(string $file, array $path, array $point): void + { + $key = $this->insertion->axis; + if (! array_key_exists($key, $point)) { + return; + } + $value = $point[$key]; + if (is_int($value)) { + if ($value >= $this->insertion->at) { + $this->shifts[$file][self::formatKey([...$path, $key])] = [ + 'path' => [...$path, $key], 'value' => $this->insertion->getShiftedCoordinate($value), + ]; + } + return; + } + $this->reportHandEdit($file, [...$path, $key], 'it is not a whole number'); + } + + /** + * Moves an area surely in the edited map's space: one starting at or + * beyond the line moves, one straddling it stretches. + * + * @param list $path + * @param array $area + */ + private function shiftArea(string $file, array $path, array $area): void + { + $startKey = $this->insertion->axis; + $sizeKey = $startKey === 'x' ? 'width' : 'height'; + $start = $area[$startKey] ?? 0; + $size = $area[$sizeKey] ?? 1; + if (! is_int($start) || ! is_int($size)) { + $this->reportHandEdit($file, $path, 'its position or size is not a whole number'); + return; + } + [$newStart, $newSize] = $this->insertion->getShiftedSpan($start, $size); + if ($newStart !== $start) { + $this->shiftPoint($file, $path, $area); + } + if ($newSize !== $size) { + $this->shifts[$file][self::formatKey([...$path, $sizeKey])] = ['path' => [...$path, $sizeKey], 'value' => $newSize]; + } + } + + /** @param list $path */ + private function reportHandEdit(string $file, array $path, string $reason): void + { + $this->handEdits[$file][self::formatKey($path)] = ['path' => $path, 'reason' => $reason]; + } + + /** @param list $path */ + private static function formatKey(array $path): string + { + return implode("\0", $path); + } + + /** + * The maps any of several contexts may be on; unknown when any is. + * + * @param list|null> $contexts + * @return list|null + */ + private static function mergeContexts(array $contexts): ?array + { + $maps = []; + foreach ($contexts as $context) { + if ($context === null) { + return null; + } + array_push($maps, ...$context); + } + $maps = array_values(array_unique($maps)); + sort($maps, SORT_STRING); + + return $maps; + } +} diff --git a/src/Maps/LineInsertionPlan.php b/src/Maps/LineInsertionPlan.php new file mode 100644 index 0000000..db8057a --- /dev/null +++ b/src/Maps/LineInsertionPlan.php @@ -0,0 +1,40 @@ + $handEdits Coordinates to move by hand, by project-relative file. + * @param list $notes What the author should know, such as whether saves follow. + */ + public function __construct( + public readonly LineInsertion $insertion, + private readonly SourceSetPlan $sources, + public readonly array $handEdits, + public readonly array $notes, + ) { + } + + public function getSourceSet(): SourceSetPlan { return $this->sources; } + + /** @return list Every file the insertion writes. */ + public function getChangedPaths(): array { return $this->sources->getChangedPaths(); } + + /** @return list Every changed path. */ + public function apply(): array { return $this->sources->apply(); } + + public function revert(): void { $this->sources->revert(); } +} diff --git a/src/Maps/LineInsertionPlanner.php b/src/Maps/LineInsertionPlanner.php new file mode 100644 index 0000000..887c1bc --- /dev/null +++ b/src/Maps/LineInsertionPlanner.php @@ -0,0 +1,341 @@ + self::buildPlan($root, $insertion)); + } + + private static function buildPlan(string $root, LineInsertion $insertion): LineInsertionPlan + { + $directory = $root . '/assets/Maps/' . $insertion->mapId; + $map = ProjectMap::fromDirectory($root . '/assets/Maps', $directory); + $map->insertLines($insertion->axis, $insertion->at, $insertion->count); + + $watched = $originals = $proposals = $before = $after = []; + $gridPaths = self::findGridPaths($directory); + foreach ($map->getGridSources() as $path => $source) { + $original = (string) file_get_contents($path); + $watched[$path] = $original; + if ($source !== $original) { + $originals[$path] = $original; + $proposals[$path] = $source; + $before[$path] = MapGridSource::parseSource($original, $path); + $after[$path] = MapGridSource::parseSource($source, $path); + } + } + + $dataPaths = self::findDataPaths($root); + $payloads = self::evaluate($dataPaths, $root); + $relative = static fn(string $path): string => substr($path, strlen($root) + 1); + foreach ($dataPaths as $path) { + $watched[$path] = (string) file_get_contents($path); + } + + $commonEvents = []; + foreach ($dataPaths as $path) { + if (dirname($path) === $root . '/assets/Events') { + $commonEvents[basename($path, '.php')] = ['file' => $path, 'commands' => $payloads[$path]]; + } + } + $inventory = new LineInsertionInventory($insertion, $commonEvents); + foreach ($dataPaths as $path) { + if (str_starts_with($path, $root . '/assets/Maps/') && is_array($payloads[$path])) { + $inventory->addMapData($path, substr(dirname($path), strlen($root . '/assets/Maps/')), $payloads[$path]); + } + } + if (is_array($payloads[$root . '/' . self::SYSTEM] ?? null)) { + $inventory->addSystemData($root . '/' . self::SYSTEM, $payloads[$root . '/' . self::SYSTEM]); + } + foreach ($dataPaths as $path) { + if (str_ends_with($path, '.data.php') && str_starts_with($path, $root . '/assets/Cutscenes/Cinematics/')) { + $scriptPath = substr($path, 0, -strlen('.data.php')) . '.script.php'; + if (is_array($payloads[$path]) && array_key_exists($scriptPath, $payloads)) { + $inventory->addCutscene($path, $scriptPath, basename(dirname($path)), $payloads[$path], $payloads[$scriptPath]); + } + } + } + $inventory->addCommonEvents(); + + $handEdits = []; + foreach ($inventory->getHandEdits() as $path => $edits) { + foreach ($edits as $edit) { + $handEdits[] = ['file' => $relative($path), 'where' => PhpArraySourceDocument::describePath($edit['path']), 'reason' => $edit['reason']]; + } + } + foreach ($inventory->getShifts() as $path => $shifts) { + $next = self::rewriteShifts($watched[$path], $payloads[$path], $shifts, $relative($path), $handEdits); + if ($next !== null) { + $originals[$path] = $watched[$path]; + $proposals[$path] = $next['source']; + $before[$path] = PhpDataFile::getComparableValue($payloads[$path]); + $after[$path] = PhpDataFile::getComparableValue($next['value']); + } + } + + $notes = []; + $manifestPath = $root . '/' . self::MANIFEST; + if (! array_key_exists($manifestPath, $payloads)) { + $notes[] = sprintf('Existing saves are not migrated: the project has no %s.', self::MANIFEST); + } else { + $next = self::planSaveMigration($watched[$manifestPath], $payloads[$manifestPath], $insertion, $handEdits); + if ($next !== null) { + $originals[$manifestPath] = $watched[$manifestPath]; + $proposals[$manifestPath] = $next['source']; + $before[$manifestPath] = PhpDataFile::getComparableValue($payloads[$manifestPath]); + $after[$manifestPath] = PhpDataFile::getComparableValue($next['value']); + $notes[] = sprintf('Saves move with the map: save content version %d adds this map shift.', $next['value']['contentVersion']); + } + } + + $sources = new SourceSetPlan($root, $watched, $originals, $proposals, $before, $after, + static fn(): array => [...self::findGridPaths($directory), ...self::findDataPaths($root)], + static fn(string $path, string $staged): mixed => in_array($path, $gridPaths, true) + ? MapGridSource::parseSource((string) file_get_contents($staged), $path) + : PhpDataFile::getComparableValue(PhpDataFile::evaluateIsolated($staged, $root)), + 'map line insertion', + 'Map and project data files', + ); + + return new LineInsertionPlan($insertion, $sources, $handEdits, $notes); + } + + /** + * Rewrites a data file's coordinates in place. A coordinate written as + * anything but a literal, or in a file that is not one returned array + * literal, is reported for a hand edit and left as authored. + * + * @param list, value: int}> $shifts + * @param list $handEdits + * @return array{source: string, value: array}|null Null when nothing in the file can be rewritten. + */ + private static function rewriteShifts(string $source, mixed $payload, array $shifts, string $file, array &$handEdits): ?array + { + $report = static function (array $path, string $reason) use (&$handEdits, $file): void { + $handEdits[] = ['file' => $file, 'where' => PhpArraySourceDocument::describePath($path), 'reason' => $reason]; + }; + try { + $document = PhpArraySourceDocument::parse($source); + } catch (SourceUnreadable $unreadable) { + foreach ($shifts as $shift) { + $report($shift['path'], 'the file is not one returned array literal (' . rtrim($unreadable->getMessage(), '.') . ')'); + } + return null; + } + + $next = $payload; + $kept = []; + foreach ($shifts as $shift) { + $refusal = self::findLiteralRefusal($document, $shift['path']); + if ($refusal !== null) { + $report($shift['path'], $refusal); + continue; + } + $value = &$next; + foreach ($shift['path'] as $step) { + $value = &$value[$step]; + } + $value = $shift['value']; + unset($value); + $kept[] = $shift; + } + if ($kept === []) { + return null; + } + + try { + return ['source' => ArraySourceWriter::rewrite($document, $payload, $next)->source, 'value' => $next]; + } catch (SourcePreservationRefusal $refusal) { + foreach ($kept as $shift) { + $report($shift['path'], rtrim($refusal->getMessage(), '.')); + } + return null; + } + } + + /** + * Why a coordinate is not a literal the file writes in its own place, + * or null when it is: every array above it must be written out entry by + * entry, so its place in the file is its place in the data. + * + * @param list $path + */ + private static function findLiteralRefusal(PhpArraySourceDocument $document, array $path): ?string + { + $node = $document->root(); + foreach ($path as $step) { + if ($node->kind !== SourceNode::ARRAY) { + return 'it is inside a value written as an expression or variable'; + } + if ($node->hasOpaqueKey) { + return 'it is inside an array built with a spread or a computed key'; + } + $entry = $node->entryFor($step); + if ($entry === null) { + return 'the file does not write it as an entry of its own'; + } + $node = $entry->value; + } + + return $node->kind === SourceNode::SCALAR ? null : 'it is written as an expression or variable, not a literal'; + } + + /** + * Raises the manifest's content version by one with a declarative map + * shift step, or reports why it must be done by hand. + * + * @param list $handEdits + * @return array{source: string, value: array}|null + */ + private static function planSaveMigration(string $source, mixed $manifest, LineInsertion $insertion, array &$handEdits): ?array + { + $version = is_array($manifest) ? ($manifest['contentVersion'] ?? null) : null; + $migrations = is_array($manifest) ? ($manifest['migrations'] ?? []) : null; + $step = ['from' => $version, 'to' => is_int($version) ? $version + 1 : null, 'mapShifts' => [$insertion->getManifestEntry()]]; + $reason = null; + if (! is_int($version) || $version < 0 || ! is_array($migrations) || ! array_is_list($migrations)) { + $reason = 'its contentVersion or migrations list is not valid, so saves cannot be migrated automatically'; + } else { + $next = $manifest; + $next['contentVersion'] = $version + 1; + $next['migrations'] = [...$migrations, $step]; + try { + return ['source' => ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $manifest, $next)->source, 'value' => $next]; + } catch (SourceUnreadable|SourcePreservationRefusal $refusal) { + $reason = rtrim($refusal->getMessage(), '.'); + } + } + $handEdits[] = ['file' => self::MANIFEST, 'where' => 'contentVersion and migrations', + 'reason' => sprintf('%s; raise contentVersion by one and add the step %s by hand', $reason, var_export($step, true))]; + + return null; + } + + /** + * The map's grid files: its terminal layers, event layer and tile layers. + * + * @return list + */ + private static function findGridPaths(string $directory): array + { + $baseName = basename($directory); + $layers = is_dir($directory . '/' . MapLayerSource::DIRECTORY) + ? (glob($directory . '/' . MapLayerSource::DIRECTORY . '/*.{map,deco}.php', GLOB_BRACE) ?: []) + : [$directory . '/' . $baseName . '.map.php']; + + return [...$layers, $directory . '/' . $baseName . '.event.php', ...TileLayerSource::findPaths($directory)]; + } + + /** + * Every data file that can hold a coordinate in a map's space: map data + * files, the system data, reusable event scripts, cutscene pairs, and + * the save compatibility manifest. + * + * @return list + */ + private static function findDataPaths(string $root): array + { + $paths = []; + if (is_dir($root . '/assets/Maps')) { + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root . '/assets/Maps', \FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + $path = $file->getPathname(); + if ($file->isFile() && basename($path) === basename(dirname($path)) . '.data.php') { + $paths[] = $path; + } + } + } + foreach ([self::SYSTEM, self::MANIFEST] as $relative) { + if (is_file($root . '/' . $relative)) { + $paths[] = $root . '/' . $relative; + } + } + array_push($paths, ...(glob($root . '/assets/Events/*.php') ?: [])); + foreach (glob($root . '/assets/Cutscenes/Cinematics/*', GLOB_ONLYDIR) ?: [] as $cutscene) { + foreach (['data', 'script'] as $member) { + $path = $cutscene . '/' . basename($cutscene) . '.' . $member . '.php'; + if (is_file($path)) { + $paths[] = $path; + } + } + } + sort($paths, SORT_STRING); + + return $paths; + } + + /** + * Evaluates every data file outside the Editor process: together when + * they load cleanly as one unit, one by one otherwise, so a failure + * names its file. + * + * @param list $paths + * @return array Payloads by path. + */ + private static function evaluate(array $paths, string $root): array + { + try { + return array_combine($paths, PhpDataFile::evaluateIsolatedFiles($paths, $root)); + } catch (Throwable) { + $payloads = []; + foreach ($paths as $path) { + try { + $payloads[$path] = PhpDataFile::evaluateIsolated($path, $root); + } catch (Throwable $failure) { + throw new RuntimeException(sprintf('%s could not be read, so the rows or columns cannot be inserted safely: %s', + substr($path, strlen($root) + 1), $failure->getMessage()), previous: $failure); + } + } + + return $payloads; + } + } +} diff --git a/src/Maps/MapLayers.php b/src/Maps/MapLayers.php index 2bdeae4..72fdd52 100644 --- a/src/Maps/MapLayers.php +++ b/src/Maps/MapLayers.php @@ -236,6 +236,31 @@ public function resize(int $width, int $height): void $this->tileSources = $tileSources; } + /** + * Inserts blank rows before row `$at` (axis `y`) or blank columns before + * column `$at` (axis `x`) into every layer: the terminal layers, the + * event layer and the tile layers, whose new cells are empty. Every tile + * layer is rewritten before anything changes, so one the Engine cannot + * read refuses the whole insertion. + * + * @throws MapSourceRefusal When a tile layer cannot be read or does not match the map. + */ + public function insertLines(string $axis, int $at, int $count): void + { + $tileSources = []; + if ($this->tileSources !== []) { + $set = $this->getLayerSet(); + foreach ($this->tileSources as $path => $source) { + $tileSources[$path] = TileLayerSource::insertLines($source, $this->getDisplayPath($path), $set, $axis, $at, $count, + $this->baselineSources[$path] ?? null); + } + } + foreach ($this->layers as $layer) { + $layer['grid']->insertLines($axis, $at, $count); + } + $this->tileSources = $tileSources; + } + /** * Writes tile entries into the named tile layers with their top-left * cell at (x, y), as one stamped piece: a `0` entry leaves its cell as it diff --git a/src/Maps/TileLayerSource.php b/src/Maps/TileLayerSource.php index b7e9c44..46d32f3 100644 --- a/src/Maps/TileLayerSource.php +++ b/src/Maps/TileLayerSource.php @@ -81,6 +81,37 @@ public static function resize(string $source, string $displayPath, MapLayerSet $ return self::rewrite($rows, $entries, $source, $displayPath, $baseline); } + /** + * Inserts empty (`0`) rows before row `$at` (axis `y`) or empty cells + * before column `$at` of every row that reaches it (axis `x`), as + * {@see EditableGrid::insertLines()} inserts them into the terminal + * layers. The result is rewritten as {@see resize()} rewrites a layer. + * + * @param MapLayerSet $layers The map's terminal layers before the insertion. + * @throws MapSourceRefusal When the layer cannot be read or does not match the map; nothing is changed. + */ + public static function insertLines(string $source, string $displayPath, MapLayerSet $layers, string $axis, int $at, int $count, ?string $baseline = null): string + { + $layer = self::readMatchingLayer($source, $displayPath, $layers, 'inserting rows or columns'); + + $empty = (string)TileId::EMPTY; + $entries = $layer->getEntries(); + $rows = $entries; + if ($axis === 'y') { + $beside = $rows[$at] ?? $rows[$at - 1] ?? []; + array_splice($rows, $at, 0, array_fill(0, $count, array_fill(0, count($beside), $empty))); + } else { + foreach ($rows as &$row) { + if (count($row) >= $at) { + array_splice($row, $at, 0, array_fill(0, $count, $empty)); + } + } + unset($row); + } + + return self::rewrite($rows, $entries, $source, $displayPath, $baseline); + } + /** * Writes a piece's tile entries into a tile layer with its top-left cell * at (x, y). A `0` entry leaves its cell as it was; every other entry, diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 6426e63..eabee38 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -1511,6 +1511,45 @@ public function resize(int $width, int $height): void $this->touchState(); } + /** + * Inserts `$count` blank rows before row `$at` (axis `y`) or blank + * columns before column `$at` (axis `x`) into every grid the map has: + * its terminal layers, its event layer and its tile layers. Only the + * grids change; the coordinates stored in data files move through the + * project-wide insertion plan. + * + * @throws MapSourceRefusal When the line is outside the map or a tile layer cannot take it; nothing is changed. + */ + public function insertLines(string $axis, int $at, int $count): void + { + $this->assertEditable(); + $size = match ($axis) { + 'x' => $this->getWidth(), + 'y' => $this->getHeight(), + default => throw new MapSourceRefusal("Insert rows (y) or columns (x), not '{$axis}'."), + }; + if ($count < 1 || $at < 0 || $at > $size) { + throw new MapSourceRefusal(sprintf('%s cannot take %d %s at %d; nothing was changed.', + $this->mapId, $count, $axis === 'y' ? 'rows' : 'columns', $at)); + } + + $this->layers->insertLines($axis, $at, $count); + + $this->cachedWidth = null; + $this->touchState(); + } + + /** + * Every grid file's source as the map now holds it: terminal and event + * layers, and tile layers, keyed by path. + * + * @return array + */ + public function getGridSources(): array + { + return $this->layers->getSources(); + } + /** * Updates a nested event field. * diff --git a/src/UI/Modal.php b/src/UI/Modal.php index c75e98d..789bca1 100644 --- a/src/UI/Modal.php +++ b/src/UI/Modal.php @@ -34,6 +34,10 @@ enum Modal: string */ case RENAME_CONFIRMATION = 'rename_confirmation'; case LAYER_EDIT = 'layer_edit'; + /** + * The count prompt for inserting rows or columns into the map. + */ + case LINE_INSERT = 'line_insert'; /** * The Ctrl+P command palette. Opens above the Database screen and the * main shell, below the safety modals. diff --git a/src/Validation/SaveCompatibilityValidator.php b/src/Validation/SaveCompatibilityValidator.php index 43e1d7e..ae370b8 100644 --- a/src/Validation/SaveCompatibilityValidator.php +++ b/src/Validation/SaveCompatibilityValidator.php @@ -8,7 +8,9 @@ use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Exceptions\InvalidSaveCompatibilityManifestException; use Ichiloto\Engine\IO\SaveCompatibility\ContentReferenceCategory; +use Ichiloto\Engine\IO\SaveCompatibility\MapShift; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use Throwable; @@ -272,7 +274,12 @@ private function checkMigrations(mixed $rawMigrations, mixed $version): array $issues[] = Issue::error($where, sprintf('Migration step %d to %d is in an impossible order.', $from, $to)); } - if ($class === '') { + if (array_key_exists('class', $entry) === array_key_exists('mapShifts', $entry)) { + $issues[] = Issue::error($where, 'A migration must declare exactly one of class or mapShifts.', + 'Use a class for a project migration, or mapShifts for rows and columns inserted into maps.'); + } elseif (array_key_exists('mapShifts', $entry)) { + $issues = [...$issues, ...$this->checkMapShifts($entry['mapShifts'], $where)]; + } elseif ($class === '') { $issues[] = Issue::error($where, 'Migration class must be a non-empty class name.'); } @@ -304,6 +311,31 @@ private function checkMigrations(mixed $rawMigrations, mixed $version): array return $issues; } + /** + * Checks a declarative map shift step exactly as the Engine reads it. + * + * @return Issue[] + */ + private function checkMapShifts(mixed $rawShifts, string $where): array + { + if (! is_array($rawShifts) || $rawShifts === [] || ! array_is_list($rawShifts)) { + return [Issue::error($where, 'mapShifts must be a non-empty list.')]; + } + + $issues = []; + + foreach ($rawShifts as $index => $shift) { + try { + MapShift::fromArray($shift, sprintf('mapShifts[%d]', $index)); + } catch (InvalidSaveCompatibilityManifestException $exception) { + $issues[] = Issue::error($where, $exception->getMessage(), + 'Each shift names a map, an axis (x or y), the 0-based line it was inserted at, and a count of at least 1.'); + } + } + + return $issues; + } + /** @return string[]|null Null means this category has no closed catalog. */ private function knownTargets(ProjectWorkspace $workspace, ContentReferenceCategory $category): ?array { diff --git a/tests/Unit/MapLineInsertionTest.php b/tests/Unit/MapLineInsertionTest.php new file mode 100644 index 0000000..ccc47b7 --- /dev/null +++ b/tests/Unit/MapLineInsertionTest.php @@ -0,0 +1,433 @@ + "######\n#....#\n#.~~.#\n#....#\n######", + 'layers/02.detail.deco.php' => " \n d \n \n d \n ", + 'home.event.php' => " \n W \n \n D S \n ", + ]; + foreach ($grids as $file => $text) { + file_put_contents($home . '/' . $file, MapGridSource::buildSource($text, 'AUTHORED', "// keep {$file}\n")); + } + writeTileLayer($home, '01.floor.tiles.php', implode("\n", array_map( + static fn(int $row): string => implode(' ', array_fill(0, 6, $row === 2 ? '5' : '2816')), range(0, 4))), + "// Painted in the GUI editor.\n"); + file_put_contents($home . '/home.data.php', <<<'PHP' + 'Home', + 'region' => '', + 'tileset' => 'home', + 'station' => ['x' => 3, 'y' => 3], + 'tileLayers' => ['floor' => ['offset' => [0, 3]]], + 'npcs' => [ + ['id' => 'cat', 'name' => 'Cat', 'sprite' => 'c', 'x' => 2, 'y' => 3, + 'movement' => 'wander', 'wanderArea' => ['x' => 1, 'y' => 1, 'width' => 3, 'height' => 3]], + ['id' => 'mother', 'name' => 'Mother', 'sprite' => '@', 'x' => 4, 'y' => 0, 'script' => [ + ['type' => 'move_player', 'x' => 1, 'y' => 4], + ['type' => 'transfer', 'map' => 'town', 'x' => 2, 'y' => 3], + ['type' => 'move_player', 'x' => 3, 'y' => 3], + ]], + ['id' => 'lamp', 'name' => 'Lamp', 'sprite' => 'i', 'x' => 1, 'y' => 2 + 1], + ], + 'events' => [ + 'D' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\SleepEventTrigger', + 'data' => ['spawnPoint' => ['x' => 1, 'y' => 4], 'spawnSprite' => ['South'], 'cost' => 0], + ], + 'W' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'home-only'], + ], + 'S' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'shared'], + ], + ], +]; +PHP); + + $town = $root . '/assets/Maps/town'; + mkdir($town, 0o777, true); + file_put_contents($town . '/town.map.php', MapGridSource::buildSource("######\n# #\n######", 'ICHILOTO_MAP')); + file_put_contents($town . '/town.event.php', MapGridSource::buildSource(" AB S \n \n ", 'ICHILOTO_EVENT_MAP')); + file_put_contents($town . '/town.data.php', <<<'PHP' + 'Town', + 'region' => '', + 'npcs' => [ + ['id' => 'guard', 'name' => 'Guard', 'sprite' => 'g', 'x' => 1, 'y' => 1, 'script' => [ + ['type' => 'transfer', 'map' => 'home', 'x' => 1, 'y' => 3], + ['type' => 'move_player', 'x' => 2, 'y' => 4], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 5], ['y' => 4]]], + ]], + ], + 'events' => [ + 'A' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\TransferPlayerTrigger', + 'data' => ['destinationMap' => 'home', 'spawnPoint' => ['x' => 2, 'y' => 4], 'spawnSprite' => ['North']], + ], + 'B' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\TransferPlayerTrigger', + 'data' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 2, 'y' => 4], 'spawnSprite' => ['North']], + ], + 'S' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'shared'], + ], + ], +]; +PHP); + + file_put_contents($root . '/assets/Events/home-only.php', <<<'PHP' + 'move_player', 'x' => 3, 'y' => 4], + ['type' => 'camera', 'operation' => 'pan', 'target' => ['kind' => 'position', 'x' => 2, 'y' => 1], 'seconds' => 1.0], +]; +PHP); + file_put_contents($root . '/assets/Events/shared.php', <<<'PHP' + 'move_player', 'x' => 3, 'y' => 4], +]; +PHP); + file_put_contents($root . '/assets/Data/system.php', <<<'PHP' + [ + 'player' => ['destinationMap' => 'home', 'spawnPoint' => ['x' => 1, 'y' => 4], 'spawnSprite' => ['South']], + ], +]; +PHP); + + return $root; +} + +/** Plans an insertion on the fixture's home map. */ +function planLineInsertion(string $root, string $axis, int $at, int $count): Ichiloto\Editor\Maps\LineInsertionPlan +{ + return LineInsertionPlanner::planProject($root, 'home', $axis, $at, $count); +} + +/** @return list Each hand edit as `file where`. */ +function describeLineInsertionHandEdits(Ichiloto\Editor\Maps\LineInsertionPlan $plan): array +{ + return array_map(static fn(array $edit): string => $edit['file'] . ' ' . $edit['where'], $plan->handEdits); +} + +/** @return list The messages validation reports for the save manifest. */ +function describeSaveCompatibilityIssues(string $root): array +{ + return array_values(array_map(static fn($issue): string => $issue->message, array_filter(validateProject($root), + static fn($issue): bool => str_starts_with($issue->where, 'assets/Data/save-compatibility.php')))); +} + +/** The editor over the fixture with `home` selected and the cursor on the canvas. */ +function lineInsertionEditor(string $root): Editor +{ + $editor = deletionEditor($root); + setEditorProperty($editor, 'focusedPane', 'canvas'); + expect(getEditorProperty($editor, 'workspace')->getMapByIndex(0)->mapId)->toBe('home'); + + return $editor; +} + +function openLineInsertPaletteItem(Editor $editor, string $label): void +{ + $items = callEditorMethod($editor, 'buildPaletteItems'); + $item = array_values(array_filter($items, static fn($item) => $item->label === $label))[0] ?? null; + expect($item)->not->toBeNull(); + ($item->action)(); +} + +it('splices blank rows into every grid, keeping styled cells and the rows that only move', function () { + $root = lineInsertionProject(); + $plan = planLineInsertion($root, 'y', 2, 2); + $plan->apply(); + $home = $root . '/assets/Maps/home'; + + expect(MapGridSource::readFile($home . '/layers/01.terrain.map.php')) + ->toBe("######\n#....#\n \n \n#.~~.#\n#....#\n######") + ->and(MapGridSource::readFile($home . '/layers/02.detail.deco.php')) + ->toBe(" \n d \n \n \n \n d \n ") + ->and(MapGridSource::readFile($home . '/home.event.php')) + ->toBe(" \n W \n \n \n \n D S \n ") + ->and(file_get_contents($home . '/layers/01.terrain.map.php'))->toContain("// keep layers/01.terrain.map.php\n") + ->and(TileLayerSource::readLayer((string) file_get_contents($home . '/graphics/01.floor.tiles.php'), 'floor')->getEntries()) + ->toBe([ + array_fill(0, 6, '2816'), array_fill(0, 6, '2816'), array_fill(0, 6, '0'), array_fill(0, 6, '0'), + array_fill(0, 6, '5'), array_fill(0, 6, '2816'), array_fill(0, 6, '2816'), + ]) + ->and(file_get_contents($home . '/graphics/01.floor.tiles.php'))->toContain('// Painted in the GUI editor.'); + + $map = ProjectMap::fromDirectory($root . '/assets/Maps', $home); + expect($map->getHeight())->toBe(7) + ->and($map->getEventBounds('D'))->toMatchArray(['x' => 1, 'y' => 5]); +}); + +it('splices blank columns into every grid, keeping styled runs', function () { + $root = lineInsertionProject(); + planLineInsertion($root, 'x', 1, 1)->apply(); + $home = $root . '/assets/Maps/home'; + + expect(MapGridSource::readFile($home . '/layers/01.terrain.map.php')) + ->toBe("# #####\n# ....#\n# .~~.#\n# ....#\n# #####") + ->and(MapGridSource::readFile($home . '/home.event.php')) + ->toBe(" \n W \n \n D S \n ") + ->and(TileLayerSource::readLayer((string) file_get_contents($home . '/graphics/01.floor.tiles.php'), 'floor')->getEntries()[2]) + ->toBe(['5', '0', '5', '5', '5', '5', '5']) + ->and((require $home . '/home.data.php')['npcs'][0])->toMatchArray(['x' => 3, 'y' => 3]) + ->and((require $home . '/home.data.php')['npcs'][0]['wanderArea'])->toBe(['x' => 2, 'y' => 1, 'width' => 3, 'height' => 3]); +}); + +it('moves points, stretches straddling areas and follows transfers into the map from every file', function () { + $root = lineInsertionProject(); + $plan = planLineInsertion($root, 'y', 2, 2); + $plan->apply(); + $home = require $root . '/assets/Maps/home/home.data.php'; + $town = require $root . '/assets/Maps/town/town.data.php'; + + expect($home['npcs'][0])->toMatchArray(['x' => 2, 'y' => 5]) + ->and($home['npcs'][0]['wanderArea'])->toBe(['x' => 1, 'y' => 1, 'width' => 3, 'height' => 5]) + ->and($home['npcs'][1])->toMatchArray(['x' => 4, 'y' => 0]) + // The script moves the player on home, then leaves: only the first move is home's. + ->and($home['npcs'][1]['script'])->toBe([ + ['type' => 'move_player', 'x' => 1, 'y' => 6], + ['type' => 'transfer', 'map' => 'town', 'x' => 2, 'y' => 3], + ['type' => 'move_player', 'x' => 3, 'y' => 3], + ]) + ->and($home['events']['D']['data']['spawnPoint'])->toBe(['x' => 1, 'y' => 6]) + ->and($home['station'])->toBe(['x' => 3, 'y' => 3]) + ->and($home['tileLayers'])->toBe(['floor' => ['offset' => [0, 3]]]) + ->and($town['events']['A']['data']['spawnPoint'])->toBe(['x' => 2, 'y' => 6]) + ->and($town['events']['B']['data']['spawnPoint'])->toBe(['x' => 2, 'y' => 4]) + // Town's guard transfers into home; what follows is in home's space, one axis at a time. + ->and($town['npcs'][0]['script'])->toBe([ + ['type' => 'transfer', 'map' => 'home', 'x' => 1, 'y' => 5], + ['type' => 'move_player', 'x' => 2, 'y' => 6], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 5], ['y' => 6]]], + ]) + ->and((require $root . '/assets/Data/system.php')['startingPositions']['player']['spawnPoint'])->toBe(['x' => 1, 'y' => 6]) + ->and(require $root . '/assets/Events/home-only.php')->toBe([ + ['type' => 'move_player', 'x' => 3, 'y' => 6], + ['type' => 'camera', 'operation' => 'pan', 'target' => ['kind' => 'position', 'x' => 2, 'y' => 1], 'seconds' => 1.0], + ]) + ->and(file_get_contents($root . '/assets/Events/home-only.php'))->toStartWith("toEqualCanonicalizing([ + 'assets/Maps/home/home.data.php npcs.2.y', + 'assets/Events/shared.php 0.y', + ]) + ->and(array_column($plan->handEdits, 'reason'))->toContain('it may run on home or town') + ->and(array_map(static fn(string $path): string => substr($path, strlen($root) + 1), $plan->getChangedPaths())) + ->not->toContain('assets/Events/shared.php'); + + $plan->apply(); + $source = (string) file_get_contents($root . '/assets/Maps/home/home.data.php'); + expect($source)->toContain("'x' => 1, 'y' => 2 + 1]") + ->and($source)->toContain("// The house.\n") + ->and(file_get_contents($root . '/assets/Events/shared.php'))->toBe($shared); +}); + +it('raises the save content version with a declarative map shift step', function () { + $root = lineInsertionProject(); + $plan = planLineInsertion($root, 'y', 2, 2); + expect($plan->notes)->toBe(['Saves move with the map: save content version 1 adds this map shift.']); + $plan->apply(); + + expect(require $root . '/assets/Data/save-compatibility.php')->toBe([ + 'contentVersion' => 1, + 'migrations' => [['from' => 0, 'to' => 1, 'mapShifts' => [['map' => 'home', 'axis' => 'y', 'at' => 2, 'by' => 2]]]], + 'aliases' => [], + 'tombstones' => [], + ]) + ->and(Ichiloto\Engine\IO\SaveCompatibility\SaveCompatibilityManifest::fromProjectRoot($root)->createMigrationFrom(0)) + ->toBeInstanceOf(Ichiloto\Engine\IO\SaveCompatibility\MapShiftContentMigration::class) + ->and(describeSaveCompatibilityIssues($root))->toBe([]); +}); + +it('says existing saves are not migrated when the project has no save manifest', function () { + $root = lineInsertionProject(); + unlink($root . '/assets/Data/save-compatibility.php'); + + expect(planLineInsertion($root, 'x', 0, 1)->notes) + ->toBe(['Existing saves are not migrated: the project has no assets/Data/save-compatibility.php.']); +}); + +it('moves a cutscene cast and script that start on the map, and a finalizer transfer into it', function () { + $root = lineInsertionProject(); + $directory = $root . '/assets/Cutscenes/Cinematics/arrival'; + mkdir($directory, 0o777, true); + file_put_contents($directory . '/arrival.data.php', " 'arrival', 'name' => 'Arrival', 'startMap' => 'home', + 'cast' => [['id' => 'visitor', 'sprite' => 'v', 'x' => 2, 'y' => 4]], + 'finalizer' => [['type' => 'transfer', 'map' => 'home', 'x' => 1, 'y' => 3]], + ], true) . ";\n"); + file_put_contents($directory . '/arrival.script.php', " 'stage_actor', 'id' => 'extra', 'sprite' => 'e', 'x' => 3, 'y' => 3], + ['type' => 'transfer', 'map' => 'town', 'x' => 1, 'y' => 1], + ['type' => 'field_animation', 'animation' => 'spark', 'target' => ['kind' => 'position', 'x' => 1, 'y' => 1]], + ], true) . ";\n"); + + $plan = planLineInsertion($root, 'y', 2, 1); + $plan->apply(); + + expect((require $directory . '/arrival.data.php')['cast'][0])->toMatchArray(['x' => 2, 'y' => 5]) + ->and((require $directory . '/arrival.data.php')['finalizer'][0])->toMatchArray(['x' => 1, 'y' => 4]) + ->and((require $directory . '/arrival.script.php')[0])->toMatchArray(['x' => 3, 'y' => 4]) + ->and((require $directory . '/arrival.script.php')[2]['target'])->toMatchArray(['y' => 1]); +}); + +it('inserts through the palette after a count and a confirmation, and one undo restores every file', function () { + $root = lineInsertionProject(); + $before = sourceHashTree($root . '/assets'); + $editor = lineInsertionEditor($root); + $workspace = getEditorProperty($editor, 'workspace'); + setEditorProperty($editor, 'cursorY', 2); + + openLineInsertPaletteItem($editor, 'Map: Insert rows above the cursor'); + expect(getEditorProperty($editor, 'modals')->has(Ichiloto\Editor\UI\Modal::LINE_INSERT))->toBeTrue(); + pressKeys($editor, "\177", '2', "\r"); + + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue() + ->and(sourceHashTree($root . '/assets'))->toBe($before); + $entries = getEditorProperty($editor, 'eventOptionDialogEntries'); + $labels = array_column($entries, 'label'); + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Insert 2 rows above row 3 of home') + ->and($entries[1]['description'])->toBe('Writes now, not on Save. Ctrl+Z restores files.') + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Writes now, not on Save. Ctrl+Z restores files.') + ->and($labels)->toContain('assets/Maps/home/layers/01.terrain.map.php') + ->and($labels)->toContain('assets/Data/system.php') + ->and($labels)->toContain('Hand edit: assets/Events/shared.php 0.y') + ->and($labels)->toContain('Saves move with the map: save content version 1 adds this map shift.'); + + pressKeys($editor, "\033[B", "\r"); + $after = sourceHashTree($root . '/assets'); + expect($after)->not->toBe($before) + ->and(getEditorProperty($editor, 'workspace'))->not->toBe($workspace) + ->and(getEditorProperty($editor, 'workspace')->getMapByIndex(0)->getHeight())->toBe(7) + ->and(getEditorProperty($editor, 'workspace')->hasUnsavedChanges())->toBeFalse(); + + pressKeys($editor, "\x1a"); + expect(sourceHashTree($root . '/assets'))->toBe($before) + ->and(getEditorProperty($editor, 'workspace'))->toBe($workspace) + ->and($workspace->getMapByIndex(0)->getHeight())->toBe(5); + + pressKeys($editor, "\x19"); + expect(sourceHashTree($root . '/assets'))->toBe($after) + ->and(getEditorProperty($editor, 'workspace')->getMapByIndex(0)->getHeight())->toBe(7); +}); + +it('cancels from the count prompt and from the confirmation without writing', function () { + $root = lineInsertionProject(); + $before = sourceHashTree($root . '/assets'); + $editor = lineInsertionEditor($root); + + openLineInsertPaletteItem($editor, 'Map: Insert columns left of the cursor'); + pressKeys($editor, "\033"); + expect(getEditorProperty($editor, 'modals')->has(Ichiloto\Editor\UI\Modal::LINE_INSERT))->toBeFalse(); + + openLineInsertPaletteItem($editor, 'Map: Insert columns left of the cursor'); + pressKeys($editor, "\177", '0', "\r"); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('at least 1'); + pressKeys($editor, "\033"); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and(sourceHashTree($root . '/assets'))->toBe($before); +}); + +it('refuses to plan while the workspace has unsaved changes', function () { + $root = lineInsertionProject(); + $before = sourceHashTree($root . '/assets'); + $editor = lineInsertionEditor($root); + getEditorProperty($editor, 'workspace')->getMapByIndex(0)->setTileSymbol(1, 1, 'x'); + + openLineInsertPaletteItem($editor, 'Map: Insert rows above the cursor'); + + expect(getEditorProperty($editor, 'modals')->has(Ichiloto\Editor\UI\Modal::LINE_INSERT))->toBeFalse() + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Save or undo pending edits') + ->and(sourceHashTree($root . '/assets'))->toBe($before); +}); + +it('offers the insert commands only on a map in Map mode', function () { + $editor = lineInsertionEditor(lineInsertionProject()); + $labels = static fn(): array => array_map(static fn($item): string => $item->label, callEditorMethod($editor, 'buildPaletteItems')); + + expect($labels())->toContain('Map: Insert rows above the cursor') + ->and($labels())->toContain('Map: Insert columns left of the cursor'); + callEditorMethod($editor, 'setEditingMode', 'event'); + expect($labels())->not->toContain('Map: Insert rows above the cursor'); +}); + +it('reports coordinates in a list built with a spread instead of rewriting the wrong entry', function () { + $root = lineInsertionProject(); + $path = $root . '/assets/Maps/home/home.data.php'; + file_put_contents($path, str_replace(" 'npcs' => [\n", " 'npcs' => [\n ...array_map(static fn(int \$y): array => ['id' => \"post-{\$y}\", 'name' => 'Post', 'sprite' => '|', 'x' => 5, 'y' => \$y], [3]),\n", (string) file_get_contents($path))); + $source = (string) file_get_contents($path); + $plan = planLineInsertion($root, 'y', 2, 1); + $plan->apply(); + + expect(describeLineInsertionHandEdits($plan))->toContain('assets/Maps/home/home.data.php npcs.0.y') + ->and(describeLineInsertionHandEdits($plan))->toContain('assets/Maps/home/home.data.php npcs.1.y') + ->and(array_column(array_filter($plan->handEdits, static fn(array $edit): bool => $edit['where'] === 'npcs.1.y'), 'reason')) + ->toBe(['it is inside an array built with a spread or a computed key']) + // Everything else in the file still moves. + ->and((require $path)['events']['D']['data']['spawnPoint'])->toBe(['x' => 1, 'y' => 5]) + ->and(substr((string) file_get_contents($path), 0, strpos($source, " 'events' =>"))) + ->toBe(substr($source, 0, strpos($source, " 'events' =>"))); +}); + +it('validates declarative map shift steps as the Engine reads them', function () { + $root = lineInsertionProject(); + file_put_contents($root . '/assets/Data/save-compatibility.php', " 2, + 'migrations' => [ + ['from' => 0, 'to' => 1, 'mapShifts' => [['map' => 'home', 'axis' => 'z', 'at' => 0, 'by' => 1]]], + ['from' => 1, 'to' => 2, 'class' => 'Game\\Save\\Step', 'mapShifts' => [['map' => 'home', 'axis' => 'y', 'at' => 0, 'by' => 1]]], + ], + 'aliases' => [], + 'tombstones' => [], + ], true) . ";\n"); + $messages = describeSaveCompatibilityIssues($root); + + expect(implode("\n", $messages))->toContain('axis must be "x" or "y"') + ->and(implode("\n", $messages))->toContain('exactly one of class or mapShifts'); +}); From bd9e937cca95ab6bd6e3fd47d3a5771e1d1274b2 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 29 Sep 2026 16:27:11 +0200 Subject: [PATCH 067/344] feat(maps)!: stamp and validate whole tiles, one per field cell The Engine now draws each terminal cell as one whole 48 x 48 RPG Maker tile and refuses tile entries that name half a tile (42L). The editor reads tile layers and pieces through the Engine, so it follows: pieces and connected pieces write one whole tile per cell, and a half-tile entry is reported by validation with the Engine's whole-tile message, in a tile layer or in a tileset piece. - Validation reports a half-tile entry in a tile layer or a piece. - A resize, row or column insertion, or stamp over a tile layer holding a half-tile entry is refused and changes nothing. - P on a map whose tileset has a half-tile piece offers no pieces and says which entry to fix. - The manual and TileLayerSource describe whole tiles. Removed behaviour: tile halves (42L, 42R) are no longer carried through a resize or written by a stamp; the test that kept them through a resize is removed, replaced by the refusals above. BREAKING CHANGE: maps and tilesets with half-tile entries must be rewritten with whole tiles before the editor resizes, stamps or offers their pieces. --- docs/manual.md | 7 +++++-- src/Maps/TileLayerSource.php | 12 ++++++------ tests/Unit/MapGraphicsTest.php | 21 +++++++++------------ tests/Unit/TilesetPiecesTest.php | 21 ++++++++++++++------- 4 files changed, 34 insertions(+), 27 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 967dd1a..f1756ec 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -301,8 +301,11 @@ Event or NPC mode and selecting another map also end it. A stamp is one undo step. It writes the piece's glyphs on the gameplay layer the piece names, whichever layer the canvas is editing, in the brush colour as painting does, and its tiles on the tile layers it names. A space glyph and a -`0` tile leave their cells as they are; tile halves such as `42L` are written -as authored. A tile layer the map does not have yet is created as +`0` tile leave their cells as they are, and every other cell takes the piece's +whole tile. A field cell holds one whole RPG Maker tile, so an entry naming +half a tile (`42L`) is refused: in a tileset piece the canvas offers no pieces +and says which entry, in a tile layer the stamp changes nothing, and +validation reports both. A tile layer the map does not have yet is created as `graphics/NN.name.tiles.php`, ordered after the map's tile layers and empty elsewhere. The terminal never shows the tiles, but a map built from pieces draws correctly graphically without a second pass. A stamp that cannot be made diff --git a/src/Maps/TileLayerSource.php b/src/Maps/TileLayerSource.php index 46d32f3..aef50d9 100644 --- a/src/Maps/TileLayerSource.php +++ b/src/Maps/TileLayerSource.php @@ -57,10 +57,10 @@ public static function readLayer(string $source, string $displayPath): MapTileLa /** * Resizes a tile layer with its map: cells and rows beyond the new size - * are cropped and new cells are empty (`0`); kept cells keep their entry, - * including a named tile half (`42L`). A layer that reads back as - * its baseline keeps the baseline's bytes; otherwise it is rewritten as a - * canonical literal nowdoc, keeping its leading comment and marker. + * are cropped and new cells are empty (`0`); kept cells keep their entry. + * A layer that reads back as its baseline keeps the baseline's bytes; + * otherwise it is rewritten as a canonical literal nowdoc, keeping its + * leading comment and marker. * * @param MapLayerSet $layers The map's terminal layers before the resize. * @throws MapSourceRefusal When the layer cannot be read or does not match the map; nothing is changed. @@ -114,8 +114,8 @@ public static function insertLines(string $source, string $displayPath, MapLayer /** * Writes a piece's tile entries into a tile layer with its top-left cell - * at (x, y). A `0` entry leaves its cell as it was; every other entry, - * a named tile half included, replaces the cell's entry. The result is + * at (x, y). A `0` entry leaves its cell as it was; every other entry + * replaces the cell's entry with that whole tile. The result is * rewritten as {@see resize()} rewrites a layer. * * @param list> $rows Tile entries by row, as a piece holds them. diff --git a/tests/Unit/MapGraphicsTest.php b/tests/Unit/MapGraphicsTest.php index 5133b82..ee74718 100644 --- a/tests/Unit/MapGraphicsTest.php +++ b/tests/Unit/MapGraphicsTest.php @@ -69,18 +69,6 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array expect(graphicsIssueLines($root, Severity::ERROR))->toBe(["Map test-map tileLayers names 'rugs', which is not one of its tile layers."]); }); -it('keeps named tile halves when a resize rewrites a tile layer', function () { - $root = mapGraphicsProject(); - $map = loadLayeredMap($root); - $furniture = writeTileLayer($map->directory, '03.furniture.tiles.php', "0 5L 5R 0\n5 0 0 0"); - $map = loadLayeredMap($root); - - $map->resize(5, 1); - $map->save(); - - expect(file_get_contents($furniture))->toContain("0 5L 5R 0 0\nTILES;"); -}); - it('resizes a ragged map\'s tile rows to the widths its terminal rows take', function () { $root = mapGraphicsProject(ragged: true); $map = loadLayeredMap($root); @@ -144,6 +132,8 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array 'invalid identity' => ["0 5 0 abc\n0 0 0 5"], 'short row' => ["0 5 0\n0 0 0 5"], 'missing row' => ['0 5 0 0'], + // A field cell holds one whole tile; half-tile entries are refused, not reinterpreted. + 'half tile' => ["0 5L 5R 0\n0 0 0 5"], ]); it('carries graphics untouched through ordinary edits, and never lets an unreadable one block a save', function () { @@ -306,6 +296,13 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array 'invalid identity' => [function (string $root, string $directory): void { writeTileLayer($directory, '02.decor.tiles.php', "0 0 0 0\n0 0 0 9999"); }, "Tile layer test-map/graphics/02.decor.tiles.php row 1, cell 3: '9999' is not an RPG Maker tile identity."], + 'half tile' => [function (string $root, string $directory): void { + writeTileLayer($directory, '02.decor.tiles.php', "0 5L 5R 0\n0 0 0 0"); + }, "Tile layer test-map/graphics/02.decor.tiles.php row 0, cell 1: '5L' names half of tile 5, but each field cell holds one whole tile; use whole tiles, such as '5'."], + 'piece with a half tile' => [function (string $root): void { + writeTestTileset($root, pieces: ['bed' => ['name' => 'Bed', 'layer' => 'buildings', 'glyphs' => ['=='], + 'tiles' => ['decor' => ['5L 5R']]]]); + }, "'5L' names half of tile 5, but each field cell holds one whole tile; use whole tiles, such as '5'."], 'executable source' => [function (string $root, string $directory): void { file_put_contents($directory . '/graphics/02.decor.tiles.php', " ['name' => 'Bed', 'layer' => 'buildings', 'glyphs' => ['= ', '=H'], - 'tiles' => ['decor' => ['5L 5R', '6 0'], 'furniture' => ['7 0', '0 8R']]], + 'tiles' => ['decor' => ['5 5', '6 0'], 'furniture' => ['7 0', '0 8']]], 'lamp' => ['name' => 'Lamp', 'layer' => 'fixtures', 'glyphs' => ['i']], 'rug' => ['name' => 'Rug', 'layer' => 'terrain', 'glyphs' => ['~~'], 'tiles' => ['floor' => ['2816 2816']]], 'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'connects' => 'lines', @@ -92,6 +92,10 @@ function choosePieceByKeys(Editor $editor, int $downs = 0): void if ($case === 'broken tileset') { file_put_contents($root . '/assets/Data/Tilesets/home.php', " 'Home'];\n"); } + if ($case === 'half-tile piece') { + writeTestTileset($root, pieces: ['bed' => ['name' => 'Bed', 'layer' => 'buildings', 'glyphs' => ['=='], + 'tiles' => ['decor' => ['5L 5R']]]]); + } [$editor, $map] = layeredCanvasEditor($root); callEditorMethod($editor, 'dispatchInput', 'P'); @@ -103,6 +107,8 @@ function choosePieceByKeys(Editor $editor, int $downs = 0): void 'no tileset' => ['no tileset', 'names no tileset'], 'no pieces' => ['no pieces', 'Tileset home has no pieces'], 'broken tileset' => ['broken tileset', 'Pieces are unavailable: Tileset home needs at least one sheet.'], + // A field cell holds one whole tile, so a piece naming half a tile is refused, not reinterpreted. + 'half-tile piece' => ['half-tile piece', "'5L' names half of tile 5, but each field cell holds one whole tile; use whole tiles, such as '5'."], ]); it('previews the piece\'s footprint at the cursor while placing it', function () { @@ -169,8 +175,8 @@ function choosePieceByKeys(Editor $editor, int $downs = 0): void ->and(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:1') ->and($map->isDirty())->toBeTrue(); $tiles = $map->getTileLayerSources(); - expect(readTileEntries($tiles[$graphics . '/02.decor.tiles.php']))->toBe([['0', '5', '5L', '5R'], ['0', '0', '6', '5']]) - ->and(readTileEntries($tiles[$graphics . '/03.furniture.tiles.php']))->toBe([['0', '0', '7', '0'], ['0', '0', '0', '8R']]) + expect(readTileEntries($tiles[$graphics . '/02.decor.tiles.php']))->toBe([['0', '5', '5', '5'], ['0', '0', '6', '5']]) + ->and(readTileEntries($tiles[$graphics . '/03.furniture.tiles.php']))->toBe([['0', '0', '7', '0'], ['0', '0', '0', '8']]) ->and($tiles[$graphics . '/01.floor.tiles.php'])->toBe($tilesBefore[$graphics . '/01.floor.tiles.php']); // Placement stays for the next stamp; one undo takes the whole stamp back. @@ -188,8 +194,8 @@ function choosePieceByKeys(Editor $editor, int $downs = 0): void expect(array_keys(array_diff_assoc(sourceHashTree($map->directory), $disk)))->toBe([ 'graphics/02.decor.tiles.php', 'graphics/03.furniture.tiles.php', 'layers/04.buildings.map.php', ]) - ->and(file_get_contents($graphics . '/02.decor.tiles.php'))->toBe("and(file_get_contents($graphics . '/03.furniture.tiles.php'))->toBe("and(file_get_contents($graphics . '/02.decor.tiles.php'))->toBe("and(file_get_contents($graphics . '/03.furniture.tiles.php'))->toBe("and(array_map(static fn($layer): string => $layer->name, MapGraphics::loadFromDirectory( $map->directory, 'test-map', 'home', loadLayeredMap($root)->getLayerSet(), $root . '/assets')->layers)) ->toBe(['floor', 'decor', 'furniture']); @@ -221,8 +227,8 @@ function choosePieceByKeys(Editor $editor, int $downs = 0): void it('refuses a stamp that cannot be made whole and changes nothing', function (string $case, string $message) { [$editor, $map, $root] = createPieceCanvasEditor($case === 'ragged'); - if ($case === 'unreadable tile layer') { - writeTileLayer($map->directory, '02.decor.tiles.php', "0 5 0 0\n0 0 0 x"); + if ($case === 'unreadable tile layer' || $case === 'half-tile layer') { + writeTileLayer($map->directory, '02.decor.tiles.php', $case === 'half-tile layer' ? "0 5L 5R 0\n0 0 0 5" : "0 5 0 0\n0 0 0 x"); [$editor, $map] = layeredCanvasEditor($root); } $before = $map->captureLayerSnapshot(); @@ -245,6 +251,7 @@ function choosePieceByKeys(Editor $editor, int $downs = 0): void 'off the edge' => ['off the edge', 'Bed (2 x 2) does not fit at (3, 0): the map has no cell at (4, 0).'], 'ragged' => ['ragged', 'Bed (2 x 2) does not fit at (1, 0): the map has no cell at (2, 1).'], 'unreadable tile layer' => ['unreadable tile layer', "'x' is not an RPG Maker tile identity"], + 'half-tile layer' => ['half-tile layer', "'5L' names half of tile 5, but each field cell holds one whole tile"], ]); it('ends placement when the mode, map, layer or tool changes', function (string $key) { From c8ac41f8a6de63b74cf9a378dbc2a670eeb63429 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 29 Sep 2026 17:06:10 +0200 Subject: [PATCH 068/344] refactor(maps): resolve the tile layers that move with glyphs through the engine ProjectMap::getTileLayersMovingWith() kept its own copy of the rule that a tile layer belongs to the gameplay layer its map data names, and otherwise to the one gameplay layer whose tileset pieces write it. It now asks MapGraphics::resolveLayerOwners(), the engine's single resolution, which the graphical field also uses to hide a glyph only under tiles of its own layer. Behaviour is unchanged: the event and decoration layers move no tiles, invalid settings move none, and a tileset that cannot load leaves only the named layers. Requires the engine with MapGraphics::resolveLayerOwners(). --- src/ProjectMap.php | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/ProjectMap.php b/src/ProjectMap.php index eabee38..25ff913 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -495,10 +495,11 @@ public function writeTileCells(array $cells): void /** * The tile layers whose tiles move with a gameplay layer's glyphs: those - * the map data names it for (`'tileLayers' => ['floor' => ['movesWith' => - * 'buildings']]`), and otherwise those that only this layer's tileset - * pieces write. The event layer moves no tiles, and neither does any - * layer while the settings are invalid, which validation reports. + * that belong to it ({@see MapGraphics::resolveLayerOwners()}), named by + * the map data (`'tileLayers' => ['floor' => ['movesWith' => + * 'buildings']]`) or otherwise written only by this layer's tileset + * pieces. The event layer moves no tiles, and neither does any layer + * while the settings are invalid, which validation reports. * * @return list Tile layer names. */ @@ -512,23 +513,19 @@ public function getTileLayersMovingWith(string $layerId): array $gameplay = array_column(array_filter($this->layers->getLayers(), static fn(array $candidate): bool => $candidate['id'] !== MapLayers::EVENT && ! $candidate['decoration']), 'name'); try { - $named = MapGraphics::readLayersMovingWith($this->getMapDataField([MapGraphics::SETTINGS_KEY]), $names, $gameplay, $this->mapId); - } catch (InvalidArgumentException) { - return []; - } - $writers = []; - try { - foreach ($this->loadTileset()?->pieces ?? [] as $piece) { - foreach (array_keys($piece->connects === null ? $piece->tiles : $piece->shapeTiles) as $name) { - $writers[$name][$piece->layer] = true; - } - } + $tileset = $this->loadTileset(); } catch (InvalidArgumentException | RuntimeException) { // A tileset that cannot load says nothing about its pieces. + $tileset = null; + } + try { + $owners = MapGraphics::resolveLayerOwners($this->getMapDataField([MapGraphics::SETTINGS_KEY]), $names, $gameplay, + $tileset, $this->mapId); + } catch (InvalidArgumentException) { + return []; } - return array_values(array_filter($names, static fn(string $name): bool => - ($named[$name] ?? (count($writers[$name] ?? []) === 1 ? array_key_first($writers[$name]) : null)) === $layer['name'])); + return array_keys(array_filter($owners, static fn(?string $owner): bool => $owner === $layer['name'])); } /** From 930d610844f01a9500eefa3945b527291877bd44 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 29 Sep 2026 17:22:12 +0200 Subject: [PATCH 069/344] feat(canvas): keep tiles following glyphs however they are edited Tiles moved with glyphs only on copy, cut and paste, so a chair moved by erasing and typing kept its tile where it was. Every glyph edit on a gameplay layer now writes the tiles of the tileset pieces its glyphs belong to, in the same undo step: typing, brush painting, erasing, the line, rectangle and fill tools, mouse strokes, facade stamps, cut and paste. A glyph that leaves a cell takes the tiles of the piece role it played there; a glyph that arrives draws its role's tiles. Tiles over a piece's blank cells, such as a sofa back, go with the nearest glyph cell. Tiles no role accounts for, such as a floor under a wall, stay put. A glyph that several roles share takes the one its neighbours prove (the right half of a table beside its left half), and otherwise the editor asks which piece it is, or No tiles; Esc leaves the map as it was. The brush remembers the answer for its glyph until a glyph is typed again, and the eyedropper picks up the piece a glyph draws. Changed behaviour: typing or painting a glyph that several pieces share, with nothing to prove which, now waits for that choice instead of painting at once. --- docs/manual.md | 19 +- src/Canvas/GlyphTileCanvas.php | 298 +++++++++++++++++++++++++++ src/Canvas/GlyphTilePlanner.php | 254 +++++++++++++++++++++++ src/Canvas/LayerCanvas.php | 5 +- src/Canvas/PieceRole.php | 141 +++++++++++++ src/Editor.php | 208 +++++++++++-------- tests/Unit/TilesFollowGlyphsTest.php | 175 ++++++++++++++++ 7 files changed, 1009 insertions(+), 91 deletions(-) create mode 100644 src/Canvas/GlyphTileCanvas.php create mode 100644 src/Canvas/GlyphTilePlanner.php create mode 100644 src/Canvas/PieceRole.php create mode 100644 tests/Unit/TilesFollowGlyphsTest.php diff --git a/docs/manual.md b/docs/manual.md index f1756ec..902a829 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -377,9 +377,22 @@ A map may name a tileset in its data file (`'tileset' => 'home'`, read from cell, so each of its rows is exactly as wide as the map's row in terminal columns. The TUI never displays tiles or paints single tiles; painting them belongs to the GUI editor. It writes tiles only when it stamps a -[piece](#pieces) or moves glyphs the tiles move with, and otherwise keeps them +[piece](#pieces) or edits glyphs the tiles follow, and otherwise keeps them intact: +- Tiles follow a gameplay layer's glyphs however they are edited: typing, + painting, erasing, the line, rectangle and fill tools, the mouse, cutting and + pasting. A glyph that leaves a cell takes the tiles of the piece it drew + there; a glyph that arrives draws its piece's tiles, in the same undo step. + A piece's tiles over blank cells, such as a sofa's back over its seat, go + with the glyph nearest them. When a glyph belongs to several pieces (`-` for + a chair facing north or south), the neighbours decide when they hold the + rest of one piece, such as the left half of a table beside the right; + otherwise the editor asks which piece it is, or No tiles, and Esc leaves the + map as it was. The brush remembers the answer for its glyph until a glyph is + typed again, and the eyedropper picks up the piece a glyph draws. Tiles no + piece accounts for, such as a floor under a wall, stay where they are. + - Each tile layer moves with one gameplay layer: the one the map data names (`'tileLayers' => ['floor' => ['movesWith' => 'buildings']]`), or else the one whose tileset pieces write it, when only one does. Copying, cutting and @@ -525,8 +538,8 @@ styling authored by hand. with a selection active (`s`), every cell in the selection; otherwise, under the brush tool, the cell at the cursor. Spaces stay uncoloured. -The eyedropper (`k` / `Ctrl+K`) picks up a cell's colour along with its -glyph; an uncoloured cell loads an uncoloured brush. A painted space is +The eyedropper (`k` / `Ctrl+K`) picks up a cell's colour and the piece it +draws along with its glyph; an uncoloured cell loads an uncoloured brush. A painted space is always uncoloured, so erasing never leaves invisible styling behind. Event markers remain authoring geometry, but can now carry colour through the same picker and undo workflow. The Status pane shows the brush colour beside the tool. diff --git a/src/Canvas/GlyphTileCanvas.php b/src/Canvas/GlyphTileCanvas.php new file mode 100644 index 0000000..e09879f --- /dev/null +++ b/src/Canvas/GlyphTileCanvas.php @@ -0,0 +1,298 @@ +}|null The tile layers before the mouse stroke in progress changed them. */ + private ?array $activeStrokeTiles = null; + + /** + * Writes cells onto the active layer and the tiles that follow them as + * one undo step. Tiles another source sets, such as a pasted block's, + * win on their layers. A glyph that needs the author's choice opens the + * choice and changes nothing yet; choosing runs the edit again through + * `$retry` with the answer among its choices. + * + * @param array $writes The cells to write. + * @param array> $tiles Tile cells set by the edit itself, keyed by tile layer name. + * @param (Closure(array): void)|null $retry Runs the edit again with the author's choices. + * @param array $choices The role key chosen for a glyph, or null for no tiles. + * @param bool $repaint Whether a glyph painted over itself may take another role. + * @return int|null The number of glyph cells that changed, or null when nothing changed: refused, or waiting on a choice. + */ + private function commitCanvasWrites(ProjectMap $map, array $writes, string $label, array $tiles = [], ?Closure $retry = null, + array $choices = [], bool $repaint = false): ?int + { + if ($writes === [] && $tiles === []) { + return 0; + } + $layer = $this->getActiveCanvasLayer(); + try { + $plan = $this->planGlyphTiles($map, $layer, $writes, $choices, $repaint, array_keys($tiles)); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + return null; + } + if ($plan !== null && $plan['unresolved'] !== [] && $retry !== null) { + $glyph = (string) array_key_first($plan['unresolved']); + $this->askForGlyphPiece($glyph, $plan['unresolved'][$glyph], $choices, $retry); + return null; + } + $tiles = [...($plan['tiles'] ?? []), ...$tiles]; + + $this->finalizeActiveStroke(); + if ($tiles === []) { + [$stroke, $changed] = $this->writeCanvasCells($map, $layer, $writes, $label); + if ($stroke->hasChanges()) { + $this->recordCommand($stroke); + } + return $changed; + } + $tilesBefore = $map->getTileLayerSources(); + try { + $map->writeTileCells($tiles); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + return null; + } + $tilesAfter = $map->getTileLayerSources(); + [$stroke, $changed] = $this->writeCanvasCells($map, $layer, $writes, $label); + if ($stroke->hasChanges() || $tilesAfter !== $tilesBefore) { + $this->recordStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter); + } + + return $changed; + } + + /** + * Writes the tiles that follow one step of a mouse stroke, before its + * glyphs are written, so the stroke records them with its glyphs when it + * ends ({@see finalizeActiveStroke()}). + * + * @param array $writes + * @param Closure(array): void $retry Paints this step again with the author's choices. + * @return bool Whether the glyphs may be written: false when refused or waiting on a choice. + */ + private function followStrokeWithTiles(ProjectMap $map, array $writes, Closure $retry): bool + { + try { + $plan = $this->planGlyphTiles($map, $this->getActiveCanvasLayer(), $writes, + $this->getPaintPieceChoices((string) ($writes[0]['symbol'] ?? '')), true); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + return false; + } + if ($plan === null) { + return true; + } + if ($plan['unresolved'] !== []) { + $this->finalizeActiveStroke(); + $glyph = (string) array_key_first($plan['unresolved']); + $this->askForGlyphPiece($glyph, $plan['unresolved'][$glyph], [], $retry); + return false; + } + if ($plan['tiles'] === []) { + return true; + } + if ($this->activeStrokeTiles === null || $this->activeStrokeTiles['map'] !== $map) { + $this->activeStrokeTiles = ['map' => $map, 'sources' => $map->getTileLayerSources()]; + } + try { + $map->writeTileCells($plan['tiles']); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + return false; + } + + return true; + } + + /** + * The tiles that keep a layer's pieces drawn where its glyphs will be, + * or null when the layer has no pieces to follow: the event layer, a + * decoration layer, or a map whose tileset cannot load, which validation + * reports. + * + * @param array $writes + * @param array $choices + * @param list $excludedLayers Tile layers the edit sets itself. + * @return array{tiles: array>, unresolved: array>}|null + * @throws MapSourceRefusal When a tile layer the pieces draw on cannot be read. + */ + private function planGlyphTiles(ProjectMap $map, string $layerId, array $writes, array $choices, bool $repaint, + array $excludedLayers = []): ?array + { + $planner = $this->loadGlyphTilePlanner($map, $layerId, $excludedLayers); + if ($planner === null) { + return null; + } + $changes = []; + foreach ($writes as $write) { + if ($map->hasLayerCell($layerId, $write['x'], $write['y'])) { + $changes[] = ['x' => $write['x'], 'y' => $write['y'], + 'old' => $map->getLayerSymbol($layerId, $write['x'], $write['y']), 'new' => $write['symbol']]; + } + } + + return $planner->plan( + $changes, + static fn(int $x, int $y): ?string => $map->hasLayerCell($layerId, $x, $y) ? $map->getLayerSymbol($layerId, $x, $y) : null, + $this->createTileReader($map), + $choices, + $repaint, + ); + } + + /** The planner for a gameplay layer's pieces, or null when it has none to follow. */ + private function loadGlyphTilePlanner(ProjectMap $map, string $layerId, array $excludedLayers = []): ?GlyphTilePlanner + { + $layer = array_find($map->getLayers(), static fn(array $candidate): bool => $candidate['id'] === $layerId); + if ($layer === null || $layer['id'] === MapLayers::EVENT || $layer['decoration']) { + return null; + } + try { + $pieces = $map->loadTileset()?->pieces ?? []; + } catch (InvalidArgumentException | RuntimeException) { + return null; + } + + return $pieces === [] ? null : GlyphTilePlanner::fromPieces($pieces, $layer['name'], $excludedLayers); + } + + /** + * Reads tile entries cell by cell, each layer once: `0` where a layer + * has no tile, or the map has no such layer. + * + * @return Closure(string, int, int): string + */ + private function createTileReader(ProjectMap $map): Closure + { + $layers = []; + + return static function (string $layer, int $x, int $y) use ($map, &$layers): string { + $layers[$layer] ??= $map->readTileEntries([$layer], 0, 0, $map->getWidth(), $map->getHeight())[$layer] ?? []; + + return $layers[$layer][$y][$x] ?? (string) TileId::EMPTY; + }; + } + + /** + * Asks which piece a glyph is, listing its roles and No tiles, with the + * brush's last answer for it first in line. The edit runs again on an + * answer; Esc drops it. + * + * @param list $roles + * @param array $choices The answers the edit already has. + * @param Closure(array): void $retry + */ + private function askForGlyphPiece(string $glyph, array $roles, array $choices, Closure $retry): void + { + $this->finalizeActiveStroke(); + $this->optionDialogField = ['glyphPiece' => ['glyph' => $glyph, 'choices' => $choices, 'retry' => $retry]]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = "Piece for {$glyph}"; + $this->eventOptionDialogEntries = [ + ...array_map(static fn(PieceRole $role): array => [ + 'label' => $role->label, + 'value' => $role->key, + 'description' => implode(' · ', array_map( + static fn(string $layer, array $cells): string => $layer . ' ' . implode(' ', array_column($cells, 'entry')), + array_keys($role->tiles), + $role->tiles, + )) ?: 'no tiles', + ], $roles), + ['label' => 'No tiles', 'value' => '', 'description' => 'the glyph alone'], + ]; + $remembered = ($this->paintPieceRole['symbol'] ?? null) === $glyph ? ($this->paintPieceRole['role'] ?? '') : ''; + $this->selectedEventOptionIndex = $this->resolveEventOptionSelectionIndex($remembered); + $this->isEventOptionDialogOpen = true; + $this->setStatus(sprintf('%s could be %d pieces. Choose the one it draws; Esc leaves the map as it was.', $glyph, count($roles))); + $this->renderSelectionDependentArea(); + } + + /** + * Runs the waiting edit again with the author's answer for its glyph. + * + * @param array{glyph: string, choices: array, retry: Closure(array): void} $pending + */ + private function chooseGlyphPiece(array $pending, string $value): void + { + $this->closeEventOptionDialog(); + ($pending['retry'])([...$pending['choices'], $pending['glyph'] => $value === '' ? null : $value]); + } + + /** + * The brush's answer for its glyph, as edit choices. + * + * @return array + */ + private function getPaintPieceChoices(string $symbol): array + { + return ($this->paintPieceRole['symbol'] ?? null) === $symbol ? [$symbol => $this->paintPieceRole['role']] : []; + } + + /** + * Keeps the answer an edit got for the brush's glyph, so the brush draws + * that piece until a glyph is typed again. + * + * @param array $choices + */ + private function rememberPaintPieceRole(string $symbol, array $choices): void + { + if (array_key_exists($symbol, $choices)) { + $this->paintPieceRole = ['symbol' => $symbol, 'role' => $choices[$symbol]]; + } + } + + /** + * Makes the brush draw the piece role the glyph in a cell plays, as the + * eyedropper picks it up, and names it; an empty string when its tiles + * show no role. + */ + private function pickPaintPieceRole(ProjectMap $map, int $x, int $y): string + { + $this->paintPieceRole = null; + $layerId = $this->getActiveCanvasLayer(); + $planner = $this->loadGlyphTilePlanner($map, $layerId); + if ($planner === null || ! $map->hasLayerCell($layerId, $x, $y)) { + return ''; + } + $symbol = $map->getLayerSymbol($layerId, $x, $y); + try { + $role = $planner->findPlayedRole($symbol, $x, $y, $this->createTileReader($map)); + } catch (MapSourceRefusal) { + return ''; + } + if ($role === null) { + return ''; + } + $this->paintPieceRole = ['symbol' => $symbol, 'role' => $role->key]; + + return $role->label; + } +} diff --git a/src/Canvas/GlyphTilePlanner.php b/src/Canvas/GlyphTilePlanner.php new file mode 100644 index 0000000..c054fd5 --- /dev/null +++ b/src/Canvas/GlyphTilePlanner.php @@ -0,0 +1,254 @@ +> $roles Roles by glyph, distinct by the tiles they draw. */ + private function __construct(private array $roles) {} + + /** + * @param iterable $pieces The map's tileset pieces. + * @param string $layerName The gameplay layer the glyphs are on. + * @param list $excludedLayers Tile layers another source already sets. + */ + public static function fromPieces(iterable $pieces, string $layerName, array $excludedLayers = []): self + { + $roles = []; + foreach ($pieces as $piece) { + if ($piece->layer !== $layerName) { + continue; + } + foreach (PieceRole::readPiece($piece, $excludedLayers) as $glyph => $pieceRoles) { + foreach ($pieceRoles as $role) { + $roles[(string) $glyph][self::getSignature($role)] ??= $role; + } + } + } + + return new self(array_map(array_values(...), $roles)); + } + + /** @return list The roles a glyph can play, one for each set of tiles. */ + public function getRoles(string $glyph): array + { + return $this->roles[$glyph] ?? []; + } + + /** The role with this key that a glyph can play, or null when it has none. */ + public function findRole(string $glyph, string $key): ?PieceRole + { + foreach ($this->getRoles($glyph) as $role) { + if ($role->key === $key) { + return $role; + } + } + + return null; + } + + /** + * The role the glyph in a cell plays now, read from the tiles there: the + * one whose own tiles are all in place and the most of whose tiles are, + * or null when its tiles do not show one. + * + * @param Closure(string, int, int): string $tileAt The entry at a cell of a tile layer. + */ + public function findPlayedRole(string $glyph, int $x, int $y, Closure $tileAt): ?PieceRole + { + $best = null; + foreach ($this->getRoles($glyph) as $role) { + $own = $role->getOwnEntries(); + if ($own === [] || array_filter($own, static fn(string $entry, string $layer): bool => + $tileAt($layer, $x, $y) !== $entry, ARRAY_FILTER_USE_BOTH) !== []) { + continue; + } + $matched = 0; + foreach ($role->tiles as $layer => $cells) { + foreach ($cells as $cell) { + $matched += $tileAt($layer, $x + $cell['dx'], $y + $cell['dy']) === $cell['entry'] ? 1 : 0; + } + } + if ($best === null || $matched > $best[1]) { + $best = [$role, $matched]; + } + } + + return $best[0] ?? null; + } + + /** + * Plans the tile writes that keep the glyphs' tiles consistent with a set + * of glyph changes. A change whose glyph stays the same is planned only + * when `$repaint` says the author painted it again on purpose, which can + * give it another role. + * + * @param list $changes Glyph changes, by cell. + * @param Closure(int, int): ?string $glyphAt The layer's glyph at a cell before the changes, or null off the map. + * @param Closure(string, int, int): string $tileAt The entry at a cell of a tile layer before the changes. + * @param array $choices The role key the author chose for a glyph, or null for no tiles. + * @return array{tiles: array>, unresolved: array>} + * The tile cells to write, keyed by tile layer name, and the roles of each glyph that still needs a choice. + */ + public function plan(array $changes, Closure $glyphAt, Closure $tileAt, array $choices = [], bool $repaint = false): array + { + $changes = array_values(array_filter($changes, static fn(array $change): bool => $repaint || $change['old'] !== $change['new'])); + $changed = []; + foreach ($changes as $change) { + $changed["{$change['x']},{$change['y']}"] = $change['new']; + } + $overlay = []; + $resolved = []; + $unresolved = []; + + foreach ($changes as $index => $change) { + $resolved[$index] = $change['new'] === ' ' ? null : $this->resolveRole($change, $glyphAt, $tileAt, $changed, $choices, $unresolved); + } + // Every leaving role takes its tiles before any arriving role draws, + // so a glyph moved within one edit keeps the tiles it brings. + foreach ($changes as $index => $change) { + $leaving = $this->findPlayedRole($change['old'], $change['x'], $change['y'], $tileAt); + if ($leaving === null || ($resolved[$index] ?? null) === $leaving) { + continue; + } + foreach ($leaving->tiles as $layer => $cells) { + foreach ($cells as $cell) { + [$x, $y] = [$change['x'] + $cell['dx'], $change['y'] + $cell['dy']]; + if ($glyphAt($x, $y) !== null && ($overlay[$layer]["{$x},{$y}"] ?? $tileAt($layer, $x, $y)) === $cell['entry']) { + $overlay[$layer]["{$x},{$y}"] = (string) TileId::EMPTY; + } + } + } + } + foreach ($changes as $index => $change) { + foreach (($resolved[$index] ?? null)?->tiles ?? [] as $layer => $cells) { + foreach ($cells as $cell) { + [$x, $y] = [$change['x'] + $cell['dx'], $change['y'] + $cell['dy']]; + if ($glyphAt($x, $y) !== null) { + $overlay[$layer]["{$x},{$y}"] = $cell['entry']; + } + } + } + } + + $tiles = []; + foreach ($overlay as $layer => $cells) { + foreach ($cells as $position => $entry) { + [$x, $y] = array_map(intval(...), explode(',', (string) $position)); + if ($tileAt((string) $layer, $x, $y) !== $entry) { + $tiles[$layer][] = ['x' => $x, 'y' => $y, 'entry' => $entry]; + } + } + } + + return ['tiles' => $tiles, 'unresolved' => $unresolved]; + } + + /** + * The role an arriving glyph plays: its only one; the one its neighbours + * outside this edit prove, by holding the rest of that role's piece; or + * the author's choice. Null draws no tiles, and a glyph with neither + * proof nor choice is noted as unresolved. + * + * @param array{x: int, y: int, old: string, new: string} $change + * @param array $changed The glyph each cell of the edit gets, by `x,y`. + * @param array $choices + * @param array> $unresolved + */ + private function resolveRole(array $change, Closure $glyphAt, Closure $tileAt, array $changed, array $choices, array &$unresolved): ?PieceRole + { + $roles = $this->getRoles($change['new']); + if (count($roles) < 2) { + return $roles[0] ?? null; + } + $proven = []; + foreach ($roles as $role) { + $proof = $this->countNeighbourProof($role, $change['x'], $change['y'], $glyphAt, $tileAt, $changed); + if ($proof > 0) { + $proven[$proof][] = $role; + } + } + if ($proven !== []) { + krsort($proven); + $strongest = reset($proven); + if (count($strongest) === 1) { + return $strongest[0]; + } + } + if (array_key_exists($change['new'], $choices)) { + return $choices[$change['new']] === null ? null : $this->findRole($change['new'], $choices[$change['new']]); + } + $unresolved[$change['new']] = $roles; + + return null; + } + + /** + * How many of the other glyph cells of a role's piece its neighbours + * outside the edit hold, each with its glyph and its own tiles. + * + * @param array $changed + */ + private function countNeighbourProof(PieceRole $role, int $x, int $y, Closure $glyphAt, Closure $tileAt, array $changed): int + { + $proof = 0; + foreach ($this->roles as $glyph => $roles) { + foreach ($roles as $other) { + $offset = self::getOffset($role, $other); + if ($offset === null) { + continue; + } + [$otherX, $otherY] = [$x + $offset[0], $y + $offset[1]]; + if (isset($changed["{$otherX},{$otherY}"]) || $glyphAt($otherX, $otherY) !== (string) $glyph) { + continue; + } + $own = $other->getOwnEntries(); + if ($own !== [] && array_filter($own, static fn(string $entry, string $layer): bool => + $tileAt($layer, $otherX, $otherY) !== $entry, ARRAY_FILTER_USE_BOTH) === []) { + $proof++; + } + } + } + + return $proof; + } + + /** + * Where another cell of the same item piece lies from this role's cell, + * or null when the two roles are not cells of one item piece. + * + * @return array{int, int}|null Columns across and rows down. + */ + private static function getOffset(PieceRole $role, PieceRole $other): ?array + { + if ($role->cell === null || $other->cell === null || $role->pieceId !== $other->pieceId || $role->key === $other->key) { + return null; + } + + return [$other->cell[1] - $role->cell[1], $other->cell[0] - $role->cell[0]]; + } + + /** What a role draws, so roles that draw the same tiles count once. */ + private static function getSignature(PieceRole $role): string + { + $tiles = $role->tiles; + ksort($tiles); + + return json_encode($tiles, JSON_THROW_ON_ERROR); + } +} diff --git a/src/Canvas/LayerCanvas.php b/src/Canvas/LayerCanvas.php index bd08404..bf4ed92 100644 --- a/src/Canvas/LayerCanvas.php +++ b/src/Canvas/LayerCanvas.php @@ -291,7 +291,8 @@ private function selectFacadeBrush(string $path, int $index): void $this->setStatus('Create a gameplay buildings layer before selecting a facade brush.', StatusLevel::WARN); } - private function stampFacadeBrush(): void + /** @param array $choices The pieces the author chose for glyphs the facade could draw several ways. */ + private function stampFacadeBrush(array $choices = []): void { if ($this->facadeBrush === null) { return; @@ -317,7 +318,7 @@ private function stampFacadeBrush(): void 'symbol' => $cell['symbol'], 'style' => ['prefix' => $cell['prefix'], 'suffix' => $cell['suffix']]]; } } - $this->applyCanvasWrites($map, $writes, 'Facade stamp'); + $this->applyCanvasWrites($map, $writes, 'Facade stamp', $this->stampFacadeBrush(...), $choices); $this->renderCanvasArea(); } catch (\Throwable $error) { $this->setStatus($error->getMessage(), StatusLevel::WARN); diff --git a/src/Canvas/PieceRole.php b/src/Canvas/PieceRole.php new file mode 100644 index 0000000..9977365 --- /dev/null +++ b/src/Canvas/PieceRole.php @@ -0,0 +1,141 @@ +> $tiles Tile entries relative to the glyph cell, keyed by tile layer name; never `0`. + * @param array{int, int}|null $cell An item piece's cell, as row and column; null for a connected piece's shape. + */ + public function __construct(public string $pieceId, string $part, public string $label, public array $tiles, public ?array $cell = null) + { + $this->key = "{$pieceId}:{$part}"; + } + + /** + * The roles a piece's glyphs play, keyed by glyph. A connected piece has + * one per shape; an item piece one per glyph cell, each blank cell with + * tiles going to the glyph cell nearest it (the first in reading order + * on a tie). A piece with no glyphs, such as a rug, has none. + * + * @param list $excludedLayers Tile layers left out of every role. + * @return array> + */ + public static function readPiece(TilesetPiece $piece, array $excludedLayers = []): array + { + $excluded = array_flip($excludedLayers); + if ($piece->connects !== null) { + $roles = []; + foreach ($piece->shapes as $shape => $glyph) { + $tiles = []; + foreach (array_diff_key($piece->shapeTiles, $excluded) as $layer => $entries) { + if ($entries[$shape] !== (string) TileId::EMPTY) { + $tiles[$layer] = [['dx' => 0, 'dy' => 0, 'entry' => $entries[$shape]]]; + } + } + $roles[$glyph][] = new self($piece->id, $shape, $piece->name, $tiles); + } + + return $roles; + } + $glyphCells = $blankCells = []; + foreach ($piece->glyphs as $row => $symbols) { + foreach ($symbols as $column => $symbol) { + if ($symbol === ' ') { + $blankCells[] = [$row, $column]; + } else { + $glyphCells[] = [$row, $column]; + } + } + } + if ($glyphCells === []) { + return []; + } + $attached = []; + foreach ($blankCells as [$row, $column]) { + $nearest = null; + foreach ($glyphCells as $index => [$glyphRow, $glyphColumn]) { + $distance = abs($glyphRow - $row) + abs($glyphColumn - $column); + if ($nearest === null || $distance < $nearest[1]) { + $nearest = [$index, $distance]; + } + } + $attached[$nearest[0]][] = [$row, $column]; + } + $roles = []; + foreach ($glyphCells as $index => [$row, $column]) { + $tiles = []; + foreach (array_diff_key($piece->tiles, $excluded) as $layer => $entries) { + foreach ([[$row, $column], ...($attached[$index] ?? [])] as [$cellRow, $cellColumn]) { + if ($entries[$cellRow][$cellColumn] !== (string) TileId::EMPTY) { + $tiles[$layer][] = ['dx' => $cellColumn - $column, 'dy' => $cellRow - $row, 'entry' => $entries[$cellRow][$cellColumn]]; + } + } + } + $roles[$piece->glyphs[$row][$column]][] = new self($piece->id, "{$row}:{$column}", + self::describeCell($piece->name, $glyphCells, $row, $column), $tiles, [$row, $column]); + } + + return $roles; + } + + /** The tile entries of this role at the glyph cell itself, keyed by tile layer name. */ + public function getOwnEntries(): array + { + $own = []; + foreach ($this->tiles as $layer => $cells) { + foreach ($cells as $cell) { + if ($cell['dx'] === 0 && $cell['dy'] === 0) { + $own[$layer] = $cell['entry']; + } + } + } + + return $own; + } + + /** + * A piece's name, with the cell's place among the piece's glyph cells + * when it has more than one, such as `Large Bed (top left)`. + * + * @param list $glyphCells The piece's glyph cells, as row and column. + */ + private static function describeCell(string $name, array $glyphCells, int $row, int $column): string + { + if (count($glyphCells) === 1) { + return $name; + } + $place = static function (int $index, array $indexes, string $first, string $last): ?string { + [$low, $high] = [min($indexes), max($indexes)]; + $size = $high - $low + 1; + return match (true) { + $size === 1 => null, + $index === $low => $first, + $index === $high => $last, + $size === 3 => 'middle', + default => sprintf('%d of %d', $index - $low + 1, $size), + }; + }; + $vertical = $place($row, array_column($glyphCells, 0), 'top', 'bottom'); + $horizontal = $place($column, array_column($glyphCells, 1), 'left', 'right'); + + return sprintf('%s (%s)', $name, implode(' ', array_filter([$vertical, $horizontal]))); + } +} diff --git a/src/Editor.php b/src/Editor.php index a608c73..45b56dc 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -116,6 +116,7 @@ final class Editor use \Ichiloto\Editor\Canvas\LayerCanvas; use \Ichiloto\Editor\Canvas\PieceCanvas; use \Ichiloto\Editor\Canvas\LineInsertCanvas; + use \Ichiloto\Editor\Canvas\GlyphTileCanvas; use CutscenesWorkspace; use CutsceneOutlinePane; use CutscenePreviewPane; @@ -1165,16 +1166,28 @@ private function handleHistoryShortcut(string $input): bool } /** - * Records the in-flight mouse stroke as one undoable command. + * Records the in-flight mouse stroke as one undoable command, with the + * tiles that followed its glyphs. * * @return void */ private function finalizeActiveStroke(): void { $stroke = $this->activeStrokeCommand; + $tiles = $this->activeStrokeTiles; $this->activeStrokeCommand = null; + $this->activeStrokeTiles = null; - if ($stroke instanceof PaintStrokeCommand && $stroke->hasChanges()) { + if (! $stroke instanceof PaintStrokeCommand) { + return; + } + + if ($tiles !== null && $stroke->targets($tiles['map']) && $tiles['map']->getTileLayerSources() !== $tiles['sources']) { + $this->recordStrokeWithTiles($stroke->label, $tiles['map'], $stroke, $tiles['sources'], $tiles['map']->getTileLayerSources()); + return; + } + + if ($stroke->hasChanges()) { $this->recordCommand($stroke); } } @@ -4048,6 +4061,7 @@ private function applyCharacterPaletteSelection(): void return; } + $this->paintPieceRole = null; $this->selectedPaintSymbol = $symbol; $this->replaceCurrentSymbol($symbol); $this->isCharacterMapOpen = false; @@ -4272,6 +4286,8 @@ private function handleTypedSymbolInput(string $input): void */ private function adoptPaintSymbol(string $symbol): void { + // A typed glyph is chosen afresh, so it asks again which piece it is. + $this->paintPieceRole = null; $this->selectedPaintSymbol = $symbol; if ($this->canvasTool !== CanvasTool::BRUSH) { @@ -4316,9 +4332,10 @@ private function handleEraseInput(string $input): bool * Replaces the current symbol on the active editing layer. * * @param string $symbol The replacement symbol. + * @param array|null $choices The pieces chosen for the glyph; null for the brush's own answer. * @return void */ - private function replaceCurrentSymbol(string $symbol): void + private function replaceCurrentSymbol(string $symbol, ?array $choices = null): void { if (! $this->workspace instanceof ProjectWorkspace) { return; @@ -4334,13 +4351,22 @@ private function replaceCurrentSymbol(string $symbol): void // The brush footprint commits as one stroke, so a wide dab is still // a single undo step (width 1 is byte-for-byte the historic path). - $this->paintCanvasCells( + $changed = $this->paintCanvasCells( $selectedMap, ToolGeometry::brush($this->cursorX, $this->cursorY, $this->canvasBrushSize), $symbol, $isEventLayer ? 'Event edit' : 'Tile edit', + function (array $choices) use ($symbol): void { + $this->replaceCurrentSymbol($symbol, $choices); + $this->renderCanvasArea(); + }, + $choices, ); + if ($changed === null) { + return; + } + $this->statusMessage = sprintf( '%s mode updated (%d, %d).', ucfirst($this->editingMode), @@ -4649,6 +4675,22 @@ private function paintCanvasStroke( $this->interpolatePoints($start['x'], $start['y'], $targetX, $targetY), $this->canvasBrushSize, ); + $retry = function (array $choices) use ($selectedMap, $symbol, $targetX, $targetY, $button, $paintEvents): void { + $this->rememberPaintPieceRole($symbol, $choices); + $this->activeMousePaintButton = null; + $this->lastMousePaintPoint = null; + $this->paintCanvasStroke($selectedMap, $symbol, $targetX, $targetY, $button, $paintEvents); + $this->renderCanvasArea(); + }; + + if (! $this->followStrokeWithTiles($selectedMap, array_map( + static fn(array $point): array => ['x' => $point['x'], 'y' => $point['y'], 'symbol' => $symbol], + $points, + ), $retry)) { + $this->activeMousePaintButton = null; + $this->lastMousePaintPoint = null; + return; + } foreach ($points as $point) { if (! $selectedMap->hasLayerCell($this->getActiveCanvasLayer(), $point['x'], $point['y'])) { @@ -4866,62 +4908,20 @@ private function writeCanvasSymbol(ProjectMap $map, int $x, int $y, string $symb * * This is the single commit path behind every canvas tool: a brush dab, * a line, a rectangle, a flood fill, a cut, and a paste all land as one - * PaintStrokeCommand, so each undoes in exactly one Ctrl+Z. + * undo step with the tiles that follow their glyphs, so each undoes in + * exactly one Ctrl+Z. * * @param ProjectMap $map The target map. * @param array $writes The cells to write. * @param string $label The undo/status label. - * @return int The number of cells that actually changed. + * @param (Closure(array): void)|null $retry Runs the edit again once the author chooses a glyph's piece. + * @param array $choices The pieces already chosen for glyphs. + * @return int|null The number of cells that actually changed, or null when nothing changed: refused, or waiting on a choice. */ - private function applyCanvasWrites(ProjectMap $map, array $writes, string $label): int + private function applyCanvasWrites(ProjectMap $map, array $writes, string $label, ?Closure $retry = null, array $choices = []): ?int { - if ($writes === []) { - return 0; - } - - $this->finalizeActiveStroke(); - [$stroke, $changed] = $this->writeCanvasCells($map, $this->getActiveCanvasLayer(), $writes, $label); - - if ($stroke->hasChanges()) { - $this->recordCommand($stroke); - } - - return $changed; - } - - /** - * Writes cells onto the active layer and the tile cells that move with - * them as one undo step. A tile layer that cannot take its cells refuses - * the whole change. - * - * @param array $writes The cells to write. - * @param array> $tiles The tile entry for each cell, keyed by tile layer name. - * @return int|null The number of glyph cells that changed, or null when nothing was changed. - */ - private function applyCanvasWritesWithTiles(ProjectMap $map, array $writes, array $tiles, string $label): ?int - { - if ($tiles === []) { - return $this->applyCanvasWrites($map, $writes, $label); - } - - $this->finalizeActiveStroke(); - $tilesBefore = $map->getTileLayerSources(); - - try { - $map->writeTileCells($tiles); - } catch (MapSourceRefusal $refusal) { - $this->setStatus($refusal->getMessage(), StatusLevel::WARN); - return null; - } - - $tilesAfter = $map->getTileLayerSources(); - [$stroke, $changed] = $this->writeCanvasCells($map, $this->getActiveCanvasLayer(), $writes, $label); - - if ($stroke->hasChanges() || $tilesAfter !== $tilesBefore) { - $this->recordStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter); - } - - return $changed; + // Tiles follow the glyphs (GlyphTileCanvas), in the same undo step. + return $this->commitCanvasWrites($map, $writes, $label, retry: $retry, choices: $choices); } /** @@ -5013,21 +5013,31 @@ private function resolvePaintStyle(string $symbol, ?string $colorDirective, arra * @param array $cells The cells to paint. * @param string $symbol The symbol to paint. * @param string $label The undo/status label. - * @return int The number of cells that actually changed. + * @param (Closure(array): void)|null $retry Paints again once the author chooses the glyph's piece. + * @param array|null $choices The pieces chosen for glyphs; null for the brush's own answer. + * @return int|null The number of cells that actually changed, or null when nothing changed: refused, or waiting on a choice. */ - private function paintCanvasCells(ProjectMap $map, array $cells, string $symbol, string $label): int + private function paintCanvasCells(ProjectMap $map, array $cells, string $symbol, string $label, ?Closure $retry = null, + ?array $choices = null): ?int { // Glyph paints carry the brush colour; the space rule and the event - // layer's lack of styling are resolved at the commit path. + // layer's lack of styling are resolved at the commit path. A glyph + // painted over itself may draw another piece, so it repaints. $color = $this->selectedPaintColor; - return $this->applyCanvasWrites( + return $this->commitCanvasWrites( $map, array_map( static fn(array $cell): array => ['x' => $cell['x'], 'y' => $cell['y'], 'symbol' => $symbol, 'color' => $color], $cells, ), $label, + retry: $retry === null ? null : function (array $choices) use ($symbol, $retry): void { + $this->rememberPaintPieceRole($symbol, $choices); + $retry($choices); + }, + choices: $choices ?? $this->getPaintPieceChoices($symbol), + repaint: true, ); } @@ -5170,21 +5180,25 @@ private function applyCanvasToolAtCursor(): void $cells = ToolGeometry::expandByBrush($cells, $this->canvasBrushSize); } - $changed = $this->paintCanvasCells( - $selectedMap, - $cells, - $this->selectedPaintSymbol, - $this->canvasTool->commandLabel(), - ); + $tool = $this->canvasTool; + $paint = function (?array $choices = null) use ($selectedMap, $cells, $tool, &$paint): void { + $changed = $this->paintCanvasCells($selectedMap, $cells, $this->selectedPaintSymbol, $tool->commandLabel(), $paint, $choices); - $this->setStatus(sprintf( - '%s: %d cell%s painted with %s.', - $this->canvasTool->label(), - $changed, - $changed === 1 ? '' : 's', - $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, - )); - $this->renderCanvasArea(); + if ($changed === null) { + $this->renderCanvasArea(); + return; + } + + $this->setStatus(sprintf( + '%s: %d cell%s painted with %s.', + $tool->label(), + $changed, + $changed === 1 ? '' : 's', + $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, + )); + $this->renderCanvasArea(); + }; + $paint(); } /** @@ -5264,14 +5278,23 @@ private function floodFillFromCursor(): void $this->cursorX, $this->cursorY, ); - $changed = $this->paintCanvasCells($selectedMap, $cells, $this->selectedPaintSymbol, 'flood fill'); - $this->setStatus(sprintf( - 'Flood filled %d cell%s with %s.', - $changed, - $changed === 1 ? '' : 's', - $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, - )); - $this->renderCanvasArea(); + $fill = function (?array $choices = null) use ($selectedMap, $cells, &$fill): void { + $changed = $this->paintCanvasCells($selectedMap, $cells, $this->selectedPaintSymbol, 'flood fill', $fill, $choices); + + if ($changed === null) { + $this->renderCanvasArea(); + return; + } + + $this->setStatus(sprintf( + 'Flood filled %d cell%s with %s.', + $changed, + $changed === 1 ? '' : 's', + $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, + )); + $this->renderCanvasArea(); + }; + $fill(); } /** @@ -5291,17 +5314,22 @@ private function pickSymbolUnderCursor(): void // On the tile layer the eyedropper picks the colour with the glyph: // an uncoloured cell loads an uncoloured brush. + // It picks up the piece the glyph draws there too, so painting it + // again draws the same piece. $pickedColor = null; + $pickedPiece = ''; + $this->paintPieceRole = null; if ($this->editingMode !== self::MODE_NPC) { $pickedColor = $selectedMap->getLayerColor($this->getActiveCanvasLayer(), $this->cursorX, $this->cursorY); $this->selectedPaintColor = $pickedColor ?? ''; + $pickedPiece = $this->pickPaintPieceRole($selectedMap, $this->cursorX, $this->cursorY); } $this->setStatus(sprintf( 'Picked up %s%s from (%d, %d).', $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, - $pickedColor === null ? '' : ' (' . $pickedColor . ')', + implode('', array_map(static fn(string $detail): string => " ({$detail})", array_filter([$pickedPiece, $pickedColor ?? '']))), $this->cursorX, $this->cursorY, )); @@ -5356,14 +5384,14 @@ private function cutCanvasSelection(): void ), $this->clipboard->projectTiles($selection['x'], $selection['y'], $selectedMap->getWidth(), $selectedMap->getHeight()), ); - $changed = $this->applyCanvasWritesWithTiles( + $changed = $this->commitCanvasWrites( $selectedMap, array_map( fn(array $cell): array => ['x' => $cell['x'], 'y' => $cell['y'], 'symbol' => ' ', 'color' => $this->selectedPaintColor], $cells, ), - $clears, 'cut selection', + $clears, ); if ($changed === null) { @@ -5440,9 +5468,10 @@ private function captureCanvasSelection(): int /** * Stamps the clipboard at the cursor as one undoable stroke. * + * @param array $choices The pieces the author chose for glyphs the block could draw several ways. * @return void */ - private function pasteCanvasClipboard(): void + private function pasteCanvasClipboard(array $choices = []): void { $selectedMap = $this->getSelectedMap(); @@ -5481,7 +5510,8 @@ private function pasteCanvasClipboard(): void $this->clipboard->projectTiles($this->cursorX, $this->cursorY, $selectedMap->getWidth(), $selectedMap->getHeight()), array_flip($selectedMap->getTileLayersMovingWith($this->getActiveCanvasLayer())), ); - $changed = $this->applyCanvasWritesWithTiles($selectedMap, $writes, $tiles, 'pasted selection'); + $changed = $this->commitCanvasWrites($selectedMap, $writes, 'pasted selection', $tiles, + $this->pasteCanvasClipboard(...), $choices); if ($changed === null) { $this->renderCanvasArea(); @@ -7925,7 +7955,8 @@ private function handleEventOptionDialogInput(string $input, string $normalizedI } if ($input === chr(27)) { - $this->closeEventOptionDialog('Selection cancelled.'); + $this->closeEventOptionDialog(is_array($this->optionDialogField['glyphPiece'] ?? null) + ? 'Nothing was painted; the map is as it was.' : 'Selection cancelled.'); return; } @@ -7987,6 +8018,11 @@ private function applySelectedEventOption(): void return; } + if (is_array($field['glyphPiece'] ?? null)) { + $this->chooseGlyphPiece($field['glyphPiece'], (string) $selectedEntry['value']); + return; + } + if (($field['lineInsertion'] ?? null) instanceof LineInsertionPlan) { if ($selectedEntry['value'] === 'preview') { return; } $this->confirmLineInsertion($field['lineInsertion'], $selectedEntry['value'] === 'insert'); diff --git a/tests/Unit/TilesFollowGlyphsTest.php b/tests/Unit/TilesFollowGlyphsTest.php new file mode 100644 index 0000000..d3d97d5 --- /dev/null +++ b/tests/Unit/TilesFollowGlyphsTest.php @@ -0,0 +1,175 @@ + ['name' => 'Chest', 'layer' => 'buildings', 'glyphs' => ['m'], 'tiles' => ['furniture' => ['60']]], + 'chair-facing-south' => ['name' => 'Chair facing south', 'layer' => 'buildings', 'glyphs' => ['-'], 'tiles' => ['furniture' => ['50']]], + 'chair-facing-north' => ['name' => 'Chair facing north', 'layer' => 'buildings', 'glyphs' => ['-'], 'tiles' => ['furniture' => ['51']]], + 'table' => ['name' => 'Dining table', 'layer' => 'buildings', 'glyphs' => ['##'], 'tiles' => ['furniture' => ['40 41']]], + // The sofa's back rises into the row above its seat. + 'sofa' => ['name' => 'Sofa', 'layer' => 'buildings', 'glyphs' => [' ', '##'], 'tiles' => ['furniture' => ['70 71', '72 73']]], + ]); + [$editor, $map] = layeredCanvasEditor($root); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + + return [$editor, $map]; +} + +/** @return list> The furniture layer's entries, all `0` while the map has none. */ +function readFurnitureRows(ProjectMap $map): array +{ + $source = $map->getTileLayerSources()[$map->directory . '/graphics/03.furniture.tiles.php'] ?? null; + + return $source === null ? [['0', '0', '0', '0'], ['0', '0', '0', '0']] + : TileLayerSource::readLayer($source, '03.furniture.tiles.php')->getEntries(); +} + +function typeCanvasGlyph(Editor $editor, string $glyph, int $x, int $y): void +{ + setEditorProperty($editor, 'cursorX', $x); + setEditorProperty($editor, 'cursorY', $y); + callEditorMethod($editor, 'adoptPaintSymbol', $glyph); +} + +function choosePieceFor(Editor $editor, string $label): void +{ + $entries = getEditorProperty($editor, 'eventOptionDialogEntries'); + setEditorProperty($editor, 'selectedEventOptionIndex', array_search($label, array_column($entries, 'label'), true)); + callEditorMethod($editor, 'applySelectedEventOption'); +} + +it('stamps a typed glyph\'s tiles and clears them when it is erased, one undo step each, leaving other tiles alone', function () { + [$editor, $map] = createFollowingTilesEditor(); + $others = [$map->directory . '/graphics/01.floor.tiles.php', $map->directory . '/graphics/02.decor.tiles.php']; + $before = array_intersect_key($map->getTileLayerSources(), array_flip($others)); + + typeCanvasGlyph($editor, 'm', 0, 0); + expect($map->getLayerSymbol('map:4', 0, 0))->toBe('m') + ->and(readFurnitureRows($map))->toBe([['60', '0', '0', '0'], ['0', '0', '0', '0']]); + + typeCanvasGlyph($editor, ' ', 0, 0); + expect(readFurnitureRows($map))->toBe([['0', '0', '0', '0'], ['0', '0', '0', '0']]) + ->and(array_intersect_key($map->getTileLayerSources(), array_flip($others)))->toBe($before); + + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->getLayerSymbol('map:4', 0, 0))->toBe('m') + ->and(readFurnitureRows($map)[0][0])->toBe('60'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->getLayerSymbol('map:4', 0, 0))->toBe(' ') + ->and(readFurnitureRows($map)[0][0])->toBe('0'); +}); + +it('asks which piece a shared glyph is, and the brush remembers the answer until a glyph is typed again', function () { + [$editor, $map] = createFollowingTilesEditor(); + + typeCanvasGlyph($editor, '-', 2, 0); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue() + ->and(array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label')) + ->toBe(['Chair facing south', 'Chair facing north', 'No tiles']) + ->and($map->getLayerSymbol('map:4', 2, 0))->toBe(' '); + + choosePieceFor($editor, 'Chair facing south'); + expect($map->getLayerSymbol('map:4', 2, 0))->toBe('-') + ->and(readFurnitureRows($map)[0])->toBe(['0', '0', '50', '0']); + + // Painting the brush again draws the same chair without asking. + setEditorProperty($editor, 'cursorX', 3); + callEditorMethod($editor, 'applySelectedPaintSymbol'); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and(readFurnitureRows($map)[0])->toBe(['0', '0', '50', '50']); + + // A chair moved by erasing and typing takes its tiles along. + typeCanvasGlyph($editor, ' ', 2, 0); + typeCanvasGlyph($editor, '-', 0, 1); + choosePieceFor($editor, 'Chair facing north'); + expect(readFurnitureRows($map))->toBe([['0', '0', '0', '50'], ['51', '0', '0', '0']]); +}); + +it('leaves the map as it was when the choice is dismissed', function () { + [$editor, $map] = createFollowingTilesEditor(); + $sources = $map->getTileLayerSources(); + + typeCanvasGlyph($editor, '-', 2, 0); + callEditorMethod($editor, 'dispatchInput', "\033"); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and($map->getLayerSymbol('map:4', 2, 0))->toBe(' ') + ->and($map->getTileLayerSources())->toBe($sources) + ->and(getEditorProperty($editor, 'statusMessage'))->toBe('Nothing was painted; the map is as it was.'); +}); + +it('gives a glyph the part of a piece its neighbours prove, without asking', function () { + [$editor, $map] = createFollowingTilesEditor(); + + typeCanvasGlyph($editor, '#', 2, 0); + choosePieceFor($editor, 'Dining table (left)'); + typeCanvasGlyph($editor, '#', 3, 0); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and(readFurnitureRows($map)[0])->toBe(['0', '0', '40', '41']); +}); + +it('carries the tiles of a piece\'s blank cells with the glyph nearest them', function () { + [$editor, $map] = createFollowingTilesEditor(); + + typeCanvasGlyph($editor, '#', 0, 1); + choosePieceFor($editor, 'Sofa (left)'); + expect(readFurnitureRows($map))->toBe([['70', '0', '0', '0'], ['72', '0', '0', '0']]); + + typeCanvasGlyph($editor, ' ', 0, 1); + expect(readFurnitureRows($map))->toBe([['0', '0', '0', '0'], ['0', '0', '0', '0']]); +}); + +it('picks up the piece a glyph draws with the eyedropper', function () { + [$editor, $map] = createFollowingTilesEditor(); + typeCanvasGlyph($editor, '-', 2, 0); + choosePieceFor($editor, 'Chair facing north'); + + callEditorMethod($editor, 'pickSymbolUnderCursor'); + expect(getEditorProperty($editor, 'statusMessage'))->toBe('Picked up - (Chair facing north) from (2, 0).'); + + setEditorProperty($editor, 'cursorX', 3); + setEditorProperty($editor, 'cursorY', 1); + callEditorMethod($editor, 'applySelectedPaintSymbol'); + expect(readFurnitureRows($map)[1])->toBe(['0', '0', '0', '51']); +}); + +it('keeps a recoloured glyph\'s tiles', function () { + [$editor, $map] = createFollowingTilesEditor(); + typeCanvasGlyph($editor, 'm', 0, 0); + $sources = $map->getTileLayerSources(); + + callEditorMethod($editor, 'applyCanvasWrites', $map, [['x' => 0, 'y' => 0, 'symbol' => 'm', 'color' => 'red']], 'Recolour'); + expect($map->getTileLayerSources())->toBe($sources); +}); + +it('records a mouse stroke\'s tiles with its glyphs as one undo step', function () { + [$editor, $map] = createFollowingTilesEditor(); + setEditorProperty($editor, 'selectedPaintSymbol', 'm'); + $sources = $map->getTileLayerSources(); + + callEditorMethod($editor, 'paintCanvasStroke', $map, 'm', 0, 1, MouseButton::LEFT_BUTTON); + callEditorMethod($editor, 'paintCanvasStroke', $map, 'm', 2, 1, MouseButton::LEFT_BUTTON); + callEditorMethod($editor, 'finalizeActiveStroke'); + expect(readFurnitureRows($map)[1])->toBe(['60', '60', '60', '0']); + + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->getTileLayerSources())->toBe($sources) + ->and($map->getLayerSymbol('map:4', 1, 1))->toBe('x'); +}); From 933e43ace3fe87823096c6542caa6f5e136e4b71 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 29 Sep 2026 20:35:37 +0200 Subject: [PATCH 070/344] docs(maps): group tilesets by the kind of setting they draw A tileset belongs to a kind of setting, such as interiors, and every map of that kind names it, as RPG Maker groups its own. Its sheets are sorted by kind, and new art goes into free cells because a tile's identity is its place on its sheet. The examples now name an interior tileset. --- docs/manual.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 902a829..876c648 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -371,8 +371,8 @@ its own edges. ### Map Graphics -A map may name a tileset in its data file (`'tileset' => 'home'`, read from -`assets/Data/Tilesets/home.php`) and keep RPG Maker tile layers in +A map may name a tileset in its data file (`'tileset' => 'interior'`, read from +`assets/Data/Tilesets/interior.php`) and keep RPG Maker tile layers in `graphics/NN.name.tiles.php`. A tile layer holds one tile identity per map cell, so each of its rows is exactly as wide as the map's row in terminal columns. The TUI never displays tiles or paints single tiles; painting them From ae6228010e70cd70b3c8f2ac3f8b85e1e89ffe16 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 30 Sep 2026 10:45:07 +0200 Subject: [PATCH 071/344] feat(npcs): author and validate an NPC's direction fix The NPC Inspector shows a Direction Fix row in the Movement group, right after Movement. It is a Database boolean that reads as false when unset, cycles with Left/Right, stores 'directionFix' => true through the existing source-preserving map data write and undo history, and removes the key again when set back to false, the engine's default. directionFix joins ProjectNpc::KNOWN_FIELDS, so it is no longer listed as a preserved field the game does not read. The project validator reports a directionFix that is not a bool as an error, alongside the movement checks. RecordField::boolean() gains an optional displayDefault; existing booleans are unchanged. The manual's NPC Inspector section documents the row and the engine's talk turn it controls. --- docs/manual.md | 12 ++++- src/Database/RecordField.php | 4 +- src/Database/RecordSchemaCatalog.php | 2 + src/Editor.php | 2 +- src/Field/ProjectNpc.php | 4 +- src/Validation/ProjectValidator.php | 19 +++++-- tests/Unit/NpcAuthoringTest.php | 81 ++++++++++++++++++++++++++++ 7 files changed, 115 insertions(+), 9 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 876c648..2bc5e67 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -599,13 +599,13 @@ category uses - pickers, condition lines, world-write rows, command frames, | Identity | `Id` (read-only), `Name` | | Placement | `X`, `Y` (the canvas moves it too) | | Appearance | `Sprite`, `Facing North/South/East/West`, `Graphical Sprites` | -| Movement | `Movement` (`fixed` / `wander`), and while wandering `Wander X/Y/Width/Height` | +| Movement | `Movement` (`fixed` / `wander`), `Direction Fix` (`false` / `true`), and while wandering `Wander X/Y/Width/Height` | | Visibility | `Visible When` - a condition line | | Interaction | `Script` (a command frame), then one `Dialogue variant N` heading per variant with its rows `When`, `Then Set`, `Script Commands`, `Line 1 Speaker`, `Line 1 Text`, … | | Completion Writes | `After Talking` - world-write rows | Rows read as the game will read them: an unset `Movement` shows `fixed`, an -unset `Sprite` shows `@`. Fields the game does not read are listed in a +unset `Direction Fix` shows `false`, an unset `Sprite` shows `@`. Fields the game does not read are listed in a `Preserved fields` row and written back untouched. Each dialogue variant is a heading (`Dialogue variant 2 · when switch:gate_open` once it has a condition) with short row labels under it, and long lines wrap, so what a @@ -624,6 +624,14 @@ character says is read in the pane rather than in the edit buffer. NPC. Omitting every bound leaves the game's unbounded wander. Patrol routes, pathfinding and followers are not engine features, so the editor does not offer them; scripted movement is a `move_route` command in an event script. +- **Direction fix.** When the player talks to an NPC, the game turns it to + face the player before it speaks, and it stays that way until something + else turns it. `Direction Fix` (RPG Maker's option of the same name) set to + `true` keeps its heading instead, for a clerk behind a counter or a guard + watching a gate. It stores `'directionFix' => true`; setting it back to + `false` removes the key, the game's default. It affects only the talk turn: + wandering and `move_route` still turn the NPC. The validator reports a + `directionFix` that is not `true` or `false`. - **Directional sprites.** Optional glyphs shown when the NPC turns; the base sprite covers a heading you leave blank. Resting the Inspector cursor on a `Facing …` row previews that glyph on the canvas in the NPC's place. diff --git a/src/Database/RecordField.php b/src/Database/RecordField.php index b980065..da49499 100644 --- a/src/Database/RecordField.php +++ b/src/Database/RecordField.php @@ -135,9 +135,10 @@ public function asReadOnly(): self * @param string $key The payload key. * @param string $label The settings-pane label. * @param bool $removeWhenEmpty Whether `false` drops the key (matching engine defaults). + * @param string|null $displayDefault What an absent key reads as, such as the engine's `false`. * @return self */ - public static function boolean(string $key, string $label, bool $removeWhenEmpty = true): self + public static function boolean(string $key, string $label, bool $removeWhenEmpty = true, ?string $displayDefault = null): self { return new self( $key, @@ -145,6 +146,7 @@ public static function boolean(string $key, string $label, bool $removeWhenEmpty InputControlType::BOOLEAN, ['false', 'true'], $removeWhenEmpty, + displayDefault: $displayDefault, ); } } diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 68b7169..9128175 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -1154,6 +1154,8 @@ public static function mapNpcs(): RecordSchema new RecordField('sprites.west', 'Facing West', removeWhenEmpty: true), // Movement new RecordField('movement', 'Movement', options: ProjectNpc::MOVEMENTS, displayDefault: 'fixed'), + // RPG Maker's Direction Fix: false (the default) is not written. + RecordField::boolean('directionFix', 'Direction Fix', displayDefault: 'false'), new RecordField('wanderArea.x', 'Wander X', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField('wanderArea.y', 'Wander Y', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField('wanderArea.width', 'Wander Width', InputControlType::INTEGER, removeWhenEmpty: true), diff --git a/src/Editor.php b/src/Editor.php index 45b56dc..c8ba5df 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -2155,7 +2155,7 @@ private function getNpcInspectorFields(): array 'Identity' => ['id', 'name'], 'Placement' => ['x', 'y'], 'Appearance' => ['sprite', 'sprites.north', 'sprites.south', 'sprites.east', 'sprites.west'], - 'Movement' => ['movement', 'wanderArea.x', 'wanderArea.y', 'wanderArea.width', 'wanderArea.height'], + 'Movement' => ['movement', 'directionFix', 'wanderArea.x', 'wanderArea.y', 'wanderArea.width', 'wanderArea.height'], 'Visibility' => ['conditions'], 'Interaction' => ['commandListScript'], 'Completion Writes' => ['sets'], diff --git a/src/Field/ProjectNpc.php b/src/Field/ProjectNpc.php index 4d05a87..f9fcef6 100644 --- a/src/Field/ProjectNpc.php +++ b/src/Field/ProjectNpc.php @@ -9,6 +9,8 @@ * * The runtime contract, verbatim from `NpcManager::configure()`: `name`, * `x`, `y` required; `sprite` (default `@`); `movement` `fixed`|`wander`; + * `directionFix` (bool, default false: true keeps the heading when the + * player talks to it instead of turning to face the player); * optional `wanderArea` {x,y,width,height}, unbounded when omitted; * `dialogue` (plain pages or conditional variants); `script` (event * commands, which take precedence over dialogue when non-empty); @@ -30,7 +32,7 @@ final class ProjectNpc * The fields the runtime reads, in the order a fresh entry is written. */ public const array KNOWN_FIELDS = [ - 'id', 'name', 'sprite', 'x', 'y', 'movement', 'wanderArea', 'sprites', + 'id', 'name', 'sprite', 'x', 'y', 'movement', 'directionFix', 'wanderArea', 'sprites', 'conditions', 'dialogue', 'script', 'sets', 'sprites2d', ]; diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index e9c2cc5..2f09ece 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -1903,8 +1903,10 @@ protected function checkEncounterTroopDuplicates(ProjectMap $map): array * `NpcManager::configure()` skips an entry silently when it is not an * array, has no name, or has no coordinates; ignores a wander area or * directional map that is not an array; treats any movement but `wander` - * as fixed; clamps wander dimensions to 1; and throws on a duplicate id. - * `Player::interact()` talks to the NPC on the faced tile, and + * as fixed; reads `directionFix` as set only when it is exactly true; + * clamps wander dimensions to 1; and throws on a duplicate id. + * `Player::interact()` turns the NPC on the faced tile toward the player + * unless its direction is fixed, then talks to it, and * `MapManager::canMoveTo()` refuses a tile an NPC stands on. Every check * here follows from one of those, with an error where the authored * content cannot happen and a warning where it can but probably not as @@ -2102,8 +2104,9 @@ protected function checkNpcSprite(array $entry, string $where, int $width): arra } /** - * Checks the movement mode and the wander area against how the game - * wanders: only into tiles inside the area, never off the map. + * Checks the movement mode, the direction fix and the wander area against + * how the game moves: wandering only into tiles inside the area, never off + * the map, and turning to the player unless the heading is fixed. * * @param array $entry The NPC entry. * @param string $where Where it lives. @@ -2133,6 +2136,14 @@ protected function checkNpcMovement(array $entry, string $where, int $width, int $wanders = $movement === 'wander'; + if (array_key_exists('directionFix', $entry) && ! is_bool($entry['directionFix'])) { + $issues[] = Issue::error( + $where, + sprintf('Its directionFix is %s, not true or false.', get_debug_type($entry['directionFix'])), + 'The game keeps its heading only for exactly true, so it turns to face the player. Write true or false.' + ); + } + if (! array_key_exists('wanderArea', $entry)) { return $issues; } diff --git a/tests/Unit/NpcAuthoringTest.php b/tests/Unit/NpcAuthoringTest.php index dba3916..b2bd24b 100644 --- a/tests/Unit/NpcAuthoringTest.php +++ b/tests/Unit/NpcAuthoringTest.php @@ -596,6 +596,87 @@ function npcIssueLines(string $root, ?Severity $severity = null): array expect(getEditorProperty($editor, 'history')->count())->toBe($before); }); +it('shows Direction Fix beside Movement, round-trips it source-preserved, and undoes it', function () { + $entry = ['id' => 'clerk', 'name' => 'Clerk', 'sprite' => 'C', 'x' => 3, 'y' => 2, 'movement' => 'fixed', 'dialogue' => [['text' => 'Welcome.']], 'authorNote' => 'keep']; + [$root, $path] = npcProject([$entry]); + $source = file_get_contents($path); + $editor = npcEditor($root); + $map = npcMap($editor); + callEditorMethod($editor, 'selectNpc', 0); + + // Grouped under Movement, right after the movement mode, and read as the game's default. + $rows = callEditorMethod($editor, 'getInspectorFields'); + $ids = array_map(static fn(array $f): string => (string) ($f['field'] ?? ''), $rows); + $labels = array_map(static fn(array $f): string => trim((string) ($f['label'] ?? '')), $rows); + $movement = array_search('movement', $ids, true); + expect($ids[$movement + 1])->toBe('directionFix') + ->and(array_slice($labels, 0, $movement))->toContain('Movement') + ->and(npcField($editor, 'directionFix')['label'])->toContain('Direction Fix') + ->and(npcField($editor, 'directionFix')['value'])->toBe('false') + ->and(npcField($editor, 'directionFix')['editable'] ?? true)->toBeTrue(); + + // Right cycles the option in place, as every Database boolean does. + setEditorProperty($editor, 'focusedPane', 'inspector'); + restNpcCursorOn($editor, 'directionFix'); + callEditorMethod($editor, 'dispatchInput', "\033[C"); + expect($map->getNpcs()->get(0)?->toArray()['directionFix'] ?? null)->toBeTrue() + ->and(npcField($editor, 'directionFix')['value'])->toBe('true') + ->and($map->getNpcs()->get(0)?->getUnknownFields())->toBe(['authorNote']); + + callEditorMethod($editor, 'saveSelectedMap'); + $edited = file_get_contents($path); + expect(npcsOnDisk($path)[0])->toBe($entry + ['directionFix' => true]) + ->and($edited)->toContain("'directionFix' => true"); + + // A fresh editor reads it back, and the engine loads it as direction-fixed. + $reloaded = npcEditor($root); + callEditorMethod($reloaded, 'selectNpc', 0); + expect(npcField($reloaded, 'directionFix')['value'])->toBe('true'); + $scene = new class extends \Ichiloto\Engine\Scenes\Game\GameScene { + public function __construct() + { + $this->currentMapId = 'test-map'; + $this->gameState = new \Ichiloto\Engine\Core\GameState(); + $this->party = new \Ichiloto\Engine\Entities\Party(); + } + }; + $manager = new \Ichiloto\Engine\Field\NpcManager($scene); + $manager->configure(npcsOnDisk($path)); + expect($manager->findById('clerk')?->directionFix)->toBeTrue(); + + // Undo restores the file byte for byte; redo brings the flag back. + callEditorMethod($editor, 'performUndo'); + callEditorMethod($editor, 'saveSelectedMap'); + expect(file_get_contents($path))->toBe($source); + callEditorMethod($editor, 'performRedo'); + callEditorMethod($editor, 'saveSelectedMap'); + expect(file_get_contents($path))->toBe($edited); + + // False is the game's default, so it removes the key rather than writing false. + setNpcField($editor, 'directionFix', 'false'); + expect($map->getNpcs()->get(0)?->toArray())->not->toHaveKey('directionFix'); + callEditorMethod($editor, 'saveSelectedMap'); + expect(file_get_contents($path))->toBe($source); +}); + +it('reports a directionFix that is not a bool, and accepts true and false', function () { + [$root] = npcProject([ + ['id' => 'word', 'name' => 'Word', 'x' => 1, 'y' => 1, 'directionFix' => 'yes'], + ['id' => 'number', 'name' => 'Number', 'x' => 2, 'y' => 1, 'directionFix' => 1], + ['id' => 'fixed', 'name' => 'Fixed', 'x' => 3, 'y' => 1, 'directionFix' => true], + ['id' => 'turns', 'name' => 'Turns', 'x' => 4, 'y' => 1, 'directionFix' => false], + ]); + + $errors = implode("\n", npcIssueLines($root, Severity::ERROR)); + $all = implode("\n", npcIssueLines($root)); + + expect($errors)->toContain('NPC Word: Its directionFix is string, not true or false.') + ->toContain('NPC Number: Its directionFix is int, not true or false.') + ->and($all)->not->toContain('NPC Fixed') + ->and($all)->not->toContain('NPC Turns') + ->and($all)->not->toContain('directionFix, which the game does not read'); +}); + it('re-derives an NPC\'s id from its new name while nothing refers to it, in the same undo step', function () { [$root] = npcProject([['id' => 'gate-guard', 'name' => 'Gate Guard', 'sprite' => 'G', 'x' => 6, 'y' => 3]]); $editor = npcEditor($root); From e239a4e11f1a3bf011fd2a544e7e6abf7120ed92 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 30 Sep 2026 10:58:11 +0200 Subject: [PATCH 072/344] docs(manual): describe the NPC turn-back after a conversation The Direction fix bullet now says the game turns a talked-to NPC back to its previous heading when the conversation ends, unless the conversation's own script turned, moved or staged it, and that Direction Fix keeps the heading throughout. It no longer says the NPC stays facing the player. --- docs/manual.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 2bc5e67..5275948 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -625,13 +625,14 @@ character says is read in the pane rather than in the edit buffer. pathfinding and followers are not engine features, so the editor does not offer them; scripted movement is a `move_route` command in an event script. - **Direction fix.** When the player talks to an NPC, the game turns it to - face the player before it speaks, and it stays that way until something - else turns it. `Direction Fix` (RPG Maker's option of the same name) set to - `true` keeps its heading instead, for a clerk behind a counter or a guard - watching a gate. It stores `'directionFix' => true`; setting it back to - `false` removes the key, the game's default. It affects only the talk turn: - wandering and `move_route` still turn the NPC. The validator reports a - `directionFix` that is not `true` or `false`. + face the player before it speaks and turns it back to its previous heading + when the conversation ends, unless the conversation's own script turned, + moved or staged it. `Direction Fix` (RPG Maker's option of the same name) + set to `true` keeps its heading throughout instead, for a clerk behind a + counter or a guard watching a gate. It stores `'directionFix' => true`; + setting it back to `false` removes the key, the game's default. It affects + only the talk turn: wandering and `move_route` still turn the NPC. The + validator reports a `directionFix` that is not `true` or `false`. - **Directional sprites.** Optional glyphs shown when the NPC turns; the base sprite covers a heading you leave blank. Resting the Inspector cursor on a `Facing …` row previews that glyph on the canvas in the NPC's place. From 3d935ccfa938d4075ecbecb113c30723be41c0c0 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 30 Sep 2026 12:27:42 +0200 Subject: [PATCH 073/344] feat(pieces): offer the pieces of the layer being edited, and choose a tileset for a map without one The piece picker (P) now lists only the pieces whose glyphs go on the layer being edited, titled after it, so Fixtures shows furniture and not walls. From Event mode it offers the pieces of the layer Map mode edits and still returns to Map mode on a choice. When the layer has no pieces the status names the layers that do. A map that names no tileset now opens a tileset chooser on P, listing the project's tilesets with their ids and piece counts. Choosing one names it in the map's data, unsaved and undoable, and opens its pieces for the layer. Changed behaviour: the picker no longer lists every piece of the tileset, and its entries no longer repeat the layer, which the title now names; a map without a tileset opens the chooser instead of only a status warning. A piece whose layer the map lacks is no longer offered; the stamp still refuses one placed another way. --- docs/manual.md | 26 ++++++--- src/Canvas/PieceCanvas.php | 93 +++++++++++++++++++++++++++++-- src/Editor.php | 6 ++ tests/Unit/TilesetPiecesTest.php | 96 ++++++++++++++++++++++++++------ 4 files changed, 189 insertions(+), 32 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 5275948..242d454 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -280,15 +280,23 @@ told that existing saves are not migrated. A map offers the pieces of the tileset its data names (see [Map Graphics](#map-graphics)): whole items such as a bed or a table, so a map is built from items instead of single glyphs. In Normal mode on the canvas, `P` -opens the piece picker, one entry per piece with its footprint in cells, the -gameplay layer its glyphs go on and the tile layers it writes, for example -`1 x 2 · fixtures · tiles: furniture`, or `connected` in place of the -footprint for a [connected piece](#connected-pieces) such as a wall -(`connected · buildings · tiles: walls`). Move with the arrows or `j` / `k`, -press `/` to filter by name, `Enter` to choose and `Esc` to cancel. The command -palette offers the same picker as `Pieces: Choose a piece to place`. When the -map names no tileset, its tileset has no pieces, or the tileset cannot be -loaded, the status line says so and nothing opens. +opens the piece picker for the layer being edited: only the pieces whose glyphs +go on that layer, titled after it (`Fixtures piece`), one entry per piece with +its footprint in cells and the tile layers it writes, for example +`1 x 2 · tiles: furniture`, or `connected` in place of the footprint for a +[connected piece](#connected-pieces) such as a wall (`connected · tiles: +walls`). In Event mode it offers the pieces of the layer Map mode edits, and +choosing one returns to Map mode. Move with the arrows or `j` / `k`, press `/` +to filter by name, `Enter` to choose and `Esc` to cancel. The command palette +offers the same picker as `Pieces: Choose a piece to place`. + +When the map names no tileset, `P` opens the tileset chooser instead: one +entry per tileset in `assets/Data/Tilesets/`, with its id and how many pieces +it has. Choosing one names it in the map's data (`'tileset' => 'interior'`), +unsaved and undoable like any edit, and opens its pieces for the layer. When +the layer being edited has no pieces, the status line names the layers that +do; when the tileset has no pieces or cannot be loaded, or the project has no +tilesets, it says so and nothing opens. Choosing a piece starts placing it, in Map mode. The canvas previews the piece's glyphs in reverse video with its top-left cell at the cursor, and the diff --git a/src/Canvas/PieceCanvas.php b/src/Canvas/PieceCanvas.php index debc76f..953d825 100644 --- a/src/Canvas/PieceCanvas.php +++ b/src/Canvas/PieceCanvas.php @@ -37,8 +37,10 @@ trait PieceCanvas /** * Opens the piece picker: one entry per piece of the selected map's - * tileset, filterable by name. Enter starts placing the highlighted - * piece. Nothing opens when the map has no pieces to offer. + * tileset that goes on the layer being edited, filterable by name. Enter + * starts placing the highlighted piece. A map that names no tileset + * opens the tileset chooser instead; nothing opens when the layer has no + * pieces to offer, and the status says which layers do. */ private function openPiecePicker(): void { @@ -46,15 +48,29 @@ private function openPiecePicker(): void if (! $map instanceof ProjectMap || $map->getGridSourceIssue() !== null) { return; } + if ($map->getMapDataField(['tileset']) === null) { + $this->openTilesetChooser($map); + return; + } $pieces = $this->loadCanvasPieces($map); if ($pieces === null) { return; } + $layer = $this->findPieceCanvasLayer($map); + $offered = array_filter($pieces, static fn(TilesetPiece $piece): bool => $piece->layer === ($layer['name'] ?? null)); + if ($offered === []) { + $layers = array_values(array_unique(array_map(static fn(TilesetPiece $piece): string => MapLayers::formatLabel($piece->layer), $pieces))); + $this->setStatus(sprintf('%s has no pieces for the %s layer. It has pieces for %s; press L to switch layers.', + $map->loadTileset()?->name ?? 'The tileset', $layer === null ? 'current' : MapLayers::formatLabel($layer['name']), + implode(', ', $layers)), StatusLevel::WARN); + $this->renderFooter(); + return; + } $this->finalizeActiveStroke(); $this->optionDialogField = ['canvasPiece' => true]; $this->eventOptionDialogMarker = null; $this->eventOptionDialogPath = null; - $this->eventOptionDialogTitle = 'Piece'; + $this->eventOptionDialogTitle = sprintf('%s piece', MapLayers::formatLabel($layer['name'])); $this->eventOptionDialogEntries = array_map(static function (TilesetPiece $piece): array { $tileLayers = array_keys($piece->connects === null ? $piece->tiles : $piece->shapeTiles); return [ @@ -62,17 +78,84 @@ private function openPiecePicker(): void 'value' => $piece->id, 'description' => implode(' · ', array_filter([ $piece->connects === null ? sprintf('%d x %d', $piece->width, $piece->height) : 'connected', - MapLayers::formatLabel($piece->layer), $tileLayers === [] ? null : 'tiles: ' . implode(', ', $tileLayers), ])), ]; - }, array_values($pieces)); + }, array_values($offered)); $this->selectedEventOptionIndex = $this->resolveEventOptionSelectionIndex($this->piecePlacement['piece']->id ?? ''); $this->isEventOptionDialogOpen = true; $this->statusMessage = 'Choose a piece to place.'; $this->renderSelectionDependentArea(); } + /** + * The gameplay layer pieces go on: the layer Map mode edits, even from + * Event mode, so choosing a piece there returns to it. Null for a + * decoration layer, which takes no pieces. + * + * @return array|null + */ + private function findPieceCanvasLayer(ProjectMap $map): ?array + { + $selected = $this->getCanvasLayerState()['selected']; + $layers = array_values(array_filter($map->getLayers(), static fn(array $layer): bool => $layer['id'] !== MapLayers::EVENT)); + $layer = array_find($layers, static fn(array $candidate): bool => $candidate['id'] === $selected) + ?? array_find($layers, static fn(array $candidate): bool => $candidate['id'] === $map->getBaseLayerId()); + + return $layer === null || $layer['decoration'] ? null : $layer; + } + + /** + * Offers the project's tilesets for a map that names none. Choosing one + * names it in the map's data (saved with the map, undoable) and opens + * its pieces. + */ + private function openTilesetChooser(ProjectMap $map): void + { + $tilesets = []; + foreach (glob($map->getAssetRoot() . '/' . Tileset::DIRECTORY . '/*.php') ?: [] as $file) { + $id = basename($file, '.php'); + try { + $tileset = Tileset::load($map->getAssetRoot(), $id); + } catch (\Throwable) { + continue; + } + $count = count($tileset->pieces); + $tilesets[] = ['label' => $tileset->name, 'value' => $id, + 'description' => sprintf('%s · %d %s', $id, $count, $count === 1 ? 'piece' : 'pieces')]; + } + if ($tilesets === []) { + $this->setStatus(sprintf('This map names no tileset, and the project has none to choose. Add one to assets/%s/.', Tileset::DIRECTORY), StatusLevel::WARN); + $this->renderFooter(); + return; + } + $this->finalizeActiveStroke(); + $this->optionDialogField = ['canvasTileset' => true]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = 'Tileset'; + $this->eventOptionDialogEntries = $tilesets; + $this->selectedEventOptionIndex = 0; + $this->isEventOptionDialogOpen = true; + $this->setStatus('This map names no tileset. Choose the one it draws from; its pieces then open.'); + $this->renderSelectionDependentArea(); + } + + /** Names the chosen tileset in the map's data, then opens its pieces. */ + private function chooseMapTileset(string $id): void + { + $map = $this->getSelectedMap(); + if (! $map instanceof ProjectMap) { + return; + } + $this->applyMapDataValue($map, ['tileset'], $id, 'Tileset'); + $this->openPiecePicker(); + if ($this->isEventOptionDialogOpen) { + $this->setStatus(sprintf('This map now draws from the %s tileset; save to keep it. Choose a piece to place.', + $map->loadTileset()?->name ?? $id), StatusLevel::INFO); + } + } + /** * The pieces of the map's tileset, or null after saying in the status * why there are none to offer. diff --git a/src/Editor.php b/src/Editor.php index c8ba5df..d13744d 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -8012,6 +8012,12 @@ private function applySelectedEventOption(): void return; } + if (($field['canvasTileset'] ?? false) === true) { + $this->closeEventOptionDialog(); + $this->chooseMapTileset((string) $selectedEntry['value']); + return; + } + if (($field['canvasPiece'] ?? false) === true) { $this->closeEventOptionDialog(); $this->choosePiece((string) $selectedEntry['value']); diff --git a/tests/Unit/TilesetPiecesTest.php b/tests/Unit/TilesetPiecesTest.php index 0f015c7..8c326ff 100644 --- a/tests/Unit/TilesetPiecesTest.php +++ b/tests/Unit/TilesetPiecesTest.php @@ -46,25 +46,34 @@ function getCurrentToast(Editor $editor): ?Ichiloto\Editor\Status\Toast return getEditorProperty($editor, 'toasts')->current(); } -function choosePieceByKeys(Editor $editor, int $downs = 0): void +/** Selects the layer a test piece goes on, as L does. */ +function selectPieceLayer(Editor $editor, string $layerName): void { + $layer = array_find(callEditorMethod($editor, 'getSelectedMap')->getLayers(), static fn(array $layer): bool => $layer['name'] === $layerName); + callEditorMethod($editor, 'selectCanvasLayer', $layer['id']); +} + +/** Chooses a piece with the keys: its layer, P, down to its entry and Enter. */ +function choosePieceByKeys(Editor $editor, string $id = 'bed'): void +{ + selectPieceLayer($editor, buildTestPieces()[$id]['layer']); callEditorMethod($editor, 'dispatchInput', 'P'); + $downs = array_search($id, array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'value'), true); for ($i = 0; $i < $downs; $i++) { callEditorMethod($editor, 'dispatchInput', "\033[B"); } callEditorMethod($editor, 'dispatchInput', "\n"); } -it('lists the pieces of the map\'s tileset in the shared picker with P', function () { +it('lists the pieces for the layer being edited in the shared picker with P', function () { [$editor] = createPieceCanvasEditor(); + selectPieceLayer($editor, 'buildings'); callEditorMethod($editor, 'dispatchInput', 'P'); - expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Piece') + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Buildings piece') ->and(getEditorProperty($editor, 'eventOptionDialogEntries'))->toBe([ - ['label' => 'Bed', 'value' => 'bed', 'description' => '2 x 2 · Buildings · tiles: decor, furniture'], - ['label' => 'Lamp', 'value' => 'lamp', 'description' => '1 x 1 · Fixtures'], - ['label' => 'Rug', 'value' => 'rug', 'description' => '2 x 1 · Terrain · tiles: floor'], - ['label' => 'Wall', 'value' => 'wall', 'description' => 'connected · Buildings · tiles: walls'], + ['label' => 'Bed', 'value' => 'bed', 'description' => '2 x 2 · tiles: decor, furniture'], + ['label' => 'Wall', 'value' => 'wall', 'description' => 'connected · tiles: walls'], ]); // Esc cancels without placing anything. @@ -73,10 +82,56 @@ function choosePieceByKeys(Editor $editor, int $downs = 0): void ->and(getEditorProperty($editor, 'piecePlacement'))->toBeNull(); // A filter narrows the list; Enter chooses the match. - foreach (['P', '/', 'L', 'a', "\n", "\n"] as $key) { + foreach (['P', '/', 'W', 'a', "\n", "\n"] as $key) { callEditorMethod($editor, 'dispatchInput', $key); } - expect(getEditorProperty($editor, 'piecePlacement')['piece']->id)->toBe('lamp'); + expect(getEditorProperty($editor, 'piecePlacement')['piece']->id)->toBe('wall'); + + // Another layer offers only its own pieces. + callEditorMethod($editor, 'dispatchInput', "\033"); + selectPieceLayer($editor, 'terrain'); + callEditorMethod($editor, 'dispatchInput', 'P'); + expect(array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'value'))->toBe(['rug']); +}); + +it('says which layers have pieces when the layer being edited has none', function () { + [$editor, $map] = createPieceCanvasEditor(); + writeTestTileset(getEditorProperty($editor, 'projectRoot'), pieces: ['wall' => buildTestPieces()['wall']]); + selectPieceLayer($editor, 'terrain'); + callEditorMethod($editor, 'dispatchInput', 'P'); + + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and(getCurrentToast($editor)?->level)->toBe(StatusLevel::WARN) + ->and(getCurrentToast($editor)?->message)->toBe('Home has no pieces for the Terrain layer. It has pieces for Buildings; press L to switch layers.') + ->and($map->isDirty())->toBeFalse(); +}); + +it('offers the project\'s tilesets for a map that names none, then its pieces', function () { + $root = layeredMapProject(); + writeTestTileset($root, pieces: buildTestPieces()); + writeTestTileset($root, 'cave', pieces: []); + [$editor, $map] = layeredCanvasEditor($root); + selectPieceLayer($editor, 'buildings'); + callEditorMethod($editor, 'dispatchInput', 'P'); + + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Tileset') + ->and(getEditorProperty($editor, 'eventOptionDialogEntries'))->toBe([ + ['label' => 'Cave', 'value' => 'cave', 'description' => 'cave · 0 pieces'], + ['label' => 'Home', 'value' => 'home', 'description' => 'home · 4 pieces'], + ]) + ->and($map->isDirty())->toBeFalse(); + + // Choosing names it in the map's data, unsaved and undoable, and opens its pieces for the layer. + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\n"); + expect($map->getMapDataField(['tileset']))->toBe('home') + ->and($map->isDirty())->toBeTrue() + ->and(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Buildings piece') + ->and(getCurrentToast($editor)?->level)->toBe(StatusLevel::INFO); + + callEditorMethod($editor, 'dispatchInput', "\033"); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->getMapDataField(['tileset']))->toBeNull(); }); it('offers one piece entry in the command palette', function () { @@ -164,15 +219,15 @@ function choosePieceByKeys(Editor $editor, int $downs = 0): void setEditorProperty($editor, 'cursorX', 2); callEditorMethod($editor, 'dispatchInput', "\n"); - // The glyphs land on buildings, not the terrain layer being edited; a - // space glyph and a 0 tile leave their cells alone. + // The glyphs land on buildings, the layer being edited, and leave the + // terrain alone; a space glyph and a 0 tile leave their cells alone. expect($map->getLayerSymbol('map:4', 2, 0))->toBe('=') ->and($map->getLayerSymbol('map:4', 3, 0))->toBe(' ') ->and($map->getLayerSymbol('map:4', 2, 1))->toBe('=') ->and($map->getLayerSymbol('map:4', 3, 1))->toBe('H') ->and($map->getLayerColor('map:4', 3, 1))->toBe('cyan') ->and($map->getLayerSymbol('map:1', 2, 0))->toBe('.') - ->and(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:1') + ->and(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:4') ->and($map->isDirty())->toBeTrue(); $tiles = $map->getTileLayerSources(); expect(readTileEntries($tiles[$graphics . '/02.decor.tiles.php']))->toBe([['0', '5', '5', '5'], ['0', '0', '6', '5']]) @@ -213,7 +268,7 @@ function choosePieceByKeys(Editor $editor, int $downs = 0): void file_put_contents($data, str_replace("'events' => [],", "'events' => [], 'tileset' => 'home',", (string) file_get_contents($data))); [$editor, $map] = layeredCanvasEditor($root); - choosePieceByKeys($editor, 2); + choosePieceByKeys($editor, 'rug'); callEditorMethod($editor, 'dispatchInput', "\033[B"); callEditorMethod($editor, 'dispatchInput', "\n"); callEditorMethod($editor, 'dispatchInput', "\x13"); @@ -233,7 +288,11 @@ function choosePieceByKeys(Editor $editor, int $downs = 0): void } $before = $map->captureLayerSnapshot(); $tiles = $map->getTileLayerSources(); - choosePieceByKeys($editor, $case === 'missing layer' ? 1 : 0); + if ($case === 'missing layer') { + callEditorMethod($editor, 'choosePiece', 'lamp'); + } else { + choosePieceByKeys($editor); + } setEditorProperty($editor, 'cursorX', match ($case) { 'off the edge' => 3, 'ragged' => 1, default => 0 }); getEditorProperty($editor, 'toasts')->clear(); callEditorMethod($editor, 'dispatchInput', "\n"); @@ -335,7 +394,7 @@ function createWallCanvasEditor(?array $buildings = null, bool $ragged = false, /** Starts drawing the wall, the fourth piece in the picker. */ function chooseWallByKeys(Editor $editor): void { - choosePieceByKeys($editor, 3); + choosePieceByKeys($editor, 'wall'); } /** Walks the canvas cursor to a cell with the arrow keys. */ @@ -558,12 +617,13 @@ function buildWallTiles(array $rows): array ->and($map->getTileLayerSources())->toBe($tiles) ->and(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBe(['x' => 0, 'y' => 3]); - // A wall whose gameplay layer the map lacks is refused as a stamp is. + // A wall whose gameplay layer the map lacks is never offered; placed + // some other way, it is refused as a stamp is. $pieces = buildTestPieces(); $pieces['wall']['layer'] = 'fixtures'; [$editor, $map] = createWallCanvasEditor(pieces: $pieces); $layers = $map->captureLayerSnapshot(); - chooseWallByKeys($editor); + callEditorMethod($editor, 'choosePiece', 'wall'); foreach (["\n", "\177"] as $key) { getEditorProperty($editor, 'toasts')->clear(); callEditorMethod($editor, 'dispatchInput', $key); @@ -641,7 +701,7 @@ function buildWallTiles(array $rows): array setEditorProperty($editor, 'cursorX', 1); callEditorMethod($editor, 'dispatchInput', "\177"); - expect($map->getLayerSymbol('map:1', 1, 0))->toBe(' ') + expect($map->getLayerSymbol('map:4', 1, 0))->toBe(' ') ->and(getEditorProperty($editor, 'piecePlacement')['piece']->id)->toBe('bed'); }); From e4921c31b651bc072659ecedcec72b709c47b937 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 30 Sep 2026 14:40:55 +0200 Subject: [PATCH 074/344] refactor(pieces): stop choosing a map's tileset from the piece picker Removed: pressing P on a map that names no tileset no longer opens a tileset chooser, and choosing a piece never writes 'tileset' into the map's data. A map's tileset belongs to the map, set with the map rather than while placing a piece, so P on such a map now opens nothing and the status says the map names no tileset, as it does when the tileset has no pieces or cannot load. The layer-contextual picker is unchanged. --- docs/manual.md | 11 +++--- src/Canvas/PieceCanvas.php | 61 ++------------------------------ src/Editor.php | 6 ---- tests/Unit/TilesetPiecesTest.php | 31 ++-------------- 4 files changed, 10 insertions(+), 99 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 242d454..db89397 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -290,13 +290,10 @@ choosing one returns to Map mode. Move with the arrows or `j` / `k`, press `/` to filter by name, `Enter` to choose and `Esc` to cancel. The command palette offers the same picker as `Pieces: Choose a piece to place`. -When the map names no tileset, `P` opens the tileset chooser instead: one -entry per tileset in `assets/Data/Tilesets/`, with its id and how many pieces -it has. Choosing one names it in the map's data (`'tileset' => 'interior'`), -unsaved and undoable like any edit, and opens its pieces for the layer. When -the layer being edited has no pieces, the status line names the layers that -do; when the tileset has no pieces or cannot be loaded, or the project has no -tilesets, it says so and nothing opens. +When the layer being edited has no pieces, the status line names the layers +that do. When the map names no tileset, or its tileset has no pieces or cannot +be loaded, it says so and nothing opens. Placing a piece never chooses a map's +tileset: the tileset belongs to the map. Choosing a piece starts placing it, in Map mode. The canvas previews the piece's glyphs in reverse video with its top-left cell at the cursor, and the diff --git a/src/Canvas/PieceCanvas.php b/src/Canvas/PieceCanvas.php index 953d825..18b8d96 100644 --- a/src/Canvas/PieceCanvas.php +++ b/src/Canvas/PieceCanvas.php @@ -38,9 +38,9 @@ trait PieceCanvas /** * Opens the piece picker: one entry per piece of the selected map's * tileset that goes on the layer being edited, filterable by name. Enter - * starts placing the highlighted piece. A map that names no tileset - * opens the tileset chooser instead; nothing opens when the layer has no - * pieces to offer, and the status says which layers do. + * starts placing the highlighted piece. Nothing opens when the map names + * no tileset or the layer has no pieces to offer, and the status says + * why, naming the layers that have pieces. */ private function openPiecePicker(): void { @@ -48,10 +48,6 @@ private function openPiecePicker(): void if (! $map instanceof ProjectMap || $map->getGridSourceIssue() !== null) { return; } - if ($map->getMapDataField(['tileset']) === null) { - $this->openTilesetChooser($map); - return; - } $pieces = $this->loadCanvasPieces($map); if ($pieces === null) { return; @@ -105,57 +101,6 @@ private function findPieceCanvasLayer(ProjectMap $map): ?array return $layer === null || $layer['decoration'] ? null : $layer; } - /** - * Offers the project's tilesets for a map that names none. Choosing one - * names it in the map's data (saved with the map, undoable) and opens - * its pieces. - */ - private function openTilesetChooser(ProjectMap $map): void - { - $tilesets = []; - foreach (glob($map->getAssetRoot() . '/' . Tileset::DIRECTORY . '/*.php') ?: [] as $file) { - $id = basename($file, '.php'); - try { - $tileset = Tileset::load($map->getAssetRoot(), $id); - } catch (\Throwable) { - continue; - } - $count = count($tileset->pieces); - $tilesets[] = ['label' => $tileset->name, 'value' => $id, - 'description' => sprintf('%s · %d %s', $id, $count, $count === 1 ? 'piece' : 'pieces')]; - } - if ($tilesets === []) { - $this->setStatus(sprintf('This map names no tileset, and the project has none to choose. Add one to assets/%s/.', Tileset::DIRECTORY), StatusLevel::WARN); - $this->renderFooter(); - return; - } - $this->finalizeActiveStroke(); - $this->optionDialogField = ['canvasTileset' => true]; - $this->eventOptionDialogMarker = null; - $this->eventOptionDialogPath = null; - $this->eventOptionDialogTitle = 'Tileset'; - $this->eventOptionDialogEntries = $tilesets; - $this->selectedEventOptionIndex = 0; - $this->isEventOptionDialogOpen = true; - $this->setStatus('This map names no tileset. Choose the one it draws from; its pieces then open.'); - $this->renderSelectionDependentArea(); - } - - /** Names the chosen tileset in the map's data, then opens its pieces. */ - private function chooseMapTileset(string $id): void - { - $map = $this->getSelectedMap(); - if (! $map instanceof ProjectMap) { - return; - } - $this->applyMapDataValue($map, ['tileset'], $id, 'Tileset'); - $this->openPiecePicker(); - if ($this->isEventOptionDialogOpen) { - $this->setStatus(sprintf('This map now draws from the %s tileset; save to keep it. Choose a piece to place.', - $map->loadTileset()?->name ?? $id), StatusLevel::INFO); - } - } - /** * The pieces of the map's tileset, or null after saying in the status * why there are none to offer. diff --git a/src/Editor.php b/src/Editor.php index d13744d..c8ba5df 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -8012,12 +8012,6 @@ private function applySelectedEventOption(): void return; } - if (($field['canvasTileset'] ?? false) === true) { - $this->closeEventOptionDialog(); - $this->chooseMapTileset((string) $selectedEntry['value']); - return; - } - if (($field['canvasPiece'] ?? false) === true) { $this->closeEventOptionDialog(); $this->choosePiece((string) $selectedEntry['value']); diff --git a/tests/Unit/TilesetPiecesTest.php b/tests/Unit/TilesetPiecesTest.php index 8c326ff..269cb7e 100644 --- a/tests/Unit/TilesetPiecesTest.php +++ b/tests/Unit/TilesetPiecesTest.php @@ -106,34 +106,6 @@ function choosePieceByKeys(Editor $editor, string $id = 'bed'): void ->and($map->isDirty())->toBeFalse(); }); -it('offers the project\'s tilesets for a map that names none, then its pieces', function () { - $root = layeredMapProject(); - writeTestTileset($root, pieces: buildTestPieces()); - writeTestTileset($root, 'cave', pieces: []); - [$editor, $map] = layeredCanvasEditor($root); - selectPieceLayer($editor, 'buildings'); - callEditorMethod($editor, 'dispatchInput', 'P'); - - expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Tileset') - ->and(getEditorProperty($editor, 'eventOptionDialogEntries'))->toBe([ - ['label' => 'Cave', 'value' => 'cave', 'description' => 'cave · 0 pieces'], - ['label' => 'Home', 'value' => 'home', 'description' => 'home · 4 pieces'], - ]) - ->and($map->isDirty())->toBeFalse(); - - // Choosing names it in the map's data, unsaved and undoable, and opens its pieces for the layer. - callEditorMethod($editor, 'dispatchInput', "\033[B"); - callEditorMethod($editor, 'dispatchInput', "\n"); - expect($map->getMapDataField(['tileset']))->toBe('home') - ->and($map->isDirty())->toBeTrue() - ->and(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Buildings piece') - ->and(getCurrentToast($editor)?->level)->toBe(StatusLevel::INFO); - - callEditorMethod($editor, 'dispatchInput', "\033"); - callEditorMethod($editor, 'dispatchInput', "\x1a"); - expect($map->getMapDataField(['tileset']))->toBeNull(); -}); - it('offers one piece entry in the command palette', function () { [$editor] = createPieceCanvasEditor(); $labels = array_map(static fn($item): string => $item->label . ' ' . $item->hint, callEditorMethod($editor, 'buildPaletteItems')); @@ -144,6 +116,9 @@ function choosePieceByKeys(Editor $editor, string $id = 'bed'): void it('says why a map has no pieces and opens nothing', function (string $case, string $message) { $root = $case === 'no tileset' ? layeredMapProject() : mapGraphicsProject(); + if ($case === 'no tileset') { + writeTestTileset($root, pieces: buildTestPieces()); + } if ($case === 'broken tileset') { file_put_contents($root . '/assets/Data/Tilesets/home.php', " 'Home'];\n"); } From 3dc626533705da8ae466ee2b3c4c66368722314f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 30 Sep 2026 15:20:09 +0200 Subject: [PATCH 075/344] feat(maps): set a map's kind in the Inspector A map's kind is the setting it draws (Interior, Exterior, World, Dungeon): one of the project's tilesets, which every tile and piece on the map comes from. The Inspector shows it as Kind, under Region, and Enter opens a picker of the project's tilesets by name. It is stored as the map data's tileset, so the engine and existing maps are unchanged. A map without a kind shows Not set and keeps any tiles when it gets one. Changing one kind to another on a map with tiles asks first, Cancel being the default: confirming clears its tile layers and their tileLayers settings, since their ids name places on the old kind's sheets. Glyphs, collision and events stay. Each change is one undo step, and saving a cleared map removes its tile layer files. A kind the project no longer has stays visible and says so. The reference catalog gains a tilesets category, and P on a map without a kind now points at the Kind field instead of the data file. --- docs/manual.md | 42 +++++-- src/Canvas/PieceCanvas.php | 2 +- src/Database/ReferenceCatalog.php | 30 +++++ src/Editor.php | 18 ++- src/Inspector/MapKindField.php | 133 +++++++++++++++++++++ tests/Unit/EditorUxTest.php | 4 +- tests/Unit/MapKindTest.php | 126 +++++++++++++++++++ tests/Unit/TilesetPiecesTest.php | 2 +- tests/__snapshots__/main_shell_100x30.ansi | 2 +- 9 files changed, 342 insertions(+), 17 deletions(-) create mode 100644 src/Inspector/MapKindField.php create mode 100644 tests/Unit/MapKindTest.php diff --git a/docs/manual.md b/docs/manual.md index db89397..eaa1526 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -54,17 +54,17 @@ is what the code draws rather than a sketch of it: ┌─Assets [Focus]───────────────┐ ┌─Canvas─────────────────────┐ ┌─Inspector──────────────────────┐ │ Maps │ │ Preview: test-map │ │ Name: Test Map │ │ > test-map │ │ Test Map | | 12 x 5 | vie │ │ Region: │ - │ │ │ ############ │ │ Description: A tiny fixture │ - │ │ │ # ~~~ # │ │ map. │ + │ │ │ ############ │ │ Kind: Not set │ + │ │ │ # ~~~ # │ │ Description: A tiny fixture │ + │ │ │ # # │ │ map. │ │ │ │ # # │ │ Size │ - │ │ │ # # │ │ X: 12 │ - │ │ │ ############ │ │ Y: 5 │ + │ │ │ ############ │ │ X: 12 │ + │ │ │ │ │ Y: 5 │ │ │ │ │ │ Events · 1 │ │ │ │ │ │ Triggers · 0 │ │ │ │ │ │ Audio │ │ │ │ │ │ Background Music: (None) │ │ │ │ │ │ Music Variants · None │ - │ │ │ │ │ Encounters · off │ └─/:Filter Del:Delete─────────┘ └─i:Paint L:Layer ?:Help─────┘ └─Enter:Edit─────────────────────┘ ┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐ │ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 │ @@ -277,7 +277,7 @@ told that existing saves are not migrated. ### Pieces -A map offers the pieces of the tileset its data names (see +A map offers the pieces of its [kind](#map-kind)'s tileset (see [Map Graphics](#map-graphics)): whole items such as a bed or a table, so a map is built from items instead of single glyphs. In Normal mode on the canvas, `P` opens the piece picker for the layer being edited: only the pieces whose glyphs @@ -291,9 +291,9 @@ to filter by name, `Enter` to choose and `Esc` to cancel. The command palette offers the same picker as `Pieces: Choose a piece to place`. When the layer being edited has no pieces, the status line names the layers -that do. When the map names no tileset, or its tileset has no pieces or cannot +that do. When the map has no kind yet, or its tileset has no pieces or cannot be loaded, it says so and nothing opens. Placing a piece never chooses a map's -tileset: the tileset belongs to the map. +tileset: it comes from the map's kind, set in the Inspector. Choosing a piece starts placing it, in Map mode. The canvas previews the piece's glyphs in reverse video with its top-left cell at the cursor, and the @@ -376,8 +376,9 @@ its own edges. ### Map Graphics -A map may name a tileset in its data file (`'tileset' => 'interior'`, read from -`assets/Data/Tilesets/interior.php`) and keep RPG Maker tile layers in +A map's [kind](#map-kind) names its tileset in its data file +(`'tileset' => 'interior'`, read from `assets/Data/Tilesets/interior.php`), +and the map may keep RPG Maker tile layers in `graphics/NN.name.tiles.php`. A tile layer holds one tile identity per map cell, so each of its rows is exactly as wide as the map's row in terminal columns. The TUI never displays tiles or paints single tiles; painting them @@ -739,6 +740,27 @@ scrolls by rows, keeping the selected row's first line in view. The Destination row on an event is a reference: `Ctrl+G` follows it. +### Map kind + +`Kind`, under `Region`, is the setting the map draws: one of the project's +tilesets in `assets/Data/Tilesets/`, shown by name, such as Interior, +Exterior, World or Dungeon. Every tile and [piece](#pieces) on the map comes +from its kind, so the tiles a map offers change only when its kind does. +Enter opens the picker; the kind is stored as the map data's `tileset`. + +- A map without a kind shows `Not set`, and `P` offers no pieces until it + has one. Choosing a kind keeps any tiles the map already has. +- Changing one kind to another on a map with tiles asks first, since its + tiles name places on the old kind's sheets and would show the wrong art. + `Cancel`, the default, keeps everything; `Clear N tile layers and change` + clears its tile layers and their `tileLayers` settings. Glyphs, collision + and events stay. +- A map without tiles changes kind at once. +- Each change is one undo step and is written on save; saving a cleared map + removes its `graphics/` tile layer files. +- A kind the project no longer has stays visible as + `id · not in assets/Data/Tilesets`. + ### Map audio and encounters In Map mode the Inspector carries the map's runtime metadata beneath its diff --git a/src/Canvas/PieceCanvas.php b/src/Canvas/PieceCanvas.php index 18b8d96..bde8902 100644 --- a/src/Canvas/PieceCanvas.php +++ b/src/Canvas/PieceCanvas.php @@ -116,7 +116,7 @@ private function loadCanvasPieces(ProjectMap $map): ?array return null; } if ($tileset === null) { - $this->setStatus("This map names no tileset, so it has no pieces. Name one in its data file ('tileset' => '').", StatusLevel::WARN); + $this->setStatus('This map has no kind yet, so it has no pieces. Set its Kind in the Inspector.', StatusLevel::WARN); return null; } if ($tileset->pieces === []) { diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 7b891d1..28f3ba7 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -13,6 +13,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Rendering\Tilesets\Tileset; /** * What a field pointing at another resource may point at. @@ -69,6 +70,7 @@ final class ReferenceCatalog 'cinematic_checkpoints', 'summon_cues', 'event_markers', + 'tilesets', ]; /** @@ -186,6 +188,8 @@ public function valuesFor(string $category): array 'cinematic_checkpoints' => $this->checkpointIds(), 'summon_cues' => $this->summonCueIds(), 'event_markers' => $this->currentMap?->getEventMarkers() ?? [], + // A map's kind is one of the project's tilesets, by file stem. + 'tilesets' => array_keys($this->loadTilesetNames()), default => $this->recordValues($category), }; } @@ -335,6 +339,10 @@ private function summonCueIds(): array */ public function labelsFor(string $category): array { + if ($category === 'tilesets') { + return $this->loadTilesetNames(); + } + if ($category === 'animation_ids') { $labels = []; foreach ($this->workspace->animationDatabase->getAnimations() as $animation) { @@ -645,6 +653,28 @@ private function fileValues(string $relativeDirectory): array return $names; } + /** + * The name of every tileset in assets/Data/Tilesets that loads, by id. One + * that cannot load is not offered; validation reports it. + * + * @return array + */ + private function loadTilesetNames(): array + { + $assetRoot = rtrim($this->workspace->projectRoot, '/') . '/assets'; + $names = []; + foreach (glob($assetRoot . '/' . Tileset::DIRECTORY . '/*.php') ?: [] as $file) { + $id = basename($file, '.php'); + try { + $names[$id] = Tileset::load($assetRoot, $id)->name; + } catch (\Throwable) { + continue; + } + } + + return $names; + } + /** Returns asset-root-relative PNG choices without following paths outside the asset root. */ public static function getPngAssets(string $projectRoot): array { diff --git a/src/Editor.php b/src/Editor.php index c8ba5df..40bd14f 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -117,6 +117,7 @@ final class Editor use \Ichiloto\Editor\Canvas\PieceCanvas; use \Ichiloto\Editor\Canvas\LineInsertCanvas; use \Ichiloto\Editor\Canvas\GlyphTileCanvas; + use \Ichiloto\Editor\Inspector\MapKindField; use CutscenesWorkspace; use CutsceneOutlinePane; use CutscenePreviewPane; @@ -536,6 +537,7 @@ final class Editor * The picker row that clears a map's background music. */ private const string MAP_BGM_NONE = '(None)'; + private const string MAP_KIND_NONE = 'Not set'; /** * The row that chooses which kind of slot the Optimize preview fills. @@ -8018,6 +8020,17 @@ private function applySelectedEventOption(): void return; } + if (($field['target'] ?? null) === 'map-kind') { + $this->closeEventOptionDialog(); + $this->chooseMapKind((string) $selectedEntry['value'], (string) $selectedEntry['label']); + return; + } + + if (is_array($field['mapKindChange'] ?? null)) { + $this->confirmMapKindChange($field['mapKindChange'], $selectedEntry['value'] === 'clear'); + return; + } + if (is_array($field['glyphPiece'] ?? null)) { $this->chooseGlyphPiece($field['glyphPiece'], (string) $selectedEntry['value']); return; @@ -13736,6 +13749,7 @@ private function getInspectorFields(): array 'target' => 'map', 'field' => 'region', ], + $this->buildMapKindField($selectedMap), [ 'label' => 'Description', 'value' => $selectedMap->getDescription(), @@ -15698,7 +15712,7 @@ private function openInspectorReferenceDialog(array $field): bool $category = (string) ($field['reference'] ?? ''); $target = (string) ($field['target'] ?? ''); - if ($category === '' || ! in_array($target, ['map-data', 'map-encounters', 'map-bgm-variants'], true)) { + if ($category === '' || ! in_array($target, ['map-data', 'map-kind', 'map-encounters', 'map-bgm-variants'], true)) { return false; } @@ -15745,7 +15759,7 @@ private function openInspectorReferenceDialog(array $field): bool $this->eventOptionDialogPath = null; $this->eventOptionDialogTitle = $title; $this->eventOptionDialogEntries = $entries; - $this->selectedEventOptionIndex = $this->resolveEventOptionSelectionIndex((string) ($field['value'] ?? '')); + $this->selectedEventOptionIndex = $this->resolveEventOptionSelectionIndex((string) ($field['selectedValue'] ?? $field['value'] ?? '')); $this->isEventOptionDialogOpen = true; $this->statusMessage = sprintf('Choose %s.', mb_strtolower($title)); $this->renderSelectionDependentArea(); diff --git a/src/Inspector/MapKindField.php b/src/Inspector/MapKindField.php new file mode 100644 index 0000000..1bd25df --- /dev/null +++ b/src/Inspector/MapKindField.php @@ -0,0 +1,133 @@ + The Kind row for the Inspector. */ + private function buildMapKindField(ProjectMap $map): array + { + $id = $map->getMapDataField(['tileset']); + $labels = $this->referenceCatalog()->labelsFor('tilesets'); + + return [ + 'label' => 'Kind', + 'value' => match (true) { + $id === null => self::MAP_KIND_NONE, + ! is_string($id) => sprintf('%s · not a tileset id', get_debug_type($id)), + ! isset($labels[$id]) => sprintf('%s · not in assets/%s', $id, Tileset::DIRECTORY), + default => $labels[$id], + }, + 'selectedValue' => is_string($id) ? $id : '', + 'reference' => 'tilesets', + 'target' => 'map-kind', + 'field' => 'tileset', + ]; + } + + /** + * Makes the selected map the chosen kind. A map without a kind keeps + * whatever tiles it has, which now draw from that kind; a map changing + * from one kind to another with tiles asks first. + */ + private function chooseMapKind(string $id, string $label): void + { + $map = $this->getSelectedMap(); + if (! $map instanceof ProjectMap) { + return; + } + $current = $map->getMapDataField(['tileset']); + if ($current === $id) { + $this->setStatus(sprintf('%s\'s kind is already %s.', $map->getDisplayName(), $label)); + $this->renderSelectionDependentArea(); + return; + } + $layers = count($map->getTileLayerSources()); + if ($current === null || $layers === 0) { + $this->changeMapKind($map, $id, $label, false); + return; + } + + $currentLabel = $this->referenceCatalog()->labelsFor('tilesets')[(string) $current] ?? (string) $current; + $this->optionDialogField = ['mapKindChange' => ['id' => $id, 'label' => $label]]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = sprintf('Change %s\'s kind to %s', $map->getDisplayName(), $label); + $this->eventOptionDialogEntries = [ + ['label' => 'Cancel', 'value' => 'cancel', 'description' => sprintf('Keep its kind, %s, and its tiles.', $currentLabel)], + ['label' => sprintf('Clear %d tile %s and change', $layers, $layers === 1 ? 'layer' : 'layers'), 'value' => 'clear', + 'description' => sprintf('Its tiles come from %s and would show the wrong art as %s. Glyphs stay. Ctrl+Z restores them.', + $currentLabel, $label)], + ]; + $this->selectedEventOptionIndex = 0; + $this->dialogFilter->clear(); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = sprintf('Confirm the change: it clears the map\'s %s tiles.', $currentLabel); + $this->renderSelectionDependentArea(); + } + + /** @param array{id: string, label: string} $change */ + private function confirmMapKindChange(array $change, bool $confirmed): void + { + $this->closeEventOptionDialog(); + $map = $this->getSelectedMap(); + if (! $confirmed || ! $map instanceof ProjectMap) { + $this->setStatus('Kind unchanged.'); + return; + } + $this->changeMapKind($map, $change['id'], $change['label'], true); + } + + /** + * Sets the map's kind, clearing its tile layers and their settings when + * asked, as one undo step. + */ + private function changeMapKind(ProjectMap $map, string $id, string $label, bool $clearTiles): void + { + $hadKind = $map->hasMapDataField(['tileset']); + $oldKind = $map->getMapDataField(['tileset']); + $oldSources = $map->getTileLayerSources(); + $hadSettings = $map->hasMapDataField([MapGraphics::SETTINGS_KEY]); + $oldSettings = $map->getMapDataField([MapGraphics::SETTINGS_KEY]); + $apply = static function () use ($map, $id, $clearTiles): void { + $map->setMapDataField(['tileset'], $id); + if ($clearTiles) { + $map->restoreTileLayerSources([]); + $map->setMapDataField([MapGraphics::SETTINGS_KEY], null); + } + }; + try { + $apply(); + } catch (Throwable $failure) { + $this->setErrorStatus($failure, 'Kind change'); + $this->renderSelectionDependentArea(); + return; + } + $this->recordCommand(new GenericCommand('Kind change', $apply, static function () use ($map, $hadKind, $oldKind, $oldSources, $hadSettings, $oldSettings): void { + $map->setMapDataField(['tileset'], $hadKind ? $oldKind : null); + $map->restoreTileLayerSources($oldSources); + $map->setMapDataField([MapGraphics::SETTINGS_KEY], $hadSettings ? $oldSettings : null); + })); + $this->setStatus(sprintf('%s\'s kind is now %s%s; save to keep it.', $map->getDisplayName(), $label, + $clearTiles ? sprintf(', and its %d tile %s cleared', count($oldSources), count($oldSources) === 1 ? 'layer is' : 'layers are') : ''), + StatusLevel::INFO); + $this->renderSelectionDependentArea(); + } +} diff --git a/tests/Unit/EditorUxTest.php b/tests/Unit/EditorUxTest.php index 5fa7b6b..4a55448 100644 --- a/tests/Unit/EditorUxTest.php +++ b/tests/Unit/EditorUxTest.php @@ -232,7 +232,7 @@ function uxEditor(): Editor it('steps integer inspector fields with the Left/Right idiom and records undo', function () { $editor = uxEditor(); setEditorProperty($editor, 'focusedPane', 'inspector'); - setEditorProperty($editor, 'selectedInspectorFieldIndex', 4); // Size X (width) + setEditorProperty($editor, 'selectedInspectorFieldIndex', 5); // Size X (width) /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); @@ -309,7 +309,7 @@ function uxEditor(): Editor $selectedHeight = $layout->spans[$last][1]; expect(count($layout->lines))->toBeGreaterThan($visibleRows) - ->and($layout->spans[2][1])->toBeGreaterThan(5) + ->and($layout->spans[3][1])->toBeGreaterThan(5) ->and($layout->rowOfField($last) + $selectedHeight - 1)->toBe($visibleRows - 1) ->and($layout->offset)->toBe($layout->spans[$last][0] + $selectedHeight - $visibleRows) ->and(callEditorMethod($editor, 'getInspectorLines')[0])->not->toContain('Name: Test Map'); diff --git a/tests/Unit/MapKindTest.php b/tests/Unit/MapKindTest.php new file mode 100644 index 0000000..9132aa7 --- /dev/null +++ b/tests/Unit/MapKindTest.php @@ -0,0 +1,126 @@ +current(); +} + +it('offers the project\'s kinds by name and sets one on a map without a kind, keeping its tiles, as one undo step', function () { + $root = mapGraphicsProject(); + editTestMapData($root, static fn(string $source): string => str_replace(" 'tileset' => 'home',", '', $source)); + writeTestTileset($root, 'interior'); + writeTestTileset($root, 'exterior'); + [$editor, $map] = kindEditor($root); + + expect(inspectorValueOf($editor, 'Kind'))->toBe('Not set'); + + selectInspectorField($editor, 'Kind'); + callEditorMethod($editor, 'activateInspectorField'); + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Kind') + ->and(array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label', 'value')) + ->toBe(['exterior' => 'Exterior', 'home' => 'Home', 'interior' => 'Interior']); + callEditorMethod($editor, 'closeEventOptionDialog', 'cancelled'); + + // Tiles drawn before the map had a kind are kept; they now draw from it. + pickInspectorReference($editor, 'Kind', 'interior'); + expect($map->getMapDataField(['tileset']))->toBe('interior') + ->and($map->getTileLayerSources())->toHaveCount(2) + ->and(inspectorValueOf($editor, 'Kind'))->toBe('Interior') + ->and($map->isDirty())->toBeTrue() + ->and(kindToast($editor)?->level)->toBe(StatusLevel::INFO) + ->and(kindToast($editor)?->message)->toBe('Test Map\'s kind is now Interior; save to keep it.'); + + callEditorMethod($editor, 'performUndo'); + expect($map->hasMapDataField(['tileset']))->toBeFalse() + ->and($map->isDirty())->toBeFalse(); +}); + +it('asks before changing a map with tiles to another kind, and clears its tiles and their settings as one undo step', function () { + $root = mapGraphicsProject(); + editTestMapData($root, static fn(string $source): string => str_replace("'tileset' => 'home',", + "'tileset' => 'home', 'tileLayers' => ['floor' => ['movesWith' => 'buildings']],", $source)); + writeTestTileset($root, 'cave'); + [$editor, $map] = kindEditor($root); + $graphics = $root . '/assets/Maps/test-map/graphics'; + $sources = $map->getTileLayerSources(); + + expect(inspectorValueOf($editor, 'Kind'))->toBe('Home'); + + // Cancel is first and leaves everything as it was. + pickInspectorReference($editor, 'Kind', 'cave'); + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Change Test Map\'s kind to Cave') + ->and(array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label'))->toBe(['Cancel', 'Clear 2 tile layers and change']) + ->and(getEditorProperty($editor, 'selectedEventOptionIndex'))->toBe(0); + callEditorMethod($editor, 'applySelectedEventOption'); + expect($map->getMapDataField(['tileset']))->toBe('home') + ->and($map->getTileLayerSources())->toBe($sources) + ->and($map->isDirty())->toBeFalse(); + + pickInspectorReference($editor, 'Kind', 'cave'); + setEditorProperty($editor, 'selectedEventOptionIndex', 1); + callEditorMethod($editor, 'applySelectedEventOption'); + expect($map->getMapDataField(['tileset']))->toBe('cave') + ->and($map->getTileLayerSources())->toBe([]) + ->and($map->hasMapDataField(['tileLayers']))->toBeFalse() + ->and(kindToast($editor)?->message)->toBe('Test Map\'s kind is now Cave, and its 2 tile layers are cleared; save to keep it.'); + + callEditorMethod($editor, 'performUndo'); + expect($map->getMapDataField(['tileset']))->toBe('home') + ->and($map->getTileLayerSources())->toBe($sources) + ->and($map->getMapDataField(['tileLayers']))->toBe(['floor' => ['movesWith' => 'buildings']]) + ->and($map->isDirty())->toBeFalse(); + + // Saving the change removes the old kind's tile layers; glyphs stay. + callEditorMethod($editor, 'performRedo'); + $map->save(); + $saved = require $root . '/assets/Maps/test-map/test-map.data.php'; + expect($saved['tileset'])->toBe('cave') + ->and($saved)->not->toHaveKey('tileLayers') + ->and(glob($graphics . '/*.tiles.php'))->toBe([]) + ->and(is_file($root . '/assets/Maps/test-map/layers/04.buildings.map.php'))->toBeTrue(); +}); + +it('changes a map without tiles to another kind without asking', function () { + $root = layeredMapProject(); + editTestMapData($root, static fn(string $source): string => str_replace("'events' => [],", "'events' => [], 'tileset' => 'home',", $source)); + writeTestTileset($root); + writeTestTileset($root, 'cave'); + [$editor, $map] = kindEditor($root); + + pickInspectorReference($editor, 'Kind', 'cave'); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and($map->getMapDataField(['tileset']))->toBe('cave'); +}); + +it('changes nothing when the chosen kind is the map\'s own', function () { + [$editor, $map] = kindEditor(mapGraphicsProject()); + + pickInspectorReference($editor, 'Kind', 'home'); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and($map->isDirty())->toBeFalse() + ->and(kindToast($editor)?->message)->toBe('Test Map\'s kind is already Home.'); +}); + +it('keeps a kind the project no longer has visible and diagnosable', function () { + $root = mapGraphicsProject(); + unlink($root . '/assets/Data/Tilesets/home.php'); + writeTestTileset($root, 'cave'); + [$editor, $map] = kindEditor($root); + + expect(inspectorValueOf($editor, 'Kind'))->toBe('home · not in assets/Data/Tilesets') + ->and($map->isDirty())->toBeFalse(); +}); diff --git a/tests/Unit/TilesetPiecesTest.php b/tests/Unit/TilesetPiecesTest.php index 269cb7e..ebacc14 100644 --- a/tests/Unit/TilesetPiecesTest.php +++ b/tests/Unit/TilesetPiecesTest.php @@ -134,7 +134,7 @@ function choosePieceByKeys(Editor $editor, string $id = 'bed'): void ->and(getCurrentToast($editor)?->message)->toContain($message) ->and($map->isDirty())->toBeFalse(); })->with([ - 'no tileset' => ['no tileset', 'names no tileset'], + 'no tileset' => ['no tileset', 'This map has no kind yet, so it has no pieces. Set its Kind in the Inspector.'], 'no pieces' => ['no pieces', 'Tileset home has no pieces'], 'broken tileset' => ['broken tileset', 'Pieces are unavailable: Tileset home needs at least one sheet.'], // A field cell holds one whole tile, so a piece naming half a tile is refused, not reinterpreted. diff --git a/tests/__snapshots__/main_shell_100x30.ansi b/tests/__snapshots__/main_shell_100x30.ansi index 6f51ae3..947cc65 100644 --- a/tests/__snapshots__/main_shell_100x30.ansi +++ b/tests/__snapshots__/main_shell_100x30.ansi @@ -1 +1 @@ -[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint L:Layer ?:Help─────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > Terrain · visible ││ Events · visible ││ ││ ││ │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file +[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint L:Layer ?:Help─────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Kind: Not set ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > Terrain · visible ││ Events · visible ││ ││ │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file From a026d9a9d3e10b5501854209d1c8209180bb7bea Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 30 Sep 2026 15:33:33 +0200 Subject: [PATCH 076/344] feat(maps): ask a new map's kind before creating it Shift+A in the Assets panel now opens a New map kind picker of the project's tilesets before creating anything; the chosen kind is written into the new map's data as its tileset, and Esc creates nothing. A project without tilesets has no kinds to offer, so its map is still created at once, and the status says it has no kind. A duplicate keeps its original's kind. Changed behaviour: in a project with tilesets, Shift+A no longer creates a map immediately; it asks the kind first. --- docs/manual.md | 7 +++-- src/Editor.php | 17 +++++++++--- src/Inspector/MapKindField.php | 28 +++++++++++++++++++- src/ProjectMap.php | 4 ++- src/ProjectWorkspace.php | 4 +-- tests/Unit/MapKindTest.php | 47 ++++++++++++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 10 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index eaa1526..5cf4da9 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -170,8 +170,8 @@ carries a trailing `*`. Controls: - `Up` / `Down`: move the selection -- `Shift+A`: create a new map -- `Shift+D`: duplicate the selected map +- `Shift+A`: create a new map, asking its [kind](#map-kind) first +- `Shift+D`: duplicate the selected map, kind included - `Delete`: delete the selected map (destructive confirmation) - `/`: filter the list incrementally - `Enter`: focus the canvas on the selected map @@ -748,6 +748,9 @@ Exterior, World or Dungeon. Every tile and [piece](#pieces) on the map comes from its kind, so the tiles a map offers change only when its kind does. Enter opens the picker; the kind is stored as the map data's `tileset`. +- A new map is asked its kind before it is created (`Shift+A` in the Assets + panel); `Esc` creates nothing. A duplicate keeps its original's kind. In a + project with no tilesets, a new map is created without a kind. - A map without a kind shows `Not set`, and `P` offers no pieces until it has one. Choosing a kind keeps any tiles the map already has. - Changing one kind to another on a map with tiles asks first, since its diff --git a/src/Editor.php b/src/Editor.php index 40bd14f..72314ec 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -102,6 +102,7 @@ use Ichiloto\Engine\Events\Enumerations\LootType; use Ichiloto\Engine\Quests\QuestObjectiveType; use Ichiloto\Engine\Rendering\Tilesets\TileId; +use Ichiloto\Engine\Rendering\Tilesets\Tileset; use RuntimeException; if (! class_exists(__NAMESPACE__ . chr(92) . 'Animation', false)) { class_alias('Ichiloto' . chr(92) . 'Engine' . chr(92) . 'Animations' . chr(92) . 'Animation', __NAMESPACE__ . chr(92) . 'Animation'); } if (! class_exists(__NAMESPACE__ . chr(92) . 'AnimationCue', false)) { class_alias('Ichiloto' . chr(92) . 'Engine' . chr(92) . 'Animations' . chr(92) . 'AnimationCue', __NAMESPACE__ . chr(92) . 'AnimationCue'); } @@ -1543,7 +1544,7 @@ private function isPlainShortcut(string $normalizedInput, string $shortcut): boo private function handleAssetsPaneInput(string $input, string $normalizedInput): void { if ($this->isShiftLetterShortcut($input, 'A')) { - $this->createNewMap(); + $this->openNewMapKindPicker(); return; } @@ -7414,17 +7415,19 @@ private function performSaveSelectedMap(): void * * @return void */ - private function createNewMap(): void + private function createNewMap(?string $kind = null, ?string $kindLabel = null): void { if (! $this->workspace instanceof ProjectWorkspace) { return; } try { - $mapId = $this->workspace->createMap(); + $mapId = $this->workspace->createMap(kind: $kind); $this->reloadWorkspaceSelectingMap($mapId); $this->selectedInspectorFieldIndex = 0; - $this->setStatus(sprintf('Created %s.', $mapId), StatusLevel::SUCCESS); + $this->setStatus($kind === null + ? sprintf('Created %s. It has no kind: the project has no tilesets in assets/%s/.', $mapId, Tileset::DIRECTORY) + : sprintf('Created %s; its kind is %s.', $mapId, $kindLabel ?? $kind), StatusLevel::SUCCESS); $this->setFocusedPane(self::FOCUS_INSPECTOR); $this->beginInspectorEdit(); } catch (Throwable $throwable) { @@ -8020,6 +8023,12 @@ private function applySelectedEventOption(): void return; } + if (($field['newMapKind'] ?? false) === true) { + $this->closeEventOptionDialog(); + $this->createNewMap((string) $selectedEntry['value'], (string) $selectedEntry['label']); + return; + } + if (($field['target'] ?? null) === 'map-kind') { $this->closeEventOptionDialog(); $this->chooseMapKind((string) $selectedEntry['value'], (string) $selectedEntry['label']); diff --git a/src/Inspector/MapKindField.php b/src/Inspector/MapKindField.php index 1bd25df..9080a99 100644 --- a/src/Inspector/MapKindField.php +++ b/src/Inspector/MapKindField.php @@ -17,10 +17,36 @@ * on the map comes from. The kind is stored as the map data's `tileset`. * Changing it on a map that has tiles clears them, on confirmation, since * tile ids name places on one tileset's sheets and would show the wrong art - * on another's. + * on another's. A new map is asked its kind before it is created. */ trait MapKindField { + /** + * Asks the kind of the map about to be created; Esc creates nothing. A + * project without tilesets has no kinds to offer, so its map is created + * without one. + */ + private function openNewMapKindPicker(): void + { + $kinds = $this->referenceCatalog()->labelsFor('tilesets'); + if ($kinds === []) { + $this->createNewMap(); + return; + } + $this->optionDialogField = ['newMapKind' => true]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = 'New map kind'; + $this->eventOptionDialogEntries = array_map(static fn(string $id, string $name): array => + ['label' => $name, 'value' => $id, 'description' => sprintf('Draws from assets/%s/%s.php.', Tileset::DIRECTORY, $id)], + array_keys($kinds), $kinds); + $this->selectedEventOptionIndex = 0; + $this->dialogFilter->clear(); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = 'Choose the kind of map to create. Its tiles and pieces come from it.'; + $this->renderSelectionDependentArea(); + } + /** @return array The Kind row for the Inspector. */ private function buildMapKindField(ProjectMap $map): array { diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 25ff913..7128d82 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -1871,15 +1871,17 @@ private function evaluateFiles(array $paths, ?array &$fingerprints = null): arra * @param int $height The map height. * @return void */ - public static function createBlank(string $directory, string $baseName, string $displayName, int $width = 48, int $height = 18, ?FileSetOperations $files = null): void + public static function createBlank(string $directory, string $baseName, string $displayName, int $width = 48, int $height = 18, ?FileSetOperations $files = null, ?string $kind = null): void { $blankTileLine = str_repeat(' ', $width); $blankEventLine = str_repeat(' ', $width); $tileText = implode(PHP_EOL, array_fill(0, $height, $blankTileLine)); $eventText = implode(PHP_EOL, array_fill(0, $height, $blankEventLine)); + // A map is born with its kind, the tileset its tiles and pieces come from. $data = [ 'name' => $displayName, 'region' => '', + ...($kind === null ? [] : ['tileset' => $kind]), 'description' => '', 'triggers' => [], 'events' => [], diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index 5e4c3c8..afa386b 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -368,12 +368,12 @@ public function getMapByIndex(int $index): ?ProjectMap * @param string|null $baseName The preferred base name. * @return string The created map id. */ - public function createMap(?string $baseName = null, ?FileSetOperations $files = null): string + public function createMap(?string $baseName = null, ?FileSetOperations $files = null, ?string $kind = null): string { $mapsRoot = $this->getMapsRoot(); $baseName = $this->getNextAvailableBaseName($baseName ?? 'new-map'); $directory = $mapsRoot . DIRECTORY_SEPARATOR . $baseName; - ProjectMap::createBlank($directory, $baseName, self::humanizeBaseName($baseName), files: $files); + ProjectMap::createBlank($directory, $baseName, self::humanizeBaseName($baseName), files: $files, kind: $kind); return $baseName; } diff --git a/tests/Unit/MapKindTest.php b/tests/Unit/MapKindTest.php index 9132aa7..5d2ae6a 100644 --- a/tests/Unit/MapKindTest.php +++ b/tests/Unit/MapKindTest.php @@ -124,3 +124,50 @@ function kindToast(Editor $editor): ?Ichiloto\Editor\Status\Toast expect(inspectorValueOf($editor, 'Kind'))->toBe('home · not in assets/Data/Tilesets') ->and($map->isDirty())->toBeFalse(); }); + +it('asks a new map\'s kind before creating it, and Esc creates nothing', function () { + $root = layeredMapProject(); + writeTestTileset($root, 'interior'); + writeTestTileset($root, 'exterior'); + [$editor] = layeredCanvasEditor($root); + setEditorProperty($editor, 'focusedPane', 'assets'); + + callEditorMethod($editor, 'dispatchInput', 'A'); + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('New map kind') + ->and(array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label', 'value')) + ->toBe(['exterior' => 'Exterior', 'interior' => 'Interior']) + ->and(is_dir($root . '/assets/Maps/new-map'))->toBeFalse(); + + callEditorMethod($editor, 'dispatchInput', "\033"); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and(is_dir($root . '/assets/Maps/new-map'))->toBeFalse(); + + callEditorMethod($editor, 'dispatchInput', 'A'); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\n"); + $data = require $root . '/assets/Maps/new-map/new-map.data.php'; + expect($data['tileset'])->toBe('interior') + ->and(kindToast($editor)?->level)->toBe(StatusLevel::SUCCESS) + ->and(kindToast($editor)?->message)->toBe('Created new-map; its kind is Interior.') + ->and(callEditorMethod($editor, 'getSelectedMap')->mapId)->toBe('new-map') + ->and(callEditorMethod($editor, 'getSelectedMap')->isDirty())->toBeFalse(); +}); + +it('creates a map without a kind at once in a project that has no tilesets', function () { + $root = layeredMapProject(); + [$editor] = layeredCanvasEditor($root); + setEditorProperty($editor, 'focusedPane', 'assets'); + + callEditorMethod($editor, 'dispatchInput', 'A'); + $data = require $root . '/assets/Maps/new-map/new-map.data.php'; + expect($data)->not->toHaveKey('tileset') + ->and(kindToast($editor)?->message)->toBe('Created new-map. It has no kind: the project has no tilesets in assets/Data/Tilesets/.'); +}); + +it('keeps the kind of a duplicated map', function () { + $root = mapGraphicsProject(); + $workspace = Ichiloto\Editor\ProjectWorkspace::fromProject($root); + $copy = $workspace->duplicateMap(0); + + expect((require $root . "/assets/Maps/{$copy}/" . basename($copy) . '.data.php')['tileset'])->toBe('home'); +}); From 98e98f744ad91c1dced4a40051b624b4ae28be6a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 30 Sep 2026 16:12:43 +0200 Subject: [PATCH 077/344] feat(validate): warn about a map without a kind In a project with tilesets, validation and the pre-save map checks now warn about a map that has no kind (names no tileset), since it has no tiles or pieces; the hint points at the Inspector's Kind field. A project without tilesets is not warned. The error for graphics/ on a map without a kind now says so in the same terms. --- docs/manual.md | 2 ++ src/Validation/MapGraphicsValidator.php | 19 +++++++++++++------ tests/Unit/MapGraphicsTest.php | 17 ++++++++++++++++- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 5cf4da9..cb44467 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -763,6 +763,8 @@ Enter opens the picker; the kind is stored as the map data's `tileset`. removes its `graphics/` tile layer files. - A kind the project no longer has stays visible as `id · not in assets/Data/Tilesets`. +- In a project with tilesets, validation and the pre-save checks warn about a + map without a kind. A project without tilesets is not warned. ### Map audio and encounters diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php index 81fc613..e38fa61 100644 --- a/src/Validation/MapGraphicsValidator.php +++ b/src/Validation/MapGraphicsValidator.php @@ -19,8 +19,10 @@ use Throwable; /** - * Checks a map's graphics as the Engine reads them: its tileset and the tile - * layers in `graphics/`, unsaved resizes included. + * Checks a map's graphics as the Engine reads them: its kind (the tileset it + * names) and the tile layers in `graphics/`, unsaved resizes included. In a + * project with tilesets every map should have a kind, so one without is + * warned about. * * Unusable graphics never stop a map loading; the game shows its terminal * glyphs instead. Every file is checked, so one report names every problem @@ -35,7 +37,8 @@ private function __construct() } /** - * @return list The issues found; empty for a map without graphics. + * @return list The issues found; empty for a map without graphics + * in a project without tilesets. */ public static function validate(ProjectMap $map): array { @@ -43,7 +46,11 @@ public static function validate(ProjectMap $map): array $tilesetId = $map->getMapDataField(['tileset']); if ($tilesetId === null && $sources === [] && ! is_dir($map->directory . '/' . MapGraphics::DIRECTORY)) { - return []; + return glob($map->getAssetRoot() . '/' . Tileset::DIRECTORY . '/*.php') === [] ? [] : [Issue::warning( + $map->mapId, + 'It has no kind, so it has no tiles or pieces.', + sprintf('Set its Kind in the Inspector, one of the tilesets in assets/%s. %s', Tileset::DIRECTORY, self::GLYPH_FALLBACK), + )]; } $issues = []; @@ -96,8 +103,8 @@ private static function loadTileset(ProjectMap $map, mixed $tilesetId, array &$i if ($tilesetId === null) { $issues[] = Issue::error( $map->mapId, - 'It has graphics/ but names no tileset.', - sprintf("Name one in the map data ('tileset' => '', from assets/%s/.php), or remove graphics/. %s", Tileset::DIRECTORY, self::GLYPH_FALLBACK), + 'It has graphics/ but no kind.', + sprintf('Set its Kind in the Inspector, one of the tilesets in assets/%s, or remove graphics/. %s', Tileset::DIRECTORY, self::GLYPH_FALLBACK), ); return null; diff --git a/tests/Unit/MapGraphicsTest.php b/tests/Unit/MapGraphicsTest.php index ee74718..4331f10 100644 --- a/tests/Unit/MapGraphicsTest.php +++ b/tests/Unit/MapGraphicsTest.php @@ -280,7 +280,7 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array 'graphics without a tileset' => [function (string $root, string $directory): void { $data = $directory . '/test-map.data.php'; file_put_contents($data, str_replace(" 'tileset' => 'home',", '', (string) file_get_contents($data))); - }, 'It has graphics/ but names no tileset.'], + }, 'It has graphics/ but no kind.'], 'badly named file' => [function (string $root, string $directory): void { writeTileLayer($directory, 'floor.tiles.php', "0 0 0 0\n0 0 0 0"); }, 'Tile layer test-map/graphics/floor.tiles.php must be named NN.name.tiles.php.'], @@ -335,6 +335,21 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array expect(MapGraphicsValidator::validate($map))->toBe([]); }); +it('warns about a map without a kind only in a project that has tilesets', function () { + $root = layeredMapProject(); + expect(MapGraphicsValidator::validate(loadLayeredMap($root)))->toBe([]); + + writeTestTileset($root, 'interior'); + $issues = MapGraphicsValidator::validate(loadLayeredMap($root)); + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::WARNING) + ->and($issues[0]->message)->toBe('It has no kind, so it has no tiles or pieces.') + ->and($issues[0]->hint)->toStartWith('Set its Kind in the Inspector, one of the tilesets in assets/Data/Tilesets.'); + + editTestMapData($root, static fn(string $source): string => str_replace("'events' => [],", "'events' => [], 'tileset' => 'interior',", $source)); + expect(MapGraphicsValidator::validate(loadLayeredMap($root)))->toBe([]); +}); + it('reports graphics through project validation, as ichiloto validate runs it', function () { $root = mapGraphicsProject(); unlink($root . '/assets/Data/Tilesets/home.php'); From 61ca4587a92fc11b2072ce81e213b6e7cc47fe13 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 30 Sep 2026 23:15:08 +0200 Subject: [PATCH 078/344] chore(deps): lock published engine develop Locks the Engine at 2e32b6f, the published develop these changes were tested against: square RPG Maker cells, glyph covers, character lift, NPC facing and map kinds. --- composer.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.lock b/composer.lock index d977829..1449258 100644 --- a/composer.lock +++ b/composer.lock @@ -260,12 +260,12 @@ "source": { "type": "git", "url": "https://github.com/ichiloto/engine.git", - "reference": "9011d7e2c07bc991cf523ec2da6e20072d88efe1" + "reference": "2e32b6f68bcfe441b639f57b5098147fe3a224dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ichiloto/engine/zipball/9011d7e2c07bc991cf523ec2da6e20072d88efe1", - "reference": "9011d7e2c07bc991cf523ec2da6e20072d88efe1", + "url": "https://api.github.com/repos/ichiloto/engine/zipball/2e32b6f68bcfe441b639f57b5098147fe3a224dc", + "reference": "2e32b6f68bcfe441b639f57b5098147fe3a224dc", "shasum": "" }, "require": { From 1ecf9320122562220762e5e44fc9ef6e3853c970 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 1 Oct 2026 02:49:00 +0200 Subject: [PATCH 079/344] fix(maps): order a new tile layer by the gameplay layer it belongs to A tile layer a piece creates was ordered after every existing tile layer, so grass painted on Terrain drew above an interior's floor, walls and furniture. A new tile layer now goes before the first tile layer that belongs to a gameplay layer drawn above its own (owners resolved as the engine resolves them: movesWith settings, else the pieces that write it), taking the free order just below it; when there is none, that layer and every later one move up one order, and saving writes their new files and removes the old ones. A layer whose owner is unknown still goes last. --- src/Maps/MapLayers.php | 47 ++++++++++++++++++++++++++++++++++-------- src/ProjectMap.php | 38 ++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/Maps/MapLayers.php b/src/Maps/MapLayers.php index 72fdd52..a876ab8 100644 --- a/src/Maps/MapLayers.php +++ b/src/Maps/MapLayers.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor\Maps; +use Closure; use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Engine\Field\MapGraphics; @@ -272,7 +273,7 @@ public function insertLines(string $axis, int $at, int $count): void * @param array>> $tiles Entries by row, keyed by tile layer name. * @throws MapSourceRefusal When a layer cannot be read, does not match the map, cannot be created, or the entries fall outside it. */ - public function writeTileEntries(array $tiles, int $x, int $y): void + public function writeTileEntries(array $tiles, int $x, int $y, ?Closure $placeBefore = null): void { if ($tiles === []) { return; @@ -280,7 +281,7 @@ public function writeTileEntries(array $tiles, int $x, int $y): void $set = $this->getLayerSet(); $sources = $this->tileSources; foreach ($tiles as $name => $rows) { - $path = $this->findTileLayerPath((string) $name, $sources) ?? $this->buildTileLayerPath((string) $name, $sources); + $path = $this->findTileLayerPath((string) $name, $sources) ?? $this->addTileLayerPath((string) $name, $sources, $placeBefore); $sources[$path] = TileLayerSource::writeEntries($sources[$path] ?? TileLayerSource::createEmpty($set), $this->getDisplayPath($path), $set, $x, $y, $rows, $this->baselineSources[$path] ?? null); } @@ -299,7 +300,7 @@ public function writeTileEntries(array $tiles, int $x, int $y): void * @param array> $cells The entry for each cell, keyed by tile layer name. * @throws MapSourceRefusal When a layer cannot be read, does not match the map, cannot be created, or a cell falls outside it. */ - public function writeTileCells(array $cells): void + public function writeTileCells(array $cells, ?Closure $placeBefore = null): void { $set = $this->getLayerSet(); $sources = $this->tileSources; @@ -309,7 +310,7 @@ public function writeTileCells(array $cells): void if (array_filter($entries, static fn(array $cell): bool => $cell['entry'] !== (string)TileId::EMPTY) === []) { continue; } - $path = $this->buildTileLayerPath((string) $name, $sources); + $path = $this->addTileLayerPath((string) $name, $sources, $placeBefore); } $sources[$path] = TileLayerSource::setCellEntries($sources[$path] ?? TileLayerSource::createEmpty($set), $this->getDisplayPath($path), $set, $entries, $this->baselineSources[$path] ?? null); @@ -393,20 +394,48 @@ private function findTileLayerPath(string $name, array $sources): ?string } /** - * The path for a new tile layer, ordered after the map's tile layers. + * Adds the path for a new tile layer: before the tile layer $placeBefore + * names, given the new layer's name and the map's tile layer names in + * order, or after them all when it names none. The new layer takes the + * free order just below that layer; when there is none, that layer and + * every later one move up one order to make room. * - * @param array $sources Tile layer sources by path. + * @param array $sources Tile layer sources by path, renumbered in place when room is made. + * @param (Closure(string, list): ?string)|null $placeBefore * @throws MapSourceRefusal When the map cannot take another tile layer. */ - private function buildTileLayerPath(string $name, array $sources): string + private function addTileLayerPath(string $name, array &$sources, ?Closure $placeBefore): string { $orders = []; foreach (array_keys($sources) as $path) { if (preg_match(MapGraphics::FILENAME_PATTERN, basename($path), $matches) === 1) { - $orders[] = (int) $matches['order']; + $orders[$path] = (int) $matches['order']; + } + } + asort($orders); + $names = array_values(array_map(static fn(string $path): string => + (preg_match(MapGraphics::FILENAME_PATTERN, basename($path), $matches) === 1 ? $matches['name'] : ''), array_keys($orders))); + $before = $placeBefore?->__invoke($name, $names); + $beforePath = $before === null ? null : $this->findTileLayerPath($before, $sources); + if ($beforePath === null) { + $order = $orders === [] ? 0 : max($orders) + 1; + } else { + $order = $orders[$beforePath]; + if ($order > 0 && !in_array($order - 1, $orders, true)) { + $order--; + } else { + $renamed = []; + foreach ($sources as $path => $source) { + $moves = isset($orders[$path]) && $orders[$path] >= $order; + $renamed[$moves ? preg_replace('/\/\d{2}\.(?=[^\/]+$)/', sprintf('/%02d.', $orders[$path] + 1), $path) : $path] = $source; + } + if (max($orders) + 1 > 99) { + throw new MapSourceRefusal(sprintf('Tile layer %s cannot be added: a map holds up to %d tile layers, ordered 00 to 99. Nothing was changed.', + $name, MapGraphics::MAX_LAYERS)); + } + $sources = $renamed; } } - $order = $orders === [] ? 0 : max($orders) + 1; if (count($sources) >= MapGraphics::MAX_LAYERS || $order > 99) { throw new MapSourceRefusal(sprintf('Tile layer %s cannot be added: a map holds up to %d tile layers, ordered 00 to 99. Nothing was changed.', $name, MapGraphics::MAX_LAYERS)); diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 7128d82..42f3bae 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -474,7 +474,7 @@ public function getTileLayerSources(): array public function writeTileEntries(array $tiles, int $x, int $y): void { $this->assertEditable(); - $this->layers->writeTileEntries($tiles, $x, $y); + $this->layers->writeTileEntries($tiles, $x, $y, $this->findNewTileLayerPlace(...)); $this->touchState(); } @@ -489,10 +489,44 @@ public function writeTileEntries(array $tiles, int $x, int $y): void public function writeTileCells(array $cells): void { $this->assertEditable(); - $this->layers->writeTileCells($cells); + $this->layers->writeTileCells($cells, $this->findNewTileLayerPlace(...)); $this->touchState(); } + /** + * Where a new tile layer goes among the map's tile layers: before the + * first that belongs to a gameplay layer drawn above the new layer's + * own, so terrain tiles draw under building tiles and those under + * fixtures. A layer whose owner is unknown goes last. + * + * @param list $names The map's tile layer names, in order. + * @return string|null The tile layer to place it before, or null for last. + */ + private function findNewTileLayerPlace(string $name, array $names): ?string + { + $gameplay = array_values(array_filter($this->layers->getLayers(), static fn(array $layer): bool => + $layer['id'] !== MapLayers::EVENT && ! $layer['decoration'])); + $rank = array_flip(array_column($gameplay, 'name')); + try { + $owners = MapGraphics::resolveLayerOwners($this->getMapDataField([MapGraphics::SETTINGS_KEY]), [...$names, $name], + array_keys($rank), $this->loadTileset(), $this->mapId); + } catch (InvalidArgumentException | RuntimeException) { + return null; + } + if (($owners[$name] ?? null) === null) { + return null; + } + $own = $rank[$owners[$name]]; + foreach ($names as $existing) { + $owner = $owners[$existing] ?? null; + if ($owner !== null && $rank[$owner] > $own) { + return $existing; + } + } + + return null; + } + /** * The tile layers whose tiles move with a gameplay layer's glyphs: those * that belong to it ({@see MapGraphics::resolveLayerOwners()}), named by From cedf9d4a0824ba75607a94799acb8fdee8b3b1d4 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 1 Oct 2026 02:49:00 +0200 Subject: [PATCH 080/344] feat(canvas): drag lines, rectangles and selections with the mouse The mouse now follows the active tool. With Line, Rectangle or Filled Rectangle in Paint mode, the press anchors the shape, the drag moves its other end with the shape previewed on the canvas, and the release draws it in one undo step in the brush colour; the right button erases it. With the Select tool, a Normal-mode drag selects; a click still only moves the cursor. A tool anchored with Enter previews its shape the same way, and the Normal-mode canvas border now names the tool keys (b/l/r/R/s:Tool). Changed behaviour: in Paint mode with a line or rectangle tool, the mouse no longer brushes single cells; it draws the tool's shape. --- docs/manual.md | 24 ++++-- src/Editor.php | 125 +++++++++++++++++++++++++++++-- tests/Unit/CanvasToolsTest.php | 66 ++++++++++++++++ tests/Unit/TilesetPiecesTest.php | 48 +++++++++++- 4 files changed, 251 insertions(+), 12 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index cb44467..240cbbb 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -311,8 +311,12 @@ whole tile. A field cell holds one whole RPG Maker tile, so an entry naming half a tile (`42L`) is refused: in a tileset piece the canvas offers no pieces and says which entry, in a tile layer the stamp changes nothing, and validation reports both. A tile layer the map does not have yet is created as -`graphics/NN.name.tiles.php`, ordered after the map's tile layers and empty -elsewhere. The terminal never shows the tiles, but a map built from pieces +`graphics/NN.name.tiles.php`, empty elsewhere, in the order of the gameplay +layer it belongs to: before the first tile layer of a gameplay layer drawn +above its own, so terrain tiles draw under building tiles and those under +fixtures, and otherwise after them all. It takes the free order just below +that layer; when there is none, that layer and every later one move up one +order, and saving writes their new files and removes the old ones. The terminal never shows the tiles, but a map built from pieces draws correctly graphically without a second pass. A stamp that cannot be made whole changes nothing: the map has no gameplay layer with the piece's name, the footprint does not fit inside the map at the cursor (ragged rows @@ -516,9 +520,19 @@ to Normal. The mouse honors the canvas's modality. In Normal mode a click **selects**: the cursor jumps to the clicked cell and the status line reads out its coordinates - the fastest way to find a tile's position for a spawn point or -event without walking the cursor there. In Paint mode a left click paints -the brush symbol and a right click erases, dragging paints a stroke, and in -Event mode clicks keep their event-editing behavior. +event without walking the cursor there. With the Select tool, dragging in +Normal mode selects the rectangle from where the press began to where it is +released; a click still only moves the cursor. In Paint mode a left click +paints the brush symbol and a right click erases, dragging paints a stroke, +and in Event mode clicks keep their event-editing behavior. + +The mouse follows the active tool. With Line, Rectangle or Filled Rectangle +in Paint mode, the press sets the anchor, dragging moves the other end with +the shape previewed on the canvas, and releasing draws it in one undo step, +in the brush colour; dragging with the right button erases the shape. The +same preview shows while a tool anchored with `Enter` waits for its second +`Enter`. In Normal mode the canvas border names the tool keys +(`b/l/r/R/s:Tool`). The wheel scrolls the viewport without moving the cursor - free look for surveying a map larger than the canvas (horizontal wheel scrolls sideways). diff --git a/src/Editor.php b/src/Editor.php index 72314ec..05c3964 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -394,6 +394,11 @@ final class Editor } } private ?MouseButton $activeMousePaintButton = null; + /** + * @var array{start: array{x: int, y: int}, symbol: string}|null A line, rectangle or selection being dragged out + * with the mouse: the cell it started on and the symbol it draws (a space erases). + */ + private ?array $mouseToolDrag = null; /** * @var array{x: int, y: int}|null */ @@ -4422,6 +4427,12 @@ private function handleMouseInput(string $input): bool $this->finalizeActiveStroke(); $this->activeMousePaintButton = null; $this->lastMousePaintPoint = null; + $this->mouseToolDrag = null; + return true; + } + + if ($event->isRelease && $this->mouseToolDrag !== null) { + $this->completeMouseToolDrag($event); return true; } @@ -4522,6 +4533,40 @@ private function getCanvasPreviewBounds(?array $layout = null): array ]; } + /** + * Ends a mouse drag of a line, rectangle or selection where the button + * was released (or at the last cell the drag reached, when released off + * the map). A selection that never left its first cell is a click: it + * moves the cursor there, as a Normal-mode click does. + */ + private function completeMouseToolDrag(MouseEvent $event): void + { + $drag = $this->mouseToolDrag; + $this->mouseToolDrag = null; + $bounds = $this->getCanvasPreviewBounds(); + $selectedMap = $this->getSelectedMap(); + if ($selectedMap instanceof ProjectMap && $event->x >= $bounds['left'] && $event->x <= $bounds['right'] + && $event->y >= $bounds['top'] && $event->y <= $bounds['bottom']) { + $this->cursorX = max(0, min($selectedMap->getWidth() - 1, $this->canvasOffsetX + ($event->x - $bounds['left']))); + $this->cursorY = max(0, min($selectedMap->getHeight() - 1, $this->canvasOffsetY + ($event->y - $bounds['top']))); + } + $anchor = $this->canvasToolAnchor ?? $drag['start']; + $this->canvasToolAnchor = null; + if (! $selectedMap instanceof ProjectMap) { + return; + } + if ($this->canvasTool === CanvasTool::SELECT) { + if ($anchor === ['x' => $this->cursorX, 'y' => $this->cursorY]) { + $this->statusMessage = sprintf('Cursor at (%d, %d).', $this->cursorX, $this->cursorY); + $this->renderCanvasArea(); + return; + } + $this->completeCanvasSelection($anchor); + return; + } + $this->drawCanvasToolShape($selectedMap, $anchor, $drag['symbol']); + } + /** Handles mouse editing over the canvas preview. */ private function handleCanvasMouseEdit(MouseEvent $event): bool { @@ -4559,6 +4604,29 @@ private function handleCanvasMouseEdit(MouseEvent $event): bool return true; } + // A tool that spans an anchor and a corner follows a mouse drag: the + // press anchors it, the drag moves the corner with a live preview, + // and the release draws (or selects). The right button erases. + $dragsShape = $this->editingMode === self::MODE_MAP && $this->getActivePiecePlacement() === null && match ($this->inputMode) { + self::INPUT_PAINT => in_array($this->canvasTool, [CanvasTool::LINE, CanvasTool::RECTANGLE, CanvasTool::FILLED_RECTANGLE], true), + self::INPUT_NORMAL => $this->canvasTool === CanvasTool::SELECT && $event->button === MouseButton::LEFT_BUTTON, + default => false, + }; + if ($dragsShape) { + if (! $event->isMotion || $this->mouseToolDrag === null) { + $this->mouseToolDrag = ['start' => ['x' => $targetX, 'y' => $targetY], + 'symbol' => $event->button === MouseButton::RIGHT_BUTTON ? ' ' : $this->selectedPaintSymbol]; + $this->canvasToolAnchor = ['x' => $targetX, 'y' => $targetY]; + } + $this->cursorX = $targetX; + $this->cursorY = $targetY; + $this->statusMessage = sprintf('%s from (%d, %d) to (%d, %d). Release to %s.', $this->canvasTool->label(), + $this->canvasToolAnchor['x'], $this->canvasToolAnchor['y'], $targetX, $targetY, + $this->canvasTool === CanvasTool::SELECT ? 'select' : 'draw'); + $focusChanged ? $this->renderFocusDependentArea() : $this->renderCanvasArea(); + return true; + } + if ($this->editingMode === self::MODE_MAP && $this->inputMode === self::INPUT_NORMAL) { // The mouse honors the canvas's modality: in Normal mode a click // selects the cell, moving the cursor and reading out its @@ -5170,6 +5238,18 @@ private function applyCanvasToolAtCursor(): void return; } + $this->drawCanvasToolShape($selectedMap, $anchor, $this->selectedPaintSymbol); + } + + /** + * The cells the active line or rectangle tool covers from an anchor to + * the cursor, widened by the brush except for a filled rectangle. + * + * @param array{x: int, y: int} $anchor + * @return array + */ + private function getCanvasToolShapeCells(array $anchor): array + { $cells = match ($this->canvasTool) { CanvasTool::LINE => ToolGeometry::line($anchor['x'], $anchor['y'], $this->cursorX, $this->cursorY), CanvasTool::RECTANGLE => ToolGeometry::rectangleOutline($anchor['x'], $anchor['y'], $this->cursorX, $this->cursorY), @@ -5179,13 +5259,42 @@ private function applyCanvasToolAtCursor(): void // The brush thickens outlines and lines; a filled rectangle is // already solid, so widening it would only spill past the corners. - if ($this->canvasTool !== CanvasTool::FILLED_RECTANGLE) { - $cells = ToolGeometry::expandByBrush($cells, $this->canvasBrushSize); + return $this->canvasTool === CanvasTool::FILLED_RECTANGLE ? $cells : ToolGeometry::expandByBrush($cells, $this->canvasBrushSize); + } + + /** + * What the canvas previews over the map: the piece being placed, or the + * shape a line or rectangle tool will draw from its anchor to the cursor. + * + * @return array> Symbols by row and column. + */ + private function getCanvasPreviewCells(): array + { + $cells = $this->getPiecePreviewCells(); + if ($cells !== [] || ! is_array($this->canvasToolAnchor) + || ! in_array($this->canvasTool, [CanvasTool::LINE, CanvasTool::RECTANGLE, CanvasTool::FILLED_RECTANGLE], true)) { + return $cells; + } + $symbol = $this->mouseToolDrag['symbol'] ?? $this->selectedPaintSymbol; + foreach ($this->getCanvasToolShapeCells($this->canvasToolAnchor) as $cell) { + $cells[$cell['y']][$cell['x']] = $symbol; } + return $cells; + } + + /** + * Draws the active line or rectangle tool's shape from an anchor to the + * cursor in one undo step, painting the symbol (a space erases). + * + * @param array{x: int, y: int} $anchor + */ + private function drawCanvasToolShape(ProjectMap $selectedMap, array $anchor, string $symbol): void + { + $cells = $this->getCanvasToolShapeCells($anchor); $tool = $this->canvasTool; - $paint = function (?array $choices = null) use ($selectedMap, $cells, $tool, &$paint): void { - $changed = $this->paintCanvasCells($selectedMap, $cells, $this->selectedPaintSymbol, $tool->commandLabel(), $paint, $choices); + $paint = function (?array $choices = null) use ($selectedMap, $cells, $tool, $symbol, &$paint): void { + $changed = $this->paintCanvasCells($selectedMap, $cells, $symbol, $tool->commandLabel(), $paint, $choices); if ($changed === null) { $this->renderCanvasArea(); @@ -5197,7 +5306,7 @@ private function applyCanvasToolAtCursor(): void $tool->label(), $changed, $changed === 1 ? '' : 's', - $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, + $symbol === ' ' ? 'space' : $symbol, )); $this->renderCanvasArea(); }; @@ -18498,6 +18607,10 @@ private function createCanvasWindow(): EditorWindow $this->getActivePiecePlacement() !== null => $this->getPiecePlacementHelp($layout['centerWidth']) ?? '', default => $this->fitHelp( $layout['centerWidth'], + 'i:Paint b/l/r/R/s:Tool L:Layer P:Piece o:Colour v:Hide d:Dim ?:Help', + 'i:Paint b/l/r/R/s:Tool L:Layer P:Piece o:Colour ?:Help', + 'i:Paint b/l/r/R/s:Tool L:Layer P:Piece ?:Help', + 'i:Paint b/l/r/R/s:Tool L:Layer ?:Help', 'i:Paint L:Layer P:Piece v:Hide d:Dim o:Colour ?:Help', 'i:Paint L:Layer P:Piece v:Hide d:Dim', 'i:Paint L:Layer P:Piece ?:Help', @@ -18528,7 +18641,7 @@ private function createCanvasWindow(): EditorWindow $this->getActiveCanvasLayer(), false, $this->getCanvasLayerState()['dim'], - $this->getPiecePreviewCells(), + $this->getCanvasPreviewCells(), ) ?? [], $contentWidth, $contentHeight diff --git a/tests/Unit/CanvasToolsTest.php b/tests/Unit/CanvasToolsTest.php index 3a601c5..4239770 100644 --- a/tests/Unit/CanvasToolsTest.php +++ b/tests/Unit/CanvasToolsTest.php @@ -777,3 +777,69 @@ function canvasCellAt(Editor $editor, int $mapX, int $mapY): array expect($map->getTileColor($x, 2))->toBeNull(); } }); + +it('draws a filled rectangle by dragging in Paint mode, previewing it on the way, as one undo step', function () { + $editor = canvasEditor(); + $before = tileRows($editor); + setEditorProperty($editor, 'selectedPaintSymbol', 'Q'); + callEditorMethod($editor, 'dispatchInput', 'R'); + callEditorMethod($editor, 'dispatchInput', 'i'); + [$fromColumn, $fromRow] = canvasCellAt($editor, 1, 1); + [$overColumn, $overRow] = canvasCellAt($editor, 2, 2); + [$toColumn, $toRow] = canvasCellAt($editor, 3, 2); + + callEditorMethod($editor, 'dispatchInput', mousePress(0, $fromColumn, $fromRow)); + callEditorMethod($editor, 'dispatchInput', mousePress(32, $overColumn, $overRow)); + // Nothing is painted while dragging; the canvas previews the shape. + expect(tileRows($editor))->toBe($before) + ->and(callEditorMethod($editor, 'getCanvasPreviewCells'))->toBe([1 => [1 => 'Q', 2 => 'Q'], 2 => [1 => 'Q', 2 => 'Q']]); + + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $toColumn, $toRow)); + $rows = tileRows($editor); + expect(substr($rows[1], 1, 3))->toBe('QQQ')->and(substr($rows[2], 1, 3))->toBe('QQQ') + ->and(getEditorProperty($editor, 'canvasToolAnchor'))->toBeNull() + ->and(callEditorMethod($editor, 'getCanvasPreviewCells'))->toBe([]); + + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect(tileRows($editor))->toBe($before); +}); + +it('erases a dragged line with the right button', function () { + $editor = canvasEditor(); + setEditorProperty($editor, 'selectedPaintSymbol', 'Q'); + callEditorMethod($editor, 'dispatchInput', 'l'); + callEditorMethod($editor, 'dispatchInput', 'i'); + [$fromColumn, $fromRow] = canvasCellAt($editor, 0, 0); + [$toColumn, $toRow] = canvasCellAt($editor, 2, 0); + + callEditorMethod($editor, 'dispatchInput', mousePress(2, $fromColumn, $fromRow)); + callEditorMethod($editor, 'dispatchInput', mousePress(34, $toColumn, $toRow)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<2;%d;%dm", $toColumn, $toRow)); + + expect(substr(tileRows($editor)[0], 0, 3))->toBe(' '); +}); + +it('selects by dragging with the Select tool in Normal mode, and a click still only moves the cursor', function () { + $editor = canvasEditor(); + $before = tileRows($editor); + callEditorMethod($editor, 'dispatchInput', 's'); + [$fromColumn, $fromRow] = canvasCellAt($editor, 1, 1); + [$toColumn, $toRow] = canvasCellAt($editor, 3, 2); + + callEditorMethod($editor, 'dispatchInput', mousePress(0, $fromColumn, $fromRow)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $fromColumn, $fromRow)); + expect(getEditorProperty($editor, 'canvasSelection'))->toBeNull() + ->and([getEditorProperty($editor, 'cursorX'), getEditorProperty($editor, 'cursorY')])->toBe([1, 1]); + + callEditorMethod($editor, 'dispatchInput', mousePress(0, $fromColumn, $fromRow)); + callEditorMethod($editor, 'dispatchInput', mousePress(32, $toColumn, $toRow)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $toColumn, $toRow)); + expect(getEditorProperty($editor, 'canvasSelection'))->toBe(['x' => 1, 'y' => 1, 'width' => 3, 'height' => 2]) + ->and(tileRows($editor))->toBe($before); +}); + +it('names the tool keys in the Normal-mode canvas border', function () { + $editor = canvasEditor(); + + expect(callEditorMethod($editor, 'createCanvasWindow')->help)->toContain('b/l/r/R/s:Tool'); +}); diff --git a/tests/Unit/TilesetPiecesTest.php b/tests/Unit/TilesetPiecesTest.php index ebacc14..1a2c653 100644 --- a/tests/Unit/TilesetPiecesTest.php +++ b/tests/Unit/TilesetPiecesTest.php @@ -178,7 +178,7 @@ function choosePieceByKeys(Editor $editor, string $id = 'bed'): void ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 24))->toBe('Enter:Stamp Esc:Done') ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 12))->toBe('Esc:Done'); callEditorMethod($editor, 'dispatchInput', "\033"); - expect(renderEditorPlainFrame($editor, 200, 45))->toContain('i:Paint L:Layer P:Piece'); + expect(renderEditorPlainFrame($editor, 200, 45))->toContain('i:Paint b/l/r/R/s:Tool L:Layer P:Piece'); }); it('stamps glyphs on the piece\'s layer and its tiles in one undo step', function () { @@ -255,6 +255,52 @@ function choosePieceByKeys(Editor $editor, string $id = 'bed'): void ->not->toBeNull(); }); +it('orders a new tile layer by the gameplay layer it belongs to', function () { + [$editor, $map, $root] = createPieceCanvasEditor(); + // floor belongs to Terrain (the rug writes it) and decor to Buildings (the bed). + writeTestTileset($root, pieces: [...buildTestPieces(), + 'grass' => ['name' => 'Grass', 'layer' => 'terrain', 'glyphs' => [';'], 'tiles' => ['ground' => ['2864']]]]); + $graphics = $map->directory . '/graphics'; + + // A Terrain layer goes under Buildings' decor; with no free order below + // decor, decor and every later layer move up one. + callEditorMethod($editor, 'choosePiece', 'grass'); + callEditorMethod($editor, 'dispatchInput', "\n"); + expect(array_map(basename(...), array_keys($map->getTileLayerSources()))) + ->toBe(['01.floor.tiles.php', '02.ground.tiles.php', '03.decor.tiles.php']); + + // A Buildings layer goes after every layer of Buildings and below. + callEditorMethod($editor, 'dispatchInput', "\033"); + callEditorMethod($editor, 'choosePiece', 'bed'); + callEditorMethod($editor, 'dispatchInput', "\033[C"); + callEditorMethod($editor, 'dispatchInput', "\033[C"); + callEditorMethod($editor, 'dispatchInput', "\n"); + expect(array_map(basename(...), array_keys($map->getTileLayerSources()))) + ->toBe(['01.floor.tiles.php', '02.ground.tiles.php', '03.decor.tiles.php', '04.furniture.tiles.php']); + + // Saving writes the renumbered layer and removes its old file; undo restores the order. + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(array_map(basename(...), glob($graphics . '/*.tiles.php'))) + ->toBe(['01.floor.tiles.php', '02.ground.tiles.php', '03.decor.tiles.php', '04.furniture.tiles.php']); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect(array_map(basename(...), array_keys($map->getTileLayerSources())))->toBe(['01.floor.tiles.php', '02.decor.tiles.php']); +}); + +it('puts a new layer in the free order just below a higher layer without renumbering', function () { + [$editor, $map, $root] = createPieceCanvasEditor(); + rename($map->directory . '/graphics/01.floor.tiles.php', $map->directory . '/graphics/00.floor.tiles.php'); + rename($map->directory . '/graphics/02.decor.tiles.php', $map->directory . '/graphics/05.decor.tiles.php'); + writeTestTileset($root, pieces: [...buildTestPieces(), + 'grass' => ['name' => 'Grass', 'layer' => 'terrain', 'glyphs' => [';'], 'tiles' => ['ground' => ['2864']]]]); + [$editor, $map] = layeredCanvasEditor($root); + + callEditorMethod($editor, 'choosePiece', 'grass'); + callEditorMethod($editor, 'dispatchInput', "\n"); + expect(array_map(basename(...), array_keys($map->getTileLayerSources()))) + ->toBe(['00.floor.tiles.php', '04.ground.tiles.php', '05.decor.tiles.php']); +}); + it('refuses a stamp that cannot be made whole and changes nothing', function (string $case, string $message) { [$editor, $map, $root] = createPieceCanvasEditor($case === 'ragged'); if ($case === 'unreadable tile layer' || $case === 'half-tile layer') { From 16b60cc649ed0409be9632f456c45c60bfdd7c76 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 1 Oct 2026 03:18:24 +0200 Subject: [PATCH 081/344] feat(pieces): draw the tiles for the glyphs already on a layer T in Normal mode, or "Pieces: Draw tiles for this layer's glyphs" in the command palette, draws the tiles for every glyph already on the layer being edited, as if each were painted again, so a map authored before its pieces existed draws correctly graphically without a per-map generator script. A glyph that could be several pieces asks which, as painting does; the glyphs themselves and glyphs no piece draws are left as they are. It is one undo step, saved with the map. --- docs/manual.md | 10 ++++ src/Canvas/GlyphTileCanvas.php | 69 ++++++++++++++++++++++++++++ src/Canvas/PieceCanvas.php | 3 ++ src/Editor.php | 1 + tests/Unit/TilesFollowGlyphsTest.php | 30 ++++++++++++ tests/Unit/TilesetPiecesTest.php | 4 +- 6 files changed, 115 insertions(+), 2 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 240cbbb..e91ce21 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -290,6 +290,15 @@ choosing one returns to Map mode. Move with the arrows or `j` / `k`, press `/` to filter by name, `Enter` to choose and `Esc` to cancel. The command palette offers the same picker as `Pieces: Choose a piece to place`. +`T` in Normal mode (or `Pieces: Draw tiles for this layer's glyphs` in the +command palette) draws the tiles for the glyphs already on the layer being +edited, as if each were painted again: every glyph a piece draws gets that +piece's tiles, so a map authored before its pieces existed, or before it had +a kind, draws correctly graphically without a per-map script. A glyph that +could be several pieces asks which, as painting does, and the answer applies +wherever its neighbours do not decide. The glyphs themselves, and glyphs no +piece draws, are left as they are. It is one undo step, saved with the map. + When the layer being edited has no pieces, the status line names the layers that do. When the map has no kind yet, or its tileset has no pieces or cannot be loaded, it says so and nothing opens. Placing a piece never chooses a map's @@ -488,6 +497,7 @@ and the GUI itself are not delivered by this TUI boundary correction. See the | `n` / `F3` | Toggle NPC mode (place and edit the map's NPCs) | | `c` | Open the character map | | `P` | Choose a tileset piece to place (see [Pieces](#pieces)) | +| `T` | Draw the tiles for the glyphs already on this layer (see [Pieces](#pieces)) | | `o` | Open the brush colour picker (see [Colour](#colour)) | | `b` / `l` / `r` / `R` / `s` | Choose a tool: Brush, Line, Rectangle, Filled Rectangle, Select | | `f` | Flood fill from the cursor (same as `Ctrl+F`) | diff --git a/src/Canvas/GlyphTileCanvas.php b/src/Canvas/GlyphTileCanvas.php index e09879f..f5b7ceb 100644 --- a/src/Canvas/GlyphTileCanvas.php +++ b/src/Canvas/GlyphTileCanvas.php @@ -5,6 +5,7 @@ namespace Ichiloto\Editor\Canvas; use Closure; +use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\ProjectMap; @@ -167,6 +168,74 @@ private function planGlyphTiles(ProjectMap $map, string $layerId, array $writes, ); } + /** + * Draws the tiles of the glyphs already on the layer being edited, as if + * each were painted again: every glyph a piece draws gets that piece's + * tiles, in one undo step, unsaved until the map is saved. A glyph that + * could be several pieces asks first, as painting does, and the answer + * applies wherever its neighbours do not decide. Glyphs no piece draws, + * and the glyphs themselves, are left as they are. + * + * @param array $choices The role key chosen for a glyph, or null for no tiles. + */ + private function drawTilesForLayerGlyphs(array $choices = []): void + { + $map = $this->getSelectedMap(); + if (! $map instanceof ProjectMap || $map->getGridSourceIssue() !== null) { + return; + } + $layerId = $this->getActiveCanvasLayer(); + $layer = array_find($map->getLayers(), static fn(array $candidate): bool => $candidate['id'] === $layerId); + $label = MapLayers::formatLabel((string) ($layer['name'] ?? $layerId)); + $writes = []; + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x < $map->getWidth(); $x++) { + if ($map->hasLayerCell($layerId, $x, $y) && ($symbol = $map->getLayerSymbol($layerId, $x, $y)) !== ' ') { + $writes[] = ['x' => $x, 'y' => $y, 'symbol' => $symbol]; + } + } + } + try { + $plan = $this->planGlyphTiles($map, $layerId, $writes, $choices, true); + } catch (MapSourceRefusal | InvalidArgumentException | RuntimeException $refusal) { + $this->setStatus('Tiles were not drawn: ' . $refusal->getMessage(), StatusLevel::WARN); + $this->renderFooter(); + return; + } + if ($plan === null) { + $this->setStatus(sprintf('The %s layer has no pieces in this map\'s kind to draw tiles for.', $label), StatusLevel::WARN); + $this->renderFooter(); + return; + } + if ($plan['unresolved'] !== []) { + $glyph = (string) array_key_first($plan['unresolved']); + $this->askForGlyphPiece($glyph, $plan['unresolved'][$glyph], $choices, + fn(array $chosen) => $this->drawTilesForLayerGlyphs($chosen)); + return; + } + $before = $map->getTileLayerSources(); + try { + $map->writeTileCells($plan['tiles']); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderFooter(); + return; + } + $after = $map->getTileLayerSources(); + if ($after === $before) { + $this->setStatus(sprintf('Every glyph on the %s layer already has its tiles.', $label)); + $this->renderFooter(); + return; + } + $this->recordCommand(new GenericCommand(sprintf('Draw %s tiles', $label), + static fn() => $map->restoreTileLayerSources($after), + static fn() => $map->restoreTileLayerSources($before))); + $cells = array_sum(array_map(count(...), $plan['tiles'])); + $this->setStatus(sprintf('Drew %d tile%s for the %s layer\'s glyphs; save to keep them.', $cells, $cells === 1 ? '' : 's', $label), + StatusLevel::INFO); + $this->renderCanvasArea(); + } + /** The planner for a gameplay layer's pieces, or null when it has none to follow. */ private function loadGlyphTilePlanner(ProjectMap $map, string $layerId, array $excludedLayers = []): ?GlyphTilePlanner { diff --git a/src/Canvas/PieceCanvas.php b/src/Canvas/PieceCanvas.php index bde8902..a5faa93 100644 --- a/src/Canvas/PieceCanvas.php +++ b/src/Canvas/PieceCanvas.php @@ -552,6 +552,9 @@ private function buildPiecePaletteItems(): array return [new PaletteItem('Pieces: Choose a piece to place', 'P', function (): void { $this->closeDatabaseIfOpen(); $this->openPiecePicker(); + }), new PaletteItem('Pieces: Draw tiles for this layer\'s glyphs', 'T', function (): void { + $this->closeDatabaseIfOpen(); + $this->drawTilesForLayerGlyphs(); })]; } } diff --git a/src/Editor.php b/src/Editor.php index 05c3964..1b565c5 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -1401,6 +1401,7 @@ private function buildInputRouter(): InputRouter KeyBinding::when($this->isNormalModeCommand('i'), $this->enterPaintMode(...), 'i', 'Canvas: enter Paint mode (every key paints; Esc returns to Normal)'), KeyBinding::when($this->isNormalModeCommand('L'), fn() => $this->openCanvasLayerPicker(), 'L', 'Canvas: choose the layer to edit'), KeyBinding::when($this->isNormalModeCommand('P'), fn() => $this->openPiecePicker(), 'P', 'Canvas: choose a tileset piece to place (Enter stamps or draws it, Esc when done)'), + KeyBinding::when($this->isNormalModeCommand('T'), fn() => $this->drawTilesForLayerGlyphs(), 'T', 'Canvas: draw the tiles for the glyphs already on this layer'), KeyBinding::when($this->isNormalModeCommand(']'), fn() => $this->cycleCanvasLayer(), ']', 'Canvas: next layer'), KeyBinding::when($this->isNormalModeCommand('['), fn() => $this->cycleCanvasLayer(-1), '[', 'Canvas: previous layer'), KeyBinding::when($this->isNormalModeCommand('v'), fn() => $this->toggleCanvasLayerVisibility(), 'v', 'Canvas: toggle selected layer visibility'), diff --git a/tests/Unit/TilesFollowGlyphsTest.php b/tests/Unit/TilesFollowGlyphsTest.php index d3d97d5..1cff42d 100644 --- a/tests/Unit/TilesFollowGlyphsTest.php +++ b/tests/Unit/TilesFollowGlyphsTest.php @@ -173,3 +173,33 @@ function choosePieceFor(Editor $editor, string $label): void expect($map->getTileLayerSources())->toBe($sources) ->and($map->getLayerSymbol('map:4', 1, 1))->toBe('x'); }); + +it('draws the tiles for the glyphs already on a layer with T, asking about a shared glyph, in one undo step', function () { + [$editor, $map] = createFollowingTilesEditor(); + // The buildings layer already holds ` / ` over ` xx `, authored before + // any piece drew tiles for them. + writeTestTileset(dirname($map->directory, 3), pieces: [ + 'crate' => ['name' => 'Crate', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['60']]], + 'banner-red' => ['name' => 'Red banner', 'layer' => 'buildings', 'glyphs' => ['/'], 'tiles' => ['furniture' => ['70']]], + 'banner-blue' => ['name' => 'Blue banner', 'layer' => 'buildings', 'glyphs' => ['/'], 'tiles' => ['furniture' => ['71']]], + ]); + $glyphs = [$map->getLayerSymbol('map:4', 1, 0), $map->getLayerSymbol('map:4', 1, 1), $map->getLayerSymbol('map:4', 2, 1)]; + setEditorProperty($editor, 'focusedPane', 'canvas'); + + callEditorMethod($editor, 'dispatchInput', 'T'); + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Piece for /') + ->and(readFurnitureRows($map))->toBe([['0', '0', '0', '0'], ['0', '0', '0', '0']]); + + choosePieceFor($editor, 'Blue banner'); + expect(readFurnitureRows($map))->toBe([['0', '71', '0', '0'], ['0', '60', '60', '0']]) + ->and([$map->getLayerSymbol('map:4', 1, 0), $map->getLayerSymbol('map:4', 1, 1), $map->getLayerSymbol('map:4', 2, 1)])->toBe($glyphs) + ->and($map->isDirty())->toBeTrue(); + + // Drawing again changes nothing. + callEditorMethod($editor, 'dispatchInput', 'T'); + choosePieceFor($editor, 'Blue banner'); + expect(getEditorProperty($editor, 'toasts')->current()?->message)->toBe('Every glyph on the Buildings layer already has its tiles.'); + + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect(readFurnitureRows($map))->toBe([['0', '0', '0', '0'], ['0', '0', '0', '0']]); +}); diff --git a/tests/Unit/TilesetPiecesTest.php b/tests/Unit/TilesetPiecesTest.php index 1a2c653..36f9161 100644 --- a/tests/Unit/TilesetPiecesTest.php +++ b/tests/Unit/TilesetPiecesTest.php @@ -106,12 +106,12 @@ function choosePieceByKeys(Editor $editor, string $id = 'bed'): void ->and($map->isDirty())->toBeFalse(); }); -it('offers one piece entry in the command palette', function () { +it('offers choosing a piece and drawing a layer's tiles in the command palette', function () { [$editor] = createPieceCanvasEditor(); $labels = array_map(static fn($item): string => $item->label . ' ' . $item->hint, callEditorMethod($editor, 'buildPaletteItems')); expect(array_values(array_filter($labels, static fn(string $label): bool => str_starts_with($label, 'Pieces:')))) - ->toBe(['Pieces: Choose a piece to place P']); + ->toBe(['Pieces: Choose a piece to place P', "Pieces: Draw tiles for this layer's glyphs T"]); }); it('says why a map has no pieces and opens nothing', function (string $case, string $message) { From 61833d9bedd8fc3a58d4f87e4e9cdd74a1784f2a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 1 Oct 2026 03:19:50 +0200 Subject: [PATCH 082/344] test(pieces): escape the apostrophe in the palette test's name 16b60cc renamed the test with an unescaped apostrophe, a parse error that stopped TilesetPiecesTest from running. --- tests/Unit/TilesetPiecesTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/TilesetPiecesTest.php b/tests/Unit/TilesetPiecesTest.php index 36f9161..9563eab 100644 --- a/tests/Unit/TilesetPiecesTest.php +++ b/tests/Unit/TilesetPiecesTest.php @@ -106,7 +106,7 @@ function choosePieceByKeys(Editor $editor, string $id = 'bed'): void ->and($map->isDirty())->toBeFalse(); }); -it('offers choosing a piece and drawing a layer's tiles in the command palette', function () { +it('offers choosing a piece and drawing a layer\'s tiles in the command palette', function () { [$editor] = createPieceCanvasEditor(); $labels = array_map(static fn($item): string => $item->label . ' ' . $item->hint, callEditorMethod($editor, 'buildPaletteItems')); From 634623557b4f4f5c5d55ee6dd6107a837b52713c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 1 Oct 2026 15:54:22 +0200 Subject: [PATCH 083/344] feat(validation): report what the graphical field still shows as glyphs Project validation (Ctrl+E, ichiloto validate) now reports, for each map with a kind, what still has no graphics, using the Engine's glyph fallback rule: glyph cells no tile covers, grouped by glyph and layer (or one warning for a map with no tiles yet), NPCs without a field sprite, and copies of an NPC's own glyph in the map that show under its sprite. These are art still to do, so the pre-save checks leave them out and a save stays green. --- docs/manual.md | 14 +++ src/Validation/MapGraphicsValidator.php | 108 ++++++++++++++++++++++++ src/Validation/ProjectValidator.php | 1 + tests/Unit/MapGraphicsTest.php | 72 +++++++++++++++- 4 files changed, 191 insertions(+), 4 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index e91ce21..a1721c5 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -437,6 +437,20 @@ invalid tile identities are errors. Unusable sheets and tiles from a sheet the tileset does not provide are warnings. The game shows terminal glyphs for anything it cannot draw. +For a map with a kind, validation also warns about what the graphical field +still shows as terminal glyphs, using the Engine's rule for which tiles hide +which glyphs: + +- glyph cells no tile covers, grouped by glyph and layer, or a single warning + when the map has no tiles yet; +- NPCs without a field sprite (`sprites2d`) whose glyph therefore shows; an + NPC with an empty sprite draws nothing and is not counted; +- a copy of an NPC's own glyph in the map, which shows under its sprite; + remove the copy when the NPC is always there. + +These are art still to do, not faults: the game plays the same either way, +so saving a map does not warn about them; `Ctrl+E` and `ichiloto validate` do. + Text catalogues in `assets/Graphics/Tilesets/*.txt` appear as Facade brushes in `Ctrl+P`. Separate multi-row shapes with blank lines. Select a brush to target the gameplay `buildings` layer, then press `Enter` to stamp it as one undo diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php index e38fa61..ce3a5ec 100644 --- a/src/Validation/MapGraphicsValidator.php +++ b/src/Validation/MapGraphicsValidator.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor\Validation; +use Ichiloto\Editor\Field\ProjectNpc; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Maps\TileLayerSource; use Ichiloto\Editor\ProjectMap; @@ -27,10 +28,15 @@ * Unusable graphics never stop a map loading; the game shows its terminal * glyphs instead. Every file is checked, so one report names every problem * rather than only the first the Engine meets. + * + * {@see validateCoverage()} reports, apart, what a map with a kind still shows + * as terminal glyphs in the graphical field. */ final class MapGraphicsValidator { private const string GLYPH_FALLBACK = 'The game shows this map\'s terminal glyphs instead of its graphics.'; + /** How many cells a coverage warning names before summing up the rest. */ + private const int CELLS_NAMED = 3; private function __construct() { @@ -69,6 +75,108 @@ public static function validate(ProjectMap $map): array return $issues; } + /** + * Warns for what a map with a kind still shows as terminal glyphs in the + * graphical field, by the Engine's glyph fallback rule: cells no tile + * covers, NPCs without a field sprite, and copies of an NPC's glyph in the + * map showing under its sprite. These are art still to do, not faults: + * the game plays the same, so project validation reports them and the + * pre-save checks do not. Graphics the Engine refuses are + * {@see validate()}'s to report; only the readable tile layers count here. + * + * @return list + */ + public static function validateCoverage(ProjectMap $map): array + { + $ignored = []; + $tileset = is_string($tilesetId = $map->getMapDataField(['tileset'])) ? self::loadTileset($map, $tilesetId, $ignored) : null; + if ($tileset === null) { + return []; + } + $layers = self::readLayers($map, $map->getTileLayerSources(), $ignored); + + try { + $layerSet = $map->getLayerSet(); + $owners = MapGraphics::resolveLayerOwners($map->getMapDataField([MapGraphics::SETTINGS_KEY]), + array_map(static fn(MapTileLayer $layer): string => $layer->name, $layers), + array_column(array_filter($map->getLayers(), static fn(array $layer): bool => + $layer['id'] !== MapLayers::EVENT && ! $layer['decoration']), 'name'), $tileset, $map->mapId); + } catch (Throwable) { + // The terminal layers' and tile layer settings' own checks report why. + return []; + } + + $shown = []; + foreach (new MapGraphics($tileset, $layers, owners: $owners)->getShownGlyphCells($layerSet, $map->getAssetRoot()) as $cell) { + $shown[$cell['y']][$cell['x']] = $cell; + } + + $issues = []; + foreach ($map->getNpcs()->all() as $npc) { + $name = self::formatNpcName($npc); + if (! array_key_exists('sprites2d', $npc->toArray())) { + // An NPC without a terminal sprite draws nothing; its map glyph, if any, is the cell's. + if ($npc->getVisibleSprite() !== '') { + $issues[] = Issue::warning($map->mapId, + sprintf('NPC %s has no field sprite, so its glyph %s shows in the graphical field.', $name, $npc->getVisibleSprite()), + 'Give it an RPG Maker character sheet (sprites2d).'); + } + + continue; + } + + // A map glyph copying the NPC's own glyph is drawn twice in the + // terminal and shows under the sprite in the graphical field. + $cell = $shown[$npc->getY()][$npc->getX()] ?? null; + if ($cell !== null && $cell['glyph'] === $npc->getVisibleSprite()) { + unset($shown[$npc->getY()][$npc->getX()]); + $issues[] = Issue::warning($map->mapId, + sprintf('NPC %s stands on a copy of its glyph %s on the %s layer, which shows under its sprite.', + $name, $cell['glyph'], $cell['layer']), + 'Remove the copy from the map if the NPC is always there; otherwise give the cell its tile.'); + } + } + + $cells = array_merge(...array_values(array_map(array_values(...), $shown)) ?: [[]]); + if ($cells === []) { + return $issues; + } + + if ($layers === []) { + return [...$issues, Issue::warning($map->mapId, + sprintf('It has no tiles yet, so all %d of its glyph cells show in the graphical field.', count($cells)), + 'Stamp tileset pieces with P, draw the tiles for glyphs already on a layer with T, or paint tiles in the GUI editor.')]; + } + + $hint = 'Draw them with T on that layer when a tileset piece draws the glyph, or paint them in the GUI editor.'; + + $groups = []; + foreach ($cells as $cell) { + $groups[$cell['layer'] . "\0" . $cell['glyph']][] = $cell; + } + foreach ($groups as $group) { + $named = array_map(static fn(array $cell): string => "({$cell['x']}, {$cell['y']})", array_slice($group, 0, self::CELLS_NAMED)); + $rest = count($group) - count($named); + $issues[] = Issue::warning($map->mapId, sprintf('%d %s of %s on the %s layer %s no tile, so the glyph shows in the graphical field: %s%s.', + count($group), count($group) === 1 ? 'cell' : 'cells', $group[0]['glyph'], $group[0]['layer'], + count($group) === 1 ? 'has' : 'have', implode(', ', $named), $rest > 0 ? " and {$rest} more" : ''), $hint); + } + + return $issues; + } + + private static function formatNpcName(ProjectNpc $npc): string + { + $name = trim($npc->getName()); + $id = $npc->getId(); + + return match (true) { + $name === '' => $id ?? 'without a name', + $id === null => $name, + default => "{$name} ({$id})", + }; + } + /** * Checks the map data's tile layer settings as the Engine reads them. * diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 2f09ece..4469d4c 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -1574,6 +1574,7 @@ protected function checkMaps(ProjectWorkspace $workspace): array ...$issues, ...$this->checkLayers($map), ...MapGraphicsValidator::validate($map), + ...MapGraphicsValidator::validateCoverage($map), ...$this->checkRetiredMapData($map), ...$this->checkEventMarkers($map), ...$this->checkDoors($map, $workspace->mapIds), diff --git a/tests/Unit/MapGraphicsTest.php b/tests/Unit/MapGraphicsTest.php index 4331f10..bf60ab7 100644 --- a/tests/Unit/MapGraphicsTest.php +++ b/tests/Unit/MapGraphicsTest.php @@ -21,11 +21,12 @@ * and delete, and validates them as the Engine reads them. */ -/** @return list The graphics issues reported for the test map. */ +/** @return list The graphics and coverage issues reported for the test map. */ function graphicsIssueLines(string $root, ?Severity $severity = null): array { + $map = loadLayeredMap($root); $issues = array_filter( - MapGraphicsValidator::validate(loadLayeredMap($root)), + [...MapGraphicsValidator::validate($map), ...MapGraphicsValidator::validateCoverage($map)], static fn(Issue $issue): bool => $issue->where === 'test-map' && ($severity === null || $issue->severity === $severity), ); @@ -256,7 +257,8 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array expect((string) file_get_contents($map->dataPath))->toContain('// Authored metadata remains authored.') ->and(loadLayeredMap($root)->getMapDataField(['tileset']))->toBe('home') - ->and(graphicsIssueLines($root))->toBe([]); + // Named but not yet painted: every glyph still shows in the graphical field. + ->and(graphicsIssueLines($root))->toBe(['It has no tiles yet, so all 8 of its glyph cells show in the graphical field.']); }); // -- Validation --------------------------------------------------------------- @@ -320,9 +322,49 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array 'Tileset home sheet A2 is unusable: Graphics/Tilesets/Home_A2.png is 30x24, which does not fit an A2 sheet of 16 x 12 tiles in one even tile size shared by every sheet (768x576 at 48 pixels).', 'Tileset home sheet B is unusable: Invalid PNG header: Graphics/Tilesets/Home_B.png', 'Tile layer test-map/graphics/02.decor.tiles.php uses sheets A5, C, which tileset home does not provide.', + // Tiles from unusable sheets draw nothing, so their glyphs show. + '5 cells of . on the terrain layer have no tile, so the glyph shows in the graphical field: (0, 0), (2, 0), (3, 0) and 2 more.', + '1 cell of / on the buildings layer has no tile, so the glyph shows in the graphical field: (1, 0).', + '2 cells of x on the buildings layer have no tile, so the glyph shows in the graphical field: (1, 1), (2, 1).', ])->and(graphicsIssueLines($root, Severity::ERROR))->toBe([]); }); +it('warns for glyphs no tile covers, NPCs without field sprites and copies of an NPC\'s glyph under it', function () { + $root = mapGraphicsProject(); + $directory = $root . '/assets/Maps/test-map'; + writeTileLayer($directory, '01.floor.tiles.php', "2816 0 2816 2816\n2816 0 0 2816"); + writeTileLayer($directory, '02.decor.tiles.php', "0 0 0 0\n0 0 0 0"); + editTestMapData($root, static fn(string $source): string => str_replace("'events' => [],", "'events' => [], 'npcs' => [ + ['id' => 'guard', 'name' => 'Guard', 'sprite' => '@', 'x' => 0, 'y' => 0], + ['id' => 'board', 'name' => 'Board', 'sprite' => '', 'x' => 3, 'y' => 0], + ['id' => 'sign', 'name' => 'Sign', 'sprite' => 'x', 'sprites2d' => ['sheet' => 'Graphics/Characters/!\$Sign.png'], 'x' => 1, 'y' => 1], + ['id' => 'lamp', 'name' => 'Lamp', 'sprite' => 'i', 'sprites2d' => ['sheet' => 'Graphics/Characters/!\$Lamp.png'], 'x' => 3, 'y' => 1], + ['id' => 'cat', 'name' => 'Cat', 'sprite' => 'c', 'sprites2d' => ['sheet' => 'Graphics/Characters/\$Cat.png'], 'x' => 2, 'y' => 1], + ],", $source)); + + // The board draws no glyph, and the lamp stands on a covered cell. The + // sign's copy is named once, for the NPC, not again among the x cells; + // the cat stands on another glyph, an uncovered cell like any other. + expect(graphicsIssueLines($root))->toBe([ + 'NPC Guard (guard) has no field sprite, so its glyph @ shows in the graphical field.', + 'NPC Sign (sign) stands on a copy of its glyph x on the buildings layer, which shows under its sprite.', + '1 cell of / on the buildings layer has no tile, so the glyph shows in the graphical field: (1, 0).', + '1 cell of x on the buildings layer has no tile, so the glyph shows in the graphical field: (2, 1).', + ])->and(graphicsIssueLines($root, Severity::ERROR))->toBe([]); +}); + +it('counts a tile as covering only the glyphs of the gameplay layer it belongs to', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: ['ground' => ['name' => 'Ground', 'layer' => 'terrain', 'glyphs' => ['.'], 'tiles' => ['floor' => ['2816']]]]); + writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "0 0 0 0\n0 0 0 0"); + + // The floor belongs to terrain, so the building glyphs above it still show. + expect(graphicsIssueLines($root))->toBe([ + '1 cell of / on the buildings layer has no tile, so the glyph shows in the graphical field: (1, 0).', + '2 cells of x on the buildings layer have no tile, so the glyph shows in the graphical field: (1, 1), (2, 1).', + ]); +}); + it('includes graphics problems in the pre-save map warnings, unsaved resizes included', function () { $root = mapGraphicsProject(); $map = loadLayeredMap($root); @@ -333,6 +375,13 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array $map->setMapDataField(['tileset'], 'home'); $map->resize(6, 3); expect(MapGraphicsValidator::validate($map))->toBe([]); + + // Glyphs without tiles are art still to do, not a save problem. + writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "0 0 0 0\n0 0 0 0"); + $map = loadLayeredMap($root); + $coverage = array_map(static fn(Issue $issue): string => $issue->message, MapGraphicsValidator::validateCoverage($map)); + expect($coverage)->not->toBe([]) + ->and(array_intersect(MapValidator::validate($map, ['test-map' => $map]), $coverage))->toBe([]); }); it('warns about a map without a kind only in a project that has tilesets', function () { @@ -347,7 +396,22 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array ->and($issues[0]->hint)->toStartWith('Set its Kind in the Inspector, one of the tilesets in assets/Data/Tilesets.'); editTestMapData($root, static fn(string $source): string => str_replace("'events' => [],", "'events' => [], 'tileset' => 'interior',", $source)); - expect(MapGraphicsValidator::validate(loadLayeredMap($root)))->toBe([]); + $map = loadLayeredMap($root); + expect(MapGraphicsValidator::validate($map))->toBe([]) + // Coverage is reported apart: the map has a kind but no tiles yet. + ->and(array_map(static fn(Issue $issue): string => $issue->message, MapGraphicsValidator::validateCoverage($map))) + ->toBe(['It has no tiles yet, so all 8 of its glyph cells show in the graphical field.']); +}); + +it('reports coverage through project validation, as ichiloto validate runs it', function () { + $root = mapGraphicsProject(); + writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "2816 2816 2816 2816\n2816 0 2816 2816"); + + $messages = array_map(static fn(Issue $issue): string => $issue->message, array_filter( + new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), + static fn(Issue $issue): bool => $issue->where === 'test-map', + )); + expect($messages)->toContain('1 cell of x on the buildings layer has no tile, so the glyph shows in the graphical field: (1, 1).'); }); it('reports graphics through project validation, as ichiloto validate runs it', function () { From 3b4e34770637623f82fa8b3fda89904581fae37d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 1 Oct 2026 22:21:09 +0200 Subject: [PATCH 084/344] docs(manual): describe the canvas preview of pending lines and rectangles --- docs/manual.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index a1721c5..1d0cc87 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -745,9 +745,9 @@ The clipboard is layer-tagged: a block lifted from the event layer refuses to land on tiles. A block lifted from a gameplay layer carries the graphical tiles that move with that layer, and a paste replaces the tiles under it. -Current limit: the canvas draws no on-screen preview of a pending line, -rectangle, or selection rectangle. The footer reports the anchor and selection -size instead. +A pending line or rectangle is previewed on the canvas in the brush glyph from +its anchor to the cursor. A pending selection is not drawn; the footer reports +its anchor and size. ## Inspector Panel From 63b80dc3b73e2b0308e50787394d67b9449e3b3d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 1 Oct 2026 22:22:59 +0200 Subject: [PATCH 085/344] docs(manual): coverage warnings come from ichiloto validate, not Ctrl+E --- docs/manual.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/manual.md b/docs/manual.md index 1d0cc87..bf0ae62 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -449,7 +449,7 @@ which glyphs: remove the copy when the NPC is always there. These are art still to do, not faults: the game plays the same either way, -so saving a map does not warn about them; `Ctrl+E` and `ichiloto validate` do. +so saving a map does not warn about them; `ichiloto validate` reports them. Text catalogues in `assets/Graphics/Tilesets/*.txt` appear as Facade brushes in `Ctrl+P`. Separate multi-row shapes with blank lines. Select a brush to target From 4dba459f8d81f25906336b4e7bf3f2af0e1c607a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 01:25:08 +0200 Subject: [PATCH 086/344] docs: correct contradictions found in the capability inventory - Nested choice options and branch arms are editable as command frames; drop the stale "current limit" from the manual's Common Events section and the cutscene guide, and point to Command Frames. - Editing a map's name or region never moves its folder; the make-a-new-map guide now matches Stable map identities and names the explicit Move Map to Derived Path operation. - The add-an-npc guide lists all seven event types, including Story Script and Cinematic. - The Overlays table opens the character map with c in Normal mode (or the command palette), not @, which paints in Paint mode. --- docs/guides/add-an-npc.md | 11 +++++++---- docs/guides/author-a-cutscene.md | 20 +++++++++----------- docs/guides/make-a-new-map.md | 11 +++++++---- docs/manual.md | 10 ++++------ 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/docs/guides/add-an-npc.md b/docs/guides/add-an-npc.md index e27a20e..7b46752 100644 --- a/docs/guides/add-an-npc.md +++ b/docs/guides/add-an-npc.md @@ -175,10 +175,13 @@ or a fixture. 4. Edit the dialogue rows: each line has a speaker `name` and its `text`. Leave the name empty for narration with no title. -The five event types are `Dialogue`, `Transfer Player`, `Shop`, `Sleep`, and -`Chest`. A `Transfer Player` event links two maps: after choosing the type, -`Enter` on `Destination` jumps to the target map to pick the arrival tile in -context, and returns you afterwards; `Ctrl+G` follows the link any time and +The seven event types are `Story Script`, `Cinematic`, `Dialogue`, +`Transfer Player`, `Shop`, `Sleep`, and `Chest`. `Story Script` runs a Common +Event ([Author a Cutscene](author-a-cutscene.md)) and `Cinematic` launches a +cinematic ([Author a Cinematic Cutscene](author-a-cinematic-cutscene.md)). +A `Transfer Player` event links two maps: after choosing the type, `Enter` on +`Destination` jumps to the target map to pick the arrival tile in context, and +returns you afterwards; `Ctrl+G` follows the link any time and `Ctrl+B` comes back. Painting a glyph or a marker is an ordinary canvas edit, so `Ctrl+Z` removes it diff --git a/docs/guides/author-a-cutscene.md b/docs/guides/author-a-cutscene.md index 3c2d108..c16c346 100644 --- a/docs/guides/author-a-cutscene.md +++ b/docs/guides/author-a-cutscene.md @@ -50,7 +50,7 @@ The full vocabulary: | Type | Fields | Notes | | --- | --- | --- | | `text` | Speaker, Text | An empty speaker means narration | -| `choice` | Prompt, Title, Options | Options are fixed here; see below | +| `choice` | Prompt, Title, Options | Each option's commands open as a frame; see below | | `wait` | Seconds | Accepts fractions | | `set_switch` | Switch, Value | | | `set_variable` | Variable, Operation, Value | Operation is `set` or `add` | @@ -92,14 +92,13 @@ being skipped. A `branch` command's `Conditions` row uses the shared condition grammar documented in [Wire a Quest](wire-a-quest.md). All conditions must hold. -Current limit: the `Then` and `Else` arms are shown as fixed rows and edited by -hand in the file. The same applies to a `choice` command's `Options`. Both -round-trip untouched when you save from the editor, so it is safe to author the -outer script here and the nested arms in your editor of choice. - -The reason is deliberate: flattening a command *tree* into one settings pane -would be unreadable, and dropping the tree on save would be worse than not -offering the edit. +The `Then` and `Else` arms, and each option of a `choice`, are edited as +frames rather than flattened into the outer list. A `branch` shows `Then +Commands` and `Else Commands` rows, and a `choice` shows each option as a text +row and a commands row. `Enter` on a commands row opens that arm's own command +list, with the same rows and keys as the top level, at any depth. `Esc` steps +back out one frame at a time. The manual's +[Command Frames](../manual.md#command-frames) section has the details. ## Continue After A Transfer Or Battle @@ -181,8 +180,7 @@ reports missing scripts, duplicate NPC IDs, missing route targets where map context is known, malformed route steps/timing, unknown commands, bad result variables, and invalid defeat policies. -Current limits: the editor preserves but does not structurally edit nested -choice/branch trees. Cutscene skipping and finalizers, camera and fade +Current limits: cutscene skipping and finalizers, camera and fade commands, field-animation commands, parallel routes, patrol routes, pathfinding, and full NPC creation/placement remain deferred. diff --git a/docs/guides/make-a-new-map.md b/docs/guides/make-a-new-map.md index 4df12bb..ae1c442 100644 --- a/docs/guides/make-a-new-map.md +++ b/docs/guides/make-a-new-map.md @@ -29,7 +29,7 @@ creation is not undoable. 1. `Tab` to the `Inspector`. 2. Move to `Name` and press `Enter`, type the display name, press `Enter`. -3. Set `Region` the same way — it groups maps, and becomes part of the map id. +3. Set `Region` the same way. Like the name, it is metadata; see below. 4. Under `Size`, move to `X` and `Y` and use `Left` / `Right` to resize, or `Enter` to type a number. @@ -37,9 +37,12 @@ Resizing is undoable. Shrinking a map discards the tiles outside the new bounds, in its terminal layers and in any graphical tile layers in `graphics/`, so `Ctrl+Z` is your friend if you overshoot. -Current behavior: the map id is derived from the region and name. Changing -either means the map's *folder* moves on the next save, and the editor asks for -explicit confirmation before it does. +The name and region are metadata. Saving keeps the map in its folder, so its +id, which doors, quests and saves refer to, does not change. To move the folder +to a path derived from the region and name, choose *Move Map to Derived Path* +from the command palette (`Ctrl+P`). It shows the current and proposed ids and +asks for `y`. References to the old id are not updated. See +[Stable map identities](../manual.md#stable-map-identities). ## Paint The Tile Layer diff --git a/docs/manual.md b/docs/manual.md index bf0ae62..fb8c3a3 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1408,11 +1408,9 @@ the map data. Routes in this phase are sequential and awaited, so `Wait` must remain true. Set either seconds-per-step (with an optional per-step override) or speed in steps per second. -Current limit: nested arms are shown but not edited. A `choice` command's -`Options` and a `branch` command's `Then` / `Else` appear as fixed rows, and -their contents round-trip untouched when you save. Editing a nested arm means -editing the file directly - flattening a command tree into one settings pane -would be unreadable, and dropping it on save would be worse. +A `choice` command's options and a `branch` command's `Then` / `Else` arms +are not flattened into this list. Each arm opens as its own frame, described +under [Command Frames](#command-frames). `start_battle` may occur in the middle of a script. The event suspends until the existing battle return path restores the field, then continues with the @@ -1810,7 +1808,7 @@ fields are preserved and are not errors. | --- | --- | --- | | Help | `?` | Generated from the binding table; scrolls with arrows | | Command palette | `Ctrl+P` | Fuzzy search over actions, tools, maps, categories, and event markers | -| Character map | `@` | Insert glyphs the keyboard reserves; lists the map's symbols, the project's collision vocabulary, and the reserved glyphs | +| Character map | `c` in Normal mode, or *Tool: Character Map* in the command palette | Insert glyphs the keyboard reserves; lists the map's symbols, the project's collision vocabulary, and the reserved glyphs | | Status detail | `Ctrl+E` | Full text of the last message, and the log file path | Current limit: the help overlay and command palette cannot open while a picker From a907c12a12b18e8f88619a11eada02d3cfbcba03 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 02:10:46 +0200 Subject: [PATCH 087/344] feat(validation): report piece tiles whose glyph is no longer there Project validation now warns for tiles a tileset piece draws that no glyph on the map accounts for, by tile layer: a window's tile left after its x was removed outside the editor, or before the tileset had the piece. Every role a glyph could play accounts for its tiles, including those of a piece's blank cells beside it; tiles no piece draws, such as a house's walls over blank cells, are not judged. --- docs/manual.md | 3 + src/Validation/MapGraphicsValidator.php | 82 ++++++++++++++++++++++++- tests/Unit/MapGraphicsTest.php | 19 ++++++ 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index fb8c3a3..8bdb18d 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -447,6 +447,9 @@ which glyphs: NPC with an empty sprite draws nothing and is not counted; - a copy of an NPC's own glyph in the map, which shows under its sprite; remove the copy when the NPC is always there. +- tiles a tileset piece draws whose glyph is no longer there, such as a + window's tile left after its `x` was removed outside the editor. Tiles no + piece draws, such as a house's walls over blank cells, are not judged. These are art still to do, not faults: the game plays the same either way, so saving a map does not warn about them; `ichiloto validate` reports them. diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php index ce3a5ec..a969ee5 100644 --- a/src/Validation/MapGraphicsValidator.php +++ b/src/Validation/MapGraphicsValidator.php @@ -4,6 +4,8 @@ namespace Ichiloto\Editor\Validation; +use Ichiloto\Editor\Canvas\GlyphTilePlanner; +use Ichiloto\Editor\Canvas\PieceRole; use Ichiloto\Editor\Field\ProjectNpc; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Maps\TileLayerSource; @@ -78,8 +80,9 @@ public static function validate(ProjectMap $map): array /** * Warns for what a map with a kind still shows as terminal glyphs in the * graphical field, by the Engine's glyph fallback rule: cells no tile - * covers, NPCs without a field sprite, and copies of an NPC's glyph in the - * map showing under its sprite. These are art still to do, not faults: + * covers, NPCs without a field sprite, copies of an NPC's glyph in the + * map showing under its sprite, and tiles a tileset piece draws whose + * glyph is no longer there. These are art still to do, not faults: * the game plays the same, so project validation reports them and the * pre-save checks do not. Graphics the Engine refuses are * {@see validate()}'s to report; only the readable tile layers count here. @@ -111,7 +114,7 @@ public static function validateCoverage(ProjectMap $map): array $shown[$cell['y']][$cell['x']] = $cell; } - $issues = []; + $issues = self::checkStalePieceTiles($map, $tileset, $layers); foreach ($map->getNpcs()->all() as $npc) { $name = self::formatNpcName($npc); if (! array_key_exists('sprites2d', $npc->toArray())) { @@ -165,6 +168,79 @@ public static function validateCoverage(ProjectMap $map): array return $issues; } + /** + * Warns for tiles a tileset piece draws that no glyph on the map accounts + * for, left behind when their glyph went without them (an edit outside the + * editor, or before the tileset had the piece). The graphical field then + * shows what the terminal no longer does. Tiles no piece draws, such as a + * house's walls over blank cells, are not judged. + * + * @param list $layers The readable tile layers. + * @return list + */ + private static function checkStalePieceTiles(ProjectMap $map, Tileset $tileset, array $layers): array + { + $drawn = []; + foreach ($tileset->pieces as $piece) { + foreach (PieceRole::readPiece($piece) as $roles) { + foreach ($roles as $role) { + foreach ($role->tiles as $tileLayer => $cells) { + foreach ($cells as $cell) { + $drawn[$tileLayer][(int) $cell['entry']] = true; + } + } + } + } + } + + // Every role a glyph on the map could play accounts for its tiles, + // including those of a piece's blank cells beside it. + $explained = []; + foreach ($map->getLayers() as $layer) { + if ($layer['id'] === MapLayers::EVENT || $layer['decoration']) { + continue; + } + $planner = GlyphTilePlanner::fromPieces($tileset->pieces, $layer['name']); + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x < $map->getWidth(); $x++) { + if (! $map->hasLayerCell($layer['id'], $x, $y) || ($glyph = $map->getLayerSymbol($layer['id'], $x, $y)) === ' ') { + continue; + } + foreach ($planner->getRoles($glyph) as $role) { + foreach ($role->tiles as $tileLayer => $cells) { + foreach ($cells as $cell) { + $explained[$tileLayer][$y + $cell['dy']][$x + $cell['dx']][(int) $cell['entry']] = true; + } + } + } + } + } + } + + $issues = []; + foreach ($layers as $layer) { + $stale = []; + foreach ($layer->tiles as $y => $row) { + foreach ($row as $x => $id) { + if (isset($drawn[$layer->name][$id]) && ! isset($explained[$layer->name][$y][$x][$id])) { + $stale[] = "({$x}, {$y})"; + } + } + } + if ($stale !== []) { + $named = array_slice($stale, 0, self::CELLS_NAMED); + $rest = count($stale) - count($named); + $issues[] = Issue::warning($map->mapId, + sprintf('%d %s on the %s tile layer %s drawn by a tileset piece whose glyph is no longer there: %s%s.', + count($stale), count($stale) === 1 ? 'tile' : 'tiles', $layer->name, count($stale) === 1 ? 'is' : 'are', + implode(', ', $named), $rest > 0 ? " and {$rest} more" : ''), + 'Paint the piece\'s glyph back and erase it, so its tiles go with it, or remove the tiles in the GUI editor.'); + } + } + + return $issues; + } + private static function formatNpcName(ProjectNpc $npc): string { $name = trim($npc->getName()); diff --git a/tests/Unit/MapGraphicsTest.php b/tests/Unit/MapGraphicsTest.php index bf60ab7..a5e6558 100644 --- a/tests/Unit/MapGraphicsTest.php +++ b/tests/Unit/MapGraphicsTest.php @@ -353,6 +353,25 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array ])->and(graphicsIssueLines($root, Severity::ERROR))->toBe([]); }); +it('warns for tiles a piece draws whose glyph is no longer there, and only those', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: [ + 'window' => ['name' => 'Window', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['decor' => ['5']]], + 'board' => ['name' => 'Board', 'layer' => 'buildings', 'glyphs' => ['/ '], 'tiles' => ['decor' => ['7 6']]], + ]); + // Buildings: " / " over " xx ". The windows at (1, 1) and (2, 1), and + // the board's / at (1, 0) with its blank cell beside it at (2, 0), are + // accounted for; a window tile at (3, 1) has no x, and a board tile at + // (3, 0) has no / beside it. The floor's 2816 is drawn by no piece, so + // it is never judged. + writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "0 0 0 6\n0 5 5 5"); + $stale = array_values(array_filter(graphicsIssueLines($root), static fn(string $line): bool => str_contains($line, 'no longer there'))); + expect($stale)->toBe(['2 tiles on the decor tile layer are drawn by a tileset piece whose glyph is no longer there: (3, 0), (3, 1).']); + + writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "0 7 6 0\n0 5 5 0"); + expect(array_filter(graphicsIssueLines($root), static fn(string $line): bool => str_contains($line, 'no longer there')))->toBe([]); +}); + it('counts a tile as covering only the glyphs of the gameplay layer it belongs to', function () { $root = mapGraphicsProject(); writeTestTileset($root, pieces: ['ground' => ['name' => 'Ground', 'layer' => 'terrain', 'glyphs' => ['.'], 'tiles' => ['floor' => ['2816']]]]); From 3e8bef255aeb466b32a84c7434d6330c4f6699e8 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 02:14:17 +0200 Subject: [PATCH 088/344] feat(validation): list cells showing the missing-art placeholder When a map's tileset names its missingArt tile, project validation lists the cells that show it, by tile layer, so placeholders stay on the art to-do list once they cover a glyph. --- docs/manual.md | 4 ++- src/Validation/MapGraphicsValidator.php | 43 +++++++++++++++++++++++-- tests/Support/MapGraphicsFixtures.php | 6 ++-- tests/Unit/MapGraphicsTest.php | 13 ++++++++ 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 8bdb18d..40524cf 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -449,7 +449,9 @@ which glyphs: remove the copy when the NPC is always there. - tiles a tileset piece draws whose glyph is no longer there, such as a window's tile left after its `x` was removed outside the editor. Tiles no - piece draws, such as a house's walls over blank cells, are not judged. + piece draws, such as a house's walls over blank cells, are not judged; +- cells showing the tileset's missing-art placeholder (its `missingArt` + tile), which mark art nobody could yet infer. These are art still to do, not faults: the game plays the same either way, so saving a map does not warn about them; `ichiloto validate` reports them. diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php index a969ee5..5f730a7 100644 --- a/src/Validation/MapGraphicsValidator.php +++ b/src/Validation/MapGraphicsValidator.php @@ -81,8 +81,9 @@ public static function validate(ProjectMap $map): array * Warns for what a map with a kind still shows as terminal glyphs in the * graphical field, by the Engine's glyph fallback rule: cells no tile * covers, NPCs without a field sprite, copies of an NPC's glyph in the - * map showing under its sprite, and tiles a tileset piece draws whose - * glyph is no longer there. These are art still to do, not faults: + * map showing under its sprite, tiles a tileset piece draws whose glyph + * is no longer there, and cells showing the tileset's missing-art + * placeholder. These are art still to do, not faults: * the game plays the same, so project validation reports them and the * pre-save checks do not. Graphics the Engine refuses are * {@see validate()}'s to report; only the readable tile layers count here. @@ -114,7 +115,7 @@ public static function validateCoverage(ProjectMap $map): array $shown[$cell['y']][$cell['x']] = $cell; } - $issues = self::checkStalePieceTiles($map, $tileset, $layers); + $issues = [...self::checkStalePieceTiles($map, $tileset, $layers), ...self::checkMissingArtTiles($map, $tileset, $layers)]; foreach ($map->getNpcs()->all() as $npc) { $name = self::formatNpcName($npc); if (! array_key_exists('sprites2d', $npc->toArray())) { @@ -241,6 +242,42 @@ private static function checkStalePieceTiles(ProjectMap $map, Tileset $tileset, return $issues; } + /** + * Lists, by tile layer, the cells showing the tileset's missing-art + * placeholder: covered, but with art still to make. + * + * @param list $layers The readable tile layers. + * @return list + */ + private static function checkMissingArtTiles(ProjectMap $map, Tileset $tileset, array $layers): array + { + if ($tileset->missingArt === null) { + return []; + } + $issues = []; + foreach ($layers as $layer) { + $cells = []; + foreach ($layer->tiles as $y => $row) { + foreach ($row as $x => $id) { + if ($id === $tileset->missingArt) { + $cells[] = "({$x}, {$y})"; + } + } + } + if ($cells !== []) { + $named = array_slice($cells, 0, self::CELLS_NAMED); + $rest = count($cells) - count($named); + $issues[] = Issue::warning($map->mapId, + sprintf('%d %s on the %s tile layer %s the missing-art placeholder: %s%s.', + count($cells), count($cells) === 1 ? 'cell' : 'cells', $layer->name, count($cells) === 1 ? 'shows' : 'show', + implode(', ', $named), $rest > 0 ? " and {$rest} more" : ''), + 'Replace it with the art that belongs there once it is known.'); + } + } + + return $issues; + } + private static function formatNpcName(ProjectNpc $npc): string { $name = trim($npc->getName()); diff --git a/tests/Support/MapGraphicsFixtures.php b/tests/Support/MapGraphicsFixtures.php index 3882147..652ec48 100644 --- a/tests/Support/MapGraphicsFixtures.php +++ b/tests/Support/MapGraphicsFixtures.php @@ -29,8 +29,9 @@ function writeTilesetTestPng(string $path, int $width, int $height): void * * @param array $sheets Asset-relative PNG paths by sheet name. * @param array> $pieces Pieces by id, as a tileset file lists them. + * @param int|null $missingArt The tileset's missing-art tile, if it names one. */ -function writeTestTileset(string $root, string $id = 'home', ?array $sheets = null, array $pieces = []): void +function writeTestTileset(string $root, string $id = 'home', ?array $sheets = null, array $pieces = [], ?int $missingArt = null): void { $sheets ??= ['A2' => 'Graphics/Tilesets/Home_A2.png', 'B' => 'Graphics/Tilesets/Home_B.png']; writeTilesetTestPng($root . '/assets/Graphics/Tilesets/Home_A2.png', 32, 24); @@ -38,7 +39,8 @@ function writeTestTileset(string $root, string $id = 'home', ?array $sheets = nu if (! is_dir($root . '/assets/Data/Tilesets')) { mkdir($root . '/assets/Data/Tilesets', 0o777, true); } - $data = ['name' => ucfirst($id), 'sheets' => $sheets] + ($pieces === [] ? [] : ['pieces' => $pieces]); + $data = ['name' => ucfirst($id), 'sheets' => $sheets] + ($pieces === [] ? [] : ['pieces' => $pieces]) + + ($missingArt === null ? [] : ['missingArt' => $missingArt]); file_put_contents($root . "/assets/Data/Tilesets/{$id}.php", " str_contains($line, 'no longer there')))->toBe([]); }); +it('lists the cells showing the tileset\'s missing-art placeholder', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, missingArt: 255); + writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "0 255 0 0\n0 0 255 255"); + $placeholder = static fn(): array => array_values(array_filter(graphicsIssueLines($root), static fn(string $line): bool => + str_contains($line, 'missing-art placeholder'))); + expect($placeholder())->toBe(['3 cells on the decor tile layer show the missing-art placeholder: (1, 0), (2, 1), (3, 1).']); + + // A tileset that names none has no placeholder to report. + writeTestTileset($root); + expect($placeholder())->toBe([]); +}); + it('counts a tile as covering only the glyphs of the gameplay layer it belongs to', function () { $root = mapGraphicsProject(); writeTestTileset($root, pieces: ['ground' => ['name' => 'Ground', 'layer' => 'terrain', 'glyphs' => ['.'], 'tiles' => ['floor' => ['2816']]]]); From ea423b51228716e379875c34a69868eb3ba65d19 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 07:51:08 +0200 Subject: [PATCH 089/344] fix(validation): read skills from the Engine's skill catalogue The Editor resolved skill references from skills.php alone. Save compatibility checked both ability and spell alias targets against the skills.php names, so a spell alias to a magic.php spell (Fire to Burn I) failed while an ability could pass as a spell target. Class grants, summon linked actions and the reference pickers likewise missed every skill authored in abilities.php or magic.php, and the loot dialog read skills.php and magic.php separately. They now read the Engine's SkillCatalog. Spell alias targets must be spells and ability alias targets abilities, wherever each is authored. References offer the skills.php entries as edited plus the skills the other files author. `ichiloto validate` reports what the catalogue cannot identify, including a name defined twice across the files. The Skills database still edits skills.php only; the roadmap records spell and ability authoring as planned and the manual says so. --- docs/manual.md | 2 +- docs/roadmap.md | 19 +++++ src/Database/ReferenceCatalog.php | 8 +- src/Editor.php | 76 ++++--------------- src/ProjectWorkspace.php | 36 +++++++++ src/Validation/ProjectValidator.php | 27 +++++-- src/Validation/SaveCompatibilityValidator.php | 22 +++++- tests/Unit/ProjectValidatorTest.php | 76 +++++++++++++++++++ 8 files changed, 188 insertions(+), 78 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 40524cf..a2ae171 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -983,7 +983,7 @@ status line says exactly why. | --- | --- | --- | | Actors | `assets/Data/Actors/*.php` | Editable | | Classes | `assets/Data/classes.php` | Editable | -| Skills | `assets/Data/skills.php` | Editable | +| Skills | `assets/Data/skills.php` | Editable - spells and abilities in `magic.php` and `abilities.php` are validated and offered as references, not yet edited here | | Items | `assets/Data/items.php` | Editable - authored as `new Item(...)` calls, edited entry by entry | | Weapons | `assets/Data/items.php` | Editable - authored as `new Weapon(...)` calls, edited entry by entry | | Armors | `assets/Data/items.php` | Editable - authored as `new Armor(...)` calls, edited entry by entry | diff --git a/docs/roadmap.md b/docs/roadmap.md index 9204060..fb565be 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -151,6 +151,25 @@ This scope does not replace the map source-integrity work or authorize a new GUI implementation. Keep runtime availability and TUI authoring completion separate until both have been verified. +### Spell and ability authoring (planned) + +The Engine reads a project's skills from one catalogue spread across +`assets/Data/skills.php`, `abilities.php` and `magic.php`: a skill is found by +its name in any of them, and its kind comes from its class. The Editor's +validation and reference pickers read that same catalogue, but the Skills +database edits `skills.php` only. Spells and abilities authored in `magic.php` +or `abilities.php` can be referenced and validated, not yet edited. This +remains an implementation gap. + +Acceptance criteria: + +- Browse and edit every skill in the catalogue, whichever file authors it, + through the existing source-preserving database path. A new skill is written + to the file its kind belongs in by the project's convention, never duplicated. +- Keep names unique across the three files; refuse a save that would define a + name twice, as validation already reports it. +- Round-trip comments, expressions and unknown fields in all three files, and + refuse unsupported source rather than flattening it. ### Phase 1 — The responsiveness sprint (quick wins, no redesign) ✅ *shipped 2026-08* > Status: implemented. `InputDecoder` (src/IO/InputDecoder.php, unit-tested) > replaces the blocking reader: non-blocking drained stdin, offline escape diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 28f3ba7..7f77f3d 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -8,7 +8,6 @@ use Ichiloto\Editor\ProjectActor; use Ichiloto\Editor\ProjectClass; use Ichiloto\Editor\ProjectQuest; -use Ichiloto\Editor\ProjectSkill; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Cutscenes\CutsceneType; @@ -127,10 +126,9 @@ public function valuesFor(string $category): array static fn(ProjectClass $class): string => $class->getName(), $this->workspace->classDatabase->getClasses() ), - 'skills' => array_map( - static fn(ProjectSkill $skill): string => $skill->getName(), - $this->workspace->skillDatabase->getSkills() - ), + // Spells and abilities may be authored in any of the Engine's + // skill files; a reference names the skill wherever it lives. + 'skills' => $this->workspace->getSkillNames(), 'quests' => array_map( static fn(ProjectQuest $quest): string => $quest->getId(), $this->workspace->questDatabase->getQuests() diff --git a/src/Editor.php b/src/Editor.php index 1b565c5..d06d694 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -98,6 +98,7 @@ use Ichiloto\Engine\Entities\Magic\MagicEffectType; use Ichiloto\Engine\Entities\Roles\ExperienceCurveGenerator; use Ichiloto\Engine\Entities\Roles\ParameterCurveGenerator; +use Ichiloto\Engine\Entities\Skills\SkillCatalog; use Ichiloto\Engine\Events\Enumerations\ChestType; use Ichiloto\Engine\Events\Enumerations\LootType; use Ichiloto\Engine\Quests\QuestObjectiveType; @@ -15163,8 +15164,8 @@ private function loadLootDialogEntries(LootType $lootType): array LootType::WEAPON, LootType::ARMOR, LootType::ACCESSORY => $this->loadInventoryLootDialogEntries($lootType), - LootType::SKILL => $this->loadSkillLootDialogEntries(), - LootType::SPELL => $this->loadSpellLootDialogEntries(), + LootType::SKILL, + LootType::SPELL => $this->loadSkillLootDialogEntries($lootType), default => [], }; } @@ -15215,75 +15216,24 @@ private function loadInventoryLootDialogEntries(LootType $lootType): array } /** - * Loads skill-based loot entries. + * Loads ability or spell loot entries from the project's skill catalogue, + * wherever each skill is authored. * + * @param LootType $lootType The loot type being edited: abilities for skills, spells for spells. * @return array */ - private function loadSkillLootDialogEntries(): array + private function loadSkillLootDialogEntries(LootType $lootType): array { - $skillsPath = $this->projectRoot . '/assets/Data/skills.php'; - - if (! is_file($skillsPath)) { - return []; - } - - $skills = require $skillsPath; - - if (! is_array($skills)) { - return []; - } - + $catalog = SkillCatalog::load($this->projectRoot . '/assets'); + $skills = $lootType === LootType::SPELL ? $catalog->getSpells() : $catalog->getAbilities(); $entries = []; foreach ($skills as $skill) { - if (! is_object($skill) || ! isset($skill->name)) { - continue; - } - - $entries[] = [ - 'name' => (string) $skill->name, - 'description' => (string) ($skill->description ?? ''), - 'icon' => trim((string) ($skill->icon ?? '')), - 'type' => 'Skill', - ]; - } - - usort($entries, static fn(array $left, array $right): int => strcmp($left['name'], $right['name'])); - - return $entries; - } - - /** - * Loads magic/spell loot entries. - * - * @return array - */ - private function loadSpellLootDialogEntries(): array - { - $magicPath = $this->projectRoot . '/assets/Data/magic.php'; - - if (! is_file($magicPath)) { - return []; - } - - $spells = require $magicPath; - - if (! is_array($spells)) { - return []; - } - - $entries = []; - - foreach ($spells as $spell) { - if (! is_object($spell) || ! isset($spell->name)) { - continue; - } - $entries[] = [ - 'name' => (string) $spell->name, - 'description' => (string) ($spell->description ?? ''), - 'icon' => trim((string) ($spell->icon ?? '')), - 'type' => 'Spell', + 'name' => $skill->name, + 'description' => $skill->description, + 'icon' => trim($skill->icon), + 'type' => $lootType === LootType::SPELL ? 'Spell' : 'Skill', ]; } diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index afa386b..0800b62 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -14,6 +14,7 @@ use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Editor\Storage\FilesystemFileSetOperations; use Ichiloto\Engine\Core\ProjectFormat; +use Ichiloto\Engine\Entities\Skills\SkillCatalog; use Ichiloto\Engine\Field\MapGraphics; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -62,6 +63,41 @@ public function getRecordDatabase(string $categoryKey): ?ProjectRecordDatabase return $this->recordDatabases[$categoryKey] ?? null; } + /** + * Reads the project's skill catalogue as saved: every skill across the + * Engine's skill data files, identified and classified as the runtime + * finds them. + * + * @return SkillCatalog + */ + public function loadSkillCatalog(): SkillCatalog + { + return SkillCatalog::load($this->projectRoot . DIRECTORY_SEPARATOR . 'assets'); + } + + /** + * Returns every skill name a reference may use: the skills.php entries as + * currently edited, then the skills the catalogue's other files author. + * + * @return string[] + */ + public function getSkillNames(): array + { + $names = array_map( + static fn(ProjectSkill $skill): string => $skill->getName(), + $this->skillDatabase->getSkills() + ); + $catalog = $this->loadSkillCatalog(); + + foreach (array_keys($catalog->getSkills()) as $name) { + if ($catalog->getSourceFile($name) !== basename($this->skillDatabase->path)) { + $names[] = $name; + } + } + + return array_values(array_unique($names)); + } + /** * Returns the maps root for the project. * diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 4469d4c..274f408 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -117,6 +117,7 @@ public function validate(ProjectWorkspace $workspace): array ...$this->checkQuests($workspace), ...$this->checkTroops($workspace), ...$this->checkBattleEntryRules($workspace), + ...$this->checkSkillCatalog($workspace), ...$this->checkSummons($workspace), ...$this->checkCutscenes($workspace), ...$this->checkReferences($workspace), @@ -1296,6 +1297,24 @@ protected function checkBattleEntryRules(ProjectWorkspace $workspace): array return $issues; } + /** + * Reports what the Engine's skill catalogue cannot identify: a skill the + * runtime would skip, or one whose name another skill already uses. + * + * @return Issue[] + */ + protected function checkSkillCatalog(ProjectWorkspace $workspace): array + { + return array_map( + static fn(string $problem): Issue => Issue::error( + 'assets/Data skill catalogue', + $problem, + 'A skill is found by its name across skills.php, abilities.php and magic.php, so each name must be unique.', + ), + $workspace->loadSkillCatalog()->getProblems(), + ); + } + /** * Checks authored summon gates, wielder policies, linked actions, and * actor starting assignments without imposing any game-specific names. @@ -1320,10 +1339,8 @@ protected function checkSummons(ProjectWorkspace $workspace): array static fn(\Ichiloto\Editor\ProjectActor $actor): string => $actor->getName(), $workspace->actorDatabase->getActors(), ); - $skillNames = array_map( - static fn(\Ichiloto\Editor\ProjectSkill $skill): string => $skill->getName(), - $workspace->skillDatabase->getSkills(), - ); + $skillNames = $workspace->getSkillNames(); + $definitions = []; $issues = []; @@ -1361,7 +1378,7 @@ protected function checkSummons(ProjectWorkspace $workspace): array $issues[] = Issue::error( $where, sprintf('It links to action "%s", which does not exist.', $linkedAction), - 'Choose an action from assets/Data/skills.php.', + 'Choose a skill from assets/Data/skills.php, abilities.php or magic.php.', ); } diff --git a/src/Validation/SaveCompatibilityValidator.php b/src/Validation/SaveCompatibilityValidator.php index ae370b8..623791d 100644 --- a/src/Validation/SaveCompatibilityValidator.php +++ b/src/Validation/SaveCompatibilityValidator.php @@ -8,6 +8,7 @@ use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Entities\Skills\SkillCatalog; use Ichiloto\Engine\Exceptions\InvalidSaveCompatibilityManifestException; use Ichiloto\Engine\IO\SaveCompatibility\ContentReferenceCategory; use Ichiloto\Engine\IO\SaveCompatibility\MapShift; @@ -24,6 +25,7 @@ final class SaveCompatibilityValidator * @var InventoryCatalog|null The project's inventory identity, read once. */ private ?InventoryCatalog $inventoryCatalog = null; + private ?SkillCatalog $skillCatalog = null; /** @return Issue[] */ public function validate(ProjectWorkspace $workspace): array @@ -358,10 +360,11 @@ private function knownTargets(ProjectWorkspace $workspace, ContentReferenceCateg // label, so the catalogue of ids is what it has to be in. ContentReferenceCategory::ITEM => $this->inventoryCatalog($workspace)->idsIn('items'), ContentReferenceCategory::EQUIPMENT => $this->inventoryCatalog($workspace)->idsIn('weapons', 'armors'), - ContentReferenceCategory::ABILITY, ContentReferenceCategory::SPELL => array_map( - static fn(object $skill): string => $skill->getName(), - $workspace->skillDatabase->getSkills() - ), + // A save keeps abilities and spells in separate books, and the + // runtime restores each book from its own kind of skill, + // wherever the catalogue authors it. + ContentReferenceCategory::ABILITY => array_keys($this->skillCatalog($workspace)->getAbilities()), + ContentReferenceCategory::SPELL => array_keys($this->skillCatalog($workspace)->getSpells()), ContentReferenceCategory::STATE => $this->recordIdentities($workspace, 'states'), ContentReferenceCategory::ENEMY => $this->recordLabels($workspace, ['enemies']), ContentReferenceCategory::ACHIEVEMENT => $this->phpListIdentities( @@ -383,6 +386,17 @@ private function inventoryCatalog(ProjectWorkspace $workspace): InventoryCatalog return $this->inventoryCatalog ??= InventoryCatalog::fromWorkspace($workspace); } + /** + * Returns the project's skill catalogue, read once per validation. + * + * @param ProjectWorkspace $workspace The project. + * @return SkillCatalog The catalogue. + */ + private function skillCatalog(ProjectWorkspace $workspace): SkillCatalog + { + return $this->skillCatalog ??= $workspace->loadSkillCatalog(); + } + /** @param string[] $categories @return string[] */ private function recordLabels(ProjectWorkspace $workspace, array $categories): array { diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index 5919de6..9c369db 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -739,3 +739,79 @@ function writeValidatorActor(string $root, string $fileId, string $name, string ->and($issues[0]->severity)->toBe(Severity::WARNING) ->and($issues[0]->message)->toBe('Its tiles2d crop table is no longer read.'); }); + +/** Writes skills spread across all three of the Engine's skill files. */ +function writeSpreadSkillCatalog(string $root, string $extraMagic = ''): void +{ + $header = " new SpecialSkill('Ward', 'Guards.', '', 2, 0),\n" + . "];\n"); + file_put_contents($root . '/assets/Data/magic.php', $header . "return [\n" + . " 'Burn I' => new MagicSkill('Burn I', 'Burns.', '', 5, 0),\n" + . $extraMagic + . "];\n"); +} + +it('checks ability and spell alias targets against their own kind across every skill file', function () { + $root = makeTemporaryProject(); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeSpreadSkillCatalog($root); + writeSaveCompatibilityManifest($root, <<<'PHP' + 0, + 'migrations' => [], + 'aliases' => [ + 'spells' => [ + ['from' => 'Fire', 'to' => 'Burn I'], + ['from' => 'Purify', 'to' => 'Cleanse'], + ['from' => 'Guard', 'to' => 'Ward'], + ], + 'abilities' => [ + ['from' => 'Shield', 'to' => 'Ward'], + ['from' => 'Blaze', 'to' => 'Burn I'], + ], + ], + 'tombstones' => [], + ]; + PHP); + + $missing = array_map( + static fn(Issue $issue): string => $issue->message, + issuesMentioning(validateProject($root), 'is not defined in the current'), + ); + + expect($missing)->toEqualCanonicalizing([ + 'Alias target "Ward" is not defined in the current spells catalog.', + 'Alias target "Burn I" is not defined in the current abilities catalog.', + ]); +}); + +it('reports a skill name the catalogue defines twice', function () { + $root = makeTemporaryProject(); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeSpreadSkillCatalog($root, " 'Cleanse' => new MagicSkill('Cleanse', 'Again.', '', 3, 0),\n"); + + $issues = issuesMentioning(validateProject($root), '"Cleanse" is defined in both skills.php and magic.php'); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::ERROR); +}); + +it('offers skills from every skill file as references', function () { + $root = makeTemporaryProject(); + writeSpreadSkillCatalog($root); + + expect(ProjectWorkspace::fromProject($root)->getSkillNames())->toBe(['Attack', 'Cleanse', 'Ward', 'Burn I']); +}); From 8b641fb9366cbc7f832c14b721fece2db2cd86b1 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 10:08:29 +0200 Subject: [PATCH 090/344] feat(validation): check screen transitions as the Engine plays them `ichiloto validate` now loads assets/Data/Presentation/transitions.php through the Engine's ScreenTransitionCatalog and checks every treatment's assets, including treatments nothing selects yet. A file the Engine refuses, a battle choice naming no treatment, or an image that is missing or cannot be loaded is an error, with the direct cut the graphical renderers take in the meantime named in the hint. The manual's Validation section describes this check and the skill catalogue checks added with it. --- docs/manual.md | 12 ++++ src/Validation/ProjectValidator.php | 1 + src/Validation/ScreenTransitionValidator.php | 53 ++++++++++++++++++ tests/Unit/ProjectValidatorTest.php | 59 ++++++++++++++++++++ 4 files changed, 125 insertions(+) create mode 100644 src/Validation/ScreenTransitionValidator.php diff --git a/docs/manual.md b/docs/manual.md index a2ae171..540208f 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1944,6 +1944,18 @@ spawn points outside the map. The warnings appear in the status footer; `Ctrl+E` shows the full text. The project-wide pass (`ichiloto validate`) also covers every NPC field, as described under [NPC Mode](#npc-mode). +Skills are read as one catalogue across `skills.php`, `abilities.php` and +`magic.php`, as the game reads them: a skill name defined twice, an entry that +is not a skill, or a key that differs from its skill's name is an error, and +save aliases for spells must point at spells and those for abilities at +abilities. + +Screen transitions in `assets/Data/Presentation/transitions.php` are checked +as the Engine plays them, including treatments nothing selects yet: a file the +Engine refuses, a battle choice naming no treatment, or an image that is +missing or cannot be loaded is an error. Until it is fixed the graphical +renderers enter battle with a direct cut; the terminal intro is unaffected. + An inventory id two definitions claim is reported once, naming every claimant with the category it was authored in, the aliases it brought, and its entry in `items.php` - the game refuses the whole catalogue until one diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 274f408..ff98151 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -130,6 +130,7 @@ public function validate(ProjectWorkspace $workspace): array ...$this->checkPermanentGrowth($workspace), ...$this->checkOptimizationPolicy($workspace), ...new SaveCompatibilityValidator()->validate($workspace), + ...new ScreenTransitionValidator()->validate($workspace), ]; usort( diff --git a/src/Validation/ScreenTransitionValidator.php b/src/Validation/ScreenTransitionValidator.php new file mode 100644 index 0000000..0842c9a --- /dev/null +++ b/src/Validation/ScreenTransitionValidator.php @@ -0,0 +1,53 @@ +projectRoot . DIRECTORY_SEPARATOR . 'assets'; + + try { + $catalog = ScreenTransitionCatalog::load($assetRoot); + } catch (Throwable $failure) { + return [Issue::error( + self::WHERE, + sprintf('The transitions could not be read: %s', $failure->getMessage()), + 'Return a ScreenTransitionCatalog whose keys match their treatment ids and whose battle names one of them. ' . self::FALLBACK, + )]; + } + + if ($catalog === null) { + return []; + } + + try { + $catalog->validateAssets($assetRoot); + } catch (Throwable $failure) { + return [Issue::error( + self::WHERE, + $failure->getMessage(), + 'Point each image at a PNG inside assets/ that the renderers can load. ' . self::FALLBACK, + )]; + } + + return []; + } +} diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index 9c369db..bd975eb 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -815,3 +815,62 @@ function writeSpreadSkillCatalog(string $root, string $extraMagic = ''): void expect(ProjectWorkspace::fromProject($root)->getSkillNames())->toBe(['Attack', 'Cleanse', 'Ward', 'Burn I']); }); + +/** Writes a transitions catalogue whose one treatment draws the given image. */ +function writeTransitionCatalog(string $root, string $asset, string $battle = 'sweep'): void +{ + @mkdir($root . '/assets/Data/Presentation', 0777, true); + file_put_contents($root . '/assets/Data/Presentation/transitions.php', << 0, 'y' => 0, 'width' => 160, 'height' => 90]; + \$brush = ['type' => 'solid', 'color' => PresentationColor::rgb(0, 0, 0)->toArray()]; + \$sweep = new ScreenTransitionTreatment(['id' => 'sweep', 'width' => 160, 'height' => 90, + 'timings' => ['gather' => 0, 'cover' => 100, 'hold' => 0, 'reveal' => 100], + 'coverBrush' => \$brush, 'phases' => [ + 'gather' => [], + 'cover' => [['operation' => ['type' => 'image', 'asset' => '$asset', 'destination' => \$bounds]]], + 'reveal' => [['operation' => ['type' => 'fill', 'destination' => \$bounds, 'brush' => \$brush]]], + ]]); + + return new ScreenTransitionCatalog(['sweep' => \$sweep], battle: '$battle'); + PHP); +} + +it('accepts transitions the Engine can play', function () { + $root = makeTemporaryProject(); + writeConsistentQuests($root); + writeConsistentSkits($root); + @mkdir($root . '/assets/Graphics/System', 0777, true); + $image = imagecreatetruecolor(4, 4); + imagepng($image, $root . '/assets/Graphics/System/Sweep.png'); + writeTransitionCatalog($root, 'Graphics/System/Sweep.png'); + + expect(validateProject($root))->toBe([]); +}); + +it('reports transitions the Engine refuses, naming the direct cut it falls back to', function (string $asset, string $battle, ?string $source, string $expected) { + $root = makeTemporaryProject(); + writeTransitionCatalog($root, $asset, $battle); + + if ($source !== null) { + file_put_contents($root . '/assets/Data/Presentation/transitions.php', $source); + } + + $issues = array_values(array_filter( + validateProject($root), + static fn(Issue $issue): bool => $issue->where === 'assets/Data/Presentation/transitions.php', + )); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::ERROR) + ->and($issues[0]->message)->toContain($expected) + ->and($issues[0]->hint)->toContain('direct cut'); +})->with([ + 'a missing image' => ['Graphics/System/Missing.png', 'sweep', null, 'Transition sweep:'], + 'an unknown battle choice' => ['Graphics/System/Missing.png', 'gilded', null, 'Unknown battle transition treatment.'], + 'the wrong return value' => ['Graphics/System/Missing.png', 'sweep', " Date: Fri, 2 Oct 2026 16:52:31 +0200 Subject: [PATCH 091/344] feat(validation): read save migration steps through the Engine's reader The Editor repeated the Engine's rules for a migration step's shape. It now validates each step through SaveCompatibilityManifest::readDeclaredPositionEdits, so declared relocations are checked exactly as the Engine reads them, beside map shifts, and the two cannot drift apart. Removed: the Editor's own map shift checker. A step with several malformed edits now reports the first one, as the Engine refuses it. The error for a step declaring both a class and declared edits, or neither, takes the Engine's wording. The manual's Validation section describes relocations. --- docs/manual.md | 7 ++++ src/Validation/SaveCompatibilityValidator.php | 42 +++++-------------- tests/Unit/MapLineInsertionTest.php | 30 ++++++++++++- 3 files changed, 45 insertions(+), 34 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 540208f..b87b62e 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1950,6 +1950,13 @@ is not a skill, or a key that differs from its skill's name is an error, and save aliases for spells must point at spells and those for abilities at abilities. +Save migration steps are read as the Engine reads them: each declares a +project class, or declared position edits, `mapShifts` for inserted rows and +columns and `relocations` for cells authored content now occupies (map, +cells, and a landing that is not one of them). A malformed step is an error. +Relocations are written by hand when an NPC or furniture moves onto open +floor; add them to a new step, never to an earlier one. + Screen transitions in `assets/Data/Presentation/transitions.php` are checked as the Engine plays them, including treatments nothing selects yet: a file the Engine refuses, a battle choice naming no treatment, or an image that is diff --git a/src/Validation/SaveCompatibilityValidator.php b/src/Validation/SaveCompatibilityValidator.php index 623791d..e548970 100644 --- a/src/Validation/SaveCompatibilityValidator.php +++ b/src/Validation/SaveCompatibilityValidator.php @@ -11,7 +11,7 @@ use Ichiloto\Engine\Entities\Skills\SkillCatalog; use Ichiloto\Engine\Exceptions\InvalidSaveCompatibilityManifestException; use Ichiloto\Engine\IO\SaveCompatibility\ContentReferenceCategory; -use Ichiloto\Engine\IO\SaveCompatibility\MapShift; +use Ichiloto\Engine\IO\SaveCompatibility\SaveCompatibilityManifest; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use Throwable; @@ -20,6 +20,7 @@ final class SaveCompatibilityValidator { private const string WHERE = 'assets/Data/save-compatibility.php'; + private const string DECLARED_EDITS_HINT = 'Use a class for a project migration, or declared position edits: mapShifts (map, axis x or y, the 0-based line inserted at, a count of at least 1) for inserted rows and columns, relocations (map, cells, to) for cells authored content now occupies.'; /** * @var InventoryCatalog|null The project's inventory identity, read once. @@ -276,12 +277,14 @@ private function checkMigrations(mixed $rawMigrations, mixed $version): array $issues[] = Issue::error($where, sprintf('Migration step %d to %d is in an impossible order.', $from, $to)); } - if (array_key_exists('class', $entry) === array_key_exists('mapShifts', $entry)) { - $issues[] = Issue::error($where, 'A migration must declare exactly one of class or mapShifts.', - 'Use a class for a project migration, or mapShifts for rows and columns inserted into maps.'); - } elseif (array_key_exists('mapShifts', $entry)) { - $issues = [...$issues, ...$this->checkMapShifts($entry['mapShifts'], $where)]; - } elseif ($class === '') { + try { + $edits = SaveCompatibilityManifest::readDeclaredPositionEdits($entry, 'This migration'); + } catch (InvalidSaveCompatibilityManifestException $exception) { + $issues[] = Issue::error($where, $exception->getMessage(), self::DECLARED_EDITS_HINT); + $edits = []; + } + + if ($edits === null && $class === '') { $issues[] = Issue::error($where, 'Migration class must be a non-empty class name.'); } @@ -313,31 +316,6 @@ private function checkMigrations(mixed $rawMigrations, mixed $version): array return $issues; } - /** - * Checks a declarative map shift step exactly as the Engine reads it. - * - * @return Issue[] - */ - private function checkMapShifts(mixed $rawShifts, string $where): array - { - if (! is_array($rawShifts) || $rawShifts === [] || ! array_is_list($rawShifts)) { - return [Issue::error($where, 'mapShifts must be a non-empty list.')]; - } - - $issues = []; - - foreach ($rawShifts as $index => $shift) { - try { - MapShift::fromArray($shift, sprintf('mapShifts[%d]', $index)); - } catch (InvalidSaveCompatibilityManifestException $exception) { - $issues[] = Issue::error($where, $exception->getMessage(), - 'Each shift names a map, an axis (x or y), the 0-based line it was inserted at, and a count of at least 1.'); - } - } - - return $issues; - } - /** @return string[]|null Null means this category has no closed catalog. */ private function knownTargets(ProjectWorkspace $workspace, ContentReferenceCategory $category): ?array { diff --git a/tests/Unit/MapLineInsertionTest.php b/tests/Unit/MapLineInsertionTest.php index ccc47b7..e32a6e7 100644 --- a/tests/Unit/MapLineInsertionTest.php +++ b/tests/Unit/MapLineInsertionTest.php @@ -280,7 +280,7 @@ function openLineInsertPaletteItem(Editor $editor, string $label): void 'tombstones' => [], ]) ->and(Ichiloto\Engine\IO\SaveCompatibility\SaveCompatibilityManifest::fromProjectRoot($root)->createMigrationFrom(0)) - ->toBeInstanceOf(Ichiloto\Engine\IO\SaveCompatibility\MapShiftContentMigration::class) + ->toBeInstanceOf(Ichiloto\Engine\IO\SaveCompatibility\DeclaredPositionContentMigration::class) ->and(describeSaveCompatibilityIssues($root))->toBe([]); }); @@ -429,5 +429,31 @@ function openLineInsertPaletteItem(Editor $editor, string $label): void $messages = describeSaveCompatibilityIssues($root); expect(implode("\n", $messages))->toContain('axis must be "x" or "y"') - ->and(implode("\n", $messages))->toContain('exactly one of class or mapShifts'); + ->and(implode("\n", $messages))->toContain('must declare either a class or declared position edits'); +}); + +it('validates declarative relocation steps as the Engine reads them', function () { + $root = lineInsertionProject(); + $write = static fn(array $migrations, int $version) => file_put_contents($root . '/assets/Data/save-compatibility.php', " $version, + 'migrations' => $migrations, + 'aliases' => [], + 'tombstones' => [], + ], true) . ";\n"); + + $write([ + ['from' => 0, 'to' => 1, 'mapShifts' => [['map' => 'home', 'axis' => 'y', 'at' => 2, 'by' => 1]], + 'relocations' => [['map' => 'home', 'cells' => [[3, 4]], 'to' => [3, 5]]]], + ['from' => 1, 'to' => 2, 'relocations' => [['map' => 'home', 'cells' => [[1, 1]], 'to' => [1, 2]]]], + ], 2); + expect(describeSaveCompatibilityIssues($root))->toBe([]); + + $write([ + ['from' => 0, 'to' => 1, 'relocations' => [['map' => 'home', 'cells' => [[3, 4]], 'to' => [3, 4]]]], + ['from' => 1, 'to' => 2, 'class' => 'Game\\Save\\Step', 'relocations' => [['map' => 'home', 'cells' => [[1, 1]], 'to' => [1, 2]]]], + ], 2); + $messages = implode("\n", describeSaveCompatibilityIssues($root)); + + expect($messages)->toContain('is one of the cells it moves players off') + ->and($messages)->toContain('must declare either a class or declared position edits'); }); From 62d898e4b52f0549d2cda9a43d9773e63390ef6f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 17:43:43 +0200 Subject: [PATCH 092/344] fix(validation): report an NPC on an event only when it leaves no tile free The check treated any fixed NPC on any tile of an event's area as making the event unreachable. An area with another tile to step onto still fires: a shopkeeper behind the counter of a shop area, a guest inside a celebration area. Last Legend's Happyville shopkeeper and Nivira at the inn were reported as errors for exactly that. It is now an error only when fixed NPCs cover every tile of the area, and a warning when a wanderer covers the last free tile. An NPC alone on a one-tile event is still an error. --- src/Validation/ProjectValidator.php | 60 ++++++++++++++++++++++++----- tests/Unit/NpcAuthoringTest.php | 23 +++++++++++ 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index ff98151..54d0f0a 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -2418,6 +2418,29 @@ protected function checkNpcInteractionShapes(array $entry, string $where): array return [...$issues, ...$this->checkStateWrites(is_array($entry['sets'] ?? null) ? $entry['sets'] : [], $where)]; } + /** + * Returns each event marker's tiles on a map, keyed "x,y". + * + * @param ProjectMap $map The map. + * @return array> Tiles keyed by marker. + */ + protected function eventAreaCells(ProjectMap $map): array + { + $cells = []; + + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x < $map->getWidth(); $x++) { + $marker = $map->getEventSymbol($x, $y); + + if (trim($marker) !== '') { + $cells[$marker]["{$x},{$y}"] = true; + } + } + } + + return $cells; + } + /** * Reports fields the runtime does not read. They are preserved by the * editor, so this is a warning, not a loss. @@ -2448,10 +2471,13 @@ protected function checkNpcUnknownFields(array $entry, string $where): array * Checks where NPCs stand against each other, against event tiles and * spawn tiles. * - * The game refuses to move onto a tile an NPC stands on, so a fixed NPC - * on an event tile makes that event unreachable; a wanderer only blocks - * it while there. Two NPCs on one tile: `npcAt` finds the first, so the - * second can never be spoken to while both stand still. + * The game refuses to move onto a tile an NPC stands on. An NPC on one + * tile of an event's area is fine while the area has another tile to step + * onto (a shopkeeper behind the counter of a shop area, a guest inside a + * celebration area). Only when fixed NPCs cover every tile of the area can + * the event never fire; a wanderer only blocks it while there. Two NPCs on + * one tile: `npcAt` finds the first, so the second can never be spoken to + * while both stand still. * * @param ProjectMap $map The map. * @param ProjectWorkspace $workspace The project. @@ -2463,6 +2489,18 @@ protected function checkNpcCollisions(ProjectMap $map, ProjectWorkspace $workspa $issues = []; $definitions = (array) ($map->data['events'] ?? []); $spawns = $this->spawnTilesOn($map, $workspace); + $occupiedBy = ['fixed' => [], 'any' => []]; + + foreach ($anchors as $anchor) { + $cell = $anchor['x'] . ',' . $anchor['y']; + $occupiedBy['any'][$cell] = true; + + if (! $anchor['wanders']) { + $occupiedBy['fixed'][$cell] = true; + } + } + + $areaCells = $this->eventAreaCells($map); foreach ($anchors as $index => $anchor) { foreach ($anchors as $otherIndex => $other) { @@ -2498,19 +2536,21 @@ protected function checkNpcCollisions(ProjectMap $map, ProjectWorkspace $workspa $marker = $map->getEventSymbol($anchor['x'], $anchor['y']); - if (trim($marker) !== '' && isset($definitions[$marker])) { + $isAreaCovered = static fn(array $occupied): bool => array_diff_key($areaCells[$marker] ?? [], $occupied) === []; + + if (trim($marker) !== '' && isset($definitions[$marker]) && $isAreaCovered($occupiedBy['any'])) { $class = is_array($definitions[$marker]) ? strval($definitions[$marker]['class'] ?? '') : ''; $classLabel = $class !== '' ? sprintf(' (%s)', basename(str_replace('\\', '/', $class))) : ''; - $issues[] = $anchor['wanders'] + $issues[] = ! $isAreaCovered($occupiedBy['fixed']) ? Issue::warning( $anchor['where'], - sprintf('It starts on event marker %s%s at (%d, %d).', $marker, $classLabel, $anchor['x'], $anchor['y']), - 'The player cannot step onto that tile while the NPC stands there, so the event cannot fire until it wanders off.' + sprintf('It starts on event marker %s%s at (%d, %d), and NPCs cover every tile of that event.', $marker, $classLabel, $anchor['x'], $anchor['y']), + 'The player cannot step onto the event while they stand there, so it cannot fire until a wanderer moves off.' ) : Issue::error( $anchor['where'], - sprintf('It stands on event marker %s%s at (%d, %d).', $marker, $classLabel, $anchor['x'], $anchor['y']), - 'The player can never step onto that tile, so the event never fires. Move the NPC or the marker.' + sprintf('It stands on event marker %s%s at (%d, %d), and NPCs cover every tile of that event.', $marker, $classLabel, $anchor['x'], $anchor['y']), + 'The player can never step onto the event, so it never fires. Move the NPC, or give the event a free tile.' ); } diff --git a/tests/Unit/NpcAuthoringTest.php b/tests/Unit/NpcAuthoringTest.php index b2bd24b..70432f4 100644 --- a/tests/Unit/NpcAuthoringTest.php +++ b/tests/Unit/NpcAuthoringTest.php @@ -1433,3 +1433,26 @@ public function __construct() ->and($editedLine)->toEndWith(mb_substr($long, -10)); callEditorMethod($editor, 'dispatchInput', "\033"); }); + +it('allows an NPC on an event while the event keeps a free tile, and reports one that covers it', function () { + [$root] = npcProject([ + ['id' => 'keeper', 'name' => 'Keeper', 'x' => 5, 'y' => 1], + ]); + // A three-tile area, the way a shop counter or a gathering spot is drawn. + file_put_contents($root . '/assets/Maps/test-map/test-map.event.php', "not->toContain('Keeper'); + + // Fixed NPCs on every tile of it leave the player nowhere to stand. + [$covered] = npcProject([ + ['id' => 'keeper', 'name' => 'Keeper', 'x' => 5, 'y' => 1], + ['id' => 'left', 'name' => 'Left', 'x' => 4, 'y' => 1], + ['id' => 'right', 'name' => 'Right', 'x' => 6, 'y' => 1, 'movement' => 'wander'], + ]); + file_put_contents($covered . '/assets/Maps/test-map/test-map.event.php', "toContain('NPC Keeper: It starts on event marker E (ChestEventTrigger) at (5, 1), and NPCs cover every tile of that event') + ->and(implode("\n", npcIssueLines($covered, Severity::ERROR)))->not->toContain('event marker'); +}); From 71ccbaac03e7838b1acfbdaafc11bcd7d997e941 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 18:12:52 +0200 Subject: [PATCH 093/344] docs(roadmap): plan per-track presentation authoring for effect timelines --- docs/roadmap.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index fb565be..59cc3b8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -147,6 +147,16 @@ Acceptance criteria for the presentation-authoring slice: stable IDs, same-path replacement PNGs, missing/invalid/out-of-root assets, unsupported PHP and failed writes. Unrelated data must remain unchanged. +Effect timelines (planned, follows the Engine's shared compiled track contract): +each track declares which presentation it belongs to (all, terminal or +graphical), so a terminal glyph track and its PNG counterpart coexist without +the Engine hiding glyphs wherever an image exists; narrative text stays its +own kind. Authoring is a constrained choice per track, edited in the TUI as a +field like any other (no graphical workflow), round-tripped source-preserving, +and validated through the Engine's contract. Rest-frame validity belongs to the +whole sequence, so a track that is dormant at rest (a later second slash) is +valid; validation must not require every image track to cover the rest frame. + This scope does not replace the map source-integrity work or authorize a new GUI implementation. Keep runtime availability and TUI authoring completion separate until both have been verified. From 13eb0c87228f240611ea679a8506002da00a7e96 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 19:10:31 +0200 Subject: [PATCH 094/344] docs(roadmap): plan both independent effect presentation sequences and their validation --- docs/roadmap.md | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 59cc3b8..7b3b921 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -147,15 +147,29 @@ Acceptance criteria for the presentation-authoring slice: stable IDs, same-path replacement PNGs, missing/invalid/out-of-root assets, unsupported PHP and failed writes. Unrelated data must remain unchanged. -Effect timelines (planned, follows the Engine's shared compiled track contract): -each track declares which presentation it belongs to (all, terminal or -graphical), so a terminal glyph track and its PNG counterpart coexist without -the Engine hiding glyphs wherever an image exists; narrative text stays its -own kind. Authoring is a constrained choice per track, edited in the TUI as a -field like any other (no graphical workflow), round-tripped source-preserving, -and validated through the Engine's contract. Rest-frame validity belongs to the -whole sequence, so a track that is dormant at rest (a later second slash) is -valid; validation must not require every image track to cover the rest frame. +Effect timelines (planned, follows the Engine's effect presentation contract in +its docs/effect-animation.md): an effect is either one flat sequence, still +supported, or `presentations` with two complete, independent sequences, +`terminal` and `graphical`. Each owns its own fps, length, rest frame, tracks and +cues; both are required, never nested, merged or partially overridden. Within a +sequence each track carries `presentation` (`all` by default, `terminal` or +`graphical`; image tracks are inherently graphical), so a terminal glyph track +and its PNG counterpart coexist without hiding glyphs wherever an image exists; +narrative text is never suppressed by an image. + +Authoring requirements: +- Both sequences and every track's `presentation` round-trip source-preserving + through the existing database and timeline editing paths; an effect authored + flat stays flat unless the author converts it. +- Validation checks BOTH choices through the Engine's own load and compile for + each presentation: the terminal sequence without depending on any PNG, the + graphical one with its resources required and diagnosed when missing. +- Rest-frame validity belongs to each image sequence: one image covers rest and + later independently timed tracks may be dormant (a second slash), so + validation never requires every image track to cover rest. +- The TUI edits these as ordinary fields and keeps its terminal editing + behaviour; it gains no graphical workflow. Rich graphical sequence authoring + (previews, image track timing) belongs to the GUI editor. This scope does not replace the map source-integrity work or authorize a new GUI implementation. Keep runtime availability and TUI authoring completion separate From 17ae81067cd4eabdfc7321c7e74df6ec487b8fb5 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 21:06:13 +0200 Subject: [PATCH 095/344] feat(validation): report what the player cannot reach, not where content stands ichiloto validate now reports the Engine's reachability analysis: an event no reachable cell lies in and an arrival outside the map or on a wall are errors; a talkable NPC with no reachable cell beside it and a map nothing reachable leads to yet are warnings. Unknown destinations and unreadable maps stay with the checks that own them. Edge triggers were never checked for their destination; they now are, beside doors, since walking off an edge to a missing map crashes the game the same way. --- docs/manual.md | 8 +++ src/Validation/ProjectValidator.php | 17 +++++++ src/Validation/ReachabilityValidator.php | 47 +++++++++++++++++ tests/Unit/ProjectValidatorTest.php | 65 ++++++++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 src/Validation/ReachabilityValidator.php diff --git a/docs/manual.md b/docs/manual.md index b87b62e..f48748c 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1957,6 +1957,14 @@ cells, and a landing that is not one of them). A malformed step is an error. Relocations are written by hand when an NPC or furniture moves onto open floor; add them to a new step, never to an earlier one. +Reachability is checked as the game moves the player, from where the game +starts and every way onto each map it can reach, never from where content +stands: moving an NPC or rebuilding a room is fine unless it traps something. +An event no reachable cell lies in, or an arrival outside the map or on a +wall, is an error; a talkable NPC with no reachable cell beside it, and a map +nothing reachable leads to yet, are warnings. Doors and edge triggers that +name a map the project does not have are errors of their own. + Screen transitions in `assets/Data/Presentation/transitions.php` are checked as the Engine plays them, including treatments nothing selects yet: a file the Engine refuses, a battle choice naming no treatment, or an image that is diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 54d0f0a..71fc530 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -131,6 +131,7 @@ public function validate(ProjectWorkspace $workspace): array ...$this->checkOptimizationPolicy($workspace), ...new SaveCompatibilityValidator()->validate($workspace), ...new ScreenTransitionValidator()->validate($workspace), + ...new ReachabilityValidator()->validate($workspace), ]; usort( @@ -1736,6 +1737,22 @@ protected function checkDoors(ProjectMap $map, array $mapIds): array } } + // Edge triggers lead off the map the same way doors do. + foreach (array_values((array) ($map->data['triggers'] ?? [])) as $index => $trigger) { + $destination = is_array($trigger) ? trim(strval($trigger['destinationMap'] ?? '')) : ''; + + if ($destination === '') { + $issues[] = Issue::error($map->mapId, sprintf('Edge trigger %d names no destinationMap.', $index + 1), + 'Add destinationMap, or remove the trigger.'); + } elseif (! in_array($destination, $mapIds, true)) { + $issues[] = Issue::error( + $map->mapId, + sprintf('Edge trigger %d leads to "%s", which is not a map in this project.', $index + 1, $destination), + 'Walking off this edge crashes the game. Check the map id.' + ); + } + } + return $issues; } diff --git a/src/Validation/ReachabilityValidator.php b/src/Validation/ReachabilityValidator.php new file mode 100644 index 0000000..e04b972 --- /dev/null +++ b/src/Validation/ReachabilityValidator.php @@ -0,0 +1,47 @@ +projectRoot . DIRECTORY_SEPARATOR . 'assets'); + + return array_values(array_filter(array_map( + static fn(ReachabilityProblem $problem): ?Issue => self::toIssue($problem), + $project->getAllProblems(), + ))); + } + + private static function toIssue(ReachabilityProblem $problem): ?Issue + { + return match ($problem->kind) { + ReachabilityProblemKind::UNREADABLE_MAP, ReachabilityProblemKind::UNKNOWN_DESTINATION => null, + ReachabilityProblemKind::UNREACHABLE_EVENT => Issue::error($problem->mapId, $problem->message, + 'Open a way to it, or move the event where the player can stand.'), + ReachabilityProblemKind::BLOCKED_ENTRANCE => Issue::error($problem->mapId, $problem->message, + 'Point the arrival at open floor the player can stand on.'), + ReachabilityProblemKind::UNREACHABLE_NPC => Issue::warning($problem->mapId, $problem->message, + 'Leave a free cell beside it, or move what stands in the way.'), + ReachabilityProblemKind::NO_ENTRANCE => Issue::warning($problem->mapId, $problem->message, + 'Fine for a map kept for content still to come; connect it when it is ready.'), + }; + } +} diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index bd975eb..e014077 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -874,3 +874,68 @@ function writeTransitionCatalog(string $root, string $asset, string $battle = 's 'an unknown battle choice' => ['Graphics/System/Missing.png', 'gilded', null, 'Unknown battle transition treatment.'], 'the wrong return value' => ['Graphics/System/Missing.png', 'sweep', " CollisionType::SOLID, ' ' => CollisionType::NONE, '~' => CollisionType::NONE];\n"); + file_put_contents($root . '/assets/Data/system.php', ' ['player' => [ + 'destinationMap' => 'test-map', 'spawnPoint' => ['x' => $startAt[0], 'y' => $startAt[1]], 'spawnSprite' => ['South'], + ]]], true) . ';'); + file_put_contents($root . '/assets/Maps/test-map/test-map.map.php', " */ +function reachabilityIssueLines(string $root): array +{ + return array_map( + static fn(Issue $issue): string => $issue->severity->value . ': ' . $issue->where . ': ' . $issue->message, + array_values(array_filter(validateProject($root), static fn(Issue $issue): bool => + str_contains($issue->message, 'reach') || str_contains($issue->message, 'arrives') + || str_contains($issue->message, 'brings them onto'))), + ); +} + +it('reports an event the player can never reach, not where content stands', function () { + $root = makeTemporaryProject(); + writeConsistentQuests($root); + writeConsistentSkits($root); + // The chest E sits in a sealed pocket. + writeReachableProject($root, + "############\n# ~~~ ### #\n# #E# #\n# ### #\n############", + " \n \n E \n \n "); + + expect(reachabilityIssueLines($root))->toBe([ + 'error: test-map: No cell of event E (ChestEventTrigger) at (8, 2) can be reached, so it never fires.', + ]); + + // Moving the chest anywhere open is simply fine. + writeReachableProject($root, + "############\n# ~~~ ### #\n# # # #\n# ### #\n############", + " \n E \n \n \n "); + expect(reachabilityIssueLines($root))->toBe([]); +}); + +it('notes a map nothing reaches yet as a warning, and checks edge trigger destinations', function () { + $root = makeTemporaryProject(); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeReachableProject($root, + "############\n# ~~~ #\n# #\n# #\n############", + " \n E \n \n \n "); + mkdir($root . '/assets/Maps/vestige', 0777, true); + file_put_contents($root . '/assets/Maps/vestige/vestige.map.php', " 'Vestige', 'triggers' => [ + ['destinationMap' => 'nowhere', 'trigger_area' => ['x' => 0, 'y' => 0, 'width' => 1, 'height' => 1], + 'spawn_point' => ['x' => 0, 'y' => 0]], + ]], true) . ';'); + + $issues = validateProject($root); + + expect(reachabilityIssueLines($root))->toBe([ + 'warning: vestige: Nothing the player can reach from the start brings them onto it yet.', + ])->and(issuesMentioning($issues, 'Edge trigger 1 leads to "nowhere", which is not a map in this project.'))->toHaveCount(1); +}); From e946ead1052d9840db745ad838fb94bc0827c8fc Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 21:26:44 +0200 Subject: [PATCH 096/344] fix(database): keep every animation field the editor does not edit The Animations database saved by rebuilding animations.php from each Animation's toArray(), so any field the Engine object did not write back was lost on save: on the committed Engine that is every sourceEffect and targetEffect binding, and roles and future fields alike. It also replaced the file's header, dropping its use statements and comments. Each loaded entry is now kept as authored, and a save replaces only the fields the editor edits (id, name, position, maxFrames, frames, cues). The file goes through PhpDataFile, which keeps its header and refuses a file it cannot rewrite safely. --- src/ProjectAnimationDatabase.php | 77 +++++++++++++++---- tests/Unit/AnimationDatabaseRoundTripTest.php | 61 +++++++++++++++ 2 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 tests/Unit/AnimationDatabaseRoundTripTest.php diff --git a/src/ProjectAnimationDatabase.php b/src/ProjectAnimationDatabase.php index 3762b17..af93c77 100644 --- a/src/ProjectAnimationDatabase.php +++ b/src/ProjectAnimationDatabase.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor; +use Ichiloto\Editor\Database\PhpDataFile; use Ichiloto\Editor\History\TracksPersistedState; use Ichiloto\Editor\IO\AtomicFile; @@ -19,6 +20,20 @@ final class ProjectAnimationDatabase { use TracksPersistedState; + /** + * The fields the editor edits. Every other field of an authored entry + * (effect bindings, roles, anything the Engine adds) is kept as written. + */ + private const array EDITED_FIELDS = ['id', 'name', 'position', 'maxFrames', 'frames', 'cues']; + + /** + * @var array> Each loaded animation's authored entry, keyed by object id. + */ + private array $authoredEntries = []; + + /** The file as read, so its header is kept and an unsafe file is refused. */ + private ?PhpDataFile $file = null; + /** * @param Animation[] $animations */ @@ -52,19 +67,25 @@ public static function fromProject(string $projectRoot): self return new self($path, []); } - $payload = require $path; + $file = PhpDataFile::load($path, $projectRoot); + $payload = $file->payload; if (! is_array($payload)) { throw new RuntimeException("Unable to parse {$path}."); } - return new self( - $path, - array_map( - static fn(array $animation): Animation => Animation::fromArray($animation), - array_values(array_filter($payload, 'is_array')) - ), - ); + $entries = array_values(array_filter($payload, 'is_array')); + $animations = array_map(static fn(array $entry): Animation => Animation::fromArray($entry), $entries); + $database = new self($path, $animations, isDirty: true); + $database->file = $file; + + foreach ($animations as $index => $animation) { + $database->authoredEntries[spl_object_id($animation)] = $entries[$index]; + } + + $database->captureBaseline(); + + return $database; } /** @@ -87,10 +108,29 @@ public function getAnimations(): array */ protected function buildPersistedPayload(): string { - return " $animation->toArray(), - $this->getAnimations() - )) . ";\n"; + return "getPersistedEntries()) . ";\n"; + } + + /** + * Returns the entries to write: each authored entry with the fields the + * editor edits replaced, so nothing else it holds is lost. + * + * @return list> + */ + private function getPersistedEntries(): array + { + return array_map(function (Animation $animation): array { + $edited = $animation->toArray(); + $entry = $this->authoredEntries[spl_object_id($animation)] ?? $edited; + + foreach (self::EDITED_FIELDS as $field) { + if (array_key_exists($field, $edited)) { + $entry[$field] = $edited[$field]; + } + } + + return $entry; + }, $this->getAnimations()); } /** @@ -275,7 +315,18 @@ public function save(): void return; } - AtomicFile::write($this->path, $this->buildPersistedPayload()); + $entries = $this->getPersistedEntries(); + + if ($this->file !== null) { + $this->file->save($entries); + } else { + AtomicFile::write($this->path, $this->buildPersistedPayload()); + } + + foreach ($this->getAnimations() as $index => $animation) { + $this->authoredEntries[spl_object_id($animation)] = $entries[$index]; + } + $this->captureBaseline(); } diff --git a/tests/Unit/AnimationDatabaseRoundTripTest.php b/tests/Unit/AnimationDatabaseRoundTripTest.php new file mode 100644 index 0000000..7adba8e --- /dev/null +++ b/tests/Unit/AnimationDatabaseRoundTripTest.php @@ -0,0 +1,61 @@ + 1, + 'name' => 'Slash', + 'position' => AnimationTargetPosition::CENTER->value, + 'maxFrames' => 2, + 'roles' => ['attack'], + 'sourceEffect' => 'battle-blade-slash', + 'targetEffect' => 'battle-physical-impact', + 'futureField' => ['kept' => true], + 'frames' => [['index' => 1, 'cells' => [['symbol' => '/', 'x' => 0, 'y' => 0, 'color' => 'red']]]], + 'cues' => [], + ], + ]; + PHP); + + $database = ProjectAnimationDatabase::fromProject($root); + $database->setField(0, 'name', 'Blade Slash'); + $database->save(); + $saved = require $path; + $source = (string) file_get_contents($path); + + expect($saved[0]['name'])->toBe('Blade Slash') + ->and($saved[0]['roles'])->toBe(['attack']) + ->and($saved[0]['sourceEffect'])->toBe('battle-blade-slash') + ->and($saved[0]['targetEffect'])->toBe('battle-physical-impact') + ->and($saved[0]['futureField'])->toBe(['kept' => true]) + ->and($saved[0]['frames'][0]['cells'][0]['symbol'])->toBe('/') + ->and($source)->toContain('// Battle and field animations.') + ->and($source)->toContain('use Ichiloto\Engine\Animations\AnimationTargetPosition;'); + + // A new animation has only what the editor writes, and saving twice changes nothing more. + $database->addAnimation('Spark'); + $database->save(); + $again = require $path; + + expect($again)->toHaveCount(2) + ->and($again[0]['targetEffect'])->toBe('battle-physical-impact') + ->and($again[1]['name'])->toBe('Spark') + ->and($again[1])->not->toHaveKey('targetEffect'); +}); From 22e214044708790ef68cf6694574291493557ec3 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 21:30:13 +0200 Subject: [PATCH 097/344] feat(validation): check every effect as each consumer plays it, in both presentations ichiloto validate did not read effect timelines at all. It now finds every effect the project uses from the consumers themselves (animations' sourceEffect and targetEffect in battle; the field presentation's cues and action prompt, maps' fieldEffects and tileset pieces' effects on the field) and loads each through the Engine's EffectTimelineLibrary in that context, for both the terminal and the graphical presentation. An effect either renderer refuses is an error naming its first user, as is an unreadable field presentation or map fieldEffects block. Animation bindings are read from the authored entry the Animations database keeps (getAuthoredEntry), so they are found whatever the Engine's Animation object carries. --- docs/manual.md | 7 ++ src/ProjectAnimationDatabase.php | 13 ++++ src/Validation/EffectValidator.php | 111 ++++++++++++++++++++++++++++ src/Validation/ProjectValidator.php | 1 + tests/Unit/ProjectValidatorTest.php | 65 ++++++++++++++++ 5 files changed, 197 insertions(+) create mode 100644 src/Validation/EffectValidator.php diff --git a/docs/manual.md b/docs/manual.md index f48748c..fe7376a 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1965,6 +1965,13 @@ wall, is an error; a talkable NPC with no reachable cell beside it, and a map nothing reachable leads to yet, are warnings. Doors and edge triggers that name a map the project does not have are errors of their own. +Effect timelines in `assets/Animations` are checked as each consumer plays +them: an animation's `sourceEffect` and `targetEffect` in battle, and the field +presentation's cue and action prompt effects, maps' `fieldEffects` and tileset +pieces' effects on the field. Each is compiled for the terminal and for the +graphical presentation, so an effect only one renderer would refuse is still +an error; until it is fixed that presentation keeps its fallback. + Screen transitions in `assets/Data/Presentation/transitions.php` are checked as the Engine plays them, including treatments nothing selects yet: a file the Engine refuses, a battle choice naming no treatment, or an image that is diff --git a/src/ProjectAnimationDatabase.php b/src/ProjectAnimationDatabase.php index af93c77..4f2f564 100644 --- a/src/ProjectAnimationDatabase.php +++ b/src/ProjectAnimationDatabase.php @@ -111,6 +111,19 @@ protected function buildPersistedPayload(): string return "getPersistedEntries()) . ";\n"; } + /** + * Returns an animation's entry as authored, with the fields the editor + * edits as they now stand, or null for one the database does not hold. + * + * @return array|null + */ + public function getAuthoredEntry(Animation $animation): ?array + { + $index = array_search($animation, $this->getAnimations(), true); + + return $index === false ? null : $this->getPersistedEntries()[$index]; + } + /** * Returns the entries to write: each authored entry with the fields the * editor edits replaced, so nothing else it holds is lost. diff --git a/src/Validation/EffectValidator.php b/src/Validation/EffectValidator.php new file mode 100644 index 0000000..9d4f238 --- /dev/null +++ b/src/Validation/EffectValidator.php @@ -0,0 +1,111 @@ +projectRoot . DIRECTORY_SEPARATOR . 'assets'; + $issues = []; + /** @var array, field: list}> $uses */ + $uses = []; + $use = static function (string $effect, bool $battle, string $where) use (&$uses): void { + $uses[$effect][$battle ? 'battle' : 'field'][] = $where; + }; + + foreach ($workspace->animationDatabase->getAnimations() as $animation) { + // Read from the authored entry, which keeps the bindings whatever the Engine's Animation carries. + $entry = $workspace->animationDatabase->getAuthoredEntry($animation) ?? []; + + foreach (['sourceEffect' => $entry['sourceEffect'] ?? null, 'targetEffect' => $entry['targetEffect'] ?? null] as $field => $effect) { + if (is_string($effect) && $effect !== '') { + $use($effect, true, sprintf('assets/Data/animations.php: %s %s', $animation->name, $field)); + } + } + } + + try { + $catalog = FieldPresentationCatalog::load($assetRoot); + + foreach ($catalog->cues as $color => $cue) { + $use($cue['effect'], false, sprintf('%s: %s cue', self::FIELD_PRESENTATION, $color)); + } + + if ($catalog->actionPrompt !== null) { + $use($catalog->actionPrompt, false, self::FIELD_PRESENTATION . ': action prompt'); + } + } catch (Throwable $failure) { + $issues[] = Issue::error(self::FIELD_PRESENTATION, 'The field presentation could not be read: ' . $failure->getMessage(), + 'Until this is fixed, cues and the action prompt show their terminal glyphs.'); + } + + foreach ($workspace->maps as $map) { + try { + foreach (FieldEffectManager::readDeclarations($map->data['fieldEffects'] ?? null) as $declaration) { + $use($declaration['effect'], false, sprintf('%s field effect %s', $map->mapId, $declaration['id'])); + } + } catch (Throwable $failure) { + $issues[] = Issue::error($map->mapId, 'Its fieldEffects cannot be read: ' . $failure->getMessage(), + 'Until this is fixed, the map shows none of its field effects; gameplay is unchanged.'); + } + } + + foreach (glob($assetRoot . DIRECTORY_SEPARATOR . Tileset::DIRECTORY . DIRECTORY_SEPARATOR . '*.php') ?: [] as $file) { + $id = basename($file, '.php'); + + try { + $tileset = Tileset::load($assetRoot, $id); + } catch (Throwable) { + continue; // Map graphics validation reports an unreadable tileset. + } + + foreach ($tileset->pieces as $piece) { + if ($piece->effect !== null) { + $use($piece->effect, false, sprintf('assets/%s/%s.php piece %s', Tileset::DIRECTORY, $id, $piece->id)); + } + } + } + + $library = new EffectTimelineLibrary($assetRoot); + ksort($uses); + + foreach ($uses as $effect => $contexts) { + foreach ($contexts as $context => $places) { + foreach (EffectPresentation::cases() as $presentation) { + try { + $library->load($effect, $context === 'battle', $presentation); + } catch (Throwable $failure) { + $issues[] = Issue::error($places[0], sprintf('Effect %s cannot be played in %s for the %s presentation: %s', + $effect, $context, $presentation->value, $failure->getMessage()), + sprintf('Fix assets/Animations/%s. Until then that presentation keeps its fallback.%s', $effect, + count($places) > 1 ? sprintf(' Also used by %d more.', count($places) - 1) : '')); + } + } + } + } + + return $issues; + } +} diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 71fc530..28ca1aa 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -132,6 +132,7 @@ public function validate(ProjectWorkspace $workspace): array ...new SaveCompatibilityValidator()->validate($workspace), ...new ScreenTransitionValidator()->validate($workspace), ...new ReachabilityValidator()->validate($workspace), + ...new EffectValidator()->validate($workspace), ]; usort( diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index e014077..07b9df1 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -939,3 +939,68 @@ function reachabilityIssueLines(string $root): array 'warning: vestige: Nothing the player can reach from the start brings them onto it yet.', ])->and(issuesMentioning($issues, 'Edge trigger 1 leads to "nowhere", which is not a map in this project.'))->toHaveCount(1); }); + +/** Writes a field effect drawing one image cell from a 3 x 4 sheet. */ +function writeFieldEffect(string $root, string $id, string $asset): void +{ + @mkdir($root . "/assets/Animations/{$id}", 0777, true); + file_put_contents($root . "/assets/Animations/{$id}/{$id}.timeline.php", ' 5, 'lengthFrames' => 2, 'playback' => 'loop', 'restFrame' => 0, + 'tracks' => [[ + 'id' => 'cue', 'type' => 'image', 'asset' => $asset, 'sheet' => ['columns' => 3, 'rows' => 4], + 'cells' => ['width' => 1, 'height' => 1], 'depth' => 'front', + 'keyframes' => [['frame' => 0, 'sourceFrame' => 0], ['frame' => 1, 'sourceFrame' => 1]], + ]], + ], true) . ';'); +} + +/** @return list */ +function effectIssueLines(string $root): array +{ + return array_map( + static fn(Issue $issue): string => $issue->where . ': ' . $issue->message, + array_values(array_filter(validateProject($root), static fn(Issue $issue): bool => + str_contains($issue->message, 'Effect ') || str_contains($issue->message, 'fieldEffects') + || str_contains($issue->message, 'field presentation'))), + ); +} + +it('checks every effect a consumer uses, as that consumer plays it, in both presentations', function () { + $root = makeTemporaryProject(); + writeConsistentQuests($root); + writeConsistentSkits($root); + @mkdir($root . '/assets/Graphics/System', 0777, true); + $image = imagecreatetruecolor(9, 8); + imagepng($image, $root . '/assets/Graphics/System/Cue.png'); + writeFieldEffect($root, 'cue-ok', 'Graphics/System/Cue.png'); + writeFieldEffect($root, 'cue-missing-art', 'Graphics/System/Missing.png'); + @mkdir($root . '/assets/Data/Presentation', 0777, true); + file_put_contents($root . '/assets/Data/Presentation/field.php', ' ['blue' => ['effect' => 'cue-ok'], 'yellow' => ['effect' => 'cue-missing-art']], + 'actionPrompt' => ['effect' => 'no-such-effect'], + ], true) . ';'); + $animations = require $root . '/assets/Data/animations.php'; + $animations[0]['targetEffect'] = 'cue-ok'; + file_put_contents($root . '/assets/Data/animations.php', ' str_contains($line, 'cue-ok')))->toBe([]) + ->and(implode("\n", $lines)) + ->toContain('assets/Data/Presentation/field.php: yellow cue: Effect cue-missing-art cannot be played in field for the graphical presentation') + ->toContain('assets/Data/Presentation/field.php: action prompt: Effect no-such-effect cannot be played in field for the terminal presentation') + // A field image effect without terminal tracks still plays in the terminal: it keeps its glyph. + ->not->toContain('cue-missing-art cannot be played in field for the terminal presentation'); +}); + +it('reports map field effects it cannot read', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['fieldEffects'] = 'not a list'; + file_put_contents($path, 'toBe([ + 'test-map: Its fieldEffects cannot be read: fieldEffects must be a list of at most 256 map-owned effects.', + ]); +}); From 7a3ecf7a1949043c6a7c57ec907399bc1984757b Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 21:32:39 +0200 Subject: [PATCH 098/344] feat(cutscenes): choose which renderers draw each summon track Summon tracks gained the Engine's presentation field (all, terminal or graphical), with no way to set it in the editor. Each track row now offers it as a constrained choice taken from the Engine's EffectPresentation, written through the existing source-preserving timeline writer, and shown as all when unset. --- docs/manual.md | 3 ++- src/Database/CutsceneSchemas.php | 5 ++++ .../Unit/Cutscenes/CutscenesWorkspaceTest.php | 24 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/manual.md b/docs/manual.md index fe7376a..a0284d6 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1721,7 +1721,8 @@ is the engine's business, and this manual claims nothing more. #### Tracks, keyframes and cues Open `Tracks` and each track is a row (`Id`, `Type`: `glyph`, `text`, -`flash`, `shake`) followed by its keyframes: frame, duration, position, +`flash`, `shake`; `Presentation`: `all`, `terminal` or `graphical`, the +renderers that draw it) followed by its keyframes: frame, duration, position, content, asset id, color, visible, z-index, blend mode, easing and a free-form payload. `Content` opens the **multiline editor** (`Enter` on the row), which keeps every space, backslash, blank line, tab and wide glyph diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index 791a544..8d343fb 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Inspector\InputControlType; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; use Ichiloto\Engine\Cutscenes\Summons\SummonEffectTiming; @@ -426,6 +427,10 @@ public static function trackList(): RecordSubList fields: [ new RecordField('id', 'Id'), new RecordField('type', 'Type', options: ['glyph', 'text', 'flash', 'shake'], removeWhenEmpty: true, displayDefault: 'glyph'), + // Which renderers draw the track: every one, or only the terminal or the graphical. + new RecordField('presentation', 'Presentation', + options: ['all', ...array_map(static fn(EffectPresentation $presentation): string => $presentation->value, EffectPresentation::cases())], + removeWhenEmpty: true, displayDefault: 'all'), ], blank: ['type' => 'glyph', 'id' => 'track', 'keyframes' => []], nestedLists: ['*' => self::keyframeList()], diff --git a/tests/Unit/Cutscenes/CutscenesWorkspaceTest.php b/tests/Unit/Cutscenes/CutscenesWorkspaceTest.php index d658342..e456fee 100644 --- a/tests/Unit/Cutscenes/CutscenesWorkspaceTest.php +++ b/tests/Unit/Cutscenes/CutscenesWorkspaceTest.php @@ -700,3 +700,27 @@ expect($timeline)->toContain("\$wisp = <<<'ART'") ->and($timeline)->toContain("'id' => 'flare-2'"); }); + +it('offers each summon track the Engine presentations and keeps the choice through a save', function () { + $field = array_values(array_filter( + \Ichiloto\Editor\Database\CutsceneSchemas::trackList()->fields, + static fn($field): bool => $field->key === 'presentation', + ))[0] ?? null; + + expect($field?->options)->toBe(['all', 'terminal', 'graphical']) + ->and($field?->displayDefault)->toBe('all'); + + $root = cutsceneProject(); + $editor = cutscenesEditor($root, 160, 50); + $asset = libraryOf($editor)->find(CutsceneType::SUMMON, 'lantern-wisp'); + $payload = $asset->payload(); + $payload['tracks'][0]['presentation'] = 'terminal'; + $asset->apply($payload); + $asset->save(); + + $timeline = require $root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.timeline.php'; + expect($timeline['tracks'][0]['presentation'])->toBe('terminal') + ->and(file_get_contents($root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.timeline.php'))->toContain("\$wisp = <<<'ART'") + // The Engine compiles the saved choice as authored. + ->and($asset->compiledSummon())->not->toBeNull(); +}); From 5e956e8cccf096abea45e69ce53b90a93907c245 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 22:01:07 +0200 Subject: [PATCH 099/344] feat(database): choose whether a state harms, enhances or is neutral States gained the Engine's disposition (harmful by default, beneficial or neutral), which battle poses follow, with no way to set it in the editor. The States category now offers it as a constrained choice taken from StateDisposition, stored as the string the Engine reads and left out while it is the default. --- docs/manual.md | 4 +++- src/Database/RecordSchemaCatalog.php | 5 +++++ tests/Unit/RecordCategoryEditorTest.php | 24 ++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/manual.md b/docs/manual.md index a0284d6..b3846c3 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1082,7 +1082,9 @@ pairs, and the line reads back exactly what was written: Status effects, read by the engine's `StateRegistry`. Fields: `Id`, `Name`, `Icon`, `Description`, `Duration Turns`, `Tick Formula`, -`Prevents Action`, `Persists After Battle`. +`Prevents Action`, `Persists After Battle`, `Disposition` (`harmful`, the +default, `beneficial` or `neutral`: whether it harms or enhances its bearer, +which battle poses follow). `Id` is the only field the engine requires. Optional fields disappear from the file when you clear them, so a state with no duration lasts until it is cured diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 9128175..58baa73 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -18,6 +18,7 @@ use Ichiloto\Editor\Field\ProjectNpc; use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Editor\PermanentGrowthCatalog; +use Ichiloto\Engine\Entities\States\StateDisposition; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; use Ichiloto\Engine\Entities\Enemies\Enemy; use Ichiloto\Engine\Entities\Enumerations\ItemUserType; @@ -126,6 +127,10 @@ private static function states(): RecordSchema new RecordField('tickFormula', 'Tick Formula', removeWhenEmpty: true), RecordField::boolean('preventsAction', 'Prevents Action'), RecordField::boolean('persistsAfterBattle', 'Persists After Battle'), + // Whether it harms, enhances or is neutral to its bearer; battle poses follow it. + new RecordField('disposition', 'Disposition', + options: array_map(static fn(StateDisposition $disposition): string => $disposition->value, StateDisposition::cases()), + removeWhenEmpty: true, displayDefault: StateDisposition::HARMFUL->value), ], labelKey: 'name', identityKey: 'id', diff --git a/tests/Unit/RecordCategoryEditorTest.php b/tests/Unit/RecordCategoryEditorTest.php index 963333c..c274498 100644 --- a/tests/Unit/RecordCategoryEditorTest.php +++ b/tests/Unit/RecordCategoryEditorTest.php @@ -203,3 +203,27 @@ removeDirectoryRecursively($root); } }); + +it("chooses a state's disposition from the Engine's, and writes nothing while it is the default", function (): void { + $root = makeTemporaryProject(); + + try { + $database = ProjectWorkspace::fromProject($root)->getRecordDatabase('states'); + $field = array_values(array_filter($database->schema->fields, + static fn($field): bool => $field->key === 'disposition'))[0] ?? null; + + expect($field?->options)->toBe(['harmful', 'beneficial', 'neutral']) + ->and($field?->displayDefault)->toBe('harmful'); + + $database->setField(1, 'disposition', 'neutral'); + $database->save(); + $saved = require $root . '/assets/Data/states.php'; + + expect($saved[1]['disposition'])->toBe('neutral') + ->and($saved[0])->not->toHaveKey('disposition') + ->and(\Ichiloto\Engine\Entities\States\State::fromArray($saved[1])->disposition) + ->toBe(\Ichiloto\Engine\Entities\States\StateDisposition::NEUTRAL); + } finally { + removeDirectoryRecursively($root); + } +}); From 75b9f33db629cb88a6ce74b9522e386028c55754 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 00:35:54 +0200 Subject: [PATCH 100/344] feat(events): author and validate registered script commands The Editor now offers the commands the Engine and the open project register. Opening a project reads assets/Data/script-commands.php in an isolated process, without loading handler classes, and makes those commands the ones Engine validation accepts and the command editor offers, replacing any earlier project's. Event and cinematic command lists list registered types after the built-in ones, with rows built from each declaration: text, numbers, flags, options, resource pickers and positions. A command's first list field, such as a shop's merchandise, is edited as nested entries; a further list is shown read-only and kept as written, recorded as a gap in the roadmap. Validation reports a malformed declaration file, a registered command missing what its declaration requires, and every resource it names that the project lacks, in list entries too. Requires the Engine script command registry (Engine 23278ad). --- docs/manual.md | 19 +++ docs/roadmap.md | 15 ++ src/Database/CutsceneSchemas.php | 4 +- src/Database/RecordSchemaCatalog.php | 162 +++++++++++++++++- src/Events/ProjectScriptCommands.php | 95 +++++++++++ src/ProjectWorkspace.php | 7 + src/Validation/CutsceneValidation.php | 11 +- src/Validation/ProjectValidator.php | 95 ++++++++++- tests/Unit/ScriptCommandAuthoringTest.php | 191 ++++++++++++++++++++++ tests/Unit/SkitAndEventEditorTest.php | 5 +- 10 files changed, 595 insertions(+), 9 deletions(-) create mode 100644 src/Events/ProjectScriptCommands.php create mode 100644 tests/Unit/ScriptCommandAuthoringTest.php diff --git a/docs/manual.md b/docs/manual.md index b3846c3..519427e 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1383,6 +1383,25 @@ Supported command types, matching the engine's interpreter: | `recover_party` | None | | `branch` | Conditions, Then (fixed), Else (fixed) | +Registered commands follow the built-in ones in the `Type` options: the +Engine's `shop` and `inn`, then any the project declares in +`assets/Data/script-commands.php` (see the Engine's story events guide). Their +rows come from their declarations: + +| Type | Fields | +| --- | --- | +| `shop` | Buy Rate, Sell Rate, Merchandise (Item, Price) | +| `inn` | Question, Speaker, Cost, Wake At X / Y, Rest Music, Result Variable | + +A command's first list, such as a shop's merchandise, is edited like a +route's steps: with the settings cursor on one of its entry rows, `Shift+O` +adds an entry and `Shift+X` removes one. A further list in a project command is shown +read-only and kept as written. The Editor reads the declarations without +running the project's handler code; the game checks the handlers when it +starts. Validation reports a malformed declaration file, a registered command +missing what its declaration requires, and any resource it names that the +project does not have. + A `knowledge` command records what the party has learned. `Operation` is chosen from the vocabulary the runtime itself defines, and each operation reads only the fields it needs, so the others are left empty and are not diff --git a/docs/roadmap.md b/docs/roadmap.md index 7b3b921..87b1ff3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -175,6 +175,21 @@ This scope does not replace the map source-integrity work or authorize a new GUI implementation. Keep runtime availability and TUI authoring completion separate until both have been verified. +### Registered command lists (planned) + +The Engine lets a registered script command declare any number of `list` +fields. The TUI record editor gives each command entry one nested list, so it +edits a command's first list field as entries and shows any further list +read-only, kept as written. This remains an implementation gap for project +commands with more than one list; the Engine's `shop` and `inn` have at most +one. + +Acceptance criteria: + +- Edit every declared list of a registered command through the existing + source-preserving sub-list path, each with its own add, remove and undo. +- Keep the settings ids unambiguous when several lists share an entry. + ### Spell and ability authoring (planned) The Engine reads a project's skills from one catalogue spread across diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index 8d343fb..0c38826 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -8,6 +8,7 @@ use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; use Ichiloto\Engine\Cutscenes\Summons\SummonEffectTiming; /** @@ -232,7 +233,7 @@ public static function cinematicCommandList(string $key): RecordSubList prefix: 'command', singular: 'command', fields: [ - new RecordField('type', 'Type', options: CinematicCommandSchema::COMMAND_TYPES), + new RecordField('type', 'Type', options: [...CinematicCommandSchema::COMMAND_TYPES, ...ScriptCommandRegistry::getCatalog()->types]), ], blank: ['type' => 'wait', 'seconds' => 0.5], variants: self::cinematicCommandVariants(), @@ -241,6 +242,7 @@ public static function cinematicCommandList(string $key): RecordSubList 'move_route' => RecordSchemaCatalog::routeStepList(), 'parallel' => self::laneList(), 'camera' => static fn(array $entry): ?RecordSubList => strtolower(strval($entry['operation'] ?? '')) === 'route' ? self::cameraPointList() : null, + ...RecordSchemaCatalog::getRegisteredCommandLists(), ], variantArms: [ 'sequence' => ['commands' => 'Sequence'], diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 58baa73..17d8c4c 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -5,6 +5,7 @@ namespace Ichiloto\Editor\Database; use Closure; +use LogicException; use Ichiloto\Editor\ActorStatPreview; use Ichiloto\Editor\Database\Projections\KeyedListProjection; @@ -19,6 +20,11 @@ use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Editor\PermanentGrowthCatalog; use Ichiloto\Engine\Entities\States\StateDisposition; +use Ichiloto\Editor\Events\ProjectScriptCommands; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandDefinition; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandField; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandFieldKind; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; use Ichiloto\Engine\Entities\Enemies\Enemy; use Ichiloto\Engine\Entities\Enumerations\ItemUserType; @@ -48,10 +54,11 @@ final class RecordSchemaCatalog { /** - * The event-script command types the engine's interpreter understands. + * The event-script command types built into the engine's interpreter. * * Imported from the runtime so the editor cannot drift into a duplicate - * command registry. + * command vocabulary. Registered commands join them through + * getEventCommandTypes(). */ public const array EVENT_COMMAND_TYPES = EventInterpreter::COMMAND_TYPES; @@ -1239,13 +1246,14 @@ public static function eventCommandList(string $key): RecordSubList prefix: 'command', singular: 'command', fields: [ - new RecordField('type', 'Type', options: self::EVENT_COMMAND_TYPES), + new RecordField('type', 'Type', options: self::getEventCommandTypes()), ], blank: ['type' => 'text', 'name' => '', 'text' => 'Something happens.'], variants: self::eventCommandVariants(), variantKey: 'type', nestedLists: [ 'move_route' => self::routeStepList(), + ...self::getRegisteredCommandLists(), ], ); } @@ -1392,6 +1400,154 @@ public static function eventCommandVariants(): array new RecordField('conditions', 'Conditions', codec: RecordFieldCodec::CONDITIONS), // The arms become frames; see describeSubEntryFields. ], + ...self::getRegisteredCommandVariants(), ]; } + + /** + * Returns every command type a script may hold: the interpreter's + * built-in vocabulary, then the commands the Engine and the open project + * register. + * + * @return list + */ + public static function getEventCommandTypes(): array + { + return [...self::EVENT_COMMAND_TYPES, ...ScriptCommandRegistry::getCatalog()->types]; + } + + /** + * Returns the field sets of the registered commands, read from their + * declarations. + * + * A command's first list field is edited as its entries, like a route's + * steps; any further list field is shown read-only and kept as written. + * + * @return array + */ + public static function getRegisteredCommandVariants(): array + { + $variants = []; + + foreach (ScriptCommandRegistry::getCatalog()->definitions as $type => $definition) { + $fields = []; + $listField = self::findRegisteredListField($definition); + + foreach ($definition->fields as $field) { + if ($field === $listField) { + continue; + } + + array_push($fields, ...($field->kind === ScriptCommandFieldKind::LIST + ? [new RecordField($field->key, $field->label, isReadOnly: true)] + : self::describeRegisteredField($field))); + } + + $variants[$type] = $fields; + } + + return $variants; + } + + /** + * Returns the entries list of each registered command that has one. + * + * @return array + */ + public static function getRegisteredCommandLists(): array + { + $lists = []; + + foreach (ScriptCommandRegistry::getCatalog()->definitions as $type => $definition) { + $listField = self::findRegisteredListField($definition); + + if ($listField === null) { + continue; + } + + $lists[$type] = new RecordSubList( + key: $listField->key, + // Settings ids read the prefix up to the entry number, so it + // holds letters alone. + prefix: lcfirst(implode('', array_map(ucfirst(...), preg_split('/[^A-Za-z]+/', $listField->key, flags: PREG_SPLIT_NO_EMPTY) ?: ['entry']))), + singular: strtolower($listField->label) . ' entry', + fields: array_merge(...array_map(self::describeRegisteredField(...), $listField->fields)), + blank: self::getRegisteredBlankEntry($listField->fields[0]), + ); + } + + return $lists; + } + + /** The list field a registered command's entries are edited from: its first. */ + private static function findRegisteredListField(ScriptCommandDefinition $definition): ?ScriptCommandField + { + foreach ($definition->fields as $field) { + if ($field->kind === ScriptCommandFieldKind::LIST) { + return $field; + } + } + + return null; + } + + /** + * Describes one declared field as the settings it is edited through. An + * optional text or reference drops out when cleared; numbers and flags + * keep what the author sets, since a project's handler decides what an + * absent one means. + * + * @return RecordField[] + */ + private static function describeRegisteredField(ScriptCommandField $field): array + { + return match ($field->kind) { + ScriptCommandFieldKind::TEXT => [new RecordField($field->key, $field->label, removeWhenEmpty: ! $field->required)], + ScriptCommandFieldKind::INTEGER => [new RecordField($field->key, $field->label, InputControlType::INTEGER)], + ScriptCommandFieldKind::NUMBER => [new RecordField($field->key, $field->label, InputControlType::FLOAT)], + ScriptCommandFieldKind::BOOLEAN => [RecordField::boolean($field->key, $field->label, removeWhenEmpty: false)], + ScriptCommandFieldKind::OPTION => [new RecordField($field->key, $field->label, options: $field->options, removeWhenEmpty: ! $field->required)], + ScriptCommandFieldKind::REFERENCE => [RecordField::reference( + $field->key, + $field->label, + ProjectScriptCommands::getReferenceCategory($field->reference + ?? throw new LogicException("Reference field {$field->key} names no resource.")), + allowsNone: ! $field->required, + )], + ScriptCommandFieldKind::POSITION => [ + new RecordField("{$field->key}.x", "{$field->label} X", InputControlType::INTEGER), + new RecordField("{$field->key}.y", "{$field->label} Y", InputControlType::INTEGER), + ], + ScriptCommandFieldKind::LIST => [new RecordField($field->key, $field->label, isReadOnly: true)], + }; + } + + /** + * Returns a fresh list entry: its first field, empty, so the entry is a + * keyed record from the start and validation names what it still needs. + * + * @return array + */ + private static function getRegisteredBlankEntry(ScriptCommandField $field): array + { + $value = match ($field->kind) { + ScriptCommandFieldKind::INTEGER => 0, + ScriptCommandFieldKind::NUMBER => 0.0, + ScriptCommandFieldKind::BOOLEAN => false, + ScriptCommandFieldKind::OPTION => $field->options[0] ?? '', + ScriptCommandFieldKind::POSITION => ['x' => 0, 'y' => 0], + default => '', + }; + $entry = []; + $target = &$entry; + + foreach (explode('.', $field->key) as $segment) { + $target[$segment] = []; + $target = &$target[$segment]; + } + + $target = $value; + + return $entry; + } } diff --git a/src/Events/ProjectScriptCommands.php b/src/Events/ProjectScriptCommands.php new file mode 100644 index 0000000..0e8b48e --- /dev/null +++ b/src/Events/ProjectScriptCommands.php @@ -0,0 +1,95 @@ +catalog = $catalog ?? ScriptCommandCatalog::createEngineCatalog(); + } + + /** + * Reads a project's declared commands. + * + * A file that cannot be read or declares a malformed command leaves the + * Engine's commands and records why, for validation to report. + * + * @param string $projectRoot The project root. + * @return self + */ + public static function fromProject(string $projectRoot): self + { + $relative = 'assets/' . ScriptCommandRegistry::PROJECT_FILE; + $path = rtrim($projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $relative; + + if (! file_exists($path) && ! is_link($path)) { + return new self(); + } + + try { + return new self(ScriptCommandCatalog::fromDeclarations( + PhpDataFile::evaluateIsolated($path, $projectRoot), + $relative, + )); + } catch (Throwable $exception) { + return new self(declarationProblem: $exception->getMessage()); + } + } + + /** + * Makes these the commands the Engine's script validation accepts and + * the command editor offers, replacing any earlier project's. + * + * @return void + */ + public function activate(): void + { + ScriptCommandRegistry::configure($this->catalog); + } + + /** + * Returns the Editor reference category for a resource a command names. + * + * @param ScriptCommandReference $reference The resource. + * @return string The ReferenceCatalog category. + */ + public static function getReferenceCategory(ScriptCommandReference $reference): string + { + return match ($reference) { + // The item store resolves every inventory definition. + ScriptCommandReference::ITEM => 'inventory', + ScriptCommandReference::MUSIC => 'bgm', + ScriptCommandReference::SOUND => 'sfx', + ScriptCommandReference::MAP => 'maps', + ScriptCommandReference::TROOP => 'troops', + ScriptCommandReference::QUEST => 'quests', + ScriptCommandReference::ACTOR => 'actors', + }; + } +} diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index 0800b62..005eea9 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -10,6 +10,7 @@ use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\RecordSchema; use Ichiloto\Editor\Database\RecordSchemaCatalog; +use Ichiloto\Editor\Events\ProjectScriptCommands; use Ichiloto\Editor\Storage\FileSetOperations; use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Editor\Storage\FilesystemFileSetOperations; @@ -49,6 +50,7 @@ public function __construct( public array $recordDatabases = [], public ?CutsceneLibrary $cutscenes = null, public ?ProjectConfig $config = null, + public ?ProjectScriptCommands $scriptCommands = null, ) { } @@ -155,6 +157,10 @@ private static function loadFromProject(string $projectRoot): self // failure instead of the author's enemies. EngineDataBootstrap::ensure($projectRoot); $projectConfig = new ProjectConfig($projectRoot); + // Before any schema is built: the command editor offers, and Engine + // validation accepts, exactly this project's registered commands. + $scriptCommands = ProjectScriptCommands::fromProject($projectRoot); + $scriptCommands->activate(); return new self( projectRoot: $projectRoot, @@ -175,6 +181,7 @@ classDatabase: ProjectClassDatabase::fromProject($projectRoot), ), cutscenes: CutsceneLibrary::fromProject($projectRoot), config: $projectConfig, + scriptCommands: $scriptCommands, ); } diff --git a/src/Validation/CutsceneValidation.php b/src/Validation/CutsceneValidation.php index 7585748..c15ea73 100644 --- a/src/Validation/CutsceneValidation.php +++ b/src/Validation/CutsceneValidation.php @@ -11,6 +11,7 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicScriptValidator; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; use Throwable; /** @@ -201,7 +202,15 @@ protected function walkCinematicCommands( $type = strval($command['type'] ?? ''); if (! in_array($type, CinematicCommandSchema::COMMAND_TYPES, true)) { - continue; // The Engine already refused the tree. + // The Engine already refused an unknown or malformed command; + // what is left to check of a registered one is what it names. + $registered = ScriptCommandRegistry::getCatalog()->findDefinition($type); + + if ($registered !== null) { + $issues = [...$issues, ...$this->checkRegisteredReferences($registered, $command, $where, $known)]; + } + + continue; } $issues = [...$issues, ...$this->checkSharedCommandSemantics( diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 28ca1aa..dfba43d 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -13,6 +13,7 @@ use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Database\SummonAssignmentDiagnostics; use Ichiloto\Editor\Events\CommandMapContext; +use Ichiloto\Editor\Events\ProjectScriptCommands; use Ichiloto\Editor\Field\MapEncounters; use Ichiloto\Editor\Field\ProjectNpc; use Ichiloto\Editor\ActorStatPreview; @@ -24,6 +25,10 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Core\WorldConditionType; use Ichiloto\Engine\Field\SkitSpeaker; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandDefinition; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandField; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandFieldKind; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; use Ichiloto\Engine\Events\Interpreter\MovementRouteRunner; use Ichiloto\Engine\IO\Console\TerminalText; @@ -113,6 +118,7 @@ public function validate(ProjectWorkspace $workspace): array } $issues = [ + ...$this->checkScriptCommandDeclarations($workspace), ...$this->checkMaps($workspace), ...$this->checkQuests($workspace), ...$this->checkTroops($workspace), @@ -3088,6 +3094,7 @@ protected function checkReferences(ProjectWorkspace $workspace): array 'sfx' => $catalog->valuesFor('sfx'), 'common_events' => $catalog->valuesFor('common_events'), 'cinematics' => $catalog->valuesFor('cinematics'), + 'actors' => $catalog->valuesFor('actors'), ]; return [ @@ -3294,11 +3301,15 @@ protected function walkEventCommands(array $commands, string $where, array $know $type = strval($command['type'] ?? ''); - if (! in_array($type, EventInterpreter::COMMAND_TYPES, true)) { + $registered = ScriptCommandRegistry::getCatalog()->findDefinition($type); + + if ($registered !== null) { + $issues = [...$issues, ...$this->checkRegisteredCommand($registered, $command, $where, $known)]; + } elseif (! in_array($type, EventInterpreter::COMMAND_TYPES, true)) { $issues[] = Issue::error( $where, sprintf('It uses the unknown event command type "%s".', $type !== '' ? $type : '(empty)'), - 'Choose a command supported by the runtime EventInterpreter.' + 'Choose a command built into the runtime, or one the Engine or the project registers.' ); } @@ -4309,6 +4320,86 @@ protected function checkConditions( * @param array $known What the project defines. * @return Issue[] The issue, if there is one. */ + /** + * Reports a project's command declarations the game would refuse to start + * with. + * + * @param ProjectWorkspace $workspace The project. + * @return Issue[] The issues found. + */ + protected function checkScriptCommandDeclarations(ProjectWorkspace $workspace): array + { + $problem = $workspace->scriptCommands?->declarationProblem; + + return $problem === null ? [] : [Issue::error( + 'assets/' . ScriptCommandRegistry::PROJECT_FILE, + sprintf('The project\'s script commands could not be read: %s', $problem), + 'The game will not start until it is fixed, and scripts using these commands are reported as unknown.' + )]; + } + + /** + * Checks a registered command against its declaration: everything the + * runtime would refuse it for, and every resource it names. + * + * @param array $command The authored command. + * @param array $known What the project defines. + * @return Issue[] The issues found. + */ + protected function checkRegisteredCommand(ScriptCommandDefinition $definition, array $command, string $where, array $known): array + { + $issues = array_map( + static fn(string $problem): Issue => Issue::error( + $where, + sprintf('Its %s command cannot run: %s', $definition->type, $problem), + 'The runtime stops the script at this command.' + ), + $definition->findProblems($command), + ); + + return [...$issues, ...$this->checkRegisteredReferences($definition, $command, $where, $known)]; + } + + /** + * Checks every resource a registered command names, in its list entries + * too. + * + * @param array $command The authored command. + * @param array $known What the project defines. + * @return Issue[] The issues found. + */ + protected function checkRegisteredReferences(ScriptCommandDefinition $definition, array $command, string $where, array $known): array + { + $check = function (ScriptCommandField $field, array $data) use (&$check, $where, $known): array { + $value = ScriptCommandField::readPath($data, $field->key); + + if ($field->kind === ScriptCommandFieldKind::REFERENCE && $field->reference !== null && is_string($value)) { + return $this->checkReference($value, ProjectScriptCommands::getReferenceCategory($field->reference), strtolower($field->label), $where, $known); + } + + if ($field->kind !== ScriptCommandFieldKind::LIST || ! is_array($value)) { + return []; + } + + $issues = []; + + foreach (array_filter($value, is_array(...)) as $entry) { + foreach ($field->fields as $entryField) { + $issues = [...$issues, ...$check($entryField, $entry)]; + } + } + + return $issues; + }; + $issues = []; + + foreach ($definition->fields as $field) { + $issues = [...$issues, ...$check($field, $command)]; + } + + return $issues; + } + protected function checkReference(string $value, string $category, string $noun, string $where, array $known): array { $value = trim($value); diff --git a/tests/Unit/ScriptCommandAuthoringTest.php b/tests/Unit/ScriptCommandAuthoringTest.php new file mode 100644 index 0000000..ead9a6f --- /dev/null +++ b/tests/Unit/ScriptCommandAuthoringTest.php @@ -0,0 +1,191 @@ + 'hire_carriage', + 'class' => HireCarriage::class, + 'label' => 'Hire Carriage', + 'fields' => [ + ['key' => 'destination', 'label' => 'Destination', 'kind' => 'reference', 'reference' => 'map', 'required' => true], + ['key' => 'arrival', 'label' => 'Arrival', 'kind' => 'position'], + ['key' => 'speed', 'label' => 'Speed', 'kind' => 'option', 'options' => ['slow', 'fast']], + ['key' => 'stops', 'label' => 'Stops', 'kind' => 'list', 'fields' => [ + ['key' => 'map', 'label' => 'Map', 'kind' => 'reference', 'reference' => 'map', 'required' => true], + ]], + ['key' => 'tolls', 'label' => 'Tolls', 'kind' => 'list', 'fields' => [ + ['key' => 'amount', 'label' => 'Amount', 'kind' => 'integer'], + ]], + ], + ]]; + PHP); +} + +/** Writes an authored PHP data file. */ +function writeScriptCommandScript(string $path, array $payload): void +{ + file_put_contents($path, " */ +function registeredVariantFields(string $type): array +{ + $fields = []; + + foreach (RecordSchemaCatalog::forKey('common_events')?->subList?->variants[$type] ?? [] as $field) { + $fields[$field->key] = $field; + } + + return $fields; +} + +afterEach(function (): void { + ScriptCommandRegistry::reset(); +}); + +it('reads a project\'s declared commands without loading their handlers', function (): void { + $root = makeTemporaryProject(); + + expect(ProjectScriptCommands::fromProject($root)->catalog->types)->toBe(['shop', 'inn']); + + writeCarriageDeclarations($root); + $commands = ProjectScriptCommands::fromProject($root); + + expect($commands->catalog->types)->toBe(['shop', 'inn', 'hire_carriage']) + ->and($commands->catalog->findDefinition('hire_carriage')?->handlerClass)->toBe('Project\\Commands\\HireCarriage') + ->and(class_exists('Project\\Commands\\HireCarriage', false))->toBeFalse() + ->and($commands->declarationProblem)->toBeNull(); +}); + +it('offers the open project\'s registered commands with their declared fields', function (): void { + $root = makeTemporaryProject(); + writeCarriageDeclarations($root); + ProjectWorkspace::fromProject($root); + $list = RecordSchemaCatalog::eventCommandList('script'); + $shop = registeredVariantFields('shop'); + $inn = registeredVariantFields('inn'); + $carriage = registeredVariantFields('hire_carriage'); + + expect($list->fields[0]->options)->toBe([...RecordSchemaCatalog::EVENT_COMMAND_TYPES, 'shop', 'inn', 'hire_carriage']) + ->and(array_keys($shop))->toBe(['buyRate', 'sellRate']) + ->and($shop['sellRate']->type)->toBe(InputControlType::FLOAT) + ->and(array_keys($inn))->toBe(['confirmDialogue.text', 'confirmDialogue.name', 'cost', 'spawnPoint.x', 'spawnPoint.y', 'bgm', 'resultVariable']) + ->and($inn['bgm']->reference)->toBe('bgm') + ->and($inn['bgm']->allowsNone)->toBeTrue() + ->and($inn['cost']->type)->toBe(InputControlType::INTEGER) + ->and($carriage['destination']->reference)->toBe('maps') + ->and($carriage['destination']->allowsNone)->toBeFalse() + ->and($carriage['speed']->options)->toBe(['slow', 'fast']) + // The first list is edited as the command's entries; a second is kept as written. + ->and($carriage)->not->toHaveKey('stops') + ->and($carriage['tolls']->isReadOnly)->toBeTrue(); + + $merchandise = $list->nestedListFor(['type' => 'shop']); + $stops = $list->nestedListFor(['type' => 'hire_carriage']); + + expect($merchandise?->key)->toBe('items') + ->and(array_map(static fn($field): string => $field->key, $merchandise->fields))->toBe(['item', 'price']) + ->and($merchandise->fields[0]->reference)->toBe('inventory') + ->and($merchandise->blank)->toBe(['item' => '']) + ->and($stops?->key)->toBe('stops'); +}); + +it('forgets a previous project\'s commands when another project opens', function (): void { + $root = makeTemporaryProject(); + writeCarriageDeclarations($root); + ProjectWorkspace::fromProject($root); + + ProjectWorkspace::fromProject(makeTemporaryProject()); + + expect(RecordSchemaCatalog::getEventCommandTypes())->not->toContain('hire_carriage') + ->and(RecordSchemaCatalog::getEventCommandTypes())->toContain('shop', 'inn'); +}); + +it('reports registered commands the runtime would refuse and resources they name that do not exist', function (): void { + $root = makeTemporaryProject(); + writeCarriageDeclarations($root); + writeScriptCommandScript($root . '/assets/Events/services.php', [ + ['type' => 'shop', 'items' => [['item' => 'S-Potion', 'price' => 40], ['item' => 'Elixir of Nowhere']], 'sellRate' => -1], + ['type' => 'inn', 'cost' => 30], + ['type' => 'hire_carriage', 'destination' => 'test-map', 'stops' => [['map' => 'atlantis']]], + ['type' => 'hire_carriage', 'destination' => 'test-map', 'speed' => 'warp'], + ]); + + $issues = new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)); + $messages = array_values(array_map( + static fn($issue): string => $issue->message, + array_filter($issues, static fn($issue): bool => str_contains($issue->where, 'services')), + )); + + expect($messages)->toContain( + 'Its shop command cannot run: "sellRate" must be at least 0.', + 'It names the item "Elixir of Nowhere", which does not exist.', + 'Its inn command cannot run: "confirmDialogue.text" is required.', + 'Its hire_carriage command cannot run: "speed" must be one of slow, fast.', + ) + ->and(implode("\n", $messages))->toContain('"atlantis"') + ->and(implode("\n", $messages))->not->toContain('unknown event command type') + ->and(implode("\n", $messages))->not->toContain('"S-Potion"'); +}); + +it('reports declarations the game would refuse to start with', function (): void { + $root = makeTemporaryProject(); + file_put_contents($root . '/assets/' . ScriptCommandRegistry::PROJECT_FILE, " 'transfer', 'class' => 'X', 'label' => 'Teleport']];\n"); + writeScriptCommandScript($root . '/assets/Events/teleport.php', [['type' => 'shop', 'items' => [['item' => 'S-Potion']]]]); + + $issues = new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)); + $declaration = array_values(array_filter($issues, static fn($issue): bool => $issue->where === 'assets/Data/script-commands.php')); + + expect($declaration)->toHaveCount(1) + ->and($declaration[0]->message)->toContain('"transfer", a built-in command type') + // The Engine's own commands stay authorable meanwhile. + ->and(array_filter($issues, static fn($issue): bool => str_contains($issue->where, 'teleport')))->toBe([]); +}); + +it('edits a shop command\'s merchandise and keeps what it does not edit', function (): void { + $root = makeTemporaryProject(); + ProjectWorkspace::fromProject($root); + $database = loadRecordDatabase($root, 'common_events'); + // The fixture's first Common Event. + $path = $root . '/assets/Events/dresser-note.php'; + $commandIndex = $database->addSubItem(0, [ + 'type' => 'shop', + 'items' => [['item' => 'S-Potion']], + 'technicalMetadata' => ['preserve' => true], + ]); + $fieldIds = static fn(): array => array_column($database->getSettingsFields(0), 'field'); + $find = static fn(string $suffix): string => array_values(array_filter( + $fieldIds(), + static fn(string $id): bool => str_starts_with($id, "command{$commandIndex}") && str_ends_with($id, $suffix), + ))[0] ?? throw new RuntimeException("No field ending {$suffix}: " . implode(', ', $fieldIds())); + + $database->setField(0, $find('0Price'), '40'); + expect($database->addNestedSubItem(0, $commandIndex))->toBe(1); + $database->setField(0, $find('1Item'), 'Antidote'); + $database->setField(0, $find('SellRate'), '0.25'); + $database->save(); + + $payload = require $path; + + expect($payload[$commandIndex])->toBe([ + 'type' => 'shop', + 'items' => [['item' => 'S-Potion', 'price' => 40], ['item' => 'Antidote']], + 'technicalMetadata' => ['preserve' => true], + 'sellRate' => 0.25, + ]); +}); diff --git a/tests/Unit/SkitAndEventEditorTest.php b/tests/Unit/SkitAndEventEditorTest.php index 1a134ea..24e703c 100644 --- a/tests/Unit/SkitAndEventEditorTest.php +++ b/tests/Unit/SkitAndEventEditorTest.php @@ -203,7 +203,7 @@ expect($byField)->not->toHaveKey('command0Amount'); }); -it('offers every interpreter command type as a cycleable option', function (): void { +it('offers every built-in and registered command type as a cycleable option', function (): void { $root = makeTemporaryProject(); $database = loadRecordDatabase($root, 'common_events'); @@ -216,7 +216,8 @@ } expect($typeRow)->not->toBeNull(); - expect($typeRow['options'])->toBe(RecordSchemaCatalog::EVENT_COMMAND_TYPES); + // The interpreter's own commands, then the Engine's registered services. + expect($typeRow['options'])->toBe([...RecordSchemaCatalog::EVENT_COMMAND_TYPES, 'shop', 'inn']); removeDirectoryRecursively($root); }); From 08110e621f35b2d93595df48ffda48a4bad10cc0 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 01:04:22 +0200 Subject: [PATCH 101/344] feat(cutscenes): author field effects by their timeline identity A field_animation now names either an effect timeline, which owns its frame rate, or a legacy animation and its pace. The cinematic command rows offer an Effect picker over the project's timelines, listed by the Engine; choosing an effect removes the animation and its seconds per frame, and clearing it brings them back. Other fields of the command are kept as written. Validation holds cinematic and event-script field_animation commands to the Engine's field_animation contract, reports an effect the project does not have, and plays the effect through the Engine for both presentations, reporting one that cannot be read or that loops, since the command waits for its effect to end. The command outline names the effect, and the duration overview times a field effect by its timeline, the longer of its two presentations, marking one it cannot read. Requires Engine 201c007 (EffectTimelineLibrary::findTimelineIds). --- docs/manual.md | 14 +- src/Cutscenes/CutsceneLaneOverview.php | 61 ++++++-- src/Cutscenes/CutsceneOutline.php | 2 +- src/Cutscenes/Editing/CutscenePreviewPane.php | 29 +++- src/Database/CutsceneSchemas.php | 20 ++- src/Database/ReferenceCatalog.php | 4 + src/Validation/CutsceneValidation.php | 8 +- src/Validation/EffectValidator.php | 34 ++++ src/Validation/ProjectValidator.php | 43 ++++++ .../Cutscenes/FieldEffectReferenceTest.php | 146 ++++++++++++++++++ 10 files changed, 334 insertions(+), 27 deletions(-) create mode 100644 tests/Unit/Cutscenes/FieldEffectReferenceTest.php diff --git a/docs/manual.md b/docs/manual.md index b3846c3..69efff9 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1597,13 +1597,21 @@ Lane 2`). `Esc` pops exactly one frame. | `camera` | Operation (`detach`, `attach`, `reset`, `focus`, `pan`, `route`, `track`, `shake`, `restore`), Target (kind, id or x/y), Seconds, Magnitude; a `route` owns Points | | `stage_actor` | the staged-actor fields | | `show_actor`, `hide_actor`, `remove_actor` | Actor (from the cast) | -| `field_animation` | Animation, Target (kind, id or x/y), Seconds Per Frame | +| `field_animation` | Effect (picker), or Animation and Seconds Per Frame; Target (kind, id or x/y) | | `title_card`, `narration` | Title, Text (multiline), Seconds | | `transition` | Style (`fade`, `wipe`, `none`), Direction (`in`, `out`), Seconds | | `clear_presentation` | none | | `cinematic_music` | Track, Loop, Fade In, Fade Out, On Completion (`continue`, `stop`, `restore_previous`) | | `move_route` | Subject (`player`, `npc`, `staged_actor`), NPC Id or Staged Actor, Seconds Per Step or Speed, Wait, Steps | +A `field_animation` plays either an effect timeline from `assets/Animations`, +chosen by its stable id, or a legacy animation from the Animations database. +The effect owns its frame rate, so choosing an Effect removes the Animation +and Seconds Per Frame rows and their values; clearing the Effect brings them +back. Validation plays the effect through the Engine for both presentations +and reports one that is missing, cannot be read, or loops: the command waits +for its effect to end, and loops belong to a map's own field effects. + Every other type from the Common Events table is available too, with the same rows. `Shift+O` adds: on a route, lane or point row, another step, lane or point; on a command that has none of those yet, its first; otherwise the @@ -1626,7 +1634,9 @@ where a field is unset (a wait of 0.5s, a route step of 0.15s, a narration of 2.5s, a transition of 0.24s). A parallel block is as long as its longest lane; a branch or choice as its longest arm. Dialogue, choices, battles and common events cannot be timed from the asset and are marked `+input`, -`+battle` and `+?` instead of guessed. `Up` / `Down` move over the rows and +`+battle` and `+?` instead of guessed. A field effect is as long as its +timeline, the longer of its two presentations, and is marked `+?` when its +timeline cannot be read. `Up` / `Down` move over the rows and `Enter` opens the row's command in the tree; a running preview marks the rows it is on. diff --git a/src/Cutscenes/CutsceneLaneOverview.php b/src/Cutscenes/CutsceneLaneOverview.php index cc3f5e2..1d472f9 100644 --- a/src/Cutscenes/CutsceneLaneOverview.php +++ b/src/Cutscenes/CutsceneLaneOverview.php @@ -4,6 +4,10 @@ namespace Ichiloto\Editor\Cutscenes; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; +use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; +use Throwable; + /** * A duration projection over a cinematic's command tree: how long each * command, lane and block is authored to take, and where the play waits on @@ -33,12 +37,13 @@ private function __construct(public readonly array $rows, public readonly float * Builds the overview of a command list. * * @param array $commands + * @param EffectTimelineLibrary|null $effects The project's effect timelines, so a field effect's length is known. */ - public static function of(array $commands, string $rootKey = 'commands'): self + public static function of(array $commands, string $rootKey = 'commands', ?EffectTimelineLibrary $effects = null): self { /** @var array $rows */ $rows = []; - [$seconds, $marks] = self::walkList($commands, $rootKey, 0, $rows); + [$seconds, $marks] = self::walkList($commands, $rootKey, 0, $rows, $effects); return new self($rows, $seconds, $marks); } @@ -72,7 +77,7 @@ public static function describe(float $seconds, array $marks): string * @param array> $rows * @return array{0: float, 1: string[]} */ - private static function walkList(array $commands, string $prefix, int $depth, array &$rows): array + private static function walkList(array $commands, string $prefix, int $depth, array &$rows, ?EffectTimelineLibrary $effects): array { $total = 0.0; $marks = []; @@ -83,7 +88,7 @@ private static function walkList(array $commands, string $prefix, int $depth, ar } $key = $prefix . '.' . $index; - [$seconds, $commandMarks] = self::walkCommand($command, $key, $depth, $rows); + [$seconds, $commandMarks] = self::walkCommand($command, $key, $depth, $rows, $effects); $total += $seconds; $marks = [...$marks, ...$commandMarks]; } @@ -96,7 +101,7 @@ private static function walkList(array $commands, string $prefix, int $depth, ar * @param array> $rows * @return array{0: float, 1: string[]} */ - private static function walkCommand(array $command, string $key, int $depth, array &$rows): array + private static function walkCommand(array $command, string $key, int $depth, array &$rows, ?EffectTimelineLibrary $effects): array { $type = strval($command['type'] ?? '?'); @@ -115,7 +120,7 @@ private static function walkCommand(array $command, string $key, int $depth, arr $laneRowIndex = count($rows); $laneKey = $key . '.lanes.' . $laneIndex; $rows[] = ['depth' => $depth + 1, 'key' => $laneKey, 'label' => 'lane ' . $laneId, 'seconds' => 0.0, 'marks' => [], 'kind' => 'lane']; - [$laneSeconds, $laneMarks] = self::walkList($laneCommands, $laneKey, $depth + 2, $rows); + [$laneSeconds, $laneMarks] = self::walkList($laneCommands, $laneKey, $depth + 2, $rows, $effects); $rows[$laneRowIndex]['seconds'] = $laneSeconds; $rows[$laneRowIndex]['marks'] = $laneMarks; $longest = max($longest, $laneSeconds); @@ -130,7 +135,7 @@ private static function walkCommand(array $command, string $key, int $depth, arr case 'sequence': $rowIndex = count($rows); $rows[] = ['depth' => $depth, 'key' => $key, 'label' => 'sequence', 'seconds' => 0.0, 'marks' => [], 'kind' => 'block']; - [$seconds, $marks] = self::walkList(is_array($command['commands'] ?? null) ? $command['commands'] : [], $key . '.commands', $depth + 1, $rows); + [$seconds, $marks] = self::walkList(is_array($command['commands'] ?? null) ? $command['commands'] : [], $key . '.commands', $depth + 1, $rows, $effects); $rows[$rowIndex]['seconds'] = $seconds; $rows[$rowIndex]['marks'] = $marks; @@ -149,7 +154,7 @@ private static function walkCommand(array $command, string $key, int $depth, arr $armRowIndex = count($rows); $rows[] = ['depth' => $depth + 1, 'key' => $key . '.' . $arm, 'label' => $arm, 'seconds' => 0.0, 'marks' => [], 'kind' => 'arm']; - [$armSeconds, $armMarks] = self::walkList($command[$arm], $key . '.' . $arm, $depth + 2, $rows); + [$armSeconds, $armMarks] = self::walkList($command[$arm], $key . '.' . $arm, $depth + 2, $rows, $effects); $rows[$armRowIndex]['seconds'] = $armSeconds; $rows[$armRowIndex]['marks'] = $armMarks; $longest = max($longest, $armSeconds); @@ -175,7 +180,7 @@ private static function walkCommand(array $command, string $key, int $depth, arr $optionKey = $key . '.options.' . $optionIndex; $optionRowIndex = count($rows); $rows[] = ['depth' => $depth + 1, 'key' => $optionKey, 'label' => sprintf('option %d', $optionIndex + 1), 'seconds' => 0.0, 'marks' => [], 'kind' => 'arm']; - [$optionSeconds, $optionMarks] = self::walkList(is_array($option['then'] ?? null) ? $option['then'] : [], $optionKey, $depth + 2, $rows); + [$optionSeconds, $optionMarks] = self::walkList(is_array($option['then'] ?? null) ? $option['then'] : [], $optionKey, $depth + 2, $rows, $effects); $rows[$optionRowIndex]['seconds'] = $optionSeconds; $rows[$optionRowIndex]['marks'] = $optionMarks; $longest = max($longest, $optionSeconds); @@ -185,7 +190,7 @@ private static function walkCommand(array $command, string $key, int $depth, arr if (is_array($command['cancel'] ?? null)) { $cancelRowIndex = count($rows); $rows[] = ['depth' => $depth + 1, 'key' => $key . '.cancel', 'label' => 'cancel', 'seconds' => 0.0, 'marks' => [], 'kind' => 'arm']; - [$cancelSeconds, $cancelMarks] = self::walkList($command['cancel'], $key . '.cancel', $depth + 2, $rows); + [$cancelSeconds, $cancelMarks] = self::walkList($command['cancel'], $key . '.cancel', $depth + 2, $rows, $effects); $rows[$cancelRowIndex]['seconds'] = $cancelSeconds; $rows[$cancelRowIndex]['marks'] = $cancelMarks; $longest = max($longest, $cancelSeconds); @@ -198,7 +203,7 @@ private static function walkCommand(array $command, string $key, int $depth, arr return [$longest, $rows[$rowIndex]['marks']]; } - [$seconds, $marks] = self::estimate($command); + [$seconds, $marks] = self::estimate($command, $effects); $rows[] = ['depth' => $depth, 'key' => $key, 'label' => CutsceneOutline::summarize($command), 'seconds' => $seconds, 'marks' => $marks, 'kind' => 'command']; return [$seconds, $marks]; @@ -210,7 +215,7 @@ private static function walkCommand(array $command, string $key, int $depth, arr * @param array $command * @return array{0: float, 1: string[]} */ - public static function estimate(array $command): array + public static function estimate(array $command, ?EffectTimelineLibrary $effects = null): array { $type = strval($command['type'] ?? ''); $seconds = static fn(string $key, float $default): float => max(0.0, is_numeric($command[$key] ?? null) ? floatval($command[$key]) : $default); @@ -224,7 +229,9 @@ public static function estimate(array $command): array 'common_event' => [0.0, [self::UNKNOWN]], 'move_route' => [self::routeSeconds($command), []], 'camera' => [self::cameraSeconds($command), []], - 'field_animation' => [self::animationSeconds($command), []], + 'field_animation' => array_key_exists('effect', $command) + ? self::effectSeconds($command['effect'], $effects) + : [self::animationSeconds($command), []], 'cinematic_music' => [max($seconds('fadeIn', 0.0), 0.0), []], default => [0.0, []], }; @@ -280,6 +287,34 @@ private static function cameraSeconds(array $command): float /** * @param array $command */ + /** + * The length of a field effect as its timeline is authored: the longer + * of its terminal and graphical sequences, since the play waits for the + * presentation in use. Without the timeline it is marked, not guessed. + * + * @return array{0: float, 1: string[]} + */ + private static function effectSeconds(mixed $effect, ?EffectTimelineLibrary $effects): array + { + if (! is_string($effect) || $effects === null) { + return [0.0, [self::UNKNOWN]]; + } + + $seconds = 0.0; + + foreach (EffectPresentation::cases() as $presentation) { + try { + $timeline = $effects->load($effect, false, $presentation); + } catch (Throwable) { + return [0.0, [self::UNKNOWN]]; + } + + $seconds = max($seconds, intval($timeline->defaults['lengthFrames'] ?? 0) / max(1, $timeline->fps)); + } + + return [$seconds, []]; + } + private static function animationSeconds(array $command): float { $frames = is_array($command['frames'] ?? null) ? count($command['frames']) : 0; diff --git a/src/Cutscenes/CutsceneOutline.php b/src/Cutscenes/CutsceneOutline.php index e82795a..b35b7c4 100644 --- a/src/Cutscenes/CutsceneOutline.php +++ b/src/Cutscenes/CutsceneOutline.php @@ -441,7 +441,7 @@ public static function summarize(array $command): string 'camera' => self::cameraSummary($command), 'stage_actor' => strval((is_array($command['actor'] ?? null) ? $command['actor'] : $command)['id'] ?? '') . self::at(is_array($command['actor'] ?? null) ? $command['actor'] : $command), 'show_actor', 'hide_actor', 'remove_actor' => strval($command['actorId'] ?? $command['id'] ?? ''), - 'field_animation' => strval($command['animation'] ?? $command['id'] ?? '') . self::target($command['target'] ?? null), + 'field_animation' => (is_scalar($command['effect'] ?? null) ? 'effect ' . strval($command['effect']) : strval($command['animation'] ?? $command['id'] ?? '')) . self::target($command['target'] ?? null), 'transition' => strval($command['style'] ?? 'fade') . ' ' . strval($command['direction'] ?? 'out') . self::seconds($command, 'seconds'), 'cinematic_music' => strval($command['track'] ?? $command['music'] ?? '') . (($command['loop'] ?? false) ? ' (loop)' : ''), default => '', diff --git a/src/Cutscenes/Editing/CutscenePreviewPane.php b/src/Cutscenes/Editing/CutscenePreviewPane.php index 647048b..9aa034b 100644 --- a/src/Cutscenes/Editing/CutscenePreviewPane.php +++ b/src/Cutscenes/Editing/CutscenePreviewPane.php @@ -17,6 +17,7 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Status\StatusLevel; use Ichiloto\Editor\UI\CutscenesScreen; +use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Throwable; /** @@ -46,6 +47,8 @@ trait CutscenePreviewPane private int $cutscenePreviewScroll = 0; /** The row under the cursor in the duration overview. */ private int $cutsceneOverviewCursor = 0; + /** The open project's effect timelines, read once, so the overview can time field effects. */ + private ?EffectTimelineLibrary $cutsceneEffectLibrary = null; /** The asset payload the running preview was built from. */ private string $cinematicPreviewFingerprint = ''; @@ -1202,6 +1205,24 @@ private function cutsceneComparisonLines(int $contentWidth): array return $lines; } + /** + * Returns the open project's effect timelines, or null with no project. + */ + private function getEffectLibrary(): ?EffectTimelineLibrary + { + if (! $this->workspace instanceof ProjectWorkspace) { + return null; + } + + $assetRoot = $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; + + if ($this->cutsceneEffectLibrary?->assetRoot !== $assetRoot) { + $this->cutsceneEffectLibrary = new EffectTimelineLibrary($assetRoot); + } + + return $this->cutsceneEffectLibrary; + } + /** * The overview rows of the selected cinematic: commands, then finalizer. * @@ -1220,8 +1241,8 @@ private function cutsceneOverviewRows(): array $finalizer = is_array($payload['finalizer'] ?? null) ? $payload['finalizer'] : []; return [ - ...CutsceneLaneOverview::of($commands)->rows, - ...CutsceneLaneOverview::of($finalizer, 'finalizer')->rows, + ...CutsceneLaneOverview::of($commands, effects: $this->getEffectLibrary())->rows, + ...CutsceneLaneOverview::of($finalizer, 'finalizer', $this->getEffectLibrary())->rows, ]; } @@ -1236,9 +1257,9 @@ private function cutsceneOverviewLines(CutsceneAsset $asset, int $contentWidth): { $payload = $asset->payload(); $commands = is_array($payload[CutsceneAsset::COMMANDS_KEY] ?? null) ? $payload[CutsceneAsset::COMMANDS_KEY] : []; - $overview = CutsceneLaneOverview::of($commands); + $overview = CutsceneLaneOverview::of($commands, effects: $this->getEffectLibrary()); $finalizer = is_array($payload['finalizer'] ?? null) ? $payload['finalizer'] : []; - $finalizerOverview = CutsceneLaneOverview::of($finalizer, 'finalizer'); + $finalizerOverview = CutsceneLaneOverview::of($finalizer, 'finalizer', $this->getEffectLibrary()); $preview = $this->cinematicPreview; $activeKeys = $preview !== null && ! $preview->isFinished() ? $preview->activeKeys() : []; $lines = [sprintf( diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index 8d343fb..5fc709f 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -246,6 +246,7 @@ public static function cinematicCommandList(string $key): RecordSubList 'sequence' => ['commands' => 'Sequence'], 'choice' => ['cancel' => 'Cancel'], ], + exclusiveFields: [['effect', 'animation'], ['effect', 'id'], ['effect', 'secondsPerFrame']], ); } @@ -347,11 +348,20 @@ public static function cinematicCommandVariants(): array 'show_actor' => [new RecordField('actorId', 'Actor', reference: 'cinematic_cast')], 'hide_actor' => [new RecordField('actorId', 'Actor', reference: 'cinematic_cast')], 'remove_actor' => [new RecordField('actorId', 'Actor', reference: 'cinematic_cast')], - 'field_animation' => [ - RecordField::reference('animation', 'Animation', 'animations'), - ...$subject('target', 'Target', true), - new RecordField('secondsPerFrame', 'Seconds Per Frame', InputControlType::FLOAT, removeWhenEmpty: true, displayDefault: '0.12'), - ], + // An effect timeline owns its frame rate, so a command names + // either an effect or a legacy animation and its pace, never + // both; choosing one removes the other (see exclusiveFields). + 'field_animation' => static fn(array $entry): array => array_key_exists('effect', $entry) + ? [ + new RecordField('effect', 'Effect', reference: 'effects', removeWhenEmpty: true, allowsNone: true, displayDefault: '(legacy animation)'), + ...$subject('target', 'Target', true), + ] + : [ + new RecordField('effect', 'Effect', reference: 'effects', removeWhenEmpty: true, allowsNone: true, displayDefault: '(legacy animation)'), + RecordField::reference('animation', 'Animation', 'animations'), + ...$subject('target', 'Target', true), + new RecordField('secondsPerFrame', 'Seconds Per Frame', InputControlType::FLOAT, removeWhenEmpty: true, displayDefault: '0.12'), + ], 'title_card' => [ new RecordField('title', 'Title', removeWhenEmpty: true), new RecordField('text', 'Text', InputControlType::MULTILINE, removeWhenEmpty: true), diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 7f77f3d..957c191 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -12,6 +12,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Rendering\Tilesets\Tileset; /** @@ -70,6 +71,7 @@ final class ReferenceCatalog 'summon_cues', 'event_markers', 'tilesets', + 'effects', ]; /** @@ -188,6 +190,8 @@ public function valuesFor(string $category): array 'event_markers' => $this->currentMap?->getEventMarkers() ?? [], // A map's kind is one of the project's tilesets, by file stem. 'tilesets' => array_keys($this->loadTilesetNames()), + // Effect timelines are folders the Engine lists by stable id. + 'effects' => new EffectTimelineLibrary($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets')->findTimelineIds(), default => $this->recordValues($category), }; } diff --git a/src/Validation/CutsceneValidation.php b/src/Validation/CutsceneValidation.php index 7585748..e8939db 100644 --- a/src/Validation/CutsceneValidation.php +++ b/src/Validation/CutsceneValidation.php @@ -217,7 +217,7 @@ protected function walkCinematicCommands( 'transfer' => ['maps', 'map', 'map'], 'start_battle' => ['troops', 'troop', 'troop'], 'common_event' => ['common_events', 'id', 'common event'], - 'show_animation', 'field_animation' => ['animations', 'animation', 'animation'], + 'show_animation' => ['animations', 'animation', 'animation'], default => null, }; @@ -226,6 +226,10 @@ protected function walkCinematicCommands( $issues = [...$issues, ...$this->checkReference(strval($command[$key] ?? ''), $category, $noun, $where, $known)]; } + if ($type === 'field_animation') { + $issues = [...$issues, ...$this->checkFieldAnimationReference($command, $where, $known)]; + } + if ($type === 'checkpoint') { $name = trim(strval($command['name'] ?? '')); @@ -457,7 +461,7 @@ protected function cutsceneKnownReferences(ProjectWorkspace $workspace): array $catalog = new \Ichiloto\Editor\Database\ReferenceCatalog($workspace); $known = []; - foreach (['quests', 'maps', 'troops', 'inventory', 'bgm', 'sfx', 'common_events', 'animations', 'cinematics', 'summons'] as $category) { + foreach (['quests', 'maps', 'troops', 'inventory', 'bgm', 'sfx', 'common_events', 'animations', 'effects', 'cinematics', 'summons'] as $category) { try { $known[$category] = $catalog->valuesFor($category); } catch (Throwable) { diff --git a/src/Validation/EffectValidator.php b/src/Validation/EffectValidator.php index 9d4f238..9adbf6e 100644 --- a/src/Validation/EffectValidator.php +++ b/src/Validation/EffectValidator.php @@ -108,4 +108,38 @@ public function validate(ProjectWorkspace $workspace): array return $issues; } + + /** + * Checks an effect a `field_animation` command plays and waits for, as + * the Engine plays it on the field for each presentation: it must load, + * and it must play once, since loops belong to a map's own effects. + * + * @param EffectTimelineLibrary $library The project's timelines. + * @param string $effect The effect's stable id. + * @param string $where Where the command is. + * @return Issue[] + */ + public static function checkOneShotFieldEffect(EffectTimelineLibrary $library, string $effect, string $where): array + { + $issues = []; + + foreach (EffectPresentation::cases() as $presentation) { + try { + $timeline = $library->load($effect, false, $presentation); + } catch (Throwable $failure) { + $issues[] = Issue::error($where, sprintf('Its effect %s cannot be played on the field for the %s presentation: %s', + $effect, $presentation->value, $failure->getMessage()), + sprintf('Fix assets/Animations/%s. The runtime stops the script at this command.', $effect)); + continue; + } + + if ($timeline->defaults['playback']['loop'] ?? false) { + $issues[] = Issue::error($where, sprintf('Its effect %s loops in the %s presentation, and a field_animation waits for its effect to end.', + $effect, $presentation->value), + 'Play a looping effect from the map\'s field effects instead, or give this timeline once playback.'); + } + } + + return $issues; + } } diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 28ca1aa..e48250c 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -22,7 +22,9 @@ use Ichiloto\Editor\ProjectQuest; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Core\WorldConditionType; +use Ichiloto\Engine\Cutscenes\Cinematics\CinematicScriptValidator; use Ichiloto\Engine\Field\SkitSpeaker; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; use Ichiloto\Engine\Events\Interpreter\MovementRouteRunner; @@ -76,6 +78,9 @@ class ProjectValidator */ protected ?string $projectRootForKnowledge = null; + /** The project's effect timelines, read once per validation. */ + protected ?EffectTimelineLibrary $effectLibrary = null; + protected const string TRANSFER_TRIGGER = 'TransferPlayerTrigger'; protected const string SCRIPT_TRIGGER = 'ScriptEventTrigger'; @@ -88,6 +93,7 @@ class ProjectValidator public function validate(ProjectWorkspace $workspace): array { $this->projectRootForKnowledge = $workspace->projectRoot; + $this->effectLibrary = new EffectTimelineLibrary($workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); $this->knowledgeCatalogData = null; $this->commonEventScripts = $this->eventScriptsById($workspace); $this->cinematicCommonEventIds = []; @@ -3088,6 +3094,8 @@ protected function checkReferences(ProjectWorkspace $workspace): array 'sfx' => $catalog->valuesFor('sfx'), 'common_events' => $catalog->valuesFor('common_events'), 'cinematics' => $catalog->valuesFor('cinematics'), + 'animations' => $catalog->valuesFor('animations'), + 'effects' => $catalog->valuesFor('effects'), ]; return [ @@ -3322,6 +3330,17 @@ protected function walkEventCommands(array $commands, string $where, array $know ]; } + if ($type === 'field_animation') { + // An event script runs the same command a cinematic does, and the + // runtime holds it to the same contract. + try { + CinematicScriptValidator::validateFieldAnimation($command, $where, 'command'); + $issues = [...$issues, ...$this->checkFieldAnimationReference($command, $where, $known)]; + } catch (InvalidArgumentException $exception) { + $issues[] = Issue::error($where, $exception->getMessage(), 'The runtime stops the script at this command.'); + } + } + if ($type === 'common_event') { $eventId = trim(strval($command['id'] ?? '')); @@ -4309,6 +4328,30 @@ protected function checkConditions( * @param array $known What the project defines. * @return Issue[] The issue, if there is one. */ + /** + * Checks what a `field_animation` command plays: a legacy animation by + * name, or an effect timeline that exists and plays once on the field. + * + * @param array $command The command. + * @param array $known What the project defines. + * @return Issue[] The issues found. + */ + protected function checkFieldAnimationReference(array $command, string $where, array $known): array + { + if (! array_key_exists('effect', $command)) { + return $this->checkReference(strval($command['animation'] ?? ''), 'animations', 'animation', $where, $known); + } + + $effect = is_string($command['effect']) ? $command['effect'] : ''; + $issues = $this->checkReference($effect, 'effects', 'effect', $where, $known); + + if ($issues !== [] || $effect === '') { + return $issues; + } + + return $this->effectLibrary === null ? [] : EffectValidator::checkOneShotFieldEffect($this->effectLibrary, $effect, $where); + } + protected function checkReference(string $value, string $category, string $noun, string $where, array $known): array { $value = trim($value); diff --git a/tests/Unit/Cutscenes/FieldEffectReferenceTest.php b/tests/Unit/Cutscenes/FieldEffectReferenceTest.php new file mode 100644 index 0000000..e75868e --- /dev/null +++ b/tests/Unit/Cutscenes/FieldEffectReferenceTest.php @@ -0,0 +1,146 @@ + 10, + 'lengthFrames' => $lengthFrames, + 'playback' => $playback, + 'tracks' => [['id' => 'glyph', 'type' => 'glyph', 'keyframes' => [['frame' => 0, 'duration' => $lengthFrames, 'content' => '*']]]], + ], true) . ';'); +} + +/** Replaces the harbour cinematic's title card with the given command. */ +function writeHarbourCommand(string $root, array $command): void +{ + $script = str_replace( + "['type' => 'title_card', 'title' => 'HARBOUR LANTERNS', 'seconds' => 0.2],", + var_export($command, true) . ',', + harbourCinematicScript(), + ); + file_put_contents($root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php', $script); +} + +/** Writes a Common Event script. */ +function writeFieldEffectScript(string $root, string $id, array $commands): void +{ + file_put_contents($root . '/assets/Events/' . $id . '.php', " */ +function fieldEffectIssues(string $root, string $where): array +{ + return array_values(array_map( + static fn(Issue $issue): string => $issue->message, + array_filter( + new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), + static fn(Issue $issue): bool => str_contains($issue->where, $where), + ), + )); +} + +it('offers the project\'s effect timelines by stable id', function () { + $root = cutsceneProject(); + writeFieldEffectTimeline($root, 'lantern-glow'); + writeFieldEffectTimeline($root, 'ember'); + + expect(new ReferenceCatalog(ProjectWorkspace::fromProject($root))->valuesFor('effects'))->toBe(['ember', 'lantern-glow']); +}); + +it('edits a field effect in place of a legacy animation and its pace, and back', function () { + $root = cutsceneProject(); + writeFieldEffectTimeline($root, 'lantern-glow'); + writeHarbourCommand($root, [ + 'type' => 'field_animation', + 'animation' => 'Slash', + 'secondsPerFrame' => 0.05, + 'target' => ['kind' => 'staged_actor', 'id' => 'boat-east'], + 'authoring' => ['note' => 'kept'], + ]); + $editor = cutscenesEditor($root); + openCutsceneFrame($editor, 'commandListCommands'); + + expect(cutsceneFieldIds($editor))->toContain('command4Effect', 'command4Animation', 'command4SecondsPerFrame'); + + setCutsceneField($editor, 'command4Effect', 'lantern-glow'); + $command = libraryOf($editor)->find(CutsceneType::CINEMATIC, 'harbour-lanterns')?->commands()[4] ?? []; + + expect($command)->toBe([ + 'type' => 'field_animation', + 'target' => ['kind' => 'staged_actor', 'id' => 'boat-east'], + 'authoring' => ['note' => 'kept'], + 'effect' => 'lantern-glow', + ]) + ->and(cutsceneFieldIds($editor))->toContain('command4Effect') + ->and(cutsceneFieldIds($editor))->not->toContain('command4Animation') + ->and(cutsceneFieldIds($editor))->not->toContain('command4SecondsPerFrame'); + + // Clearing the effect returns the command to a legacy animation. + setCutsceneField($editor, 'command4Effect', ''); + setCutsceneField($editor, 'command4Animation', 'Slash'); + + expect(libraryOf($editor)->find(CutsceneType::CINEMATIC, 'harbour-lanterns')?->commands()[4] ?? [])->toBe([ + 'type' => 'field_animation', + 'target' => ['kind' => 'staged_actor', 'id' => 'boat-east'], + 'authoring' => ['note' => 'kept'], + 'animation' => 'Slash', + ]); +}); + +it('reports a field effect that is missing, loops, or is mixed with a legacy pace', function () { + $root = cutsceneProject(); + writeFieldEffectTimeline($root, 'tide', 'loop'); + writeHarbourCommand($root, ['type' => 'parallel', 'lanes' => [ + ['id' => 'missing', 'commands' => [['type' => 'field_animation', 'effect' => 'nowhere', 'target' => ['kind' => 'staged_actor', 'id' => 'boat-east']]]], + ['id' => 'loops', 'commands' => [['type' => 'field_animation', 'effect' => 'tide', 'target' => ['kind' => 'staged_actor', 'id' => 'boat-west']]]], + ]]); + writeFieldEffectScript($root, 'mixed-pace', [ + ['type' => 'field_animation', 'effect' => 'tide', 'secondsPerFrame' => 0.1, 'target' => ['kind' => 'player']], + ]); + + $cinematic = fieldEffectIssues($root, 'cinematic harbour-lanterns'); + + expect($cinematic)->toContain( + 'It names the effect "nowhere", which does not exist.', + 'Its effect tide loops in the terminal presentation, and a field_animation waits for its effect to end.', + ) + ->and(implode("\n", fieldEffectIssues($root, 'mixed-pace')))->toContain('Field effect requires one stable timeline identity and uses its authored frame rate'); +}); + +it('accepts a once-played field effect in cinematics and event scripts', function () { + $root = cutsceneProject(); + writeFieldEffectTimeline($root, 'lantern-glow'); + writeHarbourCommand($root, ['type' => 'field_animation', 'effect' => 'lantern-glow', 'target' => ['kind' => 'staged_actor', 'id' => 'boat-east']]); + writeFieldEffectScript($root, 'glow', [['type' => 'field_animation', 'effect' => 'lantern-glow', 'target' => ['kind' => 'player']]]); + + expect(fieldEffectIssues($root, 'harbour-lanterns'))->toBe([]) + ->and(fieldEffectIssues($root, 'glow'))->toBe([]); +}); + +it('times a field effect by its timeline and marks one it cannot read', function () { + $root = cutsceneProject(); + writeFieldEffectTimeline($root, 'lantern-glow', lengthFrames: 15); + $effects = new EffectTimelineLibrary($root . '/assets'); + $glow = ['type' => 'field_animation', 'effect' => 'lantern-glow', 'target' => ['kind' => 'player']]; + $missing = ['type' => 'field_animation', 'effect' => 'nowhere', 'target' => ['kind' => 'player']]; + + expect(CutsceneLaneOverview::estimate($glow, $effects))->toBe([1.5, []]) + ->and(CutsceneLaneOverview::estimate($missing, $effects))->toBe([0.0, [CutsceneLaneOverview::UNKNOWN]]) + ->and(CutsceneLaneOverview::estimate($glow))->toBe([0.0, [CutsceneLaneOverview::UNKNOWN]]); +}); From 63f055e915815852e847a2064bef86e5ccf8a1fc Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 01:24:14 +0200 Subject: [PATCH 102/344] docs(roadmap): record effect timeline authoring status and the open gap --- docs/roadmap.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index 87b1ff3..520bed8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -171,6 +171,20 @@ Authoring requirements: behaviour; it gains no graphical workflow. Rich graphical sequence authoring (previews, image track timing) belongs to the GUI editor. +Status, 2026-10: validation and references are in place; standalone authoring +is not. Validation compiles every effect a project uses (battle animations, +field presentation, map field effects, tileset pieces, and the effect a +`field_animation` names) through the Engine for both presentations, and +reports an effect a waiting `field_animation` plays that loops. Cinematic +`field_animation` commands pick an effect by its stable id, from the +timelines the Engine lists. Summon timelines choose each track's +`presentation`. There is still no TUI surface to create, edit or preview a +standalone effect timeline under `assets/Animations`; that remains the gap. +It must reuse the summon editor's timeline editing, the shared Engine +compiler and playhead, and the existing source-preserving paths, add no +second timeline format, and refuse unsupported source before writing. It is +not started: it waits on Andrew's go. + This scope does not replace the map source-integrity work or authorize a new GUI implementation. Keep runtime availability and TUI authoring completion separate until both have been verified. From 9e5ccd933702417e7827f79bc615e6c216555af9 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 02:56:44 +0200 Subject: [PATCH 103/344] feat(maps): author events painted in any shape, as the runtime triggers them The Engine now triggers an event on exactly the cells its marker is painted on, in any shape, connected or not. The Editor follows it. Removed: the validation error "Marker does not occupy one solid rectangle" and the map save warning "Event marker must occupy one solid rectangle". The runtime accepts every shape, so these reported a problem that no longer exists; the Editor warned while the game refused the map, and now both accept it. - ProjectMap::getEventArea() returns the marker's exact cells as the Engine's CellArea; getEventBounds() is derived from it. isEventMarkerSolidRectangle() is removed with the checks that used it. - ProjectMap::moveEventCells() moves every cell of a marker together, refusing a move off the map or onto another marker before writing. - Inspector: a rectangular marker keeps Position and Size. Any other shape shows Cells (how many, in how many places) instead of Size; Position moves the whole shape, undoable, and a refused move says why. - Palette, jump-to-marker and the cinematic preview start use the marker's first cell, where the runtime places it, which is always one of its own cells. Requires Engine afe9aa8 (exact trigger cells). --- docs/manual.md | 9 ++ src/Cutscenes/Editing/CutscenePreviewPane.php | 7 +- src/Editor.php | 103 +++++++++++------- src/ProjectMap.php | 92 ++++++++++------ src/Validation/MapValidator.php | 18 --- src/Validation/ProjectValidator.php | 9 -- tests/Unit/EventMarkerCellsTest.php | 81 ++++++++++++++ tests/Unit/MapValidatorTest.php | 10 +- tests/Unit/ProjectMapTest.php | 39 +++++++ tests/Unit/ProjectValidatorTest.php | 9 +- 10 files changed, 264 insertions(+), 113 deletions(-) create mode 100644 tests/Unit/EventMarkerCellsTest.php diff --git a/docs/manual.md b/docs/manual.md index c3b83e3..ec27e18 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -894,6 +894,15 @@ Select `Script Id` and use the reference picker to choose an existing Common Event; the value is not free-typed. Conditions and completion writes use the same structured inspector-list controls as other event types. +An event triggers on exactly the cells painted with its marker, in any shape: +paint the same marker in two places, such as a town exit on the east edge +and another on the south side, and they are one event. The cells between +placements are not part of it. For a marker that fills a rectangle the +inspector's Position and Size edit that rectangle. For any other shape it +shows `Cells` (how many, in how many places) instead of Size: Position moves +every cell together, keeping the shape, and is refused when a cell would +leave the map or cover another marker; paint or erase cells to reshape it. + ## Database Screen `Ctrl+D` or `F2` opens the Database screen over the main shell; the same key diff --git a/src/Cutscenes/Editing/CutscenePreviewPane.php b/src/Cutscenes/Editing/CutscenePreviewPane.php index 9aa034b..b08c2c7 100644 --- a/src/Cutscenes/Editing/CutscenePreviewPane.php +++ b/src/Cutscenes/Editing/CutscenePreviewPane.php @@ -839,10 +839,11 @@ private function cinematicPreviewOrigin(CutsceneAsset $asset, ?string $startMap) continue; } - $bounds = $map->getEventBounds((string) $marker); + // Where the runtime places the marker: its first cell. + $first = $map->getEventArea((string) $marker)?->firstCell; - if ($bounds !== null) { - return ['mapId' => $map->mapId, 'x' => (int) $bounds['x'], 'y' => (int) $bounds['y'], 'marker' => (string) $marker]; + if ($first !== null) { + return ['mapId' => $map->mapId, 'x' => (int) $first->x, 'y' => (int) $first->y, 'marker' => (string) $marker]; } } } diff --git a/src/Editor.php b/src/Editor.php index d06d694..0603502 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -6197,13 +6197,13 @@ function () use ($tool): void { if ($selectedMap instanceof ProjectMap) { foreach ($selectedMap->getPlacedEventMarkers() as $marker) { - $bounds = $selectedMap->getEventBounds($marker); + $first = $selectedMap->getEventArea($marker)?->firstCell; $items[] = new PaletteItem( sprintf( 'Event: %s on %s%s', $marker, $selectedMap->mapId, - $bounds === null ? '' : sprintf(' (%d, %d)', $bounds['x'], $bounds['y']), + $first === null ? '' : sprintf(' (%d, %d)', $first->x, $first->y), ), '', fn() => $this->jumpToEventMarker($marker), @@ -6254,14 +6254,15 @@ private function jumpToEventMarker(string $marker): void return; } - $bounds = $selectedMap->getEventBounds($marker); + // The marker's first cell is always one of its own cells, however it is painted. + $first = $selectedMap->getEventArea($marker)?->firstCell; $this->setEditingMode(self::MODE_EVENT); $this->setFocusedPane(self::FOCUS_CANVAS, false); - if ($bounds !== null) { - $this->cursorX = $bounds['x']; - $this->cursorY = $bounds['y']; + if ($first !== null) { + $this->cursorX = (int) $first->x; + $this->cursorY = (int) $first->y; } $this->clampCursor(); @@ -9244,21 +9245,38 @@ private function applyInspectorFieldValue(array $field, string $rawValue): void } if ($target === 'event-bounds') { - $bounds = $selectedMap->getEventBounds((string) $field['marker']); + $marker = (string) $field['marker']; + $area = $selectedMap->getEventArea($marker); - if ($bounds === null) { + if ($area === null) { return; } + $bounds = $selectedMap->getEventBounds($marker); $snapshotBefore = $selectedMap->captureGridSnapshot(); - $bounds[(string) $field['field']] = max(0, (int) $value); - $selectedMap->setEventBounds( - (string) $field['marker'], - $bounds['x'], - $bounds['y'], - $bounds['width'], - $bounds['height'], - ); + + if (! $area->isRectangle) { + // A marker painted in any other shape keeps its shape: Position + // moves every cell; its cells are reshaped by painting them. + $axis = (string) $field['field']; + + if (! in_array($axis, ['x', 'y'], true)) { + $this->setStatus(sprintf('Marker %s is painted in its own shape; paint or erase its cells to reshape it.', $marker), StatusLevel::WARN); + return; + } + + $delta = max(0, (int) $value) - $bounds[$axis]; + $refusal = $selectedMap->moveEventCells($marker, $axis === 'x' ? $delta : 0, $axis === 'y' ? $delta : 0); + + if ($refusal !== null) { + $this->setStatus($refusal, StatusLevel::WARN); + return; + } + } else { + $bounds[(string) $field['field']] = max(0, (int) $value); + $selectedMap->setEventBounds($marker, $bounds['x'], $bounds['y'], $bounds['width'], $bounds['height']); + } + $snapshotAfter = $selectedMap->captureGridSnapshot(); $this->recordCommand(new GenericCommand( 'Event bounds edit', @@ -13927,6 +13945,7 @@ private function getInspectorFields(): array $definition = $selectedMap->getEventDefinition($marker); $bounds = $selectedMap->getEventBounds($marker); + $area = $selectedMap->getEventArea($marker); $fields[] = [ 'label' => 'Event', @@ -13968,27 +13987,37 @@ private function getInspectorFields(): array 'marker' => $marker, 'field' => 'y', ]; - $fields[] = [ - 'label' => 'Size', - 'value' => '', - 'editable' => false, - ]; - $fields[] = [ - 'label' => ' X', - 'value' => (string) $bounds['width'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds['width']), - 'target' => 'event-bounds', - 'marker' => $marker, - 'field' => 'width', - ]; - $fields[] = [ - 'label' => ' Y', - 'value' => (string) $bounds['height'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds['height']), - 'target' => 'event-bounds', - 'marker' => $marker, - 'field' => 'height', - ]; + if ($area?->isRectangle ?? true) { + $fields[] = [ + 'label' => 'Size', + 'value' => '', + 'editable' => false, + ]; + $fields[] = [ + 'label' => ' X', + 'value' => (string) $bounds['width'], + 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds['width']), + 'target' => 'event-bounds', + 'marker' => $marker, + 'field' => 'width', + ]; + $fields[] = [ + 'label' => ' Y', + 'value' => (string) $bounds['height'], + 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds['height']), + 'target' => 'event-bounds', + 'marker' => $marker, + 'field' => 'height', + ]; + } else { + // Painted in its own shape: the event triggers on exactly these cells. + $pieces = count($area->findPieces()); + $fields[] = [ + 'label' => 'Cells', + 'value' => sprintf('%d in %d %s', count($area->cells), $pieces, $pieces === 1 ? 'shape' : 'places'), + 'editable' => false, + ]; + } } if ($definition !== null) { diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 42f3bae..6d01e86 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -16,6 +16,7 @@ use Ichiloto\Editor\Storage\FileSetTransactionFailure; use Ichiloto\Editor\Storage\FilesystemFileSetOperations; use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Engine\Core\CellArea; use Ichiloto\Engine\Field\MapGraphics; use Ichiloto\Engine\Field\MapGridSource; use Ichiloto\Engine\Field\MapLayer; @@ -1439,67 +1440,90 @@ public function getEventDefinition(string $marker): ?array } /** - * Returns the event bounds for a marker as x/y/width/height. + * Returns the cells an event marker occupies, as the runtime triggers + * it: exactly the painted cells, in any shape, connected or not. * * @param string $marker The event marker. - * @return array{x: int, y: int, width: int, height: int}|null + * @return CellArea|null The cells, or null when the marker is not placed. */ - public function getEventBounds(string $marker): ?array + public function getEventArea(string $marker): ?CellArea { - $positions = []; + $cells = []; foreach ($this->layers->getEventGrid()->getSymbols() as $rowIndex => $row) { foreach ($row as $columnIndex => $symbol) { if ($symbol === $marker) { - $positions[] = [$columnIndex, $rowIndex]; + $cells[] = [$columnIndex, $rowIndex]; } } } - if ($positions === []) { - return null; - } + return $cells === [] ? null : CellArea::fromCells($cells); + } - $xValues = array_column($positions, 0); - $yValues = array_column($positions, 1); - $minX = min($xValues); - $maxX = max($xValues); - $minY = min($yValues); - $maxY = max($yValues); + /** + * Returns the bounds of an event marker's cells as x/y/width/height. + * + * @param string $marker The event marker. + * @return array{x: int, y: int, width: int, height: int}|null + */ + public function getEventBounds(string $marker): ?array + { + $bounds = $this->getEventArea($marker)?->bounds; - return [ - 'x' => $minX, - 'y' => $minY, - 'width' => ($maxX - $minX) + 1, - 'height' => ($maxY - $minY) + 1, + return $bounds === null ? null : [ + 'x' => $bounds->getX(), + 'y' => $bounds->getY(), + 'width' => $bounds->getWidth(), + 'height' => $bounds->getHeight(), ]; } /** - * Reports whether every cell inside an event marker's bounds contains - * that marker. The runtime represents one marker as one rectangular - * trigger area and rejects sparse, cross-shaped, or disconnected areas. + * Moves every cell of an event marker by an offset, keeping its shape. + * + * Refused, leaving the grid unchanged, when a moved cell would leave the + * event layer or land on another marker's cell. + * + * @param string $marker The event marker. + * @param int $deltaX Columns to move by. + * @param int $deltaY Rows to move by. + * @return string|null Why the move was refused, or null when it was made. */ - public function isEventMarkerSolidRectangle(string $marker): bool + public function moveEventCells(string $marker, int $deltaX, int $deltaY): ?string { - $bounds = $this->getEventBounds($marker); + $this->assertEditable(); + $area = $this->getEventArea($marker); - if ($bounds === null) { - return false; + if ($area === null) { + return sprintf('Marker %s is not placed.', $marker); } - $maxX = $bounds['x'] + $bounds['width']; - $maxY = $bounds['y'] + $bounds['height']; + foreach ($area->cells as [$x, $y]) { + [$toX, $toY] = [$x + $deltaX, $y + $deltaY]; - for ($y = $bounds['y']; $y < $maxY; $y++) { - for ($x = $bounds['x']; $x < $maxX; $x++) { - if ($this->getEventSymbol($x, $y) !== $marker) { - return false; - } + if (! $this->hasLayerCell(MapLayers::EVENT, $toX, $toY)) { + return sprintf('Marker %s would leave the map at (%d, %d).', $marker, $toX, $toY); + } + + $occupant = $this->getEventSymbol($toX, $toY); + + if ($occupant !== $marker && trim($occupant) !== '') { + return sprintf('Marker %s would cover marker %s at (%d, %d).', $marker, $occupant, $toX, $toY); } } - return true; + foreach ($area->cells as [$x, $y]) { + $this->layers->getEventGrid()->cells[$y][$x]['symbol'] = ' '; + } + + foreach ($area->cells as [$x, $y]) { + $this->layers->getEventGrid()->cells[$y + $deltaY][$x + $deltaX]['symbol'] = $marker; + } + + $this->touchState(); + + return null; } /** diff --git a/src/Validation/MapValidator.php b/src/Validation/MapValidator.php index 1601786..49bb4da 100644 --- a/src/Validation/MapValidator.php +++ b/src/Validation/MapValidator.php @@ -29,7 +29,6 @@ public static function validate(ProjectMap $map, array $mapsById): array { return [ ...self::findMarkersWithoutDefinitions($map), - ...self::findNonRectangularMarkers($map), ...self::findDanglingDestinations($map, $mapsById), ...self::findOutOfRangeSpawnPoints($map, $mapsById), ...self::findRetiredData($map), @@ -37,23 +36,6 @@ public static function validate(ProjectMap $map, array $mapsById): array ]; } - /** - * Finds marker shapes the runtime cannot turn into one trigger area. - * - * @return string[] - */ - private static function findNonRectangularMarkers(ProjectMap $map): array - { - $warnings = []; - - foreach ($map->getPlacedEventMarkers() as $marker) { - if (! $map->isEventMarkerSolidRectangle($marker)) { - $warnings[] = sprintf('Event marker %s must occupy one solid rectangle.', $marker); - } - } - - return $warnings; - } /** * Finds map data the engine no longer reads. diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index bfbc27f..af97746 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -1697,15 +1697,6 @@ protected function checkEventMarkers(ProjectMap $map): array ); } - foreach ($placed as $marker) { - if (! $map->isEventMarkerSolidRectangle($marker)) { - $issues[] = Issue::error( - $map->mapId, - sprintf('Marker "%s" does not occupy one solid rectangle.', $marker), - 'The engine rejects sparse, cross-shaped, and disconnected event markers when the map loads.' - ); - } - } return $issues; } diff --git a/tests/Unit/EventMarkerCellsTest.php b/tests/Unit/EventMarkerCellsTest.php new file mode 100644 index 0000000..344f9de --- /dev/null +++ b/tests/Unit/EventMarkerCellsTest.php @@ -0,0 +1,81 @@ + 120, 'height' => 40]); + setEditorProperty($editor, 'isRunning', true); + callEditorMethod($editor, 'setEditingMode', 'event'); + setEditorProperty($editor, 'cursorX', 1); + setEditorProperty($editor, 'cursorY', 1); + + return [$editor, getEditorProperty($editor, 'workspace')->maps[0]]; +} + +/** @return array> The event's inspector rows keyed "Section/label". */ +function scatteredMarkerRows(Ichiloto\Editor\Editor $editor): array +{ + $rows = []; + $section = ''; + $fields = callEditorMethod($editor, 'getInspectorFields'); + // The map's own rows come first; the event's begin at its Event row. + $fields = array_slice($fields, (int) array_search('Event', array_column($fields, 'label'), true)); + + foreach ($fields as $field) { + if (! str_starts_with((string) $field['label'], ' ')) { + $section = (string) $field['label']; + } + + $rows[$section . '/' . trim((string) $field['label'])] = $field; + } + + return $rows; +} + +it('shows the cells of a marker painted in separate places instead of a rectangle to resize', function () { + [$editor] = scatteredMarkerEditor(); + $rows = scatteredMarkerRows($editor); + + expect($rows['Cells/Cells']['value'] ?? null)->toBe('2 in 2 places') + ->and($rows)->toHaveKeys(['Position/X', 'Position/Y']) + ->and($rows)->not->toHaveKey('Size/X'); +}); + +it('moves every cell of a scattered marker from the inspector, and undoes it whole', function () { + /** @var ProjectMap $map */ + [$editor, $map] = scatteredMarkerEditor(); + $rows = scatteredMarkerRows($editor); + + callEditorMethod($editor, 'applyInspectorFieldValue', $rows['Position/X'], '2'); + + expect($map->getEventArea('E')?->cells)->toBe([[2, 1], [9, 3]]); + + callEditorMethod($editor, 'performUndo'); + + expect($map->getEventArea('E')?->cells)->toBe([[1, 1], [8, 3]]); +}); + +it('refuses to move a scattered marker onto another marker and leaves it where it was', function () { + /** @var ProjectMap $map */ + [$editor, $map] = scatteredMarkerEditor(); + $map->setEventSymbol(3, 1, 'F'); + $rows = scatteredMarkerRows($editor); + + callEditorMethod($editor, 'applyInspectorFieldValue', $rows['Position/X'], '3'); + + expect($map->getEventArea('E')?->cells)->toBe([[1, 1], [8, 3]]) + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('would cover marker F at (3, 1)'); +}); diff --git a/tests/Unit/MapValidatorTest.php b/tests/Unit/MapValidatorTest.php index 3bf89c1..a4d1777 100644 --- a/tests/Unit/MapValidatorTest.php +++ b/tests/Unit/MapValidatorTest.php @@ -43,19 +43,15 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ->and($warnings[0])->toContain('no definition'); }); -it('warns when a marker does not occupy one solid rectangle', function () { +it('accepts a marker painted in any shape, as the runtime triggers its exact cells', function () { $map = validatorMap('town', [ 'name' => 'Town', 'events' => [ 'E' => ['class' => 'SomeTrigger', 'data' => []], ], - ], [' E ', 'EEE ']); + ], [' E E', 'EE ']); - $warnings = MapValidator::validate($map, ['town' => $map]); - - expect($warnings)->toHaveCount(1) - ->and($warnings[0])->toContain('marker E') - ->and($warnings[0])->toContain('solid rectangle'); + expect(MapValidator::validate($map, ['town' => $map]))->toBe([]); }); it('warns about dangling transfer destinations', function () { diff --git a/tests/Unit/ProjectMapTest.php b/tests/Unit/ProjectMapTest.php index bf93054..c92511f 100644 --- a/tests/Unit/ProjectMapTest.php +++ b/tests/Unit/ProjectMapTest.php @@ -128,6 +128,45 @@ function fixtureMap(): ProjectMap ->and($map->getEventMarkerAt(5, 1))->toBeNull(); }); +/** A four-by-two map whose event layer is the given rows. */ +function eventCellsMap(array $eventLines): ProjectMap +{ + return new ProjectMap( + mapId: 'town', + directory: '/virtual/Maps/town', + dataPath: '/virtual/Maps/town/town.data.php', + mapPath: '/virtual/Maps/town/town.map.php', + eventPath: '/virtual/Maps/town/town.event.php', + data: ['events' => []], + tileLines: ['....', '....'], + eventLines: $eventLines, + ); +} + +it('reads a marker painted in separate places as its exact cells', function () { + $map = eventCellsMap([' D', 'D ']); + + expect($map->getEventArea('D')?->cells)->toBe([[3, 0], [0, 1]]) + ->and($map->getEventArea('D')?->isRectangle)->toBeFalse() + ->and($map->getEventBounds('D'))->toBe(['x' => 0, 'y' => 0, 'width' => 4, 'height' => 2]) + ->and($map->getEventArea('Z'))->toBeNull(); +}); + +it('moves every cell of a marker together and refuses a move off the map or onto another marker', function () { + $map = eventCellsMap(['D D', ' E']); + $before = $map->captureGridSnapshot(); + + expect($map->moveEventCells('D', 0, 1))->toContain('cover marker E at (3, 1)') + ->and($map->moveEventCells('D', 1, 0))->toContain('leave the map at (4, 0)') + ->and($map->captureGridSnapshot())->toBe($before); + + $map = eventCellsMap(['D D ', ' ']); + + expect($map->moveEventCells('D', 1, 1))->toBeNull() + ->and($map->getEventArea('D')?->cells)->toBe([[1, 1], [3, 1]]) + ->and($map->getEventMarkerAt(0, 0))->toBeNull(); +}); + it('never treats metadata as a rename: the loaded path is identity', function () { $map = fixtureMap(); diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index 07b9df1..9bd482a 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -405,7 +405,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string ->and($issues[0]->hint)->toContain('Unmapped event markers'); }); -it('catches a marker that is not one solid rectangle', function () { +it('accepts a marker painted in separate places, as the runtime triggers its exact cells', function () { $root = makeTemporaryProject(); $path = $root . '/assets/Maps/test-map/test-map.event.php'; @@ -417,11 +417,10 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $source )); - $issues = issuesMentioning(validateProject($root), 'does not occupy one solid rectangle'); + $issues = validateProject($root); - expect($issues)->toHaveCount(1) - ->and($issues[0]->severity)->toBe(Severity::ERROR) - ->and($issues[0]->hint)->toContain('cross-shaped'); + expect(issuesMentioning($issues, 'rectangle'))->toBe([]) + ->and(issuesMentioning($issues, 'could not be read'))->toBe([]); }); it('catches an event defined but never placed', function () { From 85bf6b301e52fe0ec00417c44838a8b979e75975 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 03:53:48 +0200 Subject: [PATCH 104/344] docs(roadmap): record battle effect orientation and weapon attack roles --- docs/roadmap.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index 520bed8..5c145af 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -185,6 +185,25 @@ compiler and playhead, and the existing source-preserving paths, add no second timeline format, and refuse unsupported source before writing. It is not started: it waits on Andrew's go. +Battle effect orientation and weapon roles (Engine contract in progress, +2026-10): a battle track may declare `facing` (`east` or `west`), the +direction its stroke is drawn in; playback mirrors it for an attack the other +way, while undirected effects such as magic and rings stay fixed. Image +keyframes may set `flipX` / `flipY` for an authored reverse stroke, such as a +cross slash's second stroke. A basic attack's animation role follows the +attacker's equipped weapon type (`attack-sword`, `attack-staff`, and so on; +`attack-unarmed` with no weapon; neutral `attack` for other battlers), bound +in the animation database's `roles`. Authoring requirements: +- `facing`, `flipX` and `flipY` round-trip source-preserving with every other + timeline field; validation compiles them through the Engine for both + presentations, as it does every effect. The TUI edits them as ordinary + fields when standalone timeline authoring exists; no graphical workflow. +- The animation database offers `roles` from the Engine's supported role + list, refuses a role bound twice before saving, and reports a weapon type + the project's weapons use that no animation is bound to. +- Until then, these fields are kept as written: the animation database edits + only its own fields and keeps `roles` and effect bindings untouched. + This scope does not replace the map source-integrity work or authorize a new GUI implementation. Keep runtime availability and TUI authoring completion separate until both have been verified. From 27703f619f87d9e753a18e9692ec7eff5182a367 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 05:22:49 +0200 Subject: [PATCH 105/344] feat(database): choose which battle actions play an animation by role The Animations database now edits an animation's roles: which battle actions play it when they name no animation of their own. Each role the Engine supports (ActionAnimationResolver::getSupportedRoles) is a row switched on or off; a role held by another animation names its holder, and switching it on is refused until it is switched off there, since the runtime plays a role only when exactly one animation holds it. Changes are undoable; a refused or no-change edit leaves no undo step. ProjectAnimationDatabase owns roles: getRoles, findRoleOwner and setRole, written over the authored entry's roles on save while every other field stays as written. The Engine Animation is now built without roles, so a role the Engine refuses is reported by validation instead of keeping the database from loading. Validation reports a role the Engine does not support (the runtime skips the whole animation), a role held twice (neither plays), and a role the project's battles reach with no animation: the neutral attack, the unarmed attack, and each weapon type its weapons use. Changed: the deprecated animation-name hint no longer applies to basic skills, whose effect follows the attacker's weapon, nor to summons, which play a cutscene. The sample fixture's animation now holds the roles its battles reach. Manual: new Animations section. --- docs/manual.md | 21 +++ src/Editor.php | 72 ++++++++- src/ProjectAnimationDatabase.php | 108 ++++++++++++- .../AnimationReferenceValidator.php | 65 ++++++++ .../sample-project/assets/Data/animations.php | 2 + tests/Unit/AnimationReferenceTest.php | 7 +- tests/Unit/AnimationRolesTest.php | 151 ++++++++++++++++++ 7 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/AnimationRolesTest.php diff --git a/docs/manual.md b/docs/manual.md index ec27e18..9d39c6c 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1100,6 +1100,27 @@ file when you clear them, so a state with no duration lasts until it is cured rather than being written as `0`. `Tick Formula` is the same formula language skill effects use, with `$target` bound to the afflicted battler. +### Animations + +Below an animation's frame and cue rows are its **Roles**: which battle +actions play it when they name no animation of their own. There is one row +per role the Engine supports, switched on or off with `Left` / `Right`: + +- `attack-sword`, `attack-staff`, `attack-flail` and the other weapon types: + a party member's basic attack with that weapon equipped; +- `attack-unarmed`: a party member's basic attack with no weapon; +- `attack`: enemy and other basic attacks; +- `skill` and `restorative`: skills and restorative magic with no animation + of their own. + +A role plays only when exactly one animation holds it, so a role row held by +another animation names it, and switching it on here is refused until it is +switched off there. Changes are undoable and saved with the animation; every +other field of the entry is kept as written. Validation reports a role the +Engine does not support (the runtime then skips the whole animation), a role +held twice, and a role the project's battles reach with no animation: the +neutral attack, the unarmed attack, and each weapon type its weapons use. + ### Troops Encounter groups. Each troop has a `Name`, an optional `Escape Policy`, and a diff --git a/src/Editor.php b/src/Editor.php index 0603502..7a38e51 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -86,6 +86,7 @@ use Ichiloto\Editor\UI\TextFieldEditor; use Ichiloto\Editor\UI\TextFieldKeyResult; use Ichiloto\Editor\Validation\MapValidator; +use Ichiloto\Engine\Animations\ActionAnimationResolver; use Ichiloto\Engine\Animations\AnimationTargetPosition; use Ichiloto\Engine\Entities\Enumerations\ItemScopeNumber; use Ichiloto\Engine\Entities\Enumerations\ItemScopeSide; @@ -10656,9 +10657,48 @@ private function getDatabaseSettingsFields(): array 'control' => new InputControl(InputControlType::INTEGER, (string) ($cue?->flashDurationFrames ?? 0)), 'field' => 'flashDurationFrames', ], + ...$this->getAnimationRoleFields(), ]; } + /** + * Returns the selected animation's role rows: which battle actions play it + * when they name no animation of their own. One row per role the Engine + * supports, switched on or off; a role bound elsewhere names its holder. + * + * @return array> + */ + private function getAnimationRoleFields(): array + { + $database = $this->workspace?->animationDatabase; + + if ($database === null || ! $this->getSelectedAnimation() instanceof Animation) { + return []; + } + + $roles = $database->getRoles($this->databaseSelectedAnimationIndex); + $fields = [[ + 'label' => 'Roles', + 'value' => $roles === [] ? '(none)' : implode(', ', $roles), + 'editable' => false, + 'field' => '', + ]]; + + foreach (ActionAnimationResolver::getSupportedRoles() as $role) { + $owner = $database->findRoleOwner($role); + $holder = $owner === null || $owner === $this->databaseSelectedAnimationIndex + ? '' : sprintf(' (on %s)', $database->getAnimationByIndex($owner)?->name ?? '?'); + $fields[] = [ + 'label' => ' ' . $role . $holder, + 'value' => in_array($role, $roles, true) ? 'Yes' : 'No', + 'options' => ['no', 'yes'], + 'field' => 'role:' . $role, + ]; + } + + return $fields; + } + /** * Returns the editable settings fields for the selected actor. @@ -12937,6 +12977,15 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) return; } + // A role row shows Yes/No and sends yes/no. Choosing what it already + // shows, or a refused change, changes nothing and leaves no undo step. + if (str_starts_with($fieldId, 'role:') + && (strcasecmp($oldRawValue, $rawValue) === 0 + || in_array(substr($fieldId, 5), $this->workspace?->animationDatabase->getRoles($this->databaseSelectedAnimationIndex) ?? [], true) + !== (strtolower(trim($rawValue)) === 'yes'))) { + return; + } + $this->recordCommand(new GenericCommand( sprintf('%s edit', $field['label'] ?? 'Database field'), fn() => $this->applyDatabaseFieldValueAt($identity, $fieldId, $rawValue), @@ -13140,7 +13189,7 @@ private function applyDatabaseFieldValue(string $field, string $rawValue): void 'brushSymbol' => $this->databaseSelectedPaintSymbol = $this->normalizeDatabaseSymbol($rawValue), 'brushColor' => $this->databaseSelectedPaintColor = $this->normalizeDatabaseColor($rawValue), 'frameSound', 'flashColor', 'flashDurationFrames' => $this->applyDatabaseCueFieldValue($field, $rawValue), - default => null, + default => str_starts_with($field, 'role:') ? $this->applyAnimationRoleValue(substr($field, 5), $rawValue) : null, }; $animation = $this->getSelectedAnimation(); @@ -13153,6 +13202,27 @@ private function applyDatabaseFieldValue(string $field, string $rawValue): void $this->centerDatabasePreviewCursor(); } + /** + * Binds or unbinds a role on the selected animation, saying why when the + * database refuses. + * + * @param string $role The role. + * @param string $rawValue `yes` to bind, anything else to unbind. + * @return void + */ + private function applyAnimationRoleValue(string $role, string $rawValue): void + { + $refusal = $this->workspace?->animationDatabase->setRole( + $this->databaseSelectedAnimationIndex, + $role, + strtolower(trim($rawValue)) === 'yes', + ); + + if ($refusal !== null) { + $this->setStatus($refusal, StatusLevel::WARN); + } + } + /** * Applies cue-field edits for the selected frame. * diff --git a/src/ProjectAnimationDatabase.php b/src/ProjectAnimationDatabase.php index 4f2f564..db8dcbd 100644 --- a/src/ProjectAnimationDatabase.php +++ b/src/ProjectAnimationDatabase.php @@ -8,6 +8,7 @@ use Ichiloto\Editor\History\TracksPersistedState; use Ichiloto\Editor\IO\AtomicFile; +use Ichiloto\Engine\Animations\ActionAnimationResolver; use Ichiloto\Engine\Animations\Animation; use Ichiloto\Engine\Animations\AnimationCue; use Ichiloto\Engine\Animations\AnimationTargetPosition; @@ -31,6 +32,13 @@ final class ProjectAnimationDatabase */ private array $authoredEntries = []; + /** + * @var array> Roles changed since loading, keyed by object id. + * The Engine's Animation keeps its roles fixed, so edits live here and are + * written over the authored entry's `roles` on save. + */ + private array $editedRoles = []; + /** The file as read, so its header is kept and an unsafe file is refused. */ private ?PhpDataFile $file = null; @@ -75,7 +83,10 @@ public static function fromProject(string $projectRoot): self } $entries = array_values(array_filter($payload, 'is_array')); - $animations = array_map(static fn(array $entry): Animation => Animation::fromArray($entry), $entries); + // Roles are the database's to read and edit (getRoles, setRole); the + // Engine object is built without them, so a role the Engine refuses is + // reported by validation instead of keeping the database from loading. + $animations = array_map(static fn(array $entry): Animation => Animation::fromArray(array_diff_key($entry, ['roles' => true])), $entries); $database = new self($path, $animations, isDirty: true); $database->file = $file; @@ -142,10 +153,103 @@ private function getPersistedEntries(): array } } + // Roles the editor changed replace the authored ones; untouched + // roles stay as written. + $roles = $this->editedRoles[spl_object_id($animation)] ?? null; + + if ($roles !== null) { + unset($entry['roles']); + + if ($roles !== []) { + $entry['roles'] = $roles; + } + } + return $entry; }, $this->getAnimations()); } + /** + * Returns the roles an animation is bound to: which battle actions play + * it when they name no animation of their own. + * + * @param int $index The animation index. + * @return list + */ + public function getRoles(int $index): array + { + $animation = $this->getAnimationByIndex($index); + + if (! $animation instanceof Animation) { + return []; + } + + $id = spl_object_id($animation); + $authored = $this->authoredEntries[$id]['roles'] ?? []; + + return $this->editedRoles[$id] ?? array_values(array_filter((array) $authored, is_string(...))); + } + + /** + * Returns the index of the animation a role is bound to, if any. + * + * @param string $role The role. + * @return int|null + */ + public function findRoleOwner(string $role): ?int + { + foreach (array_keys($this->getAnimations()) as $index) { + if (in_array($role, $this->getRoles($index), true)) { + return $index; + } + } + + return null; + } + + /** + * Binds or unbinds a role on an animation. + * + * Refused, changing nothing, for a role the Engine does not support or + * one already bound to another animation: the runtime plays a role only + * when exactly one animation holds it. + * + * @param int $index The animation index. + * @param string $role The role. + * @param bool $isBound Whether the animation should hold the role. + * @return string|null Why the change was refused, or null when it was made. + */ + public function setRole(int $index, string $role, bool $isBound): ?string + { + $animation = $this->getAnimationByIndex($index); + + if (! $animation instanceof Animation) { + return 'No animation is selected.'; + } + + if (! in_array($role, ActionAnimationResolver::getSupportedRoles(), true)) { + return sprintf('The Engine has no animation role "%s".', $role); + } + + $roles = $this->getRoles($index); + $owner = $this->findRoleOwner($role); + + if ($isBound && $owner !== null && $owner !== $index) { + return sprintf('Role %s is already bound to %s; unbind it there first.', $role, $this->getAnimationByIndex($owner)?->name ?? 'another animation'); + } + + $next = $isBound + ? array_values(array_unique([...$roles, $role])) + : array_values(array_diff($roles, [$role])); + + if ($next !== $roles) { + $this->editedRoles[spl_object_id($animation)] = $next; + $this->touchState(); + } + + return null; + } + /** * Returns the animation at the given index. * @@ -340,6 +444,8 @@ public function save(): void $this->authoredEntries[spl_object_id($animation)] = $entries[$index]; } + // The written entries now hold every edited role. + $this->editedRoles = []; $this->captureBaseline(); } diff --git a/src/Validation/AnimationReferenceValidator.php b/src/Validation/AnimationReferenceValidator.php index c3106ef..cc53ef9 100644 --- a/src/Validation/AnimationReferenceValidator.php +++ b/src/Validation/AnimationReferenceValidator.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Animations\ActionAnimationResolver; +use Ichiloto\Engine\Entities\Enumerations\WeaponType; use Ichiloto\Engine\Entities\Magic\MagicEffectType; /** Checks optional presentation references without invalidating gameplay. */ @@ -26,6 +27,12 @@ public function validate(ProjectWorkspace $workspace): array continue; } + // A basic skill's effect follows the attacker's weapon role, and a + // summon plays its cutscene; neither is chosen by an animation name. + if ($skill->getType() === 'basic') { + continue; + } + $magicEffectType = $skill->getType() === 'magic' ? MagicEffectType::tryFrom($skill->getEffectType() ?? '') : null; $candidates = ActionAnimationResolver::getSkillCandidateNames($skill->getName(), $magicEffectType); @@ -45,6 +52,64 @@ public function validate(ProjectWorkspace $workspace): array } } + return [...$issues, ...$this->checkRoles($workspace)]; + } + + /** + * Checks animation roles as the runtime reads them: a role it does not + * support makes it skip the whole animation, a role held twice plays + * neither, and a role the project's battles reach with no animation + * bound to it plays no effect. + * + * @return Issue[] + */ + private function checkRoles(ProjectWorkspace $workspace): array + { + $where = 'assets/Data/animations.php'; + $supported = ActionAnimationResolver::getSupportedRoles(); + $database = $workspace->animationDatabase; + $holders = []; + $issues = []; + + foreach ($database->getAnimations() as $index => $animation) { + foreach ($database->getRoles($index) as $role) { + if (! in_array($role, $supported, true)) { + $issues[] = Issue::error("{$where}: {$animation->name}", sprintf('Its role "%s" is not one the Engine supports, so the whole animation is skipped.', $role), + 'Choose its roles in the Roles rows, which offer only supported roles.'); + continue; + } + $holders[$role][] = $animation->name; + } + } + + foreach ($holders as $role => $names) { + if (count($names) > 1) { + $issues[] = Issue::error($where, sprintf('Role %s is bound to %s, so neither plays for it.', $role, implode(' and ', $names)), + 'Keep the role on one animation.'); + } + } + + // Every battle reaches the neutral attack (enemies) and the unarmed one + // (a party member with no weapon), and each weapon type the project's + // weapons use. + $reached = ['attack' => 'enemy and unclassified attacks', 'attack-unarmed' => 'attacks with no weapon equipped']; + + foreach ($workspace->getRecordDatabase('weapons')?->getRecords() ?? [] as $weapon) { + $type = $weapon->get('equipmentType'); + $type = $type instanceof WeaponType ? $type->value : (is_string($type) ? $type : null); + + if ($type !== null && $type !== '') { + $reached['attack-' . strtolower($type)] ??= sprintf('attacks with a %s such as %s', strtolower($type), strval($weapon->get('name'))); + } + } + + foreach ($reached as $role => $reason) { + if (! isset($holders[$role])) { + $issues[] = Issue::warning($where, sprintf('No animation holds role %s, so %s play no effect.', $role, $reason), + 'Switch the role on for the animation that should play.'); + } + } + return $issues; } diff --git a/tests/Fixtures/sample-project/assets/Data/animations.php b/tests/Fixtures/sample-project/assets/Data/animations.php index 61c3d38..3d35c90 100644 --- a/tests/Fixtures/sample-project/assets/Data/animations.php +++ b/tests/Fixtures/sample-project/assets/Data/animations.php @@ -6,6 +6,8 @@ 'name' => 'Slash', 'position' => 'center', 'maxFrames' => 2, + // Every attack the fixture reaches: enemies, the unarmed, and its sword. + 'roles' => ['attack', 'attack-unarmed', 'attack-sword'], 'frames' => [ [ 'index' => 1, diff --git a/tests/Unit/AnimationReferenceTest.php b/tests/Unit/AnimationReferenceTest.php index d58c6c3..42256cb 100644 --- a/tests/Unit/AnimationReferenceTest.php +++ b/tests/Unit/AnimationReferenceTest.php @@ -84,7 +84,12 @@ ProjectSkill::fromSkill(new MagicSkill('Flare', '', '', 0, 0, effectType: MagicEffectType::DESTRUCTIVE), 2), ], true); $database->save(); - $issues = new AnimationReferenceValidator()->validate(ProjectWorkspace::fromProject($root)); + // Only the skills' diagnostics; this project binds no roles, which is + // reported separately. + $issues = array_values(array_filter( + new AnimationReferenceValidator()->validate(ProjectWorkspace::fromProject($root)), + static fn($issue): bool => str_starts_with($issue->where, 'assets/Data/skills.php'), + )); expect($issues)->toHaveCount(1) ->and($issues[0]->where)->toContain('Cure'); }); diff --git a/tests/Unit/AnimationRolesTest.php b/tests/Unit/AnimationRolesTest.php new file mode 100644 index 0000000..39d61b8 --- /dev/null +++ b/tests/Unit/AnimationRolesTest.php @@ -0,0 +1,151 @@ + [ + 'id' => $id, 'name' => $name, 'position' => 'center', 'maxFrames' => 1, + ...($roles === [] ? [] : ['roles' => $roles]), + 'targetEffect' => $id === 1 ? 'battle-blade-slash' : 'battle-physical-impact', + 'frames' => [], 'cues' => [], + ]; + file_put_contents($root . '/assets/Data/animations.php', ' 120, 'height' => 40]); + setEditorProperty($editor, 'databaseCategoryIndex', DatabaseCatalog::indexOf('animations')); + + return $editor; +} + +/** @return array> The role rows keyed by field. */ +function roleRows(Editor $editor): array +{ + $rows = []; + + foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $field) { + if (str_starts_with((string) ($field['field'] ?? ''), 'role:')) { + $rows[(string) $field['field']] = $field; + } + } + + return $rows; +} + +/** @return list The validation messages about animations. */ +function roleIssues(string $root): array +{ + return array_map( + static fn($issue): string => $issue->message, + new AnimationReferenceValidator()->validate(ProjectWorkspace::fromProject($root)), + ); +} + +it('binds and unbinds roles, writing them over the authored entry and keeping everything else', function () { + $root = makeTemporaryProject(); + writeRoleAnimations($root); + $database = ProjectAnimationDatabase::fromProject($root); + + expect($database->getRoles(0))->toBe(['attack-sword']) + ->and($database->findRoleOwner('attack-unarmed'))->toBe(1) + ->and($database->setRole(0, 'attack-dagger', true))->toBeNull() + ->and($database->setRole(1, 'attack-unarmed', false))->toBeNull() + ->and($database->setRole(1, 'attack', false))->toBeNull(); + + $database->save(); + $saved = require $root . '/assets/Data/animations.php'; + + expect($saved[0]['roles'])->toBe(['attack-sword', 'attack-dagger']) + ->and($saved[1])->not->toHaveKey('roles') + ->and($saved[0]['targetEffect'])->toBe('battle-blade-slash') + ->and(ProjectAnimationDatabase::fromProject($root)->getRoles(0))->toBe(['attack-sword', 'attack-dagger']); +}); + +it('refuses a role the Engine does not support or one already bound elsewhere, changing nothing', function () { + $root = makeTemporaryProject(); + writeRoleAnimations($root); + $database = ProjectAnimationDatabase::fromProject($root); + + expect($database->setRole(1, 'attack-sword', true))->toBe('Role attack-sword is already bound to Blade Slash; unbind it there first.') + ->and($database->setRole(0, 'attack-lance', true))->toBe('The Engine has no animation role "attack-lance".') + ->and($database->getRoles(0))->toBe(['attack-sword']) + ->and($database->getRoles(1))->toBe(['attack', 'attack-unarmed']) + ->and($database->isDirty())->toBeFalse(); +}); + +it('offers every supported role as a row, naming the animation that holds it', function () { + $root = makeTemporaryProject(); + writeRoleAnimations($root); + $rows = roleRows(roleEditor($root)); + + expect(array_keys($rows))->toBe(array_map(static fn(string $role): string => 'role:' . $role, ActionAnimationResolver::getSupportedRoles())) + ->and($rows['role:attack-sword']['value'])->toBe('Yes') + ->and($rows['role:attack-sword']['options'])->toBe(['no', 'yes']) + ->and(trim((string) $rows['role:attack-unarmed']['label']))->toBe('attack-unarmed (on Hit Spark)') + ->and($rows['role:attack-unarmed']['value'])->toBe('No'); +}); + +it('switches a role on from its row and undoes it, and leaves no undo step when refused', function () { + $root = makeTemporaryProject(); + writeRoleAnimations($root); + $editor = roleEditor($root); + /** @var CommandHistory $history */ + $history = getEditorProperty($editor, 'history'); + $database = getEditorProperty($editor, 'workspace')->animationDatabase; + + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', roleRows($editor)['role:attack-axe'], 'yes'); + expect($database->getRoles(0))->toBe(['attack-sword', 'attack-axe']); + + $history->undo(); + expect($database->getRoles(0))->toBe(['attack-sword']); + + $history->redo(); + $history->undo(); + $steps = new ReflectionProperty(CommandHistory::class, 'undoStack')->getValue($history); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', roleRows($editor)['role:attack-unarmed'], 'yes'); + + expect($database->getRoles(0))->toBe(['attack-sword']) + ->and(new ReflectionProperty(CommandHistory::class, 'undoStack')->getValue($history))->toBe($steps) + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('already bound to Hit Spark'); +}); + +it('reports roles the runtime cannot play and roles the project reaches with no animation', function () { + $root = makeTemporaryProject(); + writeRoleAnimations($root, ['attack-sword', 'attack-lance'], ['attack-sword']); + $issues = roleIssues($root); + + expect($issues)->toContain( + 'Its role "attack-lance" is not one the Engine supports, so the whole animation is skipped.', + 'Role attack-sword is bound to Blade Slash and Hit Spark, so neither plays for it.', + 'No animation holds role attack, so enemy and unclassified attacks play no effect.', + 'No animation holds role attack-unarmed, so attacks with no weapon equipped play no effect.', + ); + + writeRoleAnimations($root, [], ['attack', 'attack-unarmed']); + + // The fixture's Wooden Sword makes swords a weapon type the battles reach. + expect(implode("\n", roleIssues($root)))->toContain('No animation holds role attack-sword, so attacks with a sword such as Wooden Sword play no effect.') + ->and(implode("\n", roleIssues($root)))->not->toContain('role attack,'); + + writeRoleAnimations($root); + + expect(array_filter(roleIssues($root), static fn(string $message): bool => str_contains($message, 'role')))->toBe([]); +}); From 82cd2defaade62f4366778be52b4f6debeadacd4 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 05:52:03 +0200 Subject: [PATCH 106/344] feat(cutscenes): read and write standalone effect timelines as assets Effect timelines (assets/Animations//.timeline.php) become a third cutscene type beside cinematics and summons, so they share the workspace's source-preserving, transactional asset model. - CutsceneType::EFFECT: a single file, no data file; its id follows the Engine's EffectTimelineLibrary::ID_PATTERN (no dots). - CutsceneAsset: single-file load, save and delete; the folder is the id, shown as a view-only `id` and never written into the timeline. An effect with separate terminal and graphical sequences is edited one sequence at a time (selectPresentation); the other stays exactly as written. splitIntoPresentations turns a flat effect into the two sequences only when asked. compiledEffect compiles it in memory. - Saving proves through the Engine (CutsceneHydration::checkEffect) that each presentation plays in battle or on the field, and rolls back otherwise. - CutsceneLibrary: discovery, rename, duplicate and free ids per type. - CutsceneSchemas::effects: rows for every key the effect library reads: timing and playback, impact timing, tracks with presentation, anchor and facing, image sheets and depth, glyph keyframes with x/y positions, image keyframes with sheet frame and flips, and cues. - ReferenceCatalog: `effect_cues` for the impact cue picker. - Cutscene validation checks effects through the Engine instead of as summons. --- src/Cutscenes/CutsceneAsset.php | 146 +++++++++++++-- src/Cutscenes/CutsceneHydration.php | 64 +++++++ src/Cutscenes/CutsceneLibrary.php | 31 ++-- src/Cutscenes/CutscenePairShape.php | 6 +- src/Cutscenes/CutsceneType.php | 50 +++++- src/Database/CutsceneSchemas.php | 166 ++++++++++++++++++ src/Database/RecordSchemaCatalog.php | 9 + src/Database/ReferenceCatalog.php | 12 +- src/Validation/CutsceneValidation.php | 16 +- tests/Support/CutsceneFixtures.php | 79 +++++++++ .../Cutscenes/EffectTimelineAssetTest.php | 134 ++++++++++++++ 11 files changed, 673 insertions(+), 40 deletions(-) create mode 100644 tests/Unit/Cutscenes/EffectTimelineAssetTest.php diff --git a/src/Cutscenes/CutsceneAsset.php b/src/Cutscenes/CutsceneAsset.php index 6a46c19..4a8c3cf 100644 --- a/src/Cutscenes/CutsceneAsset.php +++ b/src/Cutscenes/CutsceneAsset.php @@ -13,6 +13,8 @@ use Ichiloto\Editor\Database\PhpValueExporter; use Ichiloto\Editor\History\TracksPersistedState; use Ichiloto\Editor\ProjectDirectoryContext; +use Ichiloto\Engine\Animations\Timelines\CompiledEffectTimeline; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicDefinition; use Ichiloto\Engine\Cutscenes\Summons\SummonCompiledCutscene; use RuntimeException; @@ -79,6 +81,13 @@ final class CutsceneAsset private ?PhpArraySourceDocument $dataDocument = null; private ?PhpArraySourceDocument $partnerDocument = null; + + /** + * The sequence an effect with separate terminal and graphical sequences + * is being edited in. The editor shows and edits one at a time; the file + * keeps both, each exactly as written unless it is the one edited. + */ + private EffectPresentation $presentationView = EffectPresentation::TERMINAL; private ?string $readOnlyReason = null; private bool $isNew = false; private bool $isDeleted = false; @@ -114,16 +123,19 @@ public static function load(CutsceneType $type, string $folder, ?string $project $dataPath = $asset->dataPath(); $partnerPath = $asset->partnerPath(); - if (! is_file($dataPath) || ! is_file($partnerPath)) { - throw new RuntimeException(sprintf('%s "%s" is missing %s.', ucfirst($type->noun()), $asset->id, is_file($dataPath) ? basename($partnerPath) : basename($dataPath))); + $hasData = $type->hasDataFile(); + + if (($hasData && ! is_file($dataPath)) || ! is_file($partnerPath)) { + throw new RuntimeException(sprintf('%s "%s" is missing %s.', ucfirst($type->noun()), $asset->id, ! is_file($partnerPath) ? basename($partnerPath) : basename($dataPath))); } - $dataSource = (string) file_get_contents($dataPath); + $dataSource = $hasData ? (string) file_get_contents($dataPath) : ''; $partnerSource = (string) file_get_contents($partnerPath); $reasons = []; try { - $data = $asset->evaluate($dataPath); + // An effect has no data file; all of it is its timeline. + $data = $hasData ? $asset->evaluate($dataPath) : []; } catch (Throwable $throwable) { $data = []; $reasons[] = sprintf('%s could not be evaluated (%s)', basename($dataPath), $throwable->getMessage()); @@ -147,7 +159,7 @@ public static function load(CutsceneType $type, string $folder, ?string $project } try { - $asset->dataDocument = PhpArraySourceDocument::parse($dataSource); + $asset->dataDocument = $hasData ? PhpArraySourceDocument::parse($dataSource) : null; } catch (SourceUnreadable $unreadable) { $reasons[] = sprintf('%s cannot be rewritten in place: %s', basename($dataPath), rtrim($unreadable->getMessage(), '.')); } @@ -197,6 +209,10 @@ public static function create(CutsceneType $type, string $id, string $root, arra $asset = new self($type, $id, rtrim($root, '/') . '/' . $id, $projectRoot); $asset->isNew = true; $asset->shape = CutscenePairShape::forNewAsset($type); + + if (! $type->hasDataFile()) { + unset($payload['id']); + } [$data, $partner] = $asset->shape->split($payload); $asset->shape = CutscenePairShape::of($type, $data, $partner); $asset->data = $data; @@ -233,7 +249,7 @@ public function partnerPath(): string */ public function paths(): array { - return [$this->dataPath(), $this->partnerPath()]; + return $this->type->hasDataFile() ? [$this->dataPath(), $this->partnerPath()] : [$this->partnerPath()]; } /** @@ -273,6 +289,7 @@ public function markDeleted(bool $deleted): void */ public function name(): string { + // An effect is named by its id; its timeline holds no name. $name = trim(strval($this->data['name'] ?? '')); return $name !== '' ? $name : $this->id; @@ -318,12 +335,78 @@ public function commands(): array */ public function payload(): array { - $payload = $this->shape->merge($this->data, $this->partner); + $payload = $this->hasPresentations() + ? $this->getPresentationSequence($this->partner) + : $this->shape->merge($this->data, $this->partner); + + if (! $this->type->hasDataFile()) { + // An effect's id is its folder, as the Engine finds it; it is + // shown and renamed here like any id, and never written. + $payload = ['id' => $this->id, ...$payload]; + } + $payload[self::ORIGIN_KEY] = $this->id; return $payload; } + /** + * Whether this is an effect with separate terminal and graphical + * sequences, edited one at a time. + */ + public function hasPresentations(): bool + { + return $this->type === CutsceneType::EFFECT && is_array($this->partner['presentations'] ?? null); + } + + /** The sequence being edited, for an effect with separate sequences; null otherwise. */ + public function getPresentationView(): ?EffectPresentation + { + return $this->hasPresentations() ? $this->presentationView : null; + } + + /** + * Chooses which of an effect's sequences the editor shows and edits. + * Nothing is written; the other sequence stays as it is. + */ + public function selectPresentation(EffectPresentation $presentation): void + { + $this->presentationView = $presentation; + } + + /** + * Gives a flat effect separate terminal and graphical sequences, each + * starting as a copy of the flat one, so each presentation can then be + * authored on its own. A flat effect stays flat unless this is chosen. + * + * @return bool False when it already has them, is not an effect, or cannot be written. + */ + public function splitIntoPresentations(): bool + { + if ($this->type !== CutsceneType::EFFECT || $this->hasPresentations() || $this->readOnlyReason !== null) { + return false; + } + + $this->partner = ['presentations' => [ + EffectPresentation::TERMINAL->value => $this->partner, + EffectPresentation::GRAPHICAL->value => $this->partner, + ]]; + $this->touchState(); + + return true; + } + + /** + * @param array $timeline + * @return array + */ + private function getPresentationSequence(array $timeline): array + { + $sequence = $timeline['presentations'][$this->presentationView->value] ?? []; + + return is_array($sequence) ? $sequence : []; + } + /** * Takes an edited payload back into the data and partner arrays. * @@ -342,7 +425,22 @@ public function apply(array $payload): void return; } - [$data, $partner] = $this->shape->split($payload); + if (! $this->type->hasDataFile()) { + unset($payload['id']); + } + + if ($this->hasPresentations()) { + // The edited sequence goes back into its place; the other + // sequence, and the keys of this one, keep their order. + unset($payload[self::ORIGIN_KEY]); + $old = $this->getPresentationSequence($this->partner); + $ordered = array_intersect_key(array_replace(array_flip(array_keys($old)), $payload), $payload); + $partner = $this->partner; + $partner['presentations'][$this->presentationView->value] = $ordered; + $data = $this->data; + } else { + [$data, $partner] = $this->shape->split($payload); + } if ($data === $this->data && $partner === $this->partner) { return; @@ -400,9 +498,10 @@ public function save(?callable $backup = null): bool } // 1. Both proposed sources, each touched only where it changed. - $dataSource = $this->proposedSource($this->dataDocument, $this->loadedData, $this->data, 'data'); + $hasData = $this->type->hasDataFile(); + $dataSource = $hasData ? $this->proposedSource($this->dataDocument, $this->loadedData, $this->data, 'data') : ''; $partnerSource = $this->proposedSource($this->partnerDocument, $this->loadedPartner, $this->partner, $this->type->partnerNoun()); - $writeData = $this->isNew || $this->dataDocument === null || $dataSource !== $this->dataDocument->source; + $writeData = $hasData && ($this->isNew || $this->dataDocument === null || $dataSource !== $this->dataDocument->source); $writePartner = $this->isNew || $this->partnerDocument === null || $partnerSource !== $this->partnerDocument->source; if (! $writeData && ! $writePartner) { @@ -428,7 +527,9 @@ public function save(?callable $backup = null): bool $staged = $transaction->stage(); try { - $evaluatedData = $this->evaluateSide($staged[$this->dataPath()] ?? $this->dataPath(), $this->data, basename($this->dataPath()), 'data'); + $evaluatedData = $hasData + ? $this->evaluateSide($staged[$this->dataPath()] ?? $this->dataPath(), $this->data, basename($this->dataPath()), 'data') + : []; $evaluatedPartner = $this->evaluateSide($staged[$this->partnerPath()] ?? $this->partnerPath(), $this->partner, basename($this->partnerPath()), $this->type->partnerNoun()); // 3. Hydrate and compile through the engine. @@ -486,9 +587,30 @@ public function hydrate(?array $data = null, ?array $partner = null): void return; } + if ($this->type === CutsceneType::EFFECT) { + CutsceneHydration::checkEffect($this->id, $partner, $this->projectRoot); + + return; + } + CutsceneHydration::compileSummon($data, $partner, $this->projectRoot); } + /** + * Compiles the effect as it stands in memory, unsaved edits included, as + * battle or the field plays it in one presentation. + * + * @throws RuntimeException When the Engine refuses it, or for another type. + */ + public function compiledEffect(EffectPresentation $presentation, bool $forBattle): CompiledEffectTimeline + { + if ($this->type !== CutsceneType::EFFECT) { + throw new RuntimeException(sprintf('%s is a %s, not an effect.', $this->id, $this->type->noun())); + } + + return CutsceneHydration::compileEffect($this->id, $this->partner, $presentation, $forBattle, $this->projectRoot); + } + /** * Hydrates the cinematic as it stands in memory, unsaved edits included. * @@ -576,7 +698,7 @@ private function proposedSource(?PhpArraySourceDocument $document, array $loaded */ private function adoptWritten(string $dataSource, string $partnerSource): void { - $this->dataDocument = PhpArraySourceDocument::parse($dataSource); + $this->dataDocument = $this->type->hasDataFile() ? PhpArraySourceDocument::parse($dataSource) : null; $this->partnerDocument = PhpArraySourceDocument::parse($partnerSource); $this->loadedData = $this->data; $this->loadedPartner = $this->partner; diff --git a/src/Cutscenes/CutsceneHydration.php b/src/Cutscenes/CutsceneHydration.php index fb05eb5..a3e7e9a 100644 --- a/src/Cutscenes/CutsceneHydration.php +++ b/src/Cutscenes/CutsceneHydration.php @@ -5,6 +5,9 @@ namespace Ichiloto\Editor\Cutscenes; use Ichiloto\Editor\ProjectDirectoryContext; +use Ichiloto\Engine\Animations\Timelines\CompiledEffectTimeline; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; +use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicDefinition; use Ichiloto\Engine\Cutscenes\Summons\SummonCompiledCutscene; use Ichiloto\Engine\Cutscenes\Summons\SummonCutsceneCompiler; @@ -71,6 +74,67 @@ public static function compileSummon(array $data, array $timeline, ?string $proj ); } + /** + * Compiles an effect timeline through the Engine's effect library, as + * battle or the field plays it in one presentation. + * + * @param array $timeline The timeline file's array. + */ + public static function compileEffect( + string $id, + array $timeline, + EffectPresentation $presentation, + bool $forBattle, + ?string $projectRoot = null, + ): CompiledEffectTimeline { + return self::inProject( + $projectRoot, + static fn(): CompiledEffectTimeline => new EffectTimelineLibrary(self::getAssetRoot($projectRoot)) + ->compile($id, $timeline, $forBattle, $presentation), + ); + } + + /** + * Proves an effect timeline can be played: each presentation must + * compile for battle or for the field, the two places the Engine plays + * effects. Which one a project uses it in is validation's to check. + * + * @param array $timeline The timeline file's array. + * @throws RuntimeException Naming each presentation neither accepts, with the Engine's reasons. + */ + public static function checkEffect(string $id, array $timeline, ?string $projectRoot = null): void + { + $failures = []; + + foreach (EffectPresentation::cases() as $presentation) { + $reasons = []; + + foreach (['battle' => true, 'field' => false] as $context => $forBattle) { + try { + self::compileEffect($id, $timeline, $presentation, $forBattle, $projectRoot); + $reasons = []; + break; + } catch (RuntimeException $exception) { + $reasons[] = sprintf('%s: %s', $context, $exception->getMessage()); + } + } + + if ($reasons !== []) { + $failures[] = sprintf('the %s presentation plays nowhere (%s)', $presentation->value, implode('; ', $reasons)); + } + } + + if ($failures !== []) { + throw new RuntimeException(sprintf('Effect %s cannot be played: %s.', $id, implode('; ', $failures))); + } + } + + /** The asset root the Engine resolves an effect's images against. */ + private static function getAssetRoot(?string $projectRoot): string + { + return rtrim($projectRoot ?? (string) getcwd(), '/') . '/assets'; + } + /** * Returns whether the engine's cutscene classes are reachable from this * checkout at all. diff --git a/src/Cutscenes/CutsceneLibrary.php b/src/Cutscenes/CutsceneLibrary.php index 4d6b08d..9b80d5b 100644 --- a/src/Cutscenes/CutsceneLibrary.php +++ b/src/Cutscenes/CutsceneLibrary.php @@ -109,14 +109,14 @@ private function discover(CutsceneType $type): void $hasData = is_file($dataPath); $hasPartner = is_file($partnerPath); - if (! $hasData || ! $hasPartner) { + if (($type->hasDataFile() && ! $hasData) || ! $hasPartner) { $this->reportIncomplete($type, $folder, $entry, $hasData, $hasPartner); continue; } - if (preg_match('/^[a-z0-9][a-z0-9._-]*$/', $entry) !== 1) { - $this->issues[$type->value][] = ['folder' => $entry, 'message' => sprintf('The folder name "%s" is not a stable id (lowercase letters, digits, ".", "_" and "-").', $entry)]; + if (preg_match($type->getIdPattern(), $entry) !== 1) { + $this->issues[$type->value][] = ['folder' => $entry, 'message' => sprintf('The folder name "%s" is not a stable id (%s).', $entry, $type->describeIdCharacters())]; } $lower = strtolower($entry); @@ -149,7 +149,7 @@ private function discover(CutsceneType $type): void private function reportIncomplete(CutsceneType $type, string $folder, string $entry, bool $hasData, bool $hasPartner): void { $files = array_values(array_filter(scandir($folder) ?: [], static fn(string $file): bool => str_ends_with($file, '.php'))); - $missing = ! $hasData ? $entry . '.data.php' : $entry . $type->partnerSuffix(); + $missing = $type->hasDataFile() && ! $hasData ? $entry . '.data.php' : $entry . $type->partnerSuffix(); $misnamed = array_values(array_filter( $files, static fn(string $file): bool => (str_ends_with($file, '.data.php') || str_ends_with($file, $type->partnerSuffix())) @@ -168,13 +168,9 @@ private function reportIncomplete(CutsceneType $type, string $folder, string $en return; } - $this->issues[$type->value][] = ['folder' => $entry, 'message' => sprintf( - 'Folder "%s" is missing %s; a %s is a data file and a %s together.', - $entry, - $missing, - $type->noun(), - $type->partnerNoun(), - )]; + $this->issues[$type->value][] = ['folder' => $entry, 'message' => $type->hasDataFile() + ? sprintf('Folder "%s" is missing %s; a %s is a data file and a %s together.', $entry, $missing, $type->noun(), $type->partnerNoun()) + : sprintf('Folder "%s" is missing %s; an %s is its %s file.', $entry, $missing, $type->noun(), $type->partnerNoun())]; } /** @@ -305,6 +301,7 @@ public function schemaFor(CutsceneType $type): RecordSchema return match ($type) { CutsceneType::CINEMATIC => RecordSchemaCatalog::cinematics(), CutsceneType::SUMMON => RecordSchemaCatalog::summons(), + CutsceneType::EFFECT => RecordSchemaCatalog::effects(), }; } @@ -407,8 +404,8 @@ public function renameNew(CutsceneType $type, string $id, string $newId): Cutsce throw new RuntimeException(sprintf('A %s "%s" already exists.', $type->noun(), $newId)); } - if (preg_match('/^[a-z0-9][a-z0-9._-]*$/', $newId) !== 1) { - throw new RuntimeException(sprintf('"%s" is not a stable id (lowercase letters, digits, ".", "_" and "-").', $newId)); + if (preg_match($type->getIdPattern(), $newId) !== 1) { + throw new RuntimeException(sprintf('"%s" is not a stable id (%s).', $newId, $type->describeIdCharacters())); } $payload = $source->payload(); @@ -442,8 +439,8 @@ public function duplicate(CutsceneType $type, string $id, string $newId): Cutsce throw new RuntimeException(sprintf('A %s "%s" already exists.', $type->noun(), $newId)); } - if (preg_match('/^[a-z0-9][a-z0-9._-]*$/', $newId) !== 1) { - throw new RuntimeException(sprintf('"%s" is not a stable id (lowercase letters, digits, ".", "_" and "-").', $newId)); + if (preg_match($type->getIdPattern(), $newId) !== 1) { + throw new RuntimeException(sprintf('"%s" is not a stable id (%s).', $newId, $type->describeIdCharacters())); } $payload = $source->payload(); @@ -461,7 +458,9 @@ public function duplicate(CutsceneType $type, string $id, string $newId): Cutsce */ public function freeId(CutsceneType $type, string $preferred): string { - $stem = strtolower(trim(preg_replace('/[^a-z0-9._-]+/i', '-', $preferred) ?? '', '-.')); + // An effect id holds no dots, as the Engine requires. + $allowed = $type === CutsceneType::EFFECT ? '/[^a-z0-9_-]+/i' : '/[^a-z0-9._-]+/i'; + $stem = strtolower(trim(preg_replace($allowed, '-', $preferred) ?? '', '-.')); if ($stem === '' || preg_match('/^[a-z0-9]/', $stem) !== 1) { $stem = 'new-' . $type->noun(); diff --git a/src/Cutscenes/CutscenePairShape.php b/src/Cutscenes/CutscenePairShape.php index 87dafdd..f22e01b 100644 --- a/src/Cutscenes/CutscenePairShape.php +++ b/src/Cutscenes/CutscenePairShape.php @@ -64,7 +64,7 @@ public static function of(CutsceneType $type, array $data, array $partner): self $owners[(string) $key] = 'data'; } - if ($type === CutsceneType::SUMMON || $scriptIsMap) { + if ($type !== CutsceneType::CINEMATIC || $scriptIsMap) { foreach (array_keys($partner) as $key) { if (! isset($owners[(string) $key])) { $owners[(string) $key] = 'partner'; @@ -173,8 +173,10 @@ public function split(array $payload): array } foreach ($payload as $key => $value) { + // An effect is its timeline alone; a summon's new timeline fields + // go to its timeline and anything else to its data file. $owner = $this->keyOwners[$key] - ?? (in_array($key, CinematicCommandSchema::SUMMON_TIMELINE_FIELDS, true) ? 'partner' : 'data'); + ?? ($this->type === CutsceneType::EFFECT || in_array($key, CinematicCommandSchema::SUMMON_TIMELINE_FIELDS, true) ? 'partner' : 'data'); if ($owner === 'partner') { $partner[$key] = $value; diff --git a/src/Cutscenes/CutsceneType.php b/src/Cutscenes/CutsceneType.php index dc54143..b4425b5 100644 --- a/src/Cutscenes/CutsceneType.php +++ b/src/Cutscenes/CutsceneType.php @@ -4,14 +4,18 @@ namespace Ichiloto\Editor\Cutscenes; +use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; + /** - * The two cutscene forms the Cutscenes workspace authors. + * The timeline forms the Cutscenes workspace authors. * * A Cinematic is a staged story sequence: a command tree the engine's event * interpreter runs on the field. A Summon is a frame-driven battle * presentation: tracks, keyframes and cues the summon compiler builds and the - * summon player plays. They share a workspace, an asset layout of one folder - * with two files per stable id, and nothing else. + * summon player plays. An Effect is a standalone effect timeline battles, + * the field and cinematics play: tracks, keyframes and cues the Engine's + * effect library compiles. Each lives in one folder named by its stable id; + * a cinematic and a summon hold two files there, an effect only its timeline. * * @package Ichiloto\Editor\Cutscenes */ @@ -19,6 +23,8 @@ enum CutsceneType: string { case CINEMATIC = 'cinematic'; case SUMMON = 'summon'; + /** A standalone effect timeline, played by battles, the field and cinematics. */ + case EFFECT = 'effect'; /** * Returns the project-relative folder holding this type's assets. @@ -28,6 +34,7 @@ public function relativeRoot(): string return match ($this) { self::CINEMATIC => 'assets/Cutscenes/Cinematics', self::SUMMON => 'assets/Cutscenes/Summons', + self::EFFECT => 'assets/' . EffectTimelineLibrary::DIRECTORY, }; } @@ -38,7 +45,7 @@ public function partnerSuffix(): string { return match ($this) { self::CINEMATIC => '.script.php', - self::SUMMON => '.timeline.php', + self::SUMMON, self::EFFECT => '.timeline.php', }; } @@ -49,7 +56,7 @@ public function partnerNoun(): string { return match ($this) { self::CINEMATIC => 'script', - self::SUMMON => 'timeline', + self::SUMMON, self::EFFECT => 'timeline', }; } @@ -61,6 +68,7 @@ public function noun(): string return match ($this) { self::CINEMATIC => 'cinematic', self::SUMMON => 'summon', + self::EFFECT => 'effect', }; } @@ -72,6 +80,38 @@ public function label(): string return match ($this) { self::CINEMATIC => 'Cinematic', self::SUMMON => 'Summon', + self::EFFECT => 'Effect', + }; + } + + /** + * Whether the asset has an `.data.php` beside its partner file. An + * effect is its timeline alone, as the Engine reads it. + */ + public function hasDataFile(): bool + { + return $this !== self::EFFECT; + } + + /** + * Returns the pattern a stable id of this type must match: the Engine's + * own for effects, which allows no dots. + */ + public function getIdPattern(): string + { + return match ($this) { + self::EFFECT => EffectTimelineLibrary::ID_PATTERN, + self::CINEMATIC, self::SUMMON => '/^[a-z0-9][a-z0-9._-]*$/', }; } + + /** + * Returns the characters an id may hold, for messages. + */ + public function describeIdCharacters(): string + { + return $this === self::EFFECT + ? 'lowercase letters, digits, "_" and "-"' + : 'lowercase letters, digits, ".", "_" and "-"'; + } } diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index 17bf7e9..58580ee 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -7,6 +7,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Engine\Animations\Timelines\EffectPresentation; +use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; use Ichiloto\Engine\Cutscenes\Summons\SummonEffectTiming; @@ -36,6 +37,9 @@ final class CutsceneSchemas */ public const string SUMMONS_KEY = 'summons'; + /** The effect timeline category the Cutscenes workspace edits. */ + public const string EFFECTS_KEY = 'effects'; + /** * The payload key a cinematic's finalizer commands are edited under. */ @@ -426,6 +430,168 @@ public static function cameraPointList(): RecordSubList ); } + /** + * Returns the effect timeline category: one record per effect, the + * sequence being edited as fields, its tracks and cues as lists opened + * as frames. Every row is a key the Engine's effect library reads; an + * effect with separate terminal and graphical sequences is edited one + * sequence at a time. Battle-only keys (impact timing, facing, flips, + * flash and shake) are offered for every effect; validation says where + * an effect is used that refuses them. + */ + public static function effects(): RecordSchema + { + return new RecordSchema( + key: self::EFFECTS_KEY, + entryNoun: 'effect', + storage: RecordStorage::MAP_OWNED, + relativePath: 'assets/' . EffectTimelineLibrary::DIRECTORY, + fields: [ + new RecordField('id', 'Id', isReadOnly: true), + new RecordField('fps', 'FPS', InputControlType::INTEGER), + new RecordField('lengthFrames', 'Length (frames)', InputControlType::INTEGER), + new RecordField('playback', 'Playback', options: ['once', 'loop'], removeWhenEmpty: true, displayDefault: 'once'), + new RecordField('loopFrom', 'Loop From', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + new RecordField('restFrame', 'Rest Frame', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + // Battle only: when the command's result lands. + new RecordField('effectTiming.mode', 'Impact Timing', options: ['end', 'frame', 'cue'], removeWhenEmpty: true, displayDefault: '(omitted)'), + new RecordField('effectTiming.frame', 'Impact Frame', InputControlType::INTEGER, removeWhenEmpty: true), + RecordField::reference('effectTiming.cueId', 'Impact Cue', 'effect_cues', allowsNone: true, noneLabel: '(none)'), + ], + identityKey: 'id', + blank: [ + 'id' => 'new-effect', + 'fps' => 12, + 'lengthFrames' => 12, + self::TRACKS_KEY => [[ + 'id' => 'glyph', + 'type' => 'glyph', + 'keyframes' => [['frame' => 0, 'duration' => 12, 'content' => '*', 'position' => ['x' => 0, 'y' => 0]]], + ]], + self::CUES_KEY => [], + ], + commandLists: [ + self::TRACKS_KEY => self::effectTrackList(), + self::CUES_KEY => self::effectCueList(), + ], + ); + } + + /** + * Returns the track list of an effect timeline: the keys every track + * reads, an image track's sheet and cells as its own rows, and each + * track's keyframes as a nested list of the keys its type reads. + */ + public static function effectTrackList(): RecordSubList + { + $keyframes = self::effectKeyframeList(); + + return new RecordSubList( + key: self::TRACKS_KEY, + prefix: 'track', + singular: 'track', + fields: [ + new RecordField('id', 'Id'), + new RecordField('type', 'Type', options: ['glyph', 'text', 'image', 'flash', 'shake']), + new RecordField('presentation', 'Presentation', + options: ['all', ...array_map(static fn(EffectPresentation $presentation): string => $presentation->value, EffectPresentation::cases())], + removeWhenEmpty: true, displayDefault: 'all'), + new RecordField('anchor', 'Anchor', options: ['target', 'caster', 'screen'], removeWhenEmpty: true, displayDefault: 'target'), + // Battle only: the way the stroke is drawn; the battle mirrors it for an attack the other way. + // The empty choice removes it: an undirected track stays as drawn. + new RecordField('facing', 'Facing', options: ['', 'west', 'east'], removeWhenEmpty: true, displayDefault: '(undirected)'), + ], + blank: ['id' => 'track', 'type' => 'glyph', 'keyframes' => [['frame' => 0, 'duration' => 1, 'content' => '*', 'position' => ['x' => 0, 'y' => 0]]]], + variants: [ + 'image' => [ + RecordField::reference('asset', 'Image', 'png_assets'), + new RecordField('sheet.columns', 'Sheet Columns', InputControlType::INTEGER, displayDefault: '1'), + new RecordField('sheet.rows', 'Sheet Rows', InputControlType::INTEGER, displayDefault: '1'), + new RecordField('cells.width', 'Cell Width', InputControlType::INTEGER, displayDefault: '1'), + new RecordField('cells.height', 'Cell Height', InputControlType::INTEGER, displayDefault: '1'), + new RecordField('depth', 'Depth', options: ['front', 'behind'], removeWhenEmpty: true, displayDefault: 'front'), + ], + ], + variantKey: 'type', + nestedLists: [ + 'image' => self::effectImageKeyframeList(), + 'glyph' => $keyframes, + 'text' => $keyframes, + 'flash' => $keyframes, + 'shake' => $keyframes, + ], + ); + } + + /** + * Returns the keyframes of an effect's glyph, text, flash or shake + * track. A position is `x` and `y`, as the Engine reads it. + */ + public static function effectKeyframeList(): RecordSubList + { + return new RecordSubList( + key: 'keyframes', + prefix: 'keyframe', + singular: 'keyframe', + fields: [ + new RecordField('frame', 'Frame', InputControlType::INTEGER), + new RecordField('duration', 'Duration', InputControlType::INTEGER), + new RecordField('position.x', 'Position X', InputControlType::INTEGER), + new RecordField('position.y', 'Position Y', InputControlType::INTEGER), + new RecordField('content', 'Content', InputControlType::MULTILINE, removeWhenEmpty: true), + new RecordField('assetId', 'Asset Id', removeWhenEmpty: true), + new RecordField('color', 'Color', removeWhenEmpty: true), + RecordField::boolean('visible', 'Visible'), + new RecordField('zIndex', 'Z Index', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + new RecordField('payload', 'Payload', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), + ], + blank: ['frame' => 0, 'duration' => 1, 'content' => '*', 'position' => ['x' => 0, 'y' => 0]], + ); + } + + /** + * Returns the keyframes of an effect's image track: which sheet frame + * shows, where, and (battle only) whether it is flipped. + */ + public static function effectImageKeyframeList(): RecordSubList + { + return new RecordSubList( + key: 'keyframes', + prefix: 'keyframe', + singular: 'keyframe', + fields: [ + new RecordField('frame', 'Frame', InputControlType::INTEGER), + new RecordField('duration', 'Duration', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '1'), + new RecordField('sourceFrame', 'Sheet Frame', InputControlType::INTEGER), + new RecordField('position.x', 'Position X', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + new RecordField('position.y', 'Position Y', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + RecordField::boolean('flipX', 'Flip Horizontally'), + RecordField::boolean('flipY', 'Flip Vertically'), + ], + blank: ['frame' => 0, 'sourceFrame' => 0], + ); + } + + /** + * Returns the cue list of an effect timeline. The field plays only + * `playSound`; battle plays every type. + */ + public static function effectCueList(): RecordSubList + { + return new RecordSubList( + key: self::CUES_KEY, + prefix: 'cue', + singular: 'cue', + fields: [ + new RecordField('id', 'Id'), + new RecordField('frame', 'Frame', InputControlType::INTEGER), + new RecordField('type', 'Type', options: ['playSound', 'applyEffect', 'showMessage', 'flash', 'shake']), + new RecordField('payload', 'Payload', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), + ], + blank: ['id' => 'cue', 'frame' => 0, 'type' => 'playSound', 'payload' => []], + ); + } + /** * Returns the track list of a summon timeline, each track owning its * keyframes as a nested list. diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 17d8c4c..6fcf8c1 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -1296,6 +1296,15 @@ public static function summons(): RecordSchema return CutsceneSchemas::summons(); } + /** + * Returns the effect timeline category the Cutscenes workspace edits. + * Not a Database category: it is not listed by all(). + */ + public static function effects(): RecordSchema + { + return CutsceneSchemas::effects(); + } + /** * Returns the per-type field sets for event-script commands. * diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 957c191..aa2597b 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -69,6 +69,7 @@ final class ReferenceCatalog 'cinematic_subjects', 'cinematic_checkpoints', 'summon_cues', + 'effect_cues', 'event_markers', 'tilesets', 'effects', @@ -186,7 +187,8 @@ public function valuesFor(string $category): array 'cinematic_cast' => $this->castIds(['staged_actor']), 'cinematic_subjects' => $this->subjectIds(), 'cinematic_checkpoints' => $this->checkpointIds(), - 'summon_cues' => $this->summonCueIds(), + 'summon_cues' => $this->getTimelineCueIds(CutsceneType::SUMMON), + 'effect_cues' => $this->getTimelineCueIds(CutsceneType::EFFECT), 'event_markers' => $this->currentMap?->getEventMarkers() ?? [], // A map's kind is one of the project's tilesets, by file stem. 'tilesets' => array_keys($this->loadTilesetNames()), @@ -305,13 +307,15 @@ private function checkpointIds(): array } /** - * Returns the stable cue ids declared by the current summon's timeline. + * Returns the stable cue ids the current summon's or effect's timeline declares. * * @return string[] */ - private function summonCueIds(): array + private function getTimelineCueIds(CutsceneType $type): array { - if ($this->currentCutscene?->type !== CutsceneType::SUMMON) { + // An effect's payload is the sequence being edited, so its cues are + // that sequence's. + if ($this->currentCutscene?->type !== $type) { return []; } diff --git a/src/Validation/CutsceneValidation.php b/src/Validation/CutsceneValidation.php index 59fa13b..ac72597 100644 --- a/src/Validation/CutsceneValidation.php +++ b/src/Validation/CutsceneValidation.php @@ -59,7 +59,9 @@ protected function checkCutscenes(ProjectWorkspace $workspace): array $issues[] = Issue::error( sprintf('%s %s', $type->noun(), basename($finding['folder'])), $finding['message'], - sprintf('A %s is one folder holding .data.php and %s, named after its folder.', $type->noun(), $type->partnerSuffix()), + $type->hasDataFile() + ? sprintf('A %s is one folder holding .data.php and %s, named after its folder.', $type->noun(), $type->partnerSuffix()) + : sprintf('An %s is one folder holding %s, named after its folder.', $type->noun(), $type->partnerSuffix()), ); } @@ -90,6 +92,18 @@ protected function checkCutsceneAsset(ProjectWorkspace $workspace, CutsceneAsset return []; } + if ($asset->type === CutsceneType::EFFECT) { + // An effect's id is its folder alone. Where it is used, and so in + // which context it must play, the effect validation checks. + try { + $asset->hydrate(); + } catch (Throwable $throwable) { + return [Issue::error($where, $throwable->getMessage(), 'Fix the timeline until each presentation plays in battle or on the field.')]; + } + + return []; + } + $data = $asset->data(); $declared = trim(strval($data['id'] ?? '')); diff --git a/tests/Support/CutsceneFixtures.php b/tests/Support/CutsceneFixtures.php index 906e16d..e6a0e71 100644 --- a/tests/Support/CutsceneFixtures.php +++ b/tests/Support/CutsceneFixtures.php @@ -465,3 +465,82 @@ function require_pair_data(string $literal): array return $value; } + +/** + * An original flat field effect: one glyph track and a sound cue, with a + * comment the editor must keep. + */ +function emberSparkTimeline(): string +{ + return <<<'PHP_SOURCE' + 10, + 'lengthFrames' => 6, + 'tracks' => [[ + 'id' => 'spark', + 'type' => 'glyph', + 'keyframes' => [ + ['frame' => 0, 'duration' => 3, 'content' => '*', 'position' => ['x' => 0, 'y' => 0], 'color' => 'yellow'], + ['frame' => 3, 'duration' => 3, 'content' => '+', 'position' => ['x' => 1, 'y' => 0], 'color' => 'red'], + ], + ]], + 'cues' => [['id' => 'crackle', 'frame' => 0, 'type' => 'playSound', 'payload' => ['sound' => 'crackle']]], +]; +PHP_SOURCE; +} + +/** + * An original battle effect with separate terminal and graphical sequences: + * a glyph stroke in the terminal, a two-frame image stroke on screen. + */ +function duskSlashTimeline(): string +{ + return <<<'PHP_SOURCE' + [ + 'terminal' => [ + 'fps' => 12, + 'lengthFrames' => 4, + 'tracks' => [[ + 'id' => 'stroke', + 'type' => 'glyph', + 'facing' => 'west', + 'keyframes' => [['frame' => 0, 'duration' => 4, 'content' => '/', 'position' => ['x' => 0, 'y' => 0]]], + ]], + ], + 'graphical' => [ + 'fps' => 12, + 'lengthFrames' => 2, + 'restFrame' => 1, + 'tracks' => [[ + 'id' => 'stroke-art', + 'type' => 'image', + 'asset' => 'Graphics/Effects/dusk-slash.png', + 'sheet' => ['columns' => 2, 'rows' => 1], + 'facing' => 'west', + 'keyframes' => [['frame' => 0, 'sourceFrame' => 0], ['frame' => 1, 'sourceFrame' => 1]], + ]], + ], + ], +]; +PHP_SOURCE; +} + +/** A project holding the two original effects under assets/Animations. */ +function effectProject(): string +{ + $root = cutsceneProject(); + foreach (['ember-spark' => emberSparkTimeline(), 'dusk-slash' => duskSlashTimeline()] as $id => $source) { + @mkdir($root . '/assets/Animations/' . $id, 0o777, true); + file_put_contents($root . '/assets/Animations/' . $id . '/' . $id . '.timeline.php', $source); + } + @mkdir($root . '/assets/Graphics/Effects', 0o777, true); + writeTilesetTestPng($root . '/assets/Graphics/Effects/dusk-slash.png', 32, 16); + + return $root; +} diff --git a/tests/Unit/Cutscenes/EffectTimelineAssetTest.php b/tests/Unit/Cutscenes/EffectTimelineAssetTest.php new file mode 100644 index 0000000..7b8427c --- /dev/null +++ b/tests/Unit/Cutscenes/EffectTimelineAssetTest.php @@ -0,0 +1,134 @@ +find(CutsceneType::EFFECT, 'ember-spark'); + + expect($library->ids(CutsceneType::EFFECT))->toBe(['dusk-slash', 'ember-spark']) + ->and($ember?->paths())->toBe([effectPath($root, 'ember-spark')]) + ->and($ember?->isEditable())->toBeTrue() + ->and($ember?->name())->toBe('ember-spark') + ->and($ember?->payload()['id'])->toBe('ember-spark') + ->and($ember?->payload()['tracks'][0]['id'])->toBe('spark') + ->and($ember?->hasPresentations())->toBeFalse() + ->and(array_column($library->issues(CutsceneType::EFFECT), 'folder'))->not->toContain('ember-spark'); +}); + +it('edits a flat effect in place, keeping its comment and never writing an id', function () { + $root = effectProject(); + $library = CutsceneLibrary::fromProject($root); + $ember = $library->find(CutsceneType::EFFECT, 'ember-spark'); + $payload = $ember->payload(); + $payload['fps'] = 8; + $ember->apply($payload); + + expect($ember->save())->toBeTrue(); + + $source = (string) file_get_contents(effectPath($root, 'ember-spark')); + $saved = require effectPath($root, 'ember-spark'); + + expect($source)->toContain('// Ember Spark: a small flicker over its target.') + ->and($saved['fps'])->toBe(8) + ->and($saved)->not->toHaveKey('id') + ->and($saved['cues'][0]['payload'])->toBe(['sound' => 'crackle']) + ->and(is_file($root . '/assets/Animations/ember-spark/ember-spark.data.php'))->toBeFalse() + ->and(new EffectTimelineLibrary($root . '/assets')->load('ember-spark', false, EffectPresentation::TERMINAL)->fps)->toBe(8); +}); + +it('edits one sequence of a terminal-and-graphical effect and leaves the other exactly as written', function () { + $root = effectProject(); + $dusk = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'dusk-slash'); + + expect($dusk->hasPresentations())->toBeTrue() + ->and($dusk->getPresentationView())->toBe(EffectPresentation::TERMINAL) + ->and($dusk->payload()['lengthFrames'])->toBe(4); + + $dusk->selectPresentation(EffectPresentation::GRAPHICAL); + $payload = $dusk->payload(); + + expect($payload['tracks'][0]['type'])->toBe('image'); + + $payload['tracks'][0]['keyframes'][1]['flipX'] = true; + $dusk->apply($payload); + $dusk->save(); + $saved = require effectPath($root, 'dusk-slash'); + $original = eval('?>' . duskSlashTimeline()); + + expect($saved['presentations']['terminal'])->toBe($original['presentations']['terminal']) + ->and($saved['presentations']['graphical']['tracks'][0]['keyframes'][1])->toBe(['frame' => 1, 'sourceFrame' => 1, 'flipX' => true]) + ->and(array_keys($saved['presentations']['graphical']))->toBe(array_keys($original['presentations']['graphical'])); +}); + +it('splits a flat effect into terminal and graphical sequences only when asked', function () { + $root = effectProject(); + $ember = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'ember-spark'); + $flat = require effectPath($root, 'ember-spark'); + + expect($ember->splitIntoPresentations())->toBeTrue() + ->and($ember->splitIntoPresentations())->toBeFalse(); + + $ember->save(); + $saved = require effectPath($root, 'ember-spark'); + + expect($saved)->toBe(['presentations' => ['terminal' => $flat, 'graphical' => $flat]]); +}); + +it('creates a new effect as one valid timeline, and the Engine refuses an unplayable save', function () { + $root = effectProject(); + $library = CutsceneLibrary::fromProject($root); + $id = $library->freeId(CutsceneType::EFFECT, 'Smoke.Puff'); + $blank = Ichiloto\Editor\Database\RecordSchemaCatalog::effects()->blank; + $created = CutsceneAsset::create(CutsceneType::EFFECT, $id, $library->rootFor(CutsceneType::EFFECT), [...$blank, 'id' => $id], $root); + + expect($id)->toBe('smoke-puff') + ->and($created->save())->toBeTrue() + ->and(require effectPath($root, 'smoke-puff'))->not->toHaveKey('id') + ->and(new EffectTimelineLibrary($root . '/assets')->findTimelineIds())->toContain('smoke-puff'); + + $broken = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'smoke-puff'); + $payload = $broken->payload(); + $payload['tracks'] = []; + $broken->apply($payload); + + expect(fn() => $broken->save())->toThrow(RuntimeException::class, 'Effect smoke-puff cannot be played') + ->and(require effectPath($root, 'smoke-puff'))->not->toBe([]) + ->and((require effectPath($root, 'smoke-puff'))['tracks'])->not->toBe([]); +}); + +it('deletes only the effect timeline, and reports a folder without one or with a dotted id', function () { + $root = effectProject(); + file_put_contents($root . '/assets/Animations/ember-spark/notes.txt', 'keep me'); + $library = CutsceneLibrary::fromProject($root); + $ember = $library->find(CutsceneType::EFFECT, 'ember-spark'); + $ember->markDeleted(true); + $ember->save(); + + expect(is_file(effectPath($root, 'ember-spark')))->toBeFalse() + ->and(is_file($root . '/assets/Animations/ember-spark/notes.txt'))->toBeTrue(); + + @mkdir($root . '/assets/Animations/empty-effect', 0o777, true); + @mkdir($root . '/assets/Animations/old.style', 0o777, true); + file_put_contents(effectPath($root, 'old.style'), emberSparkTimeline()); + $messages = array_column(CutsceneLibrary::fromProject($root)->issues(CutsceneType::EFFECT), 'message'); + + expect(implode("\n", $messages))->toContain('Folder "empty-effect" is missing empty-effect.timeline.php; an effect is its timeline file.') + ->and(implode("\n", $messages))->toContain('The folder name "old.style" is not a stable id (lowercase letters, digits, "_" and "-").'); +}); From f2ba2c427937ee65812cdc36b55dcbe6ad51503c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 06:16:03 +0200 Subject: [PATCH 107/344] feat(cutscenes): author and preview standalone effect timelines The Cutscenes screen's Effect type now edits effects as a workspace: grouped Identity, Sequence, Timing, Impact (battle) and Timeline rows over the keys the Engine's effect library reads. A flat effect stays flat until the author chooses separate sequences; an effect with presentations is edited one sequence at a time and the other is kept as written. - Undo snapshots the asset's whole edit state (both files, the sequence shown, deletion), so undo returns to the sequence an edit was made in and a split can be undone back to the flat file. - Deleting an effect names everything that plays it: battle animations, field presentation, map field effects, tileset pieces, and the field_animation commands of map events, event scripts and cinematics (EffectValidator::findUses and findScriptUses). - The preview plays the edited sequence through the Engine's shared EffectPlaybackSession and EffectSegmentComposition, anchored to a caster and a target as battle or the field places it, turns a stroke with a facing as the battle does (B switches battle or field, D the caster's side), and describes the images, flashes and shakes a terminal cannot draw. SummonPreviewSession becomes TimelinePreviewSession over any compiled timeline; summons keep their behaviour. Changed behaviour: - Timeline rows inserted with Shift+O take the blank their schema list owns instead of hard-coded ones. For summons a new cue is now showMessage (as the record pane already added) instead of applyEffect, and a new keyframe holds '*' content as the record pane's does. - A keyframe inserted after another now starts where that one ends instead of at frame 0, which the Engine refuses for image tracks. - An empty choice of an option field marked removeWhenEmpty now removes the key instead of writing ''. Effect track Facing is the only field offering '', so choosing it leaves a stroke undirected. --- README.md | 2 +- docs/guides/README.md | 5 +- docs/manual.md | 78 ++++++- docs/roadmap.md | 26 ++- src/Cutscenes/CutsceneAsset.php | 32 +++ src/Cutscenes/CutsceneOutline.php | 42 +++- src/Cutscenes/CutsceneType.php | 24 ++ src/Cutscenes/Editing/CutsceneOutlinePane.php | 57 ++++- src/Cutscenes/Editing/CutscenePreviewPane.php | 184 +++++++++++---- src/Cutscenes/Editing/CutscenesWorkspace.php | 161 +++++++++++-- src/Cutscenes/Preview/EffectPreviewStage.php | 219 +++++++++++++++++ ...Session.php => TimelinePreviewSession.php} | 42 ++-- src/Database/ProjectRecordDatabase.php | 6 + src/Validation/EffectValidator.php | 145 ++++++++---- .../Unit/Cutscenes/CutscenesWorkspaceTest.php | 6 +- .../Cutscenes/EffectTimelineAuthoringTest.php | 221 ++++++++++++++++++ .../LastLegendSummonVerificationTest.php | 55 ++++- 17 files changed, 1137 insertions(+), 168 deletions(-) create mode 100644 src/Cutscenes/Preview/EffectPreviewStage.php rename src/Cutscenes/Preview/{SummonPreviewSession.php => TimelinePreviewSession.php} (88%) create mode 100644 tests/Unit/Cutscenes/EffectTimelineAuthoringTest.php diff --git a/README.md b/README.md index e99cd42..d6f5517 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ That frame is the editor rendering itself at 100x24 — the manual's copy of it - **NPCs** — first-class map NPCs (`F3`): placement, sprites and directional sprites, fixed or bounded wander, visibility conditions, dialogue with conditional variants, inline scripts, and completion writes. - **Events** — the engine's trigger types placed and configured on the canvas: Dialogue, Transfer Player, Shop, Sleep, Chest, Story Script, and Cinematic. - **Database** (`Ctrl+D` / `F2`) — actors, classes, skills, quests, animations, states, troops, skits, event scripts, terms and system settings authored; items, weapons, armors and enemies browsed with the reason they are read-only stated. -- **Cutscenes** (`F4`) — cinematic and summon cutscenes as paired-file assets: metadata, cast, a nested command tree with parallel lanes, skip policy and finalizer, summon tracks, keyframes and cues — previewed through the engine's own interpreter and playback session before a byte is written. +- **Cutscenes** (`F4`) — cinematic and summon cutscenes as paired-file assets: metadata, cast, a nested command tree with parallel lanes, skip policy and finalizer, summon tracks, keyframes and cues, and standalone effect timelines under `assets/Animations` — previewed through the engine's own interpreter and playback session before a byte is written. - **Safety** — everything is undoable and identity-pinned; dirty state is tracked by content, so undoing back to the saved state is clean again; saves are atomic and pair-aware; references are picked from what the project actually has, never spelled from memory. - **Validation** — one `ProjectValidator` behind the editor and `ichiloto validate`, judging content by what the engine will actually do with it. - **Playtest** (`Ctrl+T`) — the real game, launched from the cursor against a throwaway overlay, so a playtest can never write into the project. diff --git a/docs/guides/README.md b/docs/guides/README.md index fdaece8..5764092 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -29,8 +29,9 @@ Read these in order the first time: scripts, and completion writes. - Place and configure the event types: Dialogue, Transfer Player, Shop, Sleep, Chest, Story Script, and Cinematic. -- Author cinematic and summon cutscenes on the Cutscenes screen (`F4`), and - preview both through the engine itself without writing a file. +- Author cinematic and summon cutscenes and standalone effect timelines on + the Cutscenes screen (`F4`), and preview each through the engine itself + without writing a file. - Author actors, classes, skills, quests, animations, states, troops, skits, event scripts, terms, and system settings. - Browse items, weapons, armors, and enemies. diff --git a/docs/manual.md b/docs/manual.md index ec27e18..098fa5e 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1520,10 +1520,11 @@ Unparseable entries are dropped rather than written back as garbage. `F4` opens the Cutscenes screen over the main shell, the way `Ctrl+D` opens the Database; `F4` or `Esc` closes it, `Ctrl+D` from inside goes straight -across to the Database, and `Ctrl+P` offers `Cutscenes: Cinematic` and -`Cutscenes: Summon`. Five panes: **Types** (Cinematic or Summon), the asset -**list**, the **record pane** (the asset's fields, grouped), the **Command -Tree** (for a summon, the **Timeline**) and the **Preview**. `Tab` and +across to the Database, and `Ctrl+P` offers `Cutscenes: Cinematic`, +`Cutscenes: Summon` and `Cutscenes: Effect`. Five panes: **Types** +(Cinematic, Summon or Effect), the asset **list**, the **record pane** (the +asset's fields, grouped), the **Command Tree** (for a summon or an effect, +the **Timeline**) and the **Preview**. `Tab` and `Shift+Tab` cycle the panes; `/` filters the list; `Shift+A` creates, `Shift+D` duplicates, `Delete` removes (with the places that reference the asset named first); `Ctrl+S` saves the selected asset, `Ctrl+A` saves every @@ -1537,6 +1538,7 @@ Four things look alike and are not. Keep them apart: | Common Event | a reusable command list, called by id from maps and cinematics | Database › Common Events | | Cinematic Cutscene | a staged story sequence: cast, camera, parallel lanes, skip, finalizer | Cutscenes › Cinematic | | Summon Cutscene | a frame-driven battle presentation: tracks, keyframes, cues | Cutscenes › Summon | +| Effect | an effect timeline battle animations, the field and cinematics play | Cutscenes › Effect | | Skit | an optional conversation overlay | Database › Skits | | Animation | a reusable visual asset a command plays | Database › Animations | @@ -1549,9 +1551,12 @@ assets/Cutscenes/Cinematics//.data.php assets/Cutscenes/Cinematics//.script.php assets/Cutscenes/Summons//.data.php assets/Cutscenes/Summons//.timeline.php +assets/Animations//.timeline.php ``` -The stable id *is* the folder name; the display name is a field. A new +An effect is its timeline file alone, exactly as the engine's effect library +reads it: no data file, and an id of lowercase letters, digits, `_` and `-` +(no dots). The stable id *is* the folder name; the display name is a field. A new asset's id is yours to choose until its first save makes the folder; after that it is read-only (duplicate under the new id and delete the old one to migrate). The list shows `*` for unsaved work and a folder the engine cannot @@ -1815,6 +1820,69 @@ open to all), a story-locked summon cannot be a starting assignment, each id appears once, and an exclusive summon has at most one starting holder across the cast. +### Effects + +An effect is edited like a summon's timeline, over the keys the engine's +effect library reads and nothing else. The record pane groups **Identity** +(the id), **Sequence**, **Timing** (FPS, length in frames, playback `once` +or `loop`, loop from, rest frame), **Impact (battle)** (when a battle +command's result lands: at the `end`, a `frame` or a `cue`) and +**Timeline** (the Tracks and Cues frames). Tracks are `glyph`, `text`, +`image`, `flash` or `shake`, each with its `Presentation` (`all`, +`terminal` or `graphical`), an anchor (`target`, `caster` or `screen`) and, +for a battle stroke drawn one way, `Facing` (`west` or `east`; the empty +choice removes it, and the stroke stays as drawn). An image track names its +PNG through the asset picker, its sheet columns and rows, its cell size and +its depth; its keyframes give the sheet frame, the position and, in battle, +`Flip Horizontally` and `Flip Vertically` for an authored reverse stroke. +Glyph and text keyframes give frame, duration, position `x` and `y`, +content (the multiline editor), asset id, color, visibility, z-index and a +payload. Cues are `playSound` on the field and any battle cue in battle. +Impact timing, facing, flips, flashes and shakes are battle-only; +validation names the place that uses an effect where they are refused. + +**Sequence** says how the effect is drawn. A flat effect has one sequence +for the terminal and graphical renderers alike, and stays flat unless you +choose `separate`, which gives it a terminal and a graphical sequence, each +a copy of the flat one (undo puts the flat file back). An effect with +separate sequences is edited one at a time: choose `terminal` or +`graphical`, and every row, the Timeline pane and the preview are that +sequence's; the other is kept exactly as written. Undo returns to the +sequence an edit was made in. The TUI has no graphical workflow: an image is +chosen by path and its frames by number, and pictures belong to the GUI +editor. + +`Shift+O` on a Timeline row inserts the blank the effect's own lists take: +an image keyframe after an image keyframe, starting where the selected one +ends, and a track whose keyframe has a position `x` and `y`. A save is +compiled by the engine for battle and for the field, for each sequence, and +refused before anything is written unless every sequence plays in at least +one of them. `Delete` names everything that plays the effect first: battle +animations, the field presentation's cues and action prompt, maps' field +effects, tileset pieces, and the `field_animation` commands of map events, +event scripts and cinematics. + +#### Effect preview + +`Space` on the Preview pane compiles the sequence being edited, unsaved, +and plays it through the engine's shared effect playhead, with the summon +preview's keys (step, keyframe boundaries, `Home` / `End`, speed, `R`, `L`, +`X`); `O` loops, and an effect authored to loop starts looping, from its +loop frame. The frame is drawn as a terminal consumer places it: through the +engine's composition for that presentation, glyph and text at their anchor, +`C` marking the caster and `T` the target (`screen` draws from the top +left). Beside it, the context and what the terminal cannot draw: image +frames with their flips, flashes and shakes, each with its subject. + +| Key (Preview focused, effect) | Action | +| --- | --- | +| `B` | Play as battle or as the field compiles it (recompiles; a refusal is reported) | +| `D` | Put the battle caster on the other side | + +The preview starts as battle when a battle animation uses the effect or +nothing on the field does. In battle a stroke with a facing is turned toward +its target exactly as the battle turns it, so `D` shows both directions. + ### Validation `ichiloto validate` and the editor's validation cover both forms: missing or diff --git a/docs/roadmap.md b/docs/roadmap.md index 5c145af..3e2d5f0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -171,19 +171,24 @@ Authoring requirements: behaviour; it gains no graphical workflow. Rich graphical sequence authoring (previews, image track timing) belongs to the GUI editor. -Status, 2026-10: validation and references are in place; standalone authoring -is not. Validation compiles every effect a project uses (battle animations, -field presentation, map field effects, tileset pieces, and the effect a +Status, 2026-10: validation, references and TUI authoring are in place. +Validation compiles every effect a project uses (battle animations, field +presentation, map field effects, tileset pieces, and the effect a `field_animation` names) through the Engine for both presentations, and reports an effect a waiting `field_animation` plays that loops. Cinematic `field_animation` commands pick an effect by its stable id, from the timelines the Engine lists. Summon timelines choose each track's -`presentation`. There is still no TUI surface to create, edit or preview a -standalone effect timeline under `assets/Animations`; that remains the gap. -It must reuse the summon editor's timeline editing, the shared Engine -compiler and playhead, and the existing source-preserving paths, add no -second timeline format, and refuse unsupported source before writing. It is -not started: it waits on Andrew's go. +`presentation`. The Cutscenes screen's Effect type creates, edits, deletes +and previews standalone effect timelines under `assets/Animations` (Andrew's +go, 2026-10-03): the summon editor's record pane, Timeline pane and undo, +the existing source-preserving writer, no second format, and every save +compiled by the Engine before it is written. A flat effect stays flat +unless the author separates it; an effect with `presentations` is edited one +sequence at a time and the other is kept as written. The preview plays the +edited sequence through the Engine's shared playhead and composition, as +battle or the field places it, with the caster on either side. Remaining +for the GUI editor: drawing image frames, sheet and cell picking by eye, and +image timing; the TUI only names them. Battle effect orientation and weapon roles (Engine contract in progress, 2026-10): a battle track may declare `facing` (`east` or `west`), the @@ -197,7 +202,8 @@ in the animation database's `roles`. Authoring requirements: - `facing`, `flipX` and `flipY` round-trip source-preserving with every other timeline field; validation compiles them through the Engine for both presentations, as it does every effect. The TUI edits them as ordinary - fields when standalone timeline authoring exists; no graphical workflow. + fields in effect timeline authoring, and its preview turns a stroke as + the battle does; no graphical workflow. - The animation database offers `roles` from the Engine's supported role list, refuses a role bound twice before saving, and reports a weapon type the project's weapons use that no animation is bound to. diff --git a/src/Cutscenes/CutsceneAsset.php b/src/Cutscenes/CutsceneAsset.php index 4a8c3cf..d8239bc 100644 --- a/src/Cutscenes/CutsceneAsset.php +++ b/src/Cutscenes/CutsceneAsset.php @@ -396,6 +396,38 @@ public function splitIntoPresentations(): bool return true; } + /** + * Returns everything an edit can change, for undo and redo: both files' + * arrays, the sequence being edited and whether the asset is deleted. + * The payload alone is not enough: it is one sequence of an effect with + * two, and a split leaves it unchanged. + * + * @return array{data: array, partner: array, view: EffectPresentation, deleted: bool} + */ + public function captureEditState(): array + { + return ['data' => $this->data, 'partner' => $this->partner, 'view' => $this->presentationView, 'deleted' => $this->isDeleted]; + } + + /** + * Puts the asset back to a state `captureEditState` returned. + * + * @param array{data: array, partner: array, view: EffectPresentation, deleted: bool} $state + */ + public function restoreEditState(array $state): void + { + $this->presentationView = $state['view']; + $this->markDeleted($state['deleted']); + + if ($this->readOnlyReason !== null || ($state['data'] === $this->data && $state['partner'] === $this->partner)) { + return; + } + + $this->data = $state['data']; + $this->partner = $state['partner']; + $this->touchState(); + } + /** * @param array $timeline * @return array diff --git a/src/Cutscenes/CutsceneOutline.php b/src/Cutscenes/CutsceneOutline.php index b35b7c4..d5c62d4 100644 --- a/src/Cutscenes/CutsceneOutline.php +++ b/src/Cutscenes/CutsceneOutline.php @@ -62,7 +62,7 @@ public static function of(CutsceneType $type, array $payload): self $key = 'tracks.' . $index; $outline->rows[] = [ 'depth' => 1, - 'text' => sprintf('%s %s · %d keyframe%s', strval($track['type'] ?? 'glyph'), strval($track['id'] ?? ('track ' . ($index + 1))), count($keyframes), count($keyframes) === 1 ? '' : 's'), + 'text' => sprintf('%s %s · %d keyframe%s%s', strval($track['type'] ?? 'glyph'), strval($track['id'] ?? ('track ' . ($index + 1))), count($keyframes), count($keyframes) === 1 ? '' : 's', self::describeTrackScope($track)), 'frame' => [CutsceneSchemas::TRACKS_KEY], 'index' => $index, 'key' => $key, @@ -81,7 +81,12 @@ public static function of(CutsceneType $type, array $payload): self $content = is_string($keyframe['content'] ?? null) ? explode("\n", $keyframe['content'])[0] : ''; $outline->rows[] = [ 'depth' => 2, - 'text' => sprintf('f%d +%d %s', $frame, $duration, $content !== '' ? $content : (isset($keyframe['assetId']) ? 'asset ' . strval($keyframe['assetId']) : '(empty)')), + 'text' => sprintf('f%d +%d %s', $frame, $duration, match (true) { + $content !== '' => $content, + isset($keyframe['assetId']) => 'asset ' . strval($keyframe['assetId']), + isset($keyframe['sourceFrame']) => self::describeImageKeyframe($keyframe), + default => '(empty)', + }), 'frame' => [CutsceneSchemas::TRACKS_KEY], 'index' => $index, 'key' => $key . '.keyframes.' . $keyframeIndex, @@ -115,6 +120,39 @@ public static function of(CutsceneType $type, array $payload): self return $outline; } + /** + * Returns where a track plays and which way it is drawn, when authored: + * one presentation only, or a battle stroke's facing. + * + * @param array $track + */ + private static function describeTrackScope(array $track): string + { + $scope = ''; + + if (is_string($track['presentation'] ?? null) && $track['presentation'] !== '') { + $scope .= ' · ' . $track['presentation'] . ' only'; + } + + if (is_string($track['facing'] ?? null) && $track['facing'] !== '') { + $scope .= ' · faces ' . $track['facing']; + } + + return $scope; + } + + /** + * Returns an image keyframe's sheet frame and flips. + * + * @param array $keyframe + */ + private static function describeImageKeyframe(array $keyframe): string + { + $flips = array_keys(array_filter(['flipX' => $keyframe['flipX'] ?? false, 'flipY' => $keyframe['flipY'] ?? false], static fn(mixed $flip): bool => $flip === true)); + + return sprintf('sheet frame %d', intval($keyframe['sourceFrame'])) . ($flips === [] ? '' : ' ' . implode(' ', $flips)); + } + /** * Walks a command list, one row per command, recursing into blocks. * diff --git a/src/Cutscenes/CutsceneType.php b/src/Cutscenes/CutsceneType.php index b4425b5..2419450 100644 --- a/src/Cutscenes/CutsceneType.php +++ b/src/Cutscenes/CutsceneType.php @@ -84,6 +84,30 @@ public function label(): string }; } + /** + * Returns the one-line description shown with the type's category. + */ + public function describeCategory(): string + { + return match ($this) { + self::CINEMATIC => 'Staged story sequences run by the event interpreter.', + self::SUMMON => 'Frame-driven battle presentations built by the summon compiler.', + self::EFFECT => 'Effect timelines played by battle animations, the field and cinematics.', + }; + } + + /** + * Returns what an asset of this type is, for a type with none yet. + */ + public function describeAsset(): string + { + return match ($this) { + self::CINEMATIC => 'A cinematic is a staged story scene: a command tree the engine runs on the field, with its own cast, camera, skip policy and finalizer.', + self::SUMMON => 'A summon is a frame-driven battle presentation: tracks, keyframes and cues the engine compiles and plays.', + self::EFFECT => 'An effect is one timeline file under assets/Animations: glyph, text and image tracks, keyframes and cues, played by battle animations, the field and cinematics. It may hold one sequence for every renderer, or separate terminal and graphical sequences.', + }; + } + /** * Whether the asset has an `.data.php` beside its partner file. An * effect is its timeline alone, as the Engine reads it. diff --git a/src/Cutscenes/Editing/CutsceneOutlinePane.php b/src/Cutscenes/Editing/CutsceneOutlinePane.php index f17ea66..87a62ba 100644 --- a/src/Cutscenes/Editing/CutsceneOutlinePane.php +++ b/src/Cutscenes/Editing/CutsceneOutlinePane.php @@ -7,6 +7,7 @@ use Atatusoft\Termutil\IO\Enumerations\Color; use Ichiloto\Editor\Cutscenes\CutsceneOutline; use Ichiloto\Editor\Cutscenes\CutsceneType; +use Ichiloto\Editor\Database\CutsceneSchemas; use Ichiloto\Editor\ListNavigation; use Ichiloto\Editor\EditorWindow; use Ichiloto\Editor\Status\StatusLevel; @@ -224,12 +225,15 @@ private function insertCutsceneTreeRow(): void return; } - $blank = match ($entry['row']['kind']) { - 'track' => ['type' => 'glyph', 'id' => 'track', 'keyframes' => []], - 'keyframe' => ['frame' => 0, 'duration' => 1, 'content' => '', 'position' => [0, 0]], - 'cue' => ['id' => 'cue', 'frame' => 0, 'type' => 'applyEffect'], - default => ['type' => 'wait', 'seconds' => 0.5], - }; + $blank = $entry['row']['kind'] === 'command' + ? ['type' => 'wait', 'seconds' => 0.5] + : $this->findTimelineBlank($entry['row']['kind'], $entry['payload'], $entry['listPath']); + + if ($blank === null) { + $this->setStatus(sprintf('This %s list takes no new entries here.', $entry['row']['kind']), StatusLevel::WARN); + + return; + } $list = CutsceneOutline::valueAt($entry['payload'], $entry['listPath']); $list = is_array($list) ? array_values($list) : []; $index = $entry['index']; @@ -238,6 +242,12 @@ private function insertCutsceneTreeRow(): void $blank['id'] = $this->freeOutlineId($list, $blank['id']); } + if ($entry['row']['kind'] === 'keyframe' && is_array($list[$index] ?? null)) { + // After a keyframe is after it in time: the new one starts where + // the selected one ends, never over it. + $blank['frame'] = intval($list[$index]['frame'] ?? 0) + max(1, intval($list[$index]['duration'] ?? 1)); + } + array_splice($list, $index + 1, 0, [$blank]); $targetKey = $this->replaceKeyIndex($entry['row']['key'], $index + 1); @@ -250,6 +260,35 @@ private function insertCutsceneTreeRow(): void } } + /** + * Returns the blank a timeline's list takes, as its schema owns it: a + * summon's and an effect's tracks, keyframes and cues differ, and an + * effect's image keyframes differ from its glyph ones. + * + * @param array $payload + * @param array $listPath The list the entry goes into. + * @return array|null + */ + private function findTimelineBlank(string $kind, array $payload, array $listPath): ?array + { + $asset = $this->selectedCutscene(); + $schema = $asset === null ? null : $this->cutsceneLibrary()?->schemaFor($asset->type); + + if ($schema === null) { + return null; + } + + $list = match ($kind) { + 'track' => $schema->commandLists[CutsceneSchemas::TRACKS_KEY] ?? null, + 'cue' => $schema->commandLists[CutsceneSchemas::CUES_KEY] ?? null, + 'keyframe' => ($schema->commandLists[CutsceneSchemas::TRACKS_KEY] ?? null) + ?->nestedListFor((array) CutsceneOutline::valueAt($payload, array_slice($listPath, 0, -1))), + default => null, + }; + + return $list?->blank; + } + /** * Inserts a copy of the selected entry right after it. */ @@ -610,8 +649,8 @@ private function createCutsceneTreeWindow(array $layout): EditorWindow $failedKey = $preview?->failure()['key'] ?? null; $selected = $this->selectedCutscene(); - if ($selected?->type === CutsceneType::SUMMON) { - $activeKeys = $this->activeSummonKeyframeKeys($selected); + if ($selected !== null && $selected->type !== CutsceneType::CINEMATIC) { + $activeKeys = $this->findActiveTimelineKeys($selected); } foreach ($rows as $position => $row) { @@ -634,7 +673,7 @@ private function createCutsceneTreeWindow(array $layout): EditorWindow $contentHeight = max(1, $layout['treeHeight'] - 2); $scroll = max(0, min(max(0, count($lines) - $contentHeight), $this->cutsceneTreeCursor - intdiv($contentHeight, 2))); $asset = $this->selectedCutscene(); - $title = $asset?->type === CutsceneType::SUMMON ? 'Timeline' : 'Command Tree'; + $title = $asset === null || $asset->type === CutsceneType::CINEMATIC ? 'Command Tree' : 'Timeline'; return new EditorWindow( title: $title, diff --git a/src/Cutscenes/Editing/CutscenePreviewPane.php b/src/Cutscenes/Editing/CutscenePreviewPane.php index b08c2c7..4888082 100644 --- a/src/Cutscenes/Editing/CutscenePreviewPane.php +++ b/src/Cutscenes/Editing/CutscenePreviewPane.php @@ -8,8 +8,9 @@ use Ichiloto\Editor\Cutscenes\CutsceneLaneOverview; use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewSession; +use Ichiloto\Editor\Cutscenes\Preview\EffectPreviewStage; use Ichiloto\Editor\Cutscenes\Preview\PreviewSnapshot; -use Ichiloto\Editor\Cutscenes\Preview\SummonPreviewSession; +use Ichiloto\Editor\Cutscenes\Preview\TimelinePreviewSession; use Ichiloto\Editor\EditorWindow; use Ichiloto\Editor\Playtest\PlaytestLauncher; use Ichiloto\Editor\Playtest\PlaytestOverlay; @@ -17,6 +18,8 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Status\StatusLevel; use Ichiloto\Editor\UI\CutscenesScreen; +use Ichiloto\Editor\Validation\EffectValidator; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Throwable; @@ -37,7 +40,7 @@ trait CutscenePreviewPane { private ?CinematicPreviewSession $cinematicPreview = null; - private ?SummonPreviewSession $summonPreview = null; + private ?TimelinePreviewSession $timelinePreview = null; /** One of: stage, lanes, log, compare, overview. */ private string $cutscenePreviewView = 'stage'; private float $cutscenePreviewLastTickAt = 0.0; @@ -51,6 +54,10 @@ trait CutscenePreviewPane private ?EffectTimelineLibrary $cutsceneEffectLibrary = null; /** The asset payload the running preview was built from. */ private string $cinematicPreviewFingerprint = ''; + /** @var array Effect id => previewed as battle plays it (else as the field does), once the author chose. */ + private array $effectPreviewInBattle = []; + /** Which side the caster stands on in an effect's battle preview. */ + private bool $isEffectPreviewCasterOnLeft = true; /** * Whether the preview pane is taller than its resting strip: while a @@ -59,7 +66,7 @@ trait CutscenePreviewPane private function isCutscenePreviewExpanded(): bool { return $this->cinematicPreview !== null - || $this->summonPreview !== null + || $this->timelinePreview !== null || $this->cutsceneFocus === CutscenesScreen::PANE_PREVIEW || $this->cutscenePreviewView !== 'stage'; } @@ -95,8 +102,8 @@ private function moveCutscenePreview(int $deltaX, int $deltaY): void */ private function handleCutscenePreviewInput(string $input): bool { - if ($this->selectedCutscene()?->type === CutsceneType::SUMMON) { - return $this->handleSummonPreviewInput($input); + if ($this->selectedCutscene()?->type !== null && $this->selectedCutscene()?->type !== CutsceneType::CINEMATIC) { + return $this->handleTimelinePreviewInput($input); } $preview = $this->cinematicPreview; @@ -202,7 +209,7 @@ private function startCinematicPreview(bool $play): void } if ($asset->type !== CutsceneType::CINEMATIC) { - $this->startSummonPreview(play: $play); + $this->startTimelinePreview(play: $play); return; } @@ -504,7 +511,7 @@ private function tickCutscenePreview(): void return; } - $this->tickSummonPreview(); + $this->tickTimelinePreview(); $preview = $this->cinematicPreview; if ($preview === null || ! $preview->isPlaying()) { @@ -537,22 +544,48 @@ private function disposeCinematicPreview(): void { $this->cinematicPreview?->dispose(); $this->cinematicPreview = null; - $this->summonPreview = null; + $this->timelinePreview = null; } - // -- Summons ------------------------------------------------------------- + // -- Summons and effects ------------------------------------------------- /** - * Handles the preview keys while a summon is selected. + * Handles the preview keys while a summon or an effect is selected. An + * effect also takes B, playing it as battle or as the field compiles it, + * and D, putting its battle caster on the other side. */ - private function handleSummonPreviewInput(string $input): bool + private function handleTimelinePreviewInput(string $input): bool { - $preview = $this->summonPreview; + $preview = $this->timelinePreview; $lower = strtolower($input); + $asset = $this->selectedCutscene(); + + if ($asset?->type === CutsceneType::EFFECT && ($input === 'b' || $input === 'd')) { + if ($input === 'b') { + $this->effectPreviewInBattle[$asset->id] = ! $this->isEffectPreviewInBattle($asset); + } elseif ($this->isEffectPreviewInBattle($asset)) { + $this->isEffectPreviewCasterOnLeft = ! $this->isEffectPreviewCasterOnLeft; + } + + if ($input === 'b') { + // Battle and the field compile the timeline differently, and + // either may refuse it: compile now and say so. + $this->startTimelinePreview(play: $preview?->isPlaying() ?? false); + + return true; + } + + $this->setStatus($this->isEffectPreviewInBattle($asset) + ? sprintf('Caster on the %s.', $this->isEffectPreviewCasterOnLeft ? 'left' : 'right') + : 'The field has no caster; B previews battle.', StatusLevel::INFO); + $this->renderDatabasePanes(['preview', 'tree']); + + return true; + } if ($input === ' ' || $input === "\n" || $input === "\r") { if ($preview === null) { - $this->startSummonPreview(play: true); + $this->startTimelinePreview(play: true); } elseif ($preview->isPlaying()) { $preview->pause(); $this->setStatus(sprintf('Paused at frame %d.', $preview->currentFrame())); @@ -572,8 +605,8 @@ private function handleSummonPreviewInput(string $input): bool if ($input === '+' || $input === '-' || $lower === 'o' || $isHome || $isEnd) { if ($preview === null) { - $this->startSummonPreview(play: false); - $preview = $this->summonPreview; + $this->startTimelinePreview(play: false); + $preview = $this->timelinePreview; } if ($preview === null) { @@ -600,8 +633,8 @@ private function handleSummonPreviewInput(string $input): bool if ($input === '.' || $input === ',' || $input === '>' || $input === '<' || $lower === 'r' || $lower === 'l' || $lower === 'x') { if ($preview === null) { - $this->startSummonPreview(play: false); - $preview = $this->summonPreview; + $this->startTimelinePreview(play: false); + $preview = $this->timelinePreview; } if ($preview === null) { @@ -615,11 +648,11 @@ private function handleSummonPreviewInput(string $input): bool $input === '<' => $preview->seekBoundary(-1), $lower === 'r' => $preview->restart(), $lower === 'l' => $this->cutscenePreviewView = $this->cutscenePreviewView === 'timeline' ? 'stage' : 'timeline', - default => $this->summonPreview = null, + default => $this->timelinePreview = null, }; if ($lower === 'x') { - $this->setStatus('Summon preview closed.'); + $this->setStatus('Preview closed.'); } elseif ($lower === 'l') { $this->setStatus(sprintf('Preview view: %s.', $this->cutscenePreviewView)); } else { @@ -635,49 +668,54 @@ private function handleSummonPreviewInput(string $input): bool } /** - * Compiles the selected summon as it stands and opens the Engine - * playback session over it. + * Compiles the selected summon, or the effect's sequence being edited + * for battle or the field, as it stands, and opens the Engine playback + * session over it. An effect plays as its timeline says, once or + * looping; a summon once until O loops it. */ - private function startSummonPreview(bool $play): void + private function startTimelinePreview(bool $play): void { $asset = $this->selectedCutscene(); - if (! $asset instanceof CutsceneAsset || $asset->type !== CutsceneType::SUMMON) { + if (! $asset instanceof CutsceneAsset || $asset->type === CutsceneType::CINEMATIC) { return; } try { - $compiled = $asset->compiledSummon(); + $compiled = $asset->type === CutsceneType::EFFECT + ? $asset->compiledEffect($asset->getPresentationView() ?? EffectPresentation::TERMINAL, $this->isEffectPreviewInBattle($asset)) + : $asset->compiledSummon(); } catch (Throwable $throwable) { - $this->summonPreview = null; - $this->setErrorStatus($throwable, 'Summon preview'); + $this->timelinePreview = null; + $this->setErrorStatus($throwable, ucfirst($asset->type->noun()) . ' preview'); $this->renderDatabasePanes(['preview']); return; } - $this->summonPreview = new SummonPreviewSession($compiled); + $this->timelinePreview = new TimelinePreviewSession($compiled, $asset->type === CutsceneType::EFFECT ? null : false); $this->cinematicPreviewFingerprint = $this->cutscenePayloadFingerprint($asset); $this->cutscenePreviewLastTickAt = microtime(true); $this->cutscenePreviewScroll = 0; if ($play) { - $this->summonPreview->play(); + $this->timelinePreview->play(); } $this->setStatus(sprintf( - 'Previewing %s: %d frames at %d fps (%s).', + 'Previewing %s: %d frames at %d fps (%s)%s.', $asset->id, - $this->summonPreview->totalFrames(), - $this->summonPreview->fps(), + $this->timelinePreview->totalFrames(), + $this->timelinePreview->fps(), $play ? 'playing' : 'paused', + $asset->type === CutsceneType::EFFECT ? sprintf(', as %s plays it', $this->isEffectPreviewInBattle($asset) ? 'battle' : 'the field') : '', ), StatusLevel::INFO); $this->renderDatabasePanes(['preview', 'tree']); } - private function tickSummonPreview(): void + private function tickTimelinePreview(): void { - $preview = $this->summonPreview; + $preview = $this->timelinePreview; if ($preview === null || ! $preview->isPlaying()) { return; @@ -695,18 +733,46 @@ private function tickSummonPreview(): void $this->renderDatabasePanes(['preview', 'tree']); if ($preview->isCompleted()) { - $this->setStatus(sprintf('Summon preview finished: %d frames, %d cue%s fired.', $preview->totalFrames(), count($preview->cueLog()), count($preview->cueLog()) === 1 ? '' : 's'), StatusLevel::INFO); + $this->setStatus(sprintf('Preview finished: %d frames, %d cue%s fired.', $preview->totalFrames(), count($preview->cueLog()), count($preview->cueLog()) === 1 ? '' : 's'), StatusLevel::INFO); } } + /** + * Whether the selected effect is previewed as battle plays it: as the + * author last chose, else battle when a battle animation uses it or + * nothing on the field does. + */ + private function isEffectPreviewInBattle(CutsceneAsset $asset): bool + { + if (isset($this->effectPreviewInBattle[$asset->id])) { + return $this->effectPreviewInBattle[$asset->id]; + } + + $uses = $this->workspace instanceof ProjectWorkspace ? (EffectValidator::findUses($this->workspace)[$asset->id] ?? []) : []; + $fieldScripts = $this->workspace instanceof ProjectWorkspace + ? (EffectValidator::findScriptUses($this->workspace, $this->cutsceneLibrary()?->assets(CutsceneType::CINEMATIC) ?? [])[$asset->id] ?? []) + : []; + + return $this->effectPreviewInBattle[$asset->id] = isset($uses['battle']) || (! isset($uses['field']) && $fieldScripts === []); + } + + /** + * The stage an effect's frame is drawn on: its sequence being edited, + * battle or the field, and the caster's side. + */ + private function createEffectPreviewStage(CutsceneAsset $asset): EffectPreviewStage + { + return new EffectPreviewStage($asset->getPresentationView() ?? EffectPresentation::TERMINAL, $this->isEffectPreviewInBattle($asset), $this->isEffectPreviewCasterOnLeft); + } + /** * The keyframe rows active at the playhead, by outline key. * * @return string[] */ - private function activeSummonKeyframeKeys(CutsceneAsset $asset): array + private function findActiveTimelineKeys(CutsceneAsset $asset): array { - $preview = $this->summonPreview; + $preview = $this->timelinePreview; if ($preview === null) { return []; @@ -924,14 +990,21 @@ private function createCutscenePreviewWindow(array $layout): EditorWindow if ($preview !== null) { $stale = $asset !== null && $this->cutscenePayloadFingerprint($asset) !== $this->cinematicPreviewFingerprint; $title = sprintf('Preview · %s · %.1fs%s', $preview->status(), $preview->elapsed(), $stale ? ' · edited since start (R restarts)' : ''); - } elseif ($this->summonPreview !== null) { + } elseif ($this->timelinePreview !== null) { $stale = $asset !== null && $this->cutscenePayloadFingerprint($asset) !== $this->cinematicPreviewFingerprint; - $title = sprintf('Preview · frame %d/%d%s', $this->summonPreview->currentFrame(), $this->summonPreview->totalFrames(), $stale ? ' · edited since start (R restarts)' : ''); + $title = sprintf('Preview · frame %d/%d%s', $this->timelinePreview->currentFrame(), $this->timelinePreview->totalFrames(), $stale ? ' · edited since start (R restarts)' : ''); } elseif ($this->cutscenePreviewView !== 'stage') { $title = 'Preview · ' . $this->cutscenePreviewView; } - $help = $asset?->type === CutsceneType::SUMMON + $help = $asset?->type === CutsceneType::EFFECT + ? $this->fitHelp( + $layout['previewWidth'], + 'Space:Play/Pause . ,:Step < >:Keyframes Home/End +/-:Speed O:Loop B:Battle/Field D:Caster side R:Restart L:Timeline X:Close', + 'Space:Play . ,:Step < >:Keyframes +/-:Speed O:Loop B:Battle/Field D:Side', + 'Space:Play . ,:Step B:Battle/Field', + ) + : ($asset?->type === CutsceneType::SUMMON ? $this->fitHelp( $layout['previewWidth'], 'Space:Play/Pause . ,:Step < >:Keyframes Home/End +/-:Speed O:Loop R:Restart L:Timeline X:Close', @@ -943,11 +1016,11 @@ private function createCutscenePreviewWindow(array $layout): EditorWindow 'Space:Play/Pause .:Step K:Skip R:Restart X:Stop J:Jump C:Compare V:Overview L:View Ctrl+T:Playtest', 'Space:Play .:Step K:Skip R:Restart J:Jump C:Compare L:View', 'Space:Play .:Step K:Skip L:View', - ); + )); $lines = match (true) { $asset === null => [' Select a cutscene to preview it.'], - $asset->type === CutsceneType::SUMMON => $this->summonPreviewLines($asset, $contentWidth, $contentHeight), + $asset->type !== CutsceneType::CINEMATIC => $this->timelinePreviewLines($asset, $contentWidth, $contentHeight), $this->cutscenePreviewView === 'compare' => $this->cutsceneComparisonLines($contentWidth), $this->cutscenePreviewView === 'overview' => $this->cutsceneOverviewLines($asset, $contentWidth), $this->cutscenePreviewView === 'log' => $this->cutscenePreviewLogLines(), @@ -1330,21 +1403,27 @@ private function describeCutsceneStanding(CutsceneAsset $asset): array } /** - * The summon view: the Engine's playhead over the compiled timeline, a - * ruler with the keyframe bars and cues, the frame as the battle field - * would compose it, and the cues the playhead crossed. + * The summon and effect view: the Engine's playhead over the compiled + * timeline, a ruler with the keyframe bars and cues, the frame as the + * battle field would compose it (an effect's anchored to its caster and + * target), and the cues the playhead crossed. * * @return string[] */ - private function summonPreviewLines(CutsceneAsset $asset, int $contentWidth, int $contentHeight): array + private function timelinePreviewLines(CutsceneAsset $asset, int $contentWidth, int $contentHeight): array { - $preview = $this->summonPreview; + $preview = $this->timelinePreview; + $stage = $asset->type === CutsceneType::EFFECT ? $this->createEffectPreviewStage($asset) : null; if ($preview === null) { return [ ...$this->describeCutsceneStanding($asset), + ...($stage === null ? [] : [sprintf(' Plays as %s, the %s sequence.%s', + $stage->forBattle ? 'battle' : 'the field', $stage->presentation->value, + $stage->forBattle ? sprintf(' %s is the caster, %s the target.', EffectPreviewStage::CASTER_MARKER, EffectPreviewStage::TARGET_MARKER) : sprintf(' %s is the target.', EffectPreviewStage::TARGET_MARKER))]), '', - ' Space: play through the Engine session · . , : step · < > : keyframe boundaries · R: restart · L: timeline/stage', + ' Space: play through the Engine session · . , : step · < > : keyframe boundaries · R: restart · L: timeline/stage' + . ($stage === null ? '' : ' · B: battle/field · D: caster side'), ]; } @@ -1367,12 +1446,21 @@ private function summonPreviewLines(CutsceneAsset $asset, int $contentWidth, int $stageWidth = max(20, $contentWidth - ($infoWidth > 0 ? $infoWidth + 1 : 0)); $rulerLines = $preview->rulerLines($stageWidth); $stageHeight = max(4, $contentHeight - count($rulerLines) - 1); - $frame = $preview->frame($stageWidth, $stageHeight); + $frame = $stage?->drawFrame($preview->activeSegments(), $stageWidth, $stageHeight) ?? $preview->frame($stageWidth, $stageHeight); $info = []; if ($infoWidth > 0) { $info[] = sprintf(' %s · frame %d/%d · %.1fs', $preview->isPlaying() ? 'playing' : ($preview->isCompleted() ? 'completed' : 'paused'), $preview->currentFrame(), $preview->totalFrames(), $preview->elapsed()); $info[] = sprintf(' %gx speed · %s', $preview->speed(), $preview->isLooping() ? 'looping' : 'once'); + + if ($stage !== null) { + $info[] = sprintf(' %s · %s sequence', $stage->forBattle ? sprintf('battle, caster %s', $stage->isCasterOnLeft ? 'left' : 'right') : 'field', $stage->presentation->value); + $info[] = ' Not drawn here:'; + + foreach ($stage->describeUndrawn($preview->activeSegments()) ?: ['(nothing)'] as $undrawn) { + $info[] = ' ' . $undrawn; + } + } $info[] = ' Cues here: ' . (implode(', ', array_map(static fn(array $cue): string => strval($cue['id'] ?? '?'), $preview->cuesAt())) ?: '—'); $info[] = ' Fired:'; diff --git a/src/Cutscenes/Editing/CutscenesWorkspace.php b/src/Cutscenes/Editing/CutscenesWorkspace.php index 75b7bf8..96c3fb4 100644 --- a/src/Cutscenes/Editing/CutscenesWorkspace.php +++ b/src/Cutscenes/Editing/CutscenesWorkspace.php @@ -25,6 +25,8 @@ use Ichiloto\Editor\UI\Modal; use Ichiloto\Editor\UI\MultilineTextEditor; use Ichiloto\Editor\UI\SettingsPaneLayout; +use Ichiloto\Editor\Validation\EffectValidator; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Throwable; /** @@ -44,6 +46,11 @@ */ trait CutscenesWorkspace { + /** The row choosing an effect's sequence; no timeline key, so no schema field, has this id. */ + private const string EFFECT_SEQUENCE_FIELD = '@sequence'; + private const string EFFECT_SEQUENCE_SHARED = 'shared'; + private const string EFFECT_SEQUENCE_SEPARATE = 'separate'; + /** * The type whose assets the screen shows. */ @@ -241,11 +248,13 @@ private function cutsceneRecords(): ?ProjectRecordDatabase private function cutsceneCategoryDefinition(): DatabaseCategoryDefinition { return new DatabaseCategoryDefinition( - $this->cutsceneType === CutsceneType::CINEMATIC ? CutsceneSchemas::CINEMATICS_KEY : CutsceneSchemas::SUMMONS_KEY, + match ($this->cutsceneType) { + CutsceneType::CINEMATIC => CutsceneSchemas::CINEMATICS_KEY, + CutsceneType::SUMMON => CutsceneSchemas::SUMMONS_KEY, + CutsceneType::EFFECT => CutsceneSchemas::EFFECTS_KEY, + }, $this->cutsceneType->label(), - $this->cutsceneType === CutsceneType::CINEMATIC - ? 'Staged story sequences run by the event interpreter.' - : 'Frame-driven battle presentations built by the summon compiler.', + $this->cutsceneType->describeCategory(), true, ); } @@ -862,22 +871,29 @@ private function getCutsceneSettingsFields(): array private function groupCutsceneFields(CutsceneAsset $asset, array $fields): array { $heading = static fn(string $title): array => ['label' => $title, 'value' => '', 'editable' => false]; - $sections = $asset->type === CutsceneType::CINEMATIC - ? [ + $sections = match ($asset->type) { + CutsceneType::CINEMATIC => [ 'Identity' => ['id', 'name', 'description', 'version'], 'Staging' => ['startMap', 'presentation.initial', 'presentation.reducedMotion'], 'Script' => ['commandListCommands'], 'Skip' => ['skip.policy', 'checkpoints', 'commandListFinalizer'], 'Metadata' => ['authoring'], - ] - : [ + ], + CutsceneType::EFFECT => [ + 'Identity' => ['id'], + 'Timing' => ['fps', 'lengthFrames', 'playback', 'loopFrom', 'restFrame'], + 'Impact (battle)' => ['effectTiming.mode', 'effectTiming.frame', 'effectTiming.cueId'], + 'Timeline' => ['commandListTracks', 'commandListCues'], + ], + CutsceneType::SUMMON => [ 'Identity' => ['id', 'name', 'description', 'moveName', 'version', 'linkedSummonId', 'linkedActionId', 'tags'], 'Lore' => ['lore', 'element', 'strengths', 'weaknesses', 'attributes', 'authoring'], 'Availability' => ['availability.conditions'], 'Wielders' => ['wielders.mode', 'wielders.roles', 'wielders.characters', 'wielders.tenancy'], 'Playback' => ['playback.defaultSpeed', 'playback.allowSkip', 'playback.loopPreview', 'transitionIn.type', 'transitionIn.durationMs', 'transitionIn.color', 'transitionOut.type', 'transitionOut.durationMs', 'transitionOut.color', 'effectTiming.mode', 'effectTiming.cueId', 'effectTiming.frame', 'targetPresentation.mode', 'targetPresentation.showCasterNameBanner'], 'Timeline' => ['formatVersion', 'fps', 'lengthFrames', 'editor', 'commandListTracks', 'commandListCues'], - ]; + ], + }; $byId = []; $rest = []; @@ -941,6 +957,10 @@ private function groupCutsceneFields(CutsceneAsset $asset, array $fields): array } $grouped = [...$grouped, ...$rows]; + + if ($title === 'Identity' && $asset->type === CutsceneType::EFFECT) { + $grouped = [...$grouped, ...$this->getEffectSequenceFields($asset)]; + } } if ($rest !== []) { @@ -951,6 +971,90 @@ private function groupCutsceneFields(CutsceneAsset $asset, array $fields): array return $grouped; } + /** + * Returns the row that chooses which of an effect's sequences the pane + * edits, and a note on what the other one does. A flat effect's single + * sequence plays for every renderer; choosing separate gives it a + * terminal and a graphical sequence, each a copy of it, undoably. + * + * @return array> + */ + private function getEffectSequenceFields(CutsceneAsset $asset): array + { + $view = $asset->getPresentationView(); + + if ($view === null) { + return [ + [ + 'label' => 'Sequence', + 'value' => 'Shared', + 'options' => [self::EFFECT_SEQUENCE_SHARED, self::EFFECT_SEQUENCE_SEPARATE], + 'field' => self::EFFECT_SEQUENCE_FIELD, + 'hint' => 'separate gives the terminal and graphical renderers a sequence each', + ], + ['label' => ' One sequence plays for the terminal and graphical renderers alike.', 'value' => '', 'editable' => false], + ]; + } + + $other = $view === EffectPresentation::TERMINAL ? EffectPresentation::GRAPHICAL : EffectPresentation::TERMINAL; + + return [ + [ + 'label' => 'Sequence', + 'value' => ucfirst($view->value), + 'options' => array_map(static fn(EffectPresentation $presentation): string => $presentation->value, EffectPresentation::cases()), + 'field' => self::EFFECT_SEQUENCE_FIELD, + 'hint' => 'the sequence these rows edit', + ], + ['label' => sprintf(' Edits apply to the %s sequence; the %s one is kept as written.', $view->value, $other->value), 'value' => '', 'editable' => false], + ]; + } + + /** + * Shows another of the selected effect's sequences, or gives a flat + * effect separate ones, recorded so undo puts the flat file back. + */ + private function applyEffectSequenceValue(string $rawValue): void + { + $asset = $this->selectedCutscene(); + $value = strtolower(trim($rawValue)); + + if ($asset === null || $asset->type !== CutsceneType::EFFECT) { + return; + } + + if (! $asset->hasPresentations()) { + if ($value !== self::EFFECT_SEQUENCE_SEPARATE) { + return; + } + + $this->mutateSelectedCutscene('Separate effect sequences', static function () use ($asset): void { + $asset->selectPresentation(EffectPresentation::TERMINAL); + $asset->splitIntoPresentations(); + }); + $this->setStatus(sprintf('Effect "%s" now has a terminal and a graphical sequence. It reaches disk on save.', $asset->id), StatusLevel::INFO); + $this->renderCutscenesArea(); + + return; + } + + $presentation = EffectPresentation::tryFrom($value); + + if ($presentation === null || $presentation === $asset->getPresentationView()) { + return; + } + + // Which sequence is shown is the screen's, not the file's: nothing is + // written or recorded, and the records are rebuilt from that sequence. + $asset->selectPresentation($presentation); + $this->cutsceneLibrary()?->refreshRecords($asset->type); + // A running preview plays the other sequence; it starts again on this one. + $this->disposeCinematicPreview(); + $this->databaseCommandFramePath = []; + $this->setStatus(sprintf('Editing the %s sequence of effect "%s".', $presentation->value, $asset->id), StatusLevel::INFO); + $this->renderCutscenesArea(); + } + /** * Returns the settings pane's width and height while the workspace * hosts the record pane. @@ -1086,17 +1190,17 @@ private function mutateCutscenePayload(string $label, callable $change): bool } /** - * @return array{payload: array, deleted: bool} + * @return array{data: array, partner: array, view: EffectPresentation, deleted: bool} */ private function snapshotCutscene(CutsceneAsset $asset): array { - return ['payload' => $asset->payload(), 'deleted' => $asset->isDeleted()]; + return $asset->captureEditState(); } /** * Puts an asset back to a recorded state, and the screen onto it. * - * @param array{payload: array, deleted: bool} $snapshot + * @param array{data: array, partner: array, view: EffectPresentation, deleted: bool} $snapshot */ private function restoreCutsceneSnapshot(CutsceneType $type, string $id, array $snapshot): void { @@ -1107,8 +1211,7 @@ private function restoreCutsceneSnapshot(CutsceneType $type, string $id, array $ return; } - $asset->apply($snapshot['payload']); - $asset->markDeleted($snapshot['deleted']); + $asset->restoreEditState($snapshot); $library->refreshRecords($type); if ($this->isCutscenesOpen) { @@ -1142,6 +1245,12 @@ private function applyCutsceneFieldValueRecorded(array $field, string $rawValue) return; } + if ($fieldId === self::EFFECT_SEQUENCE_FIELD && $this->databaseCommandFramePath === []) { + $this->applyEffectSequenceValue($rawValue); + + return; + } + $framePath = $this->databaseCommandFramePath; $this->mutateSelectedCutscene( sprintf('%s edit', $field['label'] ?? 'Cutscene field'), @@ -1422,10 +1531,11 @@ private function createCutscene(): void return; } + $state = $created->captureEditState(); $this->recordCommand(new GenericCommand( sprintf('Create %s', $type->noun()), - fn() => $this->restoreCutsceneSnapshot($type, $id, ['payload' => $payload, 'deleted' => false]), - fn() => $this->restoreCutsceneSnapshot($type, $id, ['payload' => $payload, 'deleted' => true]), + fn() => $this->restoreCutsceneSnapshot($type, $id, [...$state, 'deleted' => false]), + fn() => $this->restoreCutsceneSnapshot($type, $id, [...$state, 'deleted' => true]), )); $this->leaveCutsceneEditingState(); @@ -1462,11 +1572,11 @@ private function duplicateSelectedCutscene(): void return; } - $payload = $copy->payload(); + $state = $copy->captureEditState(); $this->recordCommand(new GenericCommand( sprintf('Duplicate %s', $type->noun()), - fn() => $this->restoreCutsceneSnapshot($type, $newId, ['payload' => $payload, 'deleted' => false]), - fn() => $this->restoreCutsceneSnapshot($type, $newId, ['payload' => $payload, 'deleted' => true]), + fn() => $this->restoreCutsceneSnapshot($type, $newId, [...$state, 'deleted' => false]), + fn() => $this->restoreCutsceneSnapshot($type, $newId, [...$state, 'deleted' => true]), )); $this->leaveCutsceneEditingState(); @@ -1505,7 +1615,8 @@ private function openCutsceneDeleteConfirmation(): void /** * Returns who points at an asset: maps whose triggers launch a - * cinematic, actors who start with a summon. + * cinematic, actors who start with a summon, and everything that plays + * an effect. * * @return string[] */ @@ -1521,6 +1632,12 @@ private function describeCutsceneReferences(CutsceneAsset $asset): array } } } + } elseif ($asset->type === CutsceneType::EFFECT) { + if ($this->workspace instanceof ProjectWorkspace) { + $uses = EffectValidator::findUses($this->workspace)[$asset->id] ?? []; + $scripts = EffectValidator::findScriptUses($this->workspace, $this->cutsceneLibrary()?->assets(CutsceneType::CINEMATIC) ?? []); + $references = [...($uses['battle'] ?? []), ...($uses['field'] ?? []), ...($scripts[$asset->id] ?? [])]; + } } else { foreach ($this->workspace?->actorDatabase->getActors() ?? [] as $actor) { $summons = $actor->getSummons(); @@ -2020,9 +2137,7 @@ private function getCutsceneSettingsLines(): array '', 'Shift+A in the list creates one.', '', - $this->cutsceneType === CutsceneType::CINEMATIC - ? 'A cinematic is a staged story scene: a command tree the engine runs on the field, with its own cast, camera, skip policy and finalizer.' - : 'A summon is a frame-driven battle presentation: tracks, keyframes and cues the engine compiles and plays.', + $this->cutsceneType->describeAsset(), ]; } diff --git a/src/Cutscenes/Preview/EffectPreviewStage.php b/src/Cutscenes/Preview/EffectPreviewStage.php new file mode 100644 index 0000000..4cc7cbe --- /dev/null +++ b/src/Cutscenes/Preview/EffectPreviewStage.php @@ -0,0 +1,219 @@ +> $segments The playhead's active segments. + * @return string[] + */ + public function drawFrame(array $segments, int $width, int $height): array + { + $width = max(1, $width); + $height = max(1, $height); + $cells = array_fill(0, $height, array_fill(0, $width, ' ')); + $origins = $this->findOrigins($width, $height); + + foreach ($origins as $anchor => $origin) { + $this->place($cells, $anchor === 'caster' ? self::CASTER_MARKER : self::TARGET_MARKER, $origin['x'], $origin['y']); + } + + $commands = []; + + foreach ($this->composeSegments($segments) as $segment) { + if (! in_array($segment['layer'] ?? '', ['glyph', 'text'], true)) { + continue; + } + + foreach (array_filter((array) ($segment['drawCommands'] ?? []), is_array(...)) as $command) { + $commands[] = $command; + } + } + + usort($commands, static fn(array $a, array $b): int => ($a['zIndex'] ?? 0) <=> ($b['zIndex'] ?? 0)); + + foreach ($commands as $command) { + if (($command['visible'] ?? true) === false) { + continue; + } + + $anchor = strval($command['payload']['anchor'] ?? 'target'); + $base = in_array($anchor, ['screen', 'legacy-screen'], true) + ? ['x' => 0, 'y' => 0] + : ($origins[$anchor] ?? $origins['target']); + + $command = $this->orient($command); + $position = is_array($command['position'] ?? null) ? $command['position'] : []; + $x = $base['x'] + intval($position['x'] ?? 0); + $y = $base['y'] + intval($position['y'] ?? 0); + + foreach (explode("\n", trim(strval($command['content'] ?? ''), "\r\n")) as $row => $line) { + $this->place($cells, $line, $x, $y + $row); + } + } + + return array_map(static fn(array $row): string => implode('', $row), $cells); + } + + /** + * Describes the active draws a terminal cannot show: images with their + * sheet frame and flips, flashes and shakes with their subject. + * + * @param array> $segments The playhead's active segments. + * @return string[] + */ + public function describeUndrawn(array $segments): array + { + $lines = []; + + foreach ($this->composeSegments($segments) as $segment) { + $layer = strval($segment['layer'] ?? ''); + + if (in_array($layer, ['glyph', 'text'], true)) { + continue; + } + + foreach (array_filter((array) ($segment['drawCommands'] ?? []), is_array(...)) as $command) { + $command = $this->orient($command); + $payload = is_array($command['payload'] ?? null) ? $command['payload'] : []; + $subject = strval($payload['anchor'] ?? 'target'); + $lines[] = match ($layer) { + 'image' => sprintf('%s frame %d%s%s on %s', basename(strval($command['assetId'] ?? '?')), intval($payload['sourceFrame'] ?? 0), + ($payload['flipX'] ?? false) === true ? ' flipX' : '', ($payload['flipY'] ?? false) === true ? ' flipY' : '', $subject), + 'flash' => sprintf('flash %s on %s', strval($payload['color'] ?? $command['color'] ?? 'white'), $subject), + 'shake' => sprintf('shake %s on %s', strval($payload['amplitude'] ?? 1), $subject), + default => sprintf('%s on %s', $layer, $subject), + }; + } + } + + // Image tracks never reach the terminal's composition; say so once. + if ($this->presentation === EffectPresentation::TERMINAL) { + foreach ($segments as $segment) { + if (($segment['layer'] ?? '') === 'image') { + $lines[] = 'image tracks: graphical only'; + + break; + } + } + } + + return $lines; + } + + /** + * Turns a battle stroke with a facing toward its recipient, the target, + * exactly as the battle turns it; the field draws every stroke as authored. + * + * @param array $command + * @return array + */ + private function orient(array $command): array + { + if (! $this->forBattle) { + return $command; + } + + return BattleEffectDirection::orientCommand($command, $this->isCasterOnLeft ? 0 : 1, $this->isCasterOnLeft ? 1 : 0); + } + + /** + * @param array> $segments + * @return list> + */ + private function composeSegments(array $segments): array + { + return EffectSegmentComposition::compose(array_values($segments), $this->presentation); + } + + /** + * Where each subject stands on a stage of this size. + * + * @return array{caster: array{x: int, y: int}, target: array{x: int, y: int}}|array{target: array{x: int, y: int}} + */ + private function findOrigins(int $width, int $height): array + { + $y = intdiv($height, 2); + + if (! $this->forBattle) { + return ['target' => ['x' => intdiv($width, 2), 'y' => $y]]; + } + + $left = intdiv($width, 4); + $right = $width - 1 - intdiv($width, 4); + + return [ + 'caster' => ['x' => $this->isCasterOnLeft ? $left : $right, 'y' => $y], + 'target' => ['x' => $this->isCasterOnLeft ? $right : $left, 'y' => $y], + ]; + } + + /** + * Writes a line into the cells from a column, clipping at the edges and + * keeping a wide symbol whole. + * + * @param array> $cells + */ + private function place(array &$cells, string $line, int $x, int $y): void + { + if ($y < 0 || $y >= count($cells)) { + return; + } + + $width = count($cells[$y]); + $column = $x; + + foreach (TerminalText::visibleSymbols($line) as $symbol) { + $symbolWidth = max(1, TerminalText::displayWidth($symbol)); + + if (trim($symbol) !== '' && $column >= 0 && $column + $symbolWidth <= $width) { + $cells[$y][$column] = $symbol; + + for ($offset = 1; $offset < $symbolWidth; $offset++) { + $cells[$y][$column + $offset] = ''; + } + } + + $column += $symbolWidth; + } + } +} diff --git a/src/Cutscenes/Preview/SummonPreviewSession.php b/src/Cutscenes/Preview/TimelinePreviewSession.php similarity index 88% rename from src/Cutscenes/Preview/SummonPreviewSession.php rename to src/Cutscenes/Preview/TimelinePreviewSession.php index b44d310..cb82003 100644 --- a/src/Cutscenes/Preview/SummonPreviewSession.php +++ b/src/Cutscenes/Preview/TimelinePreviewSession.php @@ -4,22 +4,24 @@ namespace Ichiloto\Editor\Cutscenes\Preview; -use Ichiloto\Engine\Cutscenes\Summons\SummonCompiledCutscene; -use Ichiloto\Engine\Cutscenes\Summons\SummonPlaybackSession; +use Ichiloto\Engine\Animations\Timelines\CompiledEffectTimeline; +use Ichiloto\Engine\Animations\Timelines\EffectPlaybackSession; use Ichiloto\Engine\IO\Console\TerminalText; /** - * A summon timeline played by the Engine's own `SummonPlaybackSession`. + * A compiled timeline, a summon's or an effect's, played by the Engine's + * shared `EffectPlaybackSession`. * * The Engine compiles the timeline and owns the playhead, the frame clock, * the active segments and the cue schedule; the editor adds a clock it can - * pause or step, a log of the cues the playhead crossed, and a picture of - * the frame drawn with the battle field's rules (position, content lines, - * asset placeholder, visibility) into a buffer the pane can show. + * pause or step, a log of the cues the playhead crossed, a ruler, and for a + * summon a picture of the frame drawn with the battle field's rules + * (position, content lines, asset placeholder, visibility). An effect's + * frame is anchored to its subjects; `EffectPreviewStage` draws it. */ -final class SummonPreviewSession +final class TimelinePreviewSession { - private SummonPlaybackSession $session; + private EffectPlaybackSession $session; private bool $playing = false; private float $elapsed = 0.0; private bool $loop = false; @@ -27,9 +29,13 @@ final class SummonPreviewSession /** @var array */ private array $cueLog = []; - public function __construct(public readonly SummonCompiledCutscene $cutscene) + /** + * @param bool|null $loop Whether playback wraps at the last frame; null plays as the timeline's own playback says. + */ + public function __construct(public readonly CompiledEffectTimeline $timeline, ?bool $loop = false) { - $this->session = new SummonPlaybackSession($cutscene, loop: false); + $this->session = new EffectPlaybackSession($timeline, loop: $loop); + $this->loop = $this->session->isLooping; $this->session->pause(); } @@ -87,7 +93,7 @@ private function rebuildSession(): void { $frame = $this->session->currentFrame; $wasPlaying = $this->isPlaying(); - $this->session = new SummonPlaybackSession($this->cutscene, loop: $this->loop, speed: $this->speed); + $this->session = new EffectPlaybackSession($this->timeline, loop: $this->loop, speed: $this->speed); $this->session->seek($frame); if ($wasPlaying) { @@ -247,7 +253,7 @@ public function cueLog(): array */ public function cuesAt(?int $frame = null): array { - return array_values(array_filter($this->session->cuesAt($frame), is_array(...))); + return $this->session->getCuesAt($frame); } /** @@ -257,7 +263,7 @@ public function cuesAt(?int $frame = null): array */ public function activeSegments(?int $frame = null): array { - return array_values(array_filter($this->session->activeSegments($frame), is_array(...))); + return $this->session->getActiveSegments($frame); } /** @@ -269,7 +275,7 @@ public function boundaries(): array { $frames = []; - foreach ($this->cutscene->playbackSegments as $segment) { + foreach ($this->timeline->playbackSegments as $segment) { if (! is_array($segment)) { continue; } @@ -278,7 +284,7 @@ public function boundaries(): array $frames[] = intval($segment['endFrame'] ?? 0) + 1; } - foreach ($this->cutscene->cueSchedule as $cue) { + foreach ($this->timeline->cueSchedule as $cue) { if (is_array($cue)) { $frames[] = intval($cue['frame'] ?? 0); } @@ -291,7 +297,7 @@ public function boundaries(): array } /** - * The frame as the battle field would compose it, as plain rows. + * A summon's frame as the battle field would compose it, as plain rows. * * @return string[] */ @@ -382,7 +388,7 @@ public function rulerLines(int $width): array /** @var array $tracks */ $tracks = []; - foreach ($this->cutscene->playbackSegments as $segment) { + foreach ($this->timeline->playbackSegments as $segment) { if (! is_array($segment)) { continue; } @@ -406,7 +412,7 @@ public function rulerLines(int $width): array $cues = array_fill(0, $barWidth, ' '); $hasCues = false; - foreach ($this->cutscene->cueSchedule as $cue) { + foreach ($this->timeline->cueSchedule as $cue) { if (is_array($cue)) { $column = min($barWidth - 1, $scale(intval($cue['frame'] ?? 0))); $cues[$column] = '◆'; diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index c07fc77..b774121 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -2831,6 +2831,12 @@ private static function coerce(RecordField $field, string $rawValue): mixed } if ($field->options !== []) { + // An empty choice of a field that is removed when empty is its + // absence, whether or not '' is offered as an option. + if ($trimmed === '' && $field->removeWhenEmpty) { + return null; + } + foreach ($field->options as $option) { if (mb_strtolower(strval($option)) === mb_strtolower($trimmed)) { return self::coerceScalar($field, strval($option)); diff --git a/src/Validation/EffectValidator.php b/src/Validation/EffectValidator.php index 9adbf6e..f3846d9 100644 --- a/src/Validation/EffectValidator.php +++ b/src/Validation/EffectValidator.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor\Validation; +use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Animations\Field\FieldEffectManager; use Ichiloto\Engine\Animations\Field\FieldPresentationCatalog; @@ -29,7 +30,74 @@ public function validate(ProjectWorkspace $workspace): array { $assetRoot = $workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; $issues = []; - /** @var array, field: list}> $uses */ + $uses = self::findUses($workspace, $issues); + $library = new EffectTimelineLibrary($assetRoot); + ksort($uses); + + foreach ($uses as $effect => $contexts) { + foreach ($contexts as $context => $places) { + foreach (EffectPresentation::cases() as $presentation) { + try { + $library->load($effect, $context === 'battle', $presentation); + } catch (Throwable $failure) { + $issues[] = Issue::error($places[0], sprintf('Effect %s cannot be played in %s for the %s presentation: %s', + $effect, $context, $presentation->value, $failure->getMessage()), + sprintf('Fix assets/Animations/%s. Until then that presentation keeps its fallback.%s', $effect, + count($places) > 1 ? sprintf(' Also used by %d more.', count($places) - 1) : '')); + } + } + } + } + + return $issues; + } + + /** + * Checks an effect a `field_animation` command plays and waits for, as + * the Engine plays it on the field for each presentation: it must load, + * and it must play once, since loops belong to a map's own effects. + * + * @param EffectTimelineLibrary $library The project's timelines. + * @param string $effect The effect's stable id. + * @param string $where Where the command is. + * @return Issue[] + */ + public static function checkOneShotFieldEffect(EffectTimelineLibrary $library, string $effect, string $where): array + { + $issues = []; + + foreach (EffectPresentation::cases() as $presentation) { + try { + $timeline = $library->load($effect, false, $presentation); + } catch (Throwable $failure) { + $issues[] = Issue::error($where, sprintf('Its effect %s cannot be played on the field for the %s presentation: %s', + $effect, $presentation->value, $failure->getMessage()), + sprintf('Fix assets/Animations/%s. The runtime stops the script at this command.', $effect)); + continue; + } + + if ($timeline->defaults['playback']['loop'] ?? false) { + $issues[] = Issue::error($where, sprintf('Its effect %s loops in the %s presentation, and a field_animation waits for its effect to end.', + $effect, $presentation->value), + 'Play a looping effect from the map\'s field effects instead, or give this timeline once playback.'); + } + } + + return $issues; + } + + /** + * Returns where the project plays each effect, by context: battle + * animations' source and target effects in battle; the field + * presentation's cues and action prompt, maps' field effects and tileset + * pieces' effects on the field. + * + * @param Issue[] $issues Receives what could not be read. + * @return array, field?: list}> + */ + public static function findUses(ProjectWorkspace $workspace, array &$issues = []): array + { + $assetRoot = $workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; $uses = []; $use = static function (string $effect, bool $battle, string $where) use (&$uses): void { $uses[$effect][$battle ? 'battle' : 'field'][] = $where; @@ -88,58 +156,51 @@ public function validate(ProjectWorkspace $workspace): array } } - $library = new EffectTimelineLibrary($assetRoot); - ksort($uses); - - foreach ($uses as $effect => $contexts) { - foreach ($contexts as $context => $places) { - foreach (EffectPresentation::cases() as $presentation) { - try { - $library->load($effect, $context === 'battle', $presentation); - } catch (Throwable $failure) { - $issues[] = Issue::error($places[0], sprintf('Effect %s cannot be played in %s for the %s presentation: %s', - $effect, $context, $presentation->value, $failure->getMessage()), - sprintf('Fix assets/Animations/%s. Until then that presentation keeps its fallback.%s', $effect, - count($places) > 1 ? sprintf(' Also used by %d more.', count($places) - 1) : '')); - } - } - } - } - - return $issues; + return $uses; } /** - * Checks an effect a `field_animation` command plays and waits for, as - * the Engine plays it on the field for each presentation: it must load, - * and it must play once, since loops belong to a map's own effects. + * Returns the scripts whose `field_animation` commands play each effect: + * map events, event scripts and cinematics, at any depth of branches, + * lanes and choices, since the command is the same wherever it is + * nested. What cannot be read names nothing here; validation reports it. * - * @param EffectTimelineLibrary $library The project's timelines. - * @param string $effect The effect's stable id. - * @param string $where Where the command is. - * @return Issue[] + * @param iterable $cinematics The project's cinematics, as the editor holds them. + * @return array> Effect id to the scripts that play it. */ - public static function checkOneShotFieldEffect(EffectTimelineLibrary $library, string $effect, string $where): array + public static function findScriptUses(ProjectWorkspace $workspace, iterable $cinematics): array { - $issues = []; + $uses = []; + $collect = static function (mixed $value, string $where) use (&$uses, &$collect): void { + if (! is_array($value)) { + return; + } - foreach (EffectPresentation::cases() as $presentation) { - try { - $timeline = $library->load($effect, false, $presentation); - } catch (Throwable $failure) { - $issues[] = Issue::error($where, sprintf('Its effect %s cannot be played on the field for the %s presentation: %s', - $effect, $presentation->value, $failure->getMessage()), - sprintf('Fix assets/Animations/%s. The runtime stops the script at this command.', $effect)); - continue; + if (($value['type'] ?? null) === 'field_animation' && is_string($value['effect'] ?? null) && $value['effect'] !== '') { + $uses[$value['effect']][] = $where; } - if ($timeline->defaults['playback']['loop'] ?? false) { - $issues[] = Issue::error($where, sprintf('Its effect %s loops in the %s presentation, and a field_animation waits for its effect to end.', - $effect, $presentation->value), - 'Play a looping effect from the map\'s field effects instead, or give this timeline once playback.'); + foreach ($value as $nested) { + $collect($nested, $where); + } + }; + + foreach ($workspace->maps as $map) { + foreach ((array) ($map->data['events'] ?? []) as $marker => $definition) { + $collect($definition, sprintf('map %s event %s', $map->mapId, strval($marker))); } } - return $issues; + foreach ($workspace->getRecordDatabase('common_events')?->getRecords() ?? [] as $record) { + $script = (array) $record->toArray(); + $scriptId = trim(strval($script['__scriptId'] ?? '')); + $collect($script, sprintf('event script %s', $scriptId !== '' ? $scriptId : '(unnamed)')); + } + + foreach ($cinematics as $cinematic) { + $collect([$cinematic->data(), $cinematic->partner()], sprintf('cinematic %s', $cinematic->id)); + } + + return array_map(static fn(array $places): array => array_values(array_unique($places)), $uses); } } diff --git a/tests/Unit/Cutscenes/CutscenesWorkspaceTest.php b/tests/Unit/Cutscenes/CutscenesWorkspaceTest.php index e456fee..982ded1 100644 --- a/tests/Unit/Cutscenes/CutscenesWorkspaceTest.php +++ b/tests/Unit/Cutscenes/CutscenesWorkspaceTest.php @@ -537,8 +537,8 @@ // Space compiles the summon as it stands and starts the Engine session. pressKeys($editor, ' '); - $preview = getEditorProperty($editor, 'summonPreview'); - expect($preview)->toBeInstanceOf(\Ichiloto\Editor\Cutscenes\Preview\SummonPreviewSession::class) + $preview = getEditorProperty($editor, 'timelinePreview'); + expect($preview)->toBeInstanceOf(\Ichiloto\Editor\Cutscenes\Preview\TimelinePreviewSession::class) ->and($preview->isPlaying())->toBeTrue() ->and($preview->totalFrames())->toBe(24) ->and($preview->fps())->toBe(12); @@ -596,7 +596,7 @@ pressKeys($editor, 'l'); expect(renderEditorPlainFrame($editor, 150, 45))->toContain('f12–23'); pressKeys($editor, 'x'); - expect(getEditorProperty($editor, 'summonPreview'))->toBeNull(); + expect(getEditorProperty($editor, 'timelinePreview'))->toBeNull(); }); it('reorders, nests, un-nests, duplicates and removes commands from the tree, each undoable', function () { diff --git a/tests/Unit/Cutscenes/EffectTimelineAuthoringTest.php b/tests/Unit/Cutscenes/EffectTimelineAuthoringTest.php new file mode 100644 index 0000000..16b14de --- /dev/null +++ b/tests/Unit/Cutscenes/EffectTimelineAuthoringTest.php @@ -0,0 +1,221 @@ + trim((string) ($field['label'] ?? '')), callEditorMethod($editor, 'getDatabaseSettingsFields')); + + expect($labels)->toContain('Identity', 'Sequence', 'Timing', 'Impact (battle)', 'Timeline') + ->and(cutsceneFieldIds($editor))->toContain('id', 'fps', 'lengthFrames', 'playback', 'effectTiming.mode', 'commandListTracks', 'commandListCues') + ->and(cutsceneField($editor, '@sequence')['value'])->toBe('Shared') + ->and(renderEditorPlainFrame($editor, 160, 50))->toContain('The engine reads this effect as it stands.'); +}); + +it('splits a flat effect into two sequences from its Sequence row, and undo puts the flat file back', function () { + $root = effectProject(); + $editor = effectsEditor($root); + selectEffect($editor, 'ember-spark'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'ember-spark'); + $flat = $asset->partner(); + + setCutsceneField($editor, '@sequence', 'separate'); + + expect($asset->hasPresentations())->toBeTrue() + ->and($asset->partner())->toBe(['presentations' => ['terminal' => $flat, 'graphical' => $flat]]) + ->and(cutsceneField($editor, '@sequence')['value'])->toBe('Terminal'); + + callEditorMethod($editor, 'performUndo'); + + expect($asset->hasPresentations())->toBeFalse() + ->and($asset->partner())->toBe($flat) + ->and($asset->isDirty())->toBeFalse(); + + callEditorMethod($editor, 'performRedo'); + expect($asset->hasPresentations())->toBeTrue(); +}); + +it('edits the sequence it shows, and undo returns to that sequence whichever is shown', function () { + $root = effectProject(); + $editor = effectsEditor($root); + selectEffect($editor, 'dusk-slash'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'dusk-slash'); + + setCutsceneField($editor, '@sequence', 'graphical'); + expect($asset->getPresentationView())->toBe(EffectPresentation::GRAPHICAL) + ->and(cutsceneField($editor, 'lengthFrames')['value'])->toBe('2'); + + setCutsceneField($editor, 'lengthFrames', '3'); + setCutsceneField($editor, '@sequence', 'terminal'); + + expect(cutsceneField($editor, 'lengthFrames')['value'])->toBe('4') + ->and($asset->partner()['presentations']['graphical']['lengthFrames'])->toBe(3); + + callEditorMethod($editor, 'performUndo'); + + expect($asset->partner()['presentations']['graphical']['lengthFrames'])->toBe(2) + ->and($asset->partner()['presentations']['terminal']['lengthFrames'])->toBe(4) + ->and($asset->getPresentationView())->toBe(EffectPresentation::GRAPHICAL) + ->and($asset->isDirty())->toBeFalse(); +}); + +it("sets and removes a track's battle facing as an ordinary field, and the Engine still plays it", function () { + $root = effectProject(); + $editor = effectsEditor($root); + selectEffect($editor, 'ember-spark'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'ember-spark'); + + selectCutsceneField($editor, 'commandListTracks'); + pressKeys($editor, "\n"); + $facing = array_values(array_filter(cutsceneFieldIds($editor), static fn(string $id): bool => str_ends_with(strtolower($id), 'facing')))[0]; + + expect(cutsceneField($editor, $facing)['options'])->toBe(['', 'west', 'east']); + + setCutsceneField($editor, $facing, 'west'); + expect($asset->payload()['tracks'][0]['facing'])->toBe('west') + ->and($asset->compiledEffect(EffectPresentation::TERMINAL, true))->not->toBeNull(); + + setCutsceneField($editor, $facing, ''); + expect($asset->payload()['tracks'][0])->not->toHaveKey('facing') + ->and($asset->isDirty())->toBeFalse(); +}); + +it('adds tracks, keyframes and cues from the timeline rows with the blanks the effect schema owns', function () { + $root = effectProject(); + $editor = effectsEditor($root); + selectEffect($editor, 'dusk-slash'); + setCutsceneField($editor, '@sequence', 'graphical'); + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_TREE); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'dusk-slash'); + $keys = array_column(callEditorMethod($editor, 'visibleCutsceneTreeRows'), 'key'); + + expect(renderEditorPlainFrame($editor, 160, 50))->toContain('Timeline') + ->and(implode("\n", array_column(callEditorMethod($editor, 'visibleCutsceneTreeRows'), 'text')))->toContain('faces west', 'sheet frame 1'); + + // An image keyframe takes the image keyframe blank, not a glyph one, + // starting where the selected one ends. + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_SETTINGS); + setCutsceneField($editor, 'lengthFrames', '3'); + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_TREE); + setEditorProperty($editor, 'cutsceneTreeCursor', array_search('tracks.0.keyframes.1', $keys, true)); + pressKeys($editor, 'O'); + expect($asset->payload()['tracks'][0]['keyframes'][2])->toBe(['frame' => 2, 'sourceFrame' => 0]); + + // A track takes the effect track blank, its keyframe positioned as the Engine reads it. + setEditorProperty($editor, 'cutsceneTreeCursor', array_search('tracks.0', $keys, true)); + pressKeys($editor, 'O'); + expect($asset->payload()['tracks'][1]['keyframes'][0]['position'])->toBe(['x' => 0, 'y' => 0]) + ->and(array_unique(array_column($asset->payload()['tracks'], 'id')))->toHaveCount(2); + + // The edited sequence still compiles; the terminal one is untouched. + expect($asset->compiledEffect(EffectPresentation::GRAPHICAL, true)->playbackSegments)->not->toBe([]) + ->and($asset->partner()['presentations']['terminal'])->toBe((eval('?>' . duskSlashTimeline()))['presentations']['terminal']); +}); + +it('names what plays an effect before deleting it: battle animations, field effects and scripts', function () { + $root = effectProject(); + $animations = require $root . '/assets/Data/animations.php'; + $animations[0]['targetEffect'] = 'dusk-slash'; + file_put_contents($root . '/assets/Data/animations.php', ' 'field_animation', 'effect' => 'dusk-slash', 'target' => 'player'],\n", (string) file_get_contents($script))); + $editor = effectsEditor($root); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'dusk-slash'); + + $references = callEditorMethod($editor, 'describeCutsceneReferences', $asset); + + expect(implode("\n", $references))->toContain('assets/Data/animations.php: ' . $animations[0]['name'] . ' targetEffect') + ->toContain('cinematic harbour-lanterns') + ->and(callEditorMethod($editor, 'describeCutsceneReferences', libraryOf($editor)->find(CutsceneType::EFFECT, 'ember-spark')))->toBe([]); +}); + +it('previews an effect through the Engine playhead, anchored to caster and target, as battle or the field plays it', function () { + $root = effectProject(); + $editor = effectsEditor($root); + selectEffect($editor, 'dusk-slash'); + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_PREVIEW); + + pressKeys($editor, ' ', ' '); + $preview = getEditorProperty($editor, 'timelinePreview'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'dusk-slash'); + + expect($preview)->not->toBeNull() + ->and($preview->totalFrames())->toBe(4) + ->and(callEditorMethod($editor, 'isEffectPreviewInBattle', $asset))->toBeTrue(); + + // The west-facing stroke is turned toward a target on the right, as the battle turns it. + $stage = callEditorMethod($editor, 'createEffectPreviewStage', $asset); + $row = $stage->drawFrame($preview->activeSegments(), 21, 3)[1]; + expect($row)->toBe(' C \\ '); + + pressKeys($editor, 'd'); + $row = callEditorMethod($editor, 'createEffectPreviewStage', $asset)->drawFrame($preview->activeSegments(), 21, 3)[1]; + expect($row)->toBe(' / C '); + + // On the field the effect has one subject; B switches and recompiles. + pressKeys($editor, 'b'); + // Its facing is battle-only, so the field refuses it, and says why. + expect(callEditorMethod($editor, 'isEffectPreviewInBattle', $asset))->toBeFalse() + ->and(getEditorProperty($editor, 'timelinePreview'))->toBeNull() + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('facing'); + + pressKeys($editor, 'b'); + expect(getEditorProperty($editor, 'timelinePreview'))->not->toBeNull(); + + // The graphical sequence: its images are described, not imitated. + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_SETTINGS); + setCutsceneField($editor, '@sequence', 'graphical'); + expect(getEditorProperty($editor, 'timelinePreview'))->toBeNull(); + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_PREVIEW); + pressKeys($editor, ' ', ' '); + $stage = callEditorMethod($editor, 'createEffectPreviewStage', $asset); + + // With the caster on the right the west stroke is drawn as authored; + // D puts the caster back on the left and the battle flips it. + expect($stage->describeUndrawn(getEditorProperty($editor, 'timelinePreview')->activeSegments()))->toBe(['dusk-slash.png frame 0 on target']); + pressKeys($editor, 'd'); + + expect(getEditorProperty($editor, 'timelinePreview')->totalFrames())->toBe(2) + ->and(callEditorMethod($editor, 'createEffectPreviewStage', $asset)->describeUndrawn(getEditorProperty($editor, 'timelinePreview')->activeSegments())) + ->toBe(['dusk-slash.png frame 0 flipX on target']) + ->and(renderEditorPlainFrame($editor, 160, 50))->toContain('dusk-slash.png frame 0'); +}); + +it('draws a flat field effect around its target, leaving image tracks to the graphical renderer', function () { + $stage = new EffectPreviewStage(EffectPresentation::TERMINAL, false); + $segments = [ + ['layer' => 'glyph', 'startFrame' => 0, 'endFrame' => 0, 'drawCommands' => [['content' => '*', 'position' => ['x' => 1, 'y' => -1], 'payload' => []]]], + ['layer' => 'glyph', 'startFrame' => 0, 'endFrame' => 0, 'drawCommands' => [['content' => '!', 'position' => ['x' => 0, 'y' => 0], 'payload' => ['anchor' => 'screen']]]], + ['layer' => 'image', 'startFrame' => 0, 'endFrame' => 0, 'drawCommands' => [['assetId' => 'Graphics/x.png', 'payload' => ['sourceFrame' => 0]]]], + ]; + + expect($stage->drawFrame($segments, 9, 3))->toBe(['! * ', ' T ', ' ']) + ->and($stage->describeUndrawn($segments))->toBe(['image tracks: graphical only']); +}); diff --git a/tests/Unit/Cutscenes/LastLegendSummonVerificationTest.php b/tests/Unit/Cutscenes/LastLegendSummonVerificationTest.php index 9a03216..e2cacd8 100644 --- a/tests/Unit/Cutscenes/LastLegendSummonVerificationTest.php +++ b/tests/Unit/Cutscenes/LastLegendSummonVerificationTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use Ichiloto\Editor\Cutscenes\CutsceneType; -use Ichiloto\Editor\Cutscenes\Preview\SummonPreviewSession; +use Ichiloto\Editor\Cutscenes\Preview\TimelinePreviewSession; use Ichiloto\Editor\Database\SummonAssignmentDiagnostics; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\UI\CutscenesScreen; @@ -37,7 +37,7 @@ expect($asset->isEditable())->toBeTrue($asset->id . ' is editable') ->and($asset->isDirty())->toBeFalse(); $compiled = $asset->compiledSummon(); - $preview = new SummonPreviewSession($compiled); + $preview = new TimelinePreviewSession($compiled); $preview->play(); for ($tick = 0; $tick < 5000 && ! $preview->isCompleted(); $tick++) { @@ -55,7 +55,7 @@ callEditorMethod($editor, 'selectCutsceneById', $id); setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_PREVIEW); pressKeys($editor, ' ', ' ', '.'); - expect(getEditorProperty($editor, 'summonPreview'))->not->toBeNull(); + expect(getEditorProperty($editor, 'timelinePreview'))->not->toBeNull(); pressKeys($editor, 'x'); } @@ -196,8 +196,8 @@ // 11. Preview through the Engine session: the cue fires once at 24. setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_PREVIEW); pressKeys($editor, ' '); - $preview = getEditorProperty($editor, 'summonPreview'); - expect($preview)->toBeInstanceOf(SummonPreviewSession::class); + $preview = getEditorProperty($editor, 'timelinePreview'); + expect($preview)->toBeInstanceOf(TimelinePreviewSession::class); for ($tick = 0; $tick < 200 && ! $preview->isCompleted(); $tick++) { $preview->tick($preview->secondsPerFrame()); @@ -267,3 +267,48 @@ $actorSource = file_get_contents($actor->path); expect($actorSource)->toContain("'ember-moth'"); }); + +it('opens and previews every Last Legend effect read-only, in each of its sequences, leaving every hash unchanged', function () { + $root = disposableLastLegend(); + + if ($root === null) { + $this->markTestSkipped('No Last Legend checkout is pinned (ICHILOTO_GAME_SRC).'); + } + + $before = authoredHashTree($root); + $editor = cutscenesEditor($root, 160, 50); + callEditorMethod($editor, 'switchCutsceneType', CutsceneType::EFFECT); + $library = libraryOf($editor); + $ids = $library->ids(CutsceneType::EFFECT); + $previewed = 0; + + expect($ids)->not->toBe([]) + ->and($library->issues(CutsceneType::EFFECT))->toBe([]); + + foreach ($ids as $id) { + $asset = $library->find(CutsceneType::EFFECT, $id); + callEditorMethod($editor, 'selectCutsceneById', $id); + + foreach ($asset->hasPresentations() ? \Ichiloto\Engine\Animations\Timelines\EffectPresentation::cases() : [null] as $presentation) { + if ($presentation !== null) { + $asset->selectPresentation($presentation); + } + + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_PREVIEW); + callEditorMethod($editor, 'disposeCinematicPreview'); + callEditorMethod($editor, 'startTimelinePreview', false); + $preview = getEditorProperty($editor, 'timelinePreview'); + + // Each sequence plays where the project uses it: the preview's + // own context, chosen from those uses. + expect($preview)->toBeInstanceOf(TimelinePreviewSession::class, $id . ' previews') + ->and(renderEditorPlainFrame($editor, 160, 50))->toContain($id); + $previewed++; + } + } + + pressKeys($editor, "\033OS"); + expect($previewed)->toBeGreaterThanOrEqual(count($ids)) + ->and($library->hasUnsavedChanges())->toBeFalse() + ->and(authoredHashTree($root))->toBe($before); +}); From d0d49dbb0571488e4e88121177c0b43e0c8527c0 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 06:16:28 +0200 Subject: [PATCH 108/344] docs(roadmap): record the animation role picker as in place --- docs/roadmap.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 3e2d5f0..36ee4e0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -207,8 +207,12 @@ in the animation database's `roles`. Authoring requirements: - The animation database offers `roles` from the Engine's supported role list, refuses a role bound twice before saving, and reports a weapon type the project's weapons use that no animation is bound to. -- Until then, these fields are kept as written: the animation database edits - only its own fields and keeps `roles` and effect bindings untouched. + +Status, 2026-10: the Editor side of both is in place. Effect timeline +authoring edits `facing`, `flipX` and `flipY` (above). The animation database +shows one on/off row per supported role, naming the animation that holds it, +and writes only `roles` over the authored entry; validation reports an +unsupported role, a role bound twice and a reached role with no animation. This scope does not replace the map source-integrity work or authorize a new GUI implementation. Keep runtime availability and TUI authoring completion separate From b17aaaf696027f2c37dc0859f33e2194b1174fb9 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 10:40:10 +0200 Subject: [PATCH 109/344] feat(actors): edit each actor's own attack style An actor's attack style is the weapon type they fight with when no weapon is equipped: their own weapon, part of who they are, with no stats. The Engine plays the equipped weapon's type first, then this style, then attack-unarmed. - The Actors database shows an Attack Style row after Class: Unarmed (the default, which removes the key) or any Engine weapon type, written as the Engine spells it. A type the Engine does not know is refused before anything changes; undo and redo work like every actor field. - ProjectActor reads the authored value and resolves it with the Engine's own WeaponType::require. - Validation reports a written style the Engine does not know. - Manual: the Actors section explains the row. --- docs/manual.md | 8 +++ src/Editor.php | 14 ++++- src/ProjectActor.php | 49 +++++++++++++++ src/Validation/ProjectValidator.php | 9 +++ tests/Unit/ActorAttackStyleTest.php | 96 +++++++++++++++++++++++++++++ 5 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/ActorAttackStyleTest.php diff --git a/docs/manual.md b/docs/manual.md index 9d39c6c..bf54899 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -937,6 +937,14 @@ what a save resolves the actor by. A project that declares none is resolved by display name, so the row says so, and renaming such an actor strands every save that named it. +**Attack Style** is the character's own weapon: the weapon type they fight +with when no weapon is equipped, part of who they are and with no stats +(`Unarmed`, the default, writes nothing). A battle's basic attack plays the +animation bound to the equipped weapon's type, else to this style, else to +`attack-unarmed`; equipment stays the stat-bearing upgrade. The row offers +the Engine's weapon types, writes them as the Engine spells them, and +validation reports a written style the Engine does not know. + **Nature** is what this actor is, as distinct from the class it shares with others: an adjustment per canonical stat, applied on top of the class baseline. Adjustments keep their sign, because being slower than the diff --git a/src/Editor.php b/src/Editor.php index 7a38e51..2e5e988 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -92,6 +92,7 @@ use Ichiloto\Engine\Entities\Enumerations\ItemScopeSide; use Ichiloto\Engine\Entities\Enumerations\ItemScopeStatus; use Ichiloto\Engine\Entities\Enumerations\Occasion; +use Ichiloto\Engine\Entities\Enumerations\WeaponType; use Ichiloto\Engine\Entities\Inventory\Accessory; use Ichiloto\Engine\Entities\Inventory\Armor; use Ichiloto\Engine\Entities\Inventory\Items\Item; @@ -11066,6 +11067,17 @@ private function getDatabaseActorSettingsFields(): array 'options' => $this->getActorClassOptions(), 'field' => 'class', ], + [ + // The character's own weapon, part of who they are and with + // no stats: what their attack looks like when no weapon is + // equipped. An equipped weapon's type takes its place. + 'label' => 'Attack Style', + 'value' => $actor->getAttackStyle() === '' ? 'Unarmed' : $actor->getAttackStyle(), + 'options' => [ProjectActor::ATTACK_STYLE_UNARMED, ...array_map( + static fn(WeaponType $type): string => $type->value, WeaponType::cases())], + 'field' => 'attackStyle', + 'hint' => 'own weapon when none is equipped; no stats', + ], ...$this->actorSummonFields($actor), [ 'label' => 'Level', @@ -13052,7 +13064,7 @@ private function applyDatabaseFieldValueAt(array $identity, string $field, strin */ private function coerceActorFieldValue(string $field, string $rawValue): string|int { - if (in_array($field, ['name', 'description', 'class', 'id', 'defaultNaturalVariantId', 'summons'], true)) { + if (in_array($field, ['name', 'description', 'class', 'attackStyle', 'id', 'defaultNaturalVariantId', 'summons'], true)) { return trim($rawValue); } diff --git a/src/ProjectActor.php b/src/ProjectActor.php index 43fc046..432e8e4 100644 --- a/src/ProjectActor.php +++ b/src/ProjectActor.php @@ -12,6 +12,8 @@ use Ichiloto\Editor\Database\PhpDataFile; use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Engine\Entities\Enumerations\WeaponType; +use InvalidArgumentException; use RuntimeException; /** @@ -32,6 +34,8 @@ final class ProjectActor * The sentinel option meaning "no class reference" in the editor picker. */ public const string CLASS_NONE = 'none'; + /** The attack style of a character with no weapon of their own: the key is absent. */ + public const string ATTACK_STYLE_UNARMED = 'unarmed'; /** * @param array $payload @@ -176,6 +180,33 @@ public function getClassName(): string return is_string($className) ? $className : ''; } + /** + * Returns the actor's own attack style as written: the weapon type the + * character fights with when no weapon is equipped (their own weapon, + * part of who they are, with no stats), from `data.attackStyle`. + * + * @return string The authored value, or an empty string when they fight unarmed. + */ + public function getAttackStyle(): string + { + $style = $this->getData()['attackStyle'] ?? ''; + + return is_string($style) ? $style : ''; + } + + /** + * Returns the Engine weapon type an attack style names, read by the + * Engine's own rule, or null when it names none. + */ + public static function findAttackStyleType(string $style): ?WeaponType + { + try { + return WeaponType::require($style); + } catch (InvalidArgumentException) { + return null; + } + } + /** * Returns the actor level. * @@ -548,6 +579,24 @@ public function setField(string $field, mixed $value): void return; } + if ($field === 'attackStyle') { + $style = trim((string) $value); + + // Unarmed is the absence of a style, so it removes the key; any + // other value is written as the Engine's own spelling, and one + // the Engine does not know is refused before anything changes. + if ($style === '' || strtolower($style) === self::ATTACK_STYLE_UNARMED) { + unset($this->payload['data']['attackStyle']); + } else { + $type = self::findAttackStyleType($style) + ?? throw new RuntimeException(sprintf('The Engine has no weapon type "%s".', $style)); + $this->payload['data']['attackStyle'] = $type->value; + } + + $this->touchState(); + return; + } + if ($field === 'summons') { // A list of stable summon ids, each at most once, in the order // chosen; an empty list removes the key so the actor reads as diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index af97746..dbfb851 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -19,6 +19,7 @@ use Ichiloto\Editor\ActorStatPreview; use Ichiloto\Editor\EquipmentOptimizationPolicy; use Ichiloto\Editor\PermanentGrowthCatalog; +use Ichiloto\Editor\ProjectActor; use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\ProjectQuest; use Ichiloto\Editor\ProjectMap; @@ -300,6 +301,14 @@ protected function checkActorDefinitions(ProjectWorkspace $workspace): array 'For an absent id, use Freeze current name as ID in the actor editor or the CLI validation migration before renaming. Malformed explicit ids require manual correction; no file is repaired automatically.'); } + if ($actor->getAttackStyle() !== '' && ProjectActor::findAttackStyleType($actor->getAttackStyle()) === null) { + $issues[] = Issue::error( + $where, + sprintf('Its attack style "%s" is not a weapon type the Engine knows.', $actor->getAttackStyle()), + 'Choose one on the Attack Style row, or remove it to fight unarmed.' + ); + } + $variants = $actor->getNaturalVariants(); $default = $actor->getDefaultNaturalVariantId(); diff --git a/tests/Unit/ActorAttackStyleTest.php b/tests/Unit/ActorAttackStyleTest.php new file mode 100644 index 0000000..a6df91e --- /dev/null +++ b/tests/Unit/ActorAttackStyleTest.php @@ -0,0 +1,96 @@ + 120, 'height' => 40]); + setEditorProperty($editor, 'databaseCategoryIndex', DatabaseCatalog::indexOf('actors')); + + return $editor; +} + +/** @return array */ +function attackStyleRow(Editor $editor): array +{ + return array_find(callEditorMethod($editor, 'getDatabaseSettingsFields'), + static fn(array $field): bool => ($field['field'] ?? null) === 'attackStyle') + ?? throw new RuntimeException('The Actors database offers no Attack Style row.'); +} + +it('offers unarmed and every Engine weapon type, and writes the Engine spelling over the authored actor', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + $before = require $path; + $editor = attackStyleEditor($root); + $row = attackStyleRow($editor); + + expect($row['options'])->toBe(['unarmed', ...array_map(static fn(WeaponType $type): string => $type->value, WeaponType::cases())]) + ->and($row['value'])->toBe('Unarmed'); + + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $row, 'sword'); + getEditorProperty($editor, 'workspace')->actorDatabase->save(); + $saved = require $path; + + expect($saved['data']['attackStyle'])->toBe('Sword') + ->and(array_diff_key($saved['data'], ['attackStyle' => true]))->toBe($before['data']) + ->and(attackStyleRow($editor)['value'])->toBe('Sword'); +}); + +it('removes the key for unarmed, undoes and redoes, and refuses a weapon type the Engine does not have', function () { + $root = makeTemporaryProject(); + $editor = attackStyleEditor($root); + /** @var CommandHistory $history */ + $history = getEditorProperty($editor, 'history'); + $actor = getEditorProperty($editor, 'workspace')->actorDatabase->getActorByIndex(0); + + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', attackStyleRow($editor), 'Dagger'); + expect($actor->getAttackStyle())->toBe('Dagger'); + + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', attackStyleRow($editor), 'unarmed'); + expect($actor->getData())->not->toHaveKey('attackStyle'); + + $history->undo(); + expect($actor->getAttackStyle())->toBe('Dagger'); + $history->undo(); + expect($actor->getData())->not->toHaveKey('attackStyle'); + $history->redo(); + expect($actor->getAttackStyle())->toBe('Dagger'); + + expect(fn() => $actor->setField('attackStyle', 'Lance'))->toThrow(RuntimeException::class, 'The Engine has no weapon type "Lance".') + ->and($actor->getAttackStyle())->toBe('Dagger'); +}); + +it('reports a written attack style the Engine does not know, and none for a known one in any case', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + $write = static function (string $style) use ($path): void { + $actor = require $path; + $actor['data']['attackStyle'] = $style; + file_put_contents($path, " array_map(static fn($issue): string => $issue->message, + new ProjectValidator()->validate(ProjectWorkspace::fromProject($root))); + + $write('Lance'); + expect($messages())->toContain('Its attack style "Lance" is not a weapon type the Engine knows.'); + + $write('staff'); + expect(implode("\n", $messages()))->not->toContain('attack style') + ->and(ProjectActorDatabase::fromProject($root)->getActorByIndex(0)->getAttackStyle())->toBe('staff'); +}); From b963c98048ab2a09770062c816a8aab8e23d6b0d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 12:34:01 +0200 Subject: [PATCH 110/344] feat(session): serve one editor session to native interfaces The GUI editor (gui-editor/README.md, Stage 1) edits through the same PHP services as the terminal editor, never a second save model. This adds the first shared session and its host, and moves the canvas commit path out of the terminal shell so both interfaces use one implementation. - Canvas/CanvasEditor: plans the tiles that follow glyphs and writes glyphs and tiles as one command, returned applied and unrecorded; refusals are thrown, an ambiguous piece is reported, nothing touches a cursor, status line or dialog. GlyphTileCanvas, PieceCanvas and Editor now call it (writeCanvasCells, resolvePaintStyle, the planner and tile reader moved here); terminal behaviour is unchanged. - EventTypeCatalog::describeClass owns an event class's label (moved from Editor::resolveEventTypeLabel, which delegates). - Session/EditorSession: opens a project, describes its maps and database categories, reads a map as an interface draws it (layers' glyphs and colours, events and their cells, NPCs), paints a stroke as one undo step against a map revision (a stale revision is refused), undoes and redoes across maps naming the maps changed, and saves through ProjectMap::save with MapValidator warnings and backups. - Session/SessionHost: versioned JSON lines over a child process's standard streams; errors answered by kind (refusal, request, failure), diagnostics on standard error only. --- src/Canvas/CanvasEditor.php | 213 ++++++++++++++++ src/Canvas/GlyphTileCanvas.php | 77 +----- src/Canvas/PieceCanvas.php | 16 +- src/Editor.php | 97 +------ src/Events/EventTypeCatalog.php | 20 ++ src/Session/EditorSession.php | 306 +++++++++++++++++++++++ src/Session/InvalidRequest.php | 12 + src/Session/SessionHost.php | 165 ++++++++++++ src/Session/SessionRefusal.php | 15 ++ tests/Unit/Session/EditorSessionTest.php | 120 +++++++++ 10 files changed, 864 insertions(+), 177 deletions(-) create mode 100644 src/Canvas/CanvasEditor.php create mode 100644 src/Session/EditorSession.php create mode 100644 src/Session/InvalidRequest.php create mode 100644 src/Session/SessionHost.php create mode 100644 src/Session/SessionRefusal.php create mode 100644 tests/Unit/Session/EditorSessionTest.php diff --git a/src/Canvas/CanvasEditor.php b/src/Canvas/CanvasEditor.php new file mode 100644 index 0000000..baa4c34 --- /dev/null +++ b/src/Canvas/CanvasEditor.php @@ -0,0 +1,213 @@ + $writes The glyphs the edit writes. + * @param array $choices The role key chosen for a glyph, or null for no tiles. + * @param bool $repaint Whether a glyph painted over itself may take another role. + * @param list $excludedLayers Tile layers the edit sets itself. + * @return array{tiles: array>, unresolved: array>}|null + * The tiles and the glyphs still waiting on a choice, or null when the layer has no pieces to follow. + * @throws MapSourceRefusal When a tile layer the pieces draw on cannot be read. + */ + public static function plan(ProjectMap $map, string $layerId, array $writes, array $choices = [], bool $repaint = false, + array $excludedLayers = []): ?array + { + $planner = self::loadGlyphTilePlanner($map, $layerId, $excludedLayers); + if ($planner === null) { + return null; + } + $changes = []; + foreach ($writes as $write) { + if ($map->hasLayerCell($layerId, $write['x'], $write['y'])) { + $changes[] = ['x' => $write['x'], 'y' => $write['y'], + 'old' => $map->getLayerSymbol($layerId, $write['x'], $write['y']), 'new' => $write['symbol']]; + } + } + + return $planner->plan( + $changes, + static fn(int $x, int $y): ?string => $map->hasLayerCell($layerId, $x, $y) ? $map->getLayerSymbol($layerId, $x, $y) : null, + self::createTileReader($map), + $choices, + $repaint, + ); + } + + /** + * Writes glyphs onto a layer and tiles onto the tile layers, and returns + * the command that undoes and redoes both, already applied. The command + * is null when nothing changed. + * + * @param array $writes + * @param array> $tiles Tile cells keyed by tile layer name. + * @return array{command: ?Command, changed: int} The command and the number of glyph cells that changed. + * @throws MapSourceRefusal When the tile layers cannot take the tiles; nothing is written then. + */ + public static function apply(ProjectMap $map, string $layerId, array $writes, string $label, array $tiles = []): array + { + if ($tiles === []) { + [$stroke, $changed] = self::writeCells($map, $layerId, $writes, $label); + + return ['command' => $stroke->hasChanges() ? $stroke : null, 'changed' => $changed]; + } + $tilesBefore = $map->getTileLayerSources(); + $map->writeTileCells($tiles); + $tilesAfter = $map->getTileLayerSources(); + [$stroke, $changed] = self::writeCells($map, $layerId, $writes, $label); + $command = $stroke->hasChanges() || $tilesAfter !== $tilesBefore + ? self::combineStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter) + : null; + + return ['command' => $command, 'changed' => $changed]; + } + + /** + * Writes cells onto one layer and returns the stroke that undoes them, + * unrecorded, so a caller can record it alone or as part of a larger + * step. Cells outside the layer are skipped. + * + * @param array $writes The cells to write. + * @return array{0: PaintStrokeCommand, 1: int} The stroke and the number of cells that actually changed. + */ + public static function writeCells(ProjectMap $map, string $layer, array $writes, string $label): array + { + $stroke = new PaintStrokeCommand($map, $layer, $label); + $changed = 0; + + foreach ($writes as $write) { + if (! $map->hasLayerCell($layer, $write['x'], $write['y'])) { + continue; + } + + $oldSymbol = $map->getLayerSymbol($layer, $write['x'], $write['y']); + $oldStyle = $map->getLayerCellStyle($layer, $write['x'], $write['y']); + [$newPrefix, $newSuffix] = isset($write['style']) + ? [$write['style']['prefix'], $write['style']['suffix']] + : self::resolvePaintStyle($write['symbol'], $write['color'] ?? null, $oldStyle); + $map->setLayerCell($layer, $write['x'], $write['y'], $write['symbol'], $newPrefix, $newSuffix); + $newSymbol = $map->getLayerSymbol($layer, $write['x'], $write['y']); + $stroke->appendCell($write['x'], $write['y'], $oldSymbol, $newSymbol, $oldStyle['prefix'], $oldStyle['suffix'], + $newPrefix, $newSuffix); + + if ($oldSymbol !== $newSymbol || $oldStyle['prefix'] !== $newPrefix || $oldStyle['suffix'] !== $newSuffix) { + $changed++; + } + } + + return [$stroke, $changed]; + } + + /** + * Resolves the styling bytes a paint writes. + * + * The colour directive follows the brush contract: null keeps the + * cell's existing styling byte-for-byte, an empty string paints without + * colour, and any other value becomes an `fg=` tag. A space is always + * uncoloured, so erasing never leaves invisible styling behind. + * + * @param array{prefix: string, suffix: string} $oldStyle The cell's current styling. + * @return array{0: string, 1: string} The prefix and suffix to write. + */ + public static function resolvePaintStyle(string $symbol, ?string $colorDirective, array $oldStyle): array + { + if ($symbol === ' ') { + return ['', '']; + } + + if ($colorDirective === null) { + return [$oldStyle['prefix'], $oldStyle['suffix']]; + } + + if ($colorDirective === '') { + return ['', '']; + } + + return [sprintf('', $colorDirective), '']; + } + + /** + * One undo step for a glyph stroke and the tile layer change made with it, + * such as a stamped piece or a pasted block. + * + * @param array $tilesBefore Tile layer sources before the change. + * @param array $tilesAfter Tile layer sources after it. + */ + public static function combineStrokeWithTiles(string $label, ProjectMap $map, PaintStrokeCommand $stroke, + array $tilesBefore, array $tilesAfter): Command + { + return new GenericCommand($label, + static function () use ($stroke, $map, $tilesAfter): void { + $stroke->execute(); + $map->restoreTileLayerSources($tilesAfter); + }, + static function () use ($stroke, $map, $tilesBefore): void { + $stroke->undo(); + $map->restoreTileLayerSources($tilesBefore); + }, + ); + } + + /** The planner for a gameplay layer's pieces, or null when it has none to follow. */ + public static function loadGlyphTilePlanner(ProjectMap $map, string $layerId, array $excludedLayers = []): ?GlyphTilePlanner + { + $layer = array_find($map->getLayers(), static fn(array $candidate): bool => $candidate['id'] === $layerId); + if ($layer === null || $layer['id'] === MapLayers::EVENT || $layer['decoration']) { + return null; + } + try { + $pieces = $map->loadTileset()?->pieces ?? []; + } catch (InvalidArgumentException | RuntimeException) { + return null; + } + + return $pieces === [] ? null : GlyphTilePlanner::fromPieces($pieces, $layer['name'], $excludedLayers); + } + + /** + * Reads tile entries cell by cell, each layer once: `0` where a layer + * has no tile, or the map has no such layer. + * + * @return Closure(string, int, int): string + */ + public static function createTileReader(ProjectMap $map): Closure + { + $layers = []; + + return static function (string $layer, int $x, int $y) use ($map, &$layers): string { + $layers[$layer] ??= $map->readTileEntries([$layer], 0, 0, $map->getWidth(), $map->getHeight())[$layer] ?? []; + + return $layers[$layer][$y][$x] ?? (string) TileId::EMPTY; + }; + } +} diff --git a/src/Canvas/GlyphTileCanvas.php b/src/Canvas/GlyphTileCanvas.php index f5b7ceb..b53b83f 100644 --- a/src/Canvas/GlyphTileCanvas.php +++ b/src/Canvas/GlyphTileCanvas.php @@ -10,7 +10,6 @@ use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\Status\StatusLevel; -use Ichiloto\Engine\Rendering\Tilesets\TileId; use InvalidArgumentException; use RuntimeException; @@ -66,27 +65,17 @@ private function commitCanvasWrites(ProjectMap $map, array $writes, string $labe $tiles = [...($plan['tiles'] ?? []), ...$tiles]; $this->finalizeActiveStroke(); - if ($tiles === []) { - [$stroke, $changed] = $this->writeCanvasCells($map, $layer, $writes, $label); - if ($stroke->hasChanges()) { - $this->recordCommand($stroke); - } - return $changed; - } - $tilesBefore = $map->getTileLayerSources(); try { - $map->writeTileCells($tiles); + $applied = CanvasEditor::apply($map, $layer, $writes, $label, $tiles); } catch (MapSourceRefusal $refusal) { $this->setStatus($refusal->getMessage(), StatusLevel::WARN); return null; } - $tilesAfter = $map->getTileLayerSources(); - [$stroke, $changed] = $this->writeCanvasCells($map, $layer, $writes, $label); - if ($stroke->hasChanges() || $tilesAfter !== $tilesBefore) { - $this->recordStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter); + if ($applied['command'] !== null) { + $this->recordCommand($applied['command']); } - return $changed; + return $applied['changed']; } /** @@ -147,27 +136,8 @@ private function followStrokeWithTiles(ProjectMap $map, array $writes, Closure $ private function planGlyphTiles(ProjectMap $map, string $layerId, array $writes, array $choices, bool $repaint, array $excludedLayers = []): ?array { - $planner = $this->loadGlyphTilePlanner($map, $layerId, $excludedLayers); - if ($planner === null) { - return null; - } - $changes = []; - foreach ($writes as $write) { - if ($map->hasLayerCell($layerId, $write['x'], $write['y'])) { - $changes[] = ['x' => $write['x'], 'y' => $write['y'], - 'old' => $map->getLayerSymbol($layerId, $write['x'], $write['y']), 'new' => $write['symbol']]; - } - } - - return $planner->plan( - $changes, - static fn(int $x, int $y): ?string => $map->hasLayerCell($layerId, $x, $y) ? $map->getLayerSymbol($layerId, $x, $y) : null, - $this->createTileReader($map), - $choices, - $repaint, - ); + return CanvasEditor::plan($map, $layerId, $writes, $choices, $repaint, $excludedLayers); } - /** * Draws the tiles of the glyphs already on the layer being edited, as if * each were painted again: every glyph a piece draws gets that piece's @@ -236,39 +206,6 @@ private function drawTilesForLayerGlyphs(array $choices = []): void $this->renderCanvasArea(); } - /** The planner for a gameplay layer's pieces, or null when it has none to follow. */ - private function loadGlyphTilePlanner(ProjectMap $map, string $layerId, array $excludedLayers = []): ?GlyphTilePlanner - { - $layer = array_find($map->getLayers(), static fn(array $candidate): bool => $candidate['id'] === $layerId); - if ($layer === null || $layer['id'] === MapLayers::EVENT || $layer['decoration']) { - return null; - } - try { - $pieces = $map->loadTileset()?->pieces ?? []; - } catch (InvalidArgumentException | RuntimeException) { - return null; - } - - return $pieces === [] ? null : GlyphTilePlanner::fromPieces($pieces, $layer['name'], $excludedLayers); - } - - /** - * Reads tile entries cell by cell, each layer once: `0` where a layer - * has no tile, or the map has no such layer. - * - * @return Closure(string, int, int): string - */ - private function createTileReader(ProjectMap $map): Closure - { - $layers = []; - - return static function (string $layer, int $x, int $y) use ($map, &$layers): string { - $layers[$layer] ??= $map->readTileEntries([$layer], 0, 0, $map->getWidth(), $map->getHeight())[$layer] ?? []; - - return $layers[$layer][$y][$x] ?? (string) TileId::EMPTY; - }; - } - /** * Asks which piece a glyph is, listing its roles and No tiles, with the * brush's last answer for it first in line. The edit runs again on an @@ -347,13 +284,13 @@ private function pickPaintPieceRole(ProjectMap $map, int $x, int $y): string { $this->paintPieceRole = null; $layerId = $this->getActiveCanvasLayer(); - $planner = $this->loadGlyphTilePlanner($map, $layerId); + $planner = CanvasEditor::loadGlyphTilePlanner($map, $layerId); if ($planner === null || ! $map->hasLayerCell($layerId, $x, $y)) { return ''; } $symbol = $map->getLayerSymbol($layerId, $x, $y); try { - $role = $planner->findPlayedRole($symbol, $x, $y, $this->createTileReader($map)); + $role = $planner->findPlayedRole($symbol, $x, $y, CanvasEditor::createTileReader($map)); } catch (MapSourceRefusal) { return ''; } diff --git a/src/Canvas/PieceCanvas.php b/src/Canvas/PieceCanvas.php index a5faa93..0f20d69 100644 --- a/src/Canvas/PieceCanvas.php +++ b/src/Canvas/PieceCanvas.php @@ -304,7 +304,7 @@ private function stampPiece(ProjectMap $map, TilesetPiece $piece): void } } } - [$stroke] = $this->writeCanvasCells($map, $layer, $writes, 'Piece stamp'); + [$stroke] = CanvasEditor::writeCells($map, $layer, $writes, 'Piece stamp'); if (! $stroke->hasChanges() && $tilesAfter === $tilesBefore) { $this->setStatus(sprintf('%s is already at (%d, %d).', $piece->name, $x, $y)); $this->renderCanvasArea(); @@ -324,18 +324,8 @@ private function stampPiece(ProjectMap $map, TilesetPiece $piece): void */ private function recordStrokeWithTiles(string $label, ProjectMap $map, PaintStrokeCommand $stroke, array $tilesBefore, array $tilesAfter): void { - $this->recordCommand(new GenericCommand($label, - static function () use ($stroke, $map, $tilesAfter): void { - $stroke->execute(); - $map->restoreTileLayerSources($tilesAfter); - }, - static function () use ($stroke, $map, $tilesBefore): void { - $stroke->undo(); - $map->restoreTileLayerSources($tilesBefore); - }, - )); + $this->recordCommand(CanvasEditor::combineStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter)); } - /** * Draws a connected piece: the cell at the cursor without an anchor, * otherwise the line or room outline from the anchor to the cursor. The @@ -438,7 +428,7 @@ private function applyConnectedPiece(ProjectMap $map, TilesetPiece $piece, array 'symbol' => $cell['shape'] === null ? ' ' : $piece->shapes[$cell['shape']], 'color' => isset($drawnKeys["{$cell['x']},{$cell['y']}"]) ? $this->selectedPaintColor : null, ], $cells); - [$stroke] = $this->writeCanvasCells($map, $layer, $writes, $label); + [$stroke] = CanvasEditor::writeCells($map, $layer, $writes, $label); if (! $stroke->hasChanges() && $tilesAfter === $tilesBefore) { return false; } diff --git a/src/Editor.php b/src/Editor.php index 7a38e51..ad525e4 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -13,6 +13,7 @@ use Ichiloto\Editor\Actors\ActorIdentityMigration; use Ichiloto\Editor\Actors\ActorIdentityMigrationPlan; use Ichiloto\Editor\Backup\BackupWriter; +use Ichiloto\Editor\Canvas\CanvasEditor; use Ichiloto\Editor\Canvas\CanvasTool; use Ichiloto\Editor\Canvas\Clipboard; use Ichiloto\Editor\Canvas\ToolGeometry; @@ -4773,7 +4774,7 @@ private function paintCanvasStroke( $oldSymbol = $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']); $oldStyle = $selectedMap->getLayerCellStyle($this->getActiveCanvasLayer(), $point['x'], $point['y']); - [$newPrefix, $newSuffix] = $this->resolvePaintStyle($symbol, $this->selectedPaintColor, $oldStyle); + [$newPrefix, $newSuffix] = CanvasEditor::resolvePaintStyle($symbol, $this->selectedPaintColor, $oldStyle); $selectedMap->setLayerCell($this->getActiveCanvasLayer(), $point['x'], $point['y'], $symbol, $newPrefix, $newSuffix); $newSymbol = $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']); $this->activeStrokeCommand->appendCell( @@ -4998,88 +4999,6 @@ private function applyCanvasWrites(ProjectMap $map, array $writes, string $label return $this->commitCanvasWrites($map, $writes, $label, retry: $retry, choices: $choices); } - /** - * Writes cells onto one layer and returns the stroke that undoes them, - * unrecorded, so a caller can record it alone or as part of a larger - * step. Cells outside the layer are skipped. - * - * @param ProjectMap $map The target map. - * @param string $layer The layer id to write. - * @param array $writes The cells to write. - * @param string $label The undo/status label. - * @return array{0: PaintStrokeCommand, 1: int} The stroke and the number of cells that actually changed. - */ - private function writeCanvasCells(ProjectMap $map, string $layer, array $writes, string $label): array - { - $stroke = new PaintStrokeCommand($map, $layer, $label); - $changed = 0; - - foreach ($writes as $write) { - if (! $map->hasLayerCell($layer, $write['x'], $write['y'])) { - continue; - } - - $oldSymbol = $map->getLayerSymbol($layer, $write['x'], $write['y']); - - $oldStyle = $map->getLayerCellStyle($layer, $write['x'], $write['y']); - [$newPrefix, $newSuffix] = isset($write['style']) - ? [$write['style']['prefix'], $write['style']['suffix']] - : $this->resolvePaintStyle( - $write['symbol'], - $write['color'] ?? null, - $oldStyle, - ); - $map->setLayerCell($layer, $write['x'], $write['y'], $write['symbol'], $newPrefix, $newSuffix); - $newSymbol = $map->getLayerSymbol($layer, $write['x'], $write['y']); - $stroke->appendCell( - $write['x'], - $write['y'], - $oldSymbol, - $newSymbol, - $oldStyle['prefix'], - $oldStyle['suffix'], - $newPrefix, - $newSuffix, - ); - - if ($oldSymbol !== $newSymbol || $oldStyle['prefix'] !== $newPrefix || $oldStyle['suffix'] !== $newSuffix) { - $changed++; - } - } - - return [$stroke, $changed]; - } - - /** - * Resolves the styling bytes a tile paint writes. - * - * The colour directive follows the brush contract: null keeps the - * cell's existing styling byte-for-byte, an empty string paints without - * colour, and any other value becomes an `fg=` tag. A space is always - * uncoloured, so erasing never leaves invisible styling behind. - * - * @param string $symbol The symbol being painted. - * @param string|null $colorDirective The brush colour directive. - * @param array{prefix: string, suffix: string} $oldStyle The cell's current styling. - * @return array{0: string, 1: string} The prefix and suffix to write. - */ - private function resolvePaintStyle(string $symbol, ?string $colorDirective, array $oldStyle): array - { - if ($symbol === ' ') { - return ['', '']; - } - - if ($colorDirective === null) { - return [$oldStyle['prefix'], $oldStyle['suffix']]; - } - - if ($colorDirective === '') { - return ['', '']; - } - - return [sprintf('', $colorDirective), '']; - } - /** * Paints one symbol across a cell list as a single stroke. * @@ -16090,17 +16009,7 @@ private function buildEventOptionDialogRows(array $entries, int $availableRows): */ private function resolveEventTypeLabel(?string $className): string { - if ($className === null || $className === '') { - return 'Unset'; - } - - foreach (EventTypeCatalog::all() as $definition) { - if ($definition->className === $className) { - return $definition->label; - } - } - - return basename(str_replace('\\', '/', $className)); + return EventTypeCatalog::describeClass($className); } /** diff --git a/src/Events/EventTypeCatalog.php b/src/Events/EventTypeCatalog.php index 23c1bb2..347ddf2 100644 --- a/src/Events/EventTypeCatalog.php +++ b/src/Events/EventTypeCatalog.php @@ -173,4 +173,24 @@ public static function indexOfClass(?string $className): int return 0; } + + /** + * Returns the label an event of this trigger class is shown by: Unset for + * none, its catalog label, or the class's short name for one the catalog + * does not list. + */ + public static function describeClass(?string $className): string + { + if ($className === null || $className === '') { + return 'Unset'; + } + + foreach (self::all() as $definition) { + if ($definition->className === $className) { + return $definition->label; + } + } + + return basename(str_replace('\\', '/', $className)); + } } diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php new file mode 100644 index 0000000..4a3a88a --- /dev/null +++ b/src/Session/EditorSession.php @@ -0,0 +1,306 @@ +workspace = $workspace; + } + + /** Opens the project at a root, as the terminal editor opens it. */ + public static function open(string $projectRoot): self + { + $workspace = ProjectWorkspace::fromProject($projectRoot); + + return new self($workspace, new CommandHistory(), + new BackupWriter(BackupSettings::fromProject($workspace->projectRoot), $workspace->projectRoot)); + } + + /** + * The project's name, its maps and its database categories. + * + * @return array{name: string, root: string, maps: list>, databases: list} + */ + public function describeProject(): array + { + return [ + 'name' => $this->workspace->projectName, + 'root' => $this->workspace->projectRoot, + 'maps' => $this->describeMaps(), + 'databases' => array_map(static fn($category): array => [ + 'key' => $category->key, + 'label' => $category->label, + 'description' => $category->description, + 'implemented' => $category->isImplemented, + ], DatabaseCatalog::all()), + ]; + } + + /** + * Every map, by stable id, with what a list shows about it. + * + * @return list + */ + public function describeMaps(): array + { + return array_map(static fn(ProjectMap $map): array => [ + 'id' => $map->mapId, + 'name' => $map->getDisplayName(), + 'dirty' => $map->isDirty(), + 'readOnly' => $map->getGridSourceIssue(), + 'revision' => $map->stateVersion(), + ], $this->workspace->maps); + } + + /** + * A map as an interface draws it: its layers' glyphs and colours, its + * events and its NPCs, at its current revision, unsaved changes included. + * + * @return array + * @throws SessionRefusal When no map has the id. + */ + public function readMap(string $mapId): array + { + $map = $this->requireMap($mapId); + $width = $map->getWidth(); + $height = $map->getHeight(); + $layers = []; + + foreach ($map->getLayers() as $layer) { + $rows = []; + $colors = []; + for ($y = 0; $y < $height; $y++) { + $row = []; + for ($x = 0; $x < $width; $x++) { + $row[] = $map->getLayerSymbol($layer['id'], $x, $y); + $color = $map->getLayerColor($layer['id'], $x, $y); + if ($color !== null) { + $colors[] = [$x, $y, $color]; + } + } + $rows[] = $row; + } + $layers[] = [ + 'id' => $layer['id'], + 'name' => $layer['name'], + 'label' => MapLayers::formatLabel((string) $layer['name']), + 'order' => $layer['order'], + 'decoration' => (bool) $layer['decoration'], + 'event' => $layer['id'] === MapLayers::EVENT, + 'rows' => $rows, + 'colors' => $colors, + ]; + } + + $events = []; + foreach ($map->getEventDefinitions() as $marker => $definition) { + $area = $map->getEventArea((string) $marker); + $events[] = [ + 'marker' => (string) $marker, + 'type' => EventTypeCatalog::describeClass(is_array($definition) && is_string($definition['class'] ?? null) ? $definition['class'] : null), + 'cells' => $area === null ? [] : array_map(static fn(array $cell): array => [$cell[0], $cell[1]], $area->cells), + ]; + } + + $npcs = []; + foreach ($map->getNpcs()->all() as $index => $npc) { + $npcs[] = [ + 'index' => $index, + 'id' => $npc->getId(), + 'name' => $npc->getName(), + 'x' => $npc->getX(), + 'y' => $npc->getY(), + 'sprite' => $npc->getVisibleSprite(), + ]; + } + + return [ + 'id' => $map->mapId, + 'name' => $map->getDisplayName(), + 'width' => $width, + 'height' => $height, + 'revision' => $map->stateVersion(), + 'dirty' => $map->isDirty(), + 'readOnly' => $map->getGridSourceIssue(), + 'baseLayer' => $map->getBaseLayerId(), + 'layers' => $layers, + 'events' => $events, + 'npcs' => $npcs, + ]; + } + + /** + * Paints one glyph over cells of a layer, with the tiles that follow it, + * as one undo step. A glyph that could be several pieces is asked about: + * nothing changes until the edit is made again with the answer in + * `$choices` (a role key, or null for no tiles). + * + * @param list $cells The cells, as [x, y]. + * @param string|null $color A colour name or `#rrggbb`; null keeps each cell's colour, '' paints none. + * @param array $choices + * @return array{status: 'applied', changed: int, revision: int}|array{status: 'question', glyph: string, roles: list} + * @throws SessionRefusal When the map is unknown, stale or refuses the edit. + */ + public function paint(string $mapId, int $revision, string $layerId, array $cells, string $symbol, ?string $color = null, + array $choices = [], string $label = 'Paint'): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + if (! array_any($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $layerId)) { + throw new SessionRefusal(sprintf('%s has no layer %s.', $mapId, $layerId)); + } + $writes = array_map(static fn(array $cell): array => ['x' => (int) $cell[0], 'y' => (int) $cell[1], + 'symbol' => $symbol, 'color' => $layerId === MapLayers::EVENT ? '' : $color], $cells); + + try { + $plan = CanvasEditor::plan($map, $layerId, $writes, $choices, true); + if ($plan !== null && $plan['unresolved'] !== []) { + $glyph = (string) array_key_first($plan['unresolved']); + + return ['status' => 'question', 'glyph' => $glyph, 'roles' => array_map( + static fn(PieceRole $role): array => ['key' => $role->key, 'label' => $role->label], + $plan['unresolved'][$glyph], + )]; + } + $applied = CanvasEditor::apply($map, $layerId, $writes, $label, $plan['tiles'] ?? []); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($applied['command'] !== null) { + $this->history->record($applied['command']); + } + + return ['status' => 'applied', 'changed' => $applied['changed'], 'revision' => $map->stateVersion()]; + } + + /** + * Undoes the last change, wherever it was made. + * + * @return array{label: ?string, maps: list} What was undone and the maps it changed. + */ + public function undo(): array + { + return $this->traverseHistory(fn(): ?Command => $this->history->undo()); + } + + /** + * Redoes the last undone change. + * + * @return array{label: ?string, maps: list} + */ + public function redo(): array + { + return $this->traverseHistory(fn(): ?Command => $this->history->redo()); + } + + /** + * Saves one map through its transaction, after the warnings validation + * has for it, which never block a save. + * + * @return array{saved: string, warnings: list, backupFailures: list} + * @throws SessionRefusal When the map is unknown or refuses to save. + */ + public function saveMap(string $mapId): array + { + $map = $this->requireMap($mapId); + $warnings = MapValidator::validate($map, $this->getMapsById()); + $failures = []; + + try { + $saved = $map->save(function (string ...$paths) use (&$failures): void { + if ($this->backups->isEnabled() && $paths !== []) { + $failures = $this->backups->backup(...$paths)['failed']; + } + }); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + + return ['saved' => $saved, 'warnings' => array_values($warnings), 'backupFailures' => array_values($failures)]; + } + + /** Whether any map or database has changes not yet saved. */ + public function hasUnsavedChanges(): bool + { + return $this->workspace->hasUnsavedChanges(); + } + + /** + * Runs one history step and names the maps it changed. + * + * @param callable(): ?Command $step + * @return array{label: ?string, maps: list} + */ + private function traverseHistory(callable $step): array + { + $before = array_map(static fn(ProjectMap $map): int => $map->stateVersion(), $this->workspace->maps); + $command = $step(); + $changed = []; + foreach ($this->workspace->maps as $index => $map) { + if ($map->stateVersion() !== $before[$index]) { + $changed[] = $map->mapId; + } + } + + return ['label' => $command?->label, 'maps' => $changed]; + } + + /** @return array */ + private function getMapsById(): array + { + return array_combine($this->workspace->mapIds, $this->workspace->maps); + } + + /** @throws SessionRefusal */ + private function requireMap(string $mapId): ProjectMap + { + return $this->getMapsById()[$mapId] ?? throw new SessionRefusal(sprintf('There is no map %s.', $mapId)); + } + + /** @throws SessionRefusal When the map changed since the revision the caller saw. */ + private function requireCurrentMap(string $mapId, int $revision): ProjectMap + { + $map = $this->requireMap($mapId); + if ($map->stateVersion() !== $revision) { + throw new SessionRefusal(sprintf('%s changed since revision %d; reload it and edit again.', $mapId, $revision)); + } + + return $map; + } +} diff --git a/src/Session/InvalidRequest.php b/src/Session/InvalidRequest.php new file mode 100644 index 0000000..6e7094b --- /dev/null +++ b/src/Session/InvalidRequest.php @@ -0,0 +1,12 @@ +input)) !== false) { + $line = trim($line); + if ($line === '') { + continue; + } + $this->write($this->handle($line)); + } + } + + /** + * Answers one request line. + * + * @return array + */ + public function handle(string $line): array + { + try { + $request = json_decode($line, true, 64, JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + return ['id' => null, 'error' => ['kind' => 'request', 'message' => 'Not JSON: ' . $error->getMessage()]]; + } + $id = is_array($request) ? ($request['id'] ?? null) : null; + $method = is_array($request) && is_string($request['method'] ?? null) ? $request['method'] : ''; + $params = is_array($request) && is_array($request['params'] ?? null) ? $request['params'] : []; + + try { + return ['id' => $id, 'result' => $this->dispatch($method, $params)]; + } catch (SessionRefusal $refusal) { + return ['id' => $id, 'error' => ['kind' => 'refusal', 'message' => $refusal->getMessage()]]; + } catch (InvalidRequest $invalid) { + return ['id' => $id, 'error' => ['kind' => 'request', 'message' => $invalid->getMessage()]]; + } catch (Throwable $failure) { + fwrite($this->diagnostics, sprintf("[session-host] %s: %s\n%s\n", $failure::class, $failure->getMessage(), + $failure->getTraceAsString())); + + return ['id' => $id, 'error' => ['kind' => 'failure', 'message' => $failure->getMessage()]]; + } + } + + /** + * @param array $params + * @return array|list> + */ + private function dispatch(string $method, array $params): array + { + if ($method === 'hello') { + $protocol = $params['protocol'] ?? null; + if ($protocol !== self::PROTOCOL) { + throw new InvalidRequest(sprintf('This host speaks protocol %d, not %s.', self::PROTOCOL, var_export($protocol, true))); + } + $this->session = EditorSession::open(self::requireString($params, 'project')); + + return ['protocol' => self::PROTOCOL, 'project' => $this->session->describeProject()]; + } + + $session = $this->session ?? throw new InvalidRequest('Say hello with a project first.'); + + return match ($method) { + 'maps.list' => $session->describeMaps(), + 'map.read' => $session->readMap(self::requireString($params, 'map')), + 'map.paint' => $session->paint( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + self::requireCells($params), + self::requireString($params, 'symbol'), + array_key_exists('color', $params) && (is_string($params['color']) || $params['color'] === null) ? $params['color'] : null, + self::readChoices($params), + is_string($params['label'] ?? null) ? $params['label'] : 'Paint', + ), + 'map.save' => $session->saveMap(self::requireString($params, 'map')), + 'history.undo' => $session->undo(), + 'history.redo' => $session->redo(), + 'project.dirty' => ['dirty' => $session->hasUnsavedChanges()], + default => throw new InvalidRequest(sprintf('Unknown method "%s".', $method)), + }; + } + + /** @param array $response */ + private function write(array $response): void + { + fwrite($this->output, json_encode($response, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"); + fflush($this->output); + } + + /** @param array $params */ + private static function requireString(array $params, string $key): string + { + return is_string($params[$key] ?? null) ? $params[$key] : throw new InvalidRequest(sprintf('"%s" must be a string.', $key)); + } + + /** @param array $params */ + private static function requireInt(array $params, string $key): int + { + return is_int($params[$key] ?? null) ? $params[$key] : throw new InvalidRequest(sprintf('"%s" must be an integer.', $key)); + } + + /** + * @param array $params + * @return list + */ + private static function requireCells(array $params): array + { + $cells = $params['cells'] ?? null; + if (! is_array($cells) || ! array_is_list($cells) || ! array_all($cells, static fn(mixed $cell): bool => + is_array($cell) && count($cell) === 2 && is_int($cell[0] ?? null) && is_int($cell[1] ?? null))) { + throw new InvalidRequest('"cells" must be a list of [x, y] integer pairs.'); + } + + return $cells; + } + + /** + * @param array $params + * @return array + */ + private static function readChoices(array $params): array + { + $choices = $params['choices'] ?? []; + if (! is_array($choices) || ! array_all($choices, static fn(mixed $choice): bool => is_string($choice) || $choice === null)) { + throw new InvalidRequest('"choices" must map glyphs to a role key or null.'); + } + + return $choices; + } +} diff --git a/src/Session/SessionRefusal.php b/src/Session/SessionRefusal.php new file mode 100644 index 0000000..75f2ea2 --- /dev/null +++ b/src/Session/SessionRefusal.php @@ -0,0 +1,15 @@ + $candidate['id'] === $layer); + + return $found['rows'][$y][$x] ?? throw new RuntimeException("No cell {$x},{$y} on {$layer}."); +} + +it('describes the project: its maps and every database category', function () { + $session = EditorSession::open(makeTemporaryProject()); + $project = $session->describeProject(); + + expect(array_column($project['maps'], 'id'))->toContain('test-map') + ->and($project['maps'][0])->toHaveKeys(['id', 'name', 'dirty', 'readOnly', 'revision']) + ->and(array_column($project['databases'], 'key'))->toBe(array_map(static fn($category): string => $category->key, DatabaseCatalog::all())); +}); + +it('reads a map as an interface draws it: layers, glyphs, events and NPCs', function () { + $root = makeTemporaryProject(); + $map = EditorSession::open($root)->readMap('test-map'); + $authored = ProjectWorkspace::fromProject($root)->maps[0]; + + expect($map['width'])->toBe($authored->getWidth()) + ->and($map['height'])->toBe($authored->getHeight()) + ->and(array_column($map['layers'], 'id'))->toContain($map['baseLayer'], 'event') + ->and(sessionCell($map, $map['baseLayer'], 0, 0))->toBe($authored->getLayerSymbol($map['baseLayer'], 0, 0)) + ->and(array_column($map['events'], 'marker'))->toBe(array_keys($authored->getEventDefinitions())) + ->and($map['dirty'])->toBeFalse(); +}); + +it('paints as one undo step, undoes and redoes it, and refuses an edit made against an older revision', function () { + $session = EditorSession::open(makeTemporaryProject()); + $map = $session->readMap('test-map'); + $layer = $map['baseLayer']; + $before = sessionCell($map, $layer, 2, 2); + + $result = $session->paint('test-map', $map['revision'], $layer, [[2, 2], [3, 2]], '%', 'red'); + $painted = $session->readMap('test-map'); + + expect($result['status'])->toBe('applied') + ->and($result['changed'])->toBe(2) + ->and($result['revision'])->toBe($painted['revision']) + ->and(sessionCell($painted, $layer, 2, 2))->toBe('%') + ->and(array_find(array_find($painted['layers'], static fn(array $l): bool => $l['id'] === $layer)['colors'], + static fn(array $c): bool => $c[0] === 2 && $c[1] === 2)[2] ?? null)->toBe('red') + ->and($painted['dirty'])->toBeTrue(); + + // The same request again carries a revision that is no longer current. + expect(fn() => $session->paint('test-map', $map['revision'], $layer, [[4, 2]], '%')) + ->toThrow(SessionRefusal::class, 'changed since revision'); + + expect($session->undo())->toBe(['label' => 'Paint', 'maps' => ['test-map']]) + ->and(sessionCell($session->readMap('test-map'), $layer, 2, 2))->toBe($before); + expect($session->redo()['maps'])->toBe(['test-map']) + ->and(sessionCell($session->readMap('test-map'), $layer, 2, 2))->toBe('%'); + expect($session->undo()['label'])->toBe('Paint') + ->and($session->undo())->toBe(['label' => null, 'maps' => []]); +}); + +it('saves a painted map through its transaction, so a fresh load sees the edit', function () { + $root = makeTemporaryProject(); + $session = EditorSession::open($root); + $map = $session->readMap('test-map'); + $session->paint('test-map', $map['revision'], $map['baseLayer'], [[1, 1]], '#'); + + $saved = $session->saveMap('test-map'); + + expect($saved['saved'])->toBe('test-map') + ->and($session->hasUnsavedChanges())->toBeFalse() + ->and(ProjectWorkspace::fromProject($root)->maps[0]->getLayerSymbol($map['baseLayer'], 1, 1))->toBe('#'); +}); + +it('refuses an unknown map or layer with a reason an author reads', function () { + $session = EditorSession::open(makeTemporaryProject()); + $map = $session->readMap('test-map'); + + expect(fn() => $session->readMap('no-such-map'))->toThrow(SessionRefusal::class, 'There is no map no-such-map.') + ->and(fn() => $session->paint('test-map', $map['revision'], 'no-layer', [[0, 0]], '#')) + ->toThrow(SessionRefusal::class, 'test-map has no layer no-layer.'); +}); + +it('serves the session over its line protocol, answering refusals and bad requests by kind', function () { + $root = makeTemporaryProject(); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(int $id, string $method, array $params = []): array => + $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params])); + + expect($request(1, 'maps.list')['error']['kind'])->toBe('request') + ->and($request(2, 'hello', ['protocol' => 99, 'project' => $root])['error']['message'])->toContain('protocol 1') + ->and($host->handle('not json')['error']['kind'])->toBe('request'); + + $hello = $request(3, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + expect($hello['id'])->toBe(3) + ->and($hello['result']['protocol'])->toBe(SessionHost::PROTOCOL) + ->and(array_column($hello['result']['project']['maps'], 'id'))->toContain('test-map'); + + $map = $request(4, 'map.read', ['map' => 'test-map'])['result']; + expect($request(5, 'map.paint', ['map' => 'test-map', 'revision' => $map['revision'], 'layer' => $map['baseLayer'], + 'cells' => [[0, 0]], 'symbol' => '%'])['result']['status'])->toBe('applied') + ->and($request(6, 'map.paint', ['map' => 'test-map', 'revision' => $map['revision'], 'layer' => $map['baseLayer'], + 'cells' => [[0, 0]], 'symbol' => '#'])['error']['kind'])->toBe('refusal') + ->and($request(7, 'map.paint', ['map' => 'test-map', 'revision' => 1, 'layer' => 'tile', 'cells' => [[0]], 'symbol' => '#'])['error']) + ->toBe(['kind' => 'request', 'message' => '"cells" must be a list of [x, y] integer pairs.']) + ->and($request(8, 'no.such')['error']['message'])->toBe('Unknown method "no.such".') + ->and($request(9, 'history.undo')['result']['maps'])->toBe(['test-map']); +}); From ae6c2e72d27f0a5d0b52d13a2293e1b3015117cf Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 12:54:59 +0200 Subject: [PATCH 111/344] feat(database): show an equipment entry's own icon as legacy data Andrew: one icon per equipment type is the single source of truth wherever equipment is shown, including the terminal renderer. The Engine now shows equipment by its type's icon; an entry's own `icon` is legacy compatibility data. - Weapons and Armors (armors include accessories) show the entry's own icon as a read-only "Legacy Icon (type icon shown)" row, kept exactly as written; an edit beside it saves it unchanged. Consumable items keep an editable Icon. - Changed behaviour: an equipment entry's icon is no longer editable in the Database. - Shared fix: a read-only schema field (an entry's id, for one) was described without `editable => false`, so the pane opened a text editor on it and the record silently refused the write. Read-only rows now say so. --- docs/manual.md | 7 ++++ src/Database/ProjectRecordDatabase.php | 4 ++ src/Database/RecordSchemaCatalog.php | 14 +++++-- tests/Unit/EquipmentTypeIconTest.php | 52 ++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/EquipmentTypeIconTest.php diff --git a/docs/manual.md b/docs/manual.md index 9d39c6c..fe9566f 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1051,6 +1051,13 @@ views of `items.php`, and saving one of them, or all of them with `Ctrl+A`, reads the file once, composes every dirty category's changes against that one reading, and writes it once. +Equipment is shown everywhere, in every renderer, by the one icon of its +type (one for all swords, one for all daggers), which the game's theme +binds. A weapon's or armor's own `icon` is legacy compatibility data: the +Weapons and Armors records show it as a read-only `Legacy Icon` row and keep +it exactly as written. A consumable item's `Icon` stays its own and +editable. + Where identity cannot prove the address, nothing is written and the status line says why: two entries in the file declaring one id, an entry declaring none, or a save that would leave two entries declaring one id. Give each diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index c07fc77..2b394dc 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -2632,6 +2632,10 @@ private static function describeField(RecordField $field, string $value, string ]; if (! $isEditable || $field->isReadOnly) { + // Said outright, so the pane never opens an editor on a value the + // record would refuse to take. + $descriptor['editable'] = false; + return $descriptor; } diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 17d8c4c..87140cf 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -1004,9 +1004,15 @@ private static function tilesets(): RecordSchema /** * The shared read-only fields every inventory entry displays. * + * Equipment is shown everywhere by the one icon of its type (one for all + * swords, one for all daggers), from the theme; an equipment entry's own + * `icon` is legacy compatibility data, shown and kept exactly as written + * but no longer a presentation choice to edit. A consumable keeps its own. + * + * @param bool $isEquipment Whether the entry is equipment. * @return RecordField[] */ - private static function inventoryFields(): array + private static function inventoryFields(bool $isEquipment = false): array { return [ // Identity. The id is what a save, an alias and every reference @@ -1015,7 +1021,9 @@ private static function inventoryFields(): array new RecordField('id', 'Id', isReadOnly: true), new RecordField('name', 'Name'), new RecordField('description', 'Description'), - new RecordField('icon', 'Icon'), + $isEquipment + ? new RecordField('icon', 'Legacy Icon (type icon shown)', isReadOnly: true) + : new RecordField('icon', 'Icon'), // Compatibility references an older save may still name. new RecordField('aliases', 'Aliases', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), // Policy @@ -1097,7 +1105,7 @@ private static function parameterChangeFields(): array private static function equipmentFields(array $typeAndStats): array { return [ - ...self::inventoryFields(), + ...self::inventoryFields(isEquipment: true), // Slot and kind new RecordField( 'semanticSlot', diff --git a/tests/Unit/EquipmentTypeIconTest.php b/tests/Unit/EquipmentTypeIconTest.php new file mode 100644 index 0000000..1e1c2fd --- /dev/null +++ b/tests/Unit/EquipmentTypeIconTest.php @@ -0,0 +1,52 @@ +getSettingsFields($index), static fn(array $field): bool => ($field['field'] ?? null) === 'icon'); +} + +it('shows an armor or accessory\'s own icon as read-only legacy data', function () { + $database = ProjectRecordDatabase::fromProject(makeTemporaryProject(), RecordSchemaCatalog::forKey('armors')); + $row = iconRow($database, $database->addRecord()); + + expect($row)->not->toBeNull() + ->and($row['editable'] ?? true)->toBeFalse() + ->and($row['label'])->toContain('Legacy Icon'); +}); + +it('shows a weapon\'s own icon as read-only legacy data and keeps it through an edit', function () { + $root = makeTemporaryProject(); + $database = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('weapons')); + $before = $database->getRecords()[0]->toArray()->icon; + $row = iconRow($database, 0); + + expect($row)->not->toBeNull() + ->and($row['editable'] ?? true)->toBeFalse() + ->and($row['label'])->toContain('Legacy Icon'); + + $database->setField(0, 'description', 'Edited beside a legacy icon.'); + $database->save(); + $saved = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('weapons'))->getRecords()[0]->toArray(); + + expect($saved->description)->toBe('Edited beside a legacy icon.') + ->and($saved->icon)->toBe($before); +}); + +it('keeps a consumable\'s own icon editable', function () { + $database = ProjectRecordDatabase::fromProject(makeTemporaryProject(), RecordSchemaCatalog::forKey('items')); + $row = iconRow($database, 0); + + expect($row)->not->toBeNull() + ->and($row['editable'] ?? true)->toBeTrue() + ->and($row['label'])->toBe('Icon'); +}); From efcd97445a9ba6acf391e3f9f08da2e9c7a4bb5a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 12:58:48 +0200 Subject: [PATCH 112/344] refactor(inspector): move the map and event inspector out of the shell Stage 1 of the GUI editor (gui-editor/README.md) extracts the inspector descriptors and their dispatch so every interface shows and applies the same rows. Inspector/MapInspector now owns: - the map's rows (identity, kind, size, counts, music and its variants, encounters) and one event's rows (marker, type, bounds or painted cells, and its data, flattened with pickers and enum choices), moved verbatim from Editor (mapRuntimeFields, mapBgmVariantFields, buildEventDataFields, decorateEventInspectorFields, flattenInspectorFields, isInspectorListValue, blankInspectorListEntry, resolveEventReferenceField, buildMapKindField); - applying a row's edit: it returns the applied command instead of recording it, and throws InspectorRefusal (with details, such as the NPCs a shrink would strand) instead of setting a status. Editor keeps the terminal-only rows (layer visibility, NPC hosting, the "move the cursor onto an event" hint), records the returned command and shows refusals; applyMapDataValue, applyMapBgmVariantValue, resolveEventReferenceField and getInspectorFieldControl delegate. The map display constants now live on MapInspector alone. Terminal behaviour is unchanged; tests that reached the moved builders by reflection now call MapInspector::buildEventDataFields. --- src/Editor.php | 836 +----------------- src/Inspector/InspectorRefusal.php | 21 + src/Inspector/MapInspector.php | 885 +++++++++++++++++++ src/Inspector/MapKindField.php | 17 +- tests/Unit/EditorUxTest.php | 2 +- tests/Unit/EventReferenceFieldTest.php | 6 +- tests/Unit/ResumableStoryEventEditorTest.php | 4 +- 7 files changed, 952 insertions(+), 819 deletions(-) create mode 100644 src/Inspector/InspectorRefusal.php create mode 100644 src/Inspector/MapInspector.php diff --git a/src/Editor.php b/src/Editor.php index ad525e4..20be7a1 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -59,6 +59,8 @@ use Ichiloto\Editor\History\PaintStrokeCommand; use Ichiloto\Editor\History\SourceSetCommand; use Ichiloto\Editor\Inspector\InputControl; +use Ichiloto\Editor\Inspector\InspectorRefusal; +use Ichiloto\Editor\Inspector\MapInspector; use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Editor\IO\InputDecoder; use Ichiloto\Editor\IO\InputRouter; @@ -545,8 +547,6 @@ final class Editor /** * The picker row that clears a map's background music. */ - private const string MAP_BGM_NONE = '(None)'; - private const string MAP_KIND_NONE = 'Not set'; /** * The row that chooses which kind of slot the Optimize preview fills. @@ -9077,135 +9077,17 @@ private function applyInspectorFieldValue(array $field, string $rawValue): void return; } - $control = $this->getInspectorFieldControl($field); - $type = $control?->type ?? InputControlType::TEXT; - $value = match ($type) { - InputControlType::INTEGER => (int) trim($rawValue), - InputControlType::FLOAT => (float) trim($rawValue), - InputControlType::BOOLEAN => InputControl::parseBoolean($rawValue), - default => $rawValue, - }; - $target = (string) ($field['target'] ?? 'map'); - - if ($target === 'map') { - $fieldName = (string) $field['field']; - $oldValue = $selectedMap->getMapField($fieldName); - $selectedMap->setMapField($fieldName, $value); - $this->recordCommand(new GenericCommand( - sprintf('%s edit', $field['label'] ?? 'Field'), - static fn() => $selectedMap->setMapField($fieldName, $value), - static fn() => $selectedMap->setMapField($fieldName, $oldValue), - )); - return; - } - - if ($target === 'map-data') { - $path = array_values((array) ($field['path'] ?? [])); - - if ($path === []) { - return; - } - - $this->applyMapDataValue($selectedMap, $path, $value === '' ? null : $value, (string) ($field['label'] ?? 'Field')); - - return; - } - - if ($target === 'map-encounters') { - $this->applyMapEncounterValue($selectedMap, $field, $value); - - return; - } - - if ($target === 'map-bgm-variants') { - $this->applyMapBgmVariantValue($selectedMap, $field, $value); - - return; - } - - if ($target === 'map-size') { - $newWidth = (string) ($field['field'] ?? '') === 'width' ? max(1, (int) $value) : $selectedMap->getWidth(); - $newHeight = (string) ($field['field'] ?? '') === 'height' ? max(1, (int) $value) : $selectedMap->getHeight(); - $stranded = $selectedMap->describeNpcsStrandedBy($newWidth, $newHeight); - - if ($stranded !== []) { - // Refuse rather than clamp, delete, or truncate: the author - // moves, resizes or removes the NPC, then shrinks. - $this->setStatus( - sprintf('Cannot shrink to %dx%d: %d NPC%s would be stranded (Ctrl+E lists them).', $newWidth, $newHeight, count($stranded), count($stranded) === 1 ? '' : 's'), - StatusLevel::ERROR, - array_map(static fn(string $line): string => '- ' . $line, $stranded), - ); - - return; - } - - $snapshotBefore = $selectedMap->captureGridSnapshot(); - $selectedMap->resize($newWidth, $newHeight); - $snapshotAfter = $selectedMap->captureGridSnapshot(); - $this->recordCommand(new GenericCommand( - 'Map resize', - static fn() => $selectedMap->restoreGridSnapshot($snapshotAfter), - static fn() => $selectedMap->restoreGridSnapshot($snapshotBefore), - )); - return; - } - - if ($target === 'event') { - $marker = (string) $field['marker']; - $path = (array) ($field['path'] ?? []); - $oldValue = $selectedMap->getEventField($marker, $path); - $selectedMap->setEventField($marker, $path, $value); - $this->recordCommand(new GenericCommand( - sprintf('%s edit', $field['label'] ?? 'Event field'), - static fn() => $selectedMap->setEventField($marker, $path, $value), - static fn() => $selectedMap->setEventField($marker, $path, $oldValue), - )); + try { + $command = $this->createMapInspector()->apply($selectedMap, $field, $rawValue); + } catch (InspectorRefusal $refusal) { + $this->setStatus($refusal->getMessage(), $refusal->details === [] ? StatusLevel::WARN : StatusLevel::ERROR, $refusal->details); return; } - if ($target === 'event-bounds') { - $marker = (string) $field['marker']; - $area = $selectedMap->getEventArea($marker); - - if ($area === null) { - return; - } - - $bounds = $selectedMap->getEventBounds($marker); - $snapshotBefore = $selectedMap->captureGridSnapshot(); - - if (! $area->isRectangle) { - // A marker painted in any other shape keeps its shape: Position - // moves every cell; its cells are reshaped by painting them. - $axis = (string) $field['field']; - - if (! in_array($axis, ['x', 'y'], true)) { - $this->setStatus(sprintf('Marker %s is painted in its own shape; paint or erase its cells to reshape it.', $marker), StatusLevel::WARN); - return; - } - - $delta = max(0, (int) $value) - $bounds[$axis]; - $refusal = $selectedMap->moveEventCells($marker, $axis === 'x' ? $delta : 0, $axis === 'y' ? $delta : 0); - - if ($refusal !== null) { - $this->setStatus($refusal, StatusLevel::WARN); - return; - } - } else { - $bounds[(string) $field['field']] = max(0, (int) $value); - $selectedMap->setEventBounds($marker, $bounds['x'], $bounds['y'], $bounds['width'], $bounds['height']); - } - - $snapshotAfter = $selectedMap->captureGridSnapshot(); - $this->recordCommand(new GenericCommand( - 'Event bounds edit', - static fn() => $selectedMap->restoreGridSnapshot($snapshotAfter), - static fn() => $selectedMap->restoreGridSnapshot($snapshotBefore), - )); + if ($command !== null) { + $this->recordCommand($command); } } - /** * Returns the currently selected database category label. * @@ -13454,222 +13336,24 @@ private function normalizeDatabaseColor(?string $color): ?string } /** - * Builds the map's runtime metadata rows: the music it plays and the - * random encounters it offers. - * - * Both are read from what the map actually holds. An absent rate or tile - * mode is shown as the engine's default in parentheses and is not - * written until an author sets one, so opening a map never puts a - * default into a file. - * - * @return array> - */ - private function mapRuntimeFields(ProjectMap $map): array - { - $bgm = $map->getMapDataField(['bgm']); - $bgm = is_string($bgm) ? $bgm : ''; - $known = $this->referenceCatalog()->valuesFor('bgm'); - $missing = $bgm !== '' && ! in_array($bgm, $known, true); - $fields = [ - [ - 'label' => 'Audio', - 'value' => '', - 'editable' => false, - ], - [ - // A track is chosen from what the project has, never spelled. - // A track the project no longer has stays visible and says - // so, rather than being quietly swapped for a valid one. - 'label' => ' Background Music', - 'value' => match (true) { - $bgm === '' => self::MAP_BGM_NONE, - $missing => $bgm . ' · not in assets/Audio/BGM', - default => $bgm, - }, - 'reference' => 'bgm', - 'target' => 'map-data', - 'path' => ['bgm'], - 'field' => 'bgm', - ], - ]; - - $fields = [...$fields, ...$this->mapBgmVariantFields($map, $known)]; - - $encounters = MapEncounters::fromMap($map); - $fields[] = [ - 'label' => 'Encounters', - 'value' => $encounters->summary(), - 'editable' => false, - ]; - - if (! $encounters->isSupported()) { - $fields[] = [ - 'label' => ' ! Read-only', - 'value' => (string) $encounters->unsupportedReason(), - 'editable' => false, - ]; - - return $fields; - } - - $rows = $encounters->rows(); - $fields[] = [ - // The Inspector's own list heading, so Shift+O and Del mean here - // what they mean on every other list in the pane. - 'label' => sprintf(' Troops · %d', count($rows)), - 'value' => '', - 'editable' => false, - 'target' => 'map-encounters', - 'encounterList' => ['index' => max(0, count($rows) - 1)], - ]; - - foreach ($rows as $index => $row) { - $weight = $row['weight']; - $fields[] = [ - 'label' => ' Troop', - 'value' => $row['name'], - 'reference' => 'troops', - 'target' => 'map-encounters', - 'field' => 'troop', - 'index' => $index, - 'encounterList' => ['index' => $index], - ]; - $usable = is_numeric($weight) && (int) $weight >= 1; - $fields[] = [ - 'label' => ' Weight', - 'value' => (is_scalar($weight) ? (string) $weight : '') . ($usable ? '' : ' · the engine drops a troop it cannot weigh'), - 'control' => new InputControl(InputControlType::INTEGER, $usable ? (string) (int) $weight : '1'), - 'target' => 'map-encounters', - 'field' => 'weight', - 'index' => $index, - 'encounterList' => ['index' => $index], - ]; - } - - if ($rows === []) { - return $fields; - } - - $rate = $encounters->authoredRate(); - $fields[] = [ - 'label' => ' Rate', - 'value' => $rate === null ? sprintf('(engine default: %d)', MapEncounters::DEFAULT_RATE) : (string) $rate, - 'control' => new InputControl(InputControlType::INTEGER, (string) ($rate ?? MapEncounters::DEFAULT_RATE)), - 'target' => 'map-encounters', - 'field' => 'rate', - ]; - $tiles = $encounters->authoredTiles(); - $fields[] = [ - 'label' => ' Tiles', - 'value' => $tiles === null ? sprintf('(engine default: %s)', MapEncounters::DEFAULT_TILES) : $tiles, - 'options' => MapEncounters::TILE_MODES, - 'target' => 'map-encounters', - 'field' => 'tiles', - ]; - - return $fields; - } - - /** - * Builds the ordered Music Variants rows: the map's conditional music, - * evaluated by the engine in declaration order, first match wins. - * - * @param string[] $known The project's BGM tracks. - * @return array> - */ - private function mapBgmVariantFields(ProjectMap $map, array $known): array - { - $variants = MapBgmVariants::fromMap($map); - $fields = [ - [ - 'label' => ' Music Variants', - 'value' => $variants->summary(), - 'editable' => false, - 'target' => 'map-bgm-variants', - 'bgmVariantList' => ['index' => max(0, $variants->count() - 1)], - ], - ]; - - if (! $variants->isSupported()) { - $fields[] = [ - 'label' => ' ! Read-only', - 'value' => (string) $variants->unsupportedReason(), - 'editable' => false, - ]; - - return $fields; - } - - foreach ($variants->rows() as $index => $row) { - $track = $variants->trackAt($index); - $raw = $variants->rawTrackAt($index); - $fields[] = [ - 'label' => sprintf(' Variant %d Track', $index + 1), - 'value' => match (true) { - $track === null && $raw !== null => var_export($raw, true) . ' · not a track name', - $track === null || $track === '' => '(no track yet)', - ! in_array($track, $known, true) => $track . ' · not in assets/Audio/BGM', - default => $track, - }, - 'reference' => 'bgm', - 'target' => 'map-bgm-variants', - 'field' => 'track', - 'index' => $index, - 'bgmVariantList' => ['index' => $index], - ]; - - $issue = $variants->conditionsIssueAt($index); - $conditions = $variants->conditionsAt($index); - $fields[] = [ - 'label' => sprintf(' Variant %d When', $index + 1), - 'value' => $issue !== null - ? '! ' . $issue - : ($conditions === [] ? '(always · shadows later variants)' : ConditionCodec::encodeAll($conditions)), - 'target' => 'map-bgm-variants', - 'field' => 'conditions', - 'index' => $index, - 'bgmVariantList' => ['index' => $index], - 'editable' => $issue === null, - 'mapConditions' => $issue === null, - ]; - } - - return $fields; - } - - /** - * Writes one variant field through the variants model, which owns the - * list's shape and preserves every key it does not edit. + * Writes one music variant row as one undo step (see + * {@see MapInspector::applyMapBgmVariantValue()}). * * @param array $field The inspector field descriptor. */ private function applyMapBgmVariantValue(ProjectMap $map, array $field, mixed $value): void { - $variants = MapBgmVariants::fromMap($map); - - if (! $variants->isSupported()) { - $this->setStatus( - sprintf('Music variants are read-only here: %s.', $variants->unsupportedReason()), - StatusLevel::WARN, - ); - + try { + $command = $this->createMapInspector()->applyMapBgmVariantValue($map, $field, $value); + } catch (InspectorRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); return; } - $index = (int) ($field['index'] ?? 0); - $block = match ((string) ($field['field'] ?? '')) { - 'track' => $variants->withTrackAt($index, (string) $value), - 'conditions' => is_array($value) ? $variants->withConditionsAt($index, $value) : null, - default => null, - }; - - if ($block === null) { - return; + if ($command !== null) { + $this->recordCommand($command); } - - $this->applyMapDataValue($map, [MapBgmVariants::KEY], $block, sprintf('Music variant %d', $index + 1)); } - /** * Returns the variant row the inspector cursor is inside, or null. * @@ -13861,59 +13545,9 @@ private function getInspectorFields(): array return $this->getDatabaseSettingsFields(); } + $inspector = $this->createMapInspector(); $fields = [ - [ - 'label' => 'Name', - 'value' => $selectedMap->getDisplayName(), - 'control' => new InputControl(InputControlType::TEXT, $selectedMap->getDisplayName()), - 'target' => 'map', - 'field' => 'name', - ], - [ - 'label' => 'Region', - 'value' => $selectedMap->getRegion(), - 'control' => new InputControl(InputControlType::TEXT, $selectedMap->getRegion()), - 'target' => 'map', - 'field' => 'region', - ], - $this->buildMapKindField($selectedMap), - [ - 'label' => 'Description', - 'value' => $selectedMap->getDescription(), - 'control' => new InputControl(InputControlType::TEXT, $selectedMap->getDescription()), - 'target' => 'map', - 'field' => 'description', - ], - [ - 'label' => 'Size', - 'value' => '', - 'editable' => false, - ], - [ - 'label' => ' X', - 'value' => (string) $selectedMap->getWidth(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $selectedMap->getWidth()), - 'target' => 'map-size', - 'field' => 'width', - ], - [ - 'label' => ' Y', - 'value' => (string) $selectedMap->getHeight(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $selectedMap->getHeight()), - 'target' => 'map-size', - 'field' => 'height', - ], - [ - 'label' => 'Events', - 'value' => (string) $selectedMap->getEventDefinitionCount(), - 'editable' => false, - ], - [ - 'label' => 'Triggers', - 'value' => (string) $selectedMap->getTriggerCount(), - 'editable' => false, - ], - ...$this->mapRuntimeFields($selectedMap), + ...$inspector->getMapFields($selectedMap), ...$this->getLayerInspectorFields(), ]; @@ -13932,333 +13566,14 @@ private function getInspectorFields(): array return $fields; } - $definition = $selectedMap->getEventDefinition($marker); - $bounds = $selectedMap->getEventBounds($marker); - $area = $selectedMap->getEventArea($marker); - - $fields[] = [ - 'label' => 'Event', - 'value' => $marker, - 'editable' => false, - ]; - - $fields[] = [ - 'label' => 'Type', - 'value' => $this->resolveEventTypeLabel( - is_array($definition) && is_string($definition['class'] ?? null) - ? $definition['class'] - : null, - ), - 'editable' => true, - 'target' => 'event-type', - 'marker' => $marker, - ]; - - if ($bounds !== null) { - $fields[] = [ - 'label' => 'Position', - 'value' => '', - 'editable' => false, - ]; - $fields[] = [ - 'label' => ' X', - 'value' => (string) $bounds['x'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds['x']), - 'target' => 'event-bounds', - 'marker' => $marker, - 'field' => 'x', - ]; - $fields[] = [ - 'label' => ' Y', - 'value' => (string) $bounds['y'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds['y']), - 'target' => 'event-bounds', - 'marker' => $marker, - 'field' => 'y', - ]; - if ($area?->isRectangle ?? true) { - $fields[] = [ - 'label' => 'Size', - 'value' => '', - 'editable' => false, - ]; - $fields[] = [ - 'label' => ' X', - 'value' => (string) $bounds['width'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds['width']), - 'target' => 'event-bounds', - 'marker' => $marker, - 'field' => 'width', - ]; - $fields[] = [ - 'label' => ' Y', - 'value' => (string) $bounds['height'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds['height']), - 'target' => 'event-bounds', - 'marker' => $marker, - 'field' => 'height', - ]; - } else { - // Painted in its own shape: the event triggers on exactly these cells. - $pieces = count($area->findPieces()); - $fields[] = [ - 'label' => 'Cells', - 'value' => sprintf('%d in %d %s', count($area->cells), $pieces, $pieces === 1 ? 'shape' : 'places'), - 'editable' => false, - ]; - } - } - - if ($definition !== null) { - $fields = [...$fields, ...$this->buildEventDataFields($marker, $definition)]; - } - - return $fields; - } - - /** - * Builds editable inspector fields for event data. - * - * @param string $marker The event marker. - * @param array $definition The event definition. - * @return array> - */ - private function buildEventDataFields(string $marker, array $definition): array - { - $fields = []; - $eventData = $definition['data'] ?? []; - - if (is_array($eventData)) { - $fields = $this->decorateEventInspectorFields( - $marker, - $this->flattenInspectorFields($eventData, ['data']), - ); - } - - $rootFields = array_diff_key($definition, ['class' => true, 'data' => true]); - // Cue is a generic trigger capability, including for definitions - // created before the field existed. Supplying an empty editor-only - // default exposes the opt-in without changing the stored event until - // the author actually edits it. - $rootFields['cue'] ??= ['symbol' => '', 'color' => 'bright-yellow']; - - return [ - ...$fields, - ...$this->decorateEventInspectorFields( - $marker, - $this->flattenInspectorFields($rootFields, []), - ), - ]; - } - - /** - * Adds event context, pickers, and enum controls to flattened fields. - * - * @param array> $fields The raw fields. - * @return array> - */ - private function decorateEventInspectorFields(string $marker, array $fields): array - { - $decorated = []; - - foreach ($fields as $field) { - $field['marker'] = $marker; - $field['target'] = 'event'; - - $path = array_values((array) ($field['path'] ?? [])); - - if ($path === ['data', 'mode']) { - $field['options'] = ['action', 'auto']; - unset($field['control']); - } - - $reference = $this->resolveEventReferenceField($field); - - if (is_array($reference)) { - // A reference is chosen, never spelled: dropping the control - // is what stops the field being typed into, leaving the - // picker as the only way to set it. - $field['reference'] = $reference['category']; - unset($field['control']); - } - - $decorated[] = $field; - } - - return $decorated; - } - - /** - * Flattens nested scalar data into editable inspector fields. - * - * @param array $data The data to flatten. - * @param array $path The current path. - * @return array> - */ - private function flattenInspectorFields(array $data, array $path, ?array $list = null): array - { - $fields = []; - - foreach ($data as $key => $value) { - $segment = (string) $key; - $nextPath = [...$path, $segment]; - - if (is_array($value)) { - // A list of entries -- a shop's stock, an event's dialogue -- - // is something an author adds to and removes from, so every - // field inside one remembers which list it belongs to. - if ($this->isInspectorListValue($nextPath, $value)) { - $label = implode(' ', array_map( - static fn(string $part): string => ucwords(str_replace(['_', '-'], ' ', $part)), - ($nextPath[0] ?? null) === 'data' ? array_slice($nextPath, 1) : $nextPath - )); - $fields[] = [ - 'label' => sprintf('%s · %d', $label, count($value)), - 'value' => '', - 'editable' => false, - 'list' => [ - 'path' => $nextPath, - 'index' => max(0, count($value) - 1), - 'blank' => $this->blankInspectorListEntry($nextPath), - ], - ]; - - foreach ($value as $index => $entry) { - $entryPath = [...$nextPath, (string) $index]; - $entryList = ['path' => $nextPath, 'index' => (int) $index]; - - $fields = [ - ...$fields, - ...(is_array($entry) - ? $this->flattenInspectorFields($entry, $entryPath, $entryList) - : $this->flattenInspectorFields([(string) $index => $entry], $nextPath, $entryList)), - ]; - } - - continue; - } - - if (array_key_exists('x', $value) && array_key_exists('y', $value) && is_scalar($value['x']) && is_scalar($value['y'])) { - $label = implode(' ', array_map( - static fn(string $part): string => ucwords(str_replace(['_', '-'], ' ', $part)), - ($nextPath[0] ?? null) === 'data' ? array_slice($nextPath, 1) : $nextPath - )); - $fields[] = [ - 'label' => $label, - 'value' => '', - 'editable' => false, - ]; - $fields[] = [ - 'label' => ' X', - 'value' => (string) $value['x'], - 'control' => new InputControl( - is_int($value['x']) ? InputControlType::INTEGER : InputControlType::TEXT, - (string) $value['x'], - ), - 'path' => [...$nextPath, 'x'], - ]; - $fields[] = [ - 'label' => ' Y', - 'value' => (string) $value['y'], - 'control' => new InputControl( - is_int($value['y']) ? InputControlType::INTEGER : InputControlType::TEXT, - (string) $value['y'], - ), - 'path' => [...$nextPath, 'y'], - ]; - continue; - } - - $fields = [...$fields, ...$this->flattenInspectorFields($value, $nextPath, $list)]; - continue; - } - - if (! is_scalar($value) && $value !== null) { - continue; - } - - $displayPath = ($nextPath[0] ?? null) === 'data' - ? array_slice($nextPath, 1) - : $nextPath; - $label = implode(' ', array_map( - static fn(string $part): string => ctype_digit($part) - ? '#' . ((int) $part + 1) - : ucwords(str_replace(['_', '-'], ' ', $part)), - $displayPath - )); - $stringValue = match (true) { - is_bool($value) => $value ? 'true' : 'false', - is_float($value) => InputControl::formatFloat($value), - default => (string) $value, - }; - $controlType = match (true) { - is_bool($value) => InputControlType::BOOLEAN, - is_int($value) => InputControlType::INTEGER, - is_float($value) => InputControlType::FLOAT, - default => InputControlType::TEXT, - }; - $leaf = [ - 'label' => $label, - 'value' => $stringValue, - 'control' => new InputControl($controlType, $stringValue), - 'path' => $nextPath, - ]; - - if (is_array($list)) { - $leaf['list'] = $list; - } - - $fields[] = $leaf; - } - - return $fields; - } - - /** - * Distinguishes authored lists from associative configuration blocks. - * - * Empty arrays need an explicit known-list name because PHP cannot tell - * an empty list from an empty map. - * - * @param array $path The candidate path. - * @param array $value The candidate value. - */ - private function isInspectorListValue(array $path, array $value): bool - { - if (! array_is_list($value) || array_key_exists('x', $value)) { - return false; - } - - if ($value !== []) { - return true; - } - - return in_array( - (string) ($path[array_key_last($path)] ?? ''), - ['conditions', 'sets', 'dialogue', 'items', 'script', 'steps', 'options'], - true, - ); + return [...$fields, ...$inspector->getEventFields($selectedMap, $marker)]; } - /** - * Returns the structured first row for an empty inspector list. - * - * @param array $path The list path. - * @return array - */ - private function blankInspectorListEntry(array $path): array + /** The map inspector over the project's references as the selected map sees them. */ + private function createMapInspector(): MapInspector { - return match ((string) ($path[array_key_last($path)] ?? '')) { - 'conditions' => ['type' => 'switch', 'name' => '', 'value' => true], - 'sets' => ['type' => 'switch', 'name' => '', 'value' => true], - 'script' => ['type' => 'text', 'name' => '', 'text' => ''], - 'steps' => ['direction' => 'down', 'count' => 1, 'faceOnly' => false], - 'options' => ['text' => '', 'then' => []], - 'items' => ['item' => '', 'price' => 0], - default => ['name' => '', 'text' => ''], - }; + return new MapInspector($this->referenceCatalog()); } - /** * Draws the editor shell. * @@ -15475,62 +14790,20 @@ private function removeMapEncounterTroop(): bool } /** - * Writes one nested map-data value, recording it for undo. + * Writes one value into the map's data as one undo step (see + * {@see MapInspector::writeMapData()}); nothing is recorded when the data + * already held it. * - * @param array $path The nested data path. + * @param list $path */ private function applyMapDataValue(ProjectMap $map, array $path, mixed $value, string $label): void { - $hadValue = $map->hasMapDataField($path); - $oldValue = $map->getMapDataField($path); - $map->setMapDataField($path, $value); - - if ($map->getMapDataField($path) === $oldValue && $map->hasMapDataField($path) === $hadValue) { - return; - } - - $this->recordCommand(new GenericCommand( - sprintf('%s edit', $label), - static fn() => $map->setMapDataField($path, $value), - static fn() => $map->setMapDataField($path, $hadValue ? $oldValue : null), - )); - } + $command = $this->createMapInspector()->writeMapData($map, $path, $value, $label); - /** - * Writes one encounter field through the encounters model, which owns - * the block's shape, its defaults and its duplicate rule. - * - * @param array $field The inspector field descriptor. - */ - private function applyMapEncounterValue(ProjectMap $map, array $field, mixed $value): void - { - $encounters = MapEncounters::fromMap($map); - - if (! $encounters->isSupported()) { - $this->setStatus( - sprintf('Encounters are read-only here: %s.', $encounters->unsupportedReason()), - StatusLevel::WARN, - ); - - return; - } - - $index = (int) ($field['index'] ?? 0); - $block = match ((string) ($field['field'] ?? '')) { - 'rate' => $encounters->withRate((int) $value), - 'tiles' => $encounters->withTiles((string) $value), - 'weight' => $encounters->withWeightAt($index, (int) $value), - 'troop' => $encounters->withTroopAt($index, (string) $value), - default => null, - }; - - if ($block === null && ! $encounters->isDeclared()) { - return; + if ($command !== null) { + $this->recordCommand($command); } - - $this->applyMapDataValue($map, [MapEncounters::KEY], $block, 'Encounters'); } - /** * Returns the list the inspector cursor is inside, if any. * @@ -15701,44 +14974,16 @@ private static function describeListPath(array $path): string } /** - * Determines whether an event field names another resource. + * The picker an event data row is chosen from (see + * {@see MapInspector::findEventReference()}). * - * A door's destination and a chest's loot have flows of their own; this - * covers the rest, so a track, a sound, or a shop's stock is chosen from - * what the project actually has rather than spelled from memory. - * - * @param array $field The inspector field descriptor. - * @return array{category: string, title: string}|null The kind of - * reference and what to call the picker, or null when the field names - * nothing. + * @param array $field + * @return array{category: string, title: string}|null */ private function resolveEventReferenceField(array $field): ?array { - if (($field['target'] ?? null) !== 'event') { - return null; - } - - $path = array_values((array) ($field['path'] ?? [])); - - if (($path[0] ?? null) !== 'data' || count($path) < 2) { - return null; - } - - $leaf = (string) $path[array_key_last($path)]; - - return match (true) { - $path === ['data', 'scriptId'] => ['category' => 'common_events', 'title' => 'Event Script'], - $path === ['data', 'cinematicId'] => ['category' => 'cinematics', 'title' => 'Cinematic'], - $leaf === 'bgm' => ['category' => 'bgm', 'title' => 'Music'], - $leaf === 'sfx' => ['category' => 'sfx', 'title' => 'Sound Effect'], - // A shop's stock is data.items.N.item. The leaf alone would also - // match an unrelated event that happened to call a field "item". - $leaf === 'item' && ($path[1] ?? null) === 'items' - => ['category' => 'inventory', 'title' => 'Item'], - default => null, - }; + return MapInspector::findEventReference($field); } - /** * Opens the picker for an event field that names another resource. * @@ -15825,7 +15070,7 @@ private function openInspectorReferenceDialog(array $field): bool // track is a skipped variant, not silence, so the variant picker // offers only real tracks and removal un-authors the variant. array_unshift($entries, [ - 'label' => self::MAP_BGM_NONE, + 'label' => MapInspector::MAP_BGM_NONE, 'value' => '', 'description' => 'The map plays whatever was already playing.', ]); @@ -17893,19 +17138,14 @@ private function isInspectorFieldInteractive(array $field): bool } /** - * Returns the input control for an inspector field if it is editable. + * The input a row is typed into (see {@see MapInspector::findControl()}). * - * @param array $field The inspector field descriptor. - * @return InputControl|null + * @param array $field */ private function getInspectorFieldControl(array $field): ?InputControl { - if (($field['editable'] ?? null) === false) { return null; } - $control = $field['control'] ?? null; - - return $control instanceof InputControl ? $control : null; + return MapInspector::findControl($field); } - /** * Re-renders the canvas section without clearing the full shell. * diff --git a/src/Inspector/InspectorRefusal.php b/src/Inspector/InspectorRefusal.php new file mode 100644 index 0000000..913203d --- /dev/null +++ b/src/Inspector/InspectorRefusal.php @@ -0,0 +1,21 @@ + $details */ + public function __construct(string $message, public readonly array $details = [], ?Throwable $previous = null) + { + parent::__construct($message, 0, $previous); + } +} diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php new file mode 100644 index 0000000..245fa79 --- /dev/null +++ b/src/Inspector/MapInspector.php @@ -0,0 +1,885 @@ +> + */ + public function getMapFields(ProjectMap $map): array + { + return [ + [ + 'label' => 'Name', + 'value' => $map->getDisplayName(), + 'control' => new InputControl(InputControlType::TEXT, $map->getDisplayName()), + 'target' => 'map', + 'field' => 'name', + ], + [ + 'label' => 'Region', + 'value' => $map->getRegion(), + 'control' => new InputControl(InputControlType::TEXT, $map->getRegion()), + 'target' => 'map', + 'field' => 'region', + ], + $this->buildMapKindField($map), + [ + 'label' => 'Description', + 'value' => $map->getDescription(), + 'control' => new InputControl(InputControlType::TEXT, $map->getDescription()), + 'target' => 'map', + 'field' => 'description', + ], + [ + 'label' => 'Size', + 'value' => '', + 'editable' => false, + ], + [ + 'label' => ' X', + 'value' => (string) $map->getWidth(), + 'control' => new InputControl(InputControlType::INTEGER, (string) $map->getWidth()), + 'target' => 'map-size', + 'field' => 'width', + ], + [ + 'label' => ' Y', + 'value' => (string) $map->getHeight(), + 'control' => new InputControl(InputControlType::INTEGER, (string) $map->getHeight()), + 'target' => 'map-size', + 'field' => 'height', + ], + [ + 'label' => 'Events', + 'value' => (string) $map->getEventDefinitionCount(), + 'editable' => false, + ], + [ + 'label' => 'Triggers', + 'value' => (string) $map->getTriggerCount(), + 'editable' => false, + ], + ...$this->mapRuntimeFields($map), + ]; + } + + /** + * One event's rows: its marker, type, position and size (or its painted + * cells), and its data. + * + * @return array> + */ + public function getEventFields(ProjectMap $map, string $marker): array + { + $definition = $map->getEventDefinition($marker); + $bounds = $map->getEventBounds($marker); + $area = $map->getEventArea($marker); + $fields = [ + [ + 'label' => 'Event', + 'value' => $marker, + 'editable' => false, + ], + [ + 'label' => 'Type', + 'value' => EventTypeCatalog::describeClass( + is_array($definition) && is_string($definition['class'] ?? null) ? $definition['class'] : null, + ), + 'editable' => true, + 'target' => 'event-type', + 'marker' => $marker, + ], + ]; + + if ($bounds !== null) { + $fields[] = ['label' => 'Position', 'value' => '', 'editable' => false]; + foreach (['x' => ' X', 'y' => ' Y'] as $axis => $label) { + $fields[] = [ + 'label' => $label, + 'value' => (string) $bounds[$axis], + 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds[$axis]), + 'target' => 'event-bounds', + 'marker' => $marker, + 'field' => $axis, + ]; + } + if ($area?->isRectangle ?? true) { + $fields[] = ['label' => 'Size', 'value' => '', 'editable' => false]; + foreach (['width' => ' X', 'height' => ' Y'] as $axis => $label) { + $fields[] = [ + 'label' => $label, + 'value' => (string) $bounds[$axis], + 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds[$axis]), + 'target' => 'event-bounds', + 'marker' => $marker, + 'field' => $axis, + ]; + } + } else { + // Painted in its own shape: the event triggers on exactly these cells. + $pieces = count($area->findPieces()); + $fields[] = [ + 'label' => 'Cells', + 'value' => sprintf('%d in %d %s', count($area->cells), $pieces, $pieces === 1 ? 'shape' : 'places'), + 'editable' => false, + ]; + } + } + + if ($definition !== null) { + $fields = [...$fields, ...$this->buildEventDataFields($marker, $definition)]; + } + + return $fields; + } + + /** + * Applies one row's edit to the map and returns the command that undoes + * and redoes it, already applied; null when nothing changed or the row + * is not one an edit applies through (an event's type, the map's kind). + * + * @param array $field The row's descriptor. + * @throws InspectorRefusal When the edit cannot be made as asked. + */ + public function apply(ProjectMap $map, array $field, string $rawValue): ?Command + { + $control = self::findControl($field); + $type = $control?->type ?? InputControlType::TEXT; + $value = match ($type) { + InputControlType::INTEGER => (int) trim($rawValue), + InputControlType::FLOAT => (float) trim($rawValue), + InputControlType::BOOLEAN => InputControl::parseBoolean($rawValue), + default => $rawValue, + }; + + return match ((string) ($field['target'] ?? 'map')) { + 'map' => $this->applyMapField($map, (string) $field['field'], $value, (string) ($field['label'] ?? 'Field')), + 'map-data' => ($path = array_values((array) ($field['path'] ?? []))) === [] + ? null + : $this->writeMapData($map, $path, $value === '' ? null : $value, (string) ($field['label'] ?? 'Field')), + 'map-encounters' => $this->applyMapEncounterValue($map, $field, $value), + 'map-bgm-variants' => $this->applyMapBgmVariantValue($map, $field, $value), + 'map-size' => $this->applyMapSize($map, (string) ($field['field'] ?? ''), (int) $value), + 'event' => $this->applyEventField($map, $field, $value), + 'event-bounds' => $this->applyEventBounds($map, (string) $field['marker'], (string) $field['field'], (int) $value), + default => null, + }; + } + + /** + * Writes one value into the map's data and returns its command, or null + * when the data already held it. A null value removes the key. + * + * @param list $path + */ + public function writeMapData(ProjectMap $map, array $path, mixed $value, string $label): ?Command + { + $hadValue = $map->hasMapDataField($path); + $oldValue = $map->getMapDataField($path); + $map->setMapDataField($path, $value); + + if ($map->getMapDataField($path) === $oldValue && $map->hasMapDataField($path) === $hadValue) { + return null; + } + + return new GenericCommand( + sprintf('%s edit', $label), + static fn() => $map->setMapDataField($path, $value), + static fn() => $map->setMapDataField($path, $hadValue ? $oldValue : null), + ); + } + + /** + * Applies one music variant row's edit through the variants model, which + * owns the list's shape and preserves every key it does not edit. + * + * @param array $field + * @throws InspectorRefusal When the map's variants cannot be edited here. + */ + public function applyMapBgmVariantValue(ProjectMap $map, array $field, mixed $value): ?Command + { + $variants = MapBgmVariants::fromMap($map); + + if (! $variants->isSupported()) { + throw new InspectorRefusal(sprintf('Music variants are read-only here: %s.', $variants->unsupportedReason())); + } + + $index = (int) ($field['index'] ?? 0); + $block = match ((string) ($field['field'] ?? '')) { + 'track' => $variants->withTrackAt($index, (string) $value), + 'conditions' => is_array($value) ? $variants->withConditionsAt($index, $value) : null, + default => null, + }; + + return $block === null ? null : $this->writeMapData($map, [MapBgmVariants::KEY], $block, sprintf('Music variant %d', $index + 1)); + } + + /** + * The picker an event data row is chosen from, when its value names + * another resource. + * + * @param array $field + * @return array{category: string, title: string}|null + */ + public static function findEventReference(array $field): ?array + { + if (($field['target'] ?? null) !== 'event') { + return null; + } + + $path = array_values((array) ($field['path'] ?? [])); + + if (($path[0] ?? null) !== 'data' || count($path) < 2) { + return null; + } + + $leaf = (string) $path[array_key_last($path)]; + + return match (true) { + $path === ['data', 'scriptId'] => ['category' => 'common_events', 'title' => 'Event Script'], + $path === ['data', 'cinematicId'] => ['category' => 'cinematics', 'title' => 'Cinematic'], + $leaf === 'bgm' => ['category' => 'bgm', 'title' => 'Music'], + $leaf === 'sfx' => ['category' => 'sfx', 'title' => 'Sound Effect'], + // A shop's stock is data.items.N.item. The leaf alone would also + // match an unrelated event that happened to call a field "item". + $leaf === 'item' && ($path[1] ?? null) === 'items' => ['category' => 'inventory', 'title' => 'Item'], + default => null, + }; + } + + /** + * The input a row is typed into, or null when it is read-only or chosen + * some other way. + * + * @param array $field + */ + public static function findControl(array $field): ?InputControl + { + if (($field['editable'] ?? null) === false) { + return null; + } + + $control = $field['control'] ?? null; + + return $control instanceof InputControl ? $control : null; + } + + /** + * The map's kind row: the tileset it draws from, chosen from the + * project's tilesets. + * + * @return array + */ + public function buildMapKindField(ProjectMap $map): array + { + $id = $map->getMapDataField(['tileset']); + $labels = $this->references->labelsFor('tilesets'); + + return [ + 'label' => 'Kind', + 'value' => match (true) { + $id === null => self::MAP_KIND_NONE, + ! is_string($id) => sprintf('%s · not a tileset id', get_debug_type($id)), + ! isset($labels[$id]) => sprintf('%s · not in assets/%s', $id, Tileset::DIRECTORY), + default => $labels[$id], + }, + 'selectedValue' => is_string($id) ? $id : '', + 'reference' => 'tilesets', + 'target' => 'map-kind', + 'field' => 'tileset', + ]; + } + + private function applyMapField(ProjectMap $map, string $fieldName, mixed $value, string $label): ?Command + { + $oldValue = $map->getMapField($fieldName); + $map->setMapField($fieldName, $value); + + return new GenericCommand( + sprintf('%s edit', $label), + static fn() => $map->setMapField($fieldName, $value), + static fn() => $map->setMapField($fieldName, $oldValue), + ); + } + + /** + * Resizes the map, refusing rather than clamping, deleting or truncating + * when an NPC would be left outside it. + * + * @throws InspectorRefusal When NPCs would be stranded; its details name them. + */ + private function applyMapSize(ProjectMap $map, string $axis, int $value): ?Command + { + $newWidth = $axis === 'width' ? max(1, $value) : $map->getWidth(); + $newHeight = $axis === 'height' ? max(1, $value) : $map->getHeight(); + $stranded = $map->describeNpcsStrandedBy($newWidth, $newHeight); + + if ($stranded !== []) { + // Refuse rather than clamp, delete, or truncate: the author + // moves, resizes or removes the NPC, then shrinks. + throw new InspectorRefusal( + sprintf('Cannot shrink to %dx%d: %d NPC%s would be stranded.', $newWidth, $newHeight, count($stranded), count($stranded) === 1 ? '' : 's'), + array_map(static fn(string $line): string => '- ' . $line, $stranded), + ); + } + + $snapshotBefore = $map->captureGridSnapshot(); + $map->resize($newWidth, $newHeight); + $snapshotAfter = $map->captureGridSnapshot(); + + return new GenericCommand( + 'Map resize', + static fn() => $map->restoreGridSnapshot($snapshotAfter), + static fn() => $map->restoreGridSnapshot($snapshotBefore), + ); + } + + /** @param array $field */ + private function applyEventField(ProjectMap $map, array $field, mixed $value): ?Command + { + $marker = (string) $field['marker']; + $path = (array) ($field['path'] ?? []); + $oldValue = $map->getEventField($marker, $path); + $map->setEventField($marker, $path, $value); + + return new GenericCommand( + sprintf('%s edit', $field['label'] ?? 'Event field'), + static fn() => $map->setEventField($marker, $path, $value), + static fn() => $map->setEventField($marker, $path, $oldValue), + ); + } + + /** + * Moves or resizes an event. A marker painted in another shape than a + * rectangle keeps its shape: its position moves every cell, and its cells + * are reshaped by painting them. + * + * @throws InspectorRefusal When the event cannot move or resize as asked. + */ + private function applyEventBounds(ProjectMap $map, string $marker, string $axis, int $value): ?Command + { + $area = $map->getEventArea($marker); + + if ($area === null) { + return null; + } + + $bounds = $map->getEventBounds($marker); + $snapshotBefore = $map->captureGridSnapshot(); + + if (! $area->isRectangle) { + if (! in_array($axis, ['x', 'y'], true)) { + throw new InspectorRefusal(sprintf('Marker %s is painted in its own shape; paint or erase its cells to reshape it.', $marker)); + } + + $delta = max(0, $value) - $bounds[$axis]; + $refusal = $map->moveEventCells($marker, $axis === 'x' ? $delta : 0, $axis === 'y' ? $delta : 0); + + if ($refusal !== null) { + throw new InspectorRefusal($refusal); + } + } else { + $bounds[$axis] = max(0, $value); + $map->setEventBounds($marker, $bounds['x'], $bounds['y'], $bounds['width'], $bounds['height']); + } + + $snapshotAfter = $map->captureGridSnapshot(); + + return new GenericCommand( + 'Event bounds edit', + static fn() => $map->restoreGridSnapshot($snapshotAfter), + static fn() => $map->restoreGridSnapshot($snapshotBefore), + ); + } + + /** + * Applies one encounter row's edit through the encounters model. + * + * @param array $field + * @throws InspectorRefusal When the map's encounters cannot be edited here. + */ + private function applyMapEncounterValue(ProjectMap $map, array $field, mixed $value): ?Command + { + $encounters = MapEncounters::fromMap($map); + + if (! $encounters->isSupported()) { + throw new InspectorRefusal(sprintf('Encounters are read-only here: %s.', $encounters->unsupportedReason())); + } + + $index = (int) ($field['index'] ?? 0); + $block = match ((string) ($field['field'] ?? '')) { + 'rate' => $encounters->withRate((int) $value), + 'tiles' => $encounters->withTiles((string) $value), + 'weight' => $encounters->withWeightAt($index, (int) $value), + 'troop' => $encounters->withTroopAt($index, (string) $value), + default => null, + }; + + if ($block === null && ! $encounters->isDeclared()) { + return null; + } + + return $this->writeMapData($map, [MapEncounters::KEY], $block, 'Encounters'); + } + /** + * Builds the map's runtime metadata rows: the music it plays and the + * random encounters it offers. + * + * Both are read from what the map actually holds. An absent rate or tile + * mode is shown as the engine's default in parentheses and is not + * written until an author sets one, so opening a map never puts a + * default into a file. + * + * @return array> + */ + private function mapRuntimeFields(ProjectMap $map): array + { + $bgm = $map->getMapDataField(['bgm']); + $bgm = is_string($bgm) ? $bgm : ''; + $known = $this->references->valuesFor('bgm'); + $missing = $bgm !== '' && ! in_array($bgm, $known, true); + $fields = [ + [ + 'label' => 'Audio', + 'value' => '', + 'editable' => false, + ], + [ + // A track is chosen from what the project has, never spelled. + // A track the project no longer has stays visible and says + // so, rather than being quietly swapped for a valid one. + 'label' => ' Background Music', + 'value' => match (true) { + $bgm === '' => self::MAP_BGM_NONE, + $missing => $bgm . ' · not in assets/Audio/BGM', + default => $bgm, + }, + 'reference' => 'bgm', + 'target' => 'map-data', + 'path' => ['bgm'], + 'field' => 'bgm', + ], + ]; + + $fields = [...$fields, ...$this->mapBgmVariantFields($map, $known)]; + + $encounters = MapEncounters::fromMap($map); + $fields[] = [ + 'label' => 'Encounters', + 'value' => $encounters->summary(), + 'editable' => false, + ]; + + if (! $encounters->isSupported()) { + $fields[] = [ + 'label' => ' ! Read-only', + 'value' => (string) $encounters->unsupportedReason(), + 'editable' => false, + ]; + + return $fields; + } + + $rows = $encounters->rows(); + $fields[] = [ + // The Inspector's own list heading, so Shift+O and Del mean here + // what they mean on every other list in the pane. + 'label' => sprintf(' Troops · %d', count($rows)), + 'value' => '', + 'editable' => false, + 'target' => 'map-encounters', + 'encounterList' => ['index' => max(0, count($rows) - 1)], + ]; + + foreach ($rows as $index => $row) { + $weight = $row['weight']; + $fields[] = [ + 'label' => ' Troop', + 'value' => $row['name'], + 'reference' => 'troops', + 'target' => 'map-encounters', + 'field' => 'troop', + 'index' => $index, + 'encounterList' => ['index' => $index], + ]; + $usable = is_numeric($weight) && (int) $weight >= 1; + $fields[] = [ + 'label' => ' Weight', + 'value' => (is_scalar($weight) ? (string) $weight : '') . ($usable ? '' : ' · the engine drops a troop it cannot weigh'), + 'control' => new InputControl(InputControlType::INTEGER, $usable ? (string) (int) $weight : '1'), + 'target' => 'map-encounters', + 'field' => 'weight', + 'index' => $index, + 'encounterList' => ['index' => $index], + ]; + } + + if ($rows === []) { + return $fields; + } + + $rate = $encounters->authoredRate(); + $fields[] = [ + 'label' => ' Rate', + 'value' => $rate === null ? sprintf('(engine default: %d)', MapEncounters::DEFAULT_RATE) : (string) $rate, + 'control' => new InputControl(InputControlType::INTEGER, (string) ($rate ?? MapEncounters::DEFAULT_RATE)), + 'target' => 'map-encounters', + 'field' => 'rate', + ]; + $tiles = $encounters->authoredTiles(); + $fields[] = [ + 'label' => ' Tiles', + 'value' => $tiles === null ? sprintf('(engine default: %s)', MapEncounters::DEFAULT_TILES) : $tiles, + 'options' => MapEncounters::TILE_MODES, + 'target' => 'map-encounters', + 'field' => 'tiles', + ]; + + return $fields; + } + + /** + * Builds the ordered Music Variants rows: the map's conditional music, + * evaluated by the engine in declaration order, first match wins. + * + * @param string[] $known The project's BGM tracks. + * @return array> + */ + private function mapBgmVariantFields(ProjectMap $map, array $known): array + { + $variants = MapBgmVariants::fromMap($map); + $fields = [ + [ + 'label' => ' Music Variants', + 'value' => $variants->summary(), + 'editable' => false, + 'target' => 'map-bgm-variants', + 'bgmVariantList' => ['index' => max(0, $variants->count() - 1)], + ], + ]; + + if (! $variants->isSupported()) { + $fields[] = [ + 'label' => ' ! Read-only', + 'value' => (string) $variants->unsupportedReason(), + 'editable' => false, + ]; + + return $fields; + } + + foreach ($variants->rows() as $index => $row) { + $track = $variants->trackAt($index); + $raw = $variants->rawTrackAt($index); + $fields[] = [ + 'label' => sprintf(' Variant %d Track', $index + 1), + 'value' => match (true) { + $track === null && $raw !== null => var_export($raw, true) . ' · not a track name', + $track === null || $track === '' => '(no track yet)', + ! in_array($track, $known, true) => $track . ' · not in assets/Audio/BGM', + default => $track, + }, + 'reference' => 'bgm', + 'target' => 'map-bgm-variants', + 'field' => 'track', + 'index' => $index, + 'bgmVariantList' => ['index' => $index], + ]; + + $issue = $variants->conditionsIssueAt($index); + $conditions = $variants->conditionsAt($index); + $fields[] = [ + 'label' => sprintf(' Variant %d When', $index + 1), + 'value' => $issue !== null + ? '! ' . $issue + : ($conditions === [] ? '(always · shadows later variants)' : ConditionCodec::encodeAll($conditions)), + 'target' => 'map-bgm-variants', + 'field' => 'conditions', + 'index' => $index, + 'bgmVariantList' => ['index' => $index], + 'editable' => $issue === null, + 'mapConditions' => $issue === null, + ]; + } + + return $fields; + } + + /** + * Builds editable inspector fields for event data. + * + * @param string $marker The event marker. + * @param array $definition The event definition. + * @return array> + */ + public function buildEventDataFields(string $marker, array $definition): array + { + $fields = []; + $eventData = $definition['data'] ?? []; + + if (is_array($eventData)) { + $fields = $this->decorateEventInspectorFields( + $marker, + $this->flattenInspectorFields($eventData, ['data']), + ); + } + + $rootFields = array_diff_key($definition, ['class' => true, 'data' => true]); + // Cue is a generic trigger capability, including for definitions + // created before the field existed. Supplying an empty editor-only + // default exposes the opt-in without changing the stored event until + // the author actually edits it. + $rootFields['cue'] ??= ['symbol' => '', 'color' => 'bright-yellow']; + + return [ + ...$fields, + ...$this->decorateEventInspectorFields( + $marker, + $this->flattenInspectorFields($rootFields, []), + ), + ]; + } + + /** + * Adds event context, pickers, and enum controls to flattened fields. + * + * @param array> $fields The raw fields. + * @return array> + */ + private function decorateEventInspectorFields(string $marker, array $fields): array + { + $decorated = []; + + foreach ($fields as $field) { + $field['marker'] = $marker; + $field['target'] = 'event'; + + $path = array_values((array) ($field['path'] ?? [])); + + if ($path === ['data', 'mode']) { + $field['options'] = ['action', 'auto']; + unset($field['control']); + } + + $reference = self::findEventReference($field); + + if (is_array($reference)) { + // A reference is chosen, never spelled: dropping the control + // is what stops the field being typed into, leaving the + // picker as the only way to set it. + $field['reference'] = $reference['category']; + unset($field['control']); + } + + $decorated[] = $field; + } + + return $decorated; + } + + /** + * Flattens nested scalar data into editable inspector fields. + * + * @param array $data The data to flatten. + * @param array $path The current path. + * @return array> + */ + private function flattenInspectorFields(array $data, array $path, ?array $list = null): array + { + $fields = []; + + foreach ($data as $key => $value) { + $segment = (string) $key; + $nextPath = [...$path, $segment]; + + if (is_array($value)) { + // A list of entries -- a shop's stock, an event's dialogue -- + // is something an author adds to and removes from, so every + // field inside one remembers which list it belongs to. + if ($this->isInspectorListValue($nextPath, $value)) { + $label = implode(' ', array_map( + static fn(string $part): string => ucwords(str_replace(['_', '-'], ' ', $part)), + ($nextPath[0] ?? null) === 'data' ? array_slice($nextPath, 1) : $nextPath + )); + $fields[] = [ + 'label' => sprintf('%s · %d', $label, count($value)), + 'value' => '', + 'editable' => false, + 'list' => [ + 'path' => $nextPath, + 'index' => max(0, count($value) - 1), + 'blank' => $this->blankInspectorListEntry($nextPath), + ], + ]; + + foreach ($value as $index => $entry) { + $entryPath = [...$nextPath, (string) $index]; + $entryList = ['path' => $nextPath, 'index' => (int) $index]; + + $fields = [ + ...$fields, + ...(is_array($entry) + ? $this->flattenInspectorFields($entry, $entryPath, $entryList) + : $this->flattenInspectorFields([(string) $index => $entry], $nextPath, $entryList)), + ]; + } + + continue; + } + + if (array_key_exists('x', $value) && array_key_exists('y', $value) && is_scalar($value['x']) && is_scalar($value['y'])) { + $label = implode(' ', array_map( + static fn(string $part): string => ucwords(str_replace(['_', '-'], ' ', $part)), + ($nextPath[0] ?? null) === 'data' ? array_slice($nextPath, 1) : $nextPath + )); + $fields[] = [ + 'label' => $label, + 'value' => '', + 'editable' => false, + ]; + $fields[] = [ + 'label' => ' X', + 'value' => (string) $value['x'], + 'control' => new InputControl( + is_int($value['x']) ? InputControlType::INTEGER : InputControlType::TEXT, + (string) $value['x'], + ), + 'path' => [...$nextPath, 'x'], + ]; + $fields[] = [ + 'label' => ' Y', + 'value' => (string) $value['y'], + 'control' => new InputControl( + is_int($value['y']) ? InputControlType::INTEGER : InputControlType::TEXT, + (string) $value['y'], + ), + 'path' => [...$nextPath, 'y'], + ]; + continue; + } + + $fields = [...$fields, ...$this->flattenInspectorFields($value, $nextPath, $list)]; + continue; + } + + if (! is_scalar($value) && $value !== null) { + continue; + } + + $displayPath = ($nextPath[0] ?? null) === 'data' + ? array_slice($nextPath, 1) + : $nextPath; + $label = implode(' ', array_map( + static fn(string $part): string => ctype_digit($part) + ? '#' . ((int) $part + 1) + : ucwords(str_replace(['_', '-'], ' ', $part)), + $displayPath + )); + $stringValue = match (true) { + is_bool($value) => $value ? 'true' : 'false', + is_float($value) => InputControl::formatFloat($value), + default => (string) $value, + }; + $controlType = match (true) { + is_bool($value) => InputControlType::BOOLEAN, + is_int($value) => InputControlType::INTEGER, + is_float($value) => InputControlType::FLOAT, + default => InputControlType::TEXT, + }; + $leaf = [ + 'label' => $label, + 'value' => $stringValue, + 'control' => new InputControl($controlType, $stringValue), + 'path' => $nextPath, + ]; + + if (is_array($list)) { + $leaf['list'] = $list; + } + + $fields[] = $leaf; + } + + return $fields; + } + + /** + * Distinguishes authored lists from associative configuration blocks. + * + * Empty arrays need an explicit known-list name because PHP cannot tell + * an empty list from an empty map. + * + * @param array $path The candidate path. + * @param array $value The candidate value. + */ + private function isInspectorListValue(array $path, array $value): bool + { + if (! array_is_list($value) || array_key_exists('x', $value)) { + return false; + } + + if ($value !== []) { + return true; + } + + return in_array( + (string) ($path[array_key_last($path)] ?? ''), + ['conditions', 'sets', 'dialogue', 'items', 'script', 'steps', 'options'], + true, + ); + } + + /** + * Returns the structured first row for an empty inspector list. + * + * @param array $path The list path. + * @return array + */ + private function blankInspectorListEntry(array $path): array + { + return match ((string) ($path[array_key_last($path)] ?? '')) { + 'conditions' => ['type' => 'switch', 'name' => '', 'value' => true], + 'sets' => ['type' => 'switch', 'name' => '', 'value' => true], + 'script' => ['type' => 'text', 'name' => '', 'text' => ''], + 'steps' => ['direction' => 'down', 'count' => 1, 'faceOnly' => false], + 'options' => ['text' => '', 'then' => []], + 'items' => ['item' => '', 'price' => 0], + default => ['name' => '', 'text' => ''], + }; + } + +} diff --git a/src/Inspector/MapKindField.php b/src/Inspector/MapKindField.php index 9080a99..bed5403 100644 --- a/src/Inspector/MapKindField.php +++ b/src/Inspector/MapKindField.php @@ -50,22 +50,7 @@ private function openNewMapKindPicker(): void /** @return array The Kind row for the Inspector. */ private function buildMapKindField(ProjectMap $map): array { - $id = $map->getMapDataField(['tileset']); - $labels = $this->referenceCatalog()->labelsFor('tilesets'); - - return [ - 'label' => 'Kind', - 'value' => match (true) { - $id === null => self::MAP_KIND_NONE, - ! is_string($id) => sprintf('%s · not a tileset id', get_debug_type($id)), - ! isset($labels[$id]) => sprintf('%s · not in assets/%s', $id, Tileset::DIRECTORY), - default => $labels[$id], - }, - 'selectedValue' => is_string($id) ? $id : '', - 'reference' => 'tilesets', - 'target' => 'map-kind', - 'field' => 'tileset', - ]; + return $this->createMapInspector()->buildMapKindField($map); } /** diff --git a/tests/Unit/EditorUxTest.php b/tests/Unit/EditorUxTest.php index 4a55448..b4994b4 100644 --- a/tests/Unit/EditorUxTest.php +++ b/tests/Unit/EditorUxTest.php @@ -249,7 +249,7 @@ function uxEditor(): Editor it('builds boolean and float controls for event data values', function () { $editor = uxEditor(); - $fields = callEditorMethod($editor, 'flattenInspectorFields', ['locked' => true, 'rate' => 1.5], ['data']); + $fields = callEditorMethod($editor, 'createMapInspector')->buildEventDataFields('A', ['data' => ['locked' => true, 'rate' => 1.5]]); $byLabel = array_column($fields, null, 'label'); expect($byLabel['Locked']['control']->type)->toBe(InputControlType::BOOLEAN) diff --git a/tests/Unit/EventReferenceFieldTest.php b/tests/Unit/EventReferenceFieldTest.php index 35cca4e..cc128fc 100644 --- a/tests/Unit/EventReferenceFieldTest.php +++ b/tests/Unit/EventReferenceFieldTest.php @@ -15,9 +15,11 @@ */ function eventDataFieldFor(array $data, array $path): array { - $editor = createEditorForTesting(makeTemporaryProject()); + $root = makeTemporaryProject(); + $editor = createEditorForTesting($root); + setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); - foreach (callEditorMethod($editor, 'buildEventDataFields', 'A', ['data' => $data]) as $field) { + foreach (callEditorMethod($editor, 'createMapInspector')->buildEventDataFields('A', ['data' => $data]) as $field) { if (($field['path'] ?? null) === $path) { return $field; } diff --git a/tests/Unit/ResumableStoryEventEditorTest.php b/tests/Unit/ResumableStoryEventEditorTest.php index 29f5c89..29865e7 100644 --- a/tests/Unit/ResumableStoryEventEditorTest.php +++ b/tests/Unit/ResumableStoryEventEditorTest.php @@ -44,7 +44,7 @@ function writePhase7ArrayFile(string $path, array $payload): void ->and($definition)->toHaveKeys(['conditions', 'sets', 'whenBlocked', 'cue']) ->and($definition['cue'])->toBe(['symbol' => '', 'color' => 'bright-yellow']); - $fields = callEditorMethod($editor, 'buildEventDataFields', 'E', $definition); + $fields = callEditorMethod($editor, 'createMapInspector')->buildEventDataFields('E', $definition); $byPath = []; foreach ($fields as $field) { @@ -74,7 +74,7 @@ function writePhase7ArrayFile(string $path, array $payload): void 'class' => ScriptEventTrigger::class, 'data' => ['scriptId' => 'dresser-note', 'mode' => 'action', 'reusable' => false], ]; - $fields = callEditorMethod($editor, 'buildEventDataFields', 'E', $definition); + $fields = callEditorMethod($editor, 'createMapInspector')->buildEventDataFields('E', $definition); $paths = array_map(static fn(array $field): string => implode('.', (array) ($field['path'] ?? [])), $fields); expect($paths)->toContain('cue.symbol', 'cue.color') From e5fc24cac902addd92bb68e70154fe7d4ae9c6fc Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 13:00:42 +0200 Subject: [PATCH 113/344] feat(session): read and edit the inspector through the session The session now serves the map inspector to native interfaces: - inspector.read lists a map's rows, and an event's when one is named, each with how it is edited (text, integer, float, boolean, options, reference, or info) and the key an edit names it by. - inspector.apply finds the row again by its key among the map's current rows and applies it through MapInspector as one undo step, against the map revision; refusals carry their details. - references.list gives a reference row's choices with their labels. Rows the terminal edits through flows of its own (the map's kind, an event's type, music variant conditions) are listed as info for now. --- src/Session/EditorSession.php | 158 +++++++++++++++++++++++ src/Session/SessionHost.php | 11 ++ tests/Unit/Session/EditorSessionTest.php | 64 +++++++++ 3 files changed, 233 insertions(+) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 4a3a88a..918e430 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -9,9 +9,13 @@ use Ichiloto\Editor\Canvas\CanvasEditor; use Ichiloto\Editor\Canvas\PieceRole; use Ichiloto\Editor\Database\DatabaseCatalog; +use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Events\EventTypeCatalog; use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\CommandHistory; +use Ichiloto\Editor\Inspector\InputControlType; +use Ichiloto\Editor\Inspector\InspectorRefusal; +use Ichiloto\Editor\Inspector\MapInspector; use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\ProjectMap; @@ -209,6 +213,90 @@ public function paint(string $mapId, int $revision, string $layerId, array $cell return ['status' => 'applied', 'changed' => $applied['changed'], 'revision' => $map->stateVersion()]; } + /** + * The inspector rows of a map, and of one of its events when one is + * named, as an interface lists and edits them. Each row says how it is + * edited (`kind`) and carries the `key` an edit names it by. + * + * Kinds: `text`, `integer`, `float`, `boolean`, `options` (one of + * `options`), `reference` (one of `references.list` for its + * `reference`), or `info` for a row that is only read here: headings, + * counts, and the rows the terminal edits through flows of its own (the + * map's kind, an event's type, music variant conditions). + * + * @return array{map: string, revision: int, event: ?string, rows: list>} + * @throws SessionRefusal When the map or event is unknown. + */ + public function readInspector(string $mapId, ?string $marker = null): array + { + $map = $this->requireMap($mapId); + if ($marker !== null && $map->getEventDefinition($marker) === null && $map->getEventArea($marker) === null) { + throw new SessionRefusal(sprintf('%s has no event %s.', $mapId, $marker)); + } + + return [ + 'map' => $mapId, + 'revision' => $map->stateVersion(), + 'event' => $marker, + 'rows' => array_map(self::describeRow(...), $this->collectInspectorFields($map, $marker)), + ]; + } + + /** + * Applies one inspector row's edit as one undo step. The row is found + * again by its key among the map's current rows, so the edit applies + * exactly as that row would in any interface. + * + * @param array $key The row's key, as `readInspector` gave it. + * @return array{revision: int, changed: bool} + * @throws SessionRefusal When the map is unknown or stale, the row is gone or read-only, or the edit is refused. + */ + public function applyInspector(string $mapId, int $revision, array $key, string $value): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $marker = is_string($key['marker'] ?? null) ? $key['marker'] : null; + $field = array_find($this->collectInspectorFields($map, $marker), + static fn(array $candidate): bool => self::describeKey($candidate) === $key); + + if ($field === null) { + throw new SessionRefusal('That row is no longer in the inspector; read it again.'); + } + if (self::describeRow($field)['kind'] === 'info') { + throw new SessionRefusal(sprintf('%s cannot be edited here.', trim((string) ($field['label'] ?? 'That row')))); + } + + try { + $command = $this->createMapInspector($map)->apply($map, $field, $value); + } catch (InspectorRefusal $refusal) { + throw new SessionRefusal(implode("\n", [$refusal->getMessage(), ...$refusal->details]), previous: $refusal); + } + if ($command !== null) { + $this->history->record($command); + } + + return ['revision' => $map->stateVersion(), 'changed' => $command !== null]; + } + + /** + * The values a reference row is chosen from, with how each reads. + * + * @return list + * @throws SessionRefusal When the map is unknown or the category is not a reference. + */ + public function listReferences(string $mapId, string $category): array + { + if (! ReferenceCatalog::knows($category)) { + throw new SessionRefusal(sprintf('There is no reference category %s.', $category)); + } + $catalog = new ReferenceCatalog($this->workspace, $this->requireMap($mapId)); + $labels = $catalog->labelsFor($category); + + return array_map(static fn(mixed $value): array => [ + 'value' => (string) $value, + 'label' => (string) ($labels[$value] ?? $value), + ], array_values($catalog->valuesFor($category))); + } + /** * Undoes the last change, wherever it was made. * @@ -281,6 +369,76 @@ private function traverseHistory(callable $step): array return ['label' => $command?->label, 'maps' => $changed]; } + /** @return array> */ + private function collectInspectorFields(ProjectMap $map, ?string $marker): array + { + $inspector = $this->createMapInspector($map); + + return [...$inspector->getMapFields($map), ...($marker === null ? [] : $inspector->getEventFields($map, $marker))]; + } + + private function createMapInspector(ProjectMap $map): MapInspector + { + return new MapInspector(new ReferenceCatalog($this->workspace, $map)); + } + + /** + * One inspector row as plain data. + * + * @param array $field + * @return array + */ + private static function describeRow(array $field): array + { + $target = $field['target'] ?? null; + $control = MapInspector::findControl($field); + $kind = match (true) { + ($field['editable'] ?? true) === false, $target === null, + in_array($target, ['map-kind', 'event-type'], true), + ($field['mapConditions'] ?? false) === true => 'info', + is_string($field['reference'] ?? null) => 'reference', + is_array($field['options'] ?? null) => 'options', + $control !== null => match ($control->type) { + InputControlType::INTEGER => 'integer', + InputControlType::FLOAT => 'float', + InputControlType::BOOLEAN => 'boolean', + default => 'text', + }, + default => 'info', + }; + + return array_filter([ + 'label' => (string) ($field['label'] ?? ''), + 'value' => (string) ($field['value'] ?? ''), + 'kind' => $kind, + 'options' => $kind === 'options' ? array_values(array_map('strval', $field['options'])) : null, + 'reference' => $kind === 'reference' ? $field['reference'] : null, + 'key' => self::describeKey($field), + ], static fn(mixed $value): bool => $value !== null); + } + + /** + * What names a row for an edit: its target and the field, path, marker + * and index it edits. Null for a row no edit applies through. + * + * @param array $field + * @return array|null + */ + private static function describeKey(array $field): ?array + { + if (! is_string($field['target'] ?? null)) { + return null; + } + + return array_filter([ + 'target' => $field['target'], + 'field' => isset($field['field']) ? (string) $field['field'] : null, + 'path' => isset($field['path']) ? array_values(array_map('strval', (array) $field['path'])) : null, + 'marker' => isset($field['marker']) ? (string) $field['marker'] : null, + 'index' => isset($field['index']) ? (int) $field['index'] : null, + ], static fn(mixed $value): bool => $value !== null); + } + /** @return array */ private function getMapsById(): array { diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index c90ef46..752e908 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -108,6 +108,17 @@ private function dispatch(string $method, array $params): array is_string($params['label'] ?? null) ? $params['label'] : 'Paint', ), 'map.save' => $session->saveMap(self::requireString($params, 'map')), + 'inspector.read' => $session->readInspector( + self::requireString($params, 'map'), + is_string($params['event'] ?? null) ? $params['event'] : null, + ), + 'inspector.apply' => $session->applyInspector( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key the inspector gave.'), + is_scalar($params['value'] ?? null) ? (string) $params['value'] : throw new InvalidRequest('"value" must be a string, number or boolean.'), + ), + 'references.list' => $session->listReferences(self::requireString($params, 'map'), self::requireString($params, 'category')), 'history.undo' => $session->undo(), 'history.redo' => $session->redo(), 'project.dirty' => ['dirty' => $session->hasUnsavedChanges()], diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index 90aeb09..f7bfc53 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -118,3 +118,67 @@ function sessionCell(array $map, string $layer, int $x, int $y): string ->and($request(8, 'no.such')['error']['message'])->toBe('Unknown method "no.such".') ->and($request(9, 'history.undo')['result']['maps'])->toBe(['test-map']); }); + +/** The inspector row with a label, the first when several share it. */ +function inspectorRow(array $inspector, string $label, ?string $after = null): array +{ + $rows = $inspector['rows']; + $start = $after === null ? 0 : array_search($after, array_column($rows, 'label'), true) + 1; + foreach (array_slice($rows, $start) as $row) { + if (trim($row['label']) === $label) { + return $row; + } + } + + throw new RuntimeException("No inspector row {$label}."); +} + +it('lists a map\'s inspector rows with how each is edited and the key that names it', function () { + $session = EditorSession::open(makeTemporaryProject()); + $inspector = $session->readInspector('test-map'); + + expect(inspectorRow($inspector, 'Name'))->toMatchArray(['kind' => 'text', 'key' => ['target' => 'map', 'field' => 'name']]) + ->and(inspectorRow($inspector, 'X', 'Size')['kind'])->toBe('integer') + ->and(inspectorRow($inspector, 'Events')['kind'])->toBe('info') + ->and(inspectorRow($inspector, 'Kind')['kind'])->toBe('info') + ->and(inspectorRow($inspector, 'Background Music'))->toMatchArray(['kind' => 'reference', 'reference' => 'bgm']) + ->and($inspector['revision'])->toBe($session->readMap('test-map')['revision']); +}); + +it('applies an inspector edit as one undo step and refuses a stale or read-only row', function () { + $session = EditorSession::open(makeTemporaryProject()); + $inspector = $session->readInspector('test-map'); + $name = inspectorRow($inspector, 'Name'); + + $applied = $session->applyInspector('test-map', $inspector['revision'], $name['key'], 'Renamed Map'); + + expect($applied['changed'])->toBeTrue() + ->and($session->readMap('test-map')['name'])->toBe('Renamed Map') + ->and(fn() => $session->applyInspector('test-map', $inspector['revision'], $name['key'], 'Again')) + ->toThrow(SessionRefusal::class, 'changed since revision'); + + expect($session->undo()['maps'])->toBe(['test-map']) + ->and($session->readMap('test-map')['name'])->toBe($name['value']); + + $fresh = $session->readInspector('test-map'); + expect(fn() => $session->applyInspector('test-map', $fresh['revision'], ['target' => 'map', 'field' => 'nope'], 'x')) + ->toThrow(SessionRefusal::class, 'no longer in the inspector'); +}); + +it('moves an event through its position row and lists the choices of a reference row', function () { + $session = EditorSession::open(makeTemporaryProject()); + $event = $session->readInspector('test-map', 'E'); + $x = inspectorRow($event, 'X', 'Position'); + $before = (int) $x['value']; + + expect(inspectorRow($event, 'Type')['kind'])->toBe('info') + ->and($x['key'])->toBe(['target' => 'event-bounds', 'field' => 'x', 'marker' => 'E']); + + $session->applyInspector('test-map', $event['revision'], $x['key'], (string) ($before + 1)); + $moved = array_find($session->readMap('test-map')['events'], static fn(array $candidate): bool => $candidate['marker'] === 'E'); + + expect($moved['cells'][0][0])->toBe($before + 1) + ->and(fn() => $session->readInspector('test-map', 'Q'))->toThrow(SessionRefusal::class, 'test-map has no event Q.') + ->and(array_column($session->listReferences('test-map', 'bgm'), 'value'))->toBeArray() + ->and(fn() => $session->listReferences('test-map', 'no-such-category'))->toThrow(SessionRefusal::class); +}); From c709ed5f958b677bf7de1d503510b375e55245db Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 13:10:20 +0200 Subject: [PATCH 114/344] feat(session): read schema database records through the session database.records lists a schema category's records with whether they can be edited; database.record describes one record's rows as the inspector describes its own. The six bespoke categories (actors, classes, skills, animations, quests, system) still build their rows in the terminal editor, so the session refuses them with that reason until those builders are shared. --- src/Session/EditorSession.php | 56 ++++++++++++++++++++++++ src/Session/SessionHost.php | 2 + tests/Unit/Session/EditorSessionTest.php | 14 ++++++ 3 files changed, 72 insertions(+) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 918e430..7f2d9f7 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -9,6 +9,7 @@ use Ichiloto\Editor\Canvas\CanvasEditor; use Ichiloto\Editor\Canvas\PieceRole; use Ichiloto\Editor\Database\DatabaseCatalog; +use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Events\EventTypeCatalog; use Ichiloto\Editor\History\Command; @@ -297,6 +298,50 @@ public function listReferences(string $mapId, string $category): array ], array_values($catalog->valuesFor($category))); } + /** + * A database category's records, as its list shows them, and whether + * they can be edited. The bespoke categories (actors, classes, skills, + * animations, quests, system) still build their rows in the terminal + * editor, so they are refused here until those builders are shared. + * + * @return array{category: string, editable: bool, readOnly: ?string, records: list} + * @throws SessionRefusal When the category is unknown or not yet served. + */ + public function listDatabaseRecords(string $category): array + { + $database = $this->requireRecordDatabase($category); + + return [ + 'category' => $category, + 'editable' => $database->isEditable(), + 'readOnly' => $database->getReadOnlyReason(), + 'records' => array_values(array_map('strval', $database->getEntryLabels())), + ]; + } + + /** + * One record's rows, described as the inspector's are. + * + * @return array{category: string, index: int, rows: list>} + * @throws SessionRefusal When the category or record is unknown. + */ + public function readDatabaseRecord(string $category, int $index): array + { + $database = $this->requireRecordDatabase($category); + if ($database->getRecordByIndex($index) === null) { + throw new SessionRefusal(sprintf('%s has no record %d.', $category, $index)); + } + + return [ + 'category' => $category, + 'index' => $index, + 'rows' => array_map(static fn(array $field): array => self::describeRow([ + ...$field, + 'target' => isset($field['field']) && ($field['editable'] ?? true) !== false ? 'record' : null, + ]), $database->getSettingsFields($index)), + ]; + } + /** * Undoes the last change, wherever it was made. * @@ -439,6 +484,17 @@ private static function describeKey(array $field): ?array ], static fn(mixed $value): bool => $value !== null); } + /** @throws SessionRefusal */ + private function requireRecordDatabase(string $category): ProjectRecordDatabase + { + if (! array_any(DatabaseCatalog::all(), static fn($definition): bool => $definition->key === $category)) { + throw new SessionRefusal(sprintf('There is no database category %s.', $category)); + } + + return $this->workspace->getRecordDatabase($category) + ?? throw new SessionRefusal(sprintf('The %s database is edited in the terminal editor for now.', $category)); + } + /** @return array */ private function getMapsById(): array { diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 752e908..c42c146 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -118,6 +118,8 @@ private function dispatch(string $method, array $params): array is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key the inspector gave.'), is_scalar($params['value'] ?? null) ? (string) $params['value'] : throw new InvalidRequest('"value" must be a string, number or boolean.'), ), + 'database.records' => $session->listDatabaseRecords(self::requireString($params, 'category')), + 'database.record' => $session->readDatabaseRecord(self::requireString($params, 'category'), self::requireInt($params, 'index')), 'references.list' => $session->listReferences(self::requireString($params, 'map'), self::requireString($params, 'category')), 'history.undo' => $session->undo(), 'history.redo' => $session->redo(), diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index f7bfc53..7558706 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -182,3 +182,17 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a ->and(array_column($session->listReferences('test-map', 'bgm'), 'value'))->toBeArray() ->and(fn() => $session->listReferences('test-map', 'no-such-category'))->toThrow(SessionRefusal::class); }); + +it('lists a schema database\'s records and reads one record\'s rows', function () { + $session = EditorSession::open(makeTemporaryProject()); + $items = $session->listDatabaseRecords('items'); + $record = $session->readDatabaseRecord('items', 0); + + expect($items['records'])->not->toBe([]) + ->and($items['editable'])->toBeTrue() + ->and(array_column($record['rows'], 'label'))->toContain('Name') + ->and(array_find($record['rows'], static fn(array $row): bool => $row['label'] === 'Name')['value'])->toBe($items['records'][0]) + ->and(fn() => $session->readDatabaseRecord('items', 999))->toThrow(SessionRefusal::class, 'items has no record 999.') + ->and(fn() => $session->listDatabaseRecords('actors'))->toThrow(SessionRefusal::class, 'edited in the terminal editor for now') + ->and(fn() => $session->listDatabaseRecords('nope'))->toThrow(SessionRefusal::class, 'There is no database category nope.'); +}); From 4c591256aed7a4a1891425047ac7971ef960d4f3 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 13:44:18 +0200 Subject: [PATCH 115/344] feat(session): read a map's world and its tileset palette map.world returns the PresentationWorld operations the game uploads for a map, built from its current tile layers through MapGraphics::fromSources (ProjectMap::loadGraphics), so unsaved tile edits draw as the game draws them; a refused graphics set leaves the glyphs and says why. tiles.palette lays the map's tileset out as RPG Maker MZ's palette (TilePalette), each tab drawn by the same world composition. Needs Engine MapGraphics::fromSources (feat/map-graphics-from-sources). --- src/Maps/TilePalette.php | 85 ++++++++++++++++++++++++ src/ProjectMap.php | 11 +++ src/Session/EditorSession.php | 70 +++++++++++++++++++ src/Session/SessionHost.php | 2 + tests/Unit/Session/EditorSessionTest.php | 45 +++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 src/Maps/TilePalette.php diff --git a/src/Maps/TilePalette.php b/src/Maps/TilePalette.php new file mode 100644 index 0000000..65c29a5 --- /dev/null +++ b/src/Maps/TilePalette.php @@ -0,0 +1,85 @@ +>}> Tabs in RPG Maker order, each a grid of tile identities. + */ + public static function getTabs(Tileset $tileset): array + { + $tabs = []; + foreach (TilesetSheet::cases() as $sheet) { + if (! isset($tileset->sheets[$sheet->value])) { + continue; + } + $tab = str_starts_with($sheet->value, 'A') ? 'A' : $sheet->value; + foreach (array_chunk(self::getSheetIds($sheet), self::COLUMNS) as $row) { + $tabs[$tab][] = $row; + } + } + $ordered = []; + foreach ($tabs as $name => $ids) { + $ordered[] = ['name' => (string) $name, 'ids' => $ids]; + } + + return $ordered; + } + + /** + * A tab drawn as the game draws tiles: a world of the tab's grid, one + * tile per cell, with blank glyphs. + * + * @param list> $ids + */ + public static function buildWorld(Tileset $tileset, array $ids, string $assetRoot, string $id): PresentationWorld + { + $rows = array_map(static fn(array $row): array => array_pad($row, self::COLUMNS, TileId::EMPTY), $ids); + $layers = new MapLayerSet([new MapLayer(self::LAYER, 1, false, self::LAYER, + implode("\n", array_fill(0, count($rows), str_repeat(' ', self::COLUMNS))))]); + $tiles = new MapTileLayer(self::LAYER, 1, self::LAYER, + implode("\n", array_map(static fn(array $row): string => implode(' ', $row), $rows))); + + return PresentationWorld::getFromLayers($layers, $id, new MapGraphics($tileset, [$tiles]), $assetRoot); + } + + /** @return list */ + private static function getSheetIds(TilesetSheet $sheet): array + { + [$first, $count, $step] = match ($sheet) { + TilesetSheet::A1 => [TileId::A1, 16, TileId::SHAPES], + TilesetSheet::A2 => [TileId::A2, 32, TileId::SHAPES], + TilesetSheet::A3 => [TileId::A3, 32, TileId::SHAPES], + TilesetSheet::A4 => [TileId::A4, 48, TileId::SHAPES], + TilesetSheet::A5 => [TileId::A5, 128, 1], + TilesetSheet::B => [TileId::B, 256, 1], + TilesetSheet::C => [TileId::C, 256, 1], + TilesetSheet::D => [TileId::D, 256, 1], + TilesetSheet::E => [TileId::E, 256, 1], + }; + + return array_map(static fn(int $index): int => $first + $index * $step, range(0, $count - 1)); + } +} diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 6d01e86..d9ff39d 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -606,6 +606,17 @@ public function loadTileset(): ?Tileset return Tileset::load($this->getAssetRoot(), $id); } + /** + * The map's graphics as the game loads them, from its current tile + * layers, unsaved edits included, or null when it has none. + * + * @throws InvalidArgumentException When the game would refuse them. + */ + public function loadGraphics(): ?MapGraphics + { + return MapGraphics::fromSources($this->getTileLayerSources(), $this->mapId, $this->getMapDataField(['tileset']), + $this->getLayerSet(), $this->getAssetRoot(), $this->getMapDataField([MapGraphics::SETTINGS_KEY])); + } /** The project's asset root, where tilesets and their sheets live. */ public function getAssetRoot(): string { diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 7f2d9f7..e7c4197 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -19,9 +19,13 @@ use Ichiloto\Editor\Inspector\MapInspector; use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Maps\MapLayers; +use Ichiloto\Editor\Maps\TilePalette; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Validation\MapValidator; +use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; +use Ichiloto\Engine\Rendering\Presentation\PresentationWorld; +use InvalidArgumentException; /** * One open project as every editor interface edits it: its documents, the @@ -168,6 +172,72 @@ public function readMap(string $mapId): array ]; } + /** + * The map's world as the game uploads it to a graphical renderer: its + * glyph rows and, when its graphics load, its tileset and tile layers, + * unsaved edits included. `layerIds` maps each glyph layer's id to its + * world layer id. Graphics never decide whether a map shows: when the + * game would refuse them, the world holds glyphs only and + * `graphicsIssue` says why. + * + * @return array{map: string, revision: int, assetRoot: string, operations: list>, layerIds: array, animated: bool, graphicsIssue: ?string} + * @throws SessionRefusal When the map is unknown or its layers cannot be presented. + */ + public function readWorld(string $mapId): array + { + $map = $this->requireMap($mapId); + $issue = null; + try { + $graphics = $map->loadGraphics(); + } catch (InvalidArgumentException|MapSourceRefusal $error) { + $graphics = null; + $issue = $error->getMessage(); + } + try { + $layerSet = $map->getLayerSet(); + $world = PresentationWorld::getFromLayers($layerSet, 'map', $graphics, $map->getAssetRoot()); + } catch (InvalidArgumentException|MapSourceRefusal $error) { + throw new SessionRefusal(sprintf('%s cannot be drawn: %s', $mapId, $error->getMessage()), previous: $error); + } + $glyphLayers = array_values(array_filter($map->getLayers(), static fn(array $layer): bool => $layer['id'] !== MapLayers::EVENT)); + $layerIds = []; + foreach ($layerSet->layers as $index => $layer) { + $layerIds[(string) $glyphLayers[$index]['id']] = PresentationLayerPolicy::getMapLayerId($layer); + } + + return [ + 'map' => $map->mapId, + 'revision' => $map->stateVersion(), + 'assetRoot' => $map->getAssetRoot(), + 'operations' => $world->getOperations(true), + 'layerIds' => $layerIds, + 'animated' => $world->animated, + 'graphicsIssue' => $issue, + ]; + } + /** + * The tile palette of the map's tileset ({@see TilePalette}): each tab's + * grid of tile identities and the world that draws it. + * + * @return array{tileset: string, name: string, assetRoot: string, tabs: list>, operations: list>}>} + * @throws SessionRefusal When the map is unknown or has no usable tileset. + */ + public function readTilePalette(string $mapId): array + { + $map = $this->requireMap($mapId); + try { + $tileset = $map->loadTileset() ?? throw new SessionRefusal(sprintf('%s names no tileset; choose one before placing tiles.', $mapId)); + $tabs = []; + foreach (TilePalette::getTabs($tileset) as $tab) { + $world = TilePalette::buildWorld($tileset, $tab['ids'], $map->getAssetRoot(), 'palette:' . $tab['name']); + $tabs[] = [...$tab, 'operations' => $world->operations]; + } + } catch (InvalidArgumentException $error) { + throw new SessionRefusal(sprintf('%s tileset cannot be used: %s', $mapId, $error->getMessage()), previous: $error); + } + + return ['tileset' => $tileset->id, 'name' => $tileset->name, 'assetRoot' => $map->getAssetRoot(), 'tabs' => $tabs]; + } /** * Paints one glyph over cells of a layer, with the tiles that follow it, * as one undo step. A glyph that could be several pieces is asked about: diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index c42c146..189855c 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -97,6 +97,8 @@ private function dispatch(string $method, array $params): array return match ($method) { 'maps.list' => $session->describeMaps(), 'map.read' => $session->readMap(self::requireString($params, 'map')), + 'map.world' => $session->readWorld(self::requireString($params, 'map')), + 'tiles.palette' => $session->readTilePalette(self::requireString($params, 'map')), 'map.paint' => $session->paint( self::requireString($params, 'map'), self::requireInt($params, 'revision'), diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index 7558706..62b7dcd 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -196,3 +196,48 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a ->and(fn() => $session->listDatabaseRecords('actors'))->toThrow(SessionRefusal::class, 'edited in the terminal editor for now') ->and(fn() => $session->listDatabaseRecords('nope'))->toThrow(SessionRefusal::class, 'There is no database category nope.'); }); + +it('reads a map\'s world as the game uploads it, its glyph layers named by the editor\'s layer ids', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + $world = $session->readWorld('test-map'); + $put = $world['operations'][0]; + + expect($put['op'])->toBe('put') + ->and($put['value']['tileset']['sheets'])->toBe(['Graphics/Tilesets/Home_A2.png', 'Graphics/Tilesets/Home_B.png']) + ->and(array_column($put['value']['layers'], 'id'))->toContain('tiles:floor', 'tiles:decor') + ->and(array_values($world['layerIds']))->toBe(array_values(array_filter(array_column($put['value']['layers'], 'id'), + static fn(string $id): bool => str_starts_with($id, 'map:')))) + ->and(array_keys($world['layerIds']))->toBe(array_values(array_filter(array_column($session->readMap('test-map')['layers'], 'id'), + static fn(string $id): bool => $id !== 'event'))) + ->and($world['assetRoot'])->toBe($root . '/assets') + ->and($world['graphicsIssue'])->toBeNull(); +}); + +it('still draws a map\'s glyphs when the game would refuse its graphics, saying why', function () { + $root = mapGraphicsProject(); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace("'tileset' => 'home'", "'tileset' => 'missing'", (string) file_get_contents($data))); + $world = EditorSession::open($root)->readWorld('test-map'); + + expect($world['graphicsIssue'])->toContain('missing') + ->and($world['operations'][0]['value'])->not->toHaveKey('tileset') + ->and(array_column($world['operations'], 'op'))->toContain('worldRows'); +}); + +it('lays out the tile palette as RPG Maker MZ does, drawn by the same world', function () { + $session = EditorSession::open(mapGraphicsProject()); + $palette = $session->readTilePalette('test-map'); + [$a, $b] = $palette['tabs']; + + // A2's 32 autotile kinds, one entry each; B tile by tile from the empty tile. + expect(array_column($palette['tabs'], 'name'))->toBe(['A', 'B']) + ->and($a['ids'][0])->toBe([2816, 2864, 2912, 2960, 3008, 3056, 3104, 3152]) + ->and(count($a['ids']))->toBe(4) + ->and($b['ids'][0])->toBe([0, 1, 2, 3, 4, 5, 6, 7]) + ->and(count($b['ids']))->toBe(32) + ->and($b['operations'][0]['id'])->toBe('palette:B') + ->and($b['operations'][0]['value']['columns'])->toBe(8) + ->and(fn() => EditorSession::open(makeTemporaryProject())->readTilePalette('test-map')) + ->toThrow(SessionRefusal::class, 'names no tileset'); +}); From d6d86318d43da751871660f9c5a73d9ffd7b196f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 13:50:35 +0200 Subject: [PATCH 116/344] feat(session): place and erase single tiles as one undo step CanvasEditor::setTiles writes one tile identity into cells of a tile layer (0 erases), creating the layer when the map has none, as one command over the tile layer sources. The session serves it as tiles.paint, and tiles.read returns each tile layer's identities for a tile picker. TileLayerSource now refuses an entry that is not a tile identity the Engine reads, at the boundary every tile write passes through, instead of leaving the Engine to reject the layer on its next read. --- src/Canvas/CanvasEditor.php | 29 +++++++++++++ src/Maps/TileLayerSource.php | 8 +++- src/ProjectMap.php | 10 +++++ src/Session/EditorSession.php | 52 ++++++++++++++++++++++++ src/Session/SessionHost.php | 9 ++++ tests/Unit/Session/EditorSessionTest.php | 41 +++++++++++++++++++ 6 files changed, 148 insertions(+), 1 deletion(-) diff --git a/src/Canvas/CanvasEditor.php b/src/Canvas/CanvasEditor.php index baa4c34..16a70c4 100644 --- a/src/Canvas/CanvasEditor.php +++ b/src/Canvas/CanvasEditor.php @@ -156,6 +156,35 @@ public static function resolvePaintStyle(string $symbol, ?string $colorDirective return [sprintf('', $colorDirective), '']; } + /** + * Sets one tile in each cell of a tile layer, `0` erasing it, as one undo + * step, creating the layer when the map does not have it yet. Tiles are + * independent of glyphs: nothing here changes a glyph or collision. An + * autotile is placed by its kind; the Engine shapes it from its + * neighbours. + * + * @param list $cells The cells, as [x, y]. + * @return array{command: ?Command, changed: int} No command when nothing changed. + * @throws MapSourceRefusal When the tile is not a tile identity, a cell is outside the map or the layer cannot be written; nothing is changed. + */ + public static function setTiles(ProjectMap $map, string $layerName, array $cells, int $tile, string $label): array + { + $before = $map->getTileLayerSources(); + $entries = array_map(static fn(array $cell): array => ['x' => $cell[0], 'y' => $cell[1], 'entry' => (string) $tile], $cells); + $previous = $map->readTileEntries([$layerName], 0, 0, $map->getWidth(), $map->getHeight())[$layerName] ?? []; + $map->writeTileCells([$layerName => $entries]); + $after = $map->getTileLayerSources(); + if ($after === $before) { + return ['command' => null, 'changed' => 0]; + } + $changed = count(array_filter($cells, static fn(array $cell): bool + => ($previous[$cell[1]][$cell[0]] ?? (string) TileId::EMPTY) !== (string) $tile)); + + return ['command' => new GenericCommand($label, + static fn() => $map->restoreTileLayerSources($after), + static fn() => $map->restoreTileLayerSources($before)), 'changed' => $changed]; + } + /** * One undo step for a glyph stroke and the tile layer change made with it, * such as a stamped piece or a pasted block. diff --git a/src/Maps/TileLayerSource.php b/src/Maps/TileLayerSource.php index aef50d9..fc73296 100644 --- a/src/Maps/TileLayerSource.php +++ b/src/Maps/TileLayerSource.php @@ -151,7 +151,8 @@ public static function setCellEntries(string $source, string $displayPath, MapLa /** * Writes entries into a layer's cells, refusing them all when one cell - * is outside the layer. + * is outside the layer or one entry is not a tile identity the Engine + * reads. * * @param list $cells The entry for each cell. * @param bool $keepsEmptyCells Whether a `0` entry leaves its cell as it was instead of emptying it. @@ -166,6 +167,11 @@ private static function writeCellEntries(string $source, string $displayPath, Ma if (! isset($entries[$cell['y']][$cell['x']])) { throw new MapSourceRefusal(sprintf('%s has no cell at (%d, %d); nothing was changed.', $displayPath, $cell['x'], $cell['y'])); } + if (preg_match('/\A\d+\z/', $cell['entry']) !== 1 + || ((int) $cell['entry'] !== TileId::EMPTY && ! TileId::isValid((int) $cell['entry']))) { + throw new MapSourceRefusal(sprintf("'%s' is not an RPG Maker tile identity, so %s cannot take it at (%d, %d); nothing was changed.", + $cell['entry'], $displayPath, $cell['x'], $cell['y'])); + } if (! $keepsEmptyCells || $cell['entry'] !== (string)TileId::EMPTY) { $written[$cell['y']][$cell['x']] = $cell['entry']; } diff --git a/src/ProjectMap.php b/src/ProjectMap.php index d9ff39d..5f3b47d 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -563,6 +563,16 @@ public function getTileLayersMovingWith(string $layerId): array return array_keys(array_filter($owners, static fn(?string $owner): bool => $owner === $layer['name'])); } + /** + * The names of the map's tile layers, in drawing order. + * + * @return list + */ + public function getTileLayerNames(): array + { + return $this->layers->getTileLayerNames(); + } + /** * Reads the entries of the named tile layers over a rectangle, by row. * diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index e7c4197..b9f32a2 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -238,6 +238,58 @@ public function readTilePalette(string $mapId): array return ['tileset' => $tileset->id, 'name' => $tileset->name, 'assetRoot' => $map->getAssetRoot(), 'tabs' => $tabs]; } + /** + * Each tile layer's tile identities by row, unsaved edits included, in + * drawing order: what a tile picker reads. A layer that cannot be read + * says why instead. + * + * @return array{map: string, revision: int, layers: list>, issue: ?string}>} + * @throws SessionRefusal When the map is unknown. + */ + public function readTiles(string $mapId): array + { + $map = $this->requireMap($mapId); + $layers = []; + foreach ($map->getTileLayerNames() as $name) { + try { + $rows = $map->readTileEntries([$name], 0, 0, $map->getWidth(), $map->getHeight())[$name] ?? []; + $layers[] = ['name' => $name, 'rows' => array_map(static fn(array $row): array => array_map(intval(...), $row), $rows), + 'issue' => null]; + } catch (MapSourceRefusal $refusal) { + $layers[] = ['name' => $name, 'rows' => [], 'issue' => $refusal->getMessage()]; + } + } + + return ['map' => $map->mapId, 'revision' => $map->stateVersion(), 'layers' => $layers]; + } + + /** + * Sets one tile in cells of a tile layer, `0` erasing, as one undo step + * ({@see CanvasEditor::setTiles()}). A layer the map does not have yet is + * created. Tiles never change glyphs or collision. + * + * @param list $cells The cells, as [x, y]. + * @return array{changed: int, revision: int} + * @throws SessionRefusal When the map is unknown, stale or read-only, or refuses the tile. + */ + public function paintTiles(string $mapId, int $revision, string $layerName, array $cells, int $tile, string $label = 'Place tiles'): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + try { + $applied = CanvasEditor::setTiles($map, $layerName, $cells, $tile, $label); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($applied['command'] !== null) { + $this->history->record($applied['command']); + } + + return ['changed' => $applied['changed'], 'revision' => $map->stateVersion()]; + } + /** * Paints one glyph over cells of a layer, with the tiles that follow it, * as one undo step. A glyph that could be several pieces is asked about: diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 189855c..a435dea 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -99,6 +99,15 @@ private function dispatch(string $method, array $params): array 'map.read' => $session->readMap(self::requireString($params, 'map')), 'map.world' => $session->readWorld(self::requireString($params, 'map')), 'tiles.palette' => $session->readTilePalette(self::requireString($params, 'map')), + 'tiles.read' => $session->readTiles(self::requireString($params, 'map')), + 'tiles.paint' => $session->paintTiles( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + self::requireCells($params), + self::requireInt($params, 'tile'), + is_string($params['label'] ?? null) ? $params['label'] : 'Place tiles', + ), 'map.paint' => $session->paint( self::requireString($params, 'map'), self::requireInt($params, 'revision'), diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index 62b7dcd..0aac977 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -241,3 +241,44 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a ->and(fn() => EditorSession::open(makeTemporaryProject())->readTilePalette('test-map')) ->toThrow(SessionRefusal::class, 'names no tileset'); }); + +it('places, erases and undoes tiles as one step each, drawn from the unsaved layer and never touching glyphs', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + $map = $session->readMap('test-map'); + $floor = static fn(array $tiles): array => array_find($tiles['layers'], static fn(array $layer): bool => $layer['name'] === 'floor')['rows']; + + $placed = $session->paintTiles('test-map', $map['revision'], 'floor', [[0, 0], [1, 0]], 5); + expect($placed['changed'])->toBe(2) + ->and($floor($session->readTiles('test-map'))[0])->toBe([5, 5, 2816, 2816]) + ->and($session->readMap('test-map')['layers'])->toBe(array_map(static fn(array $layer): array => $layer, $map['layers'])) + ->and(array_filter($session->readWorld('test-map')['operations'], static fn(array $operation): bool + => $operation['op'] === 'worldTiles' && $operation['layerId'] === 'tiles:floor' && $operation['rows'][0]['row'] === 0))->not->toBe([]); + + // Placing what is already there changes nothing and records no step. + $unchanged = $session->paintTiles('test-map', $placed['revision'], 'floor', [[0, 0]], 5); + expect($unchanged['changed'])->toBe(0); + $erased = $session->paintTiles('test-map', $unchanged['revision'], 'floor', [[0, 0]], 0, 'Erase tiles'); + expect($floor($session->readTiles('test-map'))[0])->toBe([0, 5, 2816, 2816]) + ->and($session->undo()['label'])->toBe('Erase tiles') + ->and($session->undo()['label'])->toBe('Place tiles') + ->and($floor($session->readTiles('test-map'))[0])->toBe([2816, 2816, 2816, 2816]) + ->and($session->readMap('test-map')['dirty'])->toBeFalse(); + + // A new tile layer is created for its first tile; a stale revision, a cell off the map and a non-tile are refused. + $current = $session->readMap('test-map')['revision']; + $session->paintTiles('test-map', $current, 'rugs', [[3, 1]], 2864); + expect(array_column($session->readTiles('test-map')['layers'], 'name'))->toContain('rugs') + ->and(fn() => $session->paintTiles('test-map', $erased['revision'], 'floor', [[0, 0]], 5))->toThrow(SessionRefusal::class, 'changed since revision') + ->and(fn() => $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[9, 9]], 5))->toThrow(SessionRefusal::class, 'no cell at (9, 9)') + ->and(fn() => $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[0, 0]], 9000))->toThrow(SessionRefusal::class, "'9000' is not an RPG Maker tile identity"); +}); + +it('saves placed tiles into the tile layer file the Engine reads', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'decor', [[0, 1]], 7); + $session->saveMap('test-map'); + + expect(readTileRows($root . '/assets/Maps/test-map/graphics/02.decor.tiles.php'))->toBe([[0, 5, 0, 0], [7, 0, 0, 5]]); +}); From bdeacf551cea3fb4ec63318dc706a4435f93c532 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 13:55:39 +0200 Subject: [PATCH 117/344] feat(session): create and delete maps without discarding unsaved work The session serves maps.kinds, map.create (name, kind, width, height) and map.delete, which asks first while MapReferences finds something that sends the player there (the starting position, transfer events on every map, cinematic transfers). ProjectWorkspace::createMap takes the size, and withLoadedMap and withoutMap change the map list while every other loaded map keeps its unsaved changes. Fixes the terminal editor reloading the whole workspace from disk after it created, duplicated or deleted a map, which silently dropped unsaved edits on every other map and database. Creating and duplicating now also keep the undo history; deleting still clears it, since its steps may name the deleted map. withReplacedMap no longer drops the project's script commands. ProjectMap::slugify becomes public so map names have one slug rule. --- src/Editor.php | 20 +++---- src/Maps/MapReferences.php | 66 ++++++++++++++++++++++ src/ProjectMap.php | 2 +- src/ProjectWorkspace.php | 46 ++++++++++++++- src/Session/EditorSession.php | 72 ++++++++++++++++++++++++ src/Session/SessionHost.php | 8 +++ tests/Unit/Session/EditorSessionTest.php | 47 ++++++++++++++++ 7 files changed, 248 insertions(+), 13 deletions(-) create mode 100644 src/Maps/MapReferences.php diff --git a/src/Editor.php b/src/Editor.php index 20be7a1..8a785c9 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -7455,7 +7455,7 @@ private function createNewMap(?string $kind = null, ?string $kindLabel = null): try { $mapId = $this->workspace->createMap(kind: $kind); - $this->reloadWorkspaceSelectingMap($mapId); + $this->addWorkspaceMapSelecting($mapId); $this->selectedInspectorFieldIndex = 0; $this->setStatus($kind === null ? sprintf('Created %s. It has no kind: the project has no tilesets in assets/%s/.', $mapId, Tileset::DIRECTORY) @@ -7486,7 +7486,7 @@ private function duplicateSelectedMap(): void return; } - $this->reloadWorkspaceSelectingMap($mapId); + $this->addWorkspaceMapSelecting($mapId); $this->setStatus(sprintf('Duplicated %s.', $mapId), StatusLevel::SUCCESS); $this->renderSelectionDependentArea(); } catch (Throwable $throwable) { @@ -8551,9 +8551,10 @@ private function deleteSelectedMap(): void return; } + // Undo steps may name the deleted map; every other map keeps its unsaved changes. $this->history->clear(); $this->activeStrokeCommand = null; - $this->workspace = ProjectWorkspace::fromProject($this->projectRoot); + $this->workspace = $this->workspace->withoutMap($deletedMapId); $this->selectedAssetIndex = $this->clampSelection(min($currentIndex, max(0, count($this->workspace->mapIds) - 1))); $this->cursorX = 0; $this->cursorY = 0; @@ -8573,18 +8574,17 @@ private function deleteSelectedMap(): void } /** - * Reloads the workspace and selects the requested map id. + * Adds a map just written to disk to the workspace and selects it. * - * @param string $mapId The map id to select after reload. + * @param string $mapId The created or duplicated map's id. * @return void */ - private function reloadWorkspaceSelectingMap(string $mapId): void + private function addWorkspaceMapSelecting(string $mapId): void { - // A full rescan replaces every loaded map object, so retained undo - // commands would mutate stale instances - drop them. - $this->history->clear(); + // Only the new map is read, so every other map keeps its unsaved + // changes and the undo history stays valid. $this->activeStrokeCommand = null; - $this->workspace = ProjectWorkspace::fromProject($this->projectRoot); + $this->workspace = $this->workspace->withLoadedMap($mapId); $selectedIndex = array_search($mapId, $this->workspace->mapIds, true); $this->selectedAssetIndex = is_int($selectedIndex) ? $selectedIndex : 0; $this->cursorX = 0; diff --git a/src/Maps/MapReferences.php b/src/Maps/MapReferences.php new file mode 100644 index 0000000..be3f101 --- /dev/null +++ b/src/Maps/MapReferences.php @@ -0,0 +1,66 @@ + + */ + public function describe(string $mapId): array + { + $found = []; + $positions = $this->workspace->systemDatabase->getField('startingPositions'); + if (is_array($positions)) { + foreach ($positions as $role => $position) { + if (is_array($position) && ($position['destinationMap'] ?? null) === $mapId) { + $found[] = sprintf('the %s starting position (System)', $role); + } + } + } + foreach ($this->workspace->maps as $map) { + foreach ($map->getEventDefinitions() as $marker => $definition) { + if (is_array($definition) && (($definition['data'] ?? [])['destinationMap'] ?? null) === $mapId) { + $found[] = sprintf('event %s on %s', $marker, $map->mapId); + } + } + } + foreach (CutsceneType::cases() as $type) { + foreach ($this->workspace->cutscenes?->assets($type) ?? [] as $asset) { + if (self::hasTransferTo($asset->commands(), $mapId)) { + $found[] = sprintf('%s %s', $type->noun(), $asset->id); + } + } + } + + return $found; + } + + /** Whether a command tree, branches included, transfers to the map. */ + private static function hasTransferTo(array $commands, string $mapId): bool + { + if (($commands['type'] ?? null) === 'transfer' && ($commands['map'] ?? null) === $mapId) { + return true; + } + + return array_any($commands, static fn(mixed $value): bool => is_array($value) && self::hasTransferTo($value, $mapId)); + } +} diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 5f3b47d..56ce354 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -2691,7 +2691,7 @@ private static function directoryHasContents(string $directory): bool * @param string $fallback The fallback slug. * @return string */ - private static function slugify(string $value, string $fallback = 'new-map'): string + public static function slugify(string $value, string $fallback = 'new-map'): string { $value = strtolower(trim($value)); $value = preg_replace('/[^a-z0-9]+/i', '-', $value) ?? ''; diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index 005eea9..973b7a1 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -306,6 +306,43 @@ public function withReplacedMap(int $index, ProjectMap $map): self { $maps = $this->maps; $maps[$index] = $map; + + return $this->withMaps($maps); + } + + /** + * Returns a workspace that also holds a map just written to disk, such + * as a created or duplicated one, keeping every loaded object and its + * unsaved changes. A map that cannot be read joins read-only, as a full + * rescan would add it. + */ + public function withLoadedMap(string $mapId): self + { + $directory = $this->getMapsRoot() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $mapId); + try { + $map = ProjectMap::fromDirectory($this->getMapsRoot(), $directory); + } catch (Throwable $error) { + $map = ProjectMap::createReadOnlyFromDirectory($this->getMapsRoot(), $directory, $error->getMessage()); + } + $maps = array_values(array_filter($this->maps, static fn(ProjectMap $loaded): bool => $loaded->mapId !== $map->mapId)); + + return $this->withMaps([...$maps, $map]); + } + + /** Returns a workspace without a deleted map, keeping every other loaded object. */ + public function withoutMap(string $mapId): self + { + return $this->withMaps(array_values(array_filter($this->maps, static fn(ProjectMap $map): bool => $map->mapId !== $mapId))); + } + + /** + * The same project with another map list, in map id order as a full + * rescan lists them. + * + * @param list $maps + */ + private function withMaps(array $maps): self + { usort($maps, static fn(ProjectMap $left, ProjectMap $right): int => strcmp($left->mapId, $right->mapId)); return new self( @@ -324,6 +361,7 @@ classDatabase: $this->classDatabase, recordDatabases: $this->recordDatabases, cutscenes: $this->cutscenes, config: $this->config, + scriptCommands: $this->scriptCommands, ); } @@ -411,12 +449,16 @@ public function getMapByIndex(int $index): ?ProjectMap * @param string|null $baseName The preferred base name. * @return string The created map id. */ - public function createMap(?string $baseName = null, ?FileSetOperations $files = null, ?string $kind = null): string + public function createMap(?string $baseName = null, ?FileSetOperations $files = null, ?string $kind = null, int $width = 48, + int $height = 18): string { + if ($width < 1 || $height < 1) { + throw new MapSourceRefusal(sprintf('A map is at least 1 x 1 cells, not %d x %d.', $width, $height)); + } $mapsRoot = $this->getMapsRoot(); $baseName = $this->getNextAvailableBaseName($baseName ?? 'new-map'); $directory = $mapsRoot . DIRECTORY_SEPARATOR . $baseName; - ProjectMap::createBlank($directory, $baseName, self::humanizeBaseName($baseName), files: $files, kind: $kind); + ProjectMap::createBlank($directory, $baseName, self::humanizeBaseName($baseName), $width, $height, $files, $kind); return $baseName; } diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index b9f32a2..dc96edf 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -19,6 +19,7 @@ use Ichiloto\Editor\Inspector\MapInspector; use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Maps\MapLayers; +use Ichiloto\Editor\Maps\MapReferences; use Ichiloto\Editor\Maps\TilePalette; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; @@ -26,6 +27,7 @@ use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; use Ichiloto\Engine\Rendering\Presentation\PresentationWorld; use InvalidArgumentException; +use RuntimeException; /** * One open project as every editor interface edits it: its documents, the @@ -95,6 +97,76 @@ public function describeMaps(): array ], $this->workspace->maps); } + /** + * The kinds a new map can have: the project's tilesets, which its tiles + * and pieces come from. + * + * @return list + */ + public function listMapKinds(): array + { + $kinds = new ReferenceCatalog($this->workspace)->labelsFor('tilesets'); + + return array_map(static fn(string $id, string $name): array => ['value' => $id, 'label' => $name], array_keys($kinds), $kinds); + } + + /** + * Creates a blank map at the maps root, written to disk at once as the + * terminal editor creates one, named after `$name` (`new-map` when + * empty) with a numbered suffix when that is taken. Every open map keeps + * its unsaved changes and the undo history stays. + * + * @return array{map: string, maps: list>} + * @throws SessionRefusal When the kind is unknown, the size is not a size, or the files cannot be written. + */ + public function createMap(?string $name, ?string $kind, int $width, int $height): array + { + if ($kind !== null && ! in_array($kind, array_column($this->listMapKinds(), 'value'), true)) { + throw new SessionRefusal(sprintf('There is no map kind %s.', $kind)); + } + $baseName = $name === null || trim($name) === '' ? null : ProjectMap::slugify($name, ''); + if ($baseName === '') { + throw new SessionRefusal(sprintf('"%s" has no letters or digits to name a map with.', $name)); + } + try { + $mapId = $this->workspace->createMap($baseName, kind: $kind, width: $width, height: $height); + } catch (MapSourceRefusal|RuntimeException $error) { + throw new SessionRefusal(sprintf('The map was not created: %s', $error->getMessage()), previous: $error); + } + $this->workspace = $this->workspace->withLoadedMap($mapId); + + return ['map' => $mapId, 'maps' => $this->describeMaps()]; + } + + /** + * Deletes a map's files from disk at once, as the terminal editor does. + * While something still sends the player there ({@see MapReferences}), + * nothing is deleted until the author confirms: the answer is a question + * listing them. The undo history is cleared, since its steps may name + * the deleted map; every other map keeps its unsaved changes. + * + * @return array{status: 'deleted', map: string, maps: list>}|array{status: 'question', map: string, references: list} + * @throws SessionRefusal When the map is unknown or read-only, or its files cannot be removed. + */ + public function deleteMap(string $mapId, bool $confirmed = false): array + { + $this->requireMap($mapId); + $references = new MapReferences($this->workspace)->describe($mapId); + if ($references !== [] && ! $confirmed) { + return ['status' => 'question', 'map' => $mapId, 'references' => $references]; + } + try { + $this->workspace->deleteMap((int) array_search($mapId, $this->workspace->mapIds, true)); + } catch (MapSourceRefusal|RuntimeException $error) { + throw new SessionRefusal(sprintf('%s was not deleted: %s', $mapId, $error->getMessage()), previous: $error); + } + $this->workspace = $this->workspace->withoutMap($mapId); + $this->history->clear(); + + return ['status' => 'deleted', 'map' => $mapId, 'maps' => $this->describeMaps()]; + } + + /** * A map as an interface draws it: its layers' glyphs and colours, its * events and its NPCs, at its current revision, unsaved changes included. diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index a435dea..c5970f8 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -96,6 +96,14 @@ private function dispatch(string $method, array $params): array return match ($method) { 'maps.list' => $session->describeMaps(), + 'maps.kinds' => $session->listMapKinds(), + 'map.create' => $session->createMap( + is_string($params['name'] ?? null) ? $params['name'] : null, + is_string($params['kind'] ?? null) ? $params['kind'] : null, + self::requireInt($params, 'width'), + self::requireInt($params, 'height'), + ), + 'map.delete' => $session->deleteMap(self::requireString($params, 'map'), ($params['confirm'] ?? false) === true), 'map.read' => $session->readMap(self::requireString($params, 'map')), 'map.world' => $session->readWorld(self::requireString($params, 'map')), 'tiles.palette' => $session->readTilePalette(self::requireString($params, 'map')), diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index 0aac977..121ed30 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -282,3 +282,50 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a expect(readTileRows($root . '/assets/Maps/test-map/graphics/02.decor.tiles.php'))->toBe([[0, 5, 0, 0], [7, 0, 0, 5]]); }); + +it('creates a map named and sized by the author, keeping every open map\'s unsaved changes and undo', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + $map = $session->readMap('test-map'); + $session->paintTiles('test-map', $map['revision'], 'floor', [[0, 0]], 5); + + $created = $session->createMap('Old Mill', 'home', 6, 4); + $mill = $session->readMap('old-mill'); + + expect($created['map'])->toBe('old-mill') + ->and(array_column($created['maps'], 'id'))->toContain('old-mill', 'test-map') + ->and([$mill['name'], $mill['width'], $mill['height']])->toBe(['Old Mill', 6, 4]) + ->and($session->listMapKinds())->toBe([['value' => 'home', 'label' => 'Home']]) + ->and($session->readMap('test-map')['dirty'])->toBeTrue() + ->and($session->undo()['label'])->toBe('Place tiles') + ->and($session->createMap('Old Mill', null, 2, 2)['map'])->toBe('old-mill-2') + ->and(fn() => $session->createMap('!!!', null, 2, 2))->toThrow(SessionRefusal::class, 'no letters or digits') + ->and(fn() => $session->createMap(null, 'castle', 2, 2))->toThrow(SessionRefusal::class, 'no map kind castle') + ->and(fn() => $session->createMap(null, null, 0, 2))->toThrow(SessionRefusal::class, 'at least 1 x 1'); +}); + +it('asks before deleting a map something still transfers to, then deletes it and keeps other unsaved work', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + $session->createMap('Cellar', null, 3, 3); + $session->createMap('Attic', null, 3, 3); + $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[1, 1]], 5); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace("'events' => [],", "'events' => ['D' => ['class' => 'Door', 'data' => ['destinationMap' => 'cellar']]],", + (string) file_get_contents($data))); + $session = EditorSession::open($root); + $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[1, 1]], 5); + + $asked = $session->deleteMap('cellar'); + expect($asked)->toBe(['status' => 'question', 'map' => 'cellar', 'references' => ['event D on test-map']]) + ->and(is_dir($root . '/assets/Maps/cellar'))->toBeTrue() + ->and($session->deleteMap('attic')['status'])->toBe('deleted') + ->and(is_dir($root . '/assets/Maps/attic'))->toBeFalse(); + + $deleted = $session->deleteMap('cellar', confirmed: true); + expect($deleted['status'])->toBe('deleted') + ->and(array_column($deleted['maps'], 'id'))->not->toContain('cellar') + ->and(is_dir($root . '/assets/Maps/cellar'))->toBeFalse() + ->and($session->readMap('test-map')['dirty'])->toBeTrue() + ->and(fn() => $session->deleteMap('cellar'))->toThrow(SessionRefusal::class); +}); From 03cb7a3fb4e820e0345a64823c043c1d8a5fc9c7 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 14:05:45 +0200 Subject: [PATCH 118/344] feat(maps): edit glyph and tile layers through a shared layer editor LayerEditor now owns layer editing for every interface: creating, renaming, reordering, toggling decoration and removing glyph layers, and creating, renaming, reordering, removing and setting the offset and movesWith of tile layers. Each edit returns an applied, unrecorded snapshot command, or a question when it would change the map's resolved collisions; nothing changes until it is made again confirmed. ProjectMap applies layer changes to a copy of its layers that it adopts only once the tile layer settings are written, so a refused settings write leaves the map unchanged. Renaming a gameplay layer renames the movesWith references to it; removing it or making it decoration drops them. Renaming or removing a tile layer renames or drops its settings. Tile layer settings are validated with the Engine's MapGraphics readers. Reordered and renamed files are staged paths, so a save writes the new files and removes the old ones in one transaction. A created layer's id no longer collides with a layer that was loaded at that order and has since moved. The terminal layer prompt now calls LayerEditor; its workflow and messages are unchanged, except that a rename to the same name no longer records an empty undo step. Removed: ProjectMap::getLayerRenameCollisionChange(), replaced by countRenameCollisionChanges() and its move, decoration and removal counterparts, and the rename parameters of MapLayers::getLayerSet(), whose only caller was that method. --- src/Canvas/LayerCanvas.php | 40 ++-- src/Maps/LayerEditor.php | 273 +++++++++++++++++++++++++++ src/Maps/MapLayers.php | 208 +++++++++++++++++++-- src/Maps/TileLayerSettings.php | 160 ++++++++++++++++ src/ProjectMap.php | 325 +++++++++++++++++++++++++++++---- tests/Unit/LayerEditorTest.php | 257 ++++++++++++++++++++++++++ 6 files changed, 1192 insertions(+), 71 deletions(-) create mode 100644 src/Maps/LayerEditor.php create mode 100644 src/Maps/TileLayerSettings.php create mode 100644 tests/Unit/LayerEditorTest.php diff --git a/src/Canvas/LayerCanvas.php b/src/Canvas/LayerCanvas.php index bf4ed92..06b9a01 100644 --- a/src/Canvas/LayerCanvas.php +++ b/src/Canvas/LayerCanvas.php @@ -5,7 +5,7 @@ namespace Ichiloto\Editor\Canvas; use Ichiloto\Editor\EditorWindow; -use Ichiloto\Editor\History\GenericCommand; +use Ichiloto\Editor\Maps\LayerEditor; use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\UI\Modal; @@ -216,30 +216,22 @@ private function handleLayerPromptInput(string $input): void if ($map === null || ! in_array($prompt['id'], array_column($this->getTerminalCanvasLayers(), 'id'), true)) { throw new MapSourceRefusal('This layer is not available in the terminal canvas. Cancel and select a gameplay layer.'); } - $before = $map->captureLayerSnapshot(); - if ($prompt['action'] === 'rename' && ! isset($prompt['confirmation'])) { - $change = $map->getLayerRenameCollisionChange($prompt['id'], $prompt['name']); - if ($change !== null) { - $this->layerPrompt['confirmation'] = $change; - $this->requestFullRender(); - return; - } - } - $id = match ($prompt['action']) { - 'create' => $map->createLayer($prompt['name']), - 'rename' => (function () use ($map, $prompt): string { - $map->renameLayer($prompt['id'], $prompt['name'], isset($prompt['confirmation'])); - return $prompt['id']; - })(), - 'remove' => (function () use ($map, $prompt): string { - $map->removeLayer($prompt['id']); - return $map->getBaseLayerId(); - })(), + // Removal is confirmed by its own prompt; a rename that + // changes collisions asks again before anything changes. + $edit = match ($prompt['action']) { + 'create' => LayerEditor::createLayer($map, $prompt['name']), + 'rename' => LayerEditor::renameLayer($map, $prompt['id'], $prompt['name'], isset($prompt['confirmation'])), + 'remove' => LayerEditor::removeLayer($map, $prompt['id'], true), }; - $after = $map->captureLayerSnapshot(); - $this->recordCommand(new GenericCommand('Layer ' . $prompt['action'], - fn() => $map->restoreLayerSnapshot($after), fn() => $map->restoreLayerSnapshot($before))); - $this->selectCanvasLayer($id); + if ($edit['question'] !== null) { + $this->layerPrompt['confirmation'] = $edit['question']; + $this->requestFullRender(); + return; + } + if ($edit['command'] !== null) { + $this->recordCommand($edit['command']); + } + $this->selectCanvasLayer((string) $edit['layer']); $this->layerPrompt = null; $this->modals->remove(Modal::LAYER_EDIT); $this->setStatus('Layer change staged. Ctrl+S saves the complete file set.'); diff --git a/src/Maps/LayerEditor.php b/src/Maps/LayerEditor.php new file mode 100644 index 0000000..876c84c --- /dev/null +++ b/src/Maps/LayerEditor.php @@ -0,0 +1,273 @@ +createLayer($name, $decoration); + }); + + return self::describeEdit($command, $id); + } + + /** + * Renames a glyph layer. Collision lookup uses the layer name, so a + * rename that changes collisions is asked about first. + * + * @return LayerEdit + * @throws MapSourceRefusal When the rename is refused. + */ + public static function renameLayer(ProjectMap $map, string $id, string $name, bool $confirmCollisionChange = false): array + { + $changed = $confirmCollisionChange ? 0 : $map->countRenameCollisionChanges($id, $name); + if ($changed > 0) { + return self::askCollisionChange(sprintf('Rename %s to %s', self::findLayerName($map, $id), $name), $changed); + } + + return self::describeEdit(self::record($map, 'Layer rename', + static fn() => $map->renameLayer($id, $name, true)), $id); + } + + /** + * Moves a glyph layer to an order; a layer holding that order takes + * this layer's order. The topmost gameplay glyph sets a cell's collision, + * so a move that changes collisions is asked about first. + * + * @return LayerEdit + * @throws MapSourceRefusal When the move is refused. + */ + public static function moveLayer(ProjectMap $map, string $id, int $order, bool $confirmCollisionChange = false): array + { + $changed = $confirmCollisionChange ? 0 : $map->countMoveCollisionChanges($id, $order); + if ($changed > 0) { + return self::askCollisionChange(sprintf('Moving %s to order %02d', self::findLayerName($map, $id), $order), $changed); + } + + return self::describeEdit(self::record($map, 'Layer reorder', + static fn() => $map->moveLayer($id, $order, true)), $id); + } + + /** + * The order a glyph layer moves to one step above or below: the order of + * the next layer, gameplay or decoration, drawn over or under it. + * + * @throws MapSourceRefusal When the direction is unknown, there is no such layer, or no layer is there. + */ + public static function findAdjacentLayerOrder(ProjectMap $map, string $id, string $direction): int + { + $layers = array_values(array_filter($map->getLayers(), static fn(array $layer): bool => $layer['id'] !== MapLayers::EVENT)); + $index = array_search($id, array_column($layers, 'id'), true); + if ($index === false) { + throw new MapSourceRefusal("{$map->mapId} has no layer {$id} to reorder."); + } + + return self::findAdjacentOrder(array_column($layers, 'order'), $index, $direction, (string) $layers[$index]['name']); + } + + /** + * Makes a glyph layer decoration, drawn without collision, or gameplay. + * A change that changes collisions is asked about first. + * + * @return LayerEdit + * @throws MapSourceRefusal When the change is refused, such as for the last gameplay layer. + */ + public static function setLayerDecoration(ProjectMap $map, string $id, bool $decoration, bool $confirmCollisionChange = false): array + { + $changed = $confirmCollisionChange ? 0 : $map->countDecorationCollisionChanges($id, $decoration); + if ($changed > 0) { + return self::askCollisionChange(sprintf('Making %s %s', self::findLayerName($map, $id), $decoration ? 'decoration' : 'gameplay'), $changed); + } + + return self::describeEdit(self::record($map, 'Layer decoration', + static fn() => $map->setLayerDecoration($id, $decoration, true)), $id); + } + + /** + * Removes a glyph layer and its cells. A removal that changes collisions + * is asked about first; the layer left to work on is the map's base layer. + * + * @return LayerEdit + * @throws MapSourceRefusal When the removal is refused, such as for the last gameplay layer. + */ + public static function removeLayer(ProjectMap $map, string $id, bool $confirmCollisionChange = false): array + { + $changed = $confirmCollisionChange ? 0 : $map->countRemovalCollisionChanges($id); + if ($changed > 0) { + return self::askCollisionChange(sprintf('Removing %s', self::findLayerName($map, $id)), $changed); + } + $command = self::record($map, 'Layer remove', static fn() => $map->removeLayer($id)); + + return self::describeEdit($command, $map->getBaseLayerId()); + } + + /** + * Adds an empty tile layer, placed among the tile layers as one a piece + * names is. + * + * @return LayerEdit + * @throws MapSourceRefusal When the name is invalid or taken, or the map has no room for it. + */ + public static function createTileLayer(ProjectMap $map, string $name): array + { + return self::describeEdit(self::record($map, 'Tile layer create', static fn() => $map->createTileLayer($name)), $name); + } + + /** + * Renames a tile layer and its settings. + * + * @return LayerEdit + * @throws MapSourceRefusal When there is no such layer, or the new name is invalid or taken. + */ + public static function renameTileLayer(ProjectMap $map, string $name, string $newName): array + { + return self::describeEdit(self::record($map, 'Tile layer rename', static fn() => $map->renameTileLayer($name, $newName)), $newName); + } + + /** + * Moves a tile layer to a drawing order; a tile layer holding that order + * takes this layer's order. + * + * @return LayerEdit + * @throws MapSourceRefusal When there is no such layer or the order is not 00-99. + */ + public static function moveTileLayer(ProjectMap $map, string $name, int $order): array + { + return self::describeEdit(self::record($map, 'Tile layer reorder', static fn() => $map->moveTileLayer($name, $order)), $name); + } + + /** + * The order a tile layer moves to one step above or below: the order of + * the next tile layer drawn over or under it. + * + * @throws MapSourceRefusal When the direction is unknown, there is no such layer, or no layer is there. + */ + public static function findAdjacentTileLayerOrder(ProjectMap $map, string $name, string $direction): int + { + $layers = $map->describeTileLayers()['layers']; + $index = array_search($name, array_column($layers, 'name'), true); + if ($index === false) { + throw new MapSourceRefusal("There is no tile layer {$name}. Nothing was changed."); + } + + return self::findAdjacentOrder(array_column($layers, 'order'), $index, $direction, $name); + } + + /** + * Removes a tile layer, its tiles and its settings. + * + * @return LayerEdit + * @throws MapSourceRefusal When there is no such layer. + */ + public static function removeTileLayer(ProjectMap $map, string $name): array + { + return self::describeEdit(self::record($map, 'Tile layer remove', static fn() => $map->removeTileLayer($name)), null); + } + + /** + * Sets a tile layer's offset across and down, in field cells, and the + * gameplay layer its tiles move with, or none. + * + * @param array $offset + * @return LayerEdit + * @throws MapSourceRefusal When there is no such layer, or the Engine would refuse the settings. + */ + public static function setTileLayerSettings(ProjectMap $map, string $name, array $offset, ?string $movesWith): array + { + return self::describeEdit(self::record($map, 'Tile layer settings', + static fn() => $map->setTileLayerSettings($name, $offset, $movesWith)), $name); + } + + /** + * Applies a change and returns the command that restores the layers and + * data on either side of it, or null when it changed nothing. + */ + private static function record(ProjectMap $map, string $label, Closure $change): ?Command + { + $before = $map->captureLayerSnapshot(); + $change(); + $after = $map->captureLayerSnapshot(); + if ($after === $before) { + return null; + } + + return new GenericCommand($label, static fn() => $map->restoreLayerSnapshot($after), + static fn() => $map->restoreLayerSnapshot($before)); + } + + /** @return LayerEdit */ + private static function describeEdit(?Command $command, ?string $layer): array + { + return ['command' => $command, 'layer' => $layer, 'question' => null]; + } + + /** @return LayerEdit */ + private static function askCollisionChange(string $action, int $changed): array + { + return ['command' => null, 'layer' => null, 'question' => sprintf( + '%s changes collision at %d cell(s). Shared collisions.php stays unchanged.', $action, $changed)]; + } + + private static function findLayerName(ProjectMap $map, string $id): string + { + return (string) (array_find($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $id)['name'] ?? $id); + } + + /** + * @param list $orders Orders in drawing order. + * @throws MapSourceRefusal When the direction is unknown or no layer is there. + */ + private static function findAdjacentOrder(array $orders, int $index, string $direction, string $name): int + { + $step = match ($direction) { + self::ABOVE => 1, + self::BELOW => -1, + default => throw new MapSourceRefusal(sprintf("Move a layer '%s' or '%s', not '%s'.", self::ABOVE, self::BELOW, $direction)), + }; + + return $orders[$index + $step] ?? throw new MapSourceRefusal(sprintf('%s is already the %s layer.', $name, + $step > 0 ? 'top' : 'bottom')); + } +} diff --git a/src/Maps/MapLayers.php b/src/Maps/MapLayers.php index a876ab8..cc3b569 100644 --- a/src/Maps/MapLayers.php +++ b/src/Maps/MapLayers.php @@ -19,6 +19,10 @@ * The map's graphical tile layers share the boundary: the TUI never paints * single tiles, but writes the tiles of stamped and drawn pieces and resizes, * relocates and removes them with the map. + * + * Changing the layers themselves (creating, renaming, reordering, toggling + * decoration, removing) replaces layer entries and never changes a grid in + * place, so a clone can try a change without touching this set. */ final class MapLayers { @@ -142,7 +146,12 @@ public function createLayer(string $name, bool $decoration, ?int $order = null): $this->layers[self::BASE]['path'] = $this->buildPath(0, 'terrain', false); $this->legacy = false; } + // Ids name a layer for a session and survive reordering, so the id + // its order would give at load may already be taken. $id = 'map:' . $order; + for ($copy = 2; isset($this->layers[$id]); $copy++) { + $id = sprintf('map:%d:%d', $order, $copy); + } $grid = new EditableGrid(implode("\n", array_map( static fn(array $row): string => str_repeat(' ', count($row)), $this->getBaseGrid()->cells, ))); @@ -163,13 +172,67 @@ public function renameLayer(string $id, string $name): void $this->layers[$layer['id']]['path'] = $this->buildPath($layer['order'], $name, $layer['decoration']); } - public function removeLayer(string $id): void + /** + * Moves a layer to another order, renaming its file's prefix. When + * another layer holds that order the two exchange orders. + * + * @throws MapSourceRefusal When the layer cannot move or the order is not 00-99. + */ + public function moveLayer(string $id, int $order): void { $layer = $this->getLayer($id); $this->assertManagedLayer($layer); - if (! $layer['decoration'] && count(array_filter($this->layers, + if ($order < MapLayer::MIN_ORDER || $order > MapLayer::MAX_ORDER) { + throw new MapSourceRefusal('Layer order must be a two-digit number (00-99).'); + } + if ($layer['order'] === $order) { + return; + } + foreach ($this->layers as $other) { + if ($other['id'] !== self::EVENT && $other['order'] === $order) { + $this->setLayerEntry($other['id'], $other['name'], $layer['order'], $other['decoration']); + } + } + $this->setLayerEntry($layer['id'], $layer['name'], $order, $layer['decoration']); + } + + /** + * Makes a layer decoration, drawn without collision, or gameplay, + * renaming its file's kind. A map keeps at least one gameplay layer. + * + * @throws MapSourceRefusal When the layer cannot change or is the last gameplay layer. + */ + public function setLayerDecoration(string $id, bool $decoration): void + { + $layer = $this->getLayer($id); + $this->assertManagedLayer($layer); + if ($layer['decoration'] === $decoration) { + return; + } + if ($decoration && $this->isLastGameplayLayer($layer)) { + throw new MapSourceRefusal('The last gameplay layer cannot become decoration.'); + } + $this->setLayerEntry($layer['id'], $layer['name'], $layer['order'], $decoration); + } + + private function setLayerEntry(string $id, string $name, int $order, bool $decoration): void + { + $this->layers[$id] = [...$this->layers[$id], 'name' => $name, 'order' => $order, 'decoration' => $decoration, + 'path' => $this->buildPath($order, $name, $decoration)]; + } + + private function isLastGameplayLayer(array $layer): bool + { + return ! $layer['decoration'] && count(array_filter($this->layers, static fn(array $entry): bool => $entry['id'] !== self::EVENT && ! $entry['decoration'], - )) === 1) { + )) === 1; + } + + public function removeLayer(string $id): void + { + $layer = $this->getLayer($id); + $this->assertManagedLayer($layer); + if ($this->isLastGameplayLayer($layer)) { throw new MapSourceRefusal('The last gameplay layer cannot be removed.'); } unset($this->layers[$layer['id']]); @@ -178,7 +241,7 @@ public function removeLayer(string $id): void private function assertManagedLayer(array $layer): void { if ($this->legacy || $layer['id'] === self::EVENT) { - throw new MapSourceRefusal('Only authored layers in layers/ can be renamed or removed.'); + throw new MapSourceRefusal('Only authored layers in layers/ can be renamed, reordered, made decoration or removed.'); } } @@ -369,6 +432,131 @@ public function readTileEntries(array $names, int $x, int $y, int $width, int $h return $read; } + /** + * The map's tile layers in drawing order: each one's name, order and path. + * + * @return list + */ + public function getTileLayers(): array + { + $layers = []; + foreach (array_keys($this->tileSources) as $path) { + if (preg_match(MapGraphics::FILENAME_PATTERN, basename($path), $matches) === 1) { + $layers[] = ['name' => $matches['name'], 'order' => (int) $matches['order'], 'path' => $path]; + } + } + usort($layers, static fn(array $a, array $b): int => $a['order'] <=> $b['order']); + + return $layers; + } + + /** + * Adds an empty tile layer, every cell `0`, placed among the tile layers + * as a layer a piece names is ({@see writeTileEntries()}). + * + * @param (Closure(string, list): ?string)|null $placeBefore + * @throws MapSourceRefusal When the name is taken or not a tile layer name, or the map cannot take another tile layer. + */ + public function createTileLayer(string $name, ?Closure $placeBefore = null): void + { + $sources = $this->tileSources; + if ($this->findTileLayerPath($name, $sources) !== null) { + throw new MapSourceRefusal("Tile layer {$name} already exists. Nothing was changed."); + } + $path = $this->addTileLayerPath($name, $sources, $placeBefore); + $sources[$path] = TileLayerSource::createEmpty($this->getLayerSet()); + ksort($sources, SORT_STRING); + $this->tileSources = $sources; + } + + /** + * Renames a tile layer, keeping its order and its tiles. + * + * @throws MapSourceRefusal When there is no such layer, or the new name is taken or not a tile layer name. + */ + public function renameTileLayer(string $name, string $newName): void + { + $layer = $this->requireTileLayer($name); + if ($newName === $name) { + return; + } + if ($this->findTileLayerPath($newName, $this->tileSources) !== null) { + throw new MapSourceRefusal("Tile layer {$newName} already exists. Nothing was changed."); + } + $this->setTileLayerPaths([$layer['path'] => $this->buildTileLayerPath($layer['order'], $newName)]); + } + + /** + * Moves a tile layer to another order, renaming its file's prefix. When + * another tile layer holds that order the two exchange orders. + * + * @throws MapSourceRefusal When there is no such layer or the order is not 00-99. + */ + public function moveTileLayer(string $name, int $order): void + { + $layer = $this->requireTileLayer($name); + if ($order < MapLayer::MIN_ORDER || $order > MapLayer::MAX_ORDER) { + throw new MapSourceRefusal('Tile layer order must be a two-digit number (00-99). Nothing was changed.'); + } + $paths = []; + foreach ($this->getTileLayers() as $other) { + if ($other['order'] === $order && $other['path'] !== $layer['path']) { + $paths[$other['path']] = $this->buildTileLayerPath($layer['order'], $other['name']); + } + } + $paths[$layer['path']] = $this->buildTileLayerPath($order, $name); + $this->setTileLayerPaths($paths); + } + + /** + * Removes a tile layer and its tiles. + * + * @throws MapSourceRefusal When there is no such layer. + */ + public function removeTileLayer(string $name): void + { + unset($this->tileSources[$this->requireTileLayer($name)['path']]); + } + + /** + * @return array{name: string, order: int, path: string} + * @throws MapSourceRefusal When the map has no tile layer with the name. + */ + private function requireTileLayer(string $name): array + { + $path = $this->findTileLayerPath($name, $this->tileSources); + + return array_find($this->getTileLayers(), static fn(array $layer): bool => $layer['path'] === $path) + ?? throw new MapSourceRefusal("There is no tile layer {$name}. Nothing was changed."); + } + + /** @throws MapSourceRefusal When the name is not a tile layer name. */ + private function buildTileLayerPath(int $order, string $name): string + { + $path = sprintf('%s/%s/%02d.%s.tiles.php', $this->directory, MapGraphics::DIRECTORY, $order, $name); + if (preg_match(MapGraphics::FILENAME_PATTERN, basename($path)) !== 1) { + throw new MapSourceRefusal("'{$name}' is not a tile layer name. Nothing was changed."); + } + + return $path; + } + + /** + * Moves tile layer sources to new paths, so a save writes them there and + * removes the files they leave. + * + * @param array $paths New paths by current path. + */ + private function setTileLayerPaths(array $paths): void + { + $sources = []; + foreach ($this->tileSources as $path => $source) { + $sources[$paths[$path] ?? $path] = $source; + } + ksort($sources, SORT_STRING); + $this->tileSources = $sources; + } + /** @param array $tileSources Tile layer sources by path, as {@see getTileSources()} returns them. */ public function restoreTileSources(array $tileSources): void { @@ -440,12 +628,7 @@ private function addTileLayerPath(string $name, array &$sources, ?Closure $place throw new MapSourceRefusal(sprintf('Tile layer %s cannot be added: a map holds up to %d tile layers, ordered 00 to 99. Nothing was changed.', $name, MapGraphics::MAX_LAYERS)); } - $path = sprintf('%s/%s/%02d.%s.tiles.php', $this->directory, MapGraphics::DIRECTORY, $order, $name); - if (preg_match(MapGraphics::FILENAME_PATTERN, basename($path)) !== 1) { - throw new MapSourceRefusal("'{$name}' is not a tile layer name. Nothing was changed."); - } - - return $path; + return $this->buildTileLayerPath($order, $name); } /** A tile layer path relative to its map, as refusals name it. */ @@ -459,13 +642,12 @@ public function assertDimensions(): void $this->getLayerSet()->assertMatchingGrid($this->getEventGrid()->getSymbols(), $this->layers[self::EVENT]['path']); } - public function getLayerSet(?string $renamedId = null, ?string $newName = null): MapLayerSet + public function getLayerSet(): MapLayerSet { $layers = []; foreach ($this->getLayers() as $layer) { if ($layer['id'] !== self::EVENT) { - $layers[] = new MapLayer($layer['id'] === $renamedId ? $newName : $layer['name'], - $layer['order'], $layer['decoration'], $layer['path'], + $layers[] = new MapLayer($layer['name'], $layer['order'], $layer['decoration'], $layer['path'], MapGridSource::parseSource($layer['grid']->getSource(), $layer['path'])); } } diff --git a/src/Maps/TileLayerSettings.php b/src/Maps/TileLayerSettings.php new file mode 100644 index 0000000..93bc1fe --- /dev/null +++ b/src/Maps/TileLayerSettings.php @@ -0,0 +1,160 @@ + ['floor' => ['offset' => + * [0, -0.5], 'movesWith' => 'buildings']]`, {@see MapGraphics::SETTINGS_KEY}) + * naming the layers the map has while its tile and gameplay layers are + * renamed and removed, and sets one layer's settings. + * + * Each change returns the settings unchanged when it touches nothing, so an + * authored value is never rewritten for nothing. A layer whose settings + * become empty loses its entry, as the Engine refuses an empty one, and + * settings without entries become null, which removes the key. Settings that + * are not a map of layer names are returned as they are: validation reports + * them ({@see MapGraphics::readLayerOffsets()}), and only the Engine's readers + * decide what is valid. + */ +final class TileLayerSettings +{ + private const string OFFSET_KEY = 'offset'; + + private function __construct() + { + } + + /** + * Moves a tile layer's settings to its new name, last: the map's data + * source keeps the keys it keeps in order and appends new ones, and the + * Engine reads the settings by name, never by position. + */ + public static function renameLayer(mixed $settings, string $name, string $newName): mixed + { + if (! is_array($settings) || ! array_key_exists($name, $settings) || $name === $newName) { + return $settings; + } + $layer = $settings[$name]; + unset($settings[$name]); + $settings[$newName] = $layer; + + return $settings; + } + + /** Drops a removed tile layer's settings. */ + public static function removeLayer(mixed $settings, string $name): mixed + { + if (! is_array($settings) || ! array_key_exists($name, $settings)) { + return $settings; + } + unset($settings[$name]); + + return self::normalize($settings); + } + + /** Makes the tile layers that move with a renamed gameplay layer name it by its new name. */ + public static function renameOwner(mixed $settings, string $owner, string $newOwner): mixed + { + return self::updateMovesWith($settings, $owner, $newOwner); + } + + /** Makes the tile layers that moved with a gameplay layer that is gone, or now decoration, move with none. */ + public static function removeOwner(mixed $settings, string $owner): mixed + { + return self::updateMovesWith($settings, $owner, null); + } + + /** + * Sets one tile layer's offset across and down, in field cells, and the + * gameplay layer it moves with, or none. No offset (0, 0) and no layer + * leave their keys out. An offset equal to the authored one keeps the + * authored numbers. + * + * @param array $offset Across and down. + */ + public static function setLayer(mixed $settings, string $name, array $offset, ?string $movesWith): mixed + { + if ($settings !== null && ! is_array($settings)) { + return $settings; + } + $layer = $settings[$name] ?? []; + if (! is_array($layer)) { + $layer = []; + } + $zero = array_is_list($offset) && count($offset) === 2 + && array_all($offset, static fn(mixed $value): bool => (is_int($value) || is_float($value)) && (float) $value === 0.0); + if ($zero) { + unset($layer[self::OFFSET_KEY]); + } elseif (! self::isSameOffset($layer[self::OFFSET_KEY] ?? null, $offset)) { + $layer[self::OFFSET_KEY] = array_map(static fn(mixed $value): mixed => + (is_int($value) || is_float($value)) && (float) $value === 0.0 ? 0 : $value, $offset); + } + if ($movesWith === null) { + unset($layer[MapGraphics::MOVES_WITH_KEY]); + } else { + $layer[MapGraphics::MOVES_WITH_KEY] = $movesWith; + } + $next = $settings ?? []; + if ($layer === []) { + unset($next[$name]); + } else { + $next[$name] = $layer; + } + if ($next === ($settings ?? [])) { + return $settings; + } + + return self::normalize($next); + } + + private static function updateMovesWith(mixed $settings, string $owner, ?string $newOwner): mixed + { + if (! is_array($settings)) { + return $settings; + } + $next = $settings; + foreach ($settings as $name => $layer) { + if (! is_array($layer) || ($layer[MapGraphics::MOVES_WITH_KEY] ?? null) !== $owner) { + continue; + } + if ($newOwner === null) { + unset($layer[MapGraphics::MOVES_WITH_KEY]); + } else { + $layer[MapGraphics::MOVES_WITH_KEY] = $newOwner; + } + if ($layer === []) { + unset($next[$name]); + } else { + $next[$name] = $layer; + } + } + + return $next === $settings ? $settings : self::normalize($next); + } + + /** Whether two offsets are the same numbers, so the authored one can stay as written. */ + private static function isSameOffset(mixed $authored, array $offset): bool + { + if (! is_array($authored) || ! array_is_list($authored) || count($authored) !== count($offset)) { + return false; + } + foreach ($authored as $index => $value) { + $other = $offset[$index] ?? null; + if (! (is_int($value) || is_float($value)) || ! (is_int($other) || is_float($other)) || (float) $value !== (float) $other) { + return false; + } + } + + return true; + } + + /** @param array $settings */ + private static function normalize(array $settings): ?array + { + return $settings === [] ? null : $settings; + } +} diff --git a/src/ProjectMap.php b/src/ProjectMap.php index d9ff39d..4495d16 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -28,6 +28,8 @@ use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Ichiloto\Editor\Maps\EditableGrid; use Ichiloto\Editor\Maps\MapLayers; +use Ichiloto\Editor\Maps\TileLayerSettings; +use Closure; use InvalidArgumentException; use RuntimeException; @@ -410,34 +412,97 @@ public function setLayerCell(string $layer, int $x, int $y, string $symbol, stri public function createLayer(string $name, bool $decoration = false, ?int $order = null): string { - $this->assertEditable(); - $before = $this->layers->captureSnapshot(); - try { - $id = $this->layers->createLayer($name, $decoration, $order); - $this->layers->assertDimensions(); - } catch (\Throwable $error) { - $this->layers->restoreSnapshot($before); - throw $error; - } - $this->cachedWidth = null; - $this->touchState(); + $id = ''; + $this->changeLayers(static function (MapLayers $layers) use ($name, $decoration, $order, &$id): void { + $id = $layers->createLayer($name, $decoration, $order); + $layers->assertDimensions(); + }); + return $id; } + /** + * Renames a layer. The tile layers that move with a gameplay layer keep + * moving with it under its new name. + * + * @throws MapSourceRefusal When the rename is refused, or changes collisions unconfirmed. + */ public function renameLayer(string $id, string $name, bool $confirmCollisionChange = false): void { - $this->assertEditable(); - if (! $confirmCollisionChange && $this->getLayerRenameCollisionChange($id, $name) !== null) { - throw new MapSourceRefusal('Renaming this layer changes resolved collisions. Explicit confirmation is required.'); + if (! $confirmCollisionChange) { + $this->assertCollisionsUnchanged($this->countRenameCollisionChanges($id, $name), 'Renaming'); } - $this->layers->renameLayer($id, $name); - $this->touchState(); + $layer = $this->layers->getLayer($id); + $this->changeLayers(static fn(MapLayers $layers) => $layers->renameLayer($id, $name), + static fn(mixed $settings): mixed => $layer['decoration'] ? $settings : TileLayerSettings::renameOwner($settings, $layer['name'], $name)); + } + + /** + * Moves a layer to another order; a layer holding that order takes this + * layer's order. Gameplay order decides which glyph sets collision. + * + * @throws MapSourceRefusal When the move is refused, or changes collisions unconfirmed. + */ + public function moveLayer(string $id, int $order, bool $confirmCollisionChange = false): void + { + if (! $confirmCollisionChange) { + $this->assertCollisionsUnchanged($this->countMoveCollisionChanges($id, $order), 'Moving'); + } + $this->changeLayers(static fn(MapLayers $layers) => $layers->moveLayer($id, $order)); } + /** + * Makes a layer decoration, drawn without collision, or gameplay. Tile + * layers that moved with a layer made decoration move with none. + * + * @throws MapSourceRefusal When the change is refused, or changes collisions unconfirmed. + */ + public function setLayerDecoration(string $id, bool $decoration, bool $confirmCollisionChange = false): void + { + if (! $confirmCollisionChange) { + $this->assertCollisionsUnchanged($this->countDecorationCollisionChanges($id, $decoration), 'Changing'); + } + $layer = $this->layers->getLayer($id); + $this->changeLayers(static fn(MapLayers $layers) => $layers->setLayerDecoration($id, $decoration), + static fn(mixed $settings): mixed => $decoration && ! $layer['decoration'] ? TileLayerSettings::removeOwner($settings, $layer['name']) : $settings); + } + + /** + * Removes a layer and its cells. Tile layers that moved with a removed + * gameplay layer move with none. + * + * @throws MapSourceRefusal When the removal is refused. + */ public function removeLayer(string $id): void + { + $layer = $this->layers->getLayer($id); + $this->changeLayers(static fn(MapLayers $layers) => $layers->removeLayer($id), + static fn(mixed $settings): mixed => $layer['decoration'] ? $settings : TileLayerSettings::removeOwner($settings, $layer['name'])); + } + + /** + * Applies a change to the layers, and to the tile layer settings that + * name them, as one change: it is made on a copy of the layers that is + * adopted only once the settings are written, so a refusal from either + * leaves the map exactly as it was. + * + * @param Closure(MapLayers): mixed $change + * @param (Closure(mixed): mixed)|null $adjustSettings The tile layer settings the change leaves, given the current ones. + * @throws MapSourceRefusal When the change or the settings are refused. + */ + private function changeLayers(Closure $change, ?Closure $adjustSettings = null): void { $this->assertEditable(); - $this->layers->removeLayer($id); + $layers = clone $this->layers; + $change($layers); + if ($adjustSettings !== null) { + $settings = $this->getMapDataField([MapGraphics::SETTINGS_KEY]); + $next = $adjustSettings($settings); + if ($next !== $settings) { + $this->setMapDataField([MapGraphics::SETTINGS_KEY], $next); + } + } + $this->layers = $layers; $this->cachedWidth = null; $this->touchState(); } @@ -588,6 +653,126 @@ public function restoreTileLayerSources(array $sources): void $this->touchState(); } + /** + * The map's tile layers in drawing order, as an editor lists them: each + * one's name, order, file in the map's folder, and its settings as the + * Engine reads them: its offset across and down in field cells, the + * gameplay layer its settings name it `movesWith`, and the gameplay layer + * it belongs to ({@see MapGraphics::resolveLayerOwners()}). When the + * settings cannot be read, `issue` says why and no layer has settings. + * + * @return array{layers: list, issue: ?string} + */ + public function describeTileLayers(): array + { + $layers = $this->layers->getTileLayers(); + $names = array_column($layers, 'name'); + $settings = $this->getMapDataField([MapGraphics::SETTINGS_KEY]); + $gameplay = $this->getGameplayLayerNames(); + try { + $tileset = $this->loadTileset(); + } catch (InvalidArgumentException | RuntimeException) { + // A tileset that cannot load says nothing about its pieces. + $tileset = null; + } + $issue = null; + try { + $offsets = MapGraphics::readLayerOffsets($settings, $names, $this->mapId); + $movesWith = MapGraphics::readLayersMovingWith($settings, $names, $gameplay, $this->mapId); + $owners = MapGraphics::resolveLayerOwners($settings, $names, $gameplay, $tileset, $this->mapId); + } catch (InvalidArgumentException $error) { + $offsets = $movesWith = $owners = []; + $issue = $error->getMessage(); + } + + return ['layers' => array_map(static fn(array $layer): array => [ + 'name' => $layer['name'], + 'order' => $layer['order'], + 'file' => MapGraphics::DIRECTORY . '/' . basename($layer['path']), + 'offset' => $issue === null ? ($offsets[$layer['name']] ?? [0.0, 0.0]) : null, + 'movesWith' => $movesWith[$layer['name']] ?? null, + 'owner' => $owners[$layer['name']] ?? null, + ], $layers), 'issue' => $issue]; + } + + /** + * Adds an empty tile layer, placed among the tile layers as a tile layer + * a piece names is. + * + * @throws MapSourceRefusal When the name is taken or invalid, or the map cannot take another tile layer. + */ + public function createTileLayer(string $name): void + { + $this->changeLayers(fn(MapLayers $layers) => $layers->createTileLayer($name, $this->findNewTileLayerPlace(...))); + } + + /** + * Renames a tile layer and its settings. + * + * @throws MapSourceRefusal When there is no such layer, or the new name is taken or invalid. + */ + public function renameTileLayer(string $name, string $newName): void + { + $this->changeLayers(static fn(MapLayers $layers) => $layers->renameTileLayer($name, $newName), + static fn(mixed $settings): mixed => TileLayerSettings::renameLayer($settings, $name, $newName)); + } + + /** + * Moves a tile layer to another drawing order; a tile layer holding that + * order takes this layer's order. + * + * @throws MapSourceRefusal When there is no such layer or the order is not 00-99. + */ + public function moveTileLayer(string $name, int $order): void + { + $this->changeLayers(static fn(MapLayers $layers) => $layers->moveTileLayer($name, $order)); + } + + /** + * Removes a tile layer, its tiles and its settings. + * + * @throws MapSourceRefusal When there is no such layer. + */ + public function removeTileLayer(string $name): void + { + $this->changeLayers(static fn(MapLayers $layers) => $layers->removeTileLayer($name), + static fn(mixed $settings): mixed => TileLayerSettings::removeLayer($settings, $name)); + } + + /** + * Sets a tile layer's offset across and down, in field cells, and the + * gameplay layer its tiles move with, or none. The map's settings must + * read as the Engine reads them once set. + * + * @param array $offset Across and down, each one of {@see MapGraphics::OFFSET_STEPS}. + * @throws MapSourceRefusal When there is no such layer, or the Engine would refuse the settings. + */ + public function setTileLayerSettings(string $name, array $offset, ?string $movesWith): void + { + $this->assertEditable(); + $names = array_column($this->layers->getTileLayers(), 'name'); + if (! in_array($name, $names, true)) { + throw new MapSourceRefusal("There is no tile layer {$name}. Nothing was changed."); + } + $settings = $this->getMapDataField([MapGraphics::SETTINGS_KEY]); + $next = TileLayerSettings::setLayer($settings, $name, $offset, $movesWith); + try { + MapGraphics::readLayersMovingWith($next, $names, $this->getGameplayLayerNames(), $this->mapId); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal(rtrim($error->getMessage(), '.') . '. Nothing was changed.', previous: $error); + } + if ($next !== $settings) { + $this->setMapDataField([MapGraphics::SETTINGS_KEY], $next); + } + } + + /** @return list The names of the map's gameplay layers, in order. */ + private function getGameplayLayerNames(): array + { + return array_values(array_column(array_filter($this->layers->getLayers(), static fn(array $layer): bool => + $layer['id'] !== MapLayers::EVENT && ! $layer['decoration']), 'name')); + } + /** * Loads the tileset the map data names, or null when it names none. * @@ -627,32 +812,104 @@ public function getAssetRoot(): string public function validateLayerContracts(): void { $this->assertGridsAgree(); - $set = $this->layers->getLayerSet(); - $path = $this->getMapsRoot() . '/collisions.php'; - $dictionary = is_file($path) ? (static fn(string $path): mixed => require $path)($path) : []; - if (! is_array($dictionary)) { - throw new MapSourceRefusal('The collision dictionary must return an array.'); - } - MapCollisionResolver::resolveLayers($set, $dictionary); + MapCollisionResolver::resolveLayers($this->layers->getLayerSet(), $this->loadCollisionDictionary()); } - public function getLayerRenameCollisionChange(string $id, string $name): ?string + /** + * How many cells would resolve to another collision if the layer were + * renamed: collision lookup uses the layer name ({@see MapCollisionResolver}). + * + * @throws MapSourceRefusal When the rename is refused, or collisions cannot be resolved after it. + */ + public function countRenameCollisionChanges(string $id, string $name): int { - $dictionaryPath = $this->getMapsRoot() . '/collisions.php'; - $dictionary = is_file($dictionaryPath) ? (static fn(string $path): mixed => require $path)($dictionaryPath) : []; - if (! is_array($dictionary)) { - throw new MapSourceRefusal('The collision dictionary must return an array.'); + return $this->countCollisionChanges(static fn(MapLayers $layers) => $layers->renameLayer($id, $name)); + } + + /** + * How many cells would resolve to another collision if the layer moved + * to the order: the topmost gameplay glyph sets a cell's collision. + * + * @throws MapSourceRefusal When the move is refused, or collisions cannot be resolved after it. + */ + public function countMoveCollisionChanges(string $id, int $order): int + { + return $this->countCollisionChanges(static fn(MapLayers $layers) => $layers->moveLayer($id, $order)); + } + + /** + * How many cells would resolve to another collision if the layer became + * decoration, which has none, or gameplay. + * + * @throws MapSourceRefusal When the change is refused, or collisions cannot be resolved after it. + */ + public function countDecorationCollisionChanges(string $id, bool $decoration): int + { + return $this->countCollisionChanges(static fn(MapLayers $layers) => $layers->setLayerDecoration($id, $decoration)); + } + + /** + * How many cells would resolve to another collision if the layer were removed. + * + * @throws MapSourceRefusal When the removal is refused, or collisions cannot be resolved after it. + */ + public function countRemovalCollisionChanges(string $id): int + { + return $this->countCollisionChanges(static fn(MapLayers $layers) => $layers->removeLayer($id)); + } + + /** + * Resolves collisions before and after a change tried on a copy of the + * layers, and counts the cells that differ. Nothing changes. + * + * @param Closure(MapLayers): mixed $change + * @throws MapSourceRefusal When the change is refused, or collisions cannot be resolved. + */ + private function countCollisionChanges(Closure $change): int + { + $this->assertEditable(); + $trial = clone $this->layers; + $change($trial); + $dictionary = $this->loadCollisionDictionary(); + try { + $before = MapCollisionResolver::resolveLayers($this->layers->getLayerSet(), $dictionary); + $after = MapCollisionResolver::resolveLayers($trial->getLayerSet(), $dictionary); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal(rtrim($error->getMessage(), '.') . '. Nothing was changed.', previous: $error); } - $before = MapCollisionResolver::resolveLayers($this->layers->getLayerSet(), $dictionary); - $after = MapCollisionResolver::resolveLayers($this->layers->getLayerSet($id, $name), $dictionary); $changed = 0; foreach ($before as $y => $row) { foreach ($row as $x => $type) { - $changed += $type !== $after[$y][$x] ? 1 : 0; + $changed += $type !== ($after[$y][$x] ?? null) ? 1 : 0; } } - return $changed === 0 ? null : sprintf('Rename %s to %s changes collision at %d cell(s). Shared collisions.php stays unchanged.', - $this->layers->getLayer($id)['name'], $name, $changed); + + return $changed; + } + + /** @throws MapSourceRefusal When an unconfirmed change changes collisions. */ + private function assertCollisionsUnchanged(int $changed, string $action): void + { + if ($changed > 0) { + throw new MapSourceRefusal("{$action} this layer changes resolved collisions. Explicit confirmation is required."); + } + } + + /** + * The project's shared collision dictionary, or none. + * + * @return array + * @throws MapSourceRefusal When it does not return an array. + */ + private function loadCollisionDictionary(): array + { + $path = $this->getMapsRoot() . '/collisions.php'; + $dictionary = is_file($path) ? (static fn(string $path): mixed => require $path)($path) : []; + if (! is_array($dictionary)) { + throw new MapSourceRefusal('The collision dictionary must return an array.'); + } + + return $dictionary; } public function captureLayerSnapshot(): array diff --git a/tests/Unit/LayerEditorTest.php b/tests/Unit/LayerEditorTest.php new file mode 100644 index 0000000..597db91 --- /dev/null +++ b/tests/Unit/LayerEditorTest.php @@ -0,0 +1,257 @@ + CollisionType::NONE, "/" => CollisionType::SOLID, "x" => CollisionType::SOLID' . $extra . '];'); +} + +/** The graphics fixture map with tile layer settings in its data file. */ +function layerEditorGraphicsMap(string $settings): array +{ + $root = mapGraphicsProject(); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace("'tileset' => 'home',", "'tileset' => 'home', 'tileLayers' => {$settings},", (string) file_get_contents($data))); + + return [loadLayeredMap($root), $root]; +} + +/** @return list The map folder's layer and tile layer files. */ +function layerEditorFiles(ProjectMap $map): array +{ + $files = [...(glob($map->directory . '/layers/*.php') ?: []), ...(glob($map->directory . '/graphics/*.php') ?: [])]; + + return array_map(static fn(string $path): string => basename(dirname($path)) . '/' . basename($path), $files); +} + +it('creates a decoration layer at the next order as one undo step that a save writes', function () { + $map = loadLayeredMap(layeredMapProject()); + + $edit = LayerEditor::createLayer($map, 'shadows', true); + $created = array_find($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $edit['layer']); + + expect($created)->toMatchArray(['name' => 'shadows', 'order' => 8, 'decoration' => true]) + ->and($edit['question'])->toBeNull(); + $edit['command']->undo(); + expect(array_column($map->getLayers(), 'name'))->not->toContain('shadows'); + $edit['command']->execute(); + $map->save(); + expect(layerEditorFiles($map))->toContain('layers/08.shadows.deco.php'); +}); + +it('reorders a layer by exchanging orders, saving renamed files and removing the old ones together', function () { + $root = layeredMapProject(); + $map = loadLayeredMap($root); + $bytes = [file_get_contents($map->directory . '/layers/04.buildings.map.php'), file_get_contents($map->directory . '/layers/07.detail.deco.php')]; + + expect(LayerEditor::findAdjacentLayerOrder($map, 'map:4', LayerEditor::ABOVE))->toBe(7); + $edit = LayerEditor::moveLayer($map, 'map:4', 7); + $map->save(); + + expect(layerEditorFiles($map))->toBe(['layers/01.terrain.map.php', 'layers/04.detail.deco.php', 'layers/07.buildings.map.php']) + ->and([file_get_contents($map->directory . '/layers/07.buildings.map.php'), file_get_contents($map->directory . '/layers/04.detail.deco.php')])->toBe($bytes) + ->and(array_column(loadLayeredMap($root)->getLayers(), 'name'))->toBe(['terrain', 'detail', 'buildings', 'Events']); + + $edit['command']->undo(); + $map->save(); + expect(layerEditorFiles($map))->toBe(['layers/01.terrain.map.php', 'layers/04.buildings.map.php', 'layers/07.detail.deco.php']); +}); + +it('asks before a reorder that changes collisions and changes nothing until confirmed', function () { + $root = layeredMapProject(); + writeLayerEditorCollisions($root); + $map = loadLayeredMap($root); + $before = $map->captureLayerSnapshot(); + $revision = $map->stateVersion(); + $order = LayerEditor::findAdjacentLayerOrder($map, 'map:4', LayerEditor::BELOW); + + $asked = LayerEditor::moveLayer($map, 'map:4', $order); + + expect($asked['command'])->toBeNull() + ->and($asked['question'])->toContain('Moving buildings to order 01 changes collision at 3 cell(s)') + ->and($map->captureLayerSnapshot())->toBe($before) + ->and($map->stateVersion())->toBe($revision) + ->and(fn() => $map->moveLayer('map:4', $order))->toThrow(MapSourceRefusal::class, 'Explicit confirmation is required'); + + $moved = LayerEditor::moveLayer($map, 'map:4', $order, true); + expect(array_column($map->getLayers(), 'name'))->toBe(['buildings', 'terrain', 'detail', 'Events']); + $moved['command']->undo(); + expect($map->captureLayerSnapshot())->toBe($before); +}); + +it('refuses moving past the ends, the event layer and orders outside 00-99', function () { + $map = loadLayeredMap(layeredMapProject()); + + expect(fn() => LayerEditor::findAdjacentLayerOrder($map, 'map:7', LayerEditor::ABOVE))->toThrow(MapSourceRefusal::class, 'detail is already the top layer') + ->and(fn() => LayerEditor::findAdjacentLayerOrder($map, 'map:1', LayerEditor::BELOW))->toThrow(MapSourceRefusal::class, 'terrain is already the bottom layer') + ->and(fn() => LayerEditor::findAdjacentLayerOrder($map, 'map:1', 'sideways'))->toThrow(MapSourceRefusal::class, "'above' or 'below'") + ->and(fn() => LayerEditor::moveLayer($map, 'event', 3))->toThrow(MapSourceRefusal::class, 'Only authored layers') + ->and(fn() => LayerEditor::moveLayer($map, 'map:4', 100))->toThrow(MapSourceRefusal::class, '00-99') + ->and($map->isDirty())->toBeFalse(); +}); + +it('keeps layer ids unique when a created layer takes an order a moved layer was loaded with', function () { + $map = loadLayeredMap(layeredMapProject()); + LayerEditor::moveLayer($map, 'map:4', 9); + + $id = $map->createLayer('fixtures', false, 4); + + expect($id)->not->toBe('map:4') + ->and(array_column($map->getLayers(), 'name'))->toBe(['terrain', 'fixtures', 'detail', 'buildings', 'Events']); +}); + +it('makes a layer decoration after asking, keeps the last gameplay layer, and drops tile settings that moved with it', function () { + [$map, $root] = layerEditorGraphicsMap("['floor' => ['movesWith' => 'buildings'], 'decor' => ['offset' => [0, -0.5], 'movesWith' => 'buildings']]"); + writeLayerEditorCollisions($root); + $data = file_get_contents($map->dataPath); + + expect(LayerEditor::setLayerDecoration($map, 'map:4', true)['question'])->toContain('Making buildings decoration changes collision at 3 cell(s)'); + $edit = LayerEditor::setLayerDecoration($map, 'map:4', true, true); + + expect(array_find($map->getLayers(), static fn(array $layer): bool => $layer['id'] === 'map:4')['decoration'])->toBeTrue() + ->and($map->getMapDataField(['tileLayers']))->toBe(['decor' => ['offset' => [0, -0.5]]]) + ->and(fn() => LayerEditor::setLayerDecoration($map, 'map:1', true, true))->toThrow(MapSourceRefusal::class, 'The last gameplay layer cannot become decoration.'); + $map->save(); + expect(layerEditorFiles($map))->toContain('layers/04.buildings.deco.php')->not->toContain('layers/04.buildings.map.php') + ->and($map->loadGraphics()->offsets)->toBe(['decor' => [0.0, -0.5]]); + + $edit['command']->undo(); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe($data) + ->and(layerEditorFiles($map))->toContain('layers/04.buildings.map.php'); +}); + +it('refuses making a layer decoration while the collision dictionary names it', function () { + $root = layeredMapProject(); + writeLayerEditorCollisions($root, ', "buildings" => ["x" => CollisionType::NONE]'); + $map = loadLayeredMap($root); + + expect(fn() => LayerEditor::setLayerDecoration($map, 'map:4', true, false)) + ->toThrow(MapSourceRefusal::class, 'must not be named in the collision dictionary. Nothing was changed.') + ->and($map->isDirty())->toBeFalse(); +}); + +it('renames a gameplay layer and the tile layers that move with it, asking first when collisions change', function () { + [$map, $root] = layerEditorGraphicsMap("['floor' => ['movesWith' => 'buildings']]"); + writeLayerEditorCollisions($root, ', "buildings" => ["x" => CollisionType::NONE]'); + + expect(LayerEditor::renameLayer($map, 'map:4', 'houses')['question']) + ->toBe('Rename buildings to houses changes collision at 2 cell(s). Shared collisions.php stays unchanged.'); + $edit = LayerEditor::renameLayer($map, 'map:4', 'houses', true); + $map->save(); + + expect($edit['layer'])->toBe('map:4') + ->and(loadLayeredMap($root)->getMapDataField(['tileLayers']))->toBe(['floor' => ['movesWith' => 'houses']]) + ->and($map->getTileLayersMovingWith('map:4'))->toBe(['floor']); +}); + +it('removes a gameplay layer after asking, leaving the base layer and the tiles that moved with it moving with none', function () { + [$map, $root] = layerEditorGraphicsMap("['floor' => ['movesWith' => 'buildings']]"); + writeLayerEditorCollisions($root); + + expect(LayerEditor::removeLayer($map, 'map:4')['question'])->toContain('Removing buildings changes collision at 3 cell(s)'); + $edit = LayerEditor::removeLayer($map, 'map:4', true); + + expect($edit['layer'])->toBe('map:1') + ->and($map->hasMapDataField(['tileLayers']))->toBeFalse() + ->and(fn() => LayerEditor::removeLayer($map, 'map:1', true))->toThrow(MapSourceRefusal::class, 'The last gameplay layer cannot be removed.'); + $edit['command']->undo(); + expect($map->getMapDataField(['tileLayers']))->toBe(['floor' => ['movesWith' => 'buildings']]); +}); + +it('creates an empty tile layer at a free order, refusing a taken or invalid name', function () { + $root = mapGraphicsProject(); + $map = loadLayeredMap($root); + + $edit = LayerEditor::createTileLayer($map, 'rugs'); + + expect(array_column($map->describeTileLayers()['layers'], 'name', 'order'))->toBe([1 => 'floor', 2 => 'decor', 3 => 'rugs']) + ->and(fn() => LayerEditor::createTileLayer($map, 'rugs'))->toThrow(MapSourceRefusal::class, 'Tile layer rugs already exists.') + ->and(fn() => LayerEditor::createTileLayer($map, '9lives'))->toThrow(MapSourceRefusal::class, "'9lives' is not a tile layer name."); + $map->save(); + expect(readTileRows($map->directory . '/graphics/03.rugs.tiles.php'))->toBe([[0, 0, 0, 0], [0, 0, 0, 0]]); + $edit['command']->undo(); + $map->save(); + expect(layerEditorFiles($map))->not->toContain('graphics/03.rugs.tiles.php'); +}); + +it('renames a tile layer with its settings, saving the same tiles under the new name', function () { + [$map, $root] = layerEditorGraphicsMap("['decor' => ['offset' => [0, -0.5]], 'floor' => ['movesWith' => 'buildings']]"); + $bytes = file_get_contents($map->directory . '/graphics/02.decor.tiles.php'); + + LayerEditor::renameTileLayer($map, 'decor', 'props'); + $map->save(); + $reloaded = loadLayeredMap($root); + + expect(layerEditorFiles($map))->toContain('graphics/02.props.tiles.php')->not->toContain('graphics/02.decor.tiles.php') + ->and(file_get_contents($map->directory . '/graphics/02.props.tiles.php'))->toBe($bytes) + ->and($reloaded->getMapDataField(['tileLayers']))->toBe(['floor' => ['movesWith' => 'buildings'], 'props' => ['offset' => [0, -0.5]]]) + ->and($reloaded->loadGraphics()->offsets)->toBe(['props' => [0.0, -0.5]]) + ->and(fn() => LayerEditor::renameTileLayer($map, 'props', 'floor'))->toThrow(MapSourceRefusal::class, 'Tile layer floor already exists.') + ->and(fn() => LayerEditor::renameTileLayer($map, 'nothing', 'else'))->toThrow(MapSourceRefusal::class, 'There is no tile layer nothing.'); +}); + +it('reorders and removes tile layers, keeping settings naming only the layers the map has', function () { + [$map, $root] = layerEditorGraphicsMap("['decor' => ['offset' => [0.5, 0]]]"); + $data = file_get_contents($map->dataPath); + + LayerEditor::moveTileLayer($map, 'decor', LayerEditor::findAdjacentTileLayerOrder($map, 'decor', LayerEditor::BELOW)); + expect(array_column($map->describeTileLayers()['layers'], 'name'))->toBe(['decor', 'floor']); + $removed = LayerEditor::removeTileLayer($map, 'decor'); + $map->save(); + + expect(layerEditorFiles($map))->toContain('graphics/02.floor.tiles.php')->not->toContain('graphics/01.decor.tiles.php', 'graphics/02.decor.tiles.php') + ->and(loadLayeredMap($root)->hasMapDataField(['tileLayers']))->toBeFalse() + ->and(fn() => LayerEditor::findAdjacentTileLayerOrder($map, 'floor', LayerEditor::ABOVE))->toThrow(MapSourceRefusal::class, 'floor is already the top layer'); + $removed['command']->undo(); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe($data) + ->and(layerEditorFiles($map))->toContain('graphics/01.decor.tiles.php', 'graphics/02.floor.tiles.php'); +}); + +it('sets tile layer settings exactly as the Engine reads them, removing what returns to none', function () { + $root = mapGraphicsProject(); + $map = loadLayeredMap($root); + $data = $map->getEditableData(); + + $edit = LayerEditor::setTileLayerSettings($map, 'decor', [0.5, -0.5], 'buildings'); + $map->save(); + $layers = loadLayeredMap($root)->describeTileLayers(); + + expect($edit['command'])->not->toBeNull() + ->and(array_find($layers['layers'], static fn(array $layer): bool => $layer['name'] === 'decor')) + ->toMatchArray(['order' => 2, 'file' => 'graphics/02.decor.tiles.php', 'offset' => [0.5, -0.5], 'movesWith' => 'buildings', 'owner' => 'buildings']) + ->and($layers['issue'])->toBeNull() + ->and(LayerEditor::setTileLayerSettings($map, 'decor', [0.5, -0.5], 'buildings')['command'])->toBeNull() + ->and(fn() => LayerEditor::setTileLayerSettings($map, 'decor', [0.25, 0], null))->toThrow(MapSourceRefusal::class, 'offsets by -0.5, 0 or 0.5 field cells') + ->and(fn() => LayerEditor::setTileLayerSettings($map, 'decor', [0, 0], 'detail'))->toThrow(MapSourceRefusal::class, 'must name one of its gameplay layers') + ->and(fn() => LayerEditor::setTileLayerSettings($map, 'missing', [0, 0], null))->toThrow(MapSourceRefusal::class, 'There is no tile layer missing.'); + + LayerEditor::setTileLayerSettings($map, 'decor', [0, 0], null); + $map->save(); + expect(loadLayeredMap($root)->getEditableData())->toBe($data); +}); + +it('reports tile layer settings the Engine cannot read instead of guessing them', function () { + [$map] = layerEditorGraphicsMap("['gone' => ['offset' => [0, 0.5]]]"); + + $layers = $map->describeTileLayers(); + + expect($layers['issue'])->toContain("names 'gone', which is not one of its tile layers") + ->and(array_column($layers['layers'], 'offset'))->toBe([null, null]); +}); From 97d713b835007404da0be23dcf01b7ae8dca7596 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 14:05:52 +0200 Subject: [PATCH 119/344] feat(session): serve layer and tile layer editing The session reads a map's tile layers with readMap (`tileLayers`: each layer's name, order, file, offset, movesWith and owner, and the issue when the Engine cannot read their settings) and serves layer editing as undo steps made against the map's current revision: layer.create, layer.rename, layer.remove, layer.reorder, layer.decoration, tileLayer.create, tileLayer.rename, tileLayer.remove, tileLayer.reorder and tileLayer.settings. A glyph layer edit that would change the map's collisions answers with a question and is made when asked again with confirm. --- src/Session/EditorSession.php | 184 ++++++++++++++++++++++++ src/Session/SessionHost.php | 67 +++++++++ tests/Unit/Session/LayerSessionTest.php | 106 ++++++++++++++ 3 files changed, 357 insertions(+) create mode 100644 tests/Unit/Session/LayerSessionTest.php diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index e7c4197..47dd902 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -18,6 +18,7 @@ use Ichiloto\Editor\Inspector\InspectorRefusal; use Ichiloto\Editor\Inspector\MapInspector; use Ichiloto\Editor\MapSourceRefusal; +use Ichiloto\Editor\Maps\LayerEditor; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Maps\TilePalette; use Ichiloto\Editor\ProjectMap; @@ -25,6 +26,7 @@ use Ichiloto\Editor\Validation\MapValidator; use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; use Ichiloto\Engine\Rendering\Presentation\PresentationWorld; +use Closure; use InvalidArgumentException; /** @@ -169,6 +171,7 @@ public function readMap(string $mapId): array 'layers' => $layers, 'events' => $events, 'npcs' => $npcs, + 'tileLayers' => $map->describeTileLayers(), ]; } @@ -284,6 +287,187 @@ public function paint(string $mapId, int $revision, string $layerId, array $cell return ['status' => 'applied', 'changed' => $applied['changed'], 'revision' => $map->stateVersion()]; } + /** + * Adds an empty glyph layer, gameplay or decoration, at the next order, + * as one undo step. Layer edits answer as {@see editLayers()} describes. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string} + * @throws SessionRefusal When the map is unknown, stale or refuses the layer. + */ + public function createLayer(string $mapId, int $revision, string $name, bool $decoration = false): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => LayerEditor::createLayer($map, $name, $decoration)); + } + + /** + * Renames a glyph layer as one undo step. A rename that changes the + * map's collisions is asked about; it is made when asked again confirmed. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string}|array{status: 'question', question: string} + * @throws SessionRefusal When the map is unknown, stale or refuses the rename. + */ + public function renameLayer(string $mapId, int $revision, string $layerId, string $name, bool $confirm = false): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => + LayerEditor::renameLayer($map, $layerId, $name, $confirm)); + } + + /** + * Removes a glyph layer and its cells as one undo step. A removal that + * changes the map's collisions is asked about first. The layer left is + * the map's base layer. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string}|array{status: 'question', question: string} + * @throws SessionRefusal When the map is unknown, stale or refuses the removal. + */ + public function removeLayer(string $mapId, int $revision, string $layerId, bool $confirm = false): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => + LayerEditor::removeLayer($map, $layerId, $confirm)); + } + + /** + * Moves a glyph layer to an order (00-99), or one step `above` or + * `below` among the layers, as one undo step; a layer holding that order + * takes this one's. A move that changes the map's collisions is asked + * about first. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string}|array{status: 'question', question: string} + * @throws SessionRefusal When the map is unknown, stale or refuses the move, or not exactly one of order and direction is given. + */ + public function moveLayer(string $mapId, int $revision, string $layerId, ?int $order, ?string $direction = null, bool $confirm = false): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => LayerEditor::moveLayer($map, $layerId, + self::resolveOrder($order, $direction, static fn(string $step): int => LayerEditor::findAdjacentLayerOrder($map, $layerId, $step)), + $confirm)); + } + + /** + * Makes a glyph layer decoration, drawn without collision, or gameplay, + * as one undo step. A change that changes the map's collisions is asked + * about first. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string}|array{status: 'question', question: string} + * @throws SessionRefusal When the map is unknown, stale or refuses the change. + */ + public function setLayerDecoration(string $mapId, int $revision, string $layerId, bool $decoration, bool $confirm = false): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => + LayerEditor::setLayerDecoration($map, $layerId, $decoration, $confirm)); + } + + /** + * Adds an empty tile layer as one undo step, placed among the tile + * layers as one a tileset piece names is. `layer` is its name. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string} + * @throws SessionRefusal When the map is unknown, stale or refuses the layer. + */ + public function createTileLayer(string $mapId, int $revision, string $name): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => LayerEditor::createTileLayer($map, $name)); + } + + /** + * Renames a tile layer and its settings as one undo step. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string} + * @throws SessionRefusal When the map is unknown, stale or refuses the rename. + */ + public function renameTileLayer(string $mapId, int $revision, string $name, string $newName): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => + LayerEditor::renameTileLayer($map, $name, $newName)); + } + + /** + * Removes a tile layer, its tiles and its settings as one undo step. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string} + * @throws SessionRefusal When the map is unknown, stale or has no such tile layer. + */ + public function removeTileLayer(string $mapId, int $revision, string $name): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => LayerEditor::removeTileLayer($map, $name)); + } + + /** + * Moves a tile layer to a drawing order (00-99), or one step `above` or + * `below` among the tile layers, as one undo step; a tile layer holding + * that order takes this one's. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string} + * @throws SessionRefusal When the map is unknown, stale or refuses the move, or not exactly one of order and direction is given. + */ + public function moveTileLayer(string $mapId, int $revision, string $name, ?int $order, ?string $direction = null): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => LayerEditor::moveTileLayer($map, $name, + self::resolveOrder($order, $direction, static fn(string $step): int => LayerEditor::findAdjacentTileLayerOrder($map, $name, $step)))); + } + + /** + * Sets a tile layer's offset across and down in field cells (each -0.5, + * 0 or 0.5) and the gameplay layer its tiles move with, or none, as one + * undo step, validated as the Engine reads them. + * + * @param array $offset + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string} + * @throws SessionRefusal When the map is unknown, stale or the Engine would refuse the settings. + */ + public function setTileLayerSettings(string $mapId, int $revision, string $name, array $offset, ?string $movesWith): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => + LayerEditor::setTileLayerSettings($map, $name, $offset, $movesWith)); + } + + /** + * Makes one layer edit on a map at its current revision and records it. + * It answers `applied` with the map's new revision, whether anything + * changed and the layer to work on (a glyph layer's id or a tile layer's + * name; null after removing a tile layer), or `question` when the edit + * would change the map's collisions: nothing changed then, and the edit + * is made by asking again confirmed. + * + * @param Closure(ProjectMap): array{command: ?Command, layer: ?string, question: ?string} $edit + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string}|array{status: 'question', question: string} + * @throws SessionRefusal When the map is unknown or stale, or the edit is refused. + */ + private function editLayers(string $mapId, int $revision, Closure $edit): array + { + $map = $this->requireCurrentMap($mapId, $revision); + try { + $result = $edit($map); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($result['question'] !== null) { + return ['status' => 'question', 'question' => $result['question']]; + } + if ($result['command'] !== null) { + $this->history->record($result['command']); + } + + return ['status' => 'applied', 'revision' => $map->stateVersion(), 'changed' => $result['command'] !== null, + 'layer' => $result['layer']]; + } + + /** + * The order a move names: the order itself, or the order one step in a + * direction. + * + * @param Closure(string): int $findAdjacentOrder + * @throws SessionRefusal When not exactly one of order and direction is given. + */ + private static function resolveOrder(?int $order, ?string $direction, Closure $findAdjacentOrder): int + { + if (($order === null) === ($direction === null)) { + throw new SessionRefusal(sprintf("Move a layer to an order, or '%s' or '%s', not both or neither.", + LayerEditor::ABOVE, LayerEditor::BELOW)); + } + + return $order ?? $findAdjacentOrder($direction); + } + /** * The inspector rows of a map, and of one of its events when one is * named, as an interface lists and edits them. Each row says how it is diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 189855c..12222a7 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -109,6 +109,73 @@ private function dispatch(string $method, array $params): array self::readChoices($params), is_string($params['label'] ?? null) ? $params['label'] : 'Paint', ), + 'layer.create' => $session->createLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'name'), + is_bool($params['decoration'] ?? false) ? ($params['decoration'] ?? false) : throw new InvalidRequest('"decoration" must be a boolean.'), + ), + 'layer.rename' => $session->renameLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + self::requireString($params, 'name'), + is_bool($params['confirm'] ?? false) ? ($params['confirm'] ?? false) : throw new InvalidRequest('"confirm" must be a boolean.'), + ), + 'layer.remove' => $session->removeLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + is_bool($params['confirm'] ?? false) ? ($params['confirm'] ?? false) : throw new InvalidRequest('"confirm" must be a boolean.'), + ), + 'layer.reorder' => $session->moveLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + is_int($params['order'] ?? null) || ! isset($params['order']) ? ($params['order'] ?? null) : throw new InvalidRequest('"order" must be an integer.'), + is_string($params['direction'] ?? null) || ! isset($params['direction']) ? ($params['direction'] ?? null) : throw new InvalidRequest('"direction" must be "above" or "below".'), + is_bool($params['confirm'] ?? false) ? ($params['confirm'] ?? false) : throw new InvalidRequest('"confirm" must be a boolean.'), + ), + 'layer.decoration' => $session->setLayerDecoration( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + is_bool($params['decoration'] ?? null) ? $params['decoration'] : throw new InvalidRequest('"decoration" must be a boolean.'), + is_bool($params['confirm'] ?? false) ? ($params['confirm'] ?? false) : throw new InvalidRequest('"confirm" must be a boolean.'), + ), + 'tileLayer.create' => $session->createTileLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'name'), + ), + 'tileLayer.rename' => $session->renameTileLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'name'), + self::requireString($params, 'newName'), + ), + 'tileLayer.remove' => $session->removeTileLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'name'), + ), + 'tileLayer.reorder' => $session->moveTileLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'name'), + is_int($params['order'] ?? null) || ! isset($params['order']) ? ($params['order'] ?? null) : throw new InvalidRequest('"order" must be an integer.'), + is_string($params['direction'] ?? null) || ! isset($params['direction']) ? ($params['direction'] ?? null) : throw new InvalidRequest('"direction" must be "above" or "below".'), + ), + 'tileLayer.settings' => $session->setTileLayerSettings( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'name'), + is_array($params['offset'] ?? null) && array_is_list($params['offset']) && count($params['offset']) === 2 + && array_all($params['offset'], static fn(mixed $value): bool => is_int($value) || is_float($value)) + ? $params['offset'] : throw new InvalidRequest('"offset" must be two numbers, across and down.'), + array_key_exists('movesWith', $params) && (is_string($params['movesWith']) || $params['movesWith'] === null) + ? $params['movesWith'] : throw new InvalidRequest('"movesWith" must be a gameplay layer name or null.'), + ), 'map.save' => $session->saveMap(self::requireString($params, 'map')), 'inspector.read' => $session->readInspector( self::requireString($params, 'map'), diff --git a/tests/Unit/Session/LayerSessionTest.php b/tests/Unit/Session/LayerSessionTest.php new file mode 100644 index 0000000..579b6e8 --- /dev/null +++ b/tests/Unit/Session/LayerSessionTest.php @@ -0,0 +1,106 @@ +): array */ +function layerSessionHost(string $root): callable +{ + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(int $id, string $method, array $params = []): array => + $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params])); + $request(0, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + + return $request; +} + +it('lists a map\'s tile layers with their files and settings as the Engine reads them', function () { + $root = mapGraphicsProject(); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace("'tileset' => 'home',", "'tileset' => 'home', 'tileLayers' => ['decor' => ['offset' => [0, -0.5], 'movesWith' => 'buildings']],", (string) file_get_contents($data))); + + $map = EditorSession::open($root)->readMap('test-map'); + + expect($map['tileLayers'])->toBe(['layers' => [ + ['name' => 'floor', 'order' => 1, 'file' => 'graphics/01.floor.tiles.php', 'offset' => [0.0, 0.0], 'movesWith' => null, 'owner' => null], + ['name' => 'decor', 'order' => 2, 'file' => 'graphics/02.decor.tiles.php', 'offset' => [0.0, -0.5], 'movesWith' => 'buildings', 'owner' => 'buildings'], + ], 'issue' => null]); +}); + +it('asks before a glyph layer edit that changes collisions, then applies it confirmed as one undo step', function () { + $root = layeredMapProject(); + file_put_contents($root . '/assets/Maps/collisions.php', ' CollisionType::NONE, "/" => CollisionType::SOLID, "x" => CollisionType::SOLID];'); + $session = EditorSession::open($root); + $revision = $session->readMap('test-map')['revision']; + + $asked = $session->setLayerDecoration('test-map', $revision, 'map:4', true); + expect($asked['status'])->toBe('question') + ->and($asked['question'])->toContain('Making buildings decoration changes collision at 3 cell(s)') + ->and($session->readMap('test-map')['revision'])->toBe($revision); + + $applied = $session->setLayerDecoration('test-map', $revision, 'map:4', true, true); + $layer = array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] === 'map:4'); + + expect($applied)->toMatchArray(['status' => 'applied', 'changed' => true, 'layer' => 'map:4']) + ->and($layer['decoration'])->toBeTrue() + ->and(fn() => $session->renameLayer('test-map', $revision, 'map:4', 'houses'))->toThrow(SessionRefusal::class, 'changed since revision') + ->and($session->undo())->toBe(['label' => 'Layer decoration', 'maps' => ['test-map']]); +}); + +it('creates, renames, reorders and removes glyph layers, saving the layer files as one set', function () { + $root = layeredMapProject(); + $session = EditorSession::open($root); + $revision = static fn(): int => $session->readMap('test-map')['revision']; + + $created = $session->createLayer('test-map', $revision(), 'fixtures'); + $session->renameLayer('test-map', $revision(), $created['layer'], 'props'); + $session->moveLayer('test-map', $revision(), $created['layer'], null, 'below'); + $session->removeLayer('test-map', $revision(), 'map:7'); + $session->saveMap('test-map'); + + $files = array_map('basename', glob($root . '/assets/Maps/test-map/layers/*.php') ?: []); + expect($files)->toBe(['01.terrain.map.php', '04.buildings.map.php', '07.props.map.php']) + ->and(array_column(ProjectWorkspace::fromProject($root)->maps[0]->getLayers(), 'name'))->toBe(['terrain', 'buildings', 'props', 'Events']) + ->and(fn() => $session->moveLayer('test-map', $revision(), 'map:1', 3, 'above'))->toThrow(SessionRefusal::class, 'not both or neither') + ->and(fn() => $session->removeLayer('test-map', $revision(), 'event', true))->toThrow(SessionRefusal::class, 'Only authored layers'); +}); + +it('serves tile layer editing over the line protocol, refusing malformed requests by kind', function () { + $root = mapGraphicsProject(); + $request = layerSessionHost($root); + $revision = static fn(): int => $request(1, 'map.read', ['map' => 'test-map'])['result']['revision']; + + expect($request(2, 'tileLayer.create', ['map' => 'test-map', 'revision' => $revision(), 'name' => 'rugs'])['result']) + ->toMatchArray(['status' => 'applied', 'changed' => true, 'layer' => 'rugs']) + ->and($request(3, 'tileLayer.rename', ['map' => 'test-map', 'revision' => $revision(), 'name' => 'rugs', 'newName' => 'carpets'])['result']['layer']) + ->toBe('carpets') + ->and($request(4, 'tileLayer.reorder', ['map' => 'test-map', 'revision' => $revision(), 'name' => 'carpets', 'order' => 0])['result']['status']) + ->toBe('applied') + ->and($request(5, 'tileLayer.settings', ['map' => 'test-map', 'revision' => $revision(), 'name' => 'carpets', + 'offset' => [0.5, 0], 'movesWith' => 'buildings'])['result']['status'])->toBe('applied') + ->and($request(6, 'tileLayer.settings', ['map' => 'test-map', 'revision' => $revision(), 'name' => 'carpets', + 'offset' => [0.25, 0], 'movesWith' => null])['error']['kind'])->toBe('refusal') + ->and($request(7, 'tileLayer.settings', ['map' => 'test-map', 'revision' => $revision(), 'name' => 'carpets', 'offset' => [0]])['error']) + ->toBe(['kind' => 'request', 'message' => '"offset" must be two numbers, across and down.']) + ->and($request(8, 'tileLayer.remove', ['map' => 'test-map', 'revision' => $revision(), 'name' => 'decor'])['result']['layer'])->toBeNull() + ->and($request(9, 'layer.decoration', ['map' => 'test-map', 'revision' => $revision(), 'layer' => 'map:4'])['error']['kind'])->toBe('request') + ->and($request(10, 'map.save', ['map' => 'test-map'])['result']['saved'])->toBe('test-map'); + + $map = ProjectWorkspace::fromProject($root)->maps[0]; + expect(array_map('basename', glob($map->directory . '/graphics/*.php') ?: []))->toBe(['00.carpets.tiles.php', '01.floor.tiles.php']) + ->and($map->getMapDataField(['tileLayers']))->toBe(['carpets' => ['offset' => [0.5, 0], 'movesWith' => 'buildings']]) + ->and($map->loadGraphics()->offsets)->toBe(['carpets' => [0.5, 0.0]]); +}); From 558cd01c04a47cad04ae2f2e0902ec7771b27e28 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 14:09:34 +0200 Subject: [PATCH 120/344] refactor(npcs): own NPC authoring rules in one shared service NpcAuthoring now owns creating, moving, duplicating, deleting, assigning an id and editing one NPC's rows through its record pane, with the rules that were only in the terminal shell: tiles inside the map and free of another NPC's anchor, ids derived from names, deletes refused while anything names the NPC, and renames that carry the id only while nothing names it. Each operation returns an NpcChange whose command restores only the map's NPC collection; refusals are an NpcRefusal with the lines that detail them. NpcInspector now builds the grouped rows (headings, identity notes, headed dialogue variants) that every interface lists, so the GUI reads the same rows as the terminal. The terminal editor calls the service for every NPC operation and keeps only selection, prompts and status; its undo steps wrap the shared command with the selection it follows. Its keys and messages are unchanged. Behaviour narrowed: editing an NPC's X or Y row in the Inspector now applies the same rules as a move, so a coordinate outside the map or on another NPC's anchor tile is refused with a warning instead of being written. A rename whose id cannot follow no longer leaves the name applied alone. --- src/Editor.php | 568 +++++++-------------------- src/Field/NpcAuthoring.php | 401 +++++++++++++++++++ src/Field/NpcChange.php | 33 ++ src/Field/NpcInspector.php | 177 ++++++++- src/Field/NpcRefusal.php | 24 ++ tests/Unit/NpcAuthoringRulesTest.php | 251 ++++++++++++ 6 files changed, 1037 insertions(+), 417 deletions(-) create mode 100644 src/Field/NpcAuthoring.php create mode 100644 src/Field/NpcChange.php create mode 100644 src/Field/NpcRefusal.php create mode 100644 tests/Unit/NpcAuthoringRulesTest.php diff --git a/src/Editor.php b/src/Editor.php index 20be7a1..8a5bf97 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -37,9 +37,9 @@ use Ichiloto\Editor\Field\NpcInspector; use Ichiloto\Editor\Field\MapBgmVariants; use Ichiloto\Editor\Field\MapEncounters; -use Ichiloto\Editor\Field\NpcCollection; -use Ichiloto\Editor\Field\NpcReferences; -use Ichiloto\Editor\Field\ProjectNpc; +use Ichiloto\Editor\Field\NpcAuthoring; +use Ichiloto\Editor\Field\NpcChange; +use Ichiloto\Editor\Field\NpcRefusal; use Ichiloto\Editor\Database\WorldWriteEditor; use Ichiloto\Editor\Database\WorldWriteCodec; use Ichiloto\Editor\Database\BattleEntryPredicateCodec; @@ -577,7 +577,7 @@ final class Editor * The Inspector row that assigns a stable id to an NPC authored without * one; the only time an id is ever written after creation. */ - private const string NPC_ASSIGN_ID_FIELD = '__npc_assign_id'; + private const string NPC_ASSIGN_ID_FIELD = NpcInspector::ASSIGN_ID_FIELD; private bool $isConditionNaming = false; private string $conditionNameBuffer = ''; /** @@ -1993,35 +1993,7 @@ private function handleNpcInspectorInput(string $input, string $normalizedInput) */ private function addDatabaseNpcSubItem(): void { - $map = $this->getSelectedMap(); - $index = $this->selectedNpcIndex; - - if (! $map instanceof ProjectMap || $index === null || $this->npcInspector === null) { - return; - } - - $records = $this->npcInspector->records(); - $before = $map->getNpcs(); - $selectedId = (string) ($this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex]['field'] ?? ''); - - $nested = $this->databaseCommandFramePath !== [] - ? $records->frameNestedContext($index, $this->databaseCommandFramePath, $selectedId) - : null; - - if ($nested !== null) { - // A route step under a command in the frame. - $records->addFrameNestedItem($index, $this->databaseCommandFramePath, $nested['parentIndex']); - } elseif ($this->databaseCommandFramePath !== []) { - $after = preg_match('/^command(\\d+)/', $selectedId, $m) === 1 ? intval($m[1]) : null; - $records->addFrameCommand($index, $this->databaseCommandFramePath, $after); - } elseif (preg_match('/^variant(\\d+)Line/', $selectedId, $m) === 1) { - $records->addNestedSubItem($index, intval($m[1])); - } else { - $records->addSubItem($index); - } - - $this->npcInspector->commit(); - $this->recordNpcCollectionChange($map, $index, $before, 'NPC add'); + $this->changeNpcSubItem(true); } /** @@ -2031,74 +2003,97 @@ private function addDatabaseNpcSubItem(): void */ private function removeDatabaseNpcSubItem(): void { - $map = $this->getSelectedMap(); + $this->changeNpcSubItem(false); + } + + /** + * Adds or removes the sub-item at the Inspector cursor through the + * shared NPC authoring rules ({@see NpcAuthoring::addSubItem()}), as + * one undo step. + * + * @param bool $add True to add, false to remove. + * @return void + */ + private function changeNpcSubItem(bool $add): void + { + $authoring = $this->createNpcAuthoring(); $index = $this->selectedNpcIndex; - if (! $map instanceof ProjectMap || $index === null || $this->npcInspector === null) { + if (! $authoring instanceof NpcAuthoring || $index === null || $this->npcInspector === null) { return; } - $records = $this->npcInspector->records(); - $before = $map->getNpcs(); - $selectedId = (string) ($this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex]['field'] ?? ''); + $fieldId = (string) ($this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex]['field'] ?? ''); - $nested = $this->databaseCommandFramePath !== [] - ? $records->frameNestedContext($index, $this->databaseCommandFramePath, $selectedId) - : null; + try { + $change = $add + ? $authoring->addSubItem($this->npcInspector, $index, $this->databaseCommandFramePath, $fieldId) + : $authoring->removeSubItem($this->npcInspector, $index, $this->databaseCommandFramePath, $fieldId); + } catch (NpcRefusal $refusal) { + $this->reportNpcRefusal($refusal); - if ($nested !== null && $nested['nestedIndex'] !== null) { - // The step under the cursor; a command row removes the command. - $records->removeFrameNestedItem($index, $this->databaseCommandFramePath, $nested['parentIndex'], $nested['nestedIndex']); - } elseif ($this->databaseCommandFramePath !== []) { - if (preg_match('/^command(\\d+)/', $selectedId, $m) === 1) { - $records->removeFrameCommand($index, $this->databaseCommandFramePath, intval($m[1])); - } - } elseif (preg_match('/^variant(\\d+)Line(\\d+)/', $selectedId, $m) === 1) { - $records->removeNestedSubItem($index, intval($m[1]), intval($m[2])); - } elseif (preg_match('/^variant(\\d+)/', $selectedId, $m) === 1) { - $records->removeSubItem($index, intval($m[1])); - } else { return; } - $this->npcInspector->commit(); - $this->recordNpcCollectionChange($map, $index, $before, 'NPC remove'); + $this->recordNpcChange($change, $index, $index); + $this->clampDatabaseSettingSelection(); + $this->requestFullRender(); } /** - * Records a structural NPC change as one undo step, when it changed - * anything. + * Returns the shared NPC authoring rules over the open project. * - * @param ProjectMap $map The map. - * @param int $index The NPC. - * @param \Ichiloto\Editor\Field\NpcCollection $before The collection before. - * @param string $label The history label. + * @return NpcAuthoring|null The rules, or null with no project open. + */ + private function createNpcAuthoring(): ?NpcAuthoring + { + return $this->workspace instanceof ProjectWorkspace ? new NpcAuthoring($this->workspace) : null; + } + + /** + * Records an NPC change as one undo step, when it changed anything. + * + * The change's own command restores the map's NPCs; the shell adds + * only what it shows, selecting the NPC the step lands on. + * + * @param NpcChange $change The applied change. + * @param int|null $redoSelection The NPC to select after a redo. + * @param int|null $undoSelection The NPC to select after an undo. * @return void */ - private function recordNpcCollectionChange(ProjectMap $map, int $index, \Ichiloto\Editor\Field\NpcCollection $before, string $label): void + private function recordNpcChange(NpcChange $change, ?int $redoSelection, ?int $undoSelection): void { - $after = $map->getNpcs(); $this->refreshNpcInspector(); + $command = $change->command; - if ($after->toMapData() === $before->toMapData()) { - $this->requestFullRender(); - + if ($command === null) { return; } $this->recordCommand(new GenericCommand( - $label, - function () use ($map, $after, $index): void { - $map->setNpcs($after); - $this->selectNpc($index); + $command->label, + function () use ($command, $redoSelection): void { + $command->execute(); + $this->selectNpc($redoSelection); }, - function () use ($map, $before, $index): void { - $map->setNpcs($before); - $this->selectNpc($index); + function () use ($command, $undoSelection): void { + $command->undo(); + $this->selectNpc($undoSelection); }, )); - $this->clampDatabaseSettingSelection(); - $this->requestFullRender(); + } + + /** + * Shows why an NPC change was refused: a warning, or an error listing + * what stands in the way when the refusal names it. + * + * @param NpcRefusal $refusal The refusal. + * @return void + */ + private function reportNpcRefusal(NpcRefusal $refusal): void + { + $this->setStatus($refusal->getMessage(), $refusal->details === [] ? StatusLevel::WARN : StatusLevel::ERROR, $refusal->details); + $this->renderFooter(); } /** @@ -2112,7 +2107,9 @@ private function isNpcInspectorHosting(): bool } /** - * Returns the selected NPC's record-pane fields, grouped and framed. + * Returns the selected NPC's rows, grouped and framed + * ({@see NpcInspector::getFields()}). A frame that no longer resolves + * is left for the NPC's own rows; an empty one still resolves. * * @return array> The field descriptors. */ @@ -2122,166 +2119,21 @@ private function getNpcInspectorFields(): array return []; } - $records = $this->npcInspector->records(); - $fields = $records->getFrameSettingsFields($this->selectedNpcIndex, $this->databaseCommandFramePath); + $fields = $this->npcInspector->getFields($this->selectedNpcIndex, $this->databaseCommandFramePath); - if ( - $this->databaseCommandFramePath !== [] - && $records->getFrameCommands($this->selectedNpcIndex, $this->databaseCommandFramePath) === null - ) { - // The frame no longer resolves; an empty one still does. + if ($fields === null) { $this->databaseCommandFramePath = []; - $fields = $records->getFrameSettingsFields($this->selectedNpcIndex, []); - } - - if ($this->databaseCommandFramePath !== []) { - return $fields; - } - - // Group headings and honest notes, without changing any field id. - $npc = $this->getSelectedMap()?->getNpcs()->get($this->selectedNpcIndex); - $grouped = []; - $group = static fn(string $title): array => ['label' => $title, 'value' => '', 'editable' => false]; - $notes = []; - - if ($npc !== null && $npc->getId() === null) { - // Legacy entry: nothing can name an id it never had, so giving - // it one is the one identity write that is safe after creation. - $notes[] = [ - 'label' => ' ! No stable id', - 'value' => 'move_route cannot target it; Enter assigns one from the name', - 'editable' => true, - 'field' => self::NPC_ASSIGN_ID_FIELD, - ]; + $fields = $this->npcInspector->getFields($this->selectedNpcIndex) ?? []; } - if ($npc !== null && $npc->scriptShadowsDialogue()) { - $notes[] = ['label' => ' ! Script replaces dialogue', 'value' => 'the game runs the script', 'editable' => false]; - } - - if ($npc !== null && $npc->getUnknownFields() !== []) { - $notes[] = ['label' => ' Preserved fields', 'value' => implode(', ', $npc->getUnknownFields()), 'editable' => false]; - } - - $sections = [ - 'Identity' => ['id', 'name'], - 'Placement' => ['x', 'y'], - 'Appearance' => ['sprite', 'sprites.north', 'sprites.south', 'sprites.east', 'sprites.west'], - 'Movement' => ['movement', 'directionFix', 'wanderArea.x', 'wanderArea.y', 'wanderArea.width', 'wanderArea.height'], - 'Visibility' => ['conditions'], - 'Interaction' => ['commandListScript'], - 'Completion Writes' => ['sets'], - ]; - $byId = []; - - foreach ($fields as $field) { - $byId[(string) ($field['field'] ?? '')][] = $field; - } - - foreach ($sections as $title => $ids) { - $rows = []; - - foreach ($ids as $id) { - foreach ($byId[$id] ?? [] as $field) { - // Wander bounds only matter while wandering; loaded - // values are kept, just not shown for a fixed NPC. - if (! (str_starts_with($id, 'wanderArea.') && $npc !== null && ! $npc->wanders())) { - $rows[] = $field; - } - } - - unset($byId[$id]); - } - - if ($rows !== []) { - $grouped[] = $group($title); - $grouped = [...$grouped, ...$rows]; - } - - if ($title === 'Identity') { - $grouped = [...$grouped, ...$notes]; - } - - if ($title === 'Interaction') { - // Everything left is dialogue: variants, their lines, and - // their frames, each variant under its own heading. - [$variantRows, $byId] = $this->groupNpcVariantRows($byId); - $grouped = [...$grouped, ...$variantRows]; - } - } - - foreach ($byId as $rest) { - $grouped = [...$grouped, ...$rest]; - } - - return $grouped; - } - - /** - * Turns the record pane's variant rows into headed groups: one - * `Dialogue variant N` heading per variant (with its condition line - * when it has one), then that variant's rows under short labels -- - * `When`, `Then Set`, `Script Commands`, `Line 1 Speaker`, `Line 1 - * Text` -- so the label no longer eats the pane before the value - * starts. Field ids are untouched; this is the grouped view's - * presentation of the record layer's own rows. - * - * @param array>> $byId The remaining rows, keyed by field id. - * @return array{0: array>, 1: array>>} The headed rows, and what was left. - */ - private function groupNpcVariantRows(array $byId): array - { - $singular = ucfirst(\Ichiloto\Editor\Database\RecordSchemaCatalog::mapNpcs()->subList?->singular ?? 'dialogue variant'); - $variants = []; - - foreach ($byId as $id => $rows) { - if (preg_match('/^variant(\d+)/', $id, $matches) !== 1) { - continue; - } - - $variants[intval($matches[1])] = [...($variants[intval($matches[1])] ?? []), ...$rows]; - unset($byId[$id]); - } - - ksort($variants); - $headed = []; - - foreach ($variants as $number => $rows) { - $prefix = sprintf('%s %d ', $singular, $number + 1); - $when = ''; - - foreach ($rows as $row) { - if (($row['field'] ?? null) === sprintf('variant%dConditions', $number)) { - $when = trim((string) ($row['value'] ?? '')); - } - } - - // The condition line rides as the heading's value, so it reads - // "Dialogue variant 2 · when …" and wraps rather than clips. - $headed[] = [ - 'label' => sprintf('%s %d', $singular, $number + 1), - 'value' => $when === '' ? '' : 'when ' . $when, - 'editable' => false, - ]; - - foreach ($rows as $row) { - $label = (string) ($row['label'] ?? ''); - - if (str_starts_with($label, $prefix)) { - $row['label'] = substr($label, strlen($prefix)); - } - - $headed[] = $row; - } - } - - return [$headed, $byId]; + return $fields; } /** - * Applies an NPC field edit through the record pane and the map, and - * records it: the undo restores the whole previous collection, so list - * position and every other field come back exactly. + * Applies an NPC field edit through the shared authoring rules + * ({@see NpcAuthoring::applyField()}) and records it: the undo restores + * the whole previous collection, so list position and every other field + * come back exactly. A rename says what became of the id. * * @param array $field The field descriptor. * @param string $rawValue The raw value. @@ -2289,79 +2141,28 @@ private function groupNpcVariantRows(array $byId): array */ private function applyNpcFieldValueRecorded(array $field, string $rawValue): void { - $map = $this->getSelectedMap(); + $authoring = $this->createNpcAuthoring(); $index = $this->selectedNpcIndex; - if (! $map instanceof ProjectMap || $index === null || $this->npcInspector === null) { + if (! $authoring instanceof NpcAuthoring || $index === null || $this->npcInspector === null) { return; } - $before = $map->getNpcs(); - $fieldId = (string) ($field['field'] ?? ''); - $this->npcInspector->records()->setFrameField($index, $this->databaseCommandFramePath, $fieldId, $rawValue); - $this->npcInspector->commit(); - $after = $map->getNpcs(); - - if ($fieldId === 'name') { - $after = $this->followNpcNameWithId($map, $after, $index); - } - - if ($after->toMapData() === $before->toMapData()) { - // A same-value edit: no history, no dirt. - $this->refreshNpcInspector(); + try { + $change = $authoring->applyField($this->npcInspector, $index, $this->databaseCommandFramePath, $field, $rawValue); + } catch (NpcRefusal $refusal) { + $this->reportNpcRefusal($refusal); return; } - $this->refreshNpcInspector(); - $this->recordCommand(new GenericCommand( - sprintf('NPC %s edit', $field['label'] ?? 'field'), - function () use ($map, $after, $index): void { - $map->setNpcs($after); - $this->selectNpc($index); - }, - function () use ($map, $before, $index): void { - $map->setNpcs($before); - $this->selectNpc($index); - }, - )); - } - - /** - * Gives a renamed NPC the id its new name derives, as long as nothing - * refers to it yet: an id something names stays, so doors, routes and - * cinematics keep finding the NPC, and the status says what names it. - * - * @return NpcCollection The map's NPCs after the rename. - */ - private function followNpcNameWithId(ProjectMap $map, NpcCollection $npcs, int $index): NpcCollection - { - $npc = $npcs->get($index); - $id = $npc?->getId(); - - if ($npc === null || $id === null || $id === '') { - return $npcs; - } - - $derived = $npcs->withRemoved($index)->uniqueIdFor($npc->getName()); - - if ($derived === $id) { - return $npcs; + if ($change->followedId !== null) { + $this->setStatus(sprintf('Renamed. Its id is now %s.', $change->followedId), StatusLevel::INFO); + } elseif ($change->idReferences !== []) { + $this->setStatus(sprintf('Renamed. Its id stays %s: %s names it.', $change->npc?->getId() ?? '', implode(', ', $change->idReferences))); } - $references = $this->workspace instanceof ProjectWorkspace ? new NpcReferences($this->workspace)->describe($map, $id) : []; - - if ($references !== []) { - $this->setStatus(sprintf('Renamed. Its id stays %s: %s names it.', $id, implode(', ', $references))); - - return $npcs; - } - - $renamed = $npcs->withReplaced($index, $npc->withId($derived)); - $map->setNpcs($renamed); - $this->setStatus(sprintf('Renamed. Its id is now %s.', $derived), StatusLevel::INFO); - - return $renamed; + $this->recordNpcChange($change, $index, $index); } /** @@ -2497,24 +2298,24 @@ private function assignIdToSelectedNpc(): void { $map = $this->getSelectedMap(); $index = $this->selectedNpcIndex; - $npc = $index !== null ? $map?->getNpcs()->get($index) : null; + $authoring = $this->createNpcAuthoring(); - if (! $map instanceof ProjectMap || $index === null || $npc === null) { + if (! $map instanceof ProjectMap || ! $authoring instanceof NpcAuthoring || $index === null || $map->getNpcs()->get($index) === null) { return; } - if ($npc->getId() !== null) { - $this->setStatus(sprintf('%s already has the stable id "%s"; ids do not change.', $npc->getName(), $npc->getId()), StatusLevel::WARN); - $this->renderFooter(); + try { + $change = $authoring->assignId($map, $index); + } catch (NpcRefusal $refusal) { + $this->reportNpcRefusal($refusal); return; } - $before = $map->getNpcs(); - $id = $before->uniqueIdFor($npc->getName()); - $map->setNpcs($before->withReplaced($index, $npc->asCopyWithId($id))); - $this->recordNpcCollectionChange($map, $index, $before, sprintf('Assign NPC id %s', $id)); - $this->setStatus(sprintf('Assigned the stable id "%s" to %s.', $id, $npc->getName()), StatusLevel::INFO); + $this->recordNpcChange($change, $index, $index); + $this->clampDatabaseSettingSelection(); + $this->requestFullRender(); + $this->setStatus(sprintf('Assigned the stable id "%s" to %s.', $change->npc?->getId() ?? '', $change->npc?->getName() ?? ''), StatusLevel::INFO); } /** @@ -2758,7 +2559,8 @@ private function handleNpcNameInput(string $input): void $this->npcNameBuffer = ''; if ($tile !== null) { - $this->createNpcAt($tile['x'], $tile['y'], $name !== '' ? $name : 'New NPC'); + // A blank name takes the shared placeholder rather than refusing. + $this->createNpcAt($tile['x'], $tile['y'], $name); } return; @@ -2801,42 +2603,34 @@ private function buildNpcNamePromptRows(): array } /** - * Creates a fixed NPC at a tile under a stable id derived from its name. + * Creates a fixed NPC at a tile under a stable id derived from its name + * ({@see NpcAuthoring::create()}). * * @param int $x The anchor column. * @param int $y The anchor row. - * @param string $name The display name. + * @param string $name The display name; blank takes the placeholder. * @return void */ private function createNpcAt(int $x, int $y, string $name): void { $map = $this->getSelectedMap(); + $authoring = $this->createNpcAuthoring(); - if (! $map instanceof ProjectMap) { + if (! $map instanceof ProjectMap || ! $authoring instanceof NpcAuthoring) { return; } - $collection = $map->getNpcs(); - $id = $collection->uniqueIdFor($name); - $npc = ProjectNpc::createAt($id, $name, $x, $y); - $index = $collection->count(); - $before = $collection; - $after = $collection->withAdded($npc); + try { + $change = $authoring->create($map, $x, $y, $name); + } catch (NpcRefusal $refusal) { + $this->reportNpcRefusal($refusal); - $map->setNpcs($after); - $this->selectNpc($index); - $this->recordCommand(new GenericCommand( - 'NPC create', - function () use ($map, $after, $index): void { - $map->setNpcs($after); - $this->selectNpc($index); - }, - function () use ($map, $before): void { - $map->setNpcs($before); - $this->selectNpc(null); - }, - )); - $this->setStatus(sprintf('Created %s. Its id is %s.', $name, $id), StatusLevel::INFO); + return; + } + + $this->selectNpc($change->index); + $this->recordNpcChange($change, $change->index, null); + $this->setStatus(sprintf('Created %s. Its id is %s.', $change->npc?->getName() ?? '', $change->npc?->getId() ?? ''), StatusLevel::INFO); $this->focusedPane = self::FOCUS_INSPECTOR; $this->requestFullRender(); } @@ -2879,7 +2673,7 @@ private function finishNpcMove(): void } /** - * Moves an NPC to a tile, recorded for undo. + * Moves an NPC to a tile, recorded for undo ({@see NpcAuthoring::move()}). * * @param int $index The NPC's position. * @param int $x The destination column. @@ -2889,154 +2683,96 @@ private function finishNpcMove(): void private function moveNpc(int $index, int $x, int $y): void { $map = $this->getSelectedMap(); - $collection = $map?->getNpcs(); - $npc = $collection?->get($index); + $authoring = $this->createNpcAuthoring(); - if (! $map instanceof ProjectMap || $collection === null || $npc === null) { + if (! $map instanceof ProjectMap || ! $authoring instanceof NpcAuthoring || $map->getNpcs()->get($index) === null) { return; } - if ($x < 0 || $y < 0 || $x >= $map->getWidth() || $y >= $map->getHeight()) { - $this->setStatus(sprintf('%d,%d is outside the map.', $x, $y), StatusLevel::WARN); - $this->renderFooter(); + try { + $change = $authoring->move($map, $index, $x, $y); + } catch (NpcRefusal $refusal) { + $this->reportNpcRefusal($refusal); return; } - if ($npc->getX() === $x && $npc->getY() === $y) { + if ($change->command === null) { $this->setStatus('Already there.'); $this->renderFooter(); return; } - $occupant = $collection->indexAt($x, $y); - - if ($occupant !== null && $occupant !== $index) { - $this->setStatus(sprintf('%s already stands at %d,%d.', $collection->get($occupant)?->getName() ?? 'An NPC', $x, $y), StatusLevel::WARN); - $this->renderFooter(); - - return; - } - - $before = $collection; - $after = $collection->withReplaced($index, $npc->movedTo($x, $y)); - $map->setNpcs($after); - $this->refreshNpcInspector(); - $this->recordCommand(new GenericCommand( - 'NPC move', - function () use ($map, $after, $index): void { - $map->setNpcs($after); - $this->selectNpc($index); - }, - function () use ($map, $before, $index): void { - $map->setNpcs($before); - $this->selectNpc($index); - }, - )); - $this->setStatus(sprintf('Moved %s to %d,%d.', $npc->getName(), $x, $y), StatusLevel::INFO); + $this->recordNpcChange($change, $index, $index); + $this->setStatus(sprintf('Moved %s to %d,%d.', $change->npc?->getName() ?? '', $x, $y), StatusLevel::INFO); $this->requestFullRender(); } /** * Duplicates the selected NPC under a fresh unique id, one tile to the - * right when that tile is free. + * right when that tile is free ({@see NpcAuthoring::duplicate()}). * * @return void */ private function duplicateSelectedNpc(): void { $map = $this->getSelectedMap(); - $collection = $map?->getNpcs(); - $npc = $this->selectedNpcIndex !== null ? $collection?->get($this->selectedNpcIndex) : null; + $authoring = $this->createNpcAuthoring(); + $index = $this->selectedNpcIndex; - if (! $map instanceof ProjectMap || $collection === null || $npc === null) { + if (! $map instanceof ProjectMap || ! $authoring instanceof NpcAuthoring || $index === null || $map->getNpcs()->get($index) === null) { $this->setStatus('Select an NPC first (Enter on it).', StatusLevel::WARN); $this->renderFooter(); return; } - $id = $collection->uniqueIdFor($npc->getName()); - $copy = $npc->asCopyWithId($id); - $x = $npc->getX() + $npc->getSpriteWidth(); + try { + $change = $authoring->duplicate($map, $index); + } catch (NpcRefusal $refusal) { + $this->reportNpcRefusal($refusal); - if ($x < $map->getWidth() && $collection->indexAt($x, $npc->getY()) === null) { - $copy = $copy->movedTo($x, $npc->getY()); + return; } - $index = $collection->count(); - $before = $collection; - $after = $collection->withAdded($copy); - $map->setNpcs($after); - $this->selectNpc($index); - $this->recordCommand(new GenericCommand( - 'NPC duplicate', - function () use ($map, $after, $index): void { - $map->setNpcs($after); - $this->selectNpc($index); - }, - function () use ($map, $before): void { - $map->setNpcs($before); - $this->selectNpc(null); - }, - )); - $this->setStatus(sprintf('Duplicated as %s (id %s).', $copy->getName(), $id), StatusLevel::INFO); + $this->selectNpc($change->index); + $this->recordNpcChange($change, $change->index, null); + $this->setStatus(sprintf('Duplicated as %s (id %s).', $change->npc?->getName() ?? '', $change->npc?->getId() ?? ''), StatusLevel::INFO); $this->requestFullRender(); } /** - * Deletes the selected NPC, refusing while anything names its id. + * Deletes the selected NPC, refusing while anything names its id + * ({@see NpcAuthoring::delete()}). * * @return void */ private function deleteSelectedNpc(): void { $map = $this->getSelectedMap(); - $collection = $map?->getNpcs(); + $authoring = $this->createNpcAuthoring(); $index = $this->selectedNpcIndex; - $npc = $index !== null ? $collection?->get($index) : null; - if (! $map instanceof ProjectMap || $collection === null || $index === null || $npc === null || ! $this->workspace instanceof ProjectWorkspace) { + if (! $map instanceof ProjectMap || ! $authoring instanceof NpcAuthoring || $index === null || $map->getNpcs()->get($index) === null) { $this->setStatus('Select an NPC first (Enter on it).', StatusLevel::WARN); $this->renderFooter(); return; } - $references = $npc->getId() !== null - ? new NpcReferences($this->workspace)->describe($map, $npc->getId()) - : []; - - if ($references !== []) { - // Refusing beats a route or script that silently stops - // resolving. The list is what the author needs to go fix. - $this->setStatus( - sprintf('%s is named by %s - resolve those before deleting.', $npc->getName(), implode(', ', $references)), - StatusLevel::ERROR, - array_map(static fn(string $reference): string => '- ' . $reference, $references), - ); - $this->renderFooter(); + try { + $change = $authoring->delete($map, $index); + } catch (NpcRefusal $refusal) { + // The refusal lists what names the NPC: what the author goes to fix. + $this->reportNpcRefusal($refusal); return; } - $before = $collection; - $after = $collection->withRemoved($index); - $map->setNpcs($after); $this->selectNpc(null); - $this->recordCommand(new GenericCommand( - 'NPC delete', - function () use ($map, $after): void { - $map->setNpcs($after); - $this->selectNpc(null); - }, - function () use ($map, $before, $index): void { - $map->setNpcs($before); - $this->selectNpc($index); - }, - )); - $this->setStatus(sprintf('Deleted %s.', $npc->getName()), StatusLevel::INFO); + $this->recordNpcChange($change, null, $index); + $this->setStatus(sprintf('Deleted %s.', $change->npc?->getName() ?? ''), StatusLevel::INFO); $this->requestFullRender(); } diff --git a/src/Field/NpcAuthoring.php b/src/Field/NpcAuthoring.php new file mode 100644 index 0000000..7d1e054 --- /dev/null +++ b/src/Field/NpcAuthoring.php @@ -0,0 +1,401 @@ +references = new NpcReferences($workspace); + } + + /** + * Creates a fixed NPC at a tile, its stable id derived from its name. + * + * @param string $name The display name; blank takes {@see DEFAULT_NAME}. + * @throws NpcRefusal When the tile is outside the map or another NPC anchors there. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function create(ProjectMap $map, int $x, int $y, string $name): NpcChange + { + $before = $map->getNpcs(); + $this->assertPlaceable($map, $before, null, $x, $y); + $name = trim($name) === '' ? self::DEFAULT_NAME : trim($name); + $npc = ProjectNpc::createAt($before->uniqueIdFor($name), $name, $x, $y); + $after = $before->withAdded($npc); + $map->setNpcs($after); + + return new NpcChange($this->createCommand($map, 'NPC create', $before, $after), $before->count(), $npc); + } + + /** + * Moves an NPC's anchor to a tile. A move to where it already stands + * changes nothing. + * + * @throws NpcRefusal When there is no such NPC, the tile is outside the map, or another NPC anchors there. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function move(ProjectMap $map, int $index, int $x, int $y): NpcChange + { + $before = $map->getNpcs(); + $npc = $this->requireNpc($map, $before, $index); + + if ($npc->getX() === $x && $npc->getY() === $y) { + return new NpcChange(null, $index, $npc); + } + + $this->assertPlaceable($map, $before, $index, $x, $y); + $moved = $npc->movedTo($x, $y); + $after = $before->withReplaced($index, $moved); + $map->setNpcs($after); + + return new NpcChange($this->createCommand($map, 'NPC move', $before, $after), $index, $moved); + } + + /** + * Duplicates an NPC under a fresh id derived from its name, one sprite + * width to the right when that tile is inside the map and free, and on + * its own tile otherwise. The copy is appended. + * + * @throws NpcRefusal When there is no such NPC. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function duplicate(ProjectMap $map, int $index): NpcChange + { + $before = $map->getNpcs(); + $npc = $this->requireNpc($map, $before, $index); + $copy = $npc->asCopyWithId($before->uniqueIdFor($npc->getName())); + $x = $npc->getX() + $npc->getSpriteWidth(); + + if ($x < $map->getWidth() && $before->indexAt($x, $npc->getY()) === null) { + $copy = $copy->movedTo($x, $npc->getY()); + } + + $after = $before->withAdded($copy); + $map->setNpcs($after); + + return new NpcChange($this->createCommand($map, 'NPC duplicate', $before, $after), $before->count(), $copy); + } + + /** + * Deletes an NPC, refusing while anything names its id: a route or + * script that silently stops resolving is worse than a refusal, and the + * refusal's details are what the author goes to fix. + * + * @throws NpcRefusal When there is no such NPC or something names it. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function delete(ProjectMap $map, int $index): NpcChange + { + $before = $map->getNpcs(); + $npc = $this->requireNpc($map, $before, $index); + $references = $npc->getId() !== null ? $this->references->describe($map, $npc->getId()) : []; + + if ($references !== []) { + throw new NpcRefusal( + sprintf('%s is named by %s - resolve those before deleting.', $npc->getName(), implode(', ', $references)), + array_map(static fn(string $reference): string => '- ' . $reference, $references), + ); + } + + $after = $before->withRemoved($index); + $map->setNpcs($after); + + return new NpcChange($this->createCommand($map, 'NPC delete', $before, $after), null, $npc); + } + + /** + * Gives an NPC authored without a stable id one, derived from its name + * and unique on its map, so movement routes can target it. An existing + * id never changes here: what names it would not follow. + * + * @throws NpcRefusal When there is no such NPC or it already has an id. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function assignId(ProjectMap $map, int $index): NpcChange + { + $before = $map->getNpcs(); + $npc = $this->requireNpc($map, $before, $index); + + if ($npc->getId() !== null) { + throw new NpcRefusal(sprintf('%s already has the stable id "%s"; ids do not change.', $npc->getName(), $npc->getId())); + } + + $id = $before->uniqueIdFor($npc->getName()); + $identified = $npc->asCopyWithId($id); + $after = $before->withReplaced($index, $identified); + $map->setNpcs($after); + + return new NpcChange($this->createCommand($map, sprintf('Assign NPC id %s', $id), $before, $after), $index, $identified); + } + + /** + * Applies one row's edit through the NPC's record pane. A new name + * carries the id with it while nothing names the old id; otherwise the + * id stays and the change says what names it. The rename and its id + * are one undo step. The X and Y rows move the NPC, under the same tile + * rules as {@see move()}. + * + * @param array $framePath The frame the row belongs to; [] for the NPC itself. + * @param array $field The row, as {@see NpcInspector::getFields()} gave it. + * @throws NpcRefusal When there is no such NPC or frame, or a coordinate leaves the map or lands on another NPC. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function applyField(NpcInspector $inspector, int $index, array $framePath, array $field, string $rawValue): NpcChange + { + $map = $inspector->map; + $before = $map->getNpcs(); + $this->requireFrame($inspector, $before, $index, $framePath); + $fieldId = (string) ($field['field'] ?? ''); + $followedId = null; + $idReferences = []; + + try { + $inspector->records()->setFrameField($index, $framePath, $fieldId, $rawValue); + $inspector->commit(); + $after = $map->getNpcs(); + $placed = $after->get($index); + $was = $before->get($index); + + if ($framePath === [] && in_array($fieldId, ['x', 'y'], true) && $placed !== null && $was !== null + && ($placed->getX() !== $was->getX() || $placed->getY() !== $was->getY())) { + // A coordinate row is a move: the tile rules a move keeps apply. + $this->assertPlaceable($map, $before, $index, $placed->getX(), $placed->getY()); + } + + if ($framePath === [] && $fieldId === 'name') { + [$after, $followedId, $idReferences] = $this->followNameWithId($map, $after, $index); + } + } catch (Throwable $failure) { + // The rename and its id are one change: when the id cannot + // follow, the name does not land alone. + if ($map->getNpcs()->toMapData() !== $before->toMapData()) { + $map->setNpcs($before); + } + + throw $failure; + } finally { + // The pane always shows the map, whether the write landed or not. + $inspector->refresh(); + } + + return new NpcChange( + $this->createCommand($map, sprintf('NPC %s edit', $field['label'] ?? 'field'), $before, $after), + $index, + $after->get($index), + $followedId, + $idReferences, + ); + } + + /** + * Adds an item at a row of the NPC's record pane: inside a script frame, + * a route step under the route the row belongs to or a command after + * the row's command (at the end when the row names none); at the root, + * a line in the row's dialogue variant, or a new variant. + * + * @param array $framePath The frame the row belongs to. + * @param string $fieldId The row's field id; '' for none. + * @throws NpcRefusal When there is no such NPC or frame. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function addSubItem(NpcInspector $inspector, int $index, array $framePath, string $fieldId): NpcChange + { + $map = $inspector->map; + $before = $map->getNpcs(); + $this->requireFrame($inspector, $before, $index, $framePath); + $records = $inspector->records(); + $nested = $framePath !== [] ? $records->frameNestedContext($index, $framePath, $fieldId) : null; + + try { + if ($nested !== null) { + $records->addFrameNestedItem($index, $framePath, $nested['parentIndex']); + } elseif ($framePath !== []) { + $after = preg_match('/^command(\d+)/', $fieldId, $matches) === 1 ? intval($matches[1]) : null; + $records->addFrameCommand($index, $framePath, $after); + } elseif (preg_match('/^variant(\d+)Line/', $fieldId, $matches) === 1) { + $records->addNestedSubItem($index, intval($matches[1])); + } else { + $records->addSubItem($index); + } + + $inspector->commit(); + } finally { + $inspector->refresh(); + } + + return $this->createRowChange($map, 'NPC add', $before, $index); + } + + /** + * Removes the item a row of the NPC's record pane belongs to: a route + * step, a command, a dialogue line or a dialogue variant. A row that + * belongs to none changes nothing. + * + * @param array $framePath The frame the row belongs to. + * @param string $fieldId The row's field id. + * @throws NpcRefusal When there is no such NPC or frame. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function removeSubItem(NpcInspector $inspector, int $index, array $framePath, string $fieldId): NpcChange + { + $map = $inspector->map; + $before = $map->getNpcs(); + $this->requireFrame($inspector, $before, $index, $framePath); + $records = $inspector->records(); + $nested = $framePath !== [] ? $records->frameNestedContext($index, $framePath, $fieldId) : null; + + try { + if ($nested !== null && $nested['nestedIndex'] !== null) { + // The step under the row; a command row removes the command. + $records->removeFrameNestedItem($index, $framePath, $nested['parentIndex'], $nested['nestedIndex']); + } elseif ($framePath !== []) { + if (preg_match('/^command(\d+)/', $fieldId, $matches) === 1) { + $records->removeFrameCommand($index, $framePath, intval($matches[1])); + } + } elseif (preg_match('/^variant(\d+)Line(\d+)/', $fieldId, $matches) === 1) { + $records->removeNestedSubItem($index, intval($matches[1]), intval($matches[2])); + } elseif (preg_match('/^variant(\d+)/', $fieldId, $matches) === 1) { + $records->removeSubItem($index, intval($matches[1])); + } + + $inspector->commit(); + } finally { + $inspector->refresh(); + } + + return $this->createRowChange($map, 'NPC remove', $before, $index); + } + + /** + * Gives a renamed NPC the id its new name derives, unless something + * names its current id. + * + * @return array{0: NpcCollection, 1: ?string, 2: list} The NPCs afterwards, the id taken, and what names the id kept. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + private function followNameWithId(ProjectMap $map, NpcCollection $npcs, int $index): array + { + $npc = $npcs->get($index); + $id = $npc?->getId(); + + if ($npc === null || $id === null) { + return [$npcs, null, []]; + } + + $derived = $npcs->withRemoved($index)->uniqueIdFor($npc->getName()); + + if ($derived === $id) { + return [$npcs, null, []]; + } + + $references = $this->references->describe($map, $id); + + if ($references !== []) { + return [$npcs, null, $references]; + } + + $renamed = $npcs->withReplaced($index, $npc->withId($derived)); + $map->setNpcs($renamed); + + return [$renamed, $derived, []]; + } + + /** + * Refuses a tile outside the map or one another NPC anchors to. + * + * @param int|null $ownIndex The NPC being placed, when it is already on the map. + * @throws NpcRefusal + */ + private function assertPlaceable(ProjectMap $map, NpcCollection $npcs, ?int $ownIndex, int $x, int $y): void + { + if ($x < 0 || $y < 0 || $x >= $map->getWidth() || $y >= $map->getHeight()) { + throw new NpcRefusal(sprintf('%d,%d is outside the map.', $x, $y)); + } + + $occupant = $npcs->indexAt($x, $y); + + if ($occupant !== null && $occupant !== $ownIndex) { + throw new NpcRefusal(sprintf('%s already stands at %d,%d.', $npcs->get($occupant)?->getName() ?? 'An NPC', $x, $y)); + } + } + + /** @throws NpcRefusal */ + private function requireNpc(ProjectMap $map, NpcCollection $npcs, int $index): ProjectNpc + { + return $npcs->get($index) ?? throw new NpcRefusal(sprintf('%s has no NPC %d.', $map->mapId, $index)); + } + + /** + * Refuses an NPC or a script frame that is no longer there. + * + * @param array $framePath + * @throws NpcRefusal + */ + private function requireFrame(NpcInspector $inspector, NpcCollection $npcs, int $index, array $framePath): void + { + $this->requireNpc($inspector->map, $npcs, $index); + + if ($framePath !== [] && $inspector->records()->getFrameCommands($index, $framePath) === null) { + throw new NpcRefusal(sprintf('%s is no longer there.', $inspector->records()->describeFramePath($framePath))); + } + } + + /** The change a row edit made to one NPC, with its command when it changed anything. */ + private function createRowChange(ProjectMap $map, string $label, NpcCollection $before, int $index): NpcChange + { + $after = $map->getNpcs(); + + return new NpcChange($this->createCommand($map, $label, $before, $after), $index, $after->get($index)); + } + + /** + * The command that puts either collection back, or null when the two + * are the same: a same-value edit leaves no history. + */ + private function createCommand(ProjectMap $map, string $label, NpcCollection $before, NpcCollection $after): ?Command + { + if ($after->toMapData() === $before->toMapData()) { + return null; + } + + return new GenericCommand( + $label, + static fn() => $map->setNpcs($after), + static fn() => $map->setNpcs($before), + ); + } +} diff --git a/src/Field/NpcChange.php b/src/Field/NpcChange.php new file mode 100644 index 0000000..08d46ea --- /dev/null +++ b/src/Field/NpcChange.php @@ -0,0 +1,33 @@ + $idReferences What names the id a renamed NPC kept instead. + */ + public function __construct( + public ?Command $command, + public ?int $index, + public ?ProjectNpc $npc, + public ?string $followedId = null, + public array $idReferences = [], + ) { + } +} diff --git a/src/Field/NpcInspector.php b/src/Field/NpcInspector.php index 7de9409..2a09e13 100644 --- a/src/Field/NpcInspector.php +++ b/src/Field/NpcInspector.php @@ -20,17 +20,192 @@ * Dialogue is presented as variants (plain pages folded into one) and * unfolded on the way back, so a file authored as pages is saved as pages. * + * The rows every interface lists for one NPC come from {@see getFields()}: + * the record layer's own rows under headings, with the notes an author + * needs, and field ids untouched so an edit still names the record field. + * * @package Ichiloto\Editor\Field */ final class NpcInspector { + /** + * The field id of the note on an NPC without a stable id; acting on it + * assigns one ({@see NpcAuthoring::assignId()}). + */ + public const string ASSIGN_ID_FIELD = '__npc_assign_id'; + + /** + * The headings the root rows are grouped under, and the field ids each + * holds, in the order they are listed. + */ + private const array SECTIONS = [ + 'Identity' => ['id', 'name'], + 'Placement' => ['x', 'y'], + 'Appearance' => ['sprite', 'sprites.north', 'sprites.south', 'sprites.east', 'sprites.west'], + 'Movement' => ['movement', 'directionFix', 'wanderArea.x', 'wanderArea.y', 'wanderArea.width', 'wanderArea.height'], + 'Visibility' => ['conditions'], + 'Interaction' => ['commandListScript'], + 'Completion Writes' => ['sets'], + ]; + private ProjectRecordDatabase $records; - public function __construct(private readonly ProjectMap $map) + public function __construct(public readonly ProjectMap $map) { $this->records = $this->build(); } + /** + * Returns one NPC's rows in a frame of its commands: at the root, its + * fields grouped under headings with its identity notes and each + * dialogue variant headed; inside a script frame, that frame's rows. + * + * @param int $index The NPC's position. + * @param array $framePath The frame; [] for the NPC itself. + * @return array>|null The field descriptors, or null when the frame no longer resolves. + */ + public function getFields(int $index, array $framePath = []): ?array + { + if ($framePath !== [] && $this->records->getFrameCommands($index, $framePath) === null) { + return null; + } + + $fields = $this->records->getFrameSettingsFields($index, $framePath); + + if ($framePath !== []) { + return $fields; + } + + $npc = $this->map->getNpcs()->get($index); + $grouped = []; + $group = static fn(string $title): array => ['label' => $title, 'value' => '', 'editable' => false]; + $notes = []; + + if ($npc !== null && $npc->getId() === null) { + // Legacy entry: nothing can name an id it never had, so giving + // it one is the one identity write that is safe after creation. + $notes[] = [ + 'label' => ' ! No stable id', + 'value' => 'move_route cannot target it; Enter assigns one from the name', + 'editable' => true, + 'field' => self::ASSIGN_ID_FIELD, + ]; + } + + if ($npc !== null && $npc->scriptShadowsDialogue()) { + $notes[] = ['label' => ' ! Script replaces dialogue', 'value' => 'the game runs the script', 'editable' => false]; + } + + if ($npc !== null && $npc->getUnknownFields() !== []) { + $notes[] = ['label' => ' Preserved fields', 'value' => implode(', ', $npc->getUnknownFields()), 'editable' => false]; + } + + $byId = []; + + foreach ($fields as $field) { + $byId[(string) ($field['field'] ?? '')][] = $field; + } + + foreach (self::SECTIONS as $title => $ids) { + $rows = []; + + foreach ($ids as $id) { + foreach ($byId[$id] ?? [] as $field) { + // Wander bounds only matter while wandering; loaded + // values are kept, just not shown for a fixed NPC. + if (! (str_starts_with($id, 'wanderArea.') && $npc !== null && ! $npc->wanders())) { + $rows[] = $field; + } + } + + unset($byId[$id]); + } + + if ($rows !== []) { + $grouped[] = $group($title); + $grouped = [...$grouped, ...$rows]; + } + + if ($title === 'Identity') { + $grouped = [...$grouped, ...$notes]; + } + + if ($title === 'Interaction') { + // Everything left is dialogue: variants, their lines, and + // their frames, each variant under its own heading. + [$variantRows, $byId] = self::groupVariantRows($byId); + $grouped = [...$grouped, ...$variantRows]; + } + } + + foreach ($byId as $rest) { + $grouped = [...$grouped, ...$rest]; + } + + return $grouped; + } + + /** + * Turns the record pane's variant rows into headed groups: one + * `Dialogue variant N` heading per variant (with its condition line + * when it has one), then that variant's rows under short labels -- + * `When`, `Then Set`, `Script Commands`, `Line 1 Speaker`, `Line 1 + * Text` -- so the label no longer eats the pane before the value + * starts. Field ids are untouched; this is the grouped view's + * presentation of the record layer's own rows. + * + * @param array>> $byId The remaining rows, keyed by field id. + * @return array{0: array>, 1: array>>} The headed rows, and what was left. + */ + private static function groupVariantRows(array $byId): array + { + $singular = ucfirst(RecordSchemaCatalog::mapNpcs()->subList?->singular ?? 'dialogue variant'); + $variants = []; + + foreach ($byId as $id => $rows) { + if (preg_match('/^variant(\d+)/', $id, $matches) !== 1) { + continue; + } + + $variants[intval($matches[1])] = [...($variants[intval($matches[1])] ?? []), ...$rows]; + unset($byId[$id]); + } + + ksort($variants); + $headed = []; + + foreach ($variants as $number => $rows) { + $prefix = sprintf('%s %d ', $singular, $number + 1); + $when = ''; + + foreach ($rows as $row) { + if (($row['field'] ?? null) === sprintf('variant%dConditions', $number)) { + $when = trim((string) ($row['value'] ?? '')); + } + } + + // The condition line rides as the heading's value, so it reads + // "Dialogue variant 2 · when …" and wraps rather than clips. + $headed[] = [ + 'label' => sprintf('%s %d', $singular, $number + 1), + 'value' => $when === '' ? '' : 'when ' . $when, + 'editable' => false, + ]; + + foreach ($rows as $row) { + $label = (string) ($row['label'] ?? ''); + + if (str_starts_with($label, $prefix)) { + $row['label'] = substr($label, strlen($prefix)); + } + + $headed[] = $row; + } + } + + return [$headed, $byId]; + } + /** * Returns the record pane over the map's current NPCs. * diff --git a/src/Field/NpcRefusal.php b/src/Field/NpcRefusal.php new file mode 100644 index 0000000..e4cdee1 --- /dev/null +++ b/src/Field/NpcRefusal.php @@ -0,0 +1,24 @@ + $details */ + public function __construct(string $message, public readonly array $details = [], ?Throwable $previous = null) + { + parent::__construct($message, 0, $previous); + } +} diff --git a/tests/Unit/NpcAuthoringRulesTest.php b/tests/Unit/NpcAuthoringRulesTest.php new file mode 100644 index 0000000..00f93a0 --- /dev/null +++ b/tests/Unit/NpcAuthoringRulesTest.php @@ -0,0 +1,251 @@ + $npcs The npcs block. + * @param array $events Event definitions to merge. + * @return array{0: ProjectWorkspace, 1: ProjectMap} + */ +function npcRulesProject(array $npcs, array $events = []): array +{ + $root = makeTemporaryProject('ichiloto-npc-rules-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['npcs'] = $npcs; + $data['events'] = array_replace($data['events'] ?? [], $events); + file_put_contents($path, "getMapByIndex(0)]; +} + +/** The row with a field id among an NPC's rows. */ +function npcRulesRow(NpcInspector $inspector, int $index, string $fieldId, array $frame = []): array +{ + return array_find($inspector->getFields($index, $frame) ?? [], static fn(array $row): bool => ($row['field'] ?? null) === $fieldId) + ?? throw new RuntimeException("No NPC row {$fieldId}."); +} + +it('creates an NPC under an id its name derives, and refuses a tile outside the map or taken', function () { + [$workspace, $map] = npcRulesProject([['id' => 'gate-guard', 'name' => 'Gate Guard', 'x' => 2, 'y' => 1]]); + $authoring = new NpcAuthoring($workspace); + + $change = $authoring->create($map, 4, 2, 'Gate Guard'); + $blank = $authoring->create($map, 5, 2, ' '); + + expect($change->index)->toBe(1) + ->and($change->npc?->getId())->toBe('gate-guard-2') + ->and($map->getNpcs()->get(1)?->toArray())->toMatchArray(['x' => 4, 'y' => 2, 'movement' => 'fixed']) + ->and($blank->npc?->getName())->toBe(NpcAuthoring::DEFAULT_NAME) + ->and($blank->npc?->getId())->toBe('new-npc') + ->and(fn() => $authoring->create($map, 12, 0, 'Out'))->toThrow(NpcRefusal::class, '12,0 is outside the map.') + ->and(fn() => $authoring->create($map, 2, 1, 'Stacked'))->toThrow(NpcRefusal::class, 'Gate Guard already stands at 2,1.') + ->and($map->getNpcs()->count())->toBe(3); + + // The command puts back exactly the collection; nothing else is involved. + $blank->command?->undo(); + $change->command?->undo(); + expect($map->getNpcs()->ids())->toBe(['gate-guard']) + ->and($map->isDirty())->toBeFalse(); + $change->command?->execute(); + expect($map->getNpcs()->ids())->toBe(['gate-guard', 'gate-guard-2']); +}); + +it('moves an NPC within the map onto a free tile, and changes nothing for its own tile', function () { + [$workspace, $map] = npcRulesProject([ + ['id' => 'a', 'name' => 'Ann', 'x' => 2, 'y' => 1], + ['id' => 'b', 'name' => 'Bob', 'x' => 6, 'y' => 3], + ]); + $authoring = new NpcAuthoring($workspace); + + expect($authoring->move($map, 0, 2, 1)->command)->toBeNull() + ->and(fn() => $authoring->move($map, 0, -1, 1))->toThrow(NpcRefusal::class, '-1,1 is outside the map.') + ->and(fn() => $authoring->move($map, 0, 6, 3))->toThrow(NpcRefusal::class, 'Bob already stands at 6,3.') + ->and(fn() => $authoring->move($map, 7, 1, 1))->toThrow(NpcRefusal::class, 'test-map has no NPC 7.') + ->and($map->isDirty())->toBeFalse(); + + $moved = $authoring->move($map, 0, 8, 2); + expect($moved->npc?->getX())->toBe(8) + ->and($moved->command?->label)->toBe('NPC move'); + $moved->command?->undo(); + expect($map->getNpcs()->get(0)?->getX())->toBe(2); +}); + +it('duplicates beside the original when that tile is free, and on its own tile when it is not', function () { + [$workspace, $map] = npcRulesProject([ + ['id' => 'a', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1], + ['id' => 'b', 'name' => 'Bob', 'sprite' => 'B', 'x' => 3, 'y' => 1], + ]); + $authoring = new NpcAuthoring($workspace); + + $beside = $authoring->duplicate($map, 1); + $blocked = $authoring->duplicate($map, 0); + + expect($beside->index)->toBe(2) + ->and($beside->npc?->getId())->toBe('bob') + ->and([$beside->npc?->getX(), $beside->npc?->getY()])->toBe([4, 1]) + ->and($blocked->npc?->getId())->toBe('ann') + ->and([$blocked->npc?->getX(), $blocked->npc?->getY()])->toBe([2, 1]); +}); + +it('refuses to delete an NPC something names, naming each reference, and deletes it once nothing does', function () { + [$workspace, $map] = npcRulesProject([ + ['id' => 'a', 'name' => 'Ann', 'x' => 2, 'y' => 1], + ['id' => 'b', 'name' => 'Bob', 'x' => 6, 'y' => 3, 'script' => [ + ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'a', 'steps' => [['direction' => 'north']]], + ]], + ['id' => 'c', 'name' => 'Cy', 'x' => 8, 'y' => 3], + ]); + $authoring = new NpcAuthoring($workspace); + + try { + $authoring->delete($map, 0); + $this->fail('Deleting a named NPC was not refused.'); + } catch (NpcRefusal $refusal) { + expect($refusal->getMessage())->toBe('Ann is named by NPC Bob script - resolve those before deleting.') + ->and($refusal->details)->toBe(['- NPC Bob script']); + } + + $deleted = $authoring->delete($map, 1); + expect($deleted->index)->toBeNull() + ->and($deleted->npc?->getName())->toBe('Bob') + ->and($map->getNpcs()->ids())->toBe(['a', 'c']); + + // Undo puts it back where it stood in the list, not at the end. + $deleted->command?->undo(); + expect($map->getNpcs()->ids())->toBe(['a', 'b', 'c']); +}); + +it('assigns an id to an NPC without one, once', function () { + [$workspace, $map] = npcRulesProject([['name' => 'Old Man', 'x' => 2, 'y' => 1]]); + $authoring = new NpcAuthoring($workspace); + + expect(npcRulesRow(new NpcInspector($map), 0, NpcInspector::ASSIGN_ID_FIELD)['label'])->toContain('No stable id'); + + $assigned = $authoring->assignId($map, 0); + expect($assigned->npc?->getId())->toBe('old-man') + ->and($assigned->command?->label)->toBe('Assign NPC id old-man') + ->and(fn() => $authoring->assignId($map, 0)) + ->toThrow(NpcRefusal::class, 'Old Man already has the stable id "old-man"; ids do not change.'); +}); + +it('edits rows through the record pane, carrying the id with a rename unless something names it', function () { + [$workspace, $map] = npcRulesProject( + [ + ['id' => 'mara', 'name' => 'Mara', 'x' => 2, 'y' => 1], + ['id' => 'bob', 'name' => 'Bob', 'x' => 6, 'y' => 3], + ], + ['T' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\DialogueEventTrigger', 'data' => ['npcId' => 'mara']]], + ); + $authoring = new NpcAuthoring($workspace); + $inspector = new NpcInspector($map); + + $kept = $authoring->applyField($inspector, 0, [], npcRulesRow($inspector, 0, 'name'), 'Old Mara'); + $followed = $authoring->applyField($inspector, 1, [], npcRulesRow($inspector, 1, 'name'), 'Robert'); + + expect($kept->npc?->getId())->toBe('mara') + ->and($kept->followedId)->toBeNull() + ->and($kept->idReferences)->toBe(['test-map event T']) + ->and($followed->followedId)->toBe('robert') + ->and($map->getNpcs()->get(1)?->getName())->toBe('Robert') + ->and($authoring->applyField($inspector, 1, [], npcRulesRow($inspector, 1, 'name'), 'Robert')->command)->toBeNull(); + + // The rename and its id are one step. + $followed->command?->undo(); + expect($map->getNpcs()->get(1)?->getId())->toBe('bob') + ->and($map->getNpcs()->get(1)?->getName())->toBe('Bob'); +}); + +it('applies the move rules to the coordinate rows, leaving nothing changed when refused', function () { + [$workspace, $map] = npcRulesProject([ + ['id' => 'a', 'name' => 'Ann', 'x' => 2, 'y' => 1], + ['id' => 'b', 'name' => 'Bob', 'x' => 6, 'y' => 1], + ]); + $authoring = new NpcAuthoring($workspace); + $inspector = new NpcInspector($map); + + expect(fn() => $authoring->applyField($inspector, 0, [], npcRulesRow($inspector, 0, 'x'), '6')) + ->toThrow(NpcRefusal::class, 'Bob already stands at 6,1.') + ->and(fn() => $authoring->applyField($inspector, 0, [], npcRulesRow($inspector, 0, 'y'), '9')) + ->toThrow(NpcRefusal::class, '2,9 is outside the map.') + ->and($map->getNpcs()->get(0)?->getX())->toBe(2) + ->and($map->isDirty())->toBeFalse() + ->and(npcRulesRow($inspector, 0, 'x')['value'])->toBe('2'); + + expect($authoring->applyField($inspector, 0, [], npcRulesRow($inspector, 0, 'x'), '4')->npc?->getX())->toBe(4); +}); + +it('adds and removes dialogue variants, lines and script commands at a row', function () { + [$workspace, $map] = npcRulesProject([['id' => 'a', 'name' => 'Ann', 'x' => 2, 'y' => 1, 'dialogue' => [['text' => 'Hi.']]]]); + $authoring = new NpcAuthoring($workspace); + $inspector = new NpcInspector($map); + + $variant = $authoring->addSubItem($inspector, 0, [], ''); + expect($map->getNpcs()->get(0)?->getDialogueAsVariants())->toHaveCount(2) + ->and($variant->command?->label)->toBe('NPC add'); + + $authoring->addSubItem($inspector, 0, [], 'variant0Line0Text'); + expect($map->getNpcs()->get(0)?->getDialogueAsVariants()[0]['lines'])->toHaveCount(2); + + $command = $authoring->addSubItem($inspector, 0, ['script'], ''); + expect($map->getNpcs()->get(0)?->getScript())->toHaveCount(1) + ->and($inspector->getFields(0, ['script']))->not->toBeEmpty(); + + $authoring->removeSubItem($inspector, 0, ['script'], 'command0Type'); + $authoring->removeSubItem($inspector, 0, [], 'variant1'); + expect($map->getNpcs()->get(0)?->getScript())->toBe([]) + ->and($map->getNpcs()->get(0)?->getDialogueAsVariants())->toHaveCount(1) + ->and($authoring->removeSubItem($inspector, 0, [], 'name')->command)->toBeNull() + ->and(fn() => $authoring->addSubItem($inspector, 0, [3, 'then'], ''))->toThrow(NpcRefusal::class, 'is no longer there'); + + $command->command?->undo(); + expect($map->getNpcs()->get(0)?->getScript())->toBe([]); +}); + +it('groups an NPC\'s rows under headings and leaves a frame that no longer resolves to the caller', function () { + [, $map] = npcRulesProject([['id' => 'a', 'name' => 'Ann', 'x' => 2, 'y' => 1, 'mood' => 'stern', 'dialogue' => [ + ['conditions' => ['flag:met'], 'lines' => [['text' => 'Again?']]], + ]]]); + $inspector = new NpcInspector($map); + $labels = array_map(static fn(array $row): string => trim((string) $row['label']), $inspector->getFields(0) ?? []); + + expect($labels)->toContain('Identity', 'Placement', 'Movement', 'Preserved fields', 'Dialogue variant 1', 'Line 1 Text') + ->and($inspector->getFields(0, [5, 'then']))->toBeNull() + ->and($inspector->getFields(0, ['script']))->not->toBeNull(); +}); + +it('warns in the terminal Inspector when a coordinate edit would stack an NPC, recording nothing', function () { + [$workspace, $map] = npcRulesProject([ + ['id' => 'a', 'name' => 'Ann', 'x' => 2, 'y' => 1], + ['id' => 'b', 'name' => 'Bob', 'x' => 6, 'y' => 1], + ]); + $editor = createEditorForTesting($workspace->projectRoot); + setEditorProperty($editor, 'workspace', $workspace); + setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]); + setEditorProperty($editor, 'isRunning', true); + callEditorMethod($editor, 'setEditingMode', 'npc'); + callEditorMethod($editor, 'selectNpc', 0); + $x = array_find(callEditorMethod($editor, 'getInspectorFields'), static fn(array $row): bool => ($row['field'] ?? null) === 'x'); + + callEditorMethod($editor, 'applyNpcFieldValueRecorded', $x, '6'); + + expect($map->getNpcs()->get(0)?->getX())->toBe(2) + ->and(getEditorProperty($editor, 'statusMessage'))->toBe('Bob already stands at 6,1.') + ->and(getEditorProperty($editor, 'history')->count())->toBe(0); +}); From 39a4f0f0f124958cec528ce8a210c615397ae4d3 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 14:09:40 +0200 Subject: [PATCH 121/344] feat(session): author NPCs through the editor session The session serves NPC create, move, duplicate, delete and id assignment, reads one NPC's rows in any frame of its script, and applies a row edit or adds and removes a dialogue variant, line, command or route step by the key it gave. Each change is made against a current revision through the shared NPC authoring rules, recorded as one undo step, and refused with the author-readable reason, including what still names an NPC that cannot be deleted. The host answers npc.create, npc.move, npc.duplicate, npc.delete, npc.assignId, npc.read, npc.apply, npc.add and npc.remove. --- src/Session/EditorSession.php | 273 ++++++++++++++++++++++++++ src/Session/SessionHost.php | 41 ++++ tests/Unit/Session/NpcSessionTest.php | 176 +++++++++++++++++ 3 files changed, 490 insertions(+) create mode 100644 tests/Unit/Session/NpcSessionTest.php diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index e7c4197..2f04e87 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -12,6 +12,10 @@ use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Events\EventTypeCatalog; +use Ichiloto\Editor\Field\NpcAuthoring; +use Ichiloto\Editor\Field\NpcChange; +use Ichiloto\Editor\Field\NpcInspector; +use Ichiloto\Editor\Field\NpcRefusal; use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\CommandHistory; use Ichiloto\Editor\Inspector\InputControlType; @@ -412,6 +416,275 @@ public function readDatabaseRecord(string $category, int $index): array ]; } + /** + * Creates an NPC at a tile ({@see NpcAuthoring::create()}): fixed, its + * stable id derived from its name, a blank name taking the placeholder. + * + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, or the tile is outside it or taken. + */ + public function createNpc(string $mapId, int $revision, int $x, int $y, string $name): array + { + $map = $this->requireCurrentMap($mapId, $revision); + + return $this->applyNpcChange($map, fn(NpcAuthoring $authoring): NpcChange => $authoring->create($map, $x, $y, $name)); + } + + /** + * Moves an NPC's anchor to a tile; a move to where it stands changes nothing. + * + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, the NPC is unknown, or the tile is outside the map or taken. + */ + public function moveNpc(string $mapId, int $revision, int $index, int $x, int $y): array + { + $map = $this->requireCurrentMap($mapId, $revision); + + return $this->applyNpcChange($map, fn(NpcAuthoring $authoring): NpcChange => $authoring->move($map, $index, $x, $y)); + } + + /** + * Duplicates an NPC under a fresh id, appended; `index` is the copy's. + * + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, or the NPC is unknown. + */ + public function duplicateNpc(string $mapId, int $revision, int $index): array + { + $map = $this->requireCurrentMap($mapId, $revision); + + return $this->applyNpcChange($map, fn(NpcAuthoring $authoring): NpcChange => $authoring->duplicate($map, $index)); + } + + /** + * Deletes an NPC nothing names; `index` is null afterwards and `id` the deleted NPC's. + * + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, the NPC is unknown, or something names it (each on a line of its own). + */ + public function deleteNpc(string $mapId, int $revision, int $index): array + { + $map = $this->requireCurrentMap($mapId, $revision); + + return $this->applyNpcChange($map, fn(NpcAuthoring $authoring): NpcChange => $authoring->delete($map, $index)); + } + + /** + * Gives an NPC authored without a stable id one, from its name. + * + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, the NPC is unknown, or it already has an id. + */ + public function assignNpcId(string $mapId, int $revision, int $index): array + { + $map = $this->requireCurrentMap($mapId, $revision); + + return $this->applyNpcChange($map, fn(NpcAuthoring $authoring): NpcChange => $authoring->assignId($map, $index)); + } + + /** + * One NPC's rows in a frame of its commands, described as the + * inspector's are ({@see readInspector()}), with the `key` an edit names + * a row by. At the root (`frame` []) the rows are its fields under + * headings, with its identity notes and dialogue variants; a row that + * opens a script frame carries that `frame`, read again with it to edit + * the commands inside. Multi-line text carries `multiline` and its exact + * text as `value`. Visibility conditions and completion writes are + * `info` here: they are built in the terminal's own editors for now. + * + * @param array $frame The frame; [] for the NPC itself. + * @return array{map: string, revision: int, index: int, frame: list, frameLabel: ?string, npc: array, rows: list>} + * @throws SessionRefusal When the map, NPC or frame is unknown. + */ + public function readNpc(string $mapId, int $index, array $frame = []): array + { + $frame = self::requireNpcFrame($frame); + $map = $this->requireMap($mapId); + $inspector = new NpcInspector($map); + $npc = $map->getNpcs()->get($index) ?? throw new SessionRefusal(sprintf('%s has no NPC %d.', $mapId, $index)); + $fields = $this->collectNpcFields($inspector, $index, $frame); + + return [ + 'map' => $mapId, + 'revision' => $map->stateVersion(), + 'index' => $index, + 'frame' => $frame, + 'frameLabel' => $frame === [] ? null : $inspector->records()->describeFramePath($frame), + 'npc' => [ + 'index' => $index, + 'id' => $npc->getId(), + 'name' => $npc->getName(), + 'x' => $npc->getX(), + 'y' => $npc->getY(), + 'sprite' => $npc->getVisibleSprite(), + ], + 'rows' => array_map(static fn(array $field): array => self::describeNpcRow($field, $frame), $fields), + ]; + } + + /** + * Applies one NPC row's edit as one undo step. The row is found again by + * its key among the NPC's current rows. A rename carries the id along + * (`followedId`) unless something names it (`idReferences`). + * + * @param array $key The row's key, as `readNpc` gave it. + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, the row is gone or read-only, or the edit is refused. + */ + public function applyNpc(string $mapId, int $revision, int $index, array $key, string $value): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $inspector = new NpcInspector($map); + $frame = self::requireNpcFrame($key['frame'] ?? []); + $fieldId = $key['field'] ?? null; + // Field ids are unique within a frame, so the field and frame name the row. + $field = is_string($fieldId) ? array_find($this->collectNpcFields($inspector, $index, $frame), + static fn(array $candidate): bool => ($candidate['field'] ?? null) === $fieldId) : null; + + if ($field === null) { + throw new SessionRefusal('That row is no longer on the NPC; read it again.'); + } + if (self::describeNpcRow($field, $frame)['kind'] === 'info') { + throw new SessionRefusal(sprintf('%s cannot be edited here.', trim((string) ($field['label'] ?? 'That row')))); + } + + return $this->applyNpcChange($map, + static fn(NpcAuthoring $authoring): NpcChange => $authoring->applyField($inspector, $index, $frame, $field, $value)); + } + + /** + * Adds an item at an NPC row as one undo step: in a script frame a + * command after the row's (at the end when the key names no field) or a + * route step under its route; at the root a line in the row's dialogue + * variant, or a new variant. The key is a row's, or `{frame}` alone. + * + * @param array $key + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, or the NPC or frame is unknown. + */ + public function addNpcItem(string $mapId, int $revision, int $index, array $key): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $inspector = new NpcInspector($map); + $frame = self::requireNpcFrame($key['frame'] ?? []); + $fieldId = is_string($key['field'] ?? null) ? $key['field'] : ''; + + return $this->applyNpcChange($map, + static fn(NpcAuthoring $authoring): NpcChange => $authoring->addSubItem($inspector, $index, $frame, $fieldId)); + } + + /** + * Removes the item an NPC row belongs to (a route step, command, + * dialogue line or variant) as one undo step; a row that belongs to none + * changes nothing. + * + * @param array $key A row's key, as `readNpc` gave it. + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, or the NPC or frame is unknown. + */ + public function removeNpcItem(string $mapId, int $revision, int $index, array $key): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $inspector = new NpcInspector($map); + $frame = self::requireNpcFrame($key['frame'] ?? []); + $fieldId = is_string($key['field'] ?? null) ? $key['field'] : ''; + + return $this->applyNpcChange($map, + static fn(NpcAuthoring $authoring): NpcChange => $authoring->removeSubItem($inspector, $index, $frame, $fieldId)); + } + + /** + * Makes one NPC change, records it, and says what it did. + * + * @param callable(NpcAuthoring): NpcChange $change + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the change is refused or the map's source cannot take it. + */ + private function applyNpcChange(ProjectMap $map, callable $change): array + { + try { + $applied = $change(new NpcAuthoring($this->workspace)); + } catch (NpcRefusal $refusal) { + throw new SessionRefusal(implode("\n", [$refusal->getMessage(), ...$refusal->details]), previous: $refusal); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($applied->command !== null) { + $this->history->record($applied->command); + } + + return [ + 'revision' => $map->stateVersion(), + 'changed' => $applied->command !== null, + 'index' => $applied->index, + 'id' => $applied->npc?->getId(), + 'followedId' => $applied->followedId, + 'idReferences' => $applied->idReferences, + ]; + } + + /** + * An NPC's rows in a frame, refusing an NPC or frame that is gone. + * + * @param list $frame + * @return array> + * @throws SessionRefusal + */ + private function collectNpcFields(NpcInspector $inspector, int $index, array $frame): array + { + if ($inspector->map->getNpcs()->get($index) === null) { + throw new SessionRefusal(sprintf('%s has no NPC %d.', $inspector->map->mapId, $index)); + } + + return $inspector->getFields($index, $frame) + ?? throw new SessionRefusal(sprintf('%s is no longer there; read the NPC again.', $inspector->records()->describeFramePath($frame))); + } + + /** + * One NPC row as plain data: described as an inspector row, its key + * naming the frame it lives in. The id note is read here; assigning an + * id is its own request. + * + * @param array $field + * @param list $frame + * @return array + */ + private static function describeNpcRow(array $field, array $frame): array + { + $fieldId = $field['field'] ?? null; + $editable = is_string($fieldId) && $fieldId !== NpcInspector::ASSIGN_ID_FIELD && ($field['editable'] ?? true) !== false; + $row = self::describeRow([...$field, 'target' => $editable ? 'npc' : null]); + + if (isset($row['key'])) { + $row['key']['frame'] = $frame; + } + if (is_array($field['frame'] ?? null)) { + $row['frame'] = array_values($field['frame']); + } + $control = MapInspector::findControl($field); + if ($control?->type === InputControlType::MULTILINE) { + $row['multiline'] = true; + $row['value'] = $control->rawValue; + } + + return $row; + } + + /** + * A frame of an NPC's commands, as a request or a row key names it. + * + * @return list + * @throws SessionRefusal When the frame is not a list of indexes and keys. + */ + private static function requireNpcFrame(mixed $frame): array + { + if (! is_array($frame) || ! array_is_list($frame) || ! array_all($frame, static fn(mixed $segment): bool => is_int($segment) || is_string($segment))) { + throw new SessionRefusal('A frame must be a list of indexes and keys, as readNpc gave it.'); + } + + return $frame; + } + /** * Undoes the last change, wherever it was made. * diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 189855c..e3f9099 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -123,6 +123,47 @@ private function dispatch(string $method, array $params): array 'database.records' => $session->listDatabaseRecords(self::requireString($params, 'category')), 'database.record' => $session->readDatabaseRecord(self::requireString($params, 'category'), self::requireInt($params, 'index')), 'references.list' => $session->listReferences(self::requireString($params, 'map'), self::requireString($params, 'category')), + 'npc.create' => $session->createNpc( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireInt($params, 'x'), + self::requireInt($params, 'y'), + is_string($params['name'] ?? null) ? $params['name'] : '', + ), + 'npc.move' => $session->moveNpc( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireInt($params, 'index'), + self::requireInt($params, 'x'), + self::requireInt($params, 'y'), + ), + 'npc.duplicate' => $session->duplicateNpc(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireInt($params, 'index')), + 'npc.delete' => $session->deleteNpc(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireInt($params, 'index')), + 'npc.assignId' => $session->assignNpcId(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireInt($params, 'index')), + 'npc.read' => $session->readNpc( + self::requireString($params, 'map'), + self::requireInt($params, 'index'), + is_array($params['frame'] ?? []) ? ($params['frame'] ?? []) : throw new InvalidRequest('"frame" must be a list of indexes and keys.'), + ), + 'npc.apply' => $session->applyNpc( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireInt($params, 'index'), + is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key npc.read gave.'), + is_scalar($params['value'] ?? null) ? (string) $params['value'] : throw new InvalidRequest('"value" must be a string, number or boolean.'), + ), + 'npc.add' => $session->addNpcItem( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireInt($params, 'index'), + is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be a row key npc.read gave, or {"frame": [...]}.'), + ), + 'npc.remove' => $session->removeNpcItem( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireInt($params, 'index'), + is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key npc.read gave.'), + ), 'history.undo' => $session->undo(), 'history.redo' => $session->redo(), 'project.dirty' => ['dirty' => $session->hasUnsavedChanges()], diff --git a/tests/Unit/Session/NpcSessionTest.php b/tests/Unit/Session/NpcSessionTest.php new file mode 100644 index 0000000..eeeb757 --- /dev/null +++ b/tests/Unit/Session/NpcSessionTest.php @@ -0,0 +1,176 @@ + $npcs The npcs block. + */ +function npcSessionProject(array $npcs): string +{ + $root = makeTemporaryProject('ichiloto-npc-session-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['npcs'] = $npcs; + file_put_contents($path, "readMap('test-map')['npcs'], 'id'); +} + +/** The row with a field id in an NPC read. */ +function npcSessionRow(array $read, string $fieldId): array +{ + return array_find($read['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $fieldId) + ?? throw new RuntimeException("No NPC row {$fieldId}."); +} + +it('creates, moves, duplicates and deletes NPCs, each one undo step against a current revision', function () { + $session = EditorSession::open(npcSessionProject([['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1]])); + $revision = $session->readMap('test-map')['revision']; + + $created = $session->createNpc('test-map', $revision, 4, 2, 'Gate Guard'); + expect($created)->toMatchArray(['changed' => true, 'index' => 1, 'id' => 'gate-guard']) + ->and(fn() => $session->createNpc('test-map', $revision, 5, 2, 'Late'))->toThrow(SessionRefusal::class, 'changed since revision'); + + $moved = $session->moveNpc('test-map', $created['revision'], 1, 8, 3); + $copy = $session->duplicateNpc('test-map', $moved['revision'], 0); + expect(array_find($session->readMap('test-map')['npcs'], static fn(array $npc): bool => $npc['id'] === 'gate-guard')) + ->toMatchArray(['x' => 8, 'y' => 3]) + ->and($copy)->toMatchArray(['index' => 2, 'id' => 'ann-2']); + + $deleted = $session->deleteNpc('test-map', $copy['revision'], 0); + expect($deleted)->toMatchArray(['changed' => true, 'index' => null, 'id' => 'ann']) + ->and(npcSessionIds($session))->toBe(['gate-guard', 'ann-2']); + + expect($session->undo())->toBe(['label' => 'NPC delete', 'maps' => ['test-map']]) + ->and(npcSessionIds($session))->toBe(['ann', 'gate-guard', 'ann-2']); + $session->undo(); + $session->undo(); + expect($session->undo()['label'])->toBe('NPC create') + ->and(npcSessionIds($session))->toBe(['ann']); + expect($session->redo()['label'])->toBe('NPC create') + ->and(npcSessionIds($session))->toBe(['ann', 'gate-guard']); +}); + +it('refuses NPC changes the rules forbid, naming what stands in the way', function () { + $session = EditorSession::open(npcSessionProject([ + ['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1], + ['id' => 'bob', 'name' => 'Bob', 'x' => 6, 'y' => 3, 'script' => [ + ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'ann', 'steps' => [['direction' => 'north']]], + ]], + ])); + $revision = $session->readMap('test-map')['revision']; + + expect(fn() => $session->createNpc('test-map', $revision, 20, 1, 'Out'))->toThrow(SessionRefusal::class, '20,1 is outside the map.') + ->and(fn() => $session->createNpc('test-map', $revision, 2, 1, 'On Ann'))->toThrow(SessionRefusal::class, 'Ann already stands at 2,1.') + ->and(fn() => $session->moveNpc('test-map', $revision, 0, 6, 3))->toThrow(SessionRefusal::class, 'Bob already stands at 6,3.') + ->and(fn() => $session->duplicateNpc('test-map', $revision, 9))->toThrow(SessionRefusal::class, 'test-map has no NPC 9.') + ->and(fn() => $session->deleteNpc('test-map', $revision, 0)) + ->toThrow(SessionRefusal::class, "Ann is named by NPC Bob script - resolve those before deleting.\n- NPC Bob script") + ->and(fn() => $session->assignNpcId('test-map', $revision, 0))->toThrow(SessionRefusal::class, 'ids do not change') + ->and($session->moveNpc('test-map', $revision, 0, 2, 1))->toMatchArray(['changed' => false, 'revision' => $revision]) + ->and($session->hasUnsavedChanges())->toBeFalse() + ->and($session->undo()['label'])->toBeNull(); +}); + +it('reads an NPC\'s rows and edits one by its key, the id following a rename', function () { + $session = EditorSession::open(npcSessionProject([['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1]])); + $read = $session->readNpc('test-map', 0); + $name = npcSessionRow($read, 'name'); + + expect($read['npc'])->toMatchArray(['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1]) + ->and($name)->toMatchArray(['kind' => 'text', 'key' => ['target' => 'npc', 'field' => 'name', 'frame' => []]]) + ->and(npcSessionRow($read, 'x')['kind'])->toBe('integer') + ->and(npcSessionRow($read, 'movement'))->toMatchArray(['kind' => 'options', 'options' => ['fixed', 'wander']]) + ->and(npcSessionRow($read, 'id')['kind'])->toBe('info') + ->and(array_column($read['rows'], 'label'))->toContain('Identity') + ->and(npcSessionRow($read, 'commandListScript')['frame'])->toBe(['script']); + + $applied = $session->applyNpc('test-map', $read['revision'], 0, $name['key'], 'Annabel'); + expect($applied)->toMatchArray(['changed' => true, 'id' => 'annabel', 'followedId' => 'annabel', 'idReferences' => []]) + ->and(fn() => $session->applyNpc('test-map', $applied['revision'], 0, npcSessionRow($read, 'id')['key'], 'x')) + ->toThrow(SessionRefusal::class, 'Id cannot be edited here.') + ->and(fn() => $session->applyNpc('test-map', $applied['revision'], 0, ['field' => 'nope'], 'x')) + ->toThrow(SessionRefusal::class, 'no longer on the NPC') + ->and(fn() => $session->applyNpc('test-map', $applied['revision'], 0, npcSessionRow($read, 'x')['key'], '40')) + ->toThrow(SessionRefusal::class, '40,1 is outside the map.'); + + expect($session->undo()['label'])->toBe('NPC Name edit') + ->and($session->readNpc('test-map', 0)['npc'])->toMatchArray(['id' => 'ann', 'name' => 'Ann']); +}); + +it('adds and removes script commands inside a frame, and refuses a frame that is gone', function () { + $session = EditorSession::open(npcSessionProject([['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1]])); + $revision = $session->readMap('test-map')['revision']; + + $added = $session->addNpcItem('test-map', $revision, 0, ['frame' => ['script']]); + $script = $session->readNpc('test-map', 0, ['script']); + $type = npcSessionRow($script, 'command0Type'); + + expect($added['changed'])->toBeTrue() + ->and($script['frameLabel'])->toBe('Script') + ->and($type['key']['frame'])->toBe(['script']); + + $removed = $session->removeNpcItem('test-map', $added['revision'], 0, $type['key']); + expect($removed['changed'])->toBeTrue() + ->and($session->readNpc('test-map', 0, ['script'])['rows'][0]['kind'])->toBe('info') + ->and(fn() => $session->readNpc('test-map', 0, [4, 'then']))->toThrow(SessionRefusal::class, 'is no longer there') + ->and(fn() => $session->readNpc('test-map', 3))->toThrow(SessionRefusal::class, 'test-map has no NPC 3.') + ->and(fn() => $session->addNpcItem('test-map', $removed['revision'], 0, ['frame' => 'script'])) + ->toThrow(SessionRefusal::class, 'A frame must be a list'); + + expect($session->undo()['label'])->toBe('NPC remove') + ->and($session->undo()['label'])->toBe('NPC add') + ->and($session->readMap('test-map')['dirty'])->toBeFalse(); +}); + +it('serves NPC authoring over the line protocol', function () { + $root = npcSessionProject([['name' => 'Old Man', 'x' => 2, 'y' => 1]]); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(int $id, string $method, array $params = []): array => + $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params])); + $request(1, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + $revision = $request(2, 'map.read', ['map' => 'test-map'])['result']['revision']; + + $assigned = $request(3, 'npc.assignId', ['map' => 'test-map', 'revision' => $revision, 'index' => 0])['result']; + $created = $request(4, 'npc.create', ['map' => 'test-map', 'revision' => $assigned['revision'], 'x' => 5, 'y' => 2, 'name' => 'Guard'])['result']; + $moved = $request(5, 'npc.move', ['map' => 'test-map', 'revision' => $created['revision'], 'index' => 1, 'x' => 6, 'y' => 2])['result']; + $read = $request(6, 'npc.read', ['map' => 'test-map', 'index' => 1])['result']; + $applied = $request(7, 'npc.apply', ['map' => 'test-map', 'revision' => $moved['revision'], 'index' => 1, + 'key' => npcSessionRow($read, 'sprite')['key'], 'value' => 'G'])['result']; + $added = $request(8, 'npc.add', ['map' => 'test-map', 'revision' => $applied['revision'], 'index' => 1, 'key' => ['frame' => ['script']]])['result']; + $removed = $request(9, 'npc.remove', ['map' => 'test-map', 'revision' => $added['revision'], 'index' => 1, + 'key' => ['field' => 'command0Type', 'frame' => ['script']]])['result']; + $copy = $request(10, 'npc.duplicate', ['map' => 'test-map', 'revision' => $removed['revision'], 'index' => 1])['result']; + $deleted = $request(11, 'npc.delete', ['map' => 'test-map', 'revision' => $copy['revision'], 'index' => 2])['result']; + + expect($assigned['id'])->toBe('old-man') + ->and($created['id'])->toBe('guard') + ->and($moved['changed'])->toBeTrue() + ->and($applied['changed'])->toBeTrue() + ->and([$added['changed'], $removed['changed']])->toBe([true, true]) + ->and($copy['id'])->toBe('guard-2') + ->and($deleted['index'])->toBeNull() + ->and($request(12, 'npc.delete', ['map' => 'test-map', 'revision' => $copy['revision'], 'index' => 0])['error']['kind'])->toBe('refusal') + ->and($request(13, 'npc.read', ['map' => 'test-map', 'index' => 0, 'frame' => 'script'])['error']) + ->toBe(['kind' => 'request', 'message' => '"frame" must be a list of indexes and keys.']) + ->and($request(14, 'npc.apply', ['map' => 'test-map', 'revision' => $deleted['revision'], 'index' => 0, 'value' => 'x'])['error']['kind']) + ->toBe('request'); +}); From 592294b6d49b62f1f389feadb8b49cd6c35b4461 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 14:14:48 +0200 Subject: [PATCH 122/344] feat(events): author events and inspector flows through shared services Event authoring leaves the terminal shell for services every interface uses. EventAuthoring creates an event at cells with a type, retypes, moves, reshapes and deletes it, each changing the painted marker and the definition together as one undo step and changing nothing when refused. EventMarkers owns which glyphs may mark an event and which marker a new event takes; the playtest launch trigger now picks through it. MapInspector now owns the edits the terminal made in its dialogs: an event's type (chosen from the catalog's labels), chest and loot types (choices shared with the terminal's pickers), a transfer's destination and spawn point as one data write, list entries added and removed for event lists, encounters and music variants, music variant conditions as a strictly decoded line, and the map kind change with its tile clearing. The terminal keeps its dialogs, prompts and messages and calls these. Behaviour narrowed or corrected: - ProjectMap::setEventBounds refused nothing: it clamped to the map and overwrote other markers' cells. It now refuses an empty rectangle, one that leaves the map, or one that covers another marker, and changes nothing. Inspector position and size edits go through the same paths, so a negative or off-map position is refused instead of clamped. - Chest Type and Loot Type rows lost their unused text control; they are picked or stepped through their choices, as before. - A digit no longer marks a new event (PHP stores the key '5' as an integer, which the engine cannot read as a marker); the playtest trigger candidates drop digits and gain lowercase letters. - An inspector options row now refuses a value outside its options. --- src/Database/ConditionCodec.php | 29 ++ src/Editor.php | 518 +++------------------- src/Events/EventAuthoring.php | 263 +++++++++++ src/Events/EventMarkers.php | 71 +++ src/Events/EventRefusal.php | 15 + src/Events/EventTypeCatalog.php | 9 + src/Events/EventTypeDefinition.php | 22 + src/Inspector/InspectorListEdit.php | 19 + src/Inspector/MapInspector.php | 468 +++++++++++++++++-- src/Inspector/MapKindField.php | 29 +- src/Playtest/PlaytestOverlay.php | 14 +- src/ProjectMap.php | 43 +- tests/Unit/EventAuthoringTerminalTest.php | 106 +++++ tests/Unit/EventAuthoringTest.php | 195 ++++++++ 14 files changed, 1280 insertions(+), 521 deletions(-) create mode 100644 src/Events/EventAuthoring.php create mode 100644 src/Events/EventMarkers.php create mode 100644 src/Events/EventRefusal.php create mode 100644 src/Inspector/InspectorListEdit.php create mode 100644 tests/Unit/EventAuthoringTerminalTest.php create mode 100644 tests/Unit/EventAuthoringTest.php diff --git a/src/Database/ConditionCodec.php b/src/Database/ConditionCodec.php index 252f879..4b0914b 100644 --- a/src/Database/ConditionCodec.php +++ b/src/Database/ConditionCodec.php @@ -5,6 +5,7 @@ namespace Ichiloto\Editor\Database; use Ichiloto\Engine\Core\WorldConditionType; +use InvalidArgumentException; /** * Encodes and decodes the engine's world-condition arrays as a single @@ -110,6 +111,34 @@ public static function decodeAll(string $value): array return $conditions; } + /** + * Decodes the one-line form, refusing it whole when any segment cannot + * be read: for an interface that submits the line as typed, where a + * dropped segment would be a condition lost without a word. + * + * @param string $value The encoded conditions. + * @return array> + * @throws InvalidArgumentException Naming the first unreadable segment. + */ + public static function decodeAllStrictly(string $value): array + { + $conditions = []; + + foreach (explode(';', $value) as $segment) { + if (trim($segment) === '') { + continue; + } + + $conditions[] = self::decode($segment) ?? throw new InvalidArgumentException(sprintf( + 'Condition "%s" cannot be read: write [!]type:name[:extras] with a type of %s.', + trim($segment), + implode(', ', self::types()), + )); + } + + return $conditions; + } + /** * Decodes one `[!]type:name[:extras]` segment. * diff --git a/src/Editor.php b/src/Editor.php index 20be7a1..f2f8a77 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -52,6 +52,8 @@ use Ichiloto\Editor\Debug\Debug; use Ichiloto\Editor\Maps\LineInsertionPlan; use Ichiloto\Editor\Maps\MapLayers; +use Ichiloto\Editor\Events\EventAuthoring; +use Ichiloto\Editor\Events\EventRefusal; use Ichiloto\Editor\Events\EventTypeCatalog; use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\CommandHistory; @@ -59,6 +61,7 @@ use Ichiloto\Editor\History\PaintStrokeCommand; use Ichiloto\Editor\History\SourceSetCommand; use Ichiloto\Editor\Inspector\InputControl; +use Ichiloto\Editor\Inspector\InspectorListEdit; use Ichiloto\Editor\Inspector\InspectorRefusal; use Ichiloto\Editor\Inspector\MapInspector; use Ichiloto\Editor\Inspector\InputControlType; @@ -8289,27 +8292,24 @@ private function commitDestinationSpawnSelection(): void return; } - $marker = $context['marker']; - $destinationPath = $context['path']; - $newDestination = $context['destinationMapId']; - $newSpawnX = $this->cursorX; - $newSpawnY = $this->cursorY; - $oldDestination = $sourceMap->getEventField($marker, $destinationPath); - $oldSpawnX = $sourceMap->getEventField($marker, ['data', 'spawnPoint', 'x']); - $oldSpawnY = $sourceMap->getEventField($marker, ['data', 'spawnPoint', 'y']); + $destinationIndex = array_search($context['destinationMapId'], $this->workspace->mapIds, true); + $destinationMap = is_int($destinationIndex) ? $this->workspace->getMapByIndex($destinationIndex) : null; - $applyDestination = static function (mixed $destination, mixed $spawnX, mixed $spawnY) use ($sourceMap, $marker, $destinationPath): void { - $sourceMap->setEventField($marker, $destinationPath, $destination); - $sourceMap->setEventField($marker, ['data', 'spawnPoint', 'x'], $spawnX); - $sourceMap->setEventField($marker, ['data', 'spawnPoint', 'y'], $spawnY); - }; + if (! $destinationMap instanceof ProjectMap) { + $this->restoreDestinationSelectionContext('Unable to save destination selection.'); + return; + } - $applyDestination($newDestination, $newSpawnX, $newSpawnY); - $this->recordCommand(new GenericCommand( - 'Destination change', - static fn() => $applyDestination($newDestination, $newSpawnX, $newSpawnY), - static fn() => $applyDestination($oldDestination, $oldSpawnX, $oldSpawnY), - )); + try { + $command = $this->createMapInspector()->setTransferDestination($sourceMap, $context['marker'], $destinationMap, $this->cursorX, $this->cursorY); + } catch (InspectorRefusal $refusal) { + $this->restoreDestinationSelectionContext($refusal->getMessage()); + return; + } + + if ($command !== null) { + $this->recordCommand($command); + } $this->restoreDestinationSelectionContext( sprintf( @@ -8455,35 +8455,18 @@ private function applySelectedEventType(): void } $definition = EventTypeCatalog::at($this->selectedEventTypeIndex); - $currentDefinition = $selectedMap->getEventDefinition($marker); - $currentClassName = is_array($currentDefinition) ? (string) ($currentDefinition['class'] ?? '') : ''; - $newDefinition = $currentClassName === $definition->className && is_array($currentDefinition) - ? array_replace_recursive( - [ - 'class' => $definition->className, - 'data' => $definition->defaultData, - ...$definition->defaultDefinitionFields, - ], - $currentDefinition, - ) - : [ - 'class' => $definition->className, - 'data' => $definition->defaultData, - ...$definition->defaultDefinitionFields, - ]; - $selectedMap->setEventDefinition($marker, $newDefinition); - $this->recordCommand(new GenericCommand( - 'Event type change', - static fn() => $selectedMap->setEventDefinition($marker, $newDefinition), - static function () use ($selectedMap, $marker, $currentDefinition): void { - if (is_array($currentDefinition)) { - $selectedMap->setEventDefinition($marker, $currentDefinition); - return; - } - $selectedMap->removeEventDefinition($marker); - }, - )); + try { + $command = EventAuthoring::setEventType($selectedMap, $marker, $definition); + } catch (EventRefusal $refusal) { + $this->closeEventTypeDialog($refusal->getMessage()); + return; + } + + if ($command !== null) { + $this->recordCommand($command); + } + $this->clampInspectorSelection(); $this->closeEventTypeDialog(sprintf('%s is now a %s event.', $marker, $definition->label)); } @@ -13378,75 +13361,6 @@ private function selectedMapBgmVariantRow(): ?array ]; } - /** - * Appends a visibly incomplete music variant, when the cursor is on the - * variants list. - * - * @return bool True when the key meant this list. - */ - private function addMapBgmVariant(): bool - { - $context = $this->selectedMapBgmVariantRow(); - - if ($context === null) { - return false; - } - - $variants = $context['variants']; - - if (! $variants->isSupported()) { - $this->setStatus(sprintf('Music variants are read-only here: %s.', $variants->unsupportedReason()), StatusLevel::WARN); - $this->renderInspectorArea(); - - return true; - } - - $this->applyMapDataValue($context['map'], [MapBgmVariants::KEY], $variants->withVariantAdded(), 'Music variant add'); - $this->setStatus(sprintf('Added variant %d. Pick its track; it stays inactive until one is chosen.', $variants->count() + 1), StatusLevel::INFO); - $this->clampInspectorSelection(); - $this->renderSelectionDependentArea(); - - return true; - } - - /** - * Removes the music variant the cursor is inside; removing the last one - * takes the bgmVariants key with it. - * - * @return bool True when the key meant this list. - */ - private function removeMapBgmVariant(): bool - { - $context = $this->selectedMapBgmVariantRow(); - - if ($context === null) { - return false; - } - - $variants = $context['variants']; - - if (! $variants->isSupported()) { - $this->setStatus(sprintf('Music variants are read-only here: %s.', $variants->unsupportedReason()), StatusLevel::WARN); - $this->renderInspectorArea(); - - return true; - } - - if ($variants->count() === 0) { - $this->setStatus('There is no music variant here to remove.', StatusLevel::WARN); - $this->renderInspectorArea(); - - return true; - } - - $this->applyMapDataValue($context['map'], [MapBgmVariants::KEY], $variants->withVariantRemovedAt($context['index']), 'Music variant remove'); - $this->setStatus(sprintf('Removed variant %d.', $context['index'] + 1), StatusLevel::INFO); - $this->clampInspectorSelection(); - $this->renderSelectionDependentArea(); - - return true; - } - /** * Moves the music variant the cursor is inside up or down, because * declaration order is runtime behavior: the first match wins. @@ -14661,134 +14575,6 @@ private function buildLootDialogRows(array $entries, int $availableRows): array } - /** - * The encounter row the inspector cursor is on, if any. - * - * @return array{map: ProjectMap, encounters: MapEncounters, index: int}|null - */ - private function selectedMapEncounterRow(): ?array - { - $map = $this->getSelectedMap(); - $field = $this->getInspectorFields()[$this->selectedInspectorFieldIndex] ?? null; - - if (! $map instanceof ProjectMap || ! is_array($field) || ($field['target'] ?? null) !== 'map-encounters') { - return null; - } - - $list = $field['encounterList'] ?? null; - - if (! is_array($list)) { - return null; - } - - return ['map' => $map, 'encounters' => MapEncounters::fromMap($map), 'index' => (int) $list['index']]; - } - - /** - * Adds a troop row to the map's encounter table, enabling encounters - * when it is the first one. - * - * The new row names a troop the table does not already use, because a - * blank row would be a troop the engine reads as unnamed and a duplicate - * would collapse into one key. - * - * @return bool Whether the cursor was on the encounter list. - */ - private function addMapEncounterTroop(): bool - { - $context = $this->selectedMapEncounterRow(); - - if ($context === null) { - return false; - } - - $encounters = $context['encounters']; - - if (! $encounters->isSupported()) { - $this->setStatus(sprintf('Encounters are read-only here: %s.', $encounters->unsupportedReason()), StatusLevel::WARN); - $this->renderInspectorArea(); - - return true; - } - - $available = array_values(array_diff($this->referenceCatalog()->valuesFor('troops'), $encounters->troopNames())); - - if ($available === []) { - $this->setStatus( - $this->referenceCatalog()->valuesFor('troops') === [] - ? 'This project defines no troops to encounter.' - : 'Every troop this project defines is already in this table.', - StatusLevel::WARN, - ); - $this->renderInspectorArea(); - - return true; - } - - try { - $block = $encounters->withTroopAdded($available[0], $context['index']); - } catch (Throwable $throwable) { - $this->setErrorStatus($throwable, 'Encounter troop'); - $this->renderInspectorArea(); - - return true; - } - - $this->applyMapDataValue($context['map'], [MapEncounters::KEY], $block, 'Encounter troop add'); - $this->setStatus(sprintf('Added %s to the encounter table.', $available[0]), StatusLevel::INFO); - $this->clampInspectorSelection(); - $this->renderSelectionDependentArea(); - - return true; - } - - /** - * Removes the troop row the cursor is on. Removing the last one disables - * encounters and takes the empty block with it. - * - * @return bool Whether the cursor was on the encounter list. - */ - private function removeMapEncounterTroop(): bool - { - $context = $this->selectedMapEncounterRow(); - - if ($context === null) { - return false; - } - - $encounters = $context['encounters']; - - if (! $encounters->isSupported()) { - $this->setStatus(sprintf('Encounters are read-only here: %s.', $encounters->unsupportedReason()), StatusLevel::WARN); - $this->renderInspectorArea(); - - return true; - } - - $rows = $encounters->rows(); - - if (! array_key_exists($context['index'], $rows)) { - $this->setStatus('No encounter troop to remove.', StatusLevel::WARN); - $this->renderInspectorArea(); - - return true; - } - - $removed = $rows[$context['index']]['name']; - $block = $encounters->withTroopRemovedAt($context['index']); - $this->applyMapDataValue($context['map'], [MapEncounters::KEY], $block, 'Encounter troop remove'); - $this->setStatus( - $block === null - ? sprintf('Removed %s; this map no longer has random encounters.', $removed) - : sprintf('Removed %s from the encounter table.', $removed), - StatusLevel::INFO, - ); - $this->clampInspectorSelection(); - $this->renderSelectionDependentArea(); - - return true; - } - /** * Writes one value into the map's data as one undo step (see * {@see MapInspector::writeMapData()}); nothing is recorded when the data @@ -14805,174 +14591,78 @@ private function applyMapDataValue(ProjectMap $map, array $path, mixed $value, s } } /** - * Returns the list the inspector cursor is inside, if any. + * Adds an entry to the list the inspector cursor is inside + * ({@see MapInspector::addListEntry()}). * - * @return array{path: array, index: int}|null The list. + * @return void */ - private function selectedInspectorList(): ?array + private function addInspectorListItem(): void { - $fields = $this->getInspectorFields(); - $field = $fields[$this->selectedInspectorFieldIndex] ?? null; - - if (! is_array($field) || ($field['target'] ?? null) !== 'event') { - return null; - } - - $list = $field['list'] ?? null; - - return is_array($list) ? $list : null; + $this->editInspectorList( + static fn(MapInspector $inspector, ProjectMap $map, array $field): ?InspectorListEdit => $inspector->addListEntry($map, $field), + 'Nothing here is a list to add to.', + ); } /** - * Adds an entry to the list the inspector cursor is inside. - * - * The new entry is shaped like the one it follows -- the same keys, their - * values cleared -- because an author adding a second shop line means - * another line like the first, not an empty hole they have to describe. + * Removes the entry the inspector cursor is inside + * ({@see MapInspector::removeListEntry()}). * * @return void */ - private function addInspectorListItem(): void + private function removeInspectorListItem(): void { - if ($this->addMapEncounterTroop()) { - return; - } - - if ($this->addMapBgmVariant()) { - return; - } - - $selectedMap = $this->getSelectedMap(); - $list = $this->selectedInspectorList(); - $marker = $this->selectedEventMarkerForList(); - - if (! $selectedMap instanceof ProjectMap || $list === null || $marker === '') { - $this->setStatus('Nothing here is a list to add to.', StatusLevel::WARN); - - return; - } - - $entries = $selectedMap->getEventField($marker, $list['path']); - $entries = is_array($entries) ? array_values($entries) : []; - $template = $entries[$list['index']] ?? ($list['blank'] ?? ($entries === [] ? '' : end($entries))); - $position = min(count($entries), $list['index'] + 1); - - array_splice($entries, $position, 0, [self::blankLike($template)]); - - $this->applyInspectorListChange($selectedMap, $marker, $list['path'], $entries, 'Add list entry'); - $this->setStatus(sprintf('Added %s %d.', $this->describeListPath($list['path']), $position + 1), StatusLevel::INFO); + $this->editInspectorList( + static fn(MapInspector $inspector, ProjectMap $map, array $field): ?InspectorListEdit => $inspector->removeListEntry($map, $field), + 'Nothing here is a list entry to remove.', + ); } /** - * Removes the entry the inspector cursor is inside. + * Applies a list edit to the row the inspector cursor is on, recording + * it and saying what changed. * + * @param Closure(MapInspector, ProjectMap, array): ?InspectorListEdit $edit + * @param string $nothingHere What to say when there is no row to edit. * @return void */ - private function removeInspectorListItem(): void + private function editInspectorList(Closure $edit, string $nothingHere): void { - if ($this->removeMapEncounterTroop()) { - return; - } + $selectedMap = $this->getSelectedMap(); + $field = $this->getInspectorFields()[$this->selectedInspectorFieldIndex] ?? null; + + if (! $selectedMap instanceof ProjectMap || ! is_array($field)) { + $this->setStatus($nothingHere, StatusLevel::WARN); - if ($this->removeMapBgmVariant()) { return; } - $selectedMap = $this->getSelectedMap(); - $list = $this->selectedInspectorList(); - $marker = $this->selectedEventMarkerForList(); + try { + $result = $edit($this->createMapInspector(), $selectedMap, $field); + } catch (InspectorRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderInspectorArea(); - if (! $selectedMap instanceof ProjectMap || $list === null || $marker === '') { - $this->setStatus('Nothing here is a list entry to remove.', StatusLevel::WARN); + return; + } catch (Throwable $throwable) { + $this->setErrorStatus($throwable, 'List edit'); + $this->renderInspectorArea(); return; } - $entries = $selectedMap->getEventField($marker, $list['path']); - $entries = is_array($entries) ? array_values($entries) : []; - - if (! array_key_exists($list['index'], $entries)) { + if ($result === null) { return; } - array_splice($entries, $list['index'], 1); + if ($result->command !== null) { + $this->recordCommand($result->command); + } - $this->applyInspectorListChange($selectedMap, $marker, $list['path'], $entries, 'Remove list entry'); + $this->setStatus($result->summary, StatusLevel::INFO); $this->clampInspectorSelection(); - $this->setStatus(sprintf('Removed %s %d.', $this->describeListPath($list['path']), $list['index'] + 1), StatusLevel::INFO); - } - - /** - * Writes a changed list back, recording it so it can be undone. - * - * @param ProjectMap $map The map. - * @param string $marker The event marker. - * @param array $path The list's path. - * @param array $entries The list as it should be. - * @param string $label What to call the change. - * @return void - */ - private function applyInspectorListChange(ProjectMap $map, string $marker, array $path, array $entries, string $label): void - { - $previous = $map->getEventField($marker, $path); - $previous = is_array($previous) ? array_values($previous) : []; - - $map->setEventField($marker, $path, $entries); - $this->recordCommand(new GenericCommand( - $label, - static fn() => $map->setEventField($marker, $path, $entries), - static fn() => $map->setEventField($marker, $path, $previous), - )); $this->renderSelectionDependentArea(); } - - /** - * Returns the marker of the event the inspector cursor is in. - * - * @return string The marker, or an empty string. - */ - private function selectedEventMarkerForList(): string - { - $fields = $this->getInspectorFields(); - $field = $fields[$this->selectedInspectorFieldIndex] ?? null; - - return is_array($field) ? (string) ($field['marker'] ?? '') : ''; - } - - /** - * Returns an entry shaped like the given one with nothing filled in. - * - * @param mixed $template The entry to copy the shape of. - * @return mixed The blank entry. - */ - private static function blankLike(mixed $template): mixed - { - if (is_array($template)) { - return array_map(self::blankLike(...), $template); - } - - return match (true) { - is_int($template) => 0, - is_float($template) => 0.0, - is_bool($template) => false, - default => '', - }; - } - - /** - * Names a list for a status message. - * - * @param array $path The list's path. - * @return string The name, in the singular. - */ - private static function describeListPath(array $path): string - { - $leaf = (string) ($path[array_key_last($path)] ?? 'entry'); - $leaf = str_replace(['_', '-'], ' ', $leaf); - - return mb_strtolower(rtrim($leaf, 's')); - } - /** * The picker an event data row is chosen from (see * {@see MapInspector::findEventReference()}). @@ -15113,30 +14803,8 @@ private function isLootTypeField(array $field): bool private function getChestTypeOptionEntries(): array { - return [ - [ - 'label' => 'Common', - 'value' => ChestType::COMMON->value, - 'description' => 'Standard chest presentation for ordinary treasure.', - ], - [ - 'label' => 'Rare', - 'value' => ChestType::RARE->value, - 'description' => 'Highlights a chest that should feel less common.', - ], - [ - 'label' => 'Epic', - 'value' => ChestType::EPIC->value, - 'description' => 'Marks a chest carrying high-value treasure.', - ], - [ - 'label' => 'Legendary', - 'value' => ChestType::LEGENDARY->value, - 'description' => 'Reserved for the most special chest rewards.', - ], - ]; + return MapInspector::CHEST_TYPE_CHOICES; } - /** * Returns the editor-facing label for a loot type. * @@ -15160,50 +14828,8 @@ private function getLootTypeLabel(LootType $lootType, bool $plural = false): str private function getLootTypeOptionEntries(): array { - return [ - [ - 'label' => 'Item', - 'value' => LootType::ITEM->value, - 'description' => 'Rewards an item from the project item database.', - ], - [ - 'label' => 'Gold', - 'value' => LootType::GOLD->value, - 'description' => 'Awards currency directly when the chest is opened.', - ], - [ - 'label' => 'Experience', - 'value' => LootType::EXPERIENCE->value, - 'description' => 'Awards experience directly when claimed.', - ], - [ - 'label' => 'Skill', - 'value' => LootType::SKILL->value, - 'description' => 'Rewards a learnable skill identifier.', - ], - [ - 'label' => 'Spell', - 'value' => LootType::SPELL->value, - 'description' => 'Rewards a spell identifier.', - ], - [ - 'label' => 'Weapon', - 'value' => LootType::WEAPON->value, - 'description' => 'Rewards a weapon identifier.', - ], - [ - 'label' => 'Armor', - 'value' => LootType::ARMOR->value, - 'description' => 'Rewards an armor identifier.', - ], - [ - 'label' => 'Accessory', - 'value' => LootType::ACCESSORY->value, - 'description' => 'Rewards an accessory identifier.', - ], - ]; + return MapInspector::LOOT_TYPE_CHOICES; } - private function resolveEventOptionSelectionIndex(string $currentValue): int { foreach ($this->eventOptionDialogEntries as $index => $entry) { @@ -17867,7 +17493,7 @@ private function createInspectorWindow(): EditorWindow $this->isInspectorEditing => 'Enter:Apply Esc:Cancel', // Only where there is a list to act on: a hint for keys that // would answer "nothing here is a list" is worse than none. - $this->selectedInspectorList() !== null => $this->fitHelp( + MapInspector::findEventList($this->getInspectorFields()[$this->selectedInspectorFieldIndex] ?? []) !== null => $this->fitHelp( $layout['rightWidth'], 'Enter:Edit Shift+O:Add Shift+X/Del:Remove', 'Enter:Edit Shift+O:Add Del:Remove', diff --git a/src/Events/EventAuthoring.php b/src/Events/EventAuthoring.php new file mode 100644 index 0000000..d1137d8 --- /dev/null +++ b/src/Events/EventAuthoring.php @@ -0,0 +1,263 @@ + $cells The cells it triggers on, as [x, y]. + * @param string|null $marker The marker to give it; null takes the first free one ({@see EventMarkers}). + * @return array{marker: string, command: Command} + * @throws EventRefusal When the cells or marker cannot be used. + * @throws MapSourceRefusal When the map is read-only or its data cannot take the definition. + */ + public static function createEvent(ProjectMap $map, array $cells, EventTypeDefinition $type, ?string $marker = null): array + { + self::assertEditable($map); + + if ($cells === []) { + throw new EventRefusal('Choose at least one cell for the event.'); + } + + $used = EventMarkers::findUsedMarkers($map); + + if ($marker === null) { + $marker = EventMarkers::findFreeMarker($used) + ?? throw new EventRefusal(sprintf('%s has no free event marker left.', $map->mapId)); + } elseif (($reason = EventMarkers::findInvalidReason($marker)) !== null) { + throw new EventRefusal($reason); + } elseif (in_array($marker, $used, true)) { + throw new EventRefusal(sprintf('Marker %s is already an event on %s.', $marker, $map->mapId)); + } + + foreach ($cells as [$x, $y]) { + if (! $map->hasLayerCell(MapLayers::EVENT, $x, $y)) { + throw new EventRefusal(sprintf('(%d, %d) is outside %s.', $x, $y, $map->mapId)); + } + + $occupant = $map->getEventMarkerAt($x, $y); + + if ($occupant !== null) { + throw new EventRefusal(sprintf('Marker %s already holds (%d, %d).', $occupant, $x, $y)); + } + } + + $command = self::recordEdit($map, 'Event create', static function () use ($map, $cells, $type, $marker): void { + // The definition first: it is the half the data source can + // refuse, and refusing it leaves nothing painted. + $map->setEventDefinition($marker, $type->buildDefinition(null)); + + foreach ($cells as [$x, $y]) { + $map->setEventSymbol($x, $y, $marker); + } + }); + + return ['marker' => $marker, 'command' => $command ?? throw new EventRefusal('The event could not be placed.')]; + } + + /** + * Makes an event the given type ({@see EventTypeDefinition::buildDefinition()}). + * Null when it already was exactly that. + * + * @throws EventRefusal When the map has no such event. + * @throws MapSourceRefusal When the map's data cannot take the definition. + */ + public static function setEventType(ProjectMap $map, string $marker, EventTypeDefinition $type): ?Command + { + self::assertEditable($map); + self::assertEventExists($map, $marker); + $current = $map->getEventDefinition($marker); + $definition = $type->buildDefinition($current); + + if ($definition === $current) { + return null; + } + + $map->setEventDefinition($marker, $definition); + + return new GenericCommand( + 'Event type change', + static fn() => $map->setEventDefinition($marker, $definition), + static function () use ($map, $marker, $current): void { + if (is_array($current)) { + $map->setEventDefinition($marker, $current); + return; + } + + $map->removeEventDefinition($marker); + }, + ); + } + + /** + * Deletes an event: its cells cleared and its definition removed, as one + * undo step. A marker painted without a definition, or defined without + * cells, is deleted the same way. + * + * @throws EventRefusal When the map has no such event. + * @throws MapSourceRefusal When the map is read-only or its data cannot drop the definition. + */ + public static function deleteEvent(ProjectMap $map, string $marker): Command + { + self::assertEditable($map); + self::assertEventExists($map, $marker); + + return self::recordEdit($map, 'Event delete', static function () use ($map, $marker): void { + $map->removeEventDefinition($marker); + + foreach ($map->getEventArea($marker)?->cells ?? [] as [$x, $y]) { + $map->setEventSymbol($x, $y, ' '); + } + }) ?? throw new EventRefusal(sprintf('Event %s could not be deleted.', $marker)); + } + + /** + * Moves every cell of an event by an offset, keeping its shape. Null + * when the offset is nothing. + * + * @throws EventRefusal When a cell would leave the map or cover another event. + */ + public static function moveEvent(ProjectMap $map, string $marker, int $deltaX, int $deltaY, string $label = 'Event move'): ?Command + { + self::assertEditable($map); + + if ($deltaX === 0 && $deltaY === 0) { + return null; + } + + return self::recordEdit($map, $label, static function () use ($map, $marker, $deltaX, $deltaY): void { + $refusal = $map->moveEventCells($marker, $deltaX, $deltaY); + + if ($refusal !== null) { + throw new EventRefusal($refusal); + } + }); + } + + /** + * Repaints an event as exactly a rectangle. Null when it already was. + * + * @throws EventRefusal When the event is not placed, or the rectangle is empty, leaves the map or covers another event. + */ + public static function setEventBounds(ProjectMap $map, string $marker, int $x, int $y, int $width, int $height, + string $label = 'Event bounds edit'): ?Command + { + self::assertEditable($map); + + if ($map->getEventArea($marker) === null) { + throw new EventRefusal(sprintf('Marker %s is not placed.', $marker)); + } + + return self::recordEdit($map, $label, static function () use ($map, $marker, $x, $y, $width, $height): void { + $refusal = $map->setEventBounds($marker, $x, $y, $width, $height); + + if ($refusal !== null) { + throw new EventRefusal($refusal); + } + }); + } + + /** + * Refuses an edit to a map whose grids cannot be saved, before anything + * else is asked of it. + * + * @throws MapSourceRefusal + */ + private static function assertEditable(ProjectMap $map): void + { + if ($map->getGridSourceIssue() !== null) { + throw new MapSourceRefusal(sprintf('%s is read-only: %s', $map->mapId, $map->getGridSourceIssue())); + } + } + + /** @throws EventRefusal When the marker is neither placed nor defined. */ + private static function assertEventExists(ProjectMap $map, string $marker): void + { + if ($map->getEventDefinition($marker) === null && $map->getEventArea($marker) === null) { + throw new EventRefusal(sprintf('%s has no event %s.', $map->mapId, $marker)); + } + } + + /** + * Runs an edit and returns the command that redoes and undoes exactly + * what it changed: the event layer cells and the map's events. Null when + * it changed nothing. An edit that throws must have changed nothing. + * + * @param callable(): void $edit + */ + private static function recordEdit(ProjectMap $map, string $label, callable $edit): ?Command + { + $before = self::captureEventSymbols($map); + $hadEvents = $map->hasMapDataField(['events']); + $eventsBefore = $map->getMapDataField(['events']); + + $edit(); + + $cells = []; + foreach (self::captureEventSymbols($map) as $y => $row) { + foreach ($row as $x => $symbol) { + if ($symbol !== $before[$y][$x]) { + $cells[] = [$x, $y, $before[$y][$x], $symbol]; + } + } + } + $hasEvents = $map->hasMapDataField(['events']); + $eventsAfter = $map->getMapDataField(['events']); + + if ($cells === [] && $hasEvents === $hadEvents && $eventsAfter === $eventsBefore) { + return null; + } + + return new GenericCommand( + $label, + static function () use ($map, $cells, $hasEvents, $eventsAfter): void { + $map->setMapDataField(['events'], $hasEvents ? $eventsAfter : null); + foreach ($cells as [$x, $y, , $symbol]) { + $map->setEventSymbol($x, $y, $symbol); + } + }, + static function () use ($map, $cells, $hadEvents, $eventsBefore): void { + $map->setMapDataField(['events'], $hadEvents ? $eventsBefore : null); + foreach ($cells as [$x, $y, $symbol]) { + $map->setEventSymbol($x, $y, $symbol); + } + }, + ); + } + + /** @return list> The event layer's symbols, row by row. */ + private static function captureEventSymbols(ProjectMap $map): array + { + $rows = []; + for ($y = 0; $y < $map->getHeight(); $y++) { + $row = []; + for ($x = 0; $x < $map->getWidth(); $x++) { + $row[] = $map->getEventSymbol($x, $y); + } + $rows[] = $row; + } + + return $rows; + } +} diff --git a/src/Events/EventMarkers.php b/src/Events/EventMarkers.php new file mode 100644 index 0000000..3c243d4 --- /dev/null +++ b/src/Events/EventMarkers.php @@ -0,0 +1,71 @@ +ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + + /** Why a glyph cannot mark an event, or null when it can. */ + public static function findInvalidReason(string $marker): ?string + { + return match (true) { + trim($marker) === '' => 'An event marker cannot be blank.', + TerminalText::symbolCount($marker) !== 1 || TerminalText::displayWidth($marker) !== 1 + => sprintf('Event marker %s is not one terminal cell wide.', $marker), + ctype_digit($marker) => sprintf('Event marker %s is a digit, which the map data stores as a number the engine cannot read as a marker.', $marker), + default => null, + }; + } + + /** + * Every marker the map claims: painted on its event layer or defined in + * its data. + * + * @return list + */ + public static function findUsedMarkers(ProjectMap $map): array + { + return array_values(array_unique([...$map->getPlacedEventMarkers(), ...$map->getEventMarkers()])); + } + + /** + * The first candidate none of the used markers claims, or null when + * every one is taken. + * + * @param iterable $used + */ + public static function findFreeMarker(iterable $used): ?string + { + $taken = []; + foreach ($used as $marker) { + $taken[$marker] = true; + } + + foreach (str_split(self::CANDIDATES) as $candidate) { + if (! isset($taken[$candidate])) { + return $candidate; + } + } + + return null; + } +} diff --git a/src/Events/EventRefusal.php b/src/Events/EventRefusal.php new file mode 100644 index 0000000..a4b8d86 --- /dev/null +++ b/src/Events/EventRefusal.php @@ -0,0 +1,15 @@ + $definition->label === $label); + } + /** * Returns the index for the given trigger class. * diff --git a/src/Events/EventTypeDefinition.php b/src/Events/EventTypeDefinition.php index b067b92..6c681e3 100644 --- a/src/Events/EventTypeDefinition.php +++ b/src/Events/EventTypeDefinition.php @@ -22,4 +22,26 @@ public function __construct( public readonly array $defaultDefinitionFields = [], ) { } + + /** + * The definition an event of this type has. An event already of this + * type keeps everything it holds and gains any default it lacks; any + * other event starts again from the defaults, since another type's data + * means nothing to this one. + * + * @param array|null $current The event's definition now, if it has one. + * @return array + */ + public function buildDefinition(?array $current): array + { + $defaults = [ + 'class' => $this->className, + 'data' => $this->defaultData, + ...$this->defaultDefinitionFields, + ]; + + return is_array($current) && ($current['class'] ?? null) === $this->className + ? array_replace_recursive($defaults, $current) + : $defaults; + } } diff --git a/src/Inspector/InspectorListEdit.php b/src/Inspector/InspectorListEdit.php new file mode 100644 index 0000000..6de29be --- /dev/null +++ b/src/Inspector/InspectorListEdit.php @@ -0,0 +1,19 @@ + 'Common', 'value' => ChestType::COMMON->value, 'description' => 'Standard chest presentation for ordinary treasure.'], + ['label' => 'Rare', 'value' => ChestType::RARE->value, 'description' => 'Highlights a chest that should feel less common.'], + ['label' => 'Epic', 'value' => ChestType::EPIC->value, 'description' => 'Marks a chest carrying high-value treasure.'], + ['label' => 'Legendary', 'value' => ChestType::LEGENDARY->value, 'description' => 'Reserved for the most special chest rewards.'], + ]; + + /** What a chest event can give, as an author picks one. */ + public const array LOOT_TYPE_CHOICES = [ + ['label' => 'Item', 'value' => LootType::ITEM->value, 'description' => 'Rewards an item from the project item database.'], + ['label' => 'Gold', 'value' => LootType::GOLD->value, 'description' => 'Awards currency directly when the chest is opened.'], + ['label' => 'Experience', 'value' => LootType::EXPERIENCE->value, 'description' => 'Awards experience directly when claimed.'], + ['label' => 'Skill', 'value' => LootType::SKILL->value, 'description' => 'Rewards a learnable skill identifier.'], + ['label' => 'Spell', 'value' => LootType::SPELL->value, 'description' => 'Rewards a spell identifier.'], + ['label' => 'Weapon', 'value' => LootType::WEAPON->value, 'description' => 'Rewards a weapon identifier.'], + ['label' => 'Armor', 'value' => LootType::ARMOR->value, 'description' => 'Rewards an armor identifier.'], + ['label' => 'Accessory', 'value' => LootType::ACCESSORY->value, 'description' => 'Rewards an accessory identifier.'], + ]; + public function __construct(private ReferenceCatalog $references) { } @@ -120,6 +151,13 @@ public function getEventFields(ProjectMap $map, string $marker): array is_array($definition) && is_string($definition['class'] ?? null) ? $definition['class'] : null, ), 'editable' => true, + // Chosen, never stepped through: changing an event's type + // replaces data another type gave it. + 'choices' => array_map(static fn(EventTypeDefinition $type): array => [ + 'label' => $type->label, + 'value' => $type->label, + 'description' => $type->description, + ], EventTypeCatalog::all()), 'target' => 'event-type', 'marker' => $marker, ], @@ -170,13 +208,19 @@ public function getEventFields(ProjectMap $map, string $marker): array /** * Applies one row's edit to the map and returns the command that undoes * and redoes it, already applied; null when nothing changed or the row - * is not one an edit applies through (an event's type, the map's kind). + * is not one an edit applies through: the map's kind, which may first + * ask to clear tiles ({@see changeMapKind()}). * * @param array $field The row's descriptor. - * @throws InspectorRefusal When the edit cannot be made as asked. + * @throws InspectorRefusal When the edit cannot be made as asked, or the row is a transfer's + * destination, which is set with its spawn point ({@see setTransferDestination()}). */ public function apply(ProjectMap $map, array $field, string $rawValue): ?Command { + if (($field['destination'] ?? false) === true) { + throw new InspectorRefusal('A destination is set with its spawn point; choose both together.'); + } + $control = self::findControl($field); $type = $control?->type ?? InputControlType::TEXT; $value = match ($type) { @@ -185,6 +229,22 @@ public function apply(ProjectMap $map, array $field, string $rawValue): ?Command InputControlType::BOOLEAN => InputControl::parseBoolean($rawValue), default => $rawValue, }; + $choices = self::findChoiceValues($field); + + if ($control === null && $choices !== null && ! in_array($rawValue, $choices, true)) { + // A row picked from a list takes one of its choices, whatever + // interface sent the value. + throw new InspectorRefusal(sprintf('%s cannot be %s; choose one of: %s.', + trim((string) ($field['label'] ?? 'That row')), $rawValue, implode(', ', $choices))); + } + + if (($field['mapConditions'] ?? false) === true) { + try { + $value = ConditionCodec::decodeAllStrictly($rawValue); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + } return match ((string) ($field['target'] ?? 'map')) { 'map' => $this->applyMapField($map, (string) $field['field'], $value, (string) ($field['label'] ?? 'Field')), @@ -195,11 +255,236 @@ public function apply(ProjectMap $map, array $field, string $rawValue): ?Command 'map-bgm-variants' => $this->applyMapBgmVariantValue($map, $field, $value), 'map-size' => $this->applyMapSize($map, (string) ($field['field'] ?? ''), (int) $value), 'event' => $this->applyEventField($map, $field, $value), + 'event-type' => $this->applyEventType($map, (string) $field['marker'], $rawValue), 'event-bounds' => $this->applyEventBounds($map, (string) $field['marker'], (string) $field['field'], (int) $value), default => null, }; } + /** + * The values a row is picked from: its `choices`, or the `options` it + * steps through. Null for a row that is typed or referenced. + * + * @param array $field + * @return list|null + */ + public static function findChoiceValues(array $field): ?array + { + return match (true) { + is_array($field['choices'] ?? null) => array_values(array_map(static fn(array $choice): string => (string) $choice['value'], $field['choices'])), + is_array($field['options'] ?? null) => array_values(array_map('strval', $field['options'])), + default => null, + }; + } + + /** + * Sets a transfer event's destination: the map and the spawn point on + * it, as one undo step. + * + * @param ProjectMap $destination The map it leads to. + * @throws InspectorRefusal When the event is not a transfer or the spawn point is off the destination map. + */ + public function setTransferDestination(ProjectMap $map, string $marker, ProjectMap $destination, int $x, int $y): ?Command + { + $current = $map->getEventDefinition($marker); + + if (! is_array($current) || ! is_array($current['data'] ?? null) || ! array_key_exists('destinationMap', $current['data'])) { + throw new InspectorRefusal(sprintf('Event %s has no destination to set.', $marker)); + } + + if ($x < 0 || $y < 0 || $x >= $destination->getWidth() || $y >= $destination->getHeight()) { + throw new InspectorRefusal(sprintf('(%d, %d) is outside %s, which is %dx%d.', + $x, $y, $destination->mapId, $destination->getWidth(), $destination->getHeight())); + } + + $next = $current; + $next['data']['destinationMap'] = $destination->mapId; + $next['data']['spawnPoint'] = [...(is_array($current['data']['spawnPoint'] ?? null) ? $current['data']['spawnPoint'] : []), 'x' => $x, 'y' => $y]; + + if ($next === $current) { + return null; + } + + $map->setEventDefinition($marker, $next); + + return new GenericCommand( + 'Destination change', + static fn() => $map->setEventDefinition($marker, $next), + static fn() => $map->setEventDefinition($marker, $current), + ); + } + + /** + * Adds an entry to the list a row belongs to: a troop to the map's + * encounters, a music variant, or an entry like its neighbour to an + * event's list. Null when the row is in a list but nothing changed. + * + * @param array $field + * @throws InspectorRefusal When the row is in no list, or its list cannot take an entry. + */ + public function addListEntry(ProjectMap $map, array $field): ?InspectorListEdit + { + if (($field['target'] ?? null) === 'map-encounters' && is_array($field['encounterList'] ?? null)) { + $encounters = $this->requireEditableEncounters($map); + $troops = $this->references->valuesFor('troops'); + $available = array_values(array_diff($troops, $encounters->troopNames())); + + if ($available === []) { + throw new InspectorRefusal($troops === [] + ? 'This project defines no troops to encounter.' + : 'Every troop this project defines is already in this table.'); + } + + $block = $encounters->withTroopAdded($available[0], (int) $field['encounterList']['index']); + + return new InspectorListEdit($this->writeMapData($map, [MapEncounters::KEY], $block, 'Encounter troop add'), + sprintf('Added %s to the encounter table.', $available[0])); + } + + if (($field['target'] ?? null) === 'map-bgm-variants' && is_array($field['bgmVariantList'] ?? null)) { + $variants = $this->requireEditableBgmVariants($map); + + return new InspectorListEdit($this->writeMapData($map, [MapBgmVariants::KEY], $variants->withVariantAdded(), 'Music variant add'), + sprintf('Added variant %d. Pick its track; it stays inactive until one is chosen.', $variants->count() + 1)); + } + + $list = self::findEventList($field) ?? throw new InspectorRefusal('Nothing here is a list to add to.'); + $marker = (string) $field['marker']; + $entries = $map->getEventField($marker, $list['path']); + $entries = is_array($entries) ? array_values($entries) : []; + // The new entry is shaped like the one it follows -- the same keys, + // their values cleared -- because an author adding a second shop + // line means another line like the first, not an empty hole. + $template = $entries[$list['index']] ?? ($list['blank'] ?? ($entries === [] ? '' : end($entries))); + $position = min(count($entries), $list['index'] + 1); + array_splice($entries, $position, 0, [self::createBlankLike($template)]); + + return new InspectorListEdit($this->writeEventList($map, $marker, $list['path'], $entries, 'Add list entry'), + sprintf('Added %s %d.', self::describeListPath($list['path']), $position + 1)); + } + + /** + * Removes the entry a row belongs to. Removing the last troop disables + * encounters and the last music variant takes the variants key with it. + * Null when the row names no entry to remove. + * + * @param array $field + * @throws InspectorRefusal When the row is in no list, or its list cannot lose an entry. + */ + public function removeListEntry(ProjectMap $map, array $field): ?InspectorListEdit + { + if (($field['target'] ?? null) === 'map-encounters' && is_array($field['encounterList'] ?? null)) { + $encounters = $this->requireEditableEncounters($map); + $index = (int) $field['encounterList']['index']; + $rows = $encounters->rows(); + + if (! array_key_exists($index, $rows)) { + throw new InspectorRefusal('No encounter troop to remove.'); + } + + $block = $encounters->withTroopRemovedAt($index); + + return new InspectorListEdit($this->writeMapData($map, [MapEncounters::KEY], $block, 'Encounter troop remove'), $block === null + ? sprintf('Removed %s; this map no longer has random encounters.', $rows[$index]['name']) + : sprintf('Removed %s from the encounter table.', $rows[$index]['name'])); + } + + if (($field['target'] ?? null) === 'map-bgm-variants' && is_array($field['bgmVariantList'] ?? null)) { + $variants = $this->requireEditableBgmVariants($map); + $index = (int) $field['bgmVariantList']['index']; + + if ($variants->count() === 0) { + throw new InspectorRefusal('There is no music variant here to remove.'); + } + + return new InspectorListEdit($this->writeMapData($map, [MapBgmVariants::KEY], $variants->withVariantRemovedAt($index), 'Music variant remove'), + sprintf('Removed variant %d.', $index + 1)); + } + + $list = self::findEventList($field) ?? throw new InspectorRefusal('Nothing here is a list entry to remove.'); + $marker = (string) $field['marker']; + $entries = $map->getEventField($marker, $list['path']); + $entries = is_array($entries) ? array_values($entries) : []; + + if (! array_key_exists($list['index'], $entries)) { + return null; + } + + array_splice($entries, $list['index'], 1); + + return new InspectorListEdit($this->writeEventList($map, $marker, $list['path'], $entries, 'Remove list entry'), + sprintf('Removed %s %d.', self::describeListPath($list['path']), $list['index'] + 1)); + } + + /** + * How many tile layers changing the map to this kind would clear: none + * when the kind is unchanged or the map has no kind yet, since a map + * without one keeps its tiles, which then draw from the new kind. + */ + public function countTileLayersClearedBy(ProjectMap $map, string $tilesetId): int + { + $this->assertTilesetExists($tilesetId); + $current = $map->getMapDataField(['tileset']); + + return $current === null || $current === $tilesetId ? 0 : count($map->getTileLayerSources()); + } + + /** + * Sets the map's kind, clearing its tile layers and their settings when + * asked, as one undo step. Null when it already was that kind. + * + * @throws InspectorRefusal When the project has no tileset with the id. + * @throws Throwable When the map refuses the change; it is then exactly as it was. + */ + public function changeMapKind(ProjectMap $map, string $tilesetId, bool $clearTiles): ?Command + { + $this->assertTilesetExists($tilesetId); + + if ($map->getMapDataField(['tileset']) === $tilesetId) { + return null; + } + + $hadKind = $map->hasMapDataField(['tileset']); + $oldKind = $map->getMapDataField(['tileset']); + $oldSources = $map->getTileLayerSources(); + $hadSettings = $map->hasMapDataField([MapGraphics::SETTINGS_KEY]); + $oldSettings = $map->getMapDataField([MapGraphics::SETTINGS_KEY]); + $apply = static function () use ($map, $tilesetId, $clearTiles): void { + $map->setMapDataField(['tileset'], $tilesetId); + if ($clearTiles) { + $map->restoreTileLayerSources([]); + $map->setMapDataField([MapGraphics::SETTINGS_KEY], null); + } + }; + $revert = static function () use ($map, $hadKind, $oldKind, $oldSources, $hadSettings, $oldSettings): void { + $map->setMapDataField(['tileset'], $hadKind ? $oldKind : null); + $map->restoreTileLayerSources($oldSources); + $map->setMapDataField([MapGraphics::SETTINGS_KEY], $hadSettings ? $oldSettings : null); + }; + + try { + $apply(); + } catch (Throwable $failure) { + // A change refused after the kind was written is undone before + // it is reported, so the kind never changes without its tiles. + // Refused at the kind itself, nothing changed to undo. + if ($map->getMapDataField(['tileset']) === $tilesetId) { + $revert(); + } + throw $failure; + } + + return new GenericCommand('Kind change', $apply, $revert); + } + + /** @throws InspectorRefusal When the project has no tileset with the id. */ + private function assertTilesetExists(string $tilesetId): void + { + if (! array_key_exists($tilesetId, $this->references->labelsFor('tilesets'))) { + throw new InspectorRefusal(sprintf('There is no tileset %s in assets/%s.', $tilesetId, Tileset::DIRECTORY)); + } + } + /** * Writes one value into the map's data and returns its command, or null * when the data already held it. A null value removes the key. @@ -232,12 +517,7 @@ public function writeMapData(ProjectMap $map, array $path, mixed $value, string */ public function applyMapBgmVariantValue(ProjectMap $map, array $field, mixed $value): ?Command { - $variants = MapBgmVariants::fromMap($map); - - if (! $variants->isSupported()) { - throw new InspectorRefusal(sprintf('Music variants are read-only here: %s.', $variants->unsupportedReason())); - } - + $variants = $this->requireEditableBgmVariants($map); $index = (int) ($field['index'] ?? 0); $block = match ((string) ($field['field'] ?? '')) { 'track' => $variants->withTrackAt($index, (string) $value), @@ -384,9 +664,9 @@ private function applyEventField(ProjectMap $map, array $field, mixed $value): ? } /** - * Moves or resizes an event. A marker painted in another shape than a - * rectangle keeps its shape: its position moves every cell, and its cells - * are reshaped by painting them. + * Moves or resizes an event through {@see EventAuthoring}, as the canvas + * does. Its position moves every cell, keeping its shape; a marker + * painted in another shape than a rectangle is reshaped by painting it. * * @throws InspectorRefusal When the event cannot move or resize as asked. */ @@ -399,40 +679,119 @@ private function applyEventBounds(ProjectMap $map, string $marker, string $axis, } $bounds = $map->getEventBounds($marker); - $snapshotBefore = $map->captureGridSnapshot(); - if (! $area->isRectangle) { - if (! in_array($axis, ['x', 'y'], true)) { + try { + if (in_array($axis, ['x', 'y'], true)) { + $delta = $value - $bounds[$axis]; + + return EventAuthoring::moveEvent($map, $marker, $axis === 'x' ? $delta : 0, $axis === 'y' ? $delta : 0, 'Event bounds edit'); + } + + if (! $area->isRectangle) { throw new InspectorRefusal(sprintf('Marker %s is painted in its own shape; paint or erase its cells to reshape it.', $marker)); } - $delta = max(0, $value) - $bounds[$axis]; - $refusal = $map->moveEventCells($marker, $axis === 'x' ? $delta : 0, $axis === 'y' ? $delta : 0); + $bounds[$axis] = $value; - if ($refusal !== null) { - throw new InspectorRefusal($refusal); - } - } else { - $bounds[$axis] = max(0, $value); - $map->setEventBounds($marker, $bounds['x'], $bounds['y'], $bounds['width'], $bounds['height']); + return EventAuthoring::setEventBounds($map, $marker, $bounds['x'], $bounds['y'], $bounds['width'], $bounds['height']); + } catch (EventRefusal $refusal) { + throw new InspectorRefusal($refusal->getMessage(), previous: $refusal); } + } - $snapshotAfter = $map->captureGridSnapshot(); + /** + * Makes an event the type an author names by its label. + * + * @throws InspectorRefusal When no type has the label or the event cannot take it. + */ + private function applyEventType(ProjectMap $map, string $marker, string $label): ?Command + { + $type = EventTypeCatalog::findByLabel($label) ?? throw new InspectorRefusal(sprintf('There is no event type %s.', $label)); + + try { + return EventAuthoring::setEventType($map, $marker, $type); + } catch (EventRefusal $refusal) { + throw new InspectorRefusal($refusal->getMessage(), previous: $refusal); + } + } + + /** + * Writes an event's list back as one undo step; null when it already + * held exactly these entries. + * + * @param list $path The list's path. + * @param list $entries The list as it should be. + */ + private function writeEventList(ProjectMap $map, string $marker, array $path, array $entries, string $label): ?Command + { + $previous = $map->getEventField($marker, $path); + $previous = is_array($previous) ? array_values($previous) : []; + + if ($previous === $entries) { + return null; + } + + $map->setEventField($marker, $path, $entries); return new GenericCommand( - 'Event bounds edit', - static fn() => $map->restoreGridSnapshot($snapshotAfter), - static fn() => $map->restoreGridSnapshot($snapshotBefore), + $label, + static fn() => $map->setEventField($marker, $path, $entries), + static fn() => $map->setEventField($marker, $path, $previous), ); } /** - * Applies one encounter row's edit through the encounters model. + * The event list a row belongs to, when it is one an author edits. * * @param array $field - * @throws InspectorRefusal When the map's encounters cannot be edited here. + * @return array{path: list, index: int, blank?: mixed}|null */ - private function applyMapEncounterValue(ProjectMap $map, array $field, mixed $value): ?Command + public static function findEventList(array $field): ?array + { + $list = $field['list'] ?? null; + + if (($field['target'] ?? null) !== 'event' || ! is_string($field['marker'] ?? null) || $field['marker'] === '' || ! is_array($list)) { + return null; + } + + return [...$list, 'path' => array_values(array_map('strval', (array) $list['path'])), 'index' => (int) $list['index']]; + } + + /** + * An entry shaped like the given one with nothing filled in. + * + * @param mixed $template The entry to copy the shape of. + * @return mixed The blank entry. + */ + private static function createBlankLike(mixed $template): mixed + { + if (is_array($template)) { + return array_map(self::createBlankLike(...), $template); + } + + return match (true) { + is_int($template) => 0, + is_float($template) => 0.0, + is_bool($template) => false, + default => '', + }; + } + + /** + * Names a list in the singular, for what an author reads about it. + * + * @param array $path The list's path. + */ + private static function describeListPath(array $path): string + { + $leaf = (string) ($path[array_key_last($path)] ?? 'entry'); + $leaf = str_replace(['_', '-'], ' ', $leaf); + + return mb_strtolower(rtrim($leaf, 's')); + } + + /** @throws InspectorRefusal When the map's encounters cannot be edited here. */ + private function requireEditableEncounters(ProjectMap $map): MapEncounters { $encounters = MapEncounters::fromMap($map); @@ -440,6 +799,30 @@ private function applyMapEncounterValue(ProjectMap $map, array $field, mixed $va throw new InspectorRefusal(sprintf('Encounters are read-only here: %s.', $encounters->unsupportedReason())); } + return $encounters; + } + + /** @throws InspectorRefusal When the map's music variants cannot be edited here. */ + private function requireEditableBgmVariants(ProjectMap $map): MapBgmVariants + { + $variants = MapBgmVariants::fromMap($map); + + if (! $variants->isSupported()) { + throw new InspectorRefusal(sprintf('Music variants are read-only here: %s.', $variants->unsupportedReason())); + } + + return $variants; + } + + /** + * Applies one encounter row's edit through the encounters model. + * + * @param array $field + * @throws InspectorRefusal When the map's encounters cannot be edited here. + */ + private function applyMapEncounterValue(ProjectMap $map, array $field, mixed $value): ?Command + { + $encounters = $this->requireEditableEncounters($map); $index = (int) ($field['index'] ?? 0); $block = match ((string) ($field['field'] ?? '')) { 'rate' => $encounters->withRate((int) $value), @@ -633,6 +1016,7 @@ private function mapBgmVariantFields(ProjectMap $map, array $known): array 'bgmVariantList' => ['index' => $index], 'editable' => $issue === null, 'mapConditions' => $issue === null, + 'encoded' => $issue === null ? ConditionCodec::encodeAll($conditions) : null, ]; } @@ -695,6 +1079,24 @@ private function decorateEventInspectorFields(string $marker, array $fields): ar unset($field['control']); } + $choices = match ($path) { + ['data', 'chestType'] => self::CHEST_TYPE_CHOICES, + ['data', 'lootType'] => self::LOOT_TYPE_CHOICES, + default => null, + }; + + if ($choices !== null) { + // Picked or stepped through, never spelled. + $field['choices'] = $choices; + $field['options'] = array_column($choices, 'value'); + unset($field['control']); + } + + if ($path === ['data', 'destinationMap']) { + // Set with its spawn point, never alone ({@see setTransferDestination()}). + $field['destination'] = true; + } + $reference = self::findEventReference($field); if (is_array($reference)) { diff --git a/src/Inspector/MapKindField.php b/src/Inspector/MapKindField.php index bed5403..b00417b 100644 --- a/src/Inspector/MapKindField.php +++ b/src/Inspector/MapKindField.php @@ -4,10 +4,8 @@ namespace Ichiloto\Editor\Inspector; -use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\Status\StatusLevel; -use Ichiloto\Engine\Field\MapGraphics; use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Throwable; @@ -70,8 +68,8 @@ private function chooseMapKind(string $id, string $label): void $this->renderSelectionDependentArea(); return; } - $layers = count($map->getTileLayerSources()); - if ($current === null || $layers === 0) { + $layers = $this->createMapInspector()->countTileLayersClearedBy($map, $id); + if ($layers === 0) { $this->changeMapKind($map, $id, $label, false); return; } @@ -108,34 +106,21 @@ private function confirmMapKindChange(array $change, bool $confirmed): void /** * Sets the map's kind, clearing its tile layers and their settings when - * asked, as one undo step. + * asked, as one undo step ({@see MapInspector::changeMapKind()}). */ private function changeMapKind(ProjectMap $map, string $id, string $label, bool $clearTiles): void { - $hadKind = $map->hasMapDataField(['tileset']); - $oldKind = $map->getMapDataField(['tileset']); $oldSources = $map->getTileLayerSources(); - $hadSettings = $map->hasMapDataField([MapGraphics::SETTINGS_KEY]); - $oldSettings = $map->getMapDataField([MapGraphics::SETTINGS_KEY]); - $apply = static function () use ($map, $id, $clearTiles): void { - $map->setMapDataField(['tileset'], $id); - if ($clearTiles) { - $map->restoreTileLayerSources([]); - $map->setMapDataField([MapGraphics::SETTINGS_KEY], null); - } - }; try { - $apply(); + $command = $this->createMapInspector()->changeMapKind($map, $id, $clearTiles); } catch (Throwable $failure) { $this->setErrorStatus($failure, 'Kind change'); $this->renderSelectionDependentArea(); return; } - $this->recordCommand(new GenericCommand('Kind change', $apply, static function () use ($map, $hadKind, $oldKind, $oldSources, $hadSettings, $oldSettings): void { - $map->setMapDataField(['tileset'], $hadKind ? $oldKind : null); - $map->restoreTileLayerSources($oldSources); - $map->setMapDataField([MapGraphics::SETTINGS_KEY], $hadSettings ? $oldSettings : null); - })); + if ($command !== null) { + $this->recordCommand($command); + } $this->setStatus(sprintf('%s\'s kind is now %s%s; save to keep it.', $map->getDisplayName(), $label, $clearTiles ? sprintf(', and its %d tile %s cleared', count($oldSources), count($oldSources) === 1 ? 'layer is' : 'layers are') : ''), StatusLevel::INFO); diff --git a/src/Playtest/PlaytestOverlay.php b/src/Playtest/PlaytestOverlay.php index 7910284..57cb9ed 100644 --- a/src/Playtest/PlaytestOverlay.php +++ b/src/Playtest/PlaytestOverlay.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\Database\PhpDataFile; use Ichiloto\Editor\Database\PhpValueExporter; +use Ichiloto\Editor\Events\EventMarkers; use Ichiloto\Engine\Field\MapGridSource; use RuntimeException; use Throwable; @@ -239,22 +240,17 @@ private function writeCinematicLaunchFiles(string $sourceDirectory, string $targ } /** - * Picks a one-column marker the map does not use yet. + * Picks a marker the map does not use yet ({@see EventMarkers}): neither + * defined nor painted anywhere on its event layer. * * @param array $events * @param string[] $lines */ private static function freeEventMarker(array $events, array $lines): ?string { - $layer = implode('', $lines); + preg_match_all('/\X/u', implode('', $lines), $symbols); - foreach (str_split('@!$%&*+=?^{}|~<>ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') as $candidate) { - if (! array_key_exists($candidate, $events) && ! str_contains($layer, $candidate)) { - return $candidate; - } - } - - return null; + return EventMarkers::findFreeMarker([...array_map(strval(...), array_keys($events)), ...$symbols[0]]); } /** diff --git a/src/ProjectMap.php b/src/ProjectMap.php index d9ff39d..6eed6c6 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -1694,18 +1694,42 @@ public function removeEventDefinition(string $marker): void } /** - * Updates the rectangular bounds of an event marker. + * Repaints an event marker as exactly the given rectangle. + * + * Refused, leaving the grid unchanged, when the rectangle is empty, + * leaves the event layer or covers another marker's cell: the bounds an + * author asks for are the bounds the event gets, never a clamped or + * overwriting approximation of them. * * @param string $marker The event marker. * @param int $x The left coordinate. * @param int $y The top coordinate. * @param int $width The marker width. * @param int $height The marker height. - * @return void + * @return string|null Why the change was refused, or null when it was made. */ - public function setEventBounds(string $marker, int $x, int $y, int $width, int $height): void + public function setEventBounds(string $marker, int $x, int $y, int $width, int $height): ?string { $this->assertEditable(); + + if ($width < 1 || $height < 1) { + return sprintf('Marker %s needs a size of at least 1x1, not %dx%d.', $marker, $width, $height); + } + + for ($row = $y; $row < $y + $height; $row++) { + for ($column = $x; $column < $x + $width; $column++) { + if (! $this->hasLayerCell(MapLayers::EVENT, $column, $row)) { + return sprintf('Marker %s would leave the map at (%d, %d).', $marker, $column, $row); + } + + $occupant = $this->getEventSymbol($column, $row); + + if ($occupant !== $marker && trim($occupant) !== '') { + return sprintf('Marker %s would cover marker %s at (%d, %d).', $marker, $occupant, $column, $row); + } + } + } + foreach ($this->layers->getEventGrid()->getSymbols() as $rowIndex => $row) { foreach ($row as $columnIndex => $symbol) { if ($symbol === $marker) { @@ -1714,18 +1738,15 @@ public function setEventBounds(string $marker, int $x, int $y, int $width, int $ } } - $maxX = max(0, min($this->getWidth() - 1, $x + max(1, $width) - 1)); - $maxY = max(0, min($this->getHeight() - 1, $y + max(1, $height) - 1)); - - for ($row = max(0, $y); $row <= $maxY; $row++) { - for ($column = max(0, $x); $column <= $maxX; $column++) { - if ($this->hasLayerCell(MapLayers::EVENT, $column, $row)) { - $this->layers->getEventGrid()->cells[$row][$column]['symbol'] = $marker; - } + for ($row = $y; $row < $y + $height; $row++) { + for ($column = $x; $column < $x + $width; $column++) { + $this->layers->getEventGrid()->cells[$row][$column]['symbol'] = $marker; } } $this->touchState(); + + return null; } /** diff --git a/tests/Unit/EventAuthoringTerminalTest.php b/tests/Unit/EventAuthoringTerminalTest.php new file mode 100644 index 0000000..2718002 --- /dev/null +++ b/tests/Unit/EventAuthoringTerminalTest.php @@ -0,0 +1,106 @@ +maps[0]]; +} + +/** Puts the cursor on an event's first cell and returns the event's inspector rows by label. */ +function terminalEventRows(Editor $editor, ProjectMap $map, string $marker): array +{ + [$x, $y] = $map->getEventArea($marker)->cells[0]; + setEditorProperty($editor, 'cursorX', $x); + setEditorProperty($editor, 'cursorY', $y); + $fields = callEditorMethod($editor, 'getInspectorFields'); + $fields = array_slice($fields, (int) array_search('Event', array_column($fields, 'label'), true)); + $rows = []; + $section = ''; + foreach ($fields as $field) { + if (! str_starts_with((string) $field['label'], ' ')) { + $section = (string) $field['label']; + } + $rows[$section . '/' . trim((string) $field['label'])] = $field; + } + + return $rows; +} + +it('refuses an inspector move or resize onto another event instead of overwriting its cells', function () { + [$editor, $map] = terminalEventEditor(); + [$marker] = array_map(strval(...), array_keys($map->getEventDefinitions())); + $bounds = $map->getEventBounds($marker); + $neighbour = EventAuthoring::createEvent($map, [[$bounds['x'] + 1, $bounds['y']]], EventTypeCatalog::at(0))['marker']; + $rows = terminalEventRows($editor, $map, $marker); + + callEditorMethod($editor, 'applyInspectorFieldValue', $rows['Size/X'], '2'); + expect(getEditorProperty($editor, 'statusMessage'))->toContain("would cover marker {$neighbour}") + ->and($map->getEventArea($neighbour)?->cells)->toBe([[$bounds['x'] + 1, $bounds['y']]]); + + callEditorMethod($editor, 'applyInspectorFieldValue', $rows['Position/X'], (string) ($bounds['x'] + 1)); + expect(getEditorProperty($editor, 'statusMessage'))->toContain("would cover marker {$neighbour}") + ->and($map->getEventBounds($marker))->toBe($bounds); +}); + +it('refuses an inspector position off the map rather than clamping it onto the edge', function () { + [$editor, $map] = terminalEventEditor(); + [$marker] = array_map(strval(...), array_keys($map->getEventDefinitions())); + $bounds = $map->getEventBounds($marker); + + callEditorMethod($editor, 'applyInspectorFieldValue', terminalEventRows($editor, $map, $marker)['Position/X'], '-1'); + + expect(getEditorProperty($editor, 'statusMessage'))->toContain('would leave the map') + ->and($map->getEventBounds($marker))->toBe($bounds); +}); + +it('changes an event\'s type from the type dialog as one undo step', function () { + [$editor, $map] = terminalEventEditor(); + [$marker] = array_map(strval(...), array_keys($map->getEventDefinitions())); + $original = $map->getEventDefinition($marker); + + callEditorMethod($editor, 'openEventTypeDialog', $marker); + setEditorProperty($editor, 'selectedEventTypeIndex', array_search('Dialogue', array_map(static fn($type): string => $type->label, EventTypeCatalog::all()), true)); + callEditorMethod($editor, 'applySelectedEventType'); + + expect($map->getEventDefinition($marker))->toBe(EventTypeCatalog::findByLabel('Dialogue')->buildDefinition(null)) + ->and(getEditorProperty($editor, 'statusMessage'))->toBe("{$marker} is now a Dialogue event."); + + callEditorMethod($editor, 'performUndo'); + expect($map->getEventDefinition($marker))->toBe($original); +}); + +it('sets a transfer\'s destination and spawn point from the picker as one undo step', function () { + [$editor, $map] = terminalEventEditor(); + $marker = EventAuthoring::createEvent($map, [[1, $map->getHeight() - 1]], EventTypeCatalog::findByLabel('Transfer Player'))['marker']; + $before = $map->getEventDefinition($marker); + terminalEventRows($editor, $map, $marker); + + callEditorMethod($editor, 'openDestinationDialog', $marker, ['data', 'destinationMap'], ''); + callEditorMethod($editor, 'applySelectedDestination'); + setEditorProperty($editor, 'cursorX', 2); + setEditorProperty($editor, 'cursorY', 1); + callEditorMethod($editor, 'commitDestinationSpawnSelection'); + + expect($map->getEventField($marker, ['data', 'destinationMap']))->toBe($map->mapId) + ->and($map->getEventField($marker, ['data', 'spawnPoint']))->toBe(['x' => 2, 'y' => 1]) + ->and(getEditorProperty($editor, 'statusMessage'))->toBe("{$marker} destination set to {$map->mapId} (2, 1)."); + + callEditorMethod($editor, 'performUndo'); + expect($map->getEventDefinition($marker))->toBe($before); +}); diff --git a/tests/Unit/EventAuthoringTest.php b/tests/Unit/EventAuthoringTest.php new file mode 100644 index 0000000..c5270d0 --- /dev/null +++ b/tests/Unit/EventAuthoringTest.php @@ -0,0 +1,195 @@ +maps[array_search('test-map', $workspace->mapIds, true)]; +} + +/** Every cell of the event layer, so a refusal can be shown to change nothing. */ +function eventLayerOf(ProjectMap $map): array +{ + $rows = []; + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x < $map->getWidth(); $x++) { + $rows[$y][$x] = $map->getEventSymbol($x, $y); + } + } + + return [$rows, $map->getEventDefinitions()]; +} + +/** A free 2x1 run of event cells, found rather than assumed. */ +function freeEventCells(ProjectMap $map, int $count = 2): array +{ + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x + $count <= $map->getWidth(); $x++) { + $cells = array_map(static fn(int $offset): array => [$x + $offset, $y], range(0, $count - 1)); + if (array_all($cells, static fn(array $cell): bool => $map->getEventMarkerAt($cell[0], $cell[1]) === null)) { + return $cells; + } + } + } + + throw new RuntimeException('The fixture map has no free event cells.'); +} + +it('creates an event at cells with a type as one undo step, under a marker no event claims', function () { + $map = authoringMap(); + $before = eventLayerOf($map); + $cells = freeEventCells($map); + $dialogue = EventTypeCatalog::findByLabel('Dialogue'); + $expected = EventMarkers::findFreeMarker(EventMarkers::findUsedMarkers($map)); + + $created = EventAuthoring::createEvent($map, $cells, $dialogue); + $marker = $created['marker']; + + expect($marker)->toBe($expected) + ->and($map->getEventArea($marker)?->cells)->toBe($cells) + ->and($map->getEventDefinition($marker))->toBe($dialogue->buildDefinition(null)) + ->and($created['command']->label)->toBe('Event create'); + + $created['command']->undo(); + expect(eventLayerOf($map))->toBe($before) + ->and($map->isDirty())->toBeFalse(); + + $created['command']->execute(); + expect($map->getEventArea($marker)?->cells)->toBe($cells) + ->and($map->getEventDefinition($marker)['class'] ?? null)->toBe(DialogueEventTrigger::class); +}); + +it('refuses an event on cells another event holds, off the map, or under a marker that cannot be used, changing nothing', function () { + $map = authoringMap(); + [$placed] = array_keys($map->getEventDefinitions()); + [$x, $y] = $map->getEventArea((string) $placed)->cells[0]; + $type = EventTypeCatalog::at(0); + $before = eventLayerOf($map); + $revision = $map->stateVersion(); + + expect(fn() => EventAuthoring::createEvent($map, [[$x, $y]], $type))->toThrow(EventRefusal::class, "Marker {$placed} already holds") + ->and(fn() => EventAuthoring::createEvent($map, [[$map->getWidth(), 0]], $type))->toThrow(EventRefusal::class, 'is outside') + ->and(fn() => EventAuthoring::createEvent($map, [], $type))->toThrow(EventRefusal::class, 'at least one cell') + ->and(fn() => EventAuthoring::createEvent($map, freeEventCells($map), $type, (string) $placed))->toThrow(EventRefusal::class, 'already an event') + ->and(fn() => EventAuthoring::createEvent($map, freeEventCells($map), $type, '7'))->toThrow(EventRefusal::class, 'is a digit') + ->and(fn() => EventAuthoring::createEvent($map, freeEventCells($map), $type, 'AB'))->toThrow(EventRefusal::class, 'one terminal cell') + ->and(eventLayerOf($map))->toBe($before) + ->and($map->stateVersion())->toBe($revision); +}); + +it('treats a painted marker without a definition, and a definition without cells, as taken', function () { + $map = authoringMap(); + [$x, $y] = freeEventCells($map, 1)[0]; + $map->setEventSymbol($x, $y, '@'); + + expect(EventMarkers::findUsedMarkers($map))->toContain('@', ...array_map(strval(...), array_keys($map->getEventDefinitions()))) + ->and(EventMarkers::findFreeMarker(EventMarkers::findUsedMarkers($map)))->toBe('!') + ->and(EventMarkers::findFreeMarker(str_split(EventMarkers::CANDIDATES)))->toBeNull(); +}); + +it('retypes an event, keeping what it holds when the type is unchanged, and undoes to the old definition', function () { + $map = authoringMap(); + [$marker] = array_map(strval(...), array_keys($map->getEventDefinitions())); + $original = $map->getEventDefinition($marker); + $current = EventTypeCatalog::at(EventTypeCatalog::indexOfClass($original['class'])); + + $same = EventAuthoring::setEventType($map, $marker, $current); + expect($map->getEventDefinition($marker))->toBe($current->buildDefinition($original)) + ->and(array_intersect_key($map->getEventDefinition($marker)['data'], $original['data']))->toEqual($original['data']); + $same?->undo(); + expect($map->getEventDefinition($marker))->toBe($original); + + $dialogue = EventTypeCatalog::findByLabel('Dialogue'); + $command = EventAuthoring::setEventType($map, $marker, $dialogue); + expect($map->getEventDefinition($marker))->toBe($dialogue->buildDefinition(null)) + ->and(EventAuthoring::setEventType($map, $marker, $dialogue))->toBeNull(); + $command->undo(); + expect($map->getEventDefinition($marker))->toBe($original) + ->and(fn() => EventAuthoring::setEventType($map, 'Q', $dialogue))->toThrow(EventRefusal::class, 'has no event Q'); +}); + +it('deletes an event\'s cells and definition together and undoes both, back to the authored order', function () { + $map = authoringMap(); + $created = EventAuthoring::createEvent($map, freeEventCells($map), EventTypeCatalog::at(0)); + [$first] = array_map(strval(...), array_keys($map->getEventDefinitions())); + $before = eventLayerOf($map); + + $command = EventAuthoring::deleteEvent($map, $first); + expect($map->getEventDefinition($first))->toBeNull() + ->and($map->getEventArea($first))->toBeNull() + ->and($map->getEventDefinition($created['marker']))->not->toBeNull(); + + $command->undo(); + // The deleted event comes back where it was, ahead of the newer one. + expect(eventLayerOf($map))->toBe($before) + ->and(array_map(strval(...), array_keys($map->getEventDefinitions())))->toBe([$first, $created['marker']]); + + // A marker painted without a definition is deleted the same way. + [$x, $y] = freeEventCells($map, 1)[0]; + $map->setEventSymbol($x, $y, 'Q'); + EventAuthoring::deleteEvent($map, 'Q'); + expect($map->getEventMarkerAt($x, $y))->toBeNull() + ->and(fn() => EventAuthoring::deleteEvent($map, 'Q'))->toThrow(EventRefusal::class, 'has no event Q'); +}); + +it('moves and reshapes an event, refusing to leave the map or cover another event instead of clamping or overwriting', function () { + $map = authoringMap(); + [$marker] = array_map(strval(...), array_keys($map->getEventDefinitions())); + $bounds = $map->getEventBounds($marker); + // A second event directly to the right of the first. + $neighbour = EventAuthoring::createEvent($map, [[$bounds['x'] + $bounds['width'], $bounds['y']]], EventTypeCatalog::at(0))['marker']; + $before = eventLayerOf($map); + + expect(fn() => EventAuthoring::moveEvent($map, $marker, 1, 0))->toThrow(EventRefusal::class, "would cover marker {$neighbour}") + ->and(fn() => EventAuthoring::moveEvent($map, $marker, -($bounds['x'] + 1), 0))->toThrow(EventRefusal::class, 'would leave the map') + ->and(fn() => EventAuthoring::setEventBounds($map, $marker, $bounds['x'], $bounds['y'], $bounds['width'] + 1, 1)) + ->toThrow(EventRefusal::class, "would cover marker {$neighbour}") + ->and(fn() => EventAuthoring::setEventBounds($map, $marker, $bounds['x'], $bounds['y'], 1, $map->getHeight())) + ->toThrow(EventRefusal::class, 'would leave the map') + ->and(fn() => EventAuthoring::setEventBounds($map, $marker, $bounds['x'], $bounds['y'], 0, 1))->toThrow(EventRefusal::class, 'at least 1x1') + ->and(eventLayerOf($map))->toBe($before) + ->and(EventAuthoring::moveEvent($map, $marker, 0, 0))->toBeNull(); + + $move = EventAuthoring::moveEvent($map, $marker, 0, 1); + expect($map->getEventBounds($marker))->toBe([...$bounds, 'y' => $bounds['y'] + 1]) + ->and($move->label)->toBe('Event move'); + $move->undo(); + expect(eventLayerOf($map))->toBe($before); + + $grow = EventAuthoring::setEventBounds($map, $marker, $bounds['x'], $bounds['y'], 1, 2); + expect($map->getEventBounds($marker))->toBe([...$bounds, 'width' => 1, 'height' => 2]) + ->and($map->getEventArea($neighbour))->not->toBeNull(); + $grow->undo(); + expect(eventLayerOf($map))->toBe($before); +}); + +it('refuses every event edit on a read-only map before anything changes', function () { + $root = makeTemporaryProject('ichiloto-event-readonly-'); + file_put_contents($root . '/assets/Maps/test-map/test-map.map.php', "maps[0]; + $marker = '@'; + + expect($map->getGridSourceIssue())->not->toBeNull() + ->and(fn() => EventAuthoring::createEvent($map, [[0, 0]], EventTypeCatalog::at(0)))->toThrow(MapSourceRefusal::class, 'read-only') + ->and(fn() => EventAuthoring::deleteEvent($map, $marker))->toThrow(MapSourceRefusal::class, 'read-only') + ->and(fn() => EventAuthoring::moveEvent($map, $marker, 0, 1))->toThrow(MapSourceRefusal::class, 'read-only') + ->and($map->isDirty())->toBeFalse(); +}); From dd71a0a34ed3952465d33d8ad89ee6c36d16e52a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 14:15:42 +0200 Subject: [PATCH 123/344] feat(session): serve event authoring and the remaining inspector edits The session and its host now create, delete, move and reshape events, set a transfer's destination with its spawn point, add and remove list entries, and list the event types, each as one undo step against a current revision and refused, with the reason, when stale, read-only or overlapping. readMap lists every event the map holds, including markers painted without a definition, and says which have one (`defined`). The inspector rows the terminal edited only through its own flows are now served: an event's Type as options (the catalog's labels), chest and loot types with their labels, music variant conditions as their line (`conditions`), a transfer's destination (`destination`), and the map's kind as a tilesets reference, which asks before clearing tiles and takes the answer on the same edit. Rows inside lists say so (`list`), typed rows carry the value their edit starts from (`raw`), and a row's key is matched whatever order its members come back in. Narrowed: a transfer's destination row can no longer be applied as plain text through inspector.apply; it is set with its spawn point through event.destination. --- src/Session/EditorSession.php | 312 ++++++++++++++++-- src/Session/SessionHost.php | 67 ++++ tests/Unit/Session/EditorSessionTest.php | 4 +- .../Session/EventAuthoringSessionTest.php | 274 +++++++++++++++ 4 files changed, 630 insertions(+), 27 deletions(-) create mode 100644 tests/Unit/Session/EventAuthoringSessionTest.php diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index e7c4197..a129b68 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -11,10 +11,14 @@ use Ichiloto\Editor\Database\DatabaseCatalog; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\ReferenceCatalog; +use Ichiloto\Editor\Events\EventAuthoring; +use Ichiloto\Editor\Events\EventRefusal; use Ichiloto\Editor\Events\EventTypeCatalog; +use Ichiloto\Editor\Events\EventTypeDefinition; use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\CommandHistory; use Ichiloto\Editor\Inspector\InputControlType; +use Ichiloto\Editor\Inspector\InspectorListEdit; use Ichiloto\Editor\Inspector\InspectorRefusal; use Ichiloto\Editor\Inspector\MapInspector; use Ichiloto\Editor\MapSourceRefusal; @@ -25,6 +29,7 @@ use Ichiloto\Editor\Validation\MapValidator; use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; use Ichiloto\Engine\Rendering\Presentation\PresentationWorld; +use Closure; use InvalidArgumentException; /** @@ -135,12 +140,17 @@ public function readMap(string $mapId): array ]; } + // Every event the map holds: defined ones in their data order, then + // markers painted without a definition, which the engine refuses to + // load until they are given a type or cleared. $events = []; - foreach ($map->getEventDefinitions() as $marker => $definition) { - $area = $map->getEventArea((string) $marker); + foreach (array_unique([...$map->getEventMarkers(), ...$map->getPlacedEventMarkers()]) as $marker) { + $definition = $map->getEventDefinition($marker); + $area = $map->getEventArea($marker); $events[] = [ - 'marker' => (string) $marker, - 'type' => EventTypeCatalog::describeClass(is_array($definition) && is_string($definition['class'] ?? null) ? $definition['class'] : null), + 'marker' => $marker, + 'type' => EventTypeCatalog::describeClass(is_string($definition['class'] ?? null) ? $definition['class'] : null), + 'defined' => $definition !== null, 'cells' => $area === null ? [] : array_map(static fn(array $cell): array => [$cell[0], $cell[1]], $area->cells), ]; } @@ -290,10 +300,15 @@ public function paint(string $mapId, int $revision, string $layerId, array $cell * edited (`kind`) and carries the `key` an edit names it by. * * Kinds: `text`, `integer`, `float`, `boolean`, `options` (one of - * `options`), `reference` (one of `references.list` for its - * `reference`), or `info` for a row that is only read here: headings, - * counts, and the rows the terminal edits through flows of its own (the - * map's kind, an event's type, music variant conditions). + * `options`, read as the matching `optionLabels` when it has them), + * `reference` (one of `references.list` for its `reference`), + * `conditions` (the condition line, `type:name[:extras]` joined by `;`), + * `destination` (a map from `references.list` for `maps` and a spawn + * point on it, set together through `setEventDestination`), or `info` + * for a row that is only read here: headings and counts. A typed row's + * `raw` is the value its edit starts from. A row inside a list an author + * adds to and removes from carries `list` (its entry's index), and + * `addInspectorListEntry`/`removeInspectorListEntry` apply to it. * * @return array{map: string, revision: int, event: ?string, rows: list>} * @throws SessionRefusal When the map or event is unknown. @@ -318,34 +333,265 @@ public function readInspector(string $mapId, ?string $marker = null): array * again by its key among the map's current rows, so the edit applies * exactly as that row would in any interface. * + * Changing the map's kind when it has tiles from its current kind asks + * first: nothing changes until the edit is made again with `$answer` + * `clear` (clear its tile layers and change) or `cancel`. + * * @param array $key The row's key, as `readInspector` gave it. - * @return array{revision: int, changed: bool} + * @return array{status: 'applied', revision: int, changed: bool}|array{status: 'question', question: string, answers: list} * @throws SessionRefusal When the map is unknown or stale, the row is gone or read-only, or the edit is refused. */ - public function applyInspector(string $mapId, int $revision, array $key, string $value): array + public function applyInspector(string $mapId, int $revision, array $key, string $value, ?string $answer = null): array { $map = $this->requireCurrentMap($mapId, $revision); - $marker = is_string($key['marker'] ?? null) ? $key['marker'] : null; - $field = array_find($this->collectInspectorFields($map, $marker), - static fn(array $candidate): bool => self::describeKey($candidate) === $key); + $field = $this->requireInspectorField($map, $key); + $kind = self::describeRow($field)['kind']; - if ($field === null) { - throw new SessionRefusal('That row is no longer in the inspector; read it again.'); - } - if (self::describeRow($field)['kind'] === 'info') { + if ($kind === 'info') { throw new SessionRefusal(sprintf('%s cannot be edited here.', trim((string) ($field['label'] ?? 'That row')))); } + $inspector = $this->createMapInspector($map); + $cleared = 0; + if (($field['target'] ?? null) === 'map-kind') { + try { + $cleared = $inspector->countTileLayersClearedBy($map, $value); + } catch (InspectorRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($cleared > 0 && $answer === null) { + return $this->describeMapKindQuestion($map, $value, $cleared); + } + if ($answer !== null && ! in_array($answer, ['clear', 'cancel'], true)) { + throw new SessionRefusal(sprintf('Answer clear or cancel, not %s.', $answer)); + } + if ($answer === 'cancel') { + return ['status' => 'applied', 'revision' => $map->stateVersion(), 'changed' => false]; + } + } + + $command = $this->runEdit(static fn(): ?Command => ($field['target'] ?? null) === 'map-kind' + ? $inspector->changeMapKind($map, $value, $cleared > 0 && $answer === 'clear') + : $inspector->apply($map, $field, $value)); + + return ['status' => 'applied', 'revision' => $map->stateVersion(), 'changed' => $command !== null]; + } + + /** + * The event types an event can be, as `createEvent` and an event's Type + * row name them: by label. + * + * @return list + */ + public function listEventTypes(): array + { + return array_map(static fn(int $index, EventTypeDefinition $type): array => [ + 'index' => $index, + 'label' => $type->label, + 'description' => $type->description, + 'class' => $type->className, + ], array_keys(EventTypeCatalog::all()), EventTypeCatalog::all()); + } + + /** + * Places a new event of a type on cells as one undo step: its marker + * painted there and its definition written ({@see EventAuthoring}). + * + * @param list $cells The cells it triggers on, as [x, y]. + * @param string $type The type's label, as `listEventTypes` gives it. + * @param string|null $marker The marker to give it; null takes the first free one. + * @return array{marker: string, revision: int} + * @throws SessionRefusal When the map is unknown, stale or read-only, or the cells, type or marker cannot be used. + */ + public function createEvent(string $mapId, int $revision, array $cells, string $type, ?string $marker = null): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $definition = EventTypeCatalog::findByLabel($type) ?? throw new SessionRefusal(sprintf('There is no event type %s.', $type)); + $created = null; + $this->runEdit(static function () use ($map, $cells, $definition, $marker, &$created): Command { + $created = EventAuthoring::createEvent($map, $cells, $definition, $marker); + + return $created['command']; + }); + + return ['marker' => $created['marker'], 'revision' => $map->stateVersion()]; + } + + /** + * Deletes an event, its cells and its definition together, as one undo + * step. + * + * @return array{revision: int} + * @throws SessionRefusal When the map is unknown, stale or read-only, or has no such event. + */ + public function deleteEvent(string $mapId, int $revision, string $marker): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $this->runEdit(static fn(): Command => EventAuthoring::deleteEvent($map, $marker)); + + return ['revision' => $map->stateVersion()]; + } + + /** + * Moves every cell of an event by an offset, keeping its shape. + * + * @return array{revision: int, changed: bool} + * @throws SessionRefusal When the map is unknown, stale or read-only, or a cell would leave the map or cover another event. + */ + public function moveEvent(string $mapId, int $revision, string $marker, int $deltaX, int $deltaY): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $command = $this->runEdit(static fn(): ?Command => EventAuthoring::moveEvent($map, $marker, $deltaX, $deltaY)); + + return ['revision' => $map->stateVersion(), 'changed' => $command !== null]; + } + + /** + * Repaints an event as exactly a rectangle. + * + * @return array{revision: int, changed: bool} + * @throws SessionRefusal When the map is unknown, stale or read-only, or the rectangle is empty, leaves the map or covers another event. + */ + public function setEventBounds(string $mapId, int $revision, string $marker, int $x, int $y, int $width, int $height): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $command = $this->runEdit(static fn(): ?Command => EventAuthoring::setEventBounds($map, $marker, $x, $y, $width, $height)); + + return ['revision' => $map->stateVersion(), 'changed' => $command !== null]; + } + + /** + * Sets a transfer event's destination map and the spawn point on it as + * one undo step. + * + * @return array{revision: int, changed: bool} + * @throws SessionRefusal When either map is unknown, the map is stale or read-only, the event has no destination, or the spawn point is off the destination. + */ + public function setEventDestination(string $mapId, int $revision, string $marker, string $destinationMapId, int $x, int $y): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $destination = $this->requireMap($destinationMapId); + $command = $this->runEdit(fn(): ?Command => $this->createMapInspector($map)->setTransferDestination($map, $marker, $destination, $x, $y)); + + return ['revision' => $map->stateVersion(), 'changed' => $command !== null]; + } + + /** + * Adds an entry to the list an inspector row belongs to (a row with + * `list`), after that row's entry, as one undo step. + * + * @param array $key The row's key, as `readInspector` gave it. + * @return array{revision: int, changed: bool, message: string} + * @throws SessionRefusal When the map is unknown or stale, the row is gone or in no list, or the list cannot take an entry. + */ + public function addInspectorListEntry(string $mapId, int $revision, array $key): array + { + return $this->editInspectorList($mapId, $revision, $key, + static fn(MapInspector $inspector, ProjectMap $map, array $field): ?InspectorListEdit => $inspector->addListEntry($map, $field)); + } + + /** + * Removes the entry an inspector row belongs to, as one undo step. + * + * @param array $key The row's key, as `readInspector` gave it. + * @return array{revision: int, changed: bool, message: string} + * @throws SessionRefusal When the map is unknown or stale, the row is gone or in no list, or the list cannot lose the entry. + */ + public function removeInspectorListEntry(string $mapId, int $revision, array $key): array + { + return $this->editInspectorList($mapId, $revision, $key, + static fn(MapInspector $inspector, ProjectMap $map, array $field): ?InspectorListEdit => $inspector->removeListEntry($map, $field)); + } + + /** + * @param array $key + * @param Closure(MapInspector, ProjectMap, array): ?InspectorListEdit $edit + * @return array{revision: int, changed: bool, message: string} + */ + private function editInspectorList(string $mapId, int $revision, array $key, Closure $edit): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $field = $this->requireInspectorField($map, $key); + $inspector = $this->createMapInspector($map); + $result = null; + $this->runEdit(static function () use ($edit, $inspector, $map, $field, &$result): ?Command { + $result = $edit($inspector, $map, $field); + + return $result?->command; + }); + + return ['revision' => $map->stateVersion(), 'changed' => $result?->command !== null, 'message' => $result?->summary ?? 'Nothing changed.']; + } + + /** + * Runs one edit, records the command it returns, and turns any refusal + * into the session's. + * + * @param callable(): ?Command $edit + * @throws SessionRefusal + */ + private function runEdit(callable $edit): ?Command + { try { - $command = $this->createMapInspector($map)->apply($map, $field, $value); + $command = $edit(); } catch (InspectorRefusal $refusal) { throw new SessionRefusal(implode("\n", [$refusal->getMessage(), ...$refusal->details]), previous: $refusal); + } catch (EventRefusal|MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); } if ($command !== null) { $this->history->record($command); } - return ['revision' => $map->stateVersion(), 'changed' => $command !== null]; + return $command; + } + + /** + * The inspector row a key names, among the map's current rows. + * + * @param array $key + * @return array + * @throws SessionRefusal When no current row has the key. + */ + private function requireInspectorField(ProjectMap $map, array $key): array + { + $marker = is_string($key['marker'] ?? null) ? $key['marker'] : null; + + // A key comes back as the interface stored it, which may not keep + // its members in the order they were given. + ksort($key); + + return array_find($this->collectInspectorFields($map, $marker), static function (array $candidate) use ($key): bool { + $candidateKey = self::describeKey($candidate); + if ($candidateKey !== null) { + ksort($candidateKey); + } + + return $candidateKey === $key; + }) ?? throw new SessionRefusal('That row is no longer in the inspector; read it again.'); + } + + /** + * What to ask before a kind change clears a map's tiles. + * + * @return array{status: 'question', question: string, answers: list} + */ + private function describeMapKindQuestion(ProjectMap $map, string $tilesetId, int $layers): array + { + $labels = new ReferenceCatalog($this->workspace, $map)->labelsFor('tilesets'); + $current = (string) $map->getMapDataField(['tileset']); + $currentLabel = $labels[$current] ?? $current; + $label = $labels[$tilesetId] ?? $tilesetId; + + return [ + 'status' => 'question', + 'question' => sprintf('Change %s\'s kind to %s?', $map->getDisplayName(), $label), + 'answers' => [ + ['key' => 'cancel', 'label' => 'Cancel', 'description' => sprintf('Keep its kind, %s, and its tiles.', $currentLabel)], + ['key' => 'clear', 'label' => sprintf('Clear %d tile %s and change', $layers, $layers === 1 ? 'layer' : 'layers'), + 'description' => sprintf('Its tiles come from %s and would show the wrong art as %s. Glyphs stay. Undo restores them.', $currentLabel, $label)], + ], + ]; } /** @@ -507,12 +753,13 @@ private static function describeRow(array $field): array { $target = $field['target'] ?? null; $control = MapInspector::findControl($field); + $choices = MapInspector::findChoiceValues($field); $kind = match (true) { - ($field['editable'] ?? true) === false, $target === null, - in_array($target, ['map-kind', 'event-type'], true), - ($field['mapConditions'] ?? false) === true => 'info', + ($field['editable'] ?? true) === false, $target === null => 'info', + ($field['mapConditions'] ?? false) === true => 'conditions', + ($field['destination'] ?? false) === true => 'destination', is_string($field['reference'] ?? null) => 'reference', - is_array($field['options'] ?? null) => 'options', + $choices !== null => 'options', $control !== null => match ($control->type) { InputControlType::INTEGER => 'integer', InputControlType::FLOAT => 'float', @@ -521,13 +768,26 @@ private static function describeRow(array $field): array }, default => 'info', }; + $list = $field['list'] ?? $field['encounterList'] ?? $field['bgmVariantList'] ?? null; return array_filter([ 'label' => (string) ($field['label'] ?? ''), 'value' => (string) ($field['value'] ?? ''), + 'raw' => match ($kind) { + 'conditions' => is_string($field['encoded'] ?? null) ? $field['encoded'] : null, + 'text', 'integer', 'float', 'boolean' => $control?->rawValue, + default => null, + }, 'kind' => $kind, - 'options' => $kind === 'options' ? array_values(array_map('strval', $field['options'])) : null, - 'reference' => $kind === 'reference' ? $field['reference'] : null, + 'options' => $kind === 'options' ? $choices : null, + 'optionLabels' => $kind === 'options' && is_array($field['choices'] ?? null) + ? array_values(array_map(static fn(array $choice): string => (string) $choice['label'], $field['choices'])) : null, + 'reference' => match ($kind) { + 'reference' => $field['reference'], + 'destination' => 'maps', + default => null, + }, + 'list' => is_array($list) && $target !== null ? ['index' => (int) ($list['index'] ?? 0)] : null, 'key' => self::describeKey($field), ], static fn(mixed $value): bool => $value !== null); } @@ -551,6 +811,8 @@ private static function describeKey(array $field): ?array 'path' => isset($field['path']) ? array_values(array_map('strval', (array) $field['path'])) : null, 'marker' => isset($field['marker']) ? (string) $field['marker'] : null, 'index' => isset($field['index']) ? (int) $field['index'] : null, + // A list heading has no path of its own; its list's names it. + 'list' => is_array($field['list']['path'] ?? null) ? array_values(array_map('strval', $field['list']['path'])) : null, ], static fn(mixed $value): bool => $value !== null); } diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 189855c..3365935 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -119,6 +119,54 @@ private function dispatch(string $method, array $params): array self::requireInt($params, 'revision'), is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key the inspector gave.'), is_scalar($params['value'] ?? null) ? (string) $params['value'] : throw new InvalidRequest('"value" must be a string, number or boolean.'), + self::readOptionalString($params, 'answer'), + ), + 'inspector.add' => $session->addInspectorListEntry( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireKey($params), + ), + 'inspector.remove' => $session->removeInspectorListEntry( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireKey($params), + ), + 'event.types' => $session->listEventTypes(), + 'event.create' => $session->createEvent( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireCells($params), + self::requireString($params, 'type'), + self::readOptionalString($params, 'marker'), + ), + 'event.delete' => $session->deleteEvent( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'marker'), + ), + 'event.move' => $session->moveEvent( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'marker'), + self::requireInt($params, 'dx'), + self::requireInt($params, 'dy'), + ), + 'event.bounds' => $session->setEventBounds( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'marker'), + self::requireInt($params, 'x'), + self::requireInt($params, 'y'), + self::requireInt($params, 'width'), + self::requireInt($params, 'height'), + ), + 'event.destination' => $session->setEventDestination( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'marker'), + self::requireString($params, 'destination'), + self::requireInt($params, 'x'), + self::requireInt($params, 'y'), ), 'database.records' => $session->listDatabaseRecords(self::requireString($params, 'category')), 'database.record' => $session->readDatabaseRecord(self::requireString($params, 'category'), self::requireInt($params, 'index')), @@ -149,6 +197,25 @@ private static function requireInt(array $params, string $key): int return is_int($params[$key] ?? null) ? $params[$key] : throw new InvalidRequest(sprintf('"%s" must be an integer.', $key)); } + /** @param array $params */ + private static function readOptionalString(array $params, string $key): ?string + { + return match (true) { + ($params[$key] ?? null) === null => null, + is_string($params[$key]) => $params[$key], + default => throw new InvalidRequest(sprintf('"%s" must be a string when given.', $key)), + }; + } + + /** + * @param array $params + * @return array + */ + private static function requireKey(array $params): array + { + return is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key the inspector gave.'); + } + /** * @param array $params * @return list diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index 62b7dcd..4448a6d 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -140,7 +140,7 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a expect(inspectorRow($inspector, 'Name'))->toMatchArray(['kind' => 'text', 'key' => ['target' => 'map', 'field' => 'name']]) ->and(inspectorRow($inspector, 'X', 'Size')['kind'])->toBe('integer') ->and(inspectorRow($inspector, 'Events')['kind'])->toBe('info') - ->and(inspectorRow($inspector, 'Kind')['kind'])->toBe('info') + ->and(inspectorRow($inspector, 'Kind'))->toMatchArray(['kind' => 'reference', 'reference' => 'tilesets']) ->and(inspectorRow($inspector, 'Background Music'))->toMatchArray(['kind' => 'reference', 'reference' => 'bgm']) ->and($inspector['revision'])->toBe($session->readMap('test-map')['revision']); }); @@ -171,7 +171,7 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a $x = inspectorRow($event, 'X', 'Position'); $before = (int) $x['value']; - expect(inspectorRow($event, 'Type')['kind'])->toBe('info') + expect(inspectorRow($event, 'Type')['kind'])->toBe('options') ->and($x['key'])->toBe(['target' => 'event-bounds', 'field' => 'x', 'marker' => 'E']); $session->applyInspector('test-map', $event['revision'], $x['key'], (string) ($before + 1)); diff --git a/tests/Unit/Session/EventAuthoringSessionTest.php b/tests/Unit/Session/EventAuthoringSessionTest.php new file mode 100644 index 0000000..6e99e47 --- /dev/null +++ b/tests/Unit/Session/EventAuthoringSessionTest.php @@ -0,0 +1,274 @@ + array_slice($inspector['rows'], $start)], static fn(array $row): bool => trim($row['label']) === $label); +} + +/** The event a map read lists under a marker. */ +function eventSessionEvent(array $map, string $marker): ?array +{ + return array_find($map['events'], static fn(array $event): bool => $event['marker'] === $marker); +} + +/** Free event cells in one row of a map read, found rather than assumed. */ +function eventSessionFreeCells(array $map, int $count): array +{ + $layer = array_find($map['layers'], static fn(array $layer): bool => $layer['event']); + foreach ($layer['rows'] as $y => $row) { + for ($x = 0; $x + $count <= count($row); $x++) { + if (array_all(array_slice($row, $x, $count), static fn(string $symbol): bool => trim($symbol) === '')) { + return array_map(static fn(int $offset): array => [$x + $offset, $y], range(0, $count - 1)); + } + } + } + + throw new RuntimeException('No free event cells.'); +} + +it('lists every placed event, saying which has no definition yet', function () { + $session = EditorSession::open(makeTemporaryProject()); + $map = $session->readMap('test-map'); + [$cell] = eventSessionFreeCells($map, 1); + $session->paint('test-map', $map['revision'], 'event', [$cell], '@'); + + $read = $session->readMap('test-map'); + + expect(eventSessionEvent($read, '@'))->toBe(['marker' => '@', 'type' => 'Unset', 'defined' => false, 'cells' => [$cell]]) + ->and(array_filter($read['events'], static fn(array $event): bool => $event['marker'] !== '@')) + ->each->toHaveKey('defined', true); +}); + +it('creates, moves, reshapes and deletes an event over the protocol, one undo step each, refusing stale and overlapping edits', function () { + $root = makeTemporaryProject(); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params])); + $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + $map = $request('map.read', ['map' => 'test-map'])['result']; + $cells = eventSessionFreeCells($map, 2); + + expect(array_column($request('event.types')['result'], 'label')) + ->toBe(array_map(static fn($type): string => $type->label, EventTypeCatalog::all())); + + $created = $request('event.create', ['map' => 'test-map', 'revision' => $map['revision'], 'cells' => $cells, 'type' => 'Dialogue'])['result']; + $marker = $created['marker']; + $read = $request('map.read', ['map' => 'test-map'])['result']; + + expect(eventSessionEvent($read, $marker))->toBe(['marker' => $marker, 'type' => 'Dialogue', 'defined' => true, 'cells' => $cells]) + ->and($created['revision'])->toBe($read['revision']) + ->and($request('event.create', ['map' => 'test-map', 'revision' => $map['revision'], 'cells' => $cells, 'type' => 'Dialogue'])['error']['message']) + ->toContain('changed since revision') + ->and($request('event.create', ['map' => 'test-map', 'revision' => $read['revision'], 'cells' => $cells, 'type' => 'Dialogue'])['error']['message']) + ->toContain("Marker {$marker} already holds") + ->and($request('event.create', ['map' => 'test-map', 'revision' => $read['revision'], 'cells' => [[0, 0]], 'type' => 'Nope'])['error']) + ->toBe(['kind' => 'refusal', 'message' => 'There is no event type Nope.']); + + // Another event's cells are never covered: a move onto them is refused. + $other = array_find($read['events'], static fn(array $event): bool => $event['marker'] !== $marker); + [$ox, $oy] = $other['cells'][0]; + [$mx, $my] = $cells[0]; + expect($request('event.move', ['map' => 'test-map', 'revision' => $read['revision'], 'marker' => $marker, 'dx' => $ox - $mx, 'dy' => $oy - $my])['error']['message']) + ->toContain("would cover marker {$other['marker']}"); + + $height = $read['height']; + $moved = $request('event.move', ['map' => 'test-map', 'revision' => $read['revision'], 'marker' => $marker, 'dx' => 0, 'dy' => $my + 1 < $height ? 1 : -1])['result']; + expect($moved['changed'])->toBeTrue() + ->and($request('event.bounds', ['map' => 'test-map', 'revision' => $moved['revision'], 'marker' => $marker, + 'x' => 0, 'y' => 0, 'width' => $read['width'] + 1, 'height' => 1])['error']['message'])->toContain('would leave the map'); + + $deleted = $request('event.delete', ['map' => 'test-map', 'revision' => $moved['revision'], 'marker' => $marker])['result']; + expect(eventSessionEvent($request('map.read', ['map' => 'test-map'])['result'], $marker))->toBeNull() + ->and($deleted['revision'])->toBeInt(); + + expect($request('history.undo')['result']['label'])->toBe('Event delete') + ->and(eventSessionEvent($request('map.read', ['map' => 'test-map'])['result'], $marker)['type'])->toBe('Dialogue') + ->and($request('history.undo')['result']['label'])->toBe('Event move') + ->and($request('history.undo')['result']['label'])->toBe('Event create') + ->and(eventSessionEvent($request('map.read', ['map' => 'test-map'])['result'], $marker))->toBeNull() + ->and($request('history.redo')['result']['label'])->toBe('Event create') + ->and(eventSessionEvent($request('map.read', ['map' => 'test-map'])['result'], $marker)['cells'])->toBe($cells); +}); + +it('refuses event edits on a read-only map', function () { + $root = makeTemporaryProject(); + file_put_contents($root . '/assets/Maps/test-map/test-map.map.php', "readMap('test-map'); + + expect(fn() => $session->createEvent('test-map', $map['revision'], [[0, 0]], 'Dialogue'))->toThrow(SessionRefusal::class, 'read-only') + ->and(fn() => $session->deleteEvent('test-map', $map['revision'], '@'))->toThrow(SessionRefusal::class, 'read-only'); +}); + +it('retypes an event from its Type row, one of the catalog\'s labels, and picks chest and loot types from theirs', function () { + $session = EditorSession::open(makeTemporaryProject()); + $map = $session->readMap('test-map'); + $created = $session->createEvent('test-map', $map['revision'], eventSessionFreeCells($map, 1), 'Dialogue'); + $marker = $created['marker']; + $type = eventSessionRowLabelled($session->readInspector('test-map', $marker), 'Type'); + + expect($type)->toMatchArray(['kind' => 'options', 'value' => 'Dialogue']) + ->and($type['options'])->toBe(array_map(static fn($definition): string => $definition->label, EventTypeCatalog::all())) + ->and(fn() => $session->applyInspector('test-map', $created['revision'], $type['key'], 'Nope')) + ->toThrow(SessionRefusal::class, 'choose one of'); + + $applied = $session->applyInspector('test-map', $created['revision'], $type['key'], 'Chest'); + $chest = $session->readInspector('test-map', $marker); + $chestType = eventSessionRowLabelled($chest, 'ChestType'); + + expect($applied)->toMatchArray(['status' => 'applied', 'changed' => true]) + ->and(eventSessionEvent($session->readMap('test-map'), $marker)['type'])->toBe('Chest') + ->and($chestType['kind'])->toBe('options') + ->and($chestType['optionLabels'])->toContain('Common', 'Legendary') + ->and(eventSessionRowLabelled($chest, 'LootType')['options'])->toContain('item', 'gold') + ->and(fn() => $session->applyInspector('test-map', $chest['revision'], $chestType['key'], 'shiny')) + ->toThrow(SessionRefusal::class, 'choose one of'); + + $session->applyInspector('test-map', $chest['revision'], $chestType['key'], 'rare'); + expect(eventSessionRowLabelled($session->readInspector('test-map', $marker), 'ChestType')['value'])->toBe('rare') + ->and($session->undo()['label'])->toBe('ChestType edit') + ->and($session->undo()['label'])->toBe('Event type change') + ->and(eventSessionEvent($session->readMap('test-map'), $marker)['type'])->toBe('Dialogue'); +}); + +it('sets a transfer\'s destination map and spawn point together, as one undo step', function () { + $session = EditorSession::open(makeTemporaryProject()); + $map = $session->readMap('test-map'); + $created = $session->createEvent('test-map', $map['revision'], eventSessionFreeCells($map, 1), 'Transfer Player'); + $marker = $created['marker']; + $inspector = $session->readInspector('test-map', $marker); + $destination = eventSessionRowLabelled($inspector, 'DestinationMap'); + + expect($destination)->toMatchArray(['kind' => 'destination', 'reference' => 'maps']) + ->and(fn() => $session->applyInspector('test-map', $created['revision'], $destination['key'], 'test-map')) + ->toThrow(SessionRefusal::class, 'set with its spawn point') + ->and(fn() => $session->setEventDestination('test-map', $created['revision'], $marker, 'test-map', $map['width'], 0)) + ->toThrow(SessionRefusal::class, 'is outside test-map') + ->and(fn() => $session->setEventDestination('test-map', $created['revision'], $marker, 'nowhere', 0, 0)) + ->toThrow(SessionRefusal::class, 'There is no map nowhere.'); + + $set = $session->setEventDestination('test-map', $created['revision'], $marker, 'test-map', $map['width'] - 1, $map['height'] - 1); + $after = $session->readInspector('test-map', $marker); + + expect($set['changed'])->toBeTrue() + ->and(eventSessionRowLabelled($after, 'DestinationMap')['value'])->toBe('test-map') + ->and(eventSessionRowLabelled($after, 'X', 'SpawnPoint')['value'])->toBe((string) ($map['width'] - 1)) + ->and(eventSessionRowLabelled($after, 'Y', 'SpawnPoint')['value'])->toBe((string) ($map['height'] - 1)) + ->and($session->undo()['label'])->toBe('Destination change'); + + $undone = $session->readInspector('test-map', $marker); + expect(eventSessionRowLabelled($undone, 'DestinationMap')['value'])->toBe('') + ->and(eventSessionRowLabelled($undone, 'X', 'SpawnPoint')['value'])->toBe('0'); +}); + +it('adds and removes entries of an event\'s list, the map\'s encounters and its music variants', function () { + $session = EditorSession::open(metadataProject()); + $map = $session->readMap('test-map'); + $created = $session->createEvent('test-map', $map['revision'], eventSessionFreeCells($map, 1), 'Dialogue'); + $marker = $created['marker']; + $isDialogue = static fn(array $row): bool => ($row['key']['list'] ?? null) === ['data', 'dialogue']; + $heading = eventSessionRow($session->readInspector('test-map', $marker), $isDialogue); + + $added = $session->addInspectorListEntry('test-map', $created['revision'], $heading['key']); + $rows = array_filter($session->readInspector('test-map', $marker)['rows'], $isDialogue); + + expect($heading['list'])->toBe(['index' => 0]) + ->and($added)->toMatchArray(['changed' => true, 'message' => 'Added dialogue 2.']) + ->and(array_column($rows, 'list'))->toContain(['index' => 1]); + + $second = array_find($rows, static fn(array $row): bool => ($row['list'] ?? null) === ['index' => 1] && $row['kind'] === 'text'); + $removed = $session->removeInspectorListEntry('test-map', $added['revision'], $second['key']); + expect($removed['message'])->toBe('Removed dialogue 2.') + ->and(fn() => $session->addInspectorListEntry('test-map', $removed['revision'], eventSessionRowLabelled($session->readInspector('test-map'), 'Name')['key'])) + ->toThrow(SessionRefusal::class, 'Nothing here is a list to add to.'); + + // The map's encounter table: the first troop enables encounters. + $troops = eventSessionRow($session->readInspector('test-map'), static fn(array $row): bool => str_starts_with(trim($row['label']), 'Troops')); + $enabled = $session->addInspectorListEntry('test-map', $removed['revision'], $troops['key']); + $troop = eventSessionRowLabelled($session->readInspector('test-map'), 'Troop'); + expect($enabled['message'])->toStartWith('Added ') + ->and($troop['list'])->toBe(['index' => 0]); + $disabled = $session->removeInspectorListEntry('test-map', $enabled['revision'], $troop['key']); + expect($disabled['message'])->toContain('no longer has random encounters'); + + // Music variants, and a variant's conditions as the line that holds them. + $variants = eventSessionRowLabelled($session->readInspector('test-map'), 'Music Variants'); + $variant = $session->addInspectorListEntry('test-map', $disabled['revision'], $variants['key']); + $when = eventSessionRowLabelled($session->readInspector('test-map'), 'Variant 1 When'); + + expect($when)->toMatchArray(['kind' => 'conditions', 'list' => ['index' => 0]]) + ->and(fn() => $session->applyInspector('test-map', $variant['revision'], $when['key'], 'switch:door_open; nonsense')) + ->toThrow(SessionRefusal::class, 'Condition "nonsense" cannot be read'); + + $session->applyInspector('test-map', $variant['revision'], $when['key'], 'switch:door_open'); + expect(eventSessionRowLabelled($session->readInspector('test-map'), 'Variant 1 When')['raw'])->toBe('switch:door_open'); +}); + +it('asks before a kind change clears the map\'s tiles, and applies the answer as one undo step', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, 'cave'); + $session = EditorSession::open($root); + $kind = eventSessionRowLabelled($session->readInspector('test-map'), 'Kind'); + $revision = $session->readMap('test-map')['revision']; + $tileLayers = static fn(): array => array_values(array_filter(array_column($session->readWorld('test-map')['operations'][0]['value']['layers'], 'id'), + static fn(string $id): bool => str_starts_with($id, 'tiles:'))); + $before = $tileLayers(); + + $question = $session->applyInspector('test-map', $revision, $kind['key'], 'cave'); + + expect($kind)->toMatchArray(['kind' => 'reference', 'reference' => 'tilesets']) + ->and($question['status'])->toBe('question') + ->and(array_column($question['answers'], 'key'))->toBe(['cancel', 'clear']) + ->and($session->readMap('test-map')['revision'])->toBe($revision) + ->and($session->applyInspector('test-map', $revision, $kind['key'], 'cave', 'cancel')) + ->toBe(['status' => 'applied', 'revision' => $revision, 'changed' => false]) + ->and(fn() => $session->applyInspector('test-map', $revision, $kind['key'], 'nope'))->toThrow(SessionRefusal::class, 'no tileset nope') + ->and($before)->not->toBe([]); + + // The key as an interface may store it: members in another order. + $changed = $session->applyInspector('test-map', $revision, array_reverse($kind['key'], true), 'cave', 'clear'); + expect($changed['changed'])->toBeTrue() + ->and(eventSessionRowLabelled($session->readInspector('test-map'), 'Kind')['value'])->toBe('Cave') + ->and($tileLayers())->toBe([]) + ->and($session->undo()['label'])->toBe('Kind change') + ->and($tileLayers())->toBe($before); +}); + +it('gives a map without a kind one without asking, keeping its tiles whatever the answer', function () { + $root = mapGraphicsProject(); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace("'tileset' => 'home',", '', (string) file_get_contents($data))); + $session = EditorSession::open($root); + $kind = eventSessionRowLabelled($session->readInspector('test-map'), 'Kind'); + $layers = static fn(): array => array_values(array_filter(array_column($session->readWorld('test-map')['operations'][0]['value']['layers'], 'id'), + static fn(string $id): bool => str_starts_with($id, 'tiles:'))); + + $applied = $session->applyInspector('test-map', $session->readMap('test-map')['revision'], $kind['key'], 'home', 'clear'); + + expect($applied)->toMatchArray(['status' => 'applied', 'changed' => true]) + ->and($layers())->not->toBe([]); +}); From 851083c64dea83a864c43db956e3debcbdf48d8e Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 14:19:33 +0200 Subject: [PATCH 124/344] feat(session): report each changed map's revision after undo and redo history.undo and history.redo now also return revisions, by map id, so an interface sending edits in turn names the current revision without reading the map first. --- src/Session/EditorSession.php | 7 ++++--- tests/Unit/Session/EditorSessionTest.php | 9 ++++++--- tests/Unit/Session/LayerSessionTest.php | 2 +- tests/Unit/Session/NpcSessionTest.php | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 9977443..48aa5f3 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1241,7 +1241,7 @@ private static function requireNpcFrame(mixed $frame): array /** * Undoes the last change, wherever it was made. * - * @return array{label: ?string, maps: list} What was undone and the maps it changed. + * @return array{label: ?string, maps: list, revisions: array} What was undone, the maps it changed and their revisions now. */ public function undo(): array { @@ -1300,14 +1300,15 @@ private function traverseHistory(callable $step): array { $before = array_map(static fn(ProjectMap $map): int => $map->stateVersion(), $this->workspace->maps); $command = $step(); - $changed = []; + $changed = $revisions = []; foreach ($this->workspace->maps as $index => $map) { if ($map->stateVersion() !== $before[$index]) { $changed[] = $map->mapId; + $revisions[$map->mapId] = $map->stateVersion(); } } - return ['label' => $command?->label, 'maps' => $changed]; + return ['label' => $command?->label, 'maps' => $changed, 'revisions' => $revisions]; } /** @return array> */ diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index 8d83418..660ad78 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -63,12 +63,15 @@ function sessionCell(array $map, string $layer, int $x, int $y): string expect(fn() => $session->paint('test-map', $map['revision'], $layer, [[4, 2]], '%')) ->toThrow(SessionRefusal::class, 'changed since revision'); - expect($session->undo())->toBe(['label' => 'Paint', 'maps' => ['test-map']]) + expect($session->undo())->toMatchArray(['label' => 'Paint', 'maps' => ['test-map']]) ->and(sessionCell($session->readMap('test-map'), $layer, 2, 2))->toBe($before); - expect($session->redo()['maps'])->toBe(['test-map']) + $redone = $session->redo(); + // Each changed map's revision comes back, so the next edit can name it. + expect($redone['maps'])->toBe(['test-map']) + ->and($redone['revisions'])->toBe(['test-map' => $session->readMap('test-map')['revision']]) ->and(sessionCell($session->readMap('test-map'), $layer, 2, 2))->toBe('%'); expect($session->undo()['label'])->toBe('Paint') - ->and($session->undo())->toBe(['label' => null, 'maps' => []]); + ->and($session->undo())->toMatchArray(['label' => null, 'maps' => []]); }); it('saves a painted map through its transaction, so a fresh load sees the edit', function () { diff --git a/tests/Unit/Session/LayerSessionTest.php b/tests/Unit/Session/LayerSessionTest.php index 579b6e8..3183417 100644 --- a/tests/Unit/Session/LayerSessionTest.php +++ b/tests/Unit/Session/LayerSessionTest.php @@ -57,7 +57,7 @@ function layerSessionHost(string $root): callable expect($applied)->toMatchArray(['status' => 'applied', 'changed' => true, 'layer' => 'map:4']) ->and($layer['decoration'])->toBeTrue() ->and(fn() => $session->renameLayer('test-map', $revision, 'map:4', 'houses'))->toThrow(SessionRefusal::class, 'changed since revision') - ->and($session->undo())->toBe(['label' => 'Layer decoration', 'maps' => ['test-map']]); + ->and($session->undo())->toMatchArray(['label' => 'Layer decoration', 'maps' => ['test-map']]); }); it('creates, renames, reorders and removes glyph layers, saving the layer files as one set', function () { diff --git a/tests/Unit/Session/NpcSessionTest.php b/tests/Unit/Session/NpcSessionTest.php index eeeb757..3bb6847 100644 --- a/tests/Unit/Session/NpcSessionTest.php +++ b/tests/Unit/Session/NpcSessionTest.php @@ -59,7 +59,7 @@ function npcSessionRow(array $read, string $fieldId): array expect($deleted)->toMatchArray(['changed' => true, 'index' => null, 'id' => 'ann']) ->and(npcSessionIds($session))->toBe(['gate-guard', 'ann-2']); - expect($session->undo())->toBe(['label' => 'NPC delete', 'maps' => ['test-map']]) + expect($session->undo())->toMatchArray(['label' => 'NPC delete', 'maps' => ['test-map']]) ->and(npcSessionIds($session))->toBe(['ann', 'gate-guard', 'ann-2']); $session->undo(); $session->undo(); From c151023953bc223bbde1c9ff5e48d6bde009d6d6 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 14:49:05 +0200 Subject: [PATCH 125/344] feat(session): edit conditions and world writes a part at a time Rows whose field is a condition list or a world-write list (an NPC's Visible When and After Talking, a dialogue variant's When and Then Set, and the same fields on other records) are now editable: they carry their decoded entries, conditions.grammar serves the vocabulary from ConditionEditor::getGrammar and WorldWriteEditor::getGrammar (the terminal builders' own types, extras and defaults), and conditions.encode writes built entries as the line the row is set to. Narrowed: a typed condition or write line with an unreadable segment is now refused whole (decodeAllStrictly) instead of being stored without that segment, in every record field, so no condition is lost without a word. An NPC value its field cannot take is an NpcRefusal, not an unexpected error. --- src/Database/ConditionEditor.php | 50 ++++++++++++++++-- src/Database/ProjectRecordDatabase.php | 4 +- src/Database/WorldWriteCodec.php | 27 ++++++++++ src/Database/WorldWriteEditor.php | 34 ++++++++++++ src/Field/NpcAuthoring.php | 8 ++- src/Session/EditorSession.php | 71 +++++++++++++++++++++++++- src/Session/SessionHost.php | 7 ++- tests/Unit/Session/NpcSessionTest.php | 54 ++++++++++++++++++++ 8 files changed, 245 insertions(+), 10 deletions(-) diff --git a/src/Database/ConditionEditor.php b/src/Database/ConditionEditor.php index fd45045..fd10c83 100644 --- a/src/Database/ConditionEditor.php +++ b/src/Database/ConditionEditor.php @@ -34,10 +34,52 @@ final class ConditionEditor 'key_item' => ['Key Item', 'inventory'], ]; + /** The quest statuses a condition can require; the first is the default. */ + public const array QUEST_STATUSES = ['completed', 'active']; + + /** How a variable condition compares; the first is the default. */ + public const array VARIABLE_OPERATIONS = ['==', '!=', '>', '>=', '<', '<=']; + /** * @var array> The conditions being edited. */ private array $conditions = []; + + /** + * The condition vocabulary for an interface that builds conditions a + * part at a time without this editor's cursor: each type, what its name + * is called and which reference category lists names (null for a name + * the author invents), and the extras it carries, each with its choices + * or kind and its default. Every condition can be negated. + * + * @return list>}> + */ + public static function getGrammar(): array + { + $choices = static fn(array $values, array $labels): array => array_map( + static fn(mixed $value, string $label): array => ['value' => $value, 'label' => $label], $values, $labels); + $extras = [ + 'quest' => [['key' => 'status', 'label' => 'Status', 'kind' => 'options', 'default' => self::QUEST_STATUSES[0], + 'options' => $choices(self::QUEST_STATUSES, ['is completed', 'is active'])]], + 'switch' => [['key' => 'value', 'label' => 'Is', 'kind' => 'options', 'default' => true, + 'options' => $choices([true, false], ['on', 'off'])]], + 'variable' => [ + ['key' => 'op', 'label' => 'Compare', 'kind' => 'options', 'default' => self::VARIABLE_OPERATIONS[0], + 'options' => $choices(self::VARIABLE_OPERATIONS, self::VARIABLE_OPERATIONS)], + ['key' => 'value', 'label' => 'Value', 'kind' => 'text', 'default' => 0], + ], + 'item' => [['key' => 'quantity', 'label' => 'Quantity', 'kind' => 'integer', 'default' => 1]], + 'key_item' => [['key' => 'quantity', 'label' => 'Quantity', 'kind' => 'integer', 'default' => 1]], + ]; + + return array_map(static fn(string $type): array => [ + 'type' => $type, + 'label' => self::NAMES[$type][0] ?? ucfirst($type), + 'nameCategory' => self::NAMES[$type][1] ?? null, + 'negatable' => true, + 'extras' => $extras[$type] ?? [], + ], ConditionCodec::types()); + } private bool $isOpen = false; private string $fieldId = ''; private string $label = ''; @@ -243,9 +285,9 @@ public function cycleExtra(int $step): void switch (strval($condition['type'] ?? '')) { case 'quest': - $statuses = ['completed', 'active']; - $index = array_search(strval($condition['status'] ?? 'completed'), $statuses, true); - $condition['status'] = $statuses[(((is_int($index) ? $index : 0) + $step) % 2 + 2) % 2]; + $statuses = self::QUEST_STATUSES; + $index = array_search(strval($condition['status'] ?? $statuses[0]), $statuses, true); + $condition['status'] = $statuses[(((is_int($index) ? $index : 0) + $step) % count($statuses) + count($statuses)) % count($statuses)]; break; case 'switch': $condition['value'] = ($condition['value'] ?? true) === false; @@ -267,7 +309,7 @@ public function cycleExtra(int $step): void break; case 'variable': - $operations = ['==', '!=', '>', '>=', '<', '<=']; + $operations = self::VARIABLE_OPERATIONS; $index = array_search(strval($condition['op'] ?? '=='), $operations, true); $index = is_int($index) ? $index : 0; $condition['op'] = $operations[(($index + $step) % count($operations) + count($operations)) % count($operations)]; diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index c07fc77..5b5ea8e 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -2772,7 +2772,7 @@ private static function coerce(RecordField $field, string $rawValue): mixed } if ($field->codec === RecordFieldCodec::CONDITIONS) { - $conditions = ConditionCodec::decodeAll($trimmed); + $conditions = ConditionCodec::decodeAllStrictly($trimmed); return $conditions === [] && $field->removeWhenEmpty ? null : $conditions; } @@ -2784,7 +2784,7 @@ private static function coerce(RecordField $field, string $rawValue): mixed } if ($field->codec === RecordFieldCodec::WORLD_WRITES) { - $sets = WorldWriteCodec::decodeAll($trimmed); + $sets = WorldWriteCodec::decodeAllStrictly($trimmed); return $sets === [] && $field->removeWhenEmpty ? null : $sets; } diff --git a/src/Database/WorldWriteCodec.php b/src/Database/WorldWriteCodec.php index 28c645b..9a5f7e4 100644 --- a/src/Database/WorldWriteCodec.php +++ b/src/Database/WorldWriteCodec.php @@ -100,6 +100,33 @@ public static function decodeAll(string $value): array return $sets; } + /** + * Decodes the one-line form, refusing it whole when any segment cannot + * be read, so a typed write is never dropped without a word. + * + * @param string $value The encoded writes. + * @return array> The writes. + * @throws \InvalidArgumentException Naming the first unreadable segment. + */ + public static function decodeAllStrictly(string $value): array + { + $sets = []; + + foreach (explode(';', $value) as $segment) { + if (trim($segment) === '') { + continue; + } + + $sets[] = self::decode($segment) ?? throw new \InvalidArgumentException(sprintf( + 'Write "%s" cannot be read: write type:name[:extras] with a type of %s.', + trim($segment), + implode(', ', self::TYPES), + )); + } + + return $sets; + } + /** * Decodes one segment. * diff --git a/src/Database/WorldWriteEditor.php b/src/Database/WorldWriteEditor.php index 61eaed2..2855d45 100644 --- a/src/Database/WorldWriteEditor.php +++ b/src/Database/WorldWriteEditor.php @@ -35,6 +35,40 @@ final class WorldWriteEditor * @var array> The writes being edited. */ private array $sets = []; + + /** + * The write vocabulary for an interface that builds writes a part at a + * time without this editor's cursor: each type, what its name is called + * and which reference category lists names (null for a name the author + * invents), and the extras it carries with their choices or kind and + * default. Restricted to `$types` where the runtime allows only some. + * + * @param list|null $types The write types a surface may author; null for all. + * @return list>}> + */ + public static function getGrammar(?array $types = null): array + { + $choice = static fn(mixed $value, string $label): array => ['value' => $value, 'label' => $label]; + $extras = [ + 'switch' => [['key' => 'value', 'label' => 'Turn', 'kind' => 'options', 'default' => true, + 'options' => [$choice(true, 'on'), $choice(false, 'off')]]], + 'variable' => [ + ['key' => 'op', 'label' => 'Change', 'kind' => 'options', 'default' => 'set', + 'options' => [$choice('set', 'set to'), $choice('add', 'add')]], + ['key' => 'value', 'label' => 'Value', 'kind' => 'text', 'default' => 0], + ], + 'quest' => [['key' => 'confirm', 'label' => 'How', 'kind' => 'options', 'default' => true, + 'options' => [$choice(true, 'offer'), $choice(false, 'grant')]]], + ]; + + return array_values(array_map(static fn(string $type): array => [ + 'type' => $type, + 'label' => self::NAMES[$type][0] ?? ucfirst($type), + 'nameCategory' => self::NAMES[$type][1] ?? null, + 'negatable' => false, + 'extras' => $extras[$type] ?? [], + ], array_values(array_intersect(WorldWriteCodec::TYPES, $types ?? WorldWriteCodec::TYPES)))); + } private bool $isOpen = false; private string $fieldId = ''; private string $label = ''; diff --git a/src/Field/NpcAuthoring.php b/src/Field/NpcAuthoring.php index 7d1e054..b792129 100644 --- a/src/Field/NpcAuthoring.php +++ b/src/Field/NpcAuthoring.php @@ -9,6 +9,7 @@ use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; +use InvalidArgumentException; use Throwable; /** @@ -184,7 +185,12 @@ public function applyField(NpcInspector $inspector, int $index, array $framePath $idReferences = []; try { - $inspector->records()->setFrameField($index, $framePath, $fieldId, $rawValue); + try { + $inspector->records()->setFrameField($index, $framePath, $fieldId, $rawValue); + } catch (InvalidArgumentException $invalid) { + // A value its field cannot take, such as an unreadable condition, is the author's to correct. + throw new NpcRefusal($invalid->getMessage(), previous: $invalid); + } $inspector->commit(); $after = $map->getNpcs(); $placed = $after->get($index); diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 48aa5f3..5f077f5 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -8,9 +8,13 @@ use Ichiloto\Editor\Backup\BackupWriter; use Ichiloto\Editor\Canvas\CanvasEditor; use Ichiloto\Editor\Canvas\PieceRole; +use Ichiloto\Editor\Database\ConditionCodec; +use Ichiloto\Editor\Database\ConditionEditor; use Ichiloto\Editor\Database\DatabaseCatalog; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\ReferenceCatalog; +use Ichiloto\Editor\Database\WorldWriteCodec; +use Ichiloto\Editor\Database\WorldWriteEditor; use Ichiloto\Editor\Events\EventAuthoring; use Ichiloto\Editor\Events\EventRefusal; use Ichiloto\Editor\Events\EventTypeCatalog; @@ -911,6 +915,60 @@ private function describeMapKindQuestion(ProjectMap $map, string $tilesetId, int * @return list * @throws SessionRefusal When the map is unknown or the category is not a reference. */ + /** + * The vocabulary conditions and world writes are built from, a part at a + * time ({@see ConditionEditor::getGrammar()}, {@see WorldWriteEditor::getGrammar()}). + * + * @return array{conditions: list>, writes: list>} + */ + public function describeWorldStateGrammar(): array + { + return ['conditions' => ConditionEditor::getGrammar(), 'writes' => WorldWriteEditor::getGrammar()]; + } + + /** + * Writes conditions or world writes built a part at a time as the one + * line a conditions or writes row is set to, refusing an entry the + * engine could not read rather than dropping it. + * + * @param 'conditions'|'writes' $codec + * @param list $entries Each an array as a row's `entries` lists them. + * @param list|null $writeTypes The write types the row allows, when it restricts them. + * @return array{line: string, descriptions: list} + * @throws SessionRefusal When an entry has no name, an unknown type or one the row does not allow. + */ + public function encodeWorldState(string $codec, array $entries, ?array $writeTypes = null): array + { + $entries = array_values($entries); + foreach ($entries as $number => $entry) { + if (! is_array($entry) || trim((string) ($entry['name'] ?? '')) === '') { + throw new SessionRefusal(sprintf('Entry %d needs a name.', $number + 1)); + } + } + try { + if ($codec === 'conditions') { + $line = ConditionCodec::encodeAll($entries); + $decoded = ConditionCodec::decodeAllStrictly($line); + + return ['line' => $line, 'descriptions' => array_map(ConditionEditor::describe(...), $decoded)]; + } + if ($codec === 'writes') { + $line = WorldWriteCodec::encodeAll($entries); + $decoded = WorldWriteCodec::decodeAllStrictly($line); + $refused = array_find($decoded, static fn(array $set): bool => $writeTypes !== null && ! in_array($set['type'], $writeTypes, true)); + if ($refused !== null) { + throw new SessionRefusal(sprintf('This row cannot write a %s; it allows %s.', $refused['type'], implode(', ', $writeTypes ?? []))); + } + + return ['line' => $line, 'descriptions' => array_map(WorldWriteCodec::describe(...), $decoded)]; + } + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + + throw new SessionRefusal(sprintf('There is no %s codec; use conditions or writes.', $codec)); + } + public function listReferences(string $mapId, string $category): array { if (! ReferenceCatalog::knows($category)) { @@ -1337,7 +1395,8 @@ private static function describeRow(array $field): array $choices = MapInspector::findChoiceValues($field); $kind = match (true) { ($field['editable'] ?? true) === false, $target === null => 'info', - ($field['mapConditions'] ?? false) === true => 'conditions', + ($field['mapConditions'] ?? false) === true, ($field['conditions'] ?? false) === true => 'conditions', + ($field['worldWrites'] ?? false) === true => 'writes', ($field['destination'] ?? false) === true => 'destination', is_string($field['reference'] ?? null) => 'reference', $choices !== null => 'options', @@ -1355,7 +1414,8 @@ private static function describeRow(array $field): array 'label' => (string) ($field['label'] ?? ''), 'value' => (string) ($field['value'] ?? ''), 'raw' => match ($kind) { - 'conditions' => is_string($field['encoded'] ?? null) ? $field['encoded'] : null, + 'conditions' => is_string($field['encoded'] ?? null) ? $field['encoded'] : (string) ($field['value'] ?? ''), + 'writes' => (string) ($field['value'] ?? ''), 'text', 'integer', 'float', 'boolean' => $control?->rawValue, default => null, }, @@ -1369,6 +1429,13 @@ private static function describeRow(array $field): array default => null, }, 'list' => is_array($list) && $target !== null ? ['index' => (int) ($list['index'] ?? 0)] : null, + // Conditions and writes are built a part at a time from these. + 'entries' => match ($kind) { + 'conditions' => ConditionCodec::decodeAll(is_string($field['encoded'] ?? null) ? $field['encoded'] : (string) ($field['value'] ?? '')), + 'writes' => WorldWriteCodec::decodeAll((string) ($field['value'] ?? '')), + default => null, + }, + 'writeTypes' => $kind === 'writes' && is_array($field['writeTypes'] ?? null) ? array_values($field['writeTypes']) : null, 'key' => self::describeKey($field), ], static fn(mixed $value): bool => $value !== null); } diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 8b8c515..9e4c3c9 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -255,7 +255,12 @@ private function dispatch(string $method, array $params): array 'database.records' => $session->listDatabaseRecords(self::requireString($params, 'category')), 'database.record' => $session->readDatabaseRecord(self::requireString($params, 'category'), self::requireInt($params, 'index')), 'references.list' => $session->listReferences(self::requireString($params, 'map'), self::requireString($params, 'category')), - 'npc.create' => $session->createNpc( + 'conditions.grammar' => $session->describeWorldStateGrammar(), + 'conditions.encode' => $session->encodeWorldState( + self::requireString($params, 'codec'), + is_array($params['entries'] ?? null) ? $params['entries'] : throw new InvalidRequest('"entries" must be a list.'), + is_array($params['writeTypes'] ?? null) ? array_values(array_filter($params['writeTypes'], is_string(...))) : null, + ), 'npc.create' => $session->createNpc( self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireInt($params, 'x'), diff --git a/tests/Unit/Session/NpcSessionTest.php b/tests/Unit/Session/NpcSessionTest.php index 3bb6847..a654d79 100644 --- a/tests/Unit/Session/NpcSessionTest.php +++ b/tests/Unit/Session/NpcSessionTest.php @@ -174,3 +174,57 @@ function npcSessionRow(array $read, string $fieldId): array ->and($request(14, 'npc.apply', ['map' => 'test-map', 'revision' => $deleted['revision'], 'index' => 0, 'value' => 'x'])['error']['kind']) ->toBe('request'); }); + +it('builds an NPC\'s conditions and writes a part at a time and refuses a typo instead of dropping it', function () { + $session = EditorSession::open(npcSessionProject([['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1, + 'conditions' => [['type' => 'quest', 'name' => 'breakfast', 'status' => 'active']]]])); + $read = $session->readNpc('test-map', 0); + $visible = npcSessionRow($read, 'conditions'); + $after = npcSessionRow($read, 'sets'); + + expect([$visible['kind'], $visible['raw'], $visible['entries']]) + ->toBe(['conditions', 'quest:breakfast:active', [['type' => 'quest', 'name' => 'breakfast', 'status' => 'active']]]) + ->and([$after['kind'], $after['raw']])->toBe(['writes', '']); + + $conditions = $session->encodeWorldState('conditions', [ + ['type' => 'quest', 'name' => 'breakfast', 'status' => 'completed'], + ['type' => 'variable', 'name' => 'mood', 'op' => '>=', 'value' => 2, 'negate' => true], + ]); + $writes = $session->encodeWorldState('writes', [['type' => 'switch', 'name' => 'met-ann', 'value' => true]]); + expect($conditions['line'])->toBe('quest:breakfast:completed; !variable:mood:>=:2') + ->and($conditions['descriptions'][1])->toStartWith('NOT Variable mood'); + + $revision = $session->applyNpc('test-map', $read['revision'], 0, $visible['key'], $conditions['line'])['revision']; + $revision = $session->applyNpc('test-map', $revision, 0, $after['key'], $writes['line'])['revision']; + $reread = $session->readNpc('test-map', 0); + expect(npcSessionRow($reread, 'conditions')['entries'])->toBe([ + ['type' => 'quest', 'name' => 'breakfast', 'status' => 'completed'], + ['type' => 'variable', 'name' => 'mood', 'op' => '>=', 'value' => 2, 'negate' => true], + ]) + ->and(npcSessionRow($reread, 'sets')['raw'])->toBe('switch:met-ann:true') + // A typed line that cannot be read is refused whole, never stored without its unreadable part. + ->and(fn() => $session->applyNpc('test-map', $revision, 0, $visible['key'], 'quest:breakfast; swich:door')) + ->toThrow(SessionRefusal::class, 'Condition "swich:door" cannot be read') + ->and(fn() => $session->encodeWorldState('conditions', [['type' => 'switch', 'name' => ' ']])) + ->toThrow(SessionRefusal::class, 'Entry 1 needs a name.') + ->and(fn() => $session->encodeWorldState('writes', [['type' => 'quest', 'name' => 'q']], ['switch'])) + ->toThrow(SessionRefusal::class, 'cannot write a quest'); +}); + +it('describes the condition and write vocabulary from the editors that build them', function () { + $grammar = EditorSession::open(npcSessionProject([]))->describeWorldStateGrammar(); + $session = EditorSession::open(npcSessionProject([])); + + expect(array_column($grammar['conditions'], 'type'))->toBe(Ichiloto\Editor\Database\ConditionCodec::types()) + ->and(array_column($grammar['writes'], 'type'))->toBe(Ichiloto\Editor\Database\WorldWriteCodec::TYPES); + // Every type's defaults make an entry the codec writes and reads back. + foreach (['conditions', 'writes'] as $codec) { + foreach ($grammar[$codec] as $type) { + $entry = ['type' => $type['type'], 'name' => 'something']; + foreach ($type['extras'] as $extra) { + $entry[$extra['key']] = $extra['default']; + } + expect($session->encodeWorldState($codec, [$entry])['line'])->toStartWith($type['type'] . ':something'); + } + } +}); From 6d99b834da2957f14010078a59fb9387b3acfe88 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 14:54:58 +0200 Subject: [PATCH 126/344] feat(session): play test a map in the background from a graphical editor playtest.start plays a saved map from a cell in the game's own GPUI window through the same overlay and console as the terminal editor's playtest, without a terminal: PlaytestLauncher::start runs the play command in the background and PlaytestRun reports, stops (game and renderer included) and cleans up after it. playtest.status says how a run ended and why it failed; the session host stops a running playtest when the editor goes away. Fixes the playtest overlay dropping the author's player settings: its isolated data directory now holds a copy of player-settings.json, so a playtest honours the author's volume, mute and controls. Saves still stay behind, and nothing the playtest changes reaches the project. --- src/Playtest/PlaytestLauncher.php | 35 +++++++ src/Playtest/PlaytestOverlay.php | 24 ++++- src/Playtest/PlaytestRun.php | 114 +++++++++++++++++++++++ src/Session/EditorSession.php | 87 +++++++++++++++++ src/Session/SessionHost.php | 6 +- tests/Unit/PlaytestTest.php | 92 ++++++++++++++++++ tests/Unit/Session/EditorSessionTest.php | 29 ++++++ 7 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 src/Playtest/PlaytestRun.php diff --git a/src/Playtest/PlaytestLauncher.php b/src/Playtest/PlaytestLauncher.php index 32f1af3..182d487 100644 --- a/src/Playtest/PlaytestLauncher.php +++ b/src/Playtest/PlaytestLauncher.php @@ -143,6 +143,41 @@ public function buildCommand(PlaytestOverlay $overlay): string ); } + /** + * Starts the playtest in the background with a renderer of its own, for + * an editor that owns no terminal to hand over: the play command runs + * without prompts, reading nothing and writing its output to a log. + * + * @param string $rendererId The renderer, such as `gpui`. + * @param string $logPath Where the play command's output goes. + * @throws RuntimeException When the process cannot start. + */ + public function start(PlaytestOverlay $overlay, string $rendererId, string $logPath): PlaytestRun + { + $log = @fopen($logPath, 'ab'); + if (! is_resource($log)) { + throw new RuntimeException("Unable to open the playtest log at {$logPath}."); + } + + try { + $pipes = []; + $process = @proc_open( + [PHP_BINARY, $this->consoleBinary, 'play', '--no-tmux', '--no-interaction', '--renderer=' . $rendererId, '-d', $overlay->root], + [0 => ['file', '/dev/null', 'r'], 1 => $log, 2 => $log], + $pipes, + $overlay->root, + ); + } finally { + fclose($log); + } + + if (! is_resource($process)) { + throw new RuntimeException('The playtest could not be started.'); + } + + return new PlaytestRun($process, $overlay, $logPath); + } + /** * Runs the playtest, returning the child process exit code. * diff --git a/src/Playtest/PlaytestOverlay.php b/src/Playtest/PlaytestOverlay.php index 57cb9ed..92d1cd2 100644 --- a/src/Playtest/PlaytestOverlay.php +++ b/src/Playtest/PlaytestOverlay.php @@ -22,8 +22,9 @@ * to the real project, and exactly two entries are replaced by real ones — * * - `assets/Data/system.php`, rewritten with the playtest spawn; and - * - `.data/`, a fresh empty directory, so a playtest can never overwrite the - * author's save slots. + * - `.data/`, a fresh directory holding only a copy of the player settings, + * so a playtest honours the author's volume and mute but can never + * overwrite their save slots. * * Because maps, graphics, and every other asset are symlinks, the playtest * runs against the author's live files — a map saved in the editor is the map @@ -31,6 +32,9 @@ */ final class PlaytestOverlay { + /** The player's own settings in the data directory, as the Engine's PlayerSettings names them. */ + private const string PLAYER_SETTINGS = 'player-settings.json'; + /** * @param string $root The overlay project root. * @param string $mapId The map the playtest starts on. @@ -85,6 +89,7 @@ public static function create(string $projectRoot, string $mapId, int $spawnX, i self::mirrorDirectory($dataSource, $dataTarget, ['system.php']); mkdir($root . DIRECTORY_SEPARATOR . '.data', 0777, true); + self::copyPlayerSettings($projectRoot, $root); self::writeSystemOverride( $dataSource . DIRECTORY_SEPARATOR . 'system.php', @@ -266,6 +271,21 @@ public function destroy(): void self::removeTree($this->root); } + /** + * Copies the author's player settings into the overlay's isolated data + * directory: volume and mute, controls and other preferences belong to + * the player, so a playtest honours them, while the copy keeps any + * change made during the playtest out of the project. Saves stay behind. + */ + private static function copyPlayerSettings(string $projectRoot, string $root): void + { + $source = $projectRoot . DIRECTORY_SEPARATOR . '.data' . DIRECTORY_SEPARATOR . self::PLAYER_SETTINGS; + + if (is_file($source) && ! copy($source, $root . DIRECTORY_SEPARATOR . '.data' . DIRECTORY_SEPARATOR . self::PLAYER_SETTINGS)) { + throw new RuntimeException("Unable to copy the player settings into the playtest from {$source}."); + } + } + /** * Symlinks every entry of a directory, skipping the named exceptions. * diff --git a/src/Playtest/PlaytestRun.php b/src/Playtest/PlaytestRun.php new file mode 100644 index 0000000..8106d39 --- /dev/null +++ b/src/Playtest/PlaytestRun.php @@ -0,0 +1,114 @@ +finished) { + return false; + } + $status = proc_get_status($this->process); + if ($status['running']) { + return true; + } + // The exit code is only reported by the first status after exit. + $this->exitCode = $status['exitcode'] >= 0 ? $status['exitcode'] : null; + $this->finish(); + + return false; + } + + /** The play command's process id, while it runs. */ + public function getProcessId(): ?int + { + return $this->isRunning() ? proc_get_status($this->process)['pid'] : null; + } + + /** The play command's exit code once it has exited, null while running or when unknown. */ + public function getExitCode(): ?int + { + return $this->isRunning() ? null : $this->exitCode; + } + + /** Ends the game, its renderer and the play command, then removes the overlay. */ + public function stop(): void + { + if (! $this->isRunning()) { + return; + } + $root = (int) $this->getProcessId(); + // The deepest processes first, so no parent outlives to restart or report them. + foreach (array_reverse(self::findDescendants($root)) as $pid) { + posix_kill($pid, SIGTERM); + } + $this->stopped = true; + proc_terminate($this->process); + $this->exitCode = proc_close($this->process); + $this->finish(closed: true); + } + + /** Whether it ended because it was stopped, not on its own. */ + public function wasStopped(): bool + { + return $this->stopped; + } + + /** The end of the play command's output, for saying why a run failed. */ + public function readLogTail(int $lines = 12): string + { + $content = is_file($this->logPath) ? (string) file_get_contents($this->logPath) : ''; + $kept = array_slice(preg_split('/\R/', trim($content)) ?: [], -$lines); + + return implode("\n", $kept); + } + + private function finish(bool $closed = false): void + { + if ($this->finished) { + return; + } + $this->finished = true; + if (! $closed) { + proc_close($this->process); + } + $this->overlay->destroy(); + } + + /** + * Every process below one, parents before their children. + * + * @return list + */ + private static function findDescendants(int $pid): array + { + $children = array_map(intval(...), array_filter(preg_split('/\s+/', trim((string) shell_exec('pgrep -P ' . $pid))) ?: [])); + $found = []; + foreach ($children as $child) { + $found = [...$found, $child, ...self::findDescendants($child)]; + } + + return $found; + } +} diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 5f077f5..73c31b5 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -30,6 +30,9 @@ use Ichiloto\Editor\Inspector\InspectorRefusal; use Ichiloto\Editor\Inspector\MapInspector; use Ichiloto\Editor\MapSourceRefusal; +use Ichiloto\Editor\Playtest\PlaytestLauncher; +use Ichiloto\Editor\Playtest\PlaytestOverlay; +use Ichiloto\Editor\Playtest\PlaytestRun; use Ichiloto\Editor\Maps\LayerEditor; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Maps\MapReferences; @@ -56,8 +59,13 @@ */ final class EditorSession { + /** The renderer a playtest started from a graphical editor uses: it owns no terminal to hand over. */ + public const string PLAYTEST_RENDERER = 'gpui'; + private ProjectWorkspace $workspace; + /** The playtest running in the background, or the last one, to report how it ended. */ + private ?PlaytestRun $playtest = null; private function __construct( ProjectWorkspace $workspace, private readonly CommandHistory $history, @@ -1342,6 +1350,85 @@ public function saveMap(string $mapId): array return ['saved' => $saved, 'warnings' => array_values($warnings), 'backupFailures' => array_values($failures)]; } + /** + * Plays the game from a map and cell in the background, in its own + * graphical window, as the terminal editor's playtest does: from a + * temporary overlay of the project that writes nothing into it, with + * the author's player settings (volume and mute included) and no saves. + * The game reads the map from disk, so a map with unsaved changes is + * refused until it is saved. + * + * @return array The playtest, as {@see describePlaytest()}. + * @throws SessionRefusal When the map is unknown or unsaved, a playtest is running, or it cannot start. + */ + public function startPlaytest(string $mapId, int $x, int $y): array + { + $map = $this->requireMap($mapId); + if ($this->playtest?->isRunning() === true) { + throw new SessionRefusal('A playtest is already running; stop it or close its window first.'); + } + if ($map->isDirty()) { + throw new SessionRefusal(sprintf('Save %s before playtesting it; the game reads the map on disk.', $mapId)); + } + if ($x < 0 || $y < 0 || $x >= $map->getWidth() || $y >= $map->getHeight()) { + throw new SessionRefusal(sprintf('%d, %d is outside %s.', $x, $y, $mapId)); + } + $overlay = null; + try { + $overlay = PlaytestOverlay::create($this->workspace->projectRoot, $mapId, $x, $y); + $launcher = PlaytestLauncher::discover(projectRoot: $this->workspace->projectRoot); + $log = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('ichiloto-playtest-', true) . '.log'; + $this->playtest = $launcher->start($overlay, self::PLAYTEST_RENDERER, $log); + } catch (RuntimeException $error) { + $overlay?->destroy(); + throw new SessionRefusal(sprintf('The playtest could not start: %s', $error->getMessage()), previous: $error); + } + + return $this->describePlaytest(); + } + + /** + * Whether a playtest is running, where it started, and how the last one + * ended: its exit code and, when it failed, the end of its output. + * + * @return array{running: bool, map: ?string, x: ?int, y: ?int, stopped: bool, exitCode: ?int, log: ?string} + */ + public function describePlaytest(): array + { + $run = $this->playtest; + $running = $run?->isRunning() ?? false; + $exitCode = $running ? null : $run?->getExitCode(); + + return [ + 'running' => $running, + 'map' => $run?->overlay->mapId, + 'x' => $run?->overlay->spawnX, + 'y' => $run?->overlay->spawnY, + 'stopped' => $run?->wasStopped() ?? false, + 'exitCode' => $exitCode, + // Only a run that ended on its own with an error says why. + 'log' => $run !== null && ! $running && ! $run->wasStopped() && $exitCode !== 0 ? $run->readLogTail() : null, + ]; + } + + /** + * Ends a running playtest, game window and all. + * + * @return array The playtest, as {@see describePlaytest()}. + */ + public function stopPlaytest(): array + { + $this->playtest?->stop(); + + return $this->describePlaytest(); + } + + /** Ends what the session started that would outlive it: a running playtest. */ + public function close(): void + { + $this->playtest?->stop(); + } + /** Whether any map or database has changes not yet saved. */ public function hasUnsavedChanges(): bool { diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 9e4c3c9..36b6ec9 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -44,6 +44,8 @@ public function run(): void } $this->write($this->handle($line)); } + // The editor has gone: nothing it started may outlive it. + $this->session?->close(); } /** @@ -304,7 +306,9 @@ private function dispatch(string $method, array $params): array 'history.undo' => $session->undo(), 'history.redo' => $session->redo(), 'project.dirty' => ['dirty' => $session->hasUnsavedChanges()], - default => throw new InvalidRequest(sprintf('Unknown method "%s".', $method)), + 'playtest.start' => $session->startPlaytest(self::requireString($params, 'map'), self::requireInt($params, 'x'), self::requireInt($params, 'y')), + 'playtest.status' => $session->describePlaytest(), + 'playtest.stop' => $session->stopPlaytest(), default => throw new InvalidRequest(sprintf('Unknown method "%s".', $method)), }; } diff --git a/tests/Unit/PlaytestTest.php b/tests/Unit/PlaytestTest.php index 70f3bcd..a253236 100644 --- a/tests/Unit/PlaytestTest.php +++ b/tests/Unit/PlaytestTest.php @@ -211,3 +211,95 @@ function checksumTree(string $directory): array expect($message)->toContain('ICHILOTO_CONSOLE_BIN') ->and($message)->toContain('/nowhere/vendor/bin/ichiloto'); }); + +/** + * A stand-in console: it records its arguments, starts a child that would + * outlive it, and either keeps running or exits with the code given. + */ +function writeFakeConsole(string $directory, ?int $exitCode = null): string +{ + $path = $directory . '/fake-console.php'; + file_put_contents($path, ' ['player' => []]];\n"); + + return $root; +} + +it('carries the player settings into the playtest and leaves the saves behind', function (): void { + $root = playtestProject(); + mkdir($root . '/.data/saves', 0777, true); + file_put_contents($root . '/.data/player-settings.json', '{"audio":{"music":false,"sfx":false}}'); + file_put_contents($root . '/.data/saves/slot-1.json', '{}'); + $overlay = PlaytestOverlay::create($root, 'test-map', 1, 1); + + try { + // A copy, so a playtest's own changes never reach the project. + expect(file_get_contents($overlay->root . '/.data/player-settings.json'))->toBe('{"audio":{"music":false,"sfx":false}}') + ->and(is_link($overlay->root . '/.data/player-settings.json'))->toBeFalse() + ->and(is_dir($overlay->root . '/.data/saves'))->toBeFalse(); + } finally { + $overlay->destroy(); + } + + removeDirectoryRecursively($root); +}); + +it('runs a playtest in the background with its renderer and stops the whole game', function (): void { + $root = playtestProject(); + $scratch = sys_get_temp_dir() . '/' . uniqid('ichiloto-playtest-run-', true); + mkdir($scratch); + $overlay = PlaytestOverlay::create($root, 'test-map', 2, 3); + $run = (new PlaytestLauncher(writeFakeConsole($scratch)))->start($overlay, 'gpui', $scratch . '/play.log'); + + $deadline = microtime(true) + 5; + while (! str_contains((string) @file_get_contents($scratch . '/play.log'), 'play') && microtime(true) < $deadline) { + usleep(20000); + } + $pid = $run->getProcessId(); + $children = trim((string) shell_exec('pgrep -P ' . $pid)); + + expect($run->isRunning())->toBeTrue() + ->and(file_get_contents($scratch . '/play.log'))->toContain('play --no-tmux --no-interaction --renderer=gpui -d ' . $overlay->root) + ->and($children)->not->toBe(''); + + $run->stop(); + usleep(100000); + expect($run->isRunning())->toBeFalse() + ->and($run->wasStopped())->toBeTrue() + ->and(is_dir($overlay->root))->toBeFalse() + // The child the play command started went with it. + ->and(trim((string) shell_exec('ps -p ' . (int) $children . ' -o pid=')))->toBe(''); + + removeDirectoryRecursively($root); + removeDirectoryRecursively($scratch); +}); + +it('reports how a failed playtest ended and removes its overlay', function (): void { + $root = playtestProject(); + $scratch = sys_get_temp_dir() . '/' . uniqid('ichiloto-playtest-run-', true); + mkdir($scratch); + $overlay = PlaytestOverlay::create($root, 'test-map', 0, 0); + $run = (new PlaytestLauncher(writeFakeConsole($scratch, 3)))->start($overlay, 'gpui', $scratch . '/play.log'); + + $deadline = microtime(true) + 5; + while ($run->isRunning() && microtime(true) < $deadline) { + usleep(20000); + } + + expect($run->getExitCode())->toBe(3) + ->and($run->readLogTail())->toContain('renderer missing') + ->and(is_dir($overlay->root))->toBeFalse(); + + removeDirectoryRecursively($root); + removeDirectoryRecursively($scratch); +}); diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index 660ad78..06d7151 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -332,3 +332,32 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a ->and($session->readMap('test-map')['dirty'])->toBeTrue() ->and(fn() => $session->deleteMap('cellar'))->toThrow(SessionRefusal::class); }); + +it('plays a saved map from a cell in the background and refuses an unsaved one', function () { + $root = makeTemporaryProject(); + file_put_contents($root . '/assets/Data/system.php', " ['player' => []]];\n"); + $console = $root . '/fake-console.php'; + file_put_contents($console, 'readMap('test-map'); + $session->paint('test-map', $map['revision'], $map['baseLayer'], [[1, 1]], '%'); + expect(fn() => $session->startPlaytest('test-map', 1, 1))->toThrow(SessionRefusal::class, 'Save test-map before playtesting') + ->and(fn() => $session->startPlaytest('test-map', 999, 1))->toThrow(SessionRefusal::class); + $session->saveMap('test-map'); + + $started = $session->startPlaytest('test-map', 1, 1); + expect($started)->toMatchArray(['map' => 'test-map', 'x' => 1, 'y' => 1]); + $deadline = microtime(true) + 5; + while ($session->describePlaytest()['running'] && microtime(true) < $deadline) { + usleep(20000); + } + expect($session->describePlaytest())->toMatchArray(['running' => false, 'exitCode' => 2]) + ->and($session->describePlaytest()['log'])->toContain('no renderer installed') + ->and($session->stopPlaytest()['running'])->toBeFalse(); + } finally { + putenv('ICHILOTO_CONSOLE_BIN'); + } +}); From aa0996911d845f60314014e324930492e7cc15b6 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 15:26:20 +0200 Subject: [PATCH 127/344] feat(session): structured inspector rows and stamped tile blocks Inspector rows now carry their structure instead of the terminal's indentation: a depth, a heading flag for rows that only name the rows after them, and the axis of each coordinate pair's X and Y (map and event size and position, spawn points and any other x/y value, an NPC's placement), so a graphical inspector can lay them out as sections and compound fields. Labels are trimmed. tiles.stamp sets each listed cell to its own tile as one undo step (CanvasEditor::stampTiles), for a block of tiles chosen together and stamped as RPG Maker does; setTiles is a stamp of one tile. Data keys read as words in both editors: camel case is split too, so spawnPoint reads Spawn Point and chestType reads Chest Type (one shared formatter replaces three copies). --- src/Canvas/CanvasEditor.php | 19 ++++++++++-- src/Field/NpcInspector.php | 5 +++- src/Inspector/MapInspector.php | 21 +++++++++++-- src/Session/EditorSession.php | 30 +++++++++++++++++-- src/Session/SessionHost.php | 23 +++++++++++++- tests/Unit/Session/EditorSessionTest.php | 26 ++++++++++++++++ .../Session/EventAuthoringSessionTest.php | 20 ++++++------- 7 files changed, 124 insertions(+), 20 deletions(-) diff --git a/src/Canvas/CanvasEditor.php b/src/Canvas/CanvasEditor.php index 16a70c4..68e4cab 100644 --- a/src/Canvas/CanvasEditor.php +++ b/src/Canvas/CanvasEditor.php @@ -168,9 +168,24 @@ public static function resolvePaintStyle(string $symbol, ?string $colorDirective * @throws MapSourceRefusal When the tile is not a tile identity, a cell is outside the map or the layer cannot be written; nothing is changed. */ public static function setTiles(ProjectMap $map, string $layerName, array $cells, int $tile, string $label): array + { + return self::stampTiles($map, $layerName, array_map(static fn(array $cell): array => [$cell[0], $cell[1], $tile], $cells), $label); + } + + /** + * Sets each listed cell of a tile layer to its own tile, `0` erasing, as + * one undo step: a block of tiles chosen together and stamped, as RPG + * Maker places a multi-tile selection. The layer is created when the map + * does not have it yet. Glyphs and collision never change. + * + * @param list $cells Each cell as [x, y, tile]. + * @return array{command: ?Command, changed: int} No command when nothing changed. + * @throws MapSourceRefusal When a tile is not a tile identity, a cell is outside the map or the layer cannot be written; nothing is changed. + */ + public static function stampTiles(ProjectMap $map, string $layerName, array $cells, string $label): array { $before = $map->getTileLayerSources(); - $entries = array_map(static fn(array $cell): array => ['x' => $cell[0], 'y' => $cell[1], 'entry' => (string) $tile], $cells); + $entries = array_map(static fn(array $cell): array => ['x' => $cell[0], 'y' => $cell[1], 'entry' => (string) $cell[2]], $cells); $previous = $map->readTileEntries([$layerName], 0, 0, $map->getWidth(), $map->getHeight())[$layerName] ?? []; $map->writeTileCells([$layerName => $entries]); $after = $map->getTileLayerSources(); @@ -178,7 +193,7 @@ public static function setTiles(ProjectMap $map, string $layerName, array $cells return ['command' => null, 'changed' => 0]; } $changed = count(array_filter($cells, static fn(array $cell): bool - => ($previous[$cell[1]][$cell[0]] ?? (string) TileId::EMPTY) !== (string) $tile)); + => ($previous[$cell[1]][$cell[0]] ?? (string) TileId::EMPTY) !== (string) $cell[2])); return ['command' => new GenericCommand($label, static fn() => $map->restoreTileLayerSources($after), diff --git a/src/Field/NpcInspector.php b/src/Field/NpcInspector.php index 2a09e13..59372ab 100644 --- a/src/Field/NpcInspector.php +++ b/src/Field/NpcInspector.php @@ -48,6 +48,9 @@ final class NpcInspector 'Completion Writes' => ['sets'], ]; + /** The rows that are a cell's coordinates, by field, and the axis each is. */ + private const array AXES = ['x' => 'x', 'y' => 'y']; + private ProjectRecordDatabase $records; public function __construct(public readonly ProjectMap $map) @@ -114,7 +117,7 @@ public function getFields(int $index, array $framePath = []): ?array // Wander bounds only matter while wandering; loaded // values are kept, just not shown for a fixed NPC. if (! (str_starts_with($id, 'wanderArea.') && $npc !== null && ! $npc->wanders())) { - $rows[] = $field; + $rows[] = isset(self::AXES[$id]) ? [...$field, 'axis' => self::AXES[$id]] : $field; } } diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index 6d388c1..260a7e8 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -106,6 +106,7 @@ public function getMapFields(ProjectMap $map): array 'control' => new InputControl(InputControlType::INTEGER, (string) $map->getWidth()), 'target' => 'map-size', 'field' => 'width', + 'axis' => 'x', ], [ 'label' => ' Y', @@ -113,6 +114,7 @@ public function getMapFields(ProjectMap $map): array 'control' => new InputControl(InputControlType::INTEGER, (string) $map->getHeight()), 'target' => 'map-size', 'field' => 'height', + 'axis' => 'y', ], [ 'label' => 'Events', @@ -173,6 +175,7 @@ public function getEventFields(ProjectMap $map, string $marker): array 'target' => 'event-bounds', 'marker' => $marker, 'field' => $axis, + 'axis' => $axis, ]; } if ($area?->isRectangle ?? true) { @@ -185,6 +188,7 @@ public function getEventFields(ProjectMap $map, string $marker): array 'target' => 'event-bounds', 'marker' => $marker, 'field' => $axis, + 'axis' => $axis === 'width' ? 'x' : 'y', ]; } } else { @@ -1113,6 +1117,15 @@ private function decorateEventInspectorFields(string $marker, array $fields): ar return $decorated; } + /** + * A data key as an author reads it: words split at underscores, hyphens + * and camel case, each capitalised (`spawnPoint` reads Spawn Point). + */ + private static function formatFieldLabel(string $part): string + { + return ucwords(str_replace(['_', '-'], ' ', (string) preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', $part))); + } + /** * Flattens nested scalar data into editable inspector fields. * @@ -1134,7 +1147,7 @@ private function flattenInspectorFields(array $data, array $path, ?array $list = // field inside one remembers which list it belongs to. if ($this->isInspectorListValue($nextPath, $value)) { $label = implode(' ', array_map( - static fn(string $part): string => ucwords(str_replace(['_', '-'], ' ', $part)), + self::formatFieldLabel(...), ($nextPath[0] ?? null) === 'data' ? array_slice($nextPath, 1) : $nextPath )); $fields[] = [ @@ -1165,7 +1178,7 @@ private function flattenInspectorFields(array $data, array $path, ?array $list = if (array_key_exists('x', $value) && array_key_exists('y', $value) && is_scalar($value['x']) && is_scalar($value['y'])) { $label = implode(' ', array_map( - static fn(string $part): string => ucwords(str_replace(['_', '-'], ' ', $part)), + self::formatFieldLabel(...), ($nextPath[0] ?? null) === 'data' ? array_slice($nextPath, 1) : $nextPath )); $fields[] = [ @@ -1181,6 +1194,7 @@ private function flattenInspectorFields(array $data, array $path, ?array $list = (string) $value['x'], ), 'path' => [...$nextPath, 'x'], + 'axis' => 'x', ]; $fields[] = [ 'label' => ' Y', @@ -1190,6 +1204,7 @@ private function flattenInspectorFields(array $data, array $path, ?array $list = (string) $value['y'], ), 'path' => [...$nextPath, 'y'], + 'axis' => 'y', ]; continue; } @@ -1208,7 +1223,7 @@ private function flattenInspectorFields(array $data, array $path, ?array $list = $label = implode(' ', array_map( static fn(string $part): string => ctype_digit($part) ? '#' . ((int) $part + 1) - : ucwords(str_replace(['_', '-'], ' ', $part)), + : self::formatFieldLabel($part), $displayPath )); $stringValue = match (true) { diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 73c31b5..c7e9408 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -373,13 +373,27 @@ public function readTiles(string $mapId): array * @throws SessionRefusal When the map is unknown, stale or read-only, or refuses the tile. */ public function paintTiles(string $mapId, int $revision, string $layerName, array $cells, int $tile, string $label = 'Place tiles'): array + { + return $this->stampTiles($mapId, $revision, $layerName, array_map(static fn(array $cell): array => [$cell[0], $cell[1], $tile], $cells), $label); + } + + /** + * Sets each cell of a tile layer to its own tile as one undo step + * ({@see CanvasEditor::stampTiles()}): a block chosen in the palette or + * picked from the map, stamped where the author drags. + * + * @param list $cells Each cell as [x, y, tile]. + * @return array{changed: int, revision: int} + * @throws SessionRefusal When the map is unknown, stale or read-only, or refuses a tile. + */ + public function stampTiles(string $mapId, int $revision, string $layerName, array $cells, string $label = 'Place tiles'): array { $map = $this->requireCurrentMap($mapId, $revision); if ($map->getGridSourceIssue() !== null) { throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); } try { - $applied = CanvasEditor::setTiles($map, $layerName, $cells, $tile, $label); + $applied = CanvasEditor::stampTiles($map, $layerName, $cells, $label); } catch (MapSourceRefusal $refusal) { throw new SessionRefusal($refusal->getMessage(), previous: $refusal); } @@ -1497,8 +1511,16 @@ private static function describeRow(array $field): array }; $list = $field['list'] ?? $field['encounterList'] ?? $field['bgmVariantList'] ?? null; + // The terminal nests a row by indenting its label; an interface is given the depth instead. + $label = (string) ($field['label'] ?? ''); + $depth = intdiv(strlen($label) - strlen(ltrim($label, ' ')), 2); + $key = self::describeKey($field); + return array_filter([ - 'label' => (string) ($field['label'] ?? ''), + 'label' => trim($label), + 'depth' => $depth, + // A section heading: a row that only names the rows after it. + 'heading' => $kind === 'info' && (string) ($field['value'] ?? '') === '' && $key === null && $list === null ? true : null, 'value' => (string) ($field['value'] ?? ''), 'raw' => match ($kind) { 'conditions' => is_string($field['encoded'] ?? null) ? $field['encoded'] : (string) ($field['value'] ?? ''), @@ -1523,7 +1545,9 @@ private static function describeRow(array $field): array default => null, }, 'writeTypes' => $kind === 'writes' && is_array($field['writeTypes'] ?? null) ? array_values($field['writeTypes']) : null, - 'key' => self::describeKey($field), + // One axis of a coordinate pair under the heading before it. + 'axis' => in_array($field['axis'] ?? null, ['x', 'y'], true) ? $field['axis'] : null, + 'key' => $key, ], static fn(mixed $value): bool => $value !== null); } diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 36b6ec9..946d8da 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -110,7 +110,13 @@ private function dispatch(string $method, array $params): array 'map.world' => $session->readWorld(self::requireString($params, 'map')), 'tiles.palette' => $session->readTilePalette(self::requireString($params, 'map')), 'tiles.read' => $session->readTiles(self::requireString($params, 'map')), - 'tiles.paint' => $session->paintTiles( + 'tiles.stamp' => $session->stampTiles( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + self::requireTileCells($params), + is_string($params['label'] ?? null) ? $params['label'] : 'Place tiles', + ), 'tiles.paint' => $session->paintTiles( self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireString($params, 'layer'), @@ -365,6 +371,21 @@ private static function requireCells(array $params): array return $cells; } + /** + * @param array $params + * @return list + */ + private static function requireTileCells(array $params): array + { + $cells = $params['cells'] ?? null; + if (! is_array($cells) || ! array_is_list($cells) || ! array_all($cells, static fn(mixed $cell): bool => is_array($cell) && count($cell) === 3 + && is_int($cell[0] ?? null) && is_int($cell[1] ?? null) && is_int($cell[2] ?? null))) { + throw new InvalidRequest('"cells" must be a list of [x, y, tile] integer triples.'); + } + + return $cells; + } + /** * @param array $params * @return array diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index 06d7151..1b373be 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -361,3 +361,29 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a putenv('ICHILOTO_CONSOLE_BIN'); } }); + +it('stamps a block of tiles as one undo step, each cell its own tile', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + $floor = static fn(): array => array_find($session->readTiles('test-map')['layers'], static fn(array $layer): bool => $layer['name'] === 'floor')['rows']; + + $stamped = $session->stampTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[1, 0, 5], [2, 0, 6], [1, 1, 0], [2, 1, 7]]); + expect($stamped['changed'])->toBe(4) + ->and($floor())->toBe([[2816, 5, 6, 2816], [2816, 0, 7, 2816]]) + ->and($session->undo()['label'])->toBe('Place tiles') + ->and($floor())->toBe([[2816, 2816, 2816, 2816], [2816, 2816, 2816, 2816]]) + ->and(fn() => $session->stampTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[0, 0, 5], [7, 0, 5]])) + ->toThrow(SessionRefusal::class, 'no cell at (7, 0)') + ->and($floor()[0][0])->toBe(2816); +}); + +it('gives an interface each row\'s depth, its section headings and its coordinate pairs', function () { + $session = EditorSession::open(makeTemporaryProject()); + $rows = $session->readInspector('test-map')['rows']; + $size = array_search('Size', array_column($rows, 'label'), true); + + expect($rows[$size])->toMatchArray(['heading' => true, 'depth' => 0]) + ->and([$rows[$size + 1]['label'], $rows[$size + 1]['axis'], $rows[$size + 1]['depth']])->toBe(['X', 'x', 1]) + ->and([$rows[$size + 2]['label'], $rows[$size + 2]['axis']])->toBe(['Y', 'y']) + ->and(array_filter($rows, static fn(array $row): bool => str_starts_with($row['label'], ' ')))->toBe([]); +}); diff --git a/tests/Unit/Session/EventAuthoringSessionTest.php b/tests/Unit/Session/EventAuthoringSessionTest.php index 6e99e47..8726786 100644 --- a/tests/Unit/Session/EventAuthoringSessionTest.php +++ b/tests/Unit/Session/EventAuthoringSessionTest.php @@ -138,19 +138,19 @@ function eventSessionFreeCells(array $map, int $count): array $applied = $session->applyInspector('test-map', $created['revision'], $type['key'], 'Chest'); $chest = $session->readInspector('test-map', $marker); - $chestType = eventSessionRowLabelled($chest, 'ChestType'); + $chestType = eventSessionRowLabelled($chest, 'Chest Type'); expect($applied)->toMatchArray(['status' => 'applied', 'changed' => true]) ->and(eventSessionEvent($session->readMap('test-map'), $marker)['type'])->toBe('Chest') ->and($chestType['kind'])->toBe('options') ->and($chestType['optionLabels'])->toContain('Common', 'Legendary') - ->and(eventSessionRowLabelled($chest, 'LootType')['options'])->toContain('item', 'gold') + ->and(eventSessionRowLabelled($chest, 'Loot Type')['options'])->toContain('item', 'gold') ->and(fn() => $session->applyInspector('test-map', $chest['revision'], $chestType['key'], 'shiny')) ->toThrow(SessionRefusal::class, 'choose one of'); $session->applyInspector('test-map', $chest['revision'], $chestType['key'], 'rare'); - expect(eventSessionRowLabelled($session->readInspector('test-map', $marker), 'ChestType')['value'])->toBe('rare') - ->and($session->undo()['label'])->toBe('ChestType edit') + expect(eventSessionRowLabelled($session->readInspector('test-map', $marker), 'Chest Type')['value'])->toBe('rare') + ->and($session->undo()['label'])->toBe('Chest Type edit') ->and($session->undo()['label'])->toBe('Event type change') ->and(eventSessionEvent($session->readMap('test-map'), $marker)['type'])->toBe('Dialogue'); }); @@ -161,7 +161,7 @@ function eventSessionFreeCells(array $map, int $count): array $created = $session->createEvent('test-map', $map['revision'], eventSessionFreeCells($map, 1), 'Transfer Player'); $marker = $created['marker']; $inspector = $session->readInspector('test-map', $marker); - $destination = eventSessionRowLabelled($inspector, 'DestinationMap'); + $destination = eventSessionRowLabelled($inspector, 'Destination Map'); expect($destination)->toMatchArray(['kind' => 'destination', 'reference' => 'maps']) ->and(fn() => $session->applyInspector('test-map', $created['revision'], $destination['key'], 'test-map')) @@ -175,14 +175,14 @@ function eventSessionFreeCells(array $map, int $count): array $after = $session->readInspector('test-map', $marker); expect($set['changed'])->toBeTrue() - ->and(eventSessionRowLabelled($after, 'DestinationMap')['value'])->toBe('test-map') - ->and(eventSessionRowLabelled($after, 'X', 'SpawnPoint')['value'])->toBe((string) ($map['width'] - 1)) - ->and(eventSessionRowLabelled($after, 'Y', 'SpawnPoint')['value'])->toBe((string) ($map['height'] - 1)) + ->and(eventSessionRowLabelled($after, 'Destination Map')['value'])->toBe('test-map') + ->and(eventSessionRowLabelled($after, 'X', 'Spawn Point')['value'])->toBe((string) ($map['width'] - 1)) + ->and(eventSessionRowLabelled($after, 'Y', 'Spawn Point')['value'])->toBe((string) ($map['height'] - 1)) ->and($session->undo()['label'])->toBe('Destination change'); $undone = $session->readInspector('test-map', $marker); - expect(eventSessionRowLabelled($undone, 'DestinationMap')['value'])->toBe('') - ->and(eventSessionRowLabelled($undone, 'X', 'SpawnPoint')['value'])->toBe('0'); + expect(eventSessionRowLabelled($undone, 'Destination Map')['value'])->toBe('') + ->and(eventSessionRowLabelled($undone, 'X', 'Spawn Point')['value'])->toBe('0'); }); it('adds and removes entries of an event\'s list, the map\'s encounters and its music variants', function () { From 5e47b22c22d44a5c6539b426d665ebe2bb8b5a76 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 16:53:23 +0200 Subject: [PATCH 128/344] refactor(database): share schema record authoring between editor interfaces Record editing for schema-driven categories lived only in the terminal shell. RecordAuthoring now owns it: applying a row (root or frame), adding and removing sub-list entries, nested items, frame commands and choice options, and creating, duplicating, moving and deleting records. Every operation returns a RecordChange whose command undoes exactly what it did, and refuses read-only categories, gone records and frames, and values a field cannot take with a RecordRefusal. ProjectRecordDatabase::locateItem() owns the field-id grammar that says which item a row belongs to; the terminal Database screen, the NPC pane (NpcAuthoring) and the session use it instead of their own regexes. addSubItem, addNestedSubItem and addChoiceOption take an optional position, and supportsRecordCreation()/supportsRecordDeletion() name the storage rules addRecord and removeRecord already applied. The shell keeps only selection, prompts, status and the skit actor follow-up. Behaviour changes: - Terminal: creating a schema record is now one undo step; it was not undoable before. - NPC pane (terminal and session): on a choice option row in a script frame, add now adds an option and remove removes only that option. Before, add put a command after the choice and remove deleted the whole choice command with all its options. - Terminal: a value a field's codec cannot read (an unreadable condition or write line, a source the config cannot preserve) is reported as the refusal itself at error level, as a malformed parameter line already was, instead of " edit failed: ... (Ctrl+E for details)". - Terminal: Shift+O/Shift+X on a nested item in a read-only category now says "Read-only: ." instead of silently doing nothing; moving or duplicating in an empty category names the missing record. - Redoing a frame command add re-inserts the command that was added, read back from the list, rather than the root sub-list's blank. For every current Database category the two are the same. The shell's own KEY_VALUES pre-check (isDatabaseFieldRejected) is removed: setField already decodes before writing, and the shared rules turn its syntax error into the refusal. --- src/Database/ProjectRecordDatabase.php | 147 ++++++-- src/Database/RecordAuthoring.php | 460 ++++++++++++++++++++++++ src/Database/RecordChange.php | 28 ++ src/Database/RecordItem.php | 48 +++ src/Database/RecordRefusal.php | 18 + src/Editor.php | 345 ++++++++---------- src/Field/NpcAuthoring.php | 87 ++--- tests/Unit/RecordAuthoringTest.php | 222 ++++++++++++ tests/Unit/RecordCategoryEditorTest.php | 47 +++ 9 files changed, 1125 insertions(+), 277 deletions(-) create mode 100644 src/Database/RecordAuthoring.php create mode 100644 src/Database/RecordChange.php create mode 100644 src/Database/RecordItem.php create mode 100644 src/Database/RecordRefusal.php create mode 100644 tests/Unit/RecordAuthoringTest.php diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 5b5ea8e..446a88a 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -689,6 +689,31 @@ public function setField(int $index, string $fieldId, string $rawValue): void } } + /** + * Returns whether this category can take a new record at all. Terms are + * the leaves of an existing config tree and listings are informational: + * there is no meaningful blank entry to append to either. + * + * @return bool True when creating a record can do something here. + */ + public function supportsRecordCreation(): bool + { + return $this->isEditable() + && $this->schema->storage !== RecordStorage::CONFIG_SUBTREE + && $this->schema->storage !== RecordStorage::FILE_LISTING; + } + + /** + * Returns whether this category can let a record go. A term is a leaf of + * the config tree it belongs to, not an entry of its own. + * + * @return bool True when deleting a record can do something here. + */ + public function supportsRecordDeletion(): bool + { + return $this->isEditable() && $this->schema->storage !== RecordStorage::CONFIG_SUBTREE; + } + /** * Appends a blank record. * @@ -696,9 +721,7 @@ public function setField(int $index, string $fieldId, string $rawValue): void */ public function addRecord(): ?int { - if (! $this->isEditable() || $this->schema->storage === RecordStorage::CONFIG_SUBTREE || $this->schema->storage === RecordStorage::FILE_LISTING) { - // Terms are the leaves of an existing config tree and listings are - // informational: there is no meaningful blank entry to append. + if (! $this->supportsRecordCreation()) { return null; } @@ -776,7 +799,7 @@ public function addRecord(): ?int */ public function removeRecord(int $index): ?ProjectRecord { - if (! $this->isEditable() || $this->schema->storage === RecordStorage::CONFIG_SUBTREE) { + if (! $this->supportsRecordDeletion()) { return null; } @@ -969,13 +992,15 @@ public function duplicateRecord(int $index): ?int } /** - * Appends a sub-list entry (an objective, beat, member, or command). + * Adds a sub-list entry (an objective, beat, member, or command), at the + * end or at a position. * * @param int $index The record index. * @param array|null $entry The entry payload; the schema blank when null. + * @param int|null $at Where to insert it, or null for the end. * @return int|null The new entry index. */ - public function addSubItem(int $index, ?array $entry = null): ?int + public function addSubItem(int $index, ?array $entry = null, ?int $at = null): ?int { $record = $this->getRecordByIndex($index); $subList = $this->schema->subList; @@ -985,11 +1010,12 @@ public function addSubItem(int $index, ?array $entry = null): ?int } $entries = $record->getSubList($subList->key); - $entries[] = $entry ?? $subList->blank; + $position = $at === null ? count($entries) : max(0, min(count($entries), $at)); + array_splice($entries, $position, 0, [$entry ?? $subList->blank]); $record->setSubList($subList->key, $entries); $this->touchState(); - return count($entries) - 1; + return $position; } /** @@ -1102,8 +1128,8 @@ public function nestedSubListContext(int $index, string $fieldId): ?array return ['parentIndex' => $parentIndex, 'nestedIndex' => $nestedIndex, 'list' => $nestedList]; } - /** Appends an entry to a variant-owned nested list. */ - public function addNestedSubItem(int $index, int $parentIndex, ?array $entry = null): ?int + /** Adds an entry to a variant-owned nested list, at the end or at a position. */ + public function addNestedSubItem(int $index, int $parentIndex, ?array $entry = null, ?int $at = null): ?int { $context = $this->nestedListForParent($index, $parentIndex); @@ -1112,10 +1138,11 @@ public function addNestedSubItem(int $index, int $parentIndex, ?array $entry = n } $entries = array_values((array) ($context['parent'][$context['list']->key] ?? [])); - $entries[] = $entry ?? $context['list']->blank; + $position = $at === null ? count($entries) : max(0, min(count($entries), $at)); + array_splice($entries, $position, 0, [$entry ?? $context['list']->blank]); $this->writeNestedSubList($context, $entries); - return count($entries) - 1; + return $position; } /** Removes an entry from a variant-owned nested list. */ @@ -2414,7 +2441,7 @@ public function frameNestedContext(int $recordIndex, array $framePath, string $f } $commands = $this->getFrameCommands($recordIndex, $framePath); - $frameList = $this->frameSubList($framePath); + $frameList = $this->getFrameSubList($framePath); if ($commands === null || $frameList === null) { return null; @@ -2446,6 +2473,63 @@ public function frameNestedContext(int $recordIndex, array $framePath, string $f return ['parentIndex' => $parentIndex, 'nestedIndex' => $nestedIndex, 'list' => $nestedList]; } + /** + * Returns the item a settings row belongs to, to add after or remove: + * the nested entry (a route step, a dialogue line, a lane) the row + * edits, the choice option it names, or else the entry or command it is + * part of. The field ids carry the frame list's prefix, as the rows were + * made with it. The record's own rows, headings and an empty frame's + * placeholder belong to none. + * + * @param int $recordIndex The record. + * @param array $framePath The frame the row is in; [] for the record's own list. + * @param string $fieldId The row's field id. + * @return RecordItem|null The item, or null when the row belongs to none. + */ + public function locateItem(int $recordIndex, array $framePath, string $fieldId): ?RecordItem + { + $list = $this->getFrameSubList($framePath); + $entries = $this->getFrameCommands($recordIndex, $framePath); + + if ($list === null || $entries === null || preg_match('/^' . preg_quote($list->prefix, '/') . '(\d+)/', $fieldId, $matches) !== 1) { + return null; + } + + $entryIndex = intval($matches[1]); + $entry = $entries[$entryIndex] ?? null; + + if (! is_array($entry)) { + return null; + } + + $nested = $this->frameNestedContext($recordIndex, $framePath, $fieldId); + + if ($nested !== null && $nested['nestedIndex'] !== null) { + return new RecordItem(RecordItem::NESTED, $framePath, $entryIndex, $nested['nestedIndex'], $nested['list']->singular); + } + + if (preg_match('/^' . preg_quote($list->prefix . $entryIndex, '/') . 'Option(\d+)/', $fieldId, $option) === 1) { + return new RecordItem(RecordItem::OPTION, $framePath, $entryIndex, intval($option[1]), 'option'); + } + + $nestedList = $list->nestedListFor($entry); + $isChoice = $list->variantKey !== null && strval($entry[$list->variantKey] ?? '') === 'choice'; + + return new RecordItem( + RecordItem::ENTRY, + $framePath, + $entryIndex, + null, + $list->singular, + match (true) { + $nestedList !== null => RecordItem::NESTED, + $isChoice => RecordItem::OPTION, + default => null, + }, + $nestedList?->singular ?? ($isChoice ? 'option' : null), + ); + } + /** * Returns how many nested entries a frame command owns. * @@ -2461,7 +2545,7 @@ public function countFrameNestedItems(int $recordIndex, array $framePath, int $p } $commands = $this->getFrameCommands($recordIndex, $framePath) ?? []; - $frameList = $this->frameSubList($framePath); + $frameList = $this->getFrameSubList($framePath); $parent = $commands[$parentIndex] ?? null; $nestedList = is_array($parent) && $frameList !== null ? $frameList->nestedListFor($parent) : null; @@ -2481,19 +2565,12 @@ public function countFrameNestedItems(int $recordIndex, array $framePath, int $p public function addFrameNestedItem(int $recordIndex, array $framePath, int $parentIndex, ?array $entry = null, ?int $at = null): ?int { if ($framePath === []) { - if ($at === null) { - return $this->addNestedSubItem($recordIndex, $parentIndex, $entry); - } - - $blank = $entry ?? $this->nestedListForParent($recordIndex, $parentIndex)['list']->blank ?? []; - $this->insertNestedSubItem($recordIndex, $parentIndex, $at, $blank); - - return $at; + return $this->addNestedSubItem($recordIndex, $parentIndex, $entry, $at); } $record = $this->getRecordByIndex($recordIndex); $commands = $this->getFrameCommands($recordIndex, $framePath); - $frameList = $this->frameSubList($framePath); + $frameList = $this->getFrameSubList($framePath); // Without a root sub-list (a summon's tracks), the frame's list // is the one the write goes through. $rootList = $this->schema->subList ?? $frameList; @@ -2530,7 +2607,7 @@ public function removeFrameNestedItem(int $recordIndex, array $framePath, int $p $record = $this->getRecordByIndex($recordIndex); $commands = $this->getFrameCommands($recordIndex, $framePath); - $frameList = $this->frameSubList($framePath); + $frameList = $this->getFrameSubList($framePath); $rootList = $this->schema->subList ?? $frameList; $parent = $commands[$parentIndex] ?? null; $nestedList = is_array($parent) && $frameList !== null ? $frameList->nestedListFor($parent) : null; @@ -3042,7 +3119,7 @@ public function getFrameCommands(int $recordIndex, array $framePath): ?array * @param array $framePath The frame. * @return RecordSubList|null The list. */ - private function frameSubList(array $framePath): ?RecordSubList + public function getFrameSubList(array $framePath): ?RecordSubList { if ($framePath === []) { return $this->schema->subList; @@ -3097,7 +3174,7 @@ public function getFrameSettingsFields(int $recordIndex, array $framePath): arra $record = $this->getRecordByIndex($recordIndex); $commands = $this->getFrameCommands($recordIndex, $framePath); - $subList = $this->frameSubList($framePath); + $subList = $this->getFrameSubList($framePath); if ($subList === null || ! $record instanceof ProjectRecord || $commands === null) { return []; @@ -3177,7 +3254,7 @@ public function setFrameField(int $recordIndex, array $framePath, string $fieldI // command's, however the frame was reached), not the schema's // sub-list prefix: an NPC's variants are "variantN…" at the root and // its script's commands "commandN…" inside. - $frameList = $this->frameSubList($framePath) ?? $subList; + $frameList = $this->getFrameSubList($framePath) ?? $subList; if ($frameList === null || $commands === null) { return; @@ -3252,7 +3329,7 @@ public function addFrameCommand(int $recordIndex, array $framePath, ?int $afterI $commands = $this->getFrameCommands($recordIndex, $framePath); // A record whose lists are all frames (a summon's tracks and cues) // has no root sub-list; the frame's own list is the one to write. - $frameList = $this->frameSubList($framePath) ?? $subList; + $frameList = $this->getFrameSubList($framePath) ?? $subList; if ($frameList === null || ! $record instanceof ProjectRecord || $commands === null || ! $this->isEditable()) { return null; @@ -3279,7 +3356,7 @@ public function insertFrameCommand(int $recordIndex, array $framePath, int $comm $subList = $this->schema->subList; $record = $this->getRecordByIndex($recordIndex); $commands = $this->getFrameCommands($recordIndex, $framePath); - $frameList = $this->frameSubList($framePath) ?? $subList; + $frameList = $this->getFrameSubList($framePath) ?? $subList; if ($frameList === null || ! $record instanceof ProjectRecord || $commands === null) { return; @@ -3302,7 +3379,7 @@ public function removeFrameCommand(int $recordIndex, array $framePath, int $comm $subList = $this->schema->subList; $record = $this->getRecordByIndex($recordIndex); $commands = $this->getFrameCommands($recordIndex, $framePath); - $frameList = $this->frameSubList($framePath) ?? $subList; + $frameList = $this->getFrameSubList($framePath) ?? $subList; if ($frameList === null || ! $record instanceof ProjectRecord || $commands === null || ! isset($commands[$commandIndex])) { return null; @@ -3315,14 +3392,15 @@ public function removeFrameCommand(int $recordIndex, array $framePath, int $comm } /** - * Adds an option to a choice command. + * Adds an option to a choice command, at the end or at a position. * * @param int $recordIndex The record. * @param array $framePath The frame the choice sits in. * @param int $commandIndex The choice command. + * @param int|null $at Where to insert it, or null for the end. * @return int|null The new option's index. */ - public function addChoiceOption(int $recordIndex, array $framePath, int $commandIndex): ?int + public function addChoiceOption(int $recordIndex, array $framePath, int $commandIndex, ?int $at = null): ?int { $subList = $this->schema->subList; $record = $this->getRecordByIndex($recordIndex); @@ -3338,11 +3416,12 @@ public function addChoiceOption(int $recordIndex, array $framePath, int $command } $options = array_values((array) ($entry['options'] ?? [])); - $options[] = ['text' => 'New option', 'then' => []]; + $position = $at === null ? count($options) : max(0, min(count($options), $at)); + array_splice($options, $position, 0, [['text' => 'New option', 'then' => []]]); $commands[$commandIndex]['options'] = $options; $this->writeFrameCommands($record, $subList, $framePath, $commands); - return count($options) - 1; + return $position; } /** diff --git a/src/Database/RecordAuthoring.php b/src/Database/RecordAuthoring.php new file mode 100644 index 0000000..fbac6c0 --- /dev/null +++ b/src/Database/RecordAuthoring.php @@ -0,0 +1,460 @@ + $framePath The frame the row belongs to; [] for the record itself. + * @param string $label The row's label, which names the undo step. + * @throws RecordRefusal When the category or record is read-only or gone, the frame is gone, or the value is refused. + */ + public function applyField( + ProjectRecordDatabase $database, + int $index, + array $framePath, + string $fieldId, + string $rawValue, + string $label, + ): RecordChange { + $record = $this->requireRecord($database, $index); + $this->requireFrame($database, $index, $framePath); + $before = $record->toArray(); + + try { + if ($database->hasCommandFrames()) { + $database->setFrameField($index, $framePath, $fieldId, $rawValue); + } else { + $database->setField($index, $fieldId, $rawValue); + } + } catch (ParameterMapSyntaxError|SourcePreservationRefusal|InvalidArgumentException $refused) { + // The value is the author's to correct; the record was left as it was. + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + + $after = $record->toArray(); + + if ($before === $after) { + return new RecordChange(null, $index); + } + + return new RecordChange(new GenericCommand( + sprintf('%s edit', $label), + static fn() => $record->restorePayload($after), + static fn() => $record->restorePayload($before), + ), $index); + } + + /** + * Adds an item at a row: after the item the row belongs to, or, with + * `$child`, at the end of what that item holds (a route's steps, a + * choice's options). With no row it adds at the end of the frame, or of + * the record's own list. + * + * @param array $framePath The frame the row belongs to. + * @param string|null $fieldId The row's field id; null for the end of the frame. + * @param bool $child True to add beneath the row's item rather than after it. + * @throws RecordRefusal When the category or record is read-only or gone, the frame is gone, or the row's item cannot take it. + */ + public function addItem(ProjectRecordDatabase $database, int $index, array $framePath, ?string $fieldId, bool $child = false): RecordChange + { + $this->requireList($database, $index, $framePath); + + if ($fieldId === null) { + if ($child) { + throw new RecordRefusal('Name the row whose item the new one goes beneath.'); + } + + return $this->addEntry($database, $index, $framePath); + } + + $item = $database->locateItem($index, $framePath, $fieldId) + ?? throw new RecordRefusal('That row belongs to no item to add after; add at the end of the list instead.'); + + if ($child) { + return match ($item->kind === RecordItem::ENTRY ? $item->childKind : null) { + RecordItem::NESTED => $this->addNestedItem($database, $index, $framePath, $item->entryIndex), + RecordItem::OPTION => $this->addOption($database, $index, $framePath, $item->entryIndex), + default => throw new RecordRefusal(sprintf('A %s holds nothing that can be added beneath it.', $item->noun)), + }; + } + + return match ($item->kind) { + RecordItem::NESTED => $this->addNestedItem($database, $index, $framePath, $item->entryIndex, $item->childIndex), + RecordItem::OPTION => $this->addOption($database, $index, $framePath, $item->entryIndex, $item->childIndex), + default => $this->addEntry($database, $index, $framePath, $item->entryIndex), + }; + } + + /** + * Removes the item a row belongs to: a route step, line or lane, a + * choice's option with its whole arm, or an entry or command with + * everything under it. A row that belongs to none changes nothing. + * + * @param array $framePath The frame the row belongs to. + * @throws RecordRefusal When the category or record is read-only or gone, or the frame is gone. + */ + public function removeItem(ProjectRecordDatabase $database, int $index, array $framePath, string $fieldId): RecordChange + { + $this->requireList($database, $index, $framePath); + $item = $database->locateItem($index, $framePath, $fieldId); + + return match ($item?->kind) { + null => new RecordChange(null), + RecordItem::NESTED => $this->removeNestedItem($database, $index, $framePath, $item->entryIndex, (int) $item->childIndex), + RecordItem::OPTION => $this->removeOption($database, $index, $framePath, $item->entryIndex, (int) $item->childIndex), + default => $this->removeEntry($database, $index, $framePath, $item->entryIndex), + }; + } + + /** + * Adds a blank entry to the record's own list, or a blank command to a + * frame: after an entry, or at the end. + * + * @param array $framePath The frame; [] for the record's own list. + * @param int|null $after The entry it follows; null for the end. + * @throws RecordRefusal When the category or record is read-only or gone, or there is no such list. + */ + public function addEntry(ProjectRecordDatabase $database, int $index, array $framePath, ?int $after = null): RecordChange + { + $list = $this->requireList($database, $index, $framePath); + $at = $framePath === [] + ? $database->addSubItem($index, at: $after === null ? null : $after + 1) + : $database->addFrameCommand($index, $framePath, $after); + $entry = $at === null ? null : ($database->getFrameCommands($index, $framePath)[$at] ?? null); + + if ($at === null || ! is_array($entry)) { + return new RecordChange(null); + } + + return new RecordChange(new GenericCommand( + sprintf('%s add', ucfirst($list->singular)), + $framePath === [] + ? static fn() => $database->insertSubItem($index, $at, $entry) + : static fn() => $database->insertFrameCommand($index, $framePath, $at, $entry), + $framePath === [] + ? static fn() => $database->removeSubItem($index, $at) + : static fn() => $database->removeFrameCommand($index, $framePath, $at), + ), $at); + } + + /** + * Removes an entry of the record's own list, or a command of a frame, + * with everything under it. + * + * @param array $framePath The frame; [] for the record's own list. + * @throws RecordRefusal When the category or record is read-only or gone, or there is no such list. + */ + public function removeEntry(ProjectRecordDatabase $database, int $index, array $framePath, int $entryIndex): RecordChange + { + $list = $this->requireList($database, $index, $framePath); + $removed = $framePath === [] + ? $database->removeSubItem($index, $entryIndex) + : $database->removeFrameCommand($index, $framePath, $entryIndex); + + if ($removed === null) { + return new RecordChange(null); + } + + return new RecordChange(new GenericCommand( + sprintf('%s remove', ucfirst($list->singular)), + $framePath === [] + ? static fn() => $database->removeSubItem($index, $entryIndex) + : static fn() => $database->removeFrameCommand($index, $framePath, $entryIndex), + $framePath === [] + ? static fn() => $database->insertSubItem($index, $entryIndex, $removed) + : static fn() => $database->insertFrameCommand($index, $framePath, $entryIndex, $removed), + ), $entryIndex, $removed); + } + + /** + * Adds a blank entry to the nested list an entry owns (a route step, + * a dialogue line, a lane): after one of them, or at the end. + * + * @param array $framePath The frame the owning entry is in. + * @param int|null $after The nested entry it follows; null for the end. + * @throws RecordRefusal When the category or record is read-only or gone, or the frame is gone. + */ + public function addNestedItem(ProjectRecordDatabase $database, int $index, array $framePath, int $parentIndex, ?int $after = null): RecordChange + { + $list = $this->requireList($database, $index, $framePath); + $nestedList = $this->findNestedList($database, $index, $framePath, $list, $parentIndex); + $at = $nestedList === null ? null : $database->addFrameNestedItem($index, $framePath, $parentIndex, null, $after === null ? null : $after + 1); + $entry = $at === null || $nestedList === null + ? null + : ($database->getFrameCommands($index, $framePath)[$parentIndex][$nestedList->key][$at] ?? null); + + if ($at === null || $nestedList === null || ! is_array($entry)) { + return new RecordChange(null); + } + + return new RecordChange(new GenericCommand( + sprintf('%s add', ucfirst($nestedList->singular)), + static fn() => $database->addFrameNestedItem($index, $framePath, $parentIndex, $entry, $at), + static fn() => $database->removeFrameNestedItem($index, $framePath, $parentIndex, $at), + ), $at); + } + + /** + * Removes an entry of the nested list an entry owns. + * + * @param array $framePath The frame the owning entry is in. + * @throws RecordRefusal When the category or record is read-only or gone, or the frame is gone. + */ + public function removeNestedItem(ProjectRecordDatabase $database, int $index, array $framePath, int $parentIndex, int $nestedIndex): RecordChange + { + $list = $this->requireList($database, $index, $framePath); + $nestedList = $this->findNestedList($database, $index, $framePath, $list, $parentIndex); + $removed = $nestedList === null ? null : $database->removeFrameNestedItem($index, $framePath, $parentIndex, $nestedIndex); + + if ($removed === null || $nestedList === null) { + return new RecordChange(null); + } + + return new RecordChange(new GenericCommand( + sprintf('%s remove', ucfirst($nestedList->singular)), + static fn() => $database->removeFrameNestedItem($index, $framePath, $parentIndex, $nestedIndex), + static fn() => $database->addFrameNestedItem($index, $framePath, $parentIndex, $removed, $nestedIndex), + ), $nestedIndex, $removed); + } + + /** + * Adds an option to a choice command: after one of its options, or at + * the end. + * + * @param array $framePath The frame the choice is in. + * @param int|null $after The option it follows; null for the end. + * @throws RecordRefusal When the category or record is read-only or gone, or the frame is gone. + */ + public function addOption(ProjectRecordDatabase $database, int $index, array $framePath, int $commandIndex, ?int $after = null): RecordChange + { + $this->requireList($database, $index, $framePath); + $at = $database->addChoiceOption($index, $framePath, $commandIndex, $after === null ? null : $after + 1); + $option = $at === null ? null : ($database->getFrameCommands($index, $framePath)[$commandIndex]['options'][$at] ?? null); + + if ($at === null || ! is_array($option)) { + return new RecordChange(null); + } + + return new RecordChange(new GenericCommand( + 'Option add', + static fn() => $database->insertChoiceOption($index, $framePath, $commandIndex, $at, $option), + static fn() => $database->removeChoiceOption($index, $framePath, $commandIndex, $at), + ), $at); + } + + /** + * Removes an option from a choice command, its arm included. + * + * @param array $framePath The frame the choice is in. + * @throws RecordRefusal When the category or record is read-only or gone, or the frame is gone. + */ + public function removeOption(ProjectRecordDatabase $database, int $index, array $framePath, int $commandIndex, int $optionIndex): RecordChange + { + $this->requireList($database, $index, $framePath); + $removed = $database->removeChoiceOption($index, $framePath, $commandIndex, $optionIndex); + + if ($removed === null) { + return new RecordChange(null); + } + + return new RecordChange(new GenericCommand( + 'Option remove', + static fn() => $database->removeChoiceOption($index, $framePath, $commandIndex, $optionIndex), + static fn() => $database->insertChoiceOption($index, $framePath, $commandIndex, $optionIndex, $removed), + ), $optionIndex, $removed); + } + + /** + * Creates a blank record at the end of the category. Its index is the + * change's. + * + * @throws RecordRefusal When the category is read-only or takes no new records. + */ + public function createRecord(ProjectRecordDatabase $database): RecordChange + { + $this->requireEditable($database); + $index = $database->addRecord(); + $record = $index === null ? null : $database->getRecordByIndex($index); + + if ($index === null || $record === null) { + throw new RecordRefusal(sprintf('%s entries cannot be created from the editor.', ucfirst($database->schema->entryNoun))); + } + + return new RecordChange(new GenericCommand( + sprintf('%s create', ucfirst($database->schema->entryNoun)), + static fn() => $database->insertRecord($index, $record), + static fn() => $database->removeRecord($index), + ), $index); + } + + /** + * Duplicates a record below itself, under a fresh identity. The copy's + * index is the change's. + * + * @throws RecordRefusal When the category or record is read-only or gone, or the record cannot be duplicated. + */ + public function duplicateRecord(ProjectRecordDatabase $database, int $index): RecordChange + { + $this->requireRecord($database, $index); + $copyIndex = $database->duplicateRecord($index); + $copy = $copyIndex === null ? null : $database->getRecordByIndex($copyIndex); + + if ($copyIndex === null || $copy === null) { + throw new RecordRefusal(sprintf('This %s cannot be duplicated.', $database->schema->entryNoun)); + } + + return new RecordChange(new GenericCommand( + sprintf('%s duplicate', ucfirst($database->schema->entryNoun)), + static fn() => $database->insertRecord($copyIndex, $copy), + static fn() => $database->removeRecord($copyIndex), + ), $copyIndex); + } + + /** + * Moves a record one place up or down its list. Declaration order is + * part of some categories' meaning - battle-entry rules break priority + * ties by it - so the move is a recorded edit, made only where the file + * keeps the order. A move past either end changes nothing. The record's + * new index is the change's. + * + * @param int $step -1 for up, 1 for down. + * @throws RecordRefusal When the category is read-only, the record is gone, or the file would not keep the order. + */ + public function moveRecord(ProjectRecordDatabase $database, int $index, int $step): RecordChange + { + $this->requireEditable($database); + + if (! $database->supportsDurableReorder()) { + // Refusing beats a reorder the file cannot keep: nothing moves, + // nothing dirties, and the author learns why. + throw new RecordRefusal((string) $database->reorderRefusalReason()); + } + + $this->requireRecord($database, $index); + $to = $index + $step; + + if (! $database->moveRecord($index, $to)) { + return new RecordChange(null, $index); + } + + return new RecordChange(new GenericCommand( + sprintf('%s move', ucfirst($database->schema->entryNoun)), + static fn() => $database->moveRecord($index, $to), + static fn() => $database->moveRecord($to, $index), + ), $to); + } + + /** + * Deletes a record. The file changes on save, which is what makes the + * undo honest. The change's index is the record to select next: the + * one before it, or null when none is left. + * + * @throws RecordRefusal When the category is read-only, the record is gone, or the category keeps its entries. + */ + public function deleteRecord(ProjectRecordDatabase $database, int $index): RecordChange + { + $this->requireRecord($database, $index); + $label = $database->getEntryLabels()[$index] ?? ''; + $removed = $database->removeRecord($index) + ?? throw new RecordRefusal('This category does not support entry deletion yet.'); + $remaining = count($database->getRecords()); + + return new RecordChange(new GenericCommand( + sprintf('Delete %s %s', $database->schema->entryNoun, $label), + static function () use ($database, $index): void { + $database->removeRecord($index); + }, + static fn() => $database->insertRecord($index, $removed), + ), $remaining === 0 ? null : min(max(0, $index - 1), $remaining - 1)); + } + + /** @throws RecordRefusal When the category cannot be written. */ + private function requireEditable(ProjectRecordDatabase $database): void + { + if (! $database->isEditable()) { + throw new RecordRefusal(sprintf('Read-only: %s.', $database->getReadOnlyReason() ?? 'this category cannot be written')); + } + } + + /** + * The record at an index of a category that can be written. A record + * whose own values cannot be written is the category's to protect: its + * read-only rows say so, and its writes change nothing. + * + * @throws RecordRefusal When the category cannot be written or there is no such record. + */ + private function requireRecord(ProjectRecordDatabase $database, int $index): ProjectRecord + { + $this->requireEditable($database); + + return $database->getRecordByIndex($index) + ?? throw new RecordRefusal(sprintf('%s has no record %d.', $database->schema->key, $index)); + } + + /** + * Refuses a frame that no longer resolves, or any frame in a category + * whose commands do not nest. + * + * @param array $framePath + * @throws RecordRefusal + */ + private function requireFrame(ProjectRecordDatabase $database, int $index, array $framePath): void + { + if ($framePath !== [] && (! $database->hasCommandFrames() || $database->getFrameCommands($index, $framePath) === null)) { + throw new RecordRefusal(sprintf('%s is no longer there.', $database->describeFramePath($framePath))); + } + } + + /** + * The list a frame holds - the record's own at the root - refusing a + * record or frame that cannot take an item. + * + * @param array $framePath + * @throws RecordRefusal + */ + private function requireList(ProjectRecordDatabase $database, int $index, array $framePath): RecordSubList + { + $this->requireRecord($database, $index); + $this->requireFrame($database, $index, $framePath); + + return $database->getFrameSubList($framePath) + ?? throw new RecordRefusal(sprintf('A %s has no list of its own to add to.', $database->schema->entryNoun)); + } + + /** The nested list an entry of a frame owns, or null when it owns none. */ + private function findNestedList(ProjectRecordDatabase $database, int $index, array $framePath, RecordSubList $list, int $parentIndex): ?RecordSubList + { + $parent = $database->getFrameCommands($index, $framePath)[$parentIndex] ?? null; + + return is_array($parent) ? $list->nestedListFor($parent) : null; + } +} diff --git a/src/Database/RecordChange.php b/src/Database/RecordChange.php new file mode 100644 index 0000000..bd481c1 --- /dev/null +++ b/src/Database/RecordChange.php @@ -0,0 +1,28 @@ +|null $removed What an item removal took out, as the list held it. + */ + public function __construct( + public ?Command $command, + public ?int $index = null, + public ?array $removed = null, + ) { + } +} diff --git a/src/Database/RecordItem.php b/src/Database/RecordItem.php new file mode 100644 index 0000000..de4d796 --- /dev/null +++ b/src/Database/RecordItem.php @@ -0,0 +1,48 @@ + $framePath The frame whose list holds the entry. + * @param int $entryIndex The entry, or the entry a nested item or option belongs to. + * @param int|null $childIndex The nested item or option; null for an entry. + * @param string $noun What the item is called, singular. + * @param string|null $childKind What an entry holds beneath it, when it can hold more: {@see NESTED} for its nested list's entries, {@see OPTION} for a choice's options. + * @param string|null $childNoun What those are called, singular. + */ + public function __construct( + public string $kind, + public array $framePath, + public int $entryIndex, + public ?int $childIndex, + public string $noun, + public ?string $childKind = null, + public ?string $childNoun = null, + ) { + } +} diff --git a/src/Database/RecordRefusal.php b/src/Database/RecordRefusal.php new file mode 100644 index 0000000..1740072 --- /dev/null +++ b/src/Database/RecordRefusal.php @@ -0,0 +1,18 @@ + $workspace->animationDatabase->removeAnimation($index), static fn(object $entry) => $workspace->animationDatabase->insertAnimation($index, $entry), ), - default => $this->buildRecordDeletionCommand($pending['category'], $index, $label), + default => $this->buildRecordDeletionCommand($pending['category'], $index), }; if (! $command instanceof Command) { @@ -6327,26 +6328,27 @@ private function confirmDatabaseEntryDeletion(): void } /** - * Builds the deletion command for a schema-driven category. + * Deletes an entry of a schema-driven category through the shared + * record rules ({@see RecordAuthoring::deleteRecord()}). * * @param string $categoryKey The category key. * @param int $index The entry index. - * @param string $label The entry label. - * @return Command|null + * @return Command|null The deletion's command, or null when it was refused. */ - private function buildRecordDeletionCommand(string $categoryKey, int $index, string $label): ?Command + private function buildRecordDeletionCommand(string $categoryKey, int $index): ?Command { $database = $this->workspace?->getRecordDatabase($categoryKey); - if (! $database instanceof ProjectRecordDatabase || ! $database->isEditable()) { + if (! $database instanceof ProjectRecordDatabase) { return null; } - return $this->buildDatabaseDeletionCommand( - sprintf('Delete %s %s', $database->schema->entryNoun, $label), - static fn(): ?object => $database->removeRecord($index), - static fn(object $entry) => $database->insertRecord($index, $entry), - ); + try { + return (new RecordAuthoring())->deleteRecord($database, $index)->command; + } catch (RecordRefusal) { + // describeUndeletableCategory() says why. + return null; + } } /** @@ -8994,18 +8996,13 @@ private function createDatabaseRecord(): void return; } - $index = $database->addRecord(); + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->createRecord($database)); - if ($index === null) { - $this->setStatus( - sprintf('%s entries cannot be created from the editor.', ucfirst($database->schema->entryNoun)), - StatusLevel::WARN, - ); - $this->renderDatabaseArea(); + if ($change === null) { return; } - $this->setSelectedRecordIndex($index); + $this->setSelectedRecordIndex((int) $change->index); $this->databaseSelectedSettingIndex = 0; $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; $this->setStatus(sprintf('Created a new %s.', $database->schema->entryNoun), StatusLevel::INFO); @@ -9037,28 +9034,13 @@ private function duplicateDatabaseRecord(): void } $index = $this->getSelectedRecordIndex(); - $copyIndex = $database->duplicateRecord($index); + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->duplicateRecord($database, $index)); - if ($copyIndex === null) { - $this->setStatus( - sprintf('This %s cannot be duplicated.', $database->schema->entryNoun), - StatusLevel::WARN, - ); - $this->renderDatabaseArea(); + if ($change === null) { return; } - $copy = $database->getRecordByIndex($copyIndex); - - if ($copy instanceof ProjectRecord) { - $this->recordCommand(new GenericCommand( - sprintf('%s duplicate', ucfirst($database->schema->entryNoun)), - static fn() => $database->insertRecord($copyIndex, $copy), - static fn() => $database->removeRecord($copyIndex), - )); - } - - $this->setSelectedRecordIndex($copyIndex); + $this->setSelectedRecordIndex((int) $change->index); $this->setStatus(sprintf('Duplicated the %s.', $database->schema->entryNoun), StatusLevel::INFO); $this->renderDatabaseArea(); } @@ -9086,28 +9068,15 @@ private function moveDatabaseRecord(int $step): void return; } - if (! $database->supportsDurableReorder()) { - // Refusing beats a reorder the file cannot keep: nothing moves, - // nothing dirties, and the author learns why. - $this->setStatus((string) $database->reorderRefusalReason(), StatusLevel::WARN); - $this->renderDatabaseArea(); - return; - } - + // A category whose file would not keep the order refuses, and says why. $from = $this->getSelectedRecordIndex(); - $to = $from + $step; + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->moveRecord($database, $from, $step)); - if (! $database->moveRecord($from, $to)) { + if ($change?->command === null) { return; } - $this->recordCommand(new GenericCommand( - sprintf('%s move', ucfirst($database->schema->entryNoun)), - static fn() => $database->moveRecord($from, $to), - static fn() => $database->moveRecord($to, $from), - )); - - $this->setSelectedRecordIndex($to); + $this->setSelectedRecordIndex((int) $change->index); $this->setStatus(sprintf( 'Moved the %s %s.', $database->schema->entryNoun, @@ -9135,23 +9104,20 @@ private function addDatabaseFrameItem(ProjectRecordDatabase $database, RecordSub $recordIndex = $this->getSelectedRecordIndex(); $framePath = $this->databaseCommandFramePath; $selectedId = (string) ($this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex]['field'] ?? ''); - $prefix = preg_quote($subList->prefix, '/'); + // The frame's own list names its rows and the commands added to it. + $list = $database->getFrameSubList($framePath) ?? $subList; + $item = $database->locateItem($recordIndex, $framePath, $selectedId); - if (preg_match('/^' . $prefix . '(\\d+)Option(\\d+)/', $selectedId, $matches) === 1) { - $commandIndex = intval($matches[1]); - $optionIndex = $database->addChoiceOption($recordIndex, $framePath, $commandIndex); + if ($item?->kind === RecordItem::OPTION) { + $commandIndex = $item->entryIndex; + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->addOption($database, $recordIndex, $framePath, $commandIndex)); - if ($optionIndex === null) { + if ($change?->command === null) { return; } - $option = ['text' => 'New option', 'then' => []]; - $this->recordCommand(new GenericCommand( - 'Option add', - static fn() => $database->insertChoiceOption($recordIndex, $framePath, $commandIndex, $optionIndex, $option), - static fn() => $database->removeChoiceOption($recordIndex, $framePath, $commandIndex, $optionIndex), - )); - $this->selectDatabaseFieldById(sprintf('%s%dOption%dText', $subList->prefix, $commandIndex, $optionIndex)); + $optionIndex = (int) $change->index; + $this->selectDatabaseFieldById(sprintf('%s%dOption%dText', $list->prefix, $commandIndex, $optionIndex)); $this->setStatus(sprintf('Added option %d.', $optionIndex + 1), StatusLevel::INFO); $this->renderDatabaseArea(); $this->beginDatabaseEdit(); @@ -9159,21 +9125,16 @@ private function addDatabaseFrameItem(ProjectRecordDatabase $database, RecordSub return; } - $afterIndex = preg_match('/^' . $prefix . '(\\d+)/', $selectedId, $matches) === 1 ? intval($matches[1]) : null; - $commandIndex = $database->addFrameCommand($recordIndex, $framePath, $afterIndex); + $afterIndex = $item?->entryIndex; + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->addEntry($database, $recordIndex, $framePath, $afterIndex)); - if ($commandIndex === null) { + if ($change?->command === null) { return; } - $blank = $subList->blank; - $this->recordCommand(new GenericCommand( - sprintf('%s add', ucfirst($subList->singular)), - static fn() => $database->insertFrameCommand($recordIndex, $framePath, $commandIndex, $blank), - static fn() => $database->removeFrameCommand($recordIndex, $framePath, $commandIndex), - )); - $this->selectDatabaseFieldById(sprintf('%s%dType', $subList->prefix, $commandIndex)); - $this->setStatus(sprintf('Added %s %d.', $subList->singular, $commandIndex + 1), StatusLevel::INFO); + $commandIndex = (int) $change->index; + $this->selectDatabaseFieldById(sprintf('%s%dType', $list->prefix, $commandIndex)); + $this->setStatus(sprintf('Added %s %d.', $list->singular, $commandIndex + 1), StatusLevel::INFO); $this->renderDatabaseArea(); } @@ -9190,24 +9151,19 @@ private function removeDatabaseFrameItem(ProjectRecordDatabase $database, Record $recordIndex = $this->getSelectedRecordIndex(); $framePath = $this->databaseCommandFramePath; $selectedId = (string) ($this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex]['field'] ?? ''); - $prefix = preg_quote($subList->prefix, '/'); + $list = $database->getFrameSubList($framePath) ?? $subList; + $item = $database->locateItem($recordIndex, $framePath, $selectedId); - if (preg_match('/^' . $prefix . '(\\d+)Option(\\d+)/', $selectedId, $matches) === 1) { - $commandIndex = intval($matches[1]); - $optionIndex = intval($matches[2]); - $removed = $database->removeChoiceOption($recordIndex, $framePath, $commandIndex, $optionIndex); + if ($item?->kind === RecordItem::OPTION) { + $optionIndex = (int) $item->childIndex; + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->removeOption($database, $recordIndex, $framePath, $item->entryIndex, $optionIndex)); - if ($removed === null) { + if ($change?->command === null) { return; } - $armCount = count((array) ($removed['then'] ?? [])); + $armCount = count((array) ($change->removed['then'] ?? [])); $this->clampDatabaseSettingSelection(); - $this->recordCommand(new GenericCommand( - 'Option remove', - static fn() => $database->removeChoiceOption($recordIndex, $framePath, $commandIndex, $optionIndex), - static fn() => $database->insertChoiceOption($recordIndex, $framePath, $commandIndex, $optionIndex, $removed), - )); $this->setStatus( $armCount > 0 ? sprintf('Removed option %d and its %d commands.', $optionIndex + 1, $armCount) @@ -9219,23 +9175,21 @@ private function removeDatabaseFrameItem(ProjectRecordDatabase $database, Record return; } - $commands = $database->getFrameCommands($recordIndex, $framePath) ?? []; - $commandIndex = preg_match('/^' . $prefix . '(\\d+)/', $selectedId, $matches) === 1 - ? intval($matches[1]) - : count($commands) - 1; - $removed = $database->removeFrameCommand($recordIndex, $framePath, $commandIndex); + // With the cursor on no command, the last one goes. + $commandIndex = $item?->entryIndex ?? count($database->getFrameCommands($recordIndex, $framePath) ?? []) - 1; - if ($removed === null) { + if ($commandIndex < 0) { + return; + } + + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->removeEntry($database, $recordIndex, $framePath, $commandIndex)); + + if ($change?->command === null) { return; } $this->clampDatabaseSettingSelection(); - $this->recordCommand(new GenericCommand( - sprintf('%s remove', ucfirst($subList->singular)), - static fn() => $database->removeFrameCommand($recordIndex, $framePath, $commandIndex), - static fn() => $database->insertFrameCommand($recordIndex, $framePath, $commandIndex, $removed), - )); - $this->setStatus(sprintf('Removed %s %d.', $subList->singular, $commandIndex + 1), StatusLevel::INFO); + $this->setStatus(sprintf('Removed %s %d.', $list->singular, $commandIndex + 1), StatusLevel::INFO); $this->renderDatabaseArea(); } @@ -9383,20 +9337,13 @@ private function addDatabaseRecordSubItem(): void } $recordIndex = $this->getSelectedRecordIndex(); - $entryIndex = $database->addSubItem($recordIndex); + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->addEntry($database, $recordIndex, [])); - if ($entryIndex === null) { + if ($change?->command === null) { return; } - $entry = $database->getRecordByIndex($recordIndex)?->getSubList($subList->key)[$entryIndex] ?? []; - - $this->recordCommand(new GenericCommand( - sprintf('%s add', ucfirst($subList->singular)), - static fn() => $database->insertSubItem($recordIndex, $entryIndex, $entry), - static fn() => $database->removeSubItem($recordIndex, $entryIndex), - )); - + $entryIndex = (int) $change->index; $this->setStatus(sprintf('Added %s %d.', $subList->singular, $entryIndex + 1), StatusLevel::INFO); $this->renderDatabaseArea(); if ($database->schema->key === 'skits') { @@ -9450,18 +9397,12 @@ private function removeDatabaseRecordSubItem(): void return; } - $removed = $database->removeSubItem($recordIndex, $entryIndex); + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->removeEntry($database, $recordIndex, [], $entryIndex)); - if ($removed === null) { + if ($change?->command === null) { return; } - $this->recordCommand(new GenericCommand( - sprintf('%s remove', ucfirst($subList->singular)), - static fn() => $database->removeSubItem($recordIndex, $entryIndex), - static fn() => $database->insertSubItem($recordIndex, $entryIndex, $removed), - )); - $this->databaseSelectedSettingIndex = 0; $this->setStatus(sprintf('Removed %s %d.', $subList->singular, $entryIndex + 1), StatusLevel::INFO); $this->renderDatabaseArea(); @@ -9500,19 +9441,13 @@ private function addDatabaseNestedSubItem(): void $recordIndex = $this->getSelectedRecordIndex(); $framePath = $this->databaseCommandFramePath; $parentIndex = $context['parentIndex']; - $nestedIndex = $database->addFrameNestedItem($recordIndex, $framePath, $parentIndex); + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->addNestedItem($database, $recordIndex, $framePath, $parentIndex)); - if ($nestedIndex === null) { + if ($change?->command === null) { return; } - $entry = $context['list']->blank; - - $this->recordCommand(new GenericCommand( - sprintf('%s add', ucfirst($context['list']->singular)), - static fn() => $database->addFrameNestedItem($recordIndex, $framePath, $parentIndex, $entry, $nestedIndex), - static fn() => $database->removeFrameNestedItem($recordIndex, $framePath, $parentIndex, $nestedIndex), - )); + $nestedIndex = (int) $change->index; $this->setStatus( sprintf('Added %s %d.', $context['list']->singular, $nestedIndex + 1), StatusLevel::INFO, @@ -9541,17 +9476,12 @@ private function removeDatabaseNestedSubItem(): void return; } - $removed = $database->removeFrameNestedItem($recordIndex, $framePath, $parentIndex, $nestedIndex); + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->removeNestedItem($database, $recordIndex, $framePath, $parentIndex, $nestedIndex)); - if ($removed === null) { + if ($change?->command === null) { return; } - $this->recordCommand(new GenericCommand( - sprintf('%s remove', ucfirst($context['list']->singular)), - static fn() => $database->removeFrameNestedItem($recordIndex, $framePath, $parentIndex, $nestedIndex), - static fn() => $database->addFrameNestedItem($recordIndex, $framePath, $parentIndex, $removed, $nestedIndex), - )); $this->databaseSelectedSettingIndex = min( $this->databaseSelectedSettingIndex, max(0, count($this->getDatabaseSettingsFields()) - 1), @@ -9563,6 +9493,31 @@ private function removeDatabaseNestedSubItem(): void $this->renderDatabaseArea(); } + /** + * Makes one record change through the shared record rules and records + * it, reporting a refusal on the status line instead. + * + * @param Closure(RecordAuthoring): RecordChange $change The change. + * @return RecordChange|null What it did, or null when it was refused. + */ + private function applyRecordChange(Closure $change): ?RecordChange + { + try { + $applied = $change(new RecordAuthoring()); + } catch (RecordRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderDatabaseArea(); + + return null; + } + + if ($applied->command !== null) { + $this->recordCommand($applied->command); + } + + return $applied; + } + /** * Phrases a read-only category's reason for the status line. * @@ -10403,48 +10358,6 @@ private function actorStatPreviewFields(ProjectActor $actor): array return [...$rows, ...$this->actorOptimizeFields($actor)]; } - /** - * Reports a field value the editor will not guess at, leaving the record - * untouched. - * - * A project-owned parameter line has an explicit grammar, and repairing - * a malformed one silently is how an author loses a value without being - * told. The status line says what is wrong with it instead. - * - * @param string $field The field being edited. - * @param string $rawValue The value typed. - * @return bool True when the value was rejected. - */ - private function isDatabaseFieldRejected(string $field, string $rawValue): bool - { - if (! $this->workspace instanceof ProjectWorkspace) { - return false; - } - - $database = $this->getSelectedRecordDatabase(); - $schema = $database?->schema; - - if ($schema === null) { - return false; - } - - foreach ($schema->fields as $declared) { - if ($declared->key !== $field || $declared->codec !== RecordFieldCodec::KEY_VALUES) { - continue; - } - - try { - ParameterMapCodec::decode($rawValue); - } catch (ParameterMapSyntaxError $error) { - $this->setStatus($error->getMessage(), StatusLevel::ERROR); - - return true; - } - } - - return false; - } - /** * Returns which permanent growth this actor's preview assumes. * @@ -12440,6 +12353,21 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) } $fieldId = (string) ($field['field'] ?? ''); + $recordDatabase = $this->getSelectedRecordDatabase(); + + if ($recordDatabase instanceof ProjectRecordDatabase) { + // The shared record rules apply the edit and say how to undo it: + // the record's whole payload, pinned to the record itself, so an + // undo after the selection moved still edits the right one. + $change = $this->applyRecordFieldValue($recordDatabase, $fieldId, $rawValue, (string) ($field['label'] ?? 'Database field')); + + if ($change?->command !== null) { + $this->recordCommand($change->command); + } + + return; + } + $control = $this->getDatabaseFieldControl($field); // Option values keep their authored case (the actor class picker // writes `Vanguard`); matching them is case-insensitive instead. @@ -12460,10 +12388,9 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) 'records' => $this->databaseSelectedRecordIndexes, ]; - $record = $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()); - if ($this->isSystemDatabaseSelected() && $fieldId === ProjectConfig::FIELD_ZOOM) { - $record = $this->workspace?->config?->getRecord(ProjectConfig::FIELD_ZOOM); - } + $record = $this->isSystemDatabaseSelected() && $fieldId === ProjectConfig::FIELD_ZOOM + ? $this->workspace?->config?->getRecord(ProjectConfig::FIELD_ZOOM) + : null; $before = $record?->toArray(); $actor = $this->isActorsDatabaseSelected() ? $this->getSelectedActor() : null; $actorBefore = $actor?->getData(); @@ -12513,6 +12440,37 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) )); } + /** + * Applies one row of a schema-driven record through the shared record + * rules ({@see RecordAuthoring::applyField()}), at the selected record + * and open frame. A value the field cannot take - a parameter line it + * cannot read, a coordinate pair that is not two numbers - is reported + * on the status line, and the record is left as it was. + * + * @param ProjectRecordDatabase $database The category. + * @param string $fieldId The row's field id. + * @param string $rawValue The raw edited value. + * @param string $label The row's label, which names the undo step. + * @return RecordChange|null What it did, or null when it was refused. + */ + private function applyRecordFieldValue(ProjectRecordDatabase $database, string $fieldId, string $rawValue, string $label): ?RecordChange + { + try { + return (new RecordAuthoring())->applyField( + $database, + $this->getSelectedRecordIndex(), + $this->databaseCommandFramePath, + $fieldId, + $rawValue, + $label, + ); + } catch (RecordRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::ERROR); + + return null; + } + } + /** * Applies a database value onto a pinned entry identity, restoring the * live selection afterwards. @@ -12622,10 +12580,6 @@ private function applyDatabaseFieldValue(string $field, string $rawValue): void return; } - if ($this->isDatabaseFieldRejected($field, $rawValue)) { - return; - } - if ($this->isActorsDatabaseSelected()) { $this->workspace->actorDatabase->setField( $this->databaseSelectedActorIndex, @@ -12684,19 +12638,8 @@ private function applyDatabaseFieldValue(string $field, string $rawValue): void if ($recordDatabase instanceof ProjectRecordDatabase) { // The schema owns coercion, so no per-category intval/trim rules - // are needed here. - if ($recordDatabase->hasCommandFrames()) { - $recordDatabase->setFrameField( - $this->getSelectedRecordIndex(), - $this->databaseCommandFramePath, - $field, - $rawValue, - ); - - return; - } - - $recordDatabase->setField($this->getSelectedRecordIndex(), $field, $rawValue); + // are needed here. This path keeps no undo step. + $this->applyRecordFieldValue($recordDatabase, $field, $rawValue, $field); return; } diff --git a/src/Field/NpcAuthoring.php b/src/Field/NpcAuthoring.php index b792129..f306914 100644 --- a/src/Field/NpcAuthoring.php +++ b/src/Field/NpcAuthoring.php @@ -4,6 +4,10 @@ namespace Ichiloto\Editor\Field; +use Ichiloto\Editor\Database\RecordAuthoring; +use Ichiloto\Editor\Database\RecordChange; +use Ichiloto\Editor\Database\RecordItem; +use Ichiloto\Editor\Database\RecordRefusal; use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\MapSourceRefusal; @@ -229,9 +233,11 @@ public function applyField(NpcInspector $inspector, int $index, array $framePath /** * Adds an item at a row of the NPC's record pane: inside a script frame, - * a route step under the route the row belongs to or a command after - * the row's command (at the end when the row names none); at the root, - * a line in the row's dialogue variant, or a new variant. + * a route step under the route the row belongs to, another option on a + * choice option's row, or a command after the row's command (at the + * end when the row names none); at the root, a line in the row's + * dialogue variant, or a new variant. Which item a row belongs to, and + * how it is added, are the shared record rules' ({@see RecordAuthoring}). * * @param array $framePath The frame the row belongs to. * @param string $fieldId The row's field id; '' for none. @@ -240,36 +246,26 @@ public function applyField(NpcInspector $inspector, int $index, array $framePath */ public function addSubItem(NpcInspector $inspector, int $index, array $framePath, string $fieldId): NpcChange { - $map = $inspector->map; - $before = $map->getNpcs(); - $this->requireFrame($inspector, $before, $index, $framePath); $records = $inspector->records(); + $item = $records->locateItem($index, $framePath, $fieldId); + // Inside a frame, any row of a command that owns steps adds a step. $nested = $framePath !== [] ? $records->frameNestedContext($index, $framePath, $fieldId) : null; - try { - if ($nested !== null) { - $records->addFrameNestedItem($index, $framePath, $nested['parentIndex']); - } elseif ($framePath !== []) { - $after = preg_match('/^command(\d+)/', $fieldId, $matches) === 1 ? intval($matches[1]) : null; - $records->addFrameCommand($index, $framePath, $after); - } elseif (preg_match('/^variant(\d+)Line/', $fieldId, $matches) === 1) { - $records->addNestedSubItem($index, intval($matches[1])); - } else { - $records->addSubItem($index); - } - - $inspector->commit(); - } finally { - $inspector->refresh(); - } - - return $this->createRowChange($map, 'NPC add', $before, $index); + return $this->changeRows($inspector, $index, $framePath, 'NPC add', + static fn(RecordAuthoring $authoring): RecordChange => match (true) { + $nested !== null => $authoring->addNestedItem($records, $index, $framePath, $nested['parentIndex']), + $item?->kind === RecordItem::OPTION => $authoring->addOption($records, $index, $framePath, $item->entryIndex), + $framePath !== [] => $authoring->addEntry($records, $index, $framePath, $item?->entryIndex), + $item?->kind === RecordItem::NESTED => $authoring->addNestedItem($records, $index, $framePath, $item->entryIndex), + default => $authoring->addEntry($records, $index, $framePath), + }); } /** * Removes the item a row of the NPC's record pane belongs to: a route - * step, a command, a dialogue line or a dialogue variant. A row that - * belongs to none changes nothing. + * step, a choice's option, a command, a dialogue line or a dialogue + * variant ({@see RecordAuthoring::removeItem()}). A row that belongs to + * none changes nothing. * * @param array $framePath The frame the row belongs to. * @param string $fieldId The row's field id. @@ -277,33 +273,40 @@ public function addSubItem(NpcInspector $inspector, int $index, array $framePath * @throws MapSourceRefusal When the map's source cannot take the change. */ public function removeSubItem(NpcInspector $inspector, int $index, array $framePath, string $fieldId): NpcChange + { + $records = $inspector->records(); + + return $this->changeRows($inspector, $index, $framePath, 'NPC remove', + static fn(RecordAuthoring $authoring): RecordChange => $authoring->removeItem($records, $index, $framePath, $fieldId)); + } + + /** + * Makes one item change through the record pane and writes it back to + * the map, as one undo step over the map's NPCs. + * + * @param array $framePath + * @param callable(RecordAuthoring): RecordChange $change + * @throws NpcRefusal When there is no such NPC or frame, or the pane refuses the change. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + private function changeRows(NpcInspector $inspector, int $index, array $framePath, string $label, callable $change): NpcChange { $map = $inspector->map; $before = $map->getNpcs(); $this->requireFrame($inspector, $before, $index, $framePath); - $records = $inspector->records(); - $nested = $framePath !== [] ? $records->frameNestedContext($index, $framePath, $fieldId) : null; try { - if ($nested !== null && $nested['nestedIndex'] !== null) { - // The step under the row; a command row removes the command. - $records->removeFrameNestedItem($index, $framePath, $nested['parentIndex'], $nested['nestedIndex']); - } elseif ($framePath !== []) { - if (preg_match('/^command(\d+)/', $fieldId, $matches) === 1) { - $records->removeFrameCommand($index, $framePath, intval($matches[1])); - } - } elseif (preg_match('/^variant(\d+)Line(\d+)/', $fieldId, $matches) === 1) { - $records->removeNestedSubItem($index, intval($matches[1]), intval($matches[2])); - } elseif (preg_match('/^variant(\d+)/', $fieldId, $matches) === 1) { - $records->removeSubItem($index, intval($matches[1])); - } - + // The pane's own undo step is over its records; the map's NPCs are + // what this change's undo restores. + $change(new RecordAuthoring()); $inspector->commit(); + } catch (RecordRefusal $refusal) { + throw new NpcRefusal($refusal->getMessage(), previous: $refusal); } finally { $inspector->refresh(); } - return $this->createRowChange($map, 'NPC remove', $before, $index); + return $this->createRowChange($map, $label, $before, $index); } /** diff --git a/tests/Unit/RecordAuthoringTest.php b/tests/Unit/RecordAuthoringTest.php new file mode 100644 index 0000000..0ba7d0e --- /dev/null +++ b/tests/Unit/RecordAuthoringTest.php @@ -0,0 +1,222 @@ +getEntryLabels()); +} + +/** Undoes and redoes a change's command, checking each lands where it should. */ +function authoringRoundTrip(RecordChange $change, Closure $read, mixed $before, mixed $after): void +{ + expect($change->command)->not->toBeNull() + ->and($read())->toBe($after); + $change->command?->undo(); + expect($read())->toBe($before); + $change->command?->execute(); + expect($read())->toBe($after); +} + +/** A script with a choice first and a movement route second. */ +function authoringScript(ProjectRecordDatabase $events, RecordAuthoring $authoring): void +{ + $authoring->applyField($events, 0, [], 'command0Type', 'choice', 'Command 1 Type'); + $authoring->applyField($events, 0, [], 'command1Type', 'move_route', 'Command 2 Type'); +} + +it('applies a row and undoes it to the exact record, a same-value edit leaving no step', function () { + $states = loadRecordDatabase(makeTemporaryProject(), 'states'); + $authoring = new RecordAuthoring(); + $before = $states->getRecordByIndex(0)?->toArray(); + + $change = $authoring->applyField($states, 0, [], 'name', 'Venom', 'Name'); + + expect($change->command?->label)->toBe('Name edit') + ->and(authoringLabels($states)[0])->toBe('Venom'); + $change->command?->undo(); + expect($states->getRecordByIndex(0)?->toArray())->toBe($before) + ->and($states->isDirty())->toBeFalse(); + $change->command?->execute(); + expect(authoringLabels($states)[0])->toBe('Venom') + ->and($authoring->applyField($states, 0, [], 'name', 'Venom', 'Name')->command)->toBeNull(); +}); + +it('refuses a parameter line or a value its codec cannot read, leaving the record as it was', function () { + $root = makeTemporaryProject(); + file_put_contents($root . '/' . PermanentGrowthCatalog::RELATIVE_PATH, <<<'PHP' + 'growth.synthetic', 'stat' => 'maxHp', 'amount' => 1, 'sourceType' => 'event', 'sourceId' => 'test', 'metadata' => ['label' => 'Synthetic']], + ]; + PHP); + $growth = loadRecordDatabase($root, 'permanent_growth'); + $events = loadRecordDatabase($root, 'common_events'); + $authoring = new RecordAuthoring(); + $growthBefore = $growth->getRecordByIndex(0)?->toArray(); + $eventsBefore = $events->getRecordByIndex(0)?->toArray(); + + expect(fn() => $authoring->applyField($growth, 0, [], 'metadata', 'tier=1, tier=2', 'Metadata')) + ->toThrow(RecordRefusal::class, 'named twice') + ->and(fn() => $authoring->applyField($events, 0, [], 'command3Conditions', 'nonsense', 'Command 4 Conditions')) + ->toThrow(RecordRefusal::class, 'Condition "nonsense" cannot be read') + ->and($growth->getRecordByIndex(0)?->toArray())->toBe($growthBefore) + ->and($events->getRecordByIndex(0)?->toArray())->toBe($eventsBefore) + ->and($growth->isDirty())->toBeFalse() + ->and($events->isDirty())->toBeFalse(); +}); + +it('adds and removes a sub-list entry after the row\'s entry or at the end, each undone exactly', function () { + $troops = loadRecordDatabase(makeTemporaryProject(), 'troops'); + $authoring = new RecordAuthoring(); + $members = static fn(): array => array_column($troops->getRecordByIndex(0)?->getSubList('enemies') ?? [], 'position'); + $before = $members(); + + $after = $authoring->addItem($troops, 0, [], 'member0Enemy'); + expect($after->index)->toBe(1) + ->and($after->command?->label)->toBe('Member add'); + authoringRoundTrip($after, $members, $before, [$before[0], [15, 7], $before[1]]); + $after->command?->undo(); + + $appended = $authoring->addItem($troops, 0, [], null); + expect($appended->index)->toBe(count($before)); + $appended->command?->undo(); + + $removed = $authoring->removeItem($troops, 0, [], 'member1Position1'); + expect($removed->removed)->toMatchArray(['position' => $before[1]]); + authoringRoundTrip($removed, $members, $before, [$before[0]]); + + // The record's own rows belong to no item: nothing to add after, nothing to remove. + expect(fn() => $authoring->addItem($troops, 0, [], 'name'))->toThrow(RecordRefusal::class, 'belongs to no item') + ->and($authoring->removeItem($troops, 0, [], 'name')->command)->toBeNull(); +}); + +it('adds and removes commands inside a frame, and refuses a frame that is gone', function () { + $events = loadRecordDatabase(makeTemporaryProject(), 'common_events'); + $authoring = new RecordAuthoring(); + $then = [3, 'then']; + $types = static fn(): array => array_column($events->getFrameCommands(0, $then) ?? [], 'type'); + $before = $types(); + + $appended = $authoring->addItem($events, 0, $then, null); + expect($appended->index)->toBe(count($before)) + ->and($appended->command?->label)->toBe('Command add'); + authoringRoundTrip($appended, $types, $before, [...$before, 'text']); + + $removed = $authoring->removeItem($events, 0, $then, 'command0Text'); + expect($removed->command?->label)->toBe('Command remove'); + authoringRoundTrip($removed, $types, [...$before, 'text'], ['text']); + + expect(fn() => $authoring->addItem($events, 0, [9, 'then'], null))->toThrow(RecordRefusal::class, 'is no longer there') + ->and(fn() => $authoring->applyField($events, 0, [9, 'then'], 'command0Text', 'x', 'Text')) + ->toThrow(RecordRefusal::class, 'is no longer there'); +}); + +it('locates the item a row belongs to and adds beneath an entry that holds a list', function () { + $events = loadRecordDatabase(makeTemporaryProject(), 'common_events'); + $authoring = new RecordAuthoring(); + authoringScript($events, $authoring); + + $choice = $events->locateItem(0, [], 'command0Prompt'); + $route = $events->locateItem(0, [], 'command1Subject'); + expect([$choice?->kind, $choice?->childKind, $choice?->childNoun])->toBe([RecordItem::ENTRY, RecordItem::OPTION, 'option']) + ->and([$route?->kind, $route?->childKind, $route?->childNoun])->toBe([RecordItem::ENTRY, RecordItem::NESTED, 'route step']) + ->and($events->locateItem(0, [], '__scriptId'))->toBeNull() + ->and(fn() => $authoring->addItem($events, 0, [], 'command2Amount', child: true)) + ->toThrow(RecordRefusal::class, 'holds nothing that can be added beneath it'); + + $options = static fn(): array => array_column($events->getFrameCommands(0, [])[0]['options'] ?? [], 'text'); + $firstOption = $authoring->addItem($events, 0, [], 'command0Prompt', child: true); + expect($firstOption->command?->label)->toBe('Option add'); + authoringRoundTrip($firstOption, $options, [], ['New option']); + $authoring->applyField($events, 0, [], 'command0Option0Text', 'Read it', 'Option 1 Text'); + $authoring->addItem($events, 0, [], 'command0Option0Text'); + expect($options())->toBe(['Read it', 'New option']) + ->and($events->locateItem(0, [], 'command0Option1Then'))->toEqual(new RecordItem(RecordItem::OPTION, [], 0, 1, 'option')); + authoringRoundTrip($authoring->removeItem($events, 0, [], 'command0Option0Text'), $options, ['Read it', 'New option'], ['New option']); + + $steps = static fn(): array => array_column($events->getFrameCommands(0, [])[1]['steps'] ?? [], 'direction'); + $firstStep = $authoring->addItem($events, 0, [], 'command1Subject', child: true); + expect($firstStep->command?->label)->toBe('Route step add'); + authoringRoundTrip($firstStep, $steps, [], ['down']); + $authoring->applyField($events, 0, [], 'command1Step0Direction', 'up', 'Step 1 Direction'); + $authoring->addItem($events, 0, [], 'command1Step0Count'); + expect($steps())->toBe(['up', 'down']) + ->and($events->locateItem(0, [], 'command1Step1Count')?->kind)->toBe(RecordItem::NESTED); + authoringRoundTrip($authoring->removeItem($events, 0, [], 'command1Step0Direction'), $steps, ['up', 'down'], ['down']); +}); + +it('creates, duplicates and deletes records, each one undo step', function () { + $states = loadRecordDatabase(makeTemporaryProject(), 'states'); + $authoring = new RecordAuthoring(); + $labels = static fn(): array => authoringLabels($states); + $before = $labels(); + + $created = $authoring->createRecord($states); + expect($created->index)->toBe(2) + ->and($created->command?->label)->toBe('State create'); + authoringRoundTrip($created, $labels, $before, [...$before, 'New State']); + $created->command?->undo(); + + $copy = $authoring->duplicateRecord($states, 0); + expect($copy->index)->toBe(1); + authoringRoundTrip($copy, $labels, $before, [$before[0], $before[0], $before[1]]); + $copy->command?->undo(); + + $deleted = $authoring->deleteRecord($states, 1); + expect($deleted->index)->toBe(0) + ->and($deleted->command?->label)->toBe(sprintf('Delete state %s', $before[1])); + authoringRoundTrip($deleted, $labels, $before, [$before[0]]); + expect($authoring->deleteRecord($states, 0)->index)->toBeNull(); +}); + +it('moves a record only where its file keeps the order, saying why elsewhere', function () { + $root = makeTemporaryProject(); + $rules = loadRecordDatabase($root, 'battle_entry_rules'); + + foreach (['alpha', 'beta'] as $id) { + $index = $rules->addRecord(); + $rules->setField((int) $index, 'id', $id); + } + + $authoring = new RecordAuthoring(); + $labels = static fn(): array => authoringLabels($rules); + $before = $labels(); + $moved = $authoring->moveRecord($rules, 0, 1); + + expect($moved->index)->toBe(1) + ->and($authoring->moveRecord($rules, 1, 1)->command)->toBeNull(); + authoringRoundTrip($moved, $labels, $before, array_reverse($before)); + + $states = loadRecordDatabase($root, 'states'); + expect(fn() => $authoring->moveRecord($states, 0, 1))->toThrow(RecordRefusal::class, (string) $states->reorderRefusalReason()) + ->and($states->isDirty())->toBeFalse(); +}); + +it('refuses every change to a read-only category with its reason', function () { + $types = loadRecordDatabase(makeTemporaryProject(), 'types'); + $authoring = new RecordAuthoring(); + $reason = sprintf('Read-only: %s.', $types->getReadOnlyReason()); + + expect(fn() => $authoring->applyField($types, 0, [], 'file', 'x', 'File'))->toThrow(RecordRefusal::class, $reason) + ->and(fn() => $authoring->createRecord($types))->toThrow(RecordRefusal::class, $reason) + ->and(fn() => $authoring->deleteRecord($types, 0))->toThrow(RecordRefusal::class, $reason) + ->and(fn() => $authoring->addItem($types, 0, [], null))->toThrow(RecordRefusal::class, $reason); +}); diff --git a/tests/Unit/RecordCategoryEditorTest.php b/tests/Unit/RecordCategoryEditorTest.php index c274498..2afba2d 100644 --- a/tests/Unit/RecordCategoryEditorTest.php +++ b/tests/Unit/RecordCategoryEditorTest.php @@ -227,3 +227,50 @@ removeDirectoryRecursively($root); } }); + +it('creates a record through the shared record rules as one undo step', function (): void { + $editor = deletionEditor(makeTemporaryProject()); + openDatabaseCategory($editor, 'states'); + $database = getEditorProperty($editor, 'workspace')->getRecordDatabase('states'); + $before = $database->getEntryLabels(); + + callEditorMethod($editor, 'createDatabaseRecord'); + // The new record opens its first row for editing; leave it unedited. + setEditorProperty($editor, 'isDatabaseEditing', false); + + expect($database->getEntryLabels())->toHaveCount(count($before) + 1) + ->and(callEditorMethod($editor, 'getSelectedRecordIndex'))->toBe(count($before)); + + callEditorMethod($editor, 'performUndo'); + + expect($database->getEntryLabels())->toBe($before) + ->and($database->isDirty())->toBeFalse(); +}); + +it('reports a parameter line it cannot read and leaves the record and history as they were', function (): void { + $root = makeTemporaryProject(); + file_put_contents($root . '/' . \Ichiloto\Editor\PermanentGrowthCatalog::RELATIVE_PATH, <<<'SOURCE' + 'growth.synthetic', 'stat' => 'maxHp', 'amount' => 1, 'sourceType' => 'event', 'sourceId' => 'test', 'metadata' => ['label' => 'Synthetic']], + ]; + SOURCE); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'permanent_growth'); + $database = getEditorProperty($editor, 'workspace')->getRecordDatabase('permanent_growth'); + $before = $database->getRecordByIndex(0)?->toArray(); + $rows = array_column(callEditorMethod($editor, 'getDatabaseSettingsFields'), 'field'); + setEditorProperty($editor, 'databaseSelectedSettingIndex', array_search('metadata', $rows, true)); + setEditorProperty($editor, 'isDatabaseEditing', true); + setEditorProperty($editor, 'databaseEditBuffer', 'tier=1, tier=2'); + getEditorProperty($editor, 'toasts')->clear(); + + callEditorMethod($editor, 'commitDatabaseEdit'); + + // The refusal is what the author reads, not an edit reported as made. + expect(getEditorProperty($editor, 'statusMessage'))->toContain('named twice') + ->and(getEditorProperty($editor, 'statusMessage'))->not->toContain('updated') + ->and($database->getRecordByIndex(0)?->toArray())->toBe($before) + ->and(getEditorProperty($editor, 'history')->canUndo())->toBeFalse(); +}); From 8791a89ab6060a03c5beb51ba7e6ea73069cd8df Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 16:53:34 +0200 Subject: [PATCH 129/344] feat(session): edit schema database records through the session The GUI's F3 database manager edits records in the same property grid as the inspector, so the session serves the shared record rules (RecordAuthoring) over every schema-driven category: - database.records adds dirty, canCreate, canDuplicate, canDelete and canReorder. - database.record takes an optional frame and answers frame, frameLabel, editable and readOnly with its rows. A row of an item the record's lists hold carries item and itemNoun, and childNoun when the item holds a list of its own; a row that opens a command frame carries that frame. - database.apply, database.add (a row key adds after its item, child adds beneath it, a frame alone adds at the end) and database.remove answer changed and the record labels. - database.create, database.duplicate, database.delete (the record to select next) and database.move (up or down, refused with its reason where the file keeps no order) answer index and the labels. - database.save backs up the files it overwrites and saves the category. Each change is one step in the session history; history.undo and history.redo now name the database categories a step changed. npc.read rows carry the same item fields, from the same rules. The bespoke categories are still refused, as before. --- src/History/TracksPersistedState.php | 14 +- src/Session/EditorSession.php | 376 ++++++++++++++++++--- src/Session/SessionHost.php | 32 +- tests/Unit/Session/DatabaseSessionTest.php | 199 +++++++++++ 4 files changed, 575 insertions(+), 46 deletions(-) create mode 100644 tests/Unit/Session/DatabaseSessionTest.php diff --git a/src/History/TracksPersistedState.php b/src/History/TracksPersistedState.php index 2961ceb..d5aeb7d 100644 --- a/src/History/TracksPersistedState.php +++ b/src/History/TracksPersistedState.php @@ -84,6 +84,18 @@ public function stateVersion(): int return $this->stateGeneration; } + /** + * Returns a key that changes whenever this asset's content may have, + * its children's included, so a caller can tell whether a step touched + * it without fingerprinting anything. + * + * @return string The key. + */ + public function getContentVersion(): string + { + return $this->stateGeneration . '|' . $this->dependencyVersion(); + } + /** * Returns the combined version of children this asset's payload embeds. * @@ -108,7 +120,7 @@ protected function dependencyVersion(): string */ protected function stateFingerprint(): string { - $version = $this->stateGeneration . '|' . $this->dependencyVersion(); + $version = $this->getContentVersion(); if ($this->fingerprintedVersion !== $version) { $this->currentFingerprint = sha1($this->buildPersistedPayload()); diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index c7e9408..008b80c 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -8,11 +8,16 @@ use Ichiloto\Editor\Backup\BackupWriter; use Ichiloto\Editor\Canvas\CanvasEditor; use Ichiloto\Editor\Canvas\PieceRole; +use Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal; use Ichiloto\Editor\Database\ConditionCodec; use Ichiloto\Editor\Database\ConditionEditor; use Ichiloto\Editor\Database\DatabaseCatalog; use Ichiloto\Editor\Database\ProjectRecordDatabase; +use Ichiloto\Editor\Database\RecordAuthoring; +use Ichiloto\Editor\Database\RecordChange; +use Ichiloto\Editor\Database\RecordRefusal; use Ichiloto\Editor\Database\ReferenceCatalog; +use Ichiloto\Editor\Database\SourceIdentityConflict; use Ichiloto\Editor\Database\WorldWriteCodec; use Ichiloto\Editor\Database\WorldWriteEditor; use Ichiloto\Editor\Events\EventAuthoring; @@ -1006,12 +1011,13 @@ public function listReferences(string $mapId, string $category): array } /** - * A database category's records, as its list shows them, and whether - * they can be edited. The bespoke categories (actors, classes, skills, + * A database category's records, as its list shows them, whether they + * can be edited and have unsaved changes, and which record operations + * the category takes. The bespoke categories (actors, classes, skills, * animations, quests, system) still build their rows in the terminal * editor, so they are refused here until those builders are shared. * - * @return array{category: string, editable: bool, readOnly: ?string, records: list} + * @return array{category: string, editable: bool, readOnly: ?string, dirty: bool, canCreate: bool, canDuplicate: bool, canDelete: bool, canReorder: bool, records: list} * @throws SessionRefusal When the category is unknown or not yet served. */ public function listDatabaseRecords(string $category): array @@ -1022,33 +1028,328 @@ public function listDatabaseRecords(string $category): array 'category' => $category, 'editable' => $database->isEditable(), 'readOnly' => $database->getReadOnlyReason(), - 'records' => array_values(array_map('strval', $database->getEntryLabels())), + 'dirty' => $database->isDirty(), + 'canCreate' => $database->supportsRecordCreation(), + 'canDuplicate' => $database->duplicateRecordSupported(), + 'canDelete' => $database->supportsRecordDeletion(), + // Only where the file keeps the order; a move elsewhere is refused with why. + 'canReorder' => $database->supportsDurableReorder(), + 'records' => self::listRecordLabels($database), ]; } /** - * One record's rows, described as the inspector's are. + * One record's rows in a frame of its commands, described as the + * inspector's are, with the `key` an edit names a row by. At the root + * (`frame` []) the rows are the record's fields and its list's entries; + * a row that opens a command frame carries that `frame`, read again with + * it to edit the commands inside. A row of an item the record's lists + * hold carries `item` and `itemNoun`, and `childNoun` when the item holds + * a list of its own (a route's steps, a choice's options): add and + * remove act on it. * - * @return array{category: string, index: int, rows: list>} - * @throws SessionRefusal When the category or record is unknown. + * @param array $frame The frame; [] for the record itself. + * @return array{category: string, index: int, frame: list, frameLabel: ?string, editable: bool, readOnly: ?string, rows: list>} + * @throws SessionRefusal When the category, record or frame is unknown. */ - public function readDatabaseRecord(string $category, int $index): array + public function readDatabaseRecord(string $category, int $index, array $frame = []): array { $database = $this->requireRecordDatabase($category); - if ($database->getRecordByIndex($index) === null) { - throw new SessionRefusal(sprintf('%s has no record %d.', $category, $index)); - } + $frame = self::requireFrame($frame, 'database.record'); + $fields = $this->collectRecordFields($database, $index, $frame); return [ 'category' => $category, 'index' => $index, - 'rows' => array_map(static fn(array $field): array => self::describeRow([ - ...$field, - 'target' => isset($field['field']) && ($field['editable'] ?? true) !== false ? 'record' : null, - ]), $database->getSettingsFields($index)), + 'frame' => $frame, + 'frameLabel' => $frame === [] ? null : $database->describeFramePath($frame), + 'editable' => $database->isEditable(), + 'readOnly' => $database->getReadOnlyReason(), + 'rows' => array_map(static fn(array $field): array => self::describeRecordRow( + $database, + $index, + $frame, + $field, + isset($field['field']) && ($field['editable'] ?? true) !== false ? 'record' : null, + ), $fields), ]; } + /** + * Applies one record row's edit as one undo step. The row is found again + * by its key among the record's current rows in its frame. A value its + * field cannot take is refused with what is wrong with it. + * + * @param array $key The row's key, as `database.record` gave it. + * @return array{changed: bool, records: list} Whether it changed, and the labels afterwards (a rename shows). + * @throws SessionRefusal When the category or record is unknown or read-only, the row is gone or read-only, or the value is refused. + */ + public function applyDatabaseRecord(string $category, int $index, array $key, string $value): array + { + $database = $this->requireRecordDatabase($category); + $frame = self::requireFrame($key['frame'] ?? [], 'database.record'); + $fieldId = $key['field'] ?? null; + // Field ids are unique within a frame, so the field and frame name the row. + $field = is_string($fieldId) ? array_find($this->collectRecordFields($database, $index, $frame), + static fn(array $candidate): bool => ($candidate['field'] ?? null) === $fieldId) : null; + + if (! is_string($fieldId) || $field === null) { + throw new SessionRefusal('That row is no longer in the record; read it again.'); + } + if (($field['editable'] ?? true) === false || self::describeRow([...$field, 'target' => 'record'])['kind'] === 'info') { + throw new SessionRefusal(sprintf('%s cannot be edited here.', trim((string) ($field['label'] ?? 'That row')))); + } + + $change = $this->changeRecord(static fn(RecordAuthoring $authoring): RecordChange => $authoring->applyField( + $database, $index, $frame, $fieldId, $value, (string) ($field['label'] ?? 'Database field'), + )); + + return ['changed' => $change->command !== null, 'records' => self::listRecordLabels($database)]; + } + + /** + * Adds an item at a record row as one undo step: after the item the row + * belongs to, or with `$child` at the end of what that item holds (a + * route's steps, a choice's options). A key of `{frame}` alone adds at + * the end of that frame, or of the record's own list. + * + * @param array $key A row's key, as `database.record` gave it, or `{frame}` alone. + * @return array{changed: bool, records: list} + * @throws SessionRefusal When the category, record or frame is unknown or read-only, or the row's item cannot take it. + */ + public function addDatabaseItem(string $category, int $index, array $key, bool $child = false): array + { + $database = $this->requireRecordDatabase($category); + $frame = self::requireFrame($key['frame'] ?? [], 'database.record'); + $fieldId = $key['field'] ?? null; + + if ($fieldId !== null && ! is_string($fieldId)) { + throw new SessionRefusal('A row key names its field as a string, as database.record gave it.'); + } + + $change = $this->changeRecord(static fn(RecordAuthoring $authoring): RecordChange => $authoring->addItem($database, $index, $frame, $fieldId, $child)); + + return ['changed' => $change->command !== null, 'records' => self::listRecordLabels($database)]; + } + + /** + * Removes the item a record row belongs to (an entry or command with + * everything under it, a route step, line or lane, a choice's option + * with its arm) as one undo step; a row that belongs to none changes + * nothing. + * + * @param array $key A row's key, as `database.record` gave it. + * @return array{changed: bool, records: list} + * @throws SessionRefusal When the category, record or frame is unknown or read-only, or the key names no row. + */ + public function removeDatabaseItem(string $category, int $index, array $key): array + { + $database = $this->requireRecordDatabase($category); + $frame = self::requireFrame($key['frame'] ?? [], 'database.record'); + $fieldId = $key['field'] ?? null; + + if (! is_string($fieldId)) { + throw new SessionRefusal('Name the row whose item to remove by its key, as database.record gave it.'); + } + + $change = $this->changeRecord(static fn(RecordAuthoring $authoring): RecordChange => $authoring->removeItem($database, $index, $frame, $fieldId)); + + return ['changed' => $change->command !== null, 'records' => self::listRecordLabels($database)]; + } + + /** + * Creates a blank record at the end of a category, as one undo step. + * + * @return array{index: int, records: list} The new record's index, and the labels afterwards. + * @throws SessionRefusal When the category is unknown, read-only or takes no new records. + */ + public function createDatabaseRecord(string $category): array + { + $database = $this->requireRecordDatabase($category); + $change = $this->changeRecord(static fn(RecordAuthoring $authoring): RecordChange => $authoring->createRecord($database)); + + return ['index' => (int) $change->index, 'records' => self::listRecordLabels($database)]; + } + + /** + * Duplicates a record below itself under a fresh identity, as one undo step. + * + * @return array{index: int, records: list} The copy's index, and the labels afterwards. + * @throws SessionRefusal When the category or record is unknown or read-only, or the record cannot be duplicated. + */ + public function duplicateDatabaseRecord(string $category, int $index): array + { + $database = $this->requireRecordDatabase($category); + $change = $this->changeRecord(static fn(RecordAuthoring $authoring): RecordChange => $authoring->duplicateRecord($database, $index)); + + return ['index' => (int) $change->index, 'records' => self::listRecordLabels($database)]; + } + + /** + * Deletes a record as one undo step; the file changes on save. + * + * @return array{index: ?int, records: list} The record to select next (null when none is left), and the labels afterwards. + * @throws SessionRefusal When the category or record is unknown or read-only, or the category keeps its entries. + */ + public function deleteDatabaseRecord(string $category, int $index): array + { + $database = $this->requireRecordDatabase($category); + $change = $this->changeRecord(static fn(RecordAuthoring $authoring): RecordChange => $authoring->deleteRecord($database, $index)); + + return ['index' => $change->index, 'records' => self::listRecordLabels($database)]; + } + + /** + * Moves a record one place up or down, as one undo step, where the file + * keeps the order. A move past either end changes nothing. + * + * @param string $direction "up" or "down". + * @return array{index: int, changed: bool, records: list} Where the record is now, whether it moved, and the labels afterwards. + * @throws SessionRefusal When the category or record is unknown or read-only, the direction is neither, or the file would not keep the order (with why). + */ + public function moveDatabaseRecord(string $category, int $index, string $direction): array + { + $database = $this->requireRecordDatabase($category); + $step = match ($direction) { + 'up' => -1, + 'down' => 1, + default => throw new SessionRefusal(sprintf('A record moves "up" or "down", not "%s".', $direction)), + }; + $change = $this->changeRecord(static fn(RecordAuthoring $authoring): RecordChange => $authoring->moveRecord($database, $index, $step)); + + return ['index' => (int) $change->index, 'changed' => $change->command !== null, 'records' => self::listRecordLabels($database)]; + } + + /** + * Saves one database category, backing up the files it overwrites + * first, as the terminal editor's save does. A category with nothing + * unsaved writes nothing (`saved` false). No database validation runs on + * save, so there are no warnings yet. + * + * @return array{saved: bool, warnings: list, backupFailures: list} + * @throws SessionRefusal When the category is unknown or read-only, or its source cannot take the save. + */ + public function saveDatabase(string $category): array + { + $database = $this->requireRecordDatabase($category); + if (! $database->isEditable()) { + throw new SessionRefusal(sprintf('Read-only: %s.', $database->getReadOnlyReason() ?? 'this category cannot be written')); + } + if (! $database->isDirty()) { + return ['saved' => false, 'warnings' => [], 'backupFailures' => []]; + } + $paths = $database->getBackupPaths(); + $failures = $this->backups->isEnabled() && $paths !== [] ? $this->backups->backup(...$paths)['failed'] : []; + + try { + $database->save(); + } catch (SourceIdentityConflict|SourcePreservationRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + + return ['saved' => true, 'warnings' => [], 'backupFailures' => array_values($failures)]; + } + + /** + * Makes one record change, records it, and turns a refusal into the session's. + * + * @param callable(RecordAuthoring): RecordChange $change + * @throws SessionRefusal + */ + private function changeRecord(callable $change): RecordChange + { + try { + $applied = $change(new RecordAuthoring()); + } catch (RecordRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($applied->command !== null) { + $this->history->record($applied->command); + } + + return $applied; + } + + /** + * A record's rows in a frame, refusing a record or frame that is gone. + * + * @param list $frame + * @return array> + * @throws SessionRefusal + */ + private function collectRecordFields(ProjectRecordDatabase $database, int $index, array $frame): array + { + if ($database->getRecordByIndex($index) === null) { + throw new SessionRefusal(sprintf('%s has no record %d.', $database->schema->key, $index)); + } + if ($frame !== [] && (! $database->hasCommandFrames() || $database->getFrameCommands($index, $frame) === null)) { + throw new SessionRefusal(sprintf('%s is no longer there; read the record again.', $database->describeFramePath($frame))); + } + + return $database->getFrameSettingsFields($index, $frame); + } + + /** @return list A category's record labels, as its list shows them. */ + private static function listRecordLabels(ProjectRecordDatabase $database): array + { + return array_values(array_map('strval', $database->getEntryLabels())); + } + + /** + * One row of a record pane (a database record's or an NPC's) as plain + * data: described as an inspector row, its key naming the frame it lives + * in, with the frame it opens, its exact multi-line text, and the item + * it belongs to as the record's own rules locate it. + * + * @param array $frame + * @param array $field + * @param string|null $target The key's target, or null for a row no edit applies through. + * @return array + */ + private static function describeRecordRow(ProjectRecordDatabase $records, int $index, array $frame, array $field, ?string $target): array + { + $row = self::describeRow([...$field, 'target' => $target]); + + if (isset($row['key'])) { + $row['key']['frame'] = $frame; + $item = $records->locateItem($index, $frame, (string) ($field['field'] ?? '')); + + if ($item !== null) { + $row['item'] = true; + $row['itemNoun'] = $item->noun; + if ($item->childNoun !== null) { + $row['childNoun'] = $item->childNoun; + } + } + } + if (is_array($field['frame'] ?? null)) { + $row['frame'] = array_values($field['frame']); + } + $control = MapInspector::findControl($field); + if ($control?->type === InputControlType::MULTILINE) { + $row['multiline'] = true; + $row['value'] = $control->rawValue; + } + + return $row; + } + + /** + * A frame of a record's commands, as a request or a row key names it. + * + * @param string $reader What gave the frame, for the refusal. + * @return list + * @throws SessionRefusal When the frame is not a list of indexes and keys. + */ + private static function requireFrame(mixed $frame, string $reader): array + { + if (! is_array($frame) || ! array_is_list($frame) || ! array_all($frame, static fn(mixed $segment): bool => is_int($segment) || is_string($segment))) { + throw new SessionRefusal(sprintf('A frame must be a list of indexes and keys, as %s gave it.', $reader)); + } + + return $frame; + } + /** * Creates an NPC at a tile ({@see NpcAuthoring::create()}): fixed, its * stable id derived from its name, a blank name taking the placeholder. @@ -1151,7 +1452,7 @@ public function readNpc(string $mapId, int $index, array $frame = []): array 'y' => $npc->getY(), 'sprite' => $npc->getVisibleSprite(), ], - 'rows' => array_map(static fn(array $field): array => self::describeNpcRow($field, $frame), $fields), + 'rows' => array_map(static fn(array $field): array => self::describeNpcRow($inspector->records(), $index, $field, $frame), $fields), ]; } @@ -1177,7 +1478,7 @@ public function applyNpc(string $mapId, int $revision, int $index, array $key, s if ($field === null) { throw new SessionRefusal('That row is no longer on the NPC; read it again.'); } - if (self::describeNpcRow($field, $frame)['kind'] === 'info') { + if (self::describeNpcRow($inspector->records(), $index, $field, $frame)['kind'] === 'info') { throw new SessionRefusal(sprintf('%s cannot be edited here.', trim((string) ($field['label'] ?? 'That row')))); } @@ -1274,33 +1575,20 @@ private function collectNpcFields(NpcInspector $inspector, int $index, array $fr } /** - * One NPC row as plain data: described as an inspector row, its key - * naming the frame it lives in. The id note is read here; assigning an + * One NPC row as plain data, as any record pane's row is + * ({@see describeRecordRow()}). The id note is read here; assigning an * id is its own request. * * @param array $field * @param list $frame * @return array */ - private static function describeNpcRow(array $field, array $frame): array + private static function describeNpcRow(ProjectRecordDatabase $records, int $index, array $field, array $frame): array { $fieldId = $field['field'] ?? null; $editable = is_string($fieldId) && $fieldId !== NpcInspector::ASSIGN_ID_FIELD && ($field['editable'] ?? true) !== false; - $row = self::describeRow([...$field, 'target' => $editable ? 'npc' : null]); - - if (isset($row['key'])) { - $row['key']['frame'] = $frame; - } - if (is_array($field['frame'] ?? null)) { - $row['frame'] = array_values($field['frame']); - } - $control = MapInspector::findControl($field); - if ($control?->type === InputControlType::MULTILINE) { - $row['multiline'] = true; - $row['value'] = $control->rawValue; - } - return $row; + return self::describeRecordRow($records, $index, $frame, $field, $editable ? 'npc' : null); } /** @@ -1311,17 +1599,13 @@ private static function describeNpcRow(array $field, array $frame): array */ private static function requireNpcFrame(mixed $frame): array { - if (! is_array($frame) || ! array_is_list($frame) || ! array_all($frame, static fn(mixed $segment): bool => is_int($segment) || is_string($segment))) { - throw new SessionRefusal('A frame must be a list of indexes and keys, as readNpc gave it.'); - } - - return $frame; + return self::requireFrame($frame, 'readNpc'); } /** * Undoes the last change, wherever it was made. * - * @return array{label: ?string, maps: list, revisions: array} What was undone, the maps it changed and their revisions now. + * @return array{label: ?string, maps: list, revisions: array, databases: list} What was undone, the maps it changed and their revisions now, and the database categories it changed. */ public function undo(): array { @@ -1331,7 +1615,7 @@ public function undo(): array /** * Redoes the last undone change. * - * @return array{label: ?string, maps: list} + * @return array{label: ?string, maps: list, revisions: array, databases: list} */ public function redo(): array { @@ -1450,14 +1734,16 @@ public function hasUnsavedChanges(): bool } /** - * Runs one history step and names the maps it changed. + * Runs one history step and names the maps and database categories it + * changed, so an interface reloads what it shows of them. * * @param callable(): ?Command $step - * @return array{label: ?string, maps: list} + * @return array{label: ?string, maps: list, revisions: array, databases: list} */ private function traverseHistory(callable $step): array { $before = array_map(static fn(ProjectMap $map): int => $map->stateVersion(), $this->workspace->maps); + $databases = array_map(static fn(ProjectRecordDatabase $database): string => $database->getContentVersion(), $this->workspace->recordDatabases); $command = $step(); $changed = $revisions = []; foreach ($this->workspace->maps as $index => $map) { @@ -1466,8 +1752,10 @@ private function traverseHistory(callable $step): array $revisions[$map->mapId] = $map->stateVersion(); } } + $changedDatabases = array_keys(array_filter($this->workspace->recordDatabases, + static fn(ProjectRecordDatabase $database, string $key): bool => $database->getContentVersion() !== $databases[$key], ARRAY_FILTER_USE_BOTH)); - return ['label' => $command?->label, 'maps' => $changed, 'revisions' => $revisions]; + return ['label' => $command?->label, 'maps' => $changed, 'revisions' => $revisions, 'databases' => array_values(array_map('strval', $changedDatabases))]; } /** @return array> */ diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 946d8da..bd2b734 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -261,7 +261,37 @@ private function dispatch(string $method, array $params): array self::requireInt($params, 'y'), ), 'database.records' => $session->listDatabaseRecords(self::requireString($params, 'category')), - 'database.record' => $session->readDatabaseRecord(self::requireString($params, 'category'), self::requireInt($params, 'index')), + 'database.record' => $session->readDatabaseRecord( + self::requireString($params, 'category'), + self::requireInt($params, 'index'), + is_array($params['frame'] ?? []) ? ($params['frame'] ?? []) : throw new InvalidRequest('"frame" must be a list of indexes and keys.'), + ), + 'database.apply' => $session->applyDatabaseRecord( + self::requireString($params, 'category'), + self::requireInt($params, 'index'), + is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key database.record gave.'), + is_scalar($params['value'] ?? null) ? (string) $params['value'] : throw new InvalidRequest('"value" must be a string, number or boolean.'), + ), + 'database.add' => $session->addDatabaseItem( + self::requireString($params, 'category'), + self::requireInt($params, 'index'), + is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be a row key database.record gave, or {"frame": [...]}.'), + is_bool($params['child'] ?? false) ? ($params['child'] ?? false) : throw new InvalidRequest('"child" must be a boolean.'), + ), + 'database.remove' => $session->removeDatabaseItem( + self::requireString($params, 'category'), + self::requireInt($params, 'index'), + is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key database.record gave.'), + ), + 'database.create' => $session->createDatabaseRecord(self::requireString($params, 'category')), + 'database.duplicate' => $session->duplicateDatabaseRecord(self::requireString($params, 'category'), self::requireInt($params, 'index')), + 'database.delete' => $session->deleteDatabaseRecord(self::requireString($params, 'category'), self::requireInt($params, 'index')), + 'database.move' => $session->moveDatabaseRecord( + self::requireString($params, 'category'), + self::requireInt($params, 'index'), + in_array($params['direction'] ?? null, ['up', 'down'], true) ? $params['direction'] : throw new InvalidRequest('"direction" must be "up" or "down".'), + ), + 'database.save' => $session->saveDatabase(self::requireString($params, 'category')), 'references.list' => $session->listReferences(self::requireString($params, 'map'), self::requireString($params, 'category')), 'conditions.grammar' => $session->describeWorldStateGrammar(), 'conditions.encode' => $session->encodeWorldState( diff --git a/tests/Unit/Session/DatabaseSessionTest.php b/tests/Unit/Session/DatabaseSessionTest.php new file mode 100644 index 0000000..ec96e8d --- /dev/null +++ b/tests/Unit/Session/DatabaseSessionTest.php @@ -0,0 +1,199 @@ + ($row['key']['field'] ?? null) === $fieldId) + ?? throw new RuntimeException("No record row {$fieldId}."); +} + +it('lists a category with what it can do, and reads a frame\'s rows with the items they belong to', function () { + $session = EditorSession::open(makeTemporaryProject()); + $states = $session->listDatabaseRecords('states'); + $types = $session->listDatabaseRecords('types'); + $root = $session->readDatabaseRecord('common_events', 0); + $then = $session->readDatabaseRecord('common_events', 0, [3, 'then']); + + expect($states)->toMatchArray(['editable' => true, 'dirty' => false, 'canCreate' => true, 'canDuplicate' => true, 'canDelete' => true, 'canReorder' => false]) + ->and($types)->toMatchArray(['editable' => false, 'canCreate' => false, 'canDelete' => false]) + ->and($root)->toMatchArray(['frame' => [], 'frameLabel' => null, 'editable' => true, 'readOnly' => null]) + ->and(databaseSessionRow($root, '__scriptId'))->not->toHaveKey('item') + ->and(databaseSessionRow($root, 'command3Then'))->toMatchArray(['frame' => [3, 'then'], 'item' => true, 'itemNoun' => 'command']) + ->and($then['frameLabel'])->toBe('Commands › Branch 4 › Then') + ->and(databaseSessionRow($then, 'command0Text'))->toMatchArray([ + 'kind' => 'text', + 'key' => ['target' => 'record', 'field' => 'command0Text', 'frame' => [3, 'then']], + 'item' => true, + ]) + ->and(fn() => $session->readDatabaseRecord('common_events', 0, [9, 'then']))->toThrow(SessionRefusal::class, 'is no longer there') + ->and(fn() => $session->readDatabaseRecord('common_events', 0, ['then' => 3]))->toThrow(SessionRefusal::class, 'A frame must be a list') + ->and(fn() => $session->readDatabaseRecord('states', 9))->toThrow(SessionRefusal::class, 'states has no record 9.') + ->and(fn() => $session->readDatabaseRecord('actors', 0))->toThrow(SessionRefusal::class, 'edited in the terminal editor for now'); +}); + +it('applies a row as one undo step, history naming the category, and refuses what the field cannot take', function () { + $session = EditorSession::open(makeTemporaryProject()); + $name = databaseSessionRow($session->readDatabaseRecord('states', 0), 'name'); + + $applied = $session->applyDatabaseRecord('states', 0, $name['key'], 'Venom'); + expect($applied)->toBe(['changed' => true, 'records' => ['Venom', 'Stun']]) + ->and($session->listDatabaseRecords('states')['dirty'])->toBeTrue() + ->and($session->applyDatabaseRecord('states', 0, $name['key'], 'Venom')['changed'])->toBeFalse(); + + $undone = $session->undo(); + expect($undone)->toMatchArray(['label' => 'Name edit', 'maps' => [], 'databases' => ['states']]) + ->and($session->listDatabaseRecords('states'))->toMatchArray(['dirty' => false, 'records' => ['Poison', 'Stun']]) + ->and($session->redo()['databases'])->toBe(['states']) + ->and($session->listDatabaseRecords('states')['records'][0])->toBe('Venom'); + + $conditions = databaseSessionRow($session->readDatabaseRecord('common_events', 0), 'command3Conditions'); + expect(fn() => $session->applyDatabaseRecord('common_events', 0, $conditions['key'], 'nonsense')) + ->toThrow(SessionRefusal::class, 'Condition "nonsense" cannot be read') + ->and(fn() => $session->applyDatabaseRecord('common_events', 0, ['field' => 'command3Then', 'frame' => []], '2')) + ->toThrow(SessionRefusal::class, 'Command 4 Then Commands cannot be edited here.') + ->and(fn() => $session->applyDatabaseRecord('states', 0, ['field' => 'nope', 'frame' => []], 'x')) + ->toThrow(SessionRefusal::class, 'no longer in the record') + ->and($session->listDatabaseRecords('common_events')['dirty'])->toBeFalse(); +}); + +it('adds and removes items by row, beneath an item that holds a list, and at a frame\'s end', function () { + $session = EditorSession::open(makeTemporaryProject()); + $commands = static fn(array $frame = []): array => array_column( + array_filter($session->readDatabaseRecord('common_events', 0, $frame)['rows'], static fn(array $row): bool => str_ends_with($row['key']['field'] ?? '', 'Type')), + 'value', + ); + + expect($session->addDatabaseItem('common_events', 0, ['frame' => [3, 'then']])['changed'])->toBeTrue() + ->and($commands([3, 'then']))->toBe(['text', 'text']); + + $session->applyDatabaseRecord('common_events', 0, ['field' => 'command0Type', 'frame' => []], 'choice'); + $prompt = databaseSessionRow($session->readDatabaseRecord('common_events', 0), 'command0Prompt'); + expect($prompt['childNoun'])->toBe('option') + ->and($session->addDatabaseItem('common_events', 0, $prompt['key'], child: true)['changed'])->toBeTrue(); + $option = databaseSessionRow($session->readDatabaseRecord('common_events', 0), 'command0Option0Text'); + expect($option)->toMatchArray(['item' => true, 'itemNoun' => 'option'])->not->toHaveKey('childNoun'); + + $session->addDatabaseItem('common_events', 0, $prompt['key']); + expect($commands())->toBe(['choice', 'text', 'record_event', 'give_gold', 'branch']) + ->and($session->removeDatabaseItem('common_events', 0, $option['key'])['changed'])->toBeTrue() + ->and(fn() => $session->addDatabaseItem('common_events', 0, ['field' => '__scriptId', 'frame' => []])) + ->toThrow(SessionRefusal::class, 'belongs to no item') + ->and($session->removeDatabaseItem('common_events', 0, ['field' => '__scriptId', 'frame' => []])['changed'])->toBeFalse(); + + expect(array_column([$session->undo(), $session->undo(), $session->undo(), $session->undo(), $session->undo()], 'label')) + ->toBe(['Option remove', 'Command add', 'Option add', 'Command 1 Type edit', 'Command add']) + ->and($session->listDatabaseRecords('common_events')['dirty'])->toBeFalse(); +}); + +it('creates, duplicates, moves and deletes records, each one undo step', function () { + $session = EditorSession::open(makeTemporaryProject()); + + expect($session->createDatabaseRecord('states'))->toBe(['index' => 2, 'records' => ['Poison', 'Stun', 'New State']]) + ->and($session->duplicateDatabaseRecord('states', 0))->toBe(['index' => 1, 'records' => ['Poison', 'Poison', 'Stun', 'New State']]) + ->and($session->deleteDatabaseRecord('states', 1))->toBe(['index' => 0, 'records' => ['Poison', 'Stun', 'New State']]) + ->and(fn() => $session->moveDatabaseRecord('states', 0, 'down'))->toThrow(SessionRefusal::class, 'would not survive reopening') + ->and(fn() => $session->createDatabaseRecord('terms'))->toThrow(SessionRefusal::class, 'Term entries cannot be created from the editor.') + ->and(fn() => $session->deleteDatabaseRecord('types', 0))->toThrow(SessionRefusal::class, 'Read-only: '); + + expect(array_column([$session->undo(), $session->undo(), $session->undo()], 'label')) + ->toBe(['Delete state Poison', 'State duplicate', 'State create']) + ->and($session->listDatabaseRecords('states'))->toMatchArray(['dirty' => false, 'records' => ['Poison', 'Stun']]); + + $first = $session->createDatabaseRecord('battle_entry_rules'); + $second = $session->createDatabaseRecord('battle_entry_rules'); + $labels = $second['records']; + expect($session->moveDatabaseRecord('battle_entry_rules', $first['index'], 'down')) + ->toBe(['index' => $second['index'], 'changed' => true, 'records' => array_reverse($labels)]) + ->and($session->moveDatabaseRecord('battle_entry_rules', $second['index'], 'down')['changed'])->toBeFalse() + ->and($session->undo())->toMatchArray(['label' => 'Battle entry rule move', 'databases' => ['battle_entry_rules']]) + ->and($session->listDatabaseRecords('battle_entry_rules')['records'])->toBe($labels); +}); + +it('saves a category through its own file, and refuses one that cannot be written', function () { + $root = makeTemporaryProject(); + $session = EditorSession::open($root); + + expect($session->saveDatabase('states'))->toBe(['saved' => false, 'warnings' => [], 'backupFailures' => []]); + + $session->applyDatabaseRecord('states', 0, ['field' => 'name', 'frame' => []], 'Venom'); + $saved = $session->saveDatabase('states'); + + expect($saved)->toMatchArray(['saved' => true, 'warnings' => [], 'backupFailures' => []]) + ->and(loadRecordDatabase($root, 'states')->getEntryLabels())->toBe(['Venom', 'Stun']) + ->and($session->listDatabaseRecords('states')['dirty'])->toBeFalse() + ->and($session->hasUnsavedChanges())->toBeFalse() + ->and(fn() => $session->saveDatabase('types'))->toThrow(SessionRefusal::class, 'Read-only: '); +}); + +it('serves database authoring over the line protocol', function () { + $root = makeTemporaryProject(); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(int $id, string $method, array $params = []): array => + $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params])); + $request(1, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + + $read = $request(2, 'database.record', ['category' => 'troops', 'index' => 0, 'frame' => []])['result']; + $applied = $request(3, 'database.apply', ['category' => 'troops', 'index' => 0, 'key' => databaseSessionRow($read, 'name')['key'], 'value' => 'Bats'])['result']; + $added = $request(4, 'database.add', ['category' => 'troops', 'index' => 0, 'key' => databaseSessionRow($read, 'member0Enemy')['key']])['result']; + $removed = $request(5, 'database.remove', ['category' => 'troops', 'index' => 0, 'key' => databaseSessionRow($read, 'member0Enemy')['key']])['result']; + $created = $request(6, 'database.create', ['category' => 'troops'])['result']; + $copy = $request(7, 'database.duplicate', ['category' => 'troops', 'index' => 0])['result']; + $deleted = $request(8, 'database.delete', ['category' => 'troops', 'index' => 1])['result']; + $undone = $request(9, 'history.undo')['result']; + $saved = $request(10, 'database.save', ['category' => 'troops'])['result']; + + expect($applied)->toBe(['changed' => true, 'records' => ['Bats', 'Lone Rat']]) + ->and([$added['changed'], $removed['changed']])->toBe([true, true]) + ->and($created['index'])->toBe(2) + ->and($copy['index'])->toBe(1) + ->and($deleted['index'])->toBe(0) + ->and($undone['databases'])->toBe(['troops']) + ->and($saved['saved'])->toBeTrue() + ->and($request(11, 'database.records', ['category' => 'troops'])['result']['dirty'])->toBeFalse() + ->and($request(12, 'database.move', ['category' => 'troops', 'index' => 0, 'direction' => 'down'])['error']['kind'])->toBe('refusal') + ->and($request(13, 'database.move', ['category' => 'troops', 'index' => 0, 'direction' => 'sideways'])['error']) + ->toBe(['kind' => 'request', 'message' => '"direction" must be "up" or "down".']) + ->and($request(14, 'database.apply', ['category' => 'troops', 'index' => 0, 'value' => 'x'])['error']['kind'])->toBe('request') + ->and($request(15, 'database.add', ['category' => 'troops', 'index' => 0, 'key' => [], 'child' => 'yes'])['error']) + ->toBe(['kind' => 'request', 'message' => '"child" must be a boolean.']) + ->and($request(16, 'database.record', ['category' => 'troops', 'index' => 0, 'frame' => 'x'])['error']['kind'])->toBe('request'); +}); + +it('marks the items NPC rows belong to, from the same rules the database rows use', function () { + $root = makeTemporaryProject('ichiloto-npc-items-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['npcs'] = [['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1, 'script' => [ + ['type' => 'choice', 'prompt' => 'Well?', 'options' => [['text' => 'Yes', 'then' => []], ['text' => 'No', 'then' => []]]], + ]]]; + file_put_contents($path, "readNpc('test-map', 0, ['script']); + $option = databaseSessionRow($script, 'command0Option1Text'); + + expect(databaseSessionRow($script, 'command0Type'))->toMatchArray(['item' => true, 'itemNoun' => 'command', 'childNoun' => 'option']) + ->and($option)->toMatchArray(['item' => true, 'itemNoun' => 'option']) + ->and(databaseSessionRow($session->readNpc('test-map', 0), 'name'))->not->toHaveKey('item'); + + // An option row removes that option, not the whole choice. + $removed = $session->removeNpcItem('test-map', $script['revision'], 0, $option['key']); + $after = $session->readNpc('test-map', 0, ['script']); + expect($removed['changed'])->toBeTrue() + ->and(array_column(array_filter($after['rows'], static fn(array $row): bool => str_ends_with($row['key']['field'] ?? '', 'Text')), 'value')) + ->toBe(['Yes']); +}); From 74d771c3ee2679471d6422d09f88e29c7e754620 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 20:19:11 +0200 Subject: [PATCH 130/344] feat(session): name unsaved documents and save them all for both editors Save All moves out of the terminal editor into WorkspaceSave, shared by the terminal's Ctrl+A and quit guard and the session's new project.saveAll. The workspace names what is unsaved (maps, databases, cutscenes) and lists the databases a save can write; each database names the files its save overwrites. project.dirty now also names what is unsaved, so the GUI can guard quitting. The terminal's unsaved guard now lists dirty cutscenes too. --- src/Cutscenes/Editing/CutscenesWorkspace.php | 16 +- src/Editor.php | 209 +++---------------- src/ProjectActorDatabase.php | 10 + src/ProjectAnimationDatabase.php | 10 + src/ProjectClassDatabase.php | 10 + src/ProjectConfig.php | 10 + src/ProjectQuestDatabase.php | 10 + src/ProjectSkillDatabase.php | 10 + src/ProjectSystemDatabase.php | 10 + src/ProjectWorkspace.php | 61 ++++++ src/Session/EditorSession.php | 32 +++ src/Session/SessionHost.php | 6 +- src/Storage/WorkspaceSave.php | 115 ++++++++++ src/Storage/WorkspaceSaveResult.php | 39 ++++ tests/Unit/RecordCategoryEditorTest.php | 4 +- tests/Unit/Session/DatabaseSessionTest.php | 26 +++ 16 files changed, 384 insertions(+), 194 deletions(-) create mode 100644 src/Storage/WorkspaceSave.php create mode 100644 src/Storage/WorkspaceSaveResult.php diff --git a/src/Cutscenes/Editing/CutscenesWorkspace.php b/src/Cutscenes/Editing/CutscenesWorkspace.php index 75b7bf8..3633936 100644 --- a/src/Cutscenes/Editing/CutscenesWorkspace.php +++ b/src/Cutscenes/Editing/CutscenesWorkspace.php @@ -1610,26 +1610,14 @@ private function firstDeletedCutscene(): ?CutsceneAsset } /** - * Saves every dirty asset of both types, for Save All. - * - * @return array{saved: string[], failed: array} + * Shows the cutscenes screen again after a save, when it is open. */ - private function saveAllCutscenes(): array + private function showSavedCutscenes(): void { - $library = $this->cutsceneLibrary(); - - if ($library === null) { - return ['saved' => [], 'failed' => []]; - } - - $result = $library->saveAll(fn(string ...$paths) => $this->backupBeforeSave(...$paths)); - if ($this->isCutscenesOpen) { $this->clampCutsceneSelection(); $this->renderCutscenesArea(includeRoot: true); } - - return $result; } // -- Multiline editing ----------------------------------------------------- diff --git a/src/Editor.php b/src/Editor.php index fa903c5..f532c2f 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -92,6 +92,7 @@ use Ichiloto\Editor\UI\SettingsPaneLayout; use Ichiloto\Editor\UI\TextFieldEditor; use Ichiloto\Editor\UI\TextFieldKeyResult; +use Ichiloto\Editor\Storage\WorkspaceSave; use Ichiloto\Editor\Validation\MapValidator; use Ichiloto\Engine\Animations\ActionAnimationResolver; use Ichiloto\Engine\Animations\AnimationTargetPosition; @@ -6757,7 +6758,8 @@ private function navigateBack(): void } /** - * Saves every dirty map and database in one pass. + * Saves every dirty map, database and cutscene in one pass, through the + * workspace save both editors share. * * Maps whose save would move their folder are skipped - the rename flow * requires its own explicit confirmation via Ctrl+S on that map. @@ -6770,119 +6772,42 @@ private function saveAllAssets(): void return; } - $savedMaps = 0; - $savedDatabases = 0; - $skippedRenames = []; - $failures = []; - $validationWarnings = []; - $mapsById = $this->getMapsById(); + $result = WorkspaceSave::saveAll($this->workspace, $this->backups); + $this->showSavedCutscenes(); - foreach ($this->workspace->maps as $map) { - if (! $map->isDirty()) { - continue; - } - - if ($map->willMoveOnSave()) { - $skippedRenames[] = $map->mapId; - continue; - } - - foreach (MapValidator::validate($map, $mapsById) as $warning) { - $validationWarnings[] = sprintf('%s: %s', $map->mapId, $warning); - } - - try { - $map->save($this->backupBeforeSave(...)); - $savedMaps++; - } catch (Throwable $throwable) { - Debug::error(sprintf('Save all (%s): %s', $map->mapId, $throwable->getMessage())); - $failures[] = sprintf('%s: %s', $map->mapId, $throwable->getMessage()); - } - } - - // Categories sharing one file are saved together, so the file is - // written once with every dirty part folded in rather than once per - // category, each rewriting what the last just wrote. - foreach (SharedFileTransaction::groupByPath($this->getSaveableDatabases()) as $group) { - $dirty = array_filter($group, static fn(object $database): bool => $database->isDirty()); - - if ($dirty === []) { - continue; - } - - $label = implode(', ', array_keys($dirty)); - - try { - $backed = []; - - foreach ($dirty as $database) { - foreach ($this->getDatabaseBackupPaths($database) as $backupPath) { - // One file, one backup, however many categories of - // it are being written. - $backed[$backupPath] = $backupPath; - } - } - - $this->backupBeforeSave(...array_values($backed)); - - $shared = reset($dirty); - - if ($shared instanceof ProjectRecordDatabase && $shared->sharesBackingFile()) { - // One write for the file, with every dirty category's - // edits composed against one snapshot of it first. - SharedFileTransaction::commit(array_values($dirty)); - } else { - foreach ($dirty as $database) { - $database->save(); - } - } - - $savedDatabases += count($dirty); - } catch (Throwable $throwable) { - Debug::error(sprintf('Save all (%s): %s', $label, $throwable->getMessage())); - $failures[] = sprintf('%s: %s', $label, $throwable->getMessage()); - } + foreach ($result->failures as $failure) { + Debug::error(sprintf('Save all (%s)', $failure)); } - // Cutscenes: each dirty asset as its own paired transaction. - $cutscenes = $this->saveAllCutscenes(); - $savedCutscenes = count($cutscenes['saved']); - - foreach ($cutscenes['failed'] as $asset => $reason) { - Debug::error(sprintf('Save all (%s): %s', $asset, $reason)); - $failures[] = sprintf('%s: %s', $asset, $reason); + if ($result->backupFailures !== []) { + Debug::error(sprintf('Backup failed for: %s', implode(', ', $result->backupFailures))); } - $summary = sprintf( - 'Saved %d map%s, %d database%s and %d cutscene%s.', - $savedMaps, - $savedMaps === 1 ? '' : 's', - $savedDatabases, - $savedDatabases === 1 ? '' : 's', - $savedCutscenes, - $savedCutscenes === 1 ? '' : 's', - ); $detailLines = []; - if ($skippedRenames !== []) { + if ($result->skippedRenames !== []) { $detailLines[] = 'Skipped - saving would move the map folder (use Ctrl+S on the map to confirm):'; - $detailLines = [...$detailLines, ...array_map(static fn(string $mapId): string => ' ' . $mapId, $skippedRenames), '']; + $detailLines = [...$detailLines, ...array_map(static fn(string $mapId): string => ' ' . $mapId, $result->skippedRenames), '']; + } + + if ($result->failures !== []) { + $detailLines = [...$detailLines, 'Failed:', ...array_map(static fn(string $failure): string => ' ' . $failure, $result->failures), '']; } - if ($failures !== []) { - $detailLines = [...$detailLines, 'Failed:', ...array_map(static fn(string $failure): string => ' ' . $failure, $failures), '']; + if ($result->backupFailures !== []) { + $detailLines = [...$detailLines, 'Backups failed (the save still ran):', ...array_map(static fn(string $path): string => ' ' . $path, $result->backupFailures), '']; } - if ($validationWarnings !== []) { - $detailLines = [...$detailLines, 'Validation warnings:', ...array_map(static fn(string $warning): string => ' ' . $warning, $validationWarnings)]; + if ($result->warnings !== []) { + $detailLines = [...$detailLines, 'Validation warnings:', ...array_map(static fn(string $warning): string => ' ' . $warning, $result->warnings)]; } - if ($failures !== []) { - $this->setStatus($summary . sprintf(' %d failed (Ctrl+E for details).', count($failures)), StatusLevel::ERROR, $detailLines); - } elseif ($skippedRenames !== [] || $validationWarnings !== []) { - $this->setStatus($summary . ' See Ctrl+E for skipped saves and warnings.', StatusLevel::WARN, $detailLines); + if ($result->failures !== []) { + $this->setStatus($result->summary . sprintf(' %d failed (Ctrl+E for details).', count($result->failures)), StatusLevel::ERROR, $detailLines); + } elseif ($result->skippedRenames !== [] || $result->warnings !== [] || $result->backupFailures !== []) { + $this->setStatus($result->summary . ' See Ctrl+E for skipped saves and warnings.', StatusLevel::WARN, $detailLines); } else { - $this->setStatus($summary, StatusLevel::SUCCESS); + $this->setStatus($result->summary, StatusLevel::SUCCESS); } $this->renderSelectionDependentArea(); @@ -6918,60 +6843,6 @@ private function backupBeforeSave(string ...$paths): void } - /** - * Returns the files a database save overwrites. - * - * @param object $database The database about to be saved. - * @return string[] - */ - private function getDatabaseBackupPaths(object $database): array - { - if ($database instanceof ProjectActorDatabase) { - return array_map( - static fn(ProjectActor $actor): string => $actor->path, - $database->getActors(), - ); - } - - if ($database instanceof ProjectRecordDatabase) { - return $database->getBackupPaths(); - } - - return property_exists($database, 'path') ? [(string) $database->path] : []; - } - - /** - * Returns every saveable database keyed by display label. - * - * @return array - */ - private function getSaveableDatabases(): array - { - if (! $this->workspace instanceof ProjectWorkspace) { - return []; - } - - $databases = [ - 'Actors' => $this->workspace->actorDatabase, - 'Classes' => $this->workspace->classDatabase, - 'Skills' => $this->workspace->skillDatabase, - 'Quests' => $this->workspace->questDatabase, - 'Animations' => $this->workspace->animationDatabase, - 'System' => $this->workspace->systemDatabase, - ]; - if ($this->workspace->config !== null) { $databases['Project configuration'] = $this->workspace->config; } - - // Read-only categories never join Save All: they hold no edits, and - // asking them to save would raise instead of no-op. - foreach ($this->workspace->recordDatabases as $categoryKey => $recordDatabase) { - if ($recordDatabase->isEditable()) { - $databases[DatabaseCatalog::at(DatabaseCatalog::indexOf($categoryKey))->label] = $recordDatabase; - } - } - - return $databases; - } - /** * Returns the workspace maps keyed by map id for validation lookups. * @@ -9957,23 +9828,23 @@ private function saveActiveDatabase(): void try { if ($this->isActorsDatabaseSelected()) { - $this->backupBeforeSave(...$this->getDatabaseBackupPaths($this->workspace->actorDatabase)); + $this->backupBeforeSave(...$this->workspace->actorDatabase->getBackupPaths()); $this->workspace->actorDatabase->save(); $this->setStatus('Actor database saved.', StatusLevel::SUCCESS); } elseif ($this->isClassesDatabaseSelected()) { - $this->backupBeforeSave(...$this->getDatabaseBackupPaths($this->workspace->classDatabase)); + $this->backupBeforeSave(...$this->workspace->classDatabase->getBackupPaths()); $this->workspace->classDatabase->save(); $this->setStatus('Class database saved.', StatusLevel::SUCCESS); } elseif ($this->isSkillsDatabaseSelected()) { - $this->backupBeforeSave(...$this->getDatabaseBackupPaths($this->workspace->skillDatabase)); + $this->backupBeforeSave(...$this->workspace->skillDatabase->getBackupPaths()); $this->workspace->skillDatabase->save(); $this->setStatus('Skill database saved.', StatusLevel::SUCCESS); } elseif ($this->isQuestsDatabaseSelected()) { - $this->backupBeforeSave(...$this->getDatabaseBackupPaths($this->workspace->questDatabase)); + $this->backupBeforeSave(...$this->workspace->questDatabase->getBackupPaths()); $this->workspace->questDatabase->save(); $this->setStatus('Quest database saved.', StatusLevel::SUCCESS); } elseif ($this->isAnimationsDatabaseSelected()) { - $this->backupBeforeSave(...$this->getDatabaseBackupPaths($this->workspace->animationDatabase)); + $this->backupBeforeSave(...$this->workspace->animationDatabase->getBackupPaths()); $this->workspace->animationDatabase->save(); $this->setStatus('Animation database saved.', StatusLevel::SUCCESS); } elseif ($this->isSystemDatabaseSelected()) { @@ -9981,14 +9852,14 @@ private function saveActiveDatabase(): void $this->backupBeforeSave($this->workspace->config->path); $this->workspace->config->save(); } - $this->backupBeforeSave(...$this->getDatabaseBackupPaths($this->workspace->systemDatabase)); + $this->backupBeforeSave(...$this->workspace->systemDatabase->getBackupPaths()); $this->workspace->systemDatabase->save(); $this->setStatus('System database saved.', StatusLevel::SUCCESS); } elseif (($recordDatabase = $this->getSelectedRecordDatabase()) instanceof ProjectRecordDatabase) { if (! $recordDatabase->isEditable()) { $this->setStatus($this->describeRecordReadOnly($recordDatabase), StatusLevel::WARN); } else { - $this->backupBeforeSave(...$this->getDatabaseBackupPaths($recordDatabase)); + $this->backupBeforeSave(...$recordDatabase->getBackupPaths()); $recordDatabase->save(); $this->setStatus( sprintf('%s database saved.', $this->getSelectedDatabaseCategoryDefinition()->label), @@ -16777,26 +16648,12 @@ private function renderStatusDetailOverlay(array $layout): void private function renderUnsavedChangesGuardOverlay(array $layout): void { $actionLabel = $this->pendingGuardAction === self::GUARD_ACTION_QUIT ? 'quit' : 'reload the workspace'; - $dirtyMaps = []; - - foreach ($this->workspace?->maps ?? [] as $map) { - if ($map->isDirty()) { - $dirtyMaps[] = ' ' . $map->mapId; - } - } - - $dirtyDatabases = []; - - foreach ($this->getSaveableDatabases() as $label => $database) { - if ($database->isDirty()) { - $dirtyDatabases[] = ' ' . $label . ' database'; - } - } + $unsaved = array_map(static fn(string $document): string => ' ' . $document, $this->workspace?->listUnsavedChanges() ?? []); $rows = [ sprintf('You have unsaved changes. %s anyway?', ucfirst($actionLabel)), '', - ...array_slice([...$dirtyMaps, ...$dirtyDatabases], 0, 8), + ...array_slice($unsaved, 0, 8), '', 'Y: Discard changes and continue', 'S: Save everything first, then continue', diff --git a/src/ProjectActorDatabase.php b/src/ProjectActorDatabase.php index 9fd6767..809bfec 100644 --- a/src/ProjectActorDatabase.php +++ b/src/ProjectActorDatabase.php @@ -308,4 +308,14 @@ private static function humanizeId(string $id): string return trim(str_replace(['_', '-'], ' ', $label)); } + + /** + * Returns the files a save of this database would overwrite: one per actor. + * + * @return string[] + */ + public function getBackupPaths(): array + { + return array_map(static fn(ProjectActor $actor): string => $actor->path, $this->getActors()); + } } diff --git a/src/ProjectAnimationDatabase.php b/src/ProjectAnimationDatabase.php index db8dcbd..6091623 100644 --- a/src/ProjectAnimationDatabase.php +++ b/src/ProjectAnimationDatabase.php @@ -508,4 +508,14 @@ private static function exportPhpValue(mixed $value, int $indentLevel = 0): stri return implode("\n", $lines); } + + /** + * Returns the files a save of this database would overwrite. + * + * @return string[] + */ + public function getBackupPaths(): array + { + return [$this->path]; + } } diff --git a/src/ProjectClassDatabase.php b/src/ProjectClassDatabase.php index 5947b0d..e8cda09 100644 --- a/src/ProjectClassDatabase.php +++ b/src/ProjectClassDatabase.php @@ -277,4 +277,14 @@ private static function exportPhpValue(mixed $value, int $indentLevel = 0): stri return implode("\n", $lines); } + + /** + * Returns the files a save of this database would overwrite. + * + * @return string[] + */ + public function getBackupPaths(): array + { + return [$this->path]; + } } diff --git a/src/ProjectConfig.php b/src/ProjectConfig.php index 8128cf1..7862823 100644 --- a/src/ProjectConfig.php +++ b/src/ProjectConfig.php @@ -199,4 +199,14 @@ private static function getValue(array $payload, string $path): mixed foreach (explode('.', $path) as $key) { $value = is_array($value) ? ($value[$key] ?? null) : null; } return $value; } + + /** + * Returns the files a save of this database would overwrite. + * + * @return string[] + */ + public function getBackupPaths(): array + { + return [$this->path]; + } } diff --git a/src/ProjectQuestDatabase.php b/src/ProjectQuestDatabase.php index 3d2d9d9..523f903 100644 --- a/src/ProjectQuestDatabase.php +++ b/src/ProjectQuestDatabase.php @@ -454,4 +454,14 @@ private static function exportPhpValue(mixed $value, int $indentLevel = 0): stri return implode("\n", $lines); } + + /** + * Returns the files a save of this database would overwrite. + * + * @return string[] + */ + public function getBackupPaths(): array + { + return [$this->path]; + } } diff --git a/src/ProjectSkillDatabase.php b/src/ProjectSkillDatabase.php index a45c473..734591e 100644 --- a/src/ProjectSkillDatabase.php +++ b/src/ProjectSkillDatabase.php @@ -451,4 +451,14 @@ private function enumCaseFromValue(string $enumClass, string $value, string $fal } return $fallback; } + + /** + * Returns the files a save of this database would overwrite. + * + * @return string[] + */ + public function getBackupPaths(): array + { + return [$this->path]; + } } diff --git a/src/ProjectSystemDatabase.php b/src/ProjectSystemDatabase.php index f7ea0a3..52039fc 100644 --- a/src/ProjectSystemDatabase.php +++ b/src/ProjectSystemDatabase.php @@ -275,4 +275,14 @@ private static function exportPhpValue(mixed $value, int $indentLevel = 0): stri return implode("\n", $lines); } + + /** + * Returns the files a save of this database would overwrite. + * + * @return string[] + */ + public function getBackupPaths(): array + { + return [$this->path]; + } } diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index 973b7a1..ce2b1ba 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -6,6 +6,7 @@ use FilesystemIterator; use Ichiloto\Editor\Cutscenes\CutsceneLibrary; +use Ichiloto\Editor\Database\DatabaseCatalog; use Ichiloto\Editor\Database\EngineDataBootstrap; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\RecordSchema; @@ -291,6 +292,66 @@ public function hasUnsavedChanges(): bool || ($this->cutscenes?->hasUnsavedChanges() ?? false); } + /** + * Names every document holding unsaved changes: maps by id, then + * databases, then cutscenes, so a guard can say what would be lost. + * + * @return list + */ + public function listUnsavedChanges(): array + { + $unsaved = []; + + foreach ($this->maps as $map) { + if ($map->isDirty()) { + $unsaved[] = $map->mapId; + } + } + + foreach ($this->listSaveableDatabases() as $label => $database) { + if ($database->isDirty()) { + $unsaved[] = $label . ' database'; + } + } + + foreach ($this->cutscenes?->dirtyAssets() ?? [] as $asset) { + $unsaved[] = $asset->type->noun() . ' ' . $asset->id; + } + + return $unsaved; + } + + /** + * Returns every database a save can write, keyed by display label. + * Read-only record categories are left out: they hold no edits, and + * asking them to save would raise instead of doing nothing. + * + * @return array + */ + public function listSaveableDatabases(): array + { + $databases = [ + 'Actors' => $this->actorDatabase, + 'Classes' => $this->classDatabase, + 'Skills' => $this->skillDatabase, + 'Quests' => $this->questDatabase, + 'Animations' => $this->animationDatabase, + 'System' => $this->systemDatabase, + ]; + + if ($this->config !== null) { + $databases['Project configuration'] = $this->config; + } + + foreach ($this->recordDatabases as $categoryKey => $recordDatabase) { + if ($recordDatabase->isEditable()) { + $databases[DatabaseCatalog::at(DatabaseCatalog::indexOf($categoryKey))->label] = $recordDatabase; + } + } + + return $databases; + } + /** * Returns a workspace with one map swapped for a fresh instance, keeping * every other loaded object intact (no whole-workspace reload). diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 008b80c..2e1c120 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -44,6 +44,7 @@ use Ichiloto\Editor\Maps\TilePalette; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Editor\Storage\WorkspaceSave; use Ichiloto\Editor\Validation\MapValidator; use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; use Ichiloto\Engine\Rendering\Presentation\PresentationWorld; @@ -1733,6 +1734,37 @@ public function hasUnsavedChanges(): bool return $this->workspace->hasUnsavedChanges(); } + /** + * Names every map, database and cutscene with changes not yet saved. + * + * @return list + */ + public function listUnsavedChanges(): array + { + return $this->workspace->listUnsavedChanges(); + } + + /** + * Saves every unsaved map, database and cutscene, as the terminal + * editor's Save All does, and names what is still unsaved after it: a + * map whose save would move its folder, or a document that failed. + * + * @return array{summary: string, skippedRenames: list, failures: list, warnings: list, backupFailures: list, unsaved: list} + */ + public function saveAll(): array + { + $result = WorkspaceSave::saveAll($this->workspace, $this->backups); + + return [ + 'summary' => $result->summary, + 'skippedRenames' => $result->skippedRenames, + 'failures' => $result->failures, + 'warnings' => $result->warnings, + 'backupFailures' => $result->backupFailures, + 'unsaved' => $this->workspace->listUnsavedChanges(), + ]; + } + /** * Runs one history step and names the maps and database categories it * changed, so an interface reloads what it shows of them. diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index bd2b734..a56bd45 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -341,10 +341,12 @@ private function dispatch(string $method, array $params): array ), 'history.undo' => $session->undo(), 'history.redo' => $session->redo(), - 'project.dirty' => ['dirty' => $session->hasUnsavedChanges()], + 'project.dirty' => ['dirty' => $session->hasUnsavedChanges(), 'unsaved' => $session->listUnsavedChanges()], + 'project.saveAll' => $session->saveAll(), 'playtest.start' => $session->startPlaytest(self::requireString($params, 'map'), self::requireInt($params, 'x'), self::requireInt($params, 'y')), 'playtest.status' => $session->describePlaytest(), - 'playtest.stop' => $session->stopPlaytest(), default => throw new InvalidRequest(sprintf('Unknown method "%s".', $method)), + 'playtest.stop' => $session->stopPlaytest(), + default => throw new InvalidRequest(sprintf('Unknown method "%s".', $method)), }; } diff --git a/src/Storage/WorkspaceSave.php b/src/Storage/WorkspaceSave.php new file mode 100644 index 0000000..f63c00c --- /dev/null +++ b/src/Storage/WorkspaceSave.php @@ -0,0 +1,115 @@ +isEnabled() && $paths !== []) { + array_push($backupFailures, ...$backups->backup(...$paths)['failed']); + } + }; + $savedMaps = 0; + $savedDatabases = 0; + $skippedRenames = []; + $failures = []; + $warnings = []; + $mapsById = []; + + foreach ($workspace->maps as $map) { + $mapsById[$map->mapId] = $map; + } + + foreach ($workspace->maps as $map) { + if (! $map->isDirty()) { + continue; + } + + if ($map->willMoveOnSave()) { + $skippedRenames[] = $map->mapId; + continue; + } + + foreach (MapValidator::validate($map, $mapsById) as $warning) { + $warnings[] = sprintf('%s: %s', $map->mapId, $warning); + } + + try { + $map->save($backup); + $savedMaps++; + } catch (Throwable $throwable) { + $failures[] = sprintf('%s: %s', $map->mapId, $throwable->getMessage()); + } + } + + // Categories sharing one file are saved together, so the file is + // written once with every dirty part folded in rather than once per + // category, each rewriting what the last just wrote. + foreach (SharedFileTransaction::groupByPath($workspace->listSaveableDatabases()) as $group) { + $dirty = array_filter($group, static fn(object $database): bool => $database->isDirty()); + + if ($dirty === []) { + continue; + } + + try { + // One file, one backup, however many categories of it are being written. + $backup(...array_values(array_unique(array_merge(...array_map( + static fn(object $database): array => $database->getBackupPaths(), + array_values($dirty), + ))))); + $shared = reset($dirty); + + if ($shared instanceof ProjectRecordDatabase && $shared->sharesBackingFile()) { + // One write for the file, with every dirty category's + // edits composed against one snapshot of it first. + SharedFileTransaction::commit(array_values($dirty)); + } else { + foreach ($dirty as $database) { + $database->save(); + } + } + + $savedDatabases += count($dirty); + } catch (Throwable $throwable) { + $failures[] = sprintf('%s: %s', implode(', ', array_keys($dirty)), $throwable->getMessage()); + } + } + + // Cutscenes: each dirty asset as its own paired transaction. + $cutscenes = $workspace->cutscenes?->saveAll($backup) ?? ['saved' => [], 'failed' => []]; + + foreach ($cutscenes['failed'] as $asset => $reason) { + $failures[] = sprintf('%s: %s', $asset, $reason); + } + + return new WorkspaceSaveResult( + savedMaps: $savedMaps, + savedDatabases: $savedDatabases, + savedCutscenes: count($cutscenes['saved']), + skippedRenames: $skippedRenames, + failures: $failures, + warnings: $warnings, + backupFailures: array_values(array_unique($backupFailures)), + ); + } +} diff --git a/src/Storage/WorkspaceSaveResult.php b/src/Storage/WorkspaceSaveResult.php new file mode 100644 index 0000000..e036aa5 --- /dev/null +++ b/src/Storage/WorkspaceSaveResult.php @@ -0,0 +1,39 @@ + sprintf( + 'Saved %d map%s, %d database%s and %d cutscene%s.', + $this->savedMaps, + $this->savedMaps === 1 ? '' : 's', + $this->savedDatabases, + $this->savedDatabases === 1 ? '' : 's', + $this->savedCutscenes, + $this->savedCutscenes === 1 ? '' : 's', + ); + } + + /** + * @param list $skippedRenames Maps left unsaved because saving would move their folder. + * @param list $failures Each document that could not be written, with why. + * @param list $warnings What validation noticed in the maps written, which never blocks a save. + * @param list $backupFailures Files whose backup failed; their save still ran. + */ + public function __construct( + public readonly int $savedMaps, + public readonly int $savedDatabases, + public readonly int $savedCutscenes, + public readonly array $skippedRenames, + public readonly array $failures, + public readonly array $warnings, + public readonly array $backupFailures, + ) { + } +} diff --git a/tests/Unit/RecordCategoryEditorTest.php b/tests/Unit/RecordCategoryEditorTest.php index 2afba2d..0ac2622 100644 --- a/tests/Unit/RecordCategoryEditorTest.php +++ b/tests/Unit/RecordCategoryEditorTest.php @@ -152,7 +152,7 @@ try { $editor = deletionEditor($root); - $saveable = callEditorMethod($editor, 'getSaveableDatabases'); + $saveable = getEditorProperty($editor, 'workspace')->listSaveableDatabases(); expect(array_keys($saveable))->toContain('States', 'Troops', 'Skits', 'Common Events', 'Terms'); expect(array_keys($saveable))->not->toContain('Items', 'Weapons', 'Armors', 'Enemies', 'Types', 'Tilesets'); @@ -190,7 +190,7 @@ $database = getEditorProperty($editor, 'workspace')->getRecordDatabase('troops'); $database->setField(0, 'name', 'Bat Swarm'); - expect(callEditorMethod($editor, 'getDatabaseBackupPaths', $database)) + expect($database->getBackupPaths()) ->toBe([$root . '/assets/Data/troops.php']); callEditorMethod($editor, 'saveActiveDatabase'); diff --git a/tests/Unit/Session/DatabaseSessionTest.php b/tests/Unit/Session/DatabaseSessionTest.php index ec96e8d..74960ff 100644 --- a/tests/Unit/Session/DatabaseSessionTest.php +++ b/tests/Unit/Session/DatabaseSessionTest.php @@ -2,7 +2,9 @@ declare(strict_types=1); +use Ichiloto\Editor\Database\DatabaseCatalog; use Ichiloto\Editor\Database\ProjectRecordDatabase; +use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Session\EditorSession; use Ichiloto\Editor\Session\SessionHost; use Ichiloto\Editor\Session\SessionRefusal; @@ -197,3 +199,27 @@ function databaseSessionRow(array $read, string $fieldId): array ->and(array_column(array_filter($after['rows'], static fn(array $row): bool => str_ends_with($row['key']['field'] ?? '', 'Text')), 'value')) ->toBe(['Yes']); }); + +it('names every unsaved map and database, and saves them all in one request', function () { + $root = makeTemporaryProject(); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(int $id, string $method, array $params = []): array => + $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params]))['result']; + $request(1, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + + expect($request(2, 'project.dirty'))->toBe(['dirty' => false, 'unsaved' => []]); + + $map = $request(3, 'map.read', ['map' => 'test-map']); + $request(4, 'map.paint', ['map' => 'test-map', 'revision' => $map['revision'], 'layer' => $map['baseLayer'], 'cells' => [[1, 1]], 'symbol' => '#']); + $request(5, 'database.apply', ['category' => 'states', 'index' => 0, 'key' => ['field' => 'name', 'frame' => []], 'value' => 'Venom']); + $states = DatabaseCatalog::at(DatabaseCatalog::indexOf('states'))->label; + + expect($request(6, 'project.dirty'))->toBe(['dirty' => true, 'unsaved' => ['test-map', $states . ' database']]); + + $saved = $request(7, 'project.saveAll'); + + expect($saved)->toMatchArray(['summary' => 'Saved 1 map, 1 database and 0 cutscenes.', 'failures' => [], 'skippedRenames' => [], 'unsaved' => []]) + ->and($request(8, 'project.dirty'))->toBe(['dirty' => false, 'unsaved' => []]) + ->and(ProjectWorkspace::fromProject($root)->maps[0]->getLayerSymbol($map['baseLayer'], 1, 1))->toBe('#') + ->and(loadRecordDatabase($root, 'states')->getEntryLabels())->toBe(['Venom', 'Stun']); +}); From e569d7236d6c216cce75920b2f68ce8b5f0095c0 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 23:46:06 +0200 Subject: [PATCH 131/344] feat(events): choose a battle's reserve policy on start_battle A start_battle command gains Reserve Policy, a choice of the Engine's none (the default: the battle is lost when the frontline is wiped out) or replace_after_wipeout (reserves come in, for this encounter only). Absent reads as none; choosing none removes the key. Validation names an invalid policy. A test keeps the choices equal to the Engine's ReservePolicy cases once the Engine has them. --- src/Database/RecordSchemaCatalog.php | 2 ++ src/Validation/ProjectValidator.php | 12 ++++++++++++ tests/Unit/ResumableStoryEventEditorTest.php | 16 +++++++++++++--- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 6fcf8c1..1938486 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -1404,6 +1404,8 @@ public static function eventCommandVariants(): array new RecordField('resultVariable', 'Result Variable', removeWhenEmpty: true), new RecordField('defeatPolicy', 'Defeat Policy', options: ['game_over', 'continue'], removeWhenEmpty: true), new RecordField('escapePolicy', 'Escape Policy', options: ['allowed', 'forbidden'], removeWhenEmpty: true), + // Reserves replace a wiped-out frontline only where an encounter opts in. + new RecordField('reservePolicy', 'Reserve Policy', options: ['none', 'replace_after_wipeout'], removeWhenEmpty: true, displayDefault: 'none'), ], 'branch' => [ new RecordField('conditions', 'Conditions', codec: RecordFieldCodec::CONDITIONS), diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index af97746..b0217fe 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -3651,6 +3651,18 @@ protected function checkBattleContinuation(array $command, string $where): array ); } + if ( + array_key_exists('reservePolicy', $command) + && ! in_array($command['reservePolicy'], ['none', 'replace_after_wipeout'], true) + ) { + $reservePolicy = $command['reservePolicy']; + $issues[] = Issue::error( + $where, + sprintf('A start_battle command uses invalid reservePolicy "%s".', is_scalar($reservePolicy) ? strval($reservePolicy) : get_debug_type($reservePolicy)), + 'Choose none or replace_after_wipeout, or remove it: by default a wiped-out frontline loses the battle.' + ); + } + if (array_key_exists('escapePolicy', $command)) { $escapePolicy = $command['escapePolicy']; diff --git a/tests/Unit/ResumableStoryEventEditorTest.php b/tests/Unit/ResumableStoryEventEditorTest.php index 29865e7..2506713 100644 --- a/tests/Unit/ResumableStoryEventEditorTest.php +++ b/tests/Unit/ResumableStoryEventEditorTest.php @@ -94,9 +94,18 @@ function writePhase7ArrayFile(string $path, array $payload): void expect(RecordSchemaCatalog::EVENT_COMMAND_TYPES)->toBe(EventInterpreter::COMMAND_TYPES) ->and($battleFields['troop']->reference)->toBe('troops') - ->and($battleFields)->toHaveKeys(['resultVariable', 'defeatPolicy', 'escapePolicy']) + ->and($battleFields)->toHaveKeys(['resultVariable', 'defeatPolicy', 'escapePolicy', 'reservePolicy']) ->and($battleFields['defeatPolicy']->options)->toBe(['game_over', 'continue']) - ->and($battleFields['escapePolicy']->options)->toBe(['allowed', 'forbidden']); + ->and($battleFields['escapePolicy']->options)->toBe(['allowed', 'forbidden']) + // Absent, the encounter loses when its frontline is wiped out. + ->and($battleFields['reservePolicy']->removeWhenEmpty)->toBeTrue() + ->and($battleFields['reservePolicy']->displayDefault)->toBe('none'); + + // The choices are exactly the Engine's reserve policies, when the Engine has them. + if (enum_exists(\Ichiloto\Engine\Battle\ReservePolicy::class)) { + expect($battleFields['reservePolicy']->options) + ->toBe(array_map(static fn(\Ichiloto\Engine\Battle\ReservePolicy $policy): string => $policy->value, \Ichiloto\Engine\Battle\ReservePolicy::cases())); + } }); it('accepts every runtime command type and still rejects vocabulary drift', function (): void { @@ -247,7 +256,7 @@ function writePhase7ArrayFile(string $path, array $payload): void ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'missing-npc', 'wait' => 'true', 'speed' => 0, 'steps' => [ ['direction' => 'diagonal', 'count' => -1, 'faceOnly' => 'false'], ]], - ['type' => 'start_battle', 'troop' => '', 'resultVariable' => '', 'defeatPolicy' => 'always_win', 'escapePolicy' => 'sometimes'], + ['type' => 'start_battle', 'troop' => '', 'resultVariable' => '', 'defeatPolicy' => 'always_win', 'escapePolicy' => 'sometimes', 'reservePolicy' => 'everyone'], ['type' => 'parallel_cutscene'], ]); writePhase7ArrayFile($root . '/assets/Data/troops.php', [ @@ -268,6 +277,7 @@ function writePhase7ArrayFile(string $path, array $payload): void ->and($joined)->toContain('invalid wait value') ->and($joined)->toContain('speed must be greater than zero') ->and($joined)->toContain('invalid escapePolicy "sometimes"') + ->and($joined)->toContain('invalid reservePolicy "everyone"') ->and($joined)->toContain('troop Malformed Policy Troop') ->and($joined)->toContain('unsupported direction "diagonal"') ->and($joined)->toContain('invalid count') From 57e21b1ef15a52dd8a6078caee362a3fff1130f9 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 02:09:26 +0200 Subject: [PATCH 132/344] feat(cutscenes): author effect cadence and preview battle-paced effects An effect sequence's Cadence is a choice of the Engine's cadences: fixed plays at its FPS; battle_phase spreads its frames over the battle phase it plays in. A battle-paced sequence has no FPS, as the Engine requires: its FPS row is hidden, and choosing battle_phase puts its FPS aside so choosing fixed again gives it back; undo restores both. The preview plays a battle-paced sequence over its phase as the Engine times it (BattleTurnTimings for the command's category at a battle pace): the action animation for a source stage, the effect animation for a target stage. It starts at the project's own pace, the target stage unless every battle binding uses the effect as a source, and A, P and S change the kind of command, the pace and the stage. --- src/Cutscenes/CutsceneAsset.php | 44 +++++++- src/Cutscenes/Editing/CutscenePreviewPane.php | 105 +++++++++++++++++- src/Cutscenes/Editing/CutscenesWorkspace.php | 8 +- .../Preview/TimelinePreviewSession.php | 12 +- src/Database/CutsceneSchemas.php | 10 ++ .../Cutscenes/EffectTimelineAuthoringTest.php | 79 +++++++++++++ 6 files changed, 247 insertions(+), 11 deletions(-) diff --git a/src/Cutscenes/CutsceneAsset.php b/src/Cutscenes/CutsceneAsset.php index d8239bc..1f33a49 100644 --- a/src/Cutscenes/CutsceneAsset.php +++ b/src/Cutscenes/CutsceneAsset.php @@ -14,6 +14,7 @@ use Ichiloto\Editor\History\TracksPersistedState; use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Engine\Animations\Timelines\CompiledEffectTimeline; +use Ichiloto\Engine\Animations\Timelines\EffectCadence; use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicDefinition; use Ichiloto\Engine\Cutscenes\Summons\SummonCompiledCutscene; @@ -88,6 +89,12 @@ final class CutsceneAsset * keeps both, each exactly as written unless it is the one edited. */ private EffectPresentation $presentationView = EffectPresentation::TERMINAL; + /** + * @var array An effect sequence's own FPS while the battle + * paces it, by sequence ('flat', 'terminal' or 'graphical'), so choosing + * fixed cadence again gives it back. + */ + private array $pacedFps = []; private ?string $readOnlyReason = null; private bool $isNew = false; private bool $isDeleted = false; @@ -402,21 +409,22 @@ public function splitIntoPresentations(): bool * The payload alone is not enough: it is one sequence of an effect with * two, and a split leaves it unchanged. * - * @return array{data: array, partner: array, view: EffectPresentation, deleted: bool} + * @return array{data: array, partner: array, view: EffectPresentation, deleted: bool, pacedFps: array} */ public function captureEditState(): array { - return ['data' => $this->data, 'partner' => $this->partner, 'view' => $this->presentationView, 'deleted' => $this->isDeleted]; + return ['data' => $this->data, 'partner' => $this->partner, 'view' => $this->presentationView, 'deleted' => $this->isDeleted, 'pacedFps' => $this->pacedFps]; } /** * Puts the asset back to a state `captureEditState` returned. * - * @param array{data: array, partner: array, view: EffectPresentation, deleted: bool} $state + * @param array{data: array, partner: array, view: EffectPresentation, deleted: bool, pacedFps?: array} $state */ public function restoreEditState(array $state): void { $this->presentationView = $state['view']; + $this->pacedFps = $state['pacedFps'] ?? []; $this->markDeleted($state['deleted']); if ($this->readOnlyReason !== null || ($state['data'] === $this->data && $state['partner'] === $this->partner)) { @@ -428,6 +436,32 @@ public function restoreEditState(array $state): void $this->touchState(); } + /** + * A sequence the battle paces has no FPS of its own: the battle phase it + * plays in sets its timing, and the Engine refuses one. Choosing + * battle_phase cadence takes the sequence's FPS out and keeps it; + * choosing fixed again puts it back, first, as sequences write it. + * + * @param array $payload + * @return array + */ + private function settleEffectCadence(array $payload): array + { + $sequence = $this->hasPresentations() ? $this->presentationView->value : 'flat'; + + if (($payload['cadence'] ?? null) === EffectCadence::BATTLE_PHASE->value) { + if (array_key_exists('fps', $payload)) { + $this->pacedFps[$sequence] = $payload['fps']; + unset($payload['fps']); + } + } elseif (! array_key_exists('fps', $payload) && array_key_exists($sequence, $this->pacedFps)) { + $payload = ['fps' => $this->pacedFps[$sequence], ...$payload]; + unset($this->pacedFps[$sequence]); + } + + return $payload; + } + /** * @param array $timeline * @return array @@ -461,6 +495,10 @@ public function apply(array $payload): void unset($payload['id']); } + if ($this->type === CutsceneType::EFFECT) { + $payload = $this->settleEffectCadence($payload); + } + if ($this->hasPresentations()) { // The edited sequence goes back into its place; the other // sequence, and the keys of this one, keep their order. diff --git a/src/Cutscenes/Editing/CutscenePreviewPane.php b/src/Cutscenes/Editing/CutscenePreviewPane.php index 4888082..4aa0352 100644 --- a/src/Cutscenes/Editing/CutscenePreviewPane.php +++ b/src/Cutscenes/Editing/CutscenePreviewPane.php @@ -19,8 +19,13 @@ use Ichiloto\Editor\Status\StatusLevel; use Ichiloto\Editor\UI\CutscenesScreen; use Ichiloto\Editor\Validation\EffectValidator; +use Ichiloto\Engine\Animations\Timelines\EffectCadence; use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; +use Ichiloto\Engine\Battle\BattlePacing; +use Ichiloto\Engine\Battle\BattleTurnTimings; +use Ichiloto\Engine\Battle\Enumerations\BattleActionCategory; +use Ichiloto\Engine\Battle\Enumerations\BattlePace; use Throwable; /** @@ -58,6 +63,12 @@ trait CutscenePreviewPane private array $effectPreviewInBattle = []; /** Which side the caster stands on in an effect's battle preview. */ private bool $isEffectPreviewCasterOnLeft = true; + /** The kind of command a battle-paced effect is previewed inside, which sets how long its phase lasts. */ + private BattleActionCategory $effectPreviewAction = BattleActionCategory::PHYSICAL_ATTACK; + /** The battle pace a battle-paced effect is previewed at; the project's own until the author changes it. */ + private ?BattlePace $effectPreviewPace = null; + /** @var array Effect id => previewed as a command's source stage (else its target), once the author chose. */ + private array $effectPreviewAsSource = []; /** * Whether the preview pane is taller than its resting strip: while a @@ -560,6 +571,19 @@ private function handleTimelinePreviewInput(string $input): bool $lower = strtolower($input); $asset = $this->selectedCutscene(); + if ($asset?->type === CutsceneType::EFFECT && in_array($lower, ['a', 'p', 's'], true) && $this->isEffectPreviewInBattle($asset)) { + // The battle command a battle-paced effect plays inside: its kind, + // the battle's pace and the stage, which together set its phase. + match ($lower) { + 'a' => $this->effectPreviewAction = self::cycleCase(BattleActionCategory::cases(), $this->effectPreviewAction), + 'p' => $this->effectPreviewPace = self::cycleCase(BattlePace::cases(), $this->getEffectPreviewPace()), + 's' => $this->effectPreviewAsSource[$asset->id] = ! $this->isEffectPreviewSource($asset), + }; + $this->startTimelinePreview(play: $preview?->isPlaying() ?? false); + + return true; + } + if ($asset?->type === CutsceneType::EFFECT && ($input === 'b' || $input === 'd')) { if ($input === 'b') { $this->effectPreviewInBattle[$asset->id] = ! $this->isEffectPreviewInBattle($asset); @@ -693,7 +717,11 @@ private function startTimelinePreview(bool $play): void return; } - $this->timelinePreview = new TimelinePreviewSession($compiled, $asset->type === CutsceneType::EFFECT ? null : false); + // A battle-paced sequence spreads its frames over the phase it plays in. + $phaseSeconds = $asset->type === CutsceneType::EFFECT && $compiled->cadence === EffectCadence::BATTLE_PHASE + ? $this->getEffectPreviewPhaseSeconds($asset) + : null; + $this->timelinePreview = new TimelinePreviewSession($compiled, $asset->type === CutsceneType::EFFECT ? null : false, $phaseSeconds); $this->cinematicPreviewFingerprint = $this->cutscenePayloadFingerprint($asset); $this->cutscenePreviewLastTickAt = microtime(true); $this->cutscenePreviewScroll = 0; @@ -703,10 +731,12 @@ private function startTimelinePreview(bool $play): void } $this->setStatus(sprintf( - 'Previewing %s: %d frames at %d fps (%s)%s.', + 'Previewing %s: %d frames %s (%s)%s.', $asset->id, $this->timelinePreview->totalFrames(), - $this->timelinePreview->fps(), + $phaseSeconds === null + ? sprintf('at %d fps', $this->timelinePreview->fps()) + : sprintf('over %.2fs, %s (A, P, S change it)', $phaseSeconds, $this->describeEffectPreviewPacing($asset)), $play ? 'playing' : 'paused', $asset->type === CutsceneType::EFFECT ? sprintf(', as %s plays it', $this->isEffectPreviewInBattle($asset) ? 'battle' : 'the field') : '', ), StatusLevel::INFO); @@ -756,6 +786,69 @@ private function isEffectPreviewInBattle(CutsceneAsset $asset): bool return $this->effectPreviewInBattle[$asset->id] = isset($uses['battle']) || (! isset($uses['field']) && $fieldScripts === []); } + /** + * Whether the selected effect is previewed as a command's source stage: + * as the author last chose, else when every battle animation using it + * plays it as its source effect. + */ + private function isEffectPreviewSource(CutsceneAsset $asset): bool + { + if (isset($this->effectPreviewAsSource[$asset->id])) { + return $this->effectPreviewAsSource[$asset->id]; + } + + $battleUses = $this->workspace instanceof ProjectWorkspace ? (EffectValidator::findUses($this->workspace)[$asset->id]['battle'] ?? []) : []; + + return $this->effectPreviewAsSource[$asset->id] = $battleUses !== [] + && array_all($battleUses, static fn(string $use): bool => str_ends_with($use, ' sourceEffect')); + } + + /** The battle pace a paced effect is previewed at: the author's choice, else the project's. */ + private function getEffectPreviewPace(): BattlePace + { + $battleUi = $this->workspace?->config?->getRecord('ui.battle')->get('value'); + + return $this->effectPreviewPace ??= BattlePacing::fromBattleUiConfig(is_array($battleUi) ? $battleUi : [])->getAnimationPace(); + } + + /** + * The seconds the battle phase lasts that a paced effect plays in, from + * the Engine's own turn timings: the action animation for a source + * stage, the effect animation for a target stage. + */ + private function getEffectPreviewPhaseSeconds(CutsceneAsset $asset): float + { + $timings = BattleTurnTimings::fromTotalDuration($this->effectPreviewAction->totalDurationSeconds($this->getEffectPreviewPace())); + + return $this->isEffectPreviewSource($asset) ? $timings->actionAnimation : $timings->effectAnimation; + } + + /** The battle command a paced effect is previewed inside, as the author reads it. */ + private function describeEffectPreviewPacing(CutsceneAsset $asset): string + { + return sprintf( + 'the %s of a %s at %s pace', + $this->isEffectPreviewSource($asset) ? 'source' : 'target', + str_replace('_', ' ', $this->effectPreviewAction->value), + $this->getEffectPreviewPace()->value, + ); + } + + /** + * The case after the given one, wrapping to the first. + * + * @template T of \UnitEnum + * @param list $cases + * @param T $current + * @return T + */ + private static function cycleCase(array $cases, \UnitEnum $current): \UnitEnum + { + $index = array_search($current, $cases, true); + + return $cases[(($index === false ? -1 : $index) + 1) % count($cases)]; + } + /** * The stage an effect's frame is drawn on: its sequence being edited, * battle or the field, and the caster's side. @@ -1455,6 +1548,12 @@ private function timelinePreviewLines(CutsceneAsset $asset, int $contentWidth, i if ($stage !== null) { $info[] = sprintf(' %s · %s sequence', $stage->forBattle ? sprintf('battle, caster %s', $stage->isCasterOnLeft ? 'left' : 'right') : 'field', $stage->presentation->value); + + if ($preview->phaseDurationSeconds !== null) { + $info[] = sprintf(' paced over %.2fs:', $preview->phaseDurationSeconds); + $info[] = ' ' . $this->describeEffectPreviewPacing($asset); + $info[] = ' A action · P pace · S stage'; + } $info[] = ' Not drawn here:'; foreach ($stage->describeUndrawn($preview->activeSegments()) ?: ['(nothing)'] as $undrawn) { diff --git a/src/Cutscenes/Editing/CutscenesWorkspace.php b/src/Cutscenes/Editing/CutscenesWorkspace.php index e15dbef..1f23206 100644 --- a/src/Cutscenes/Editing/CutscenesWorkspace.php +++ b/src/Cutscenes/Editing/CutscenesWorkspace.php @@ -26,6 +26,7 @@ use Ichiloto\Editor\UI\MultilineTextEditor; use Ichiloto\Editor\UI\SettingsPaneLayout; use Ichiloto\Editor\Validation\EffectValidator; +use Ichiloto\Engine\Animations\Timelines\EffectCadence; use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Throwable; @@ -881,7 +882,7 @@ private function groupCutsceneFields(CutsceneAsset $asset, array $fields): array ], CutsceneType::EFFECT => [ 'Identity' => ['id'], - 'Timing' => ['fps', 'lengthFrames', 'playback', 'loopFrom', 'restFrame'], + 'Timing' => ['fps', 'lengthFrames', 'playback', 'cadence', 'loopFrom', 'restFrame'], 'Impact (battle)' => ['effectTiming.mode', 'effectTiming.frame', 'effectTiming.cueId'], 'Timeline' => ['commandListTracks', 'commandListCues'], ], @@ -897,6 +898,11 @@ private function groupCutsceneFields(CutsceneAsset $asset, array $fields): array $byId = []; $rest = []; + if ($asset->type === CutsceneType::EFFECT && ($asset->payload()['cadence'] ?? null) === EffectCadence::BATTLE_PHASE->value) { + // A sequence the battle paces has no FPS: the phase it plays in sets its timing. + $fields = array_values(array_filter($fields, static fn(array $field): bool => ($field['field'] ?? null) !== 'fps')); + } + foreach ($fields as $field) { $id = (string) ($field['field'] ?? ''); $placed = false; diff --git a/src/Cutscenes/Preview/TimelinePreviewSession.php b/src/Cutscenes/Preview/TimelinePreviewSession.php index cb82003..aec9304 100644 --- a/src/Cutscenes/Preview/TimelinePreviewSession.php +++ b/src/Cutscenes/Preview/TimelinePreviewSession.php @@ -31,10 +31,14 @@ final class TimelinePreviewSession /** * @param bool|null $loop Whether playback wraps at the last frame; null plays as the timeline's own playback says. + * @param float|null $phaseDurationSeconds For an effect paced by the battle, the seconds its battle phase lasts; its frames spread over them. */ - public function __construct(public readonly CompiledEffectTimeline $timeline, ?bool $loop = false) - { - $this->session = new EffectPlaybackSession($timeline, loop: $loop); + public function __construct( + public readonly CompiledEffectTimeline $timeline, + ?bool $loop = false, + public readonly ?float $phaseDurationSeconds = null, + ) { + $this->session = new EffectPlaybackSession($timeline, loop: $loop, phaseDurationSeconds: $phaseDurationSeconds); $this->loop = $this->session->isLooping; $this->session->pause(); } @@ -93,7 +97,7 @@ private function rebuildSession(): void { $frame = $this->session->currentFrame; $wasPlaying = $this->isPlaying(); - $this->session = new EffectPlaybackSession($this->timeline, loop: $this->loop, speed: $this->speed); + $this->session = new EffectPlaybackSession($this->timeline, loop: $this->loop, speed: $this->speed, phaseDurationSeconds: $this->phaseDurationSeconds); $this->session->seek($frame); if ($wasPlaying) { diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index 58580ee..9ab0521 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -7,6 +7,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Engine\Animations\Timelines\EffectPresentation; +use Ichiloto\Engine\Animations\Timelines\EffectCadence; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; @@ -453,6 +454,15 @@ public static function effects(): RecordSchema new RecordField('playback', 'Playback', options: ['once', 'loop'], removeWhenEmpty: true, displayDefault: 'once'), new RecordField('loopFrom', 'Loop From', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), new RecordField('restFrame', 'Rest Frame', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + // Battle only: fixed plays at its FPS; battle_phase spreads its frames + // over the battle phase it plays in, as the battle's pace sets it. + new RecordField( + 'cadence', + 'Cadence', + options: array_map(static fn(EffectCadence $cadence): string => $cadence->value, EffectCadence::cases()), + removeWhenEmpty: true, + displayDefault: EffectCadence::FIXED->value, + ), // Battle only: when the command's result lands. new RecordField('effectTiming.mode', 'Impact Timing', options: ['end', 'frame', 'cue'], removeWhenEmpty: true, displayDefault: '(omitted)'), new RecordField('effectTiming.frame', 'Impact Frame', InputControlType::INTEGER, removeWhenEmpty: true), diff --git a/tests/Unit/Cutscenes/EffectTimelineAuthoringTest.php b/tests/Unit/Cutscenes/EffectTimelineAuthoringTest.php index 16b14de..06e891c 100644 --- a/tests/Unit/Cutscenes/EffectTimelineAuthoringTest.php +++ b/tests/Unit/Cutscenes/EffectTimelineAuthoringTest.php @@ -6,7 +6,12 @@ use Ichiloto\Editor\Cutscenes\Preview\EffectPreviewStage; use Ichiloto\Editor\Editor; use Ichiloto\Editor\UI\CutscenesScreen; +use Ichiloto\Engine\Animations\Timelines\EffectCadence; use Ichiloto\Engine\Animations\Timelines\EffectPresentation; +use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; +use Ichiloto\Engine\Battle\BattleTurnTimings; +use Ichiloto\Engine\Battle\Enumerations\BattleActionCategory; +use Ichiloto\Engine\Battle\Enumerations\BattlePace; /** * Standalone effect timelines on the Cutscenes screen: grouped rows over @@ -208,6 +213,80 @@ function selectEffect(Editor $editor, string $id): void ->and(renderEditorPlainFrame($editor, 160, 50))->toContain('dusk-slash.png frame 0'); }); +it('puts a sequence\'s FPS aside while the battle paces it, and gives it back for fixed cadence', function () { + $root = effectProject(); + $editor = effectsEditor($root); + selectEffect($editor, 'ember-spark'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'ember-spark'); + + // Paced by the battle, the sequence has no FPS: the Engine refuses one, so its row goes too. + setCutsceneField($editor, 'cadence', 'battle_phase'); + expect($asset->partner())->not->toHaveKey('fps') + ->and($asset->partner()['cadence'])->toBe('battle_phase') + ->and(cutsceneFieldIds($editor))->not->toContain('fps') + ->and($asset->compiledEffect(EffectPresentation::TERMINAL, true)->cadence)->toBe(EffectCadence::BATTLE_PHASE); + + // Fixed again, the FPS the author wrote comes back. + setCutsceneField($editor, 'cadence', 'fixed'); + expect($asset->partner()['fps'])->toBe(10) + ->and(cutsceneFieldIds($editor))->toContain('fps'); + + // Undo walks back through both, and the file was never left without one or the other. + callEditorMethod($editor, 'performUndo'); + expect($asset->partner())->not->toHaveKey('fps'); + callEditorMethod($editor, 'performUndo'); + expect($asset->partner()['fps'])->toBe(10) + ->and($asset->partner())->not->toHaveKey('cadence') + ->and($asset->isDirty())->toBeFalse(); + + // Saved paced, the file holds no FPS and the Engine loads it for battle. + callEditorMethod($editor, 'performRedo'); + expect($asset->save())->toBeTrue(); + $saved = require $root . '/assets/Animations/ember-spark/ember-spark.timeline.php'; + expect($saved)->not->toHaveKey('fps') + ->and((new EffectTimelineLibrary($root . '/assets'))->load('ember-spark', true, EffectPresentation::TERMINAL)->cadence)->toBe(EffectCadence::BATTLE_PHASE); +}); + +it('previews a battle-paced effect over the phase the Engine times for its command, pace and stage', function () { + $root = effectProject(); + file_put_contents($root . '/config.php', " ['battle' => ['animation_pace' => 'fast']]];\n"); + @mkdir($root . '/assets/Animations/paced-burst', 0o777, true); + file_put_contents($root . '/assets/Animations/paced-burst/paced-burst.timeline.php', " 4,\n 'cadence' => 'battle_phase',\n 'tracks' => [['id' => 'burst', 'type' => 'glyph', 'keyframes' => [['frame' => 0, 'duration' => 4, 'content' => '*']]]],\n];\n"); + $phase = static fn(BattleActionCategory $action, BattlePace $pace): BattleTurnTimings => + BattleTurnTimings::fromTotalDuration($action->totalDurationSeconds($pace)); + $editor = effectsEditor($root); + selectEffect($editor, 'paced-burst'); + + // Cadence is a choice of the Engine's cadences, read from the file. + expect(cutsceneField($editor, 'cadence')['value'])->toBe('battle_phase') + ->and(cutsceneField($editor, 'cadence')['options'])->toBe(array_map(static fn(EffectCadence $cadence): string => $cadence->value, EffectCadence::cases())); + + // It starts as the target of a physical attack at the project's own pace. + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_PREVIEW); + pressKeys($editor, ' ', ' '); + expect(getEditorProperty($editor, 'timelinePreview')->phaseDurationSeconds) + ->toBe($phase(BattleActionCategory::PHYSICAL_ATTACK, BattlePace::FAST)->effectAnimation); + + // S plays it as the source stage, A as the next kind of command, P at the next pace. + pressKeys($editor, 's'); + expect(getEditorProperty($editor, 'timelinePreview')->phaseDurationSeconds) + ->toBe($phase(BattleActionCategory::PHYSICAL_ATTACK, BattlePace::FAST)->actionAnimation); + pressKeys($editor, 'a'); + expect(getEditorProperty($editor, 'timelinePreview')->phaseDurationSeconds) + ->toBe($phase(BattleActionCategory::cases()[1], BattlePace::FAST)->actionAnimation); + $next = BattlePace::cases()[(array_search(BattlePace::FAST, BattlePace::cases(), true) + 1) % count(BattlePace::cases())]; + pressKeys($editor, 'p'); + expect(getEditorProperty($editor, 'timelinePreview')->phaseDurationSeconds) + ->toBe($phase(BattleActionCategory::cases()[1], $next)->actionAnimation) + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('over '); + + // A fixed effect plays at its own fps, whatever the battle's pace. + selectEffect($editor, 'ember-spark'); + callEditorMethod($editor, 'startTimelinePreview', false); + expect(getEditorProperty($editor, 'timelinePreview')->timeline->sourceId)->toBe('ember-spark') + ->and(getEditorProperty($editor, 'timelinePreview')->phaseDurationSeconds)->toBeNull(); +}); + it('draws a flat field effect around its target, leaving image tracks to the graphical renderer', function () { $stage = new EffectPreviewStage(EffectPresentation::TERMINAL, false); $segments = [ From 9c65495f7eecbe2e45a142576b281d33db1675d2 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 05:07:02 +0200 Subject: [PATCH 133/344] fix(actors): repair every actor keyed presentation reference Legacy actor identity repair now covers battle pose sets keyed by actor and dialogue speaker bindings to actors, so a repair no longer strands them. Speaker bindings to artwork resources stay untouched. Presentation constructor arguments are read from the Engine class by reflection instead of a copied parameter list, so new catalog fields such as a battle scale are matched without editing a signature here. Nested constructors and single string arguments are rewritten in place; variable or computed arguments are still refused rather than flattened. --- src/Actors/ActorIdentityMigration.php | 2 +- src/Actors/ActorReferenceInventory.php | 10 ++- src/Actors/ActorReferenceSource.php | 78 +++++++++++++++++------ tests/Unit/ActorIdentityMigrationTest.php | 60 +++++++++++++++++ 4 files changed, 127 insertions(+), 23 deletions(-) diff --git a/src/Actors/ActorIdentityMigration.php b/src/Actors/ActorIdentityMigration.php index 7990a4b..9c8a2db 100644 --- a/src/Actors/ActorIdentityMigration.php +++ b/src/Actors/ActorIdentityMigration.php @@ -88,7 +88,7 @@ private static function buildPlan(string $projectRoot): ActorIdentityMigrationPl if ($changes === []) { continue; } try { $after[$path] = ActorReferenceSource::getUpdatedValue($payload, $changes); - $proposals[$path] = ActorReferenceSource::rewrite($source, $changes); + $proposals[$path] = ActorReferenceSource::rewrite($source, $changes, $payload); } catch (Throwable $failure) { throw self::createContextualFailure($path, $failure); } diff --git a/src/Actors/ActorReferenceInventory.php b/src/Actors/ActorReferenceInventory.php index 4ccef9a..ec31426 100644 --- a/src/Actors/ActorReferenceInventory.php +++ b/src/Actors/ActorReferenceInventory.php @@ -21,7 +21,8 @@ public static function getReferences(mixed $payload, string $relativePath): arra } } if (str_starts_with($relativePath, 'assets/Data/Presentation/')) { - foreach (['actors', 'portraits'] as $field) { + // Dialogue artwork, menu portraits and battle artwork/poses are keyed by actor ID. + foreach (['actors', 'portraits', 'actorPoses'] as $field) { foreach (array_keys((array) ($payload[$field] ?? [])) as $id) { $references[] = ['path' => [$field, $id], 'reference' => $id, 'kind' => 'key']; } @@ -29,6 +30,13 @@ public static function getReferences(mixed $payload, string $relativePath): arra foreach (array_keys((array) ($payload['results']['portraits'] ?? [])) as $id) { $references[] = ['path' => ['results', 'portraits', $id], 'reference' => $id, 'kind' => 'key']; } + // Speaker labels bind to an actor or to a non-actor artwork resource. + $resources = (array) ($payload['resources'] ?? []); + foreach ((array) ($payload['speakers'] ?? []) as $label => $identity) { + if (! is_string($identity) || ! array_key_exists($identity, $resources)) { + $references[] = ['path' => ['speakers', $label], 'reference' => $identity, 'kind' => 'value']; + } + } return $references; } if (str_starts_with($relativePath, 'assets/Data/Skits/')) { diff --git a/src/Actors/ActorReferenceSource.php b/src/Actors/ActorReferenceSource.php index 30d2fc3..ae9fb70 100644 --- a/src/Actors/ActorReferenceSource.php +++ b/src/Actors/ActorReferenceSource.php @@ -9,14 +9,25 @@ use Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal; use Ichiloto\Editor\Cutscenes\Source\SourceUnreadable; use Ichiloto\Editor\Database\PhpSourceDocument; +use Ichiloto\Engine\UI\Presentation\MenuPresentationCatalog; +use ReflectionClass; +use ReflectionParameter; /** Surgical literal edits; never a text-wide replacement of character names. */ final class ActorReferenceSource { - public static function rewrite(string $source, array $changes): string + /** @param mixed $payload The evaluated file; its classes name the constructors a returned catalog is read with. */ + public static function rewrite(string $source, array $changes, mixed $payload = null): string { try { $document = PhpArraySourceDocument::parse($source); } - catch (SourceUnreadable) { return self::rewriteReturnedConstructor($source, $changes); } + catch (SourceUnreadable) { + return self::rewriteReturnedConstructor($source, $changes, ActorReferenceInventory::getComparableValue($payload)); + } + return self::rewriteArray($document, $source, $changes); + } + + private static function rewriteArray(PhpArraySourceDocument $document, string $source, array $changes): string + { $edits = []; foreach ($changes as $change) { $path = $change['path']; @@ -49,7 +60,7 @@ public static function rewrite(string $source, array $changes): string } /** Presentation constructors retain imports, comments, argument order and unrelated expressions. */ - private static function rewriteReturnedConstructor(string $source, array $changes): string + private static function rewriteReturnedConstructor(string $source, array $changes, mixed $value): string { $depth = 0; $start = $end = null; @@ -64,7 +75,7 @@ private static function rewriteReturnedConstructor(string $source, array $change } if ($start === null || $end === null) { throw new SourceUnreadable('Cannot locate the returned presentation constructor.'); } $expression = substr($source, $start, $end - $start); - $rewritten = self::rewriteConstructor($expression, $changes); + $rewritten = self::rewriteConstructor($expression, $changes, $value); // Preserve whitespace/comments surrounding the constructor itself. $original = PhpSourceDocument::parse('entrySource(0); if ($original === null) { throw new SourceUnreadable('Presentation must return a supported constructor literal.'); } @@ -72,38 +83,63 @@ private static function rewriteReturnedConstructor(string $source, array $change return substr($source, 0, $start) . substr_replace($expression, $rewritten, $offset, strlen($original)) . substr($source, $end); } - private static function rewriteConstructor(string $expression, array $changes): string + /** Arguments are matched against the Engine constructor, so new catalog fields need no copied signature here. */ + private static function rewriteConstructor(string $expression, array $changes, mixed $value): string { $document = PhpSourceDocument::parse('entryClasses()[0] ?? '')); - $parameters = match ($class) { - 'DialoguePresentationCatalog' => ['actors'], - 'BattlePresentationCatalog' => ['arenas', 'actors', 'enemies', 'ui', 'results', 'pause'], - 'BattleResultsSkin' => ['textures', 'colors', 'portraits', 'icons', 'assetRoot'], - 'MenuPresentationCatalog' => ['assetRoot', 'data'], - default => throw new SourcePreservationRefusal('Unsupported presentation constructor; refusing to guess its arguments.'), - }; + $class = is_array($value) ? ($value['__class'] ?? null) : null; + $written = $document->entryClasses()[0] ?? ''; + if (! is_string($class) || ! class_exists($class) + || strcasecmp(self::getShortName($written), self::getShortName($class)) !== 0) { + throw new SourcePreservationRefusal('Unsupported presentation constructor; refusing to guess its arguments.'); + } + $parameters = array_map(static fn(ReflectionParameter $parameter): string => $parameter->getName(), + new ReflectionClass($class)->getConstructor()?->getParameters() ?? []); + // The menu theme derives every property from its single data argument. + $isMenu = $class === MenuPresentationCatalog::class; $groups = []; foreach ($changes as $change) { - if ($class === 'MenuPresentationCatalog') { array_unshift($change['path'], 'data'); } + if ($isMenu) { array_unshift($change['path'], 'data'); } $argument = array_shift($change['path']); $groups[$argument][] = $change; } foreach ($groups as $argument => $edits) { + if (! in_array($argument, $parameters, true)) { + throw new SourcePreservationRefusal("{$class} has no constructor argument {$argument} holding actor references."); + } $literal = $document->getConstructorArgumentSource(0, $argument, $parameters); if ($literal === null) { throw new SourcePreservationRefusal('Missing actor reference argument ' . $argument . '.'); } - if ($argument === 'results') { - $updated = self::rewriteConstructor($literal, $edits); - } else { - $wrapper = 'getWithConstructorArgument(0, $argument, $updated, $parameters); } return $document->entrySource(0) ?? throw new SourcePreservationRefusal('Cannot preserve the presentation constructor.'); } + /** A nested constructor, a single string literal or an array literal; never a variable or computed value. */ + private static function rewriteArgument(string $literal, array $edits, mixed $value, string $argument): string + { + $tokens = array_values(array_filter(\PhpToken::tokenize(' ! $token->is([T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT]))); + if (($tokens[0] ?? null)?->is(T_NEW)) { return self::rewriteConstructor($literal, $edits, $value); } + $refusal = "Actor references in {$argument} are an expression or variable; refusing to flatten them."; + if (count($edits) === 1 && $edits[0]['path'] === []) { + if (count($tokens) !== 1 || ! $tokens[0]->is(T_CONSTANT_ENCAPSED_STRING)) { + throw new SourcePreservationRefusal($refusal); + } + $start = $tokens[0]->pos - strlen('text)); + } + $wrapper = 'and($after[0]->where)->toBe('assets/Data/Skits/z-last-scene.php:beats.0.actor') ->and(\Ichiloto\Editor\Validation\Issue::error('elsewhere', 'Other failure')->code)->toBeNull(); }); + +it('repairs every actor keyed presentation reference without touching enemies or artwork resources', function () { + [$root, $actorPath] = createLegacyActorProject(); + file_put_contents($actorPath, " ['id' => 'hero', 'name' => 'Kaelion']];"); + mkdir($root . '/assets/Data/Presentation'); + $battle = <<<'PHP' + new BattlePoseSet([], displayWidth: 90)], + // Party poses follow the same stable identity as their artwork. + actorPoses: [/* lead */ 'Kaelion' => new BattlePoseSet([], displayWidth: 120)], + arenas: [], + enemies: ['Kaelion' => new BattlerArtwork('shade.png', 1, 1, 0, 0)], + actors: ['Kaelion' => new BattlerArtwork('hero.png', 1, 1, 0, 0)], +); +PHP; + $dialogue = <<<'PHP' + ['emotions' => []]], + resources: ['Fira' => ['emotions' => []]], + speakers: ['Kael' => 'Kaelion', 'Fira Nel' => 'Fira'], +); +PHP; + file_put_contents($root . '/assets/Data/Presentation/battle.php', $battle); + file_put_contents($root . '/assets/Data/Presentation/dialogue.php', $dialogue); + ActorIdentityMigration::migrateProject($root); + expect(file_get_contents($root . '/assets/Data/Presentation/battle.php'))->toBe(str_replace( + ["/* lead */ 'Kaelion'", "actors: ['Kaelion'"], ["/* lead */ 'hero'", "actors: ['hero'"], $battle)) + ->and(file_get_contents($root . '/assets/Data/Presentation/dialogue.php'))->toBe(str_replace( + ["actors: ['Kaelion'", "'Kael' => 'Kaelion'"], ["actors: ['hero'", "'Kael' => 'hero'"], $dialogue)) + ->and(ActorIdentityMigration::planProject($root)->getChangedPaths())->toBe([]) + ->and(new \Ichiloto\Editor\Validation\ActorReferenceValidator()->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)))->toBe([]); +}); + +it('refuses a variable backed presentation argument rather than stranding or flattening its actor keys', function () { + [$root, $actorPath] = createLegacyActorProject(); + file_put_contents($actorPath, " ['id' => 'hero', 'name' => 'Kaelion']];"); + mkdir($root . '/assets/Data/Presentation'); + file_put_contents($root . '/assets/Data/Presentation/battle.php', <<<'PHP' + new BattlePoseSet([], displayWidth: 120)]; +return new BattlePresentationCatalog([], [], [], actorPoses: $poses); +PHP); + $before = sourceHashTree($root); + try { + ActorIdentityMigration::planProject($root); + $this->fail('A variable-backed pose catalog must be refused.'); + } catch (RuntimeException $failure) { + expect($failure->getPrevious())->toBeInstanceOf(\Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal::class) + ->and($failure->getMessage())->toContain('battle.php', 'actorPoses', 'refusing to flatten'); + } + expect(sourceHashTree($root))->toBe($before); +}); From ebbde3c9c7ff259a70132c46871d178d094ca33e Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 05:17:37 +0200 Subject: [PATCH 134/344] fix(actors): repair battle scale actor references Legacy actor identity repair now rewrites the battle scale's reference actor and its party profile keys. Enemy profiles and every calibration value are preserved. --- src/Actors/ActorReferenceInventory.php | 10 +++++++ tests/Unit/ActorIdentityMigrationTest.php | 32 +++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/Actors/ActorReferenceInventory.php b/src/Actors/ActorReferenceInventory.php index ec31426..9f945e0 100644 --- a/src/Actors/ActorReferenceInventory.php +++ b/src/Actors/ActorReferenceInventory.php @@ -30,6 +30,16 @@ public static function getReferences(mixed $payload, string $relativePath): arra foreach (array_keys((array) ($payload['results']['portraits'] ?? [])) as $id) { $references[] = ['path' => ['results', 'portraits', $id], 'reference' => $id, 'kind' => 'key']; } + // Battle scale: its reference body and party profiles; enemy profiles are enemy identities. + $scale = $payload['scale'] ?? null; + if (is_array($scale)) { + if (array_key_exists('referenceActorId', $scale)) { + $references[] = ['path' => ['scale', 'referenceActorId'], 'reference' => $scale['referenceActorId'], 'kind' => 'value']; + } + foreach (array_keys((array) ($scale['actors'] ?? [])) as $id) { + $references[] = ['path' => ['scale', 'actors', $id], 'reference' => $id, 'kind' => 'key']; + } + } // Speaker labels bind to an actor or to a non-actor artwork resource. $resources = (array) ($payload['resources'] ?? []); foreach ((array) ($payload['speakers'] ?? []) as $label => $identity) { diff --git a/tests/Unit/ActorIdentityMigrationTest.php b/tests/Unit/ActorIdentityMigrationTest.php index a11176e..4d05852 100644 --- a/tests/Unit/ActorIdentityMigrationTest.php +++ b/tests/Unit/ActorIdentityMigrationTest.php @@ -320,3 +320,35 @@ function createLegacyActorProject(): array } expect(sourceHashTree($root))->toBe($before); }); + +it('repairs the battle scale reference actor and actor profiles while preserving enemies and calibration', function () { + [$root, $actorPath] = createLegacyActorProject(); + file_put_contents($actorPath, " ['id' => 'hero', 'name' => 'Kaelion']];"); + mkdir($root . '/assets/Data/Presentation'); + $battle = <<<'PHP' + new BattlerArtwork('hero.png', 1, 1, 0, 0)], + enemies: ['Kaelion' => new BattlerArtwork('shade.png', 1, 1, 0, 0)], + scale: new BattleScale( + // The party lead is the body every battler is sized against. + referenceActorId: 'Kaelion', + referenceHeight: 210.0, + actors: ['Kaelion' => new BattlerScale(1.0, 0.82)], + enemies: ['Kaelion' => new BattlerScale(0.4, 0.6, horizontal: true)], + ), +); +PHP; + file_put_contents($root . '/assets/Data/Presentation/battle.php', $battle); + ActorIdentityMigration::migrateProject($root); + expect(file_get_contents($root . '/assets/Data/Presentation/battle.php'))->toBe(str_replace( + ["actors: ['Kaelion' => new BattlerArtwork", "referenceActorId: 'Kaelion'", "actors: ['Kaelion' => new BattlerScale"], + ["actors: ['hero' => new BattlerArtwork", "referenceActorId: 'hero'", "actors: ['hero' => new BattlerScale"], $battle)) + ->and(ActorIdentityMigration::planProject($root)->getChangedPaths())->toBe([]) + ->and(new \Ichiloto\Editor\Validation\ActorReferenceValidator()->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)))->toBe([]); +}); From 419774fd597c47341de1703aab5e03a288fdf597 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 05:34:04 +0200 Subject: [PATCH 135/344] feat(session): draw wall shadows in the map world The map world asks the Engine for tile shadows, so the GUI editor shows the same bands the game draws, rebuilt from the tiles after every stroke. The tile palette keeps its shadow-free world. --- src/Session/EditorSession.php | 2 +- tests/Support/MapGraphicsFixtures.php | 5 +++-- tests/Unit/Session/EditorSessionTest.php | 26 ++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 2e1c120..8724943 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -315,7 +315,7 @@ public function readWorld(string $mapId): array 'map' => $map->mapId, 'revision' => $map->stateVersion(), 'assetRoot' => $map->getAssetRoot(), - 'operations' => $world->getOperations(true), + 'operations' => $world->getOperations(true, true), 'layerIds' => $layerIds, 'animated' => $world->animated, 'graphicsIssue' => $issue, diff --git a/tests/Support/MapGraphicsFixtures.php b/tests/Support/MapGraphicsFixtures.php index 652ec48..991d522 100644 --- a/tests/Support/MapGraphicsFixtures.php +++ b/tests/Support/MapGraphicsFixtures.php @@ -31,7 +31,8 @@ function writeTilesetTestPng(string $path, int $width, int $height): void * @param array> $pieces Pieces by id, as a tileset file lists them. * @param int|null $missingArt The tileset's missing-art tile, if it names one. */ -function writeTestTileset(string $root, string $id = 'home', ?array $sheets = null, array $pieces = [], ?int $missingArt = null): void +function writeTestTileset(string $root, string $id = 'home', ?array $sheets = null, array $pieces = [], ?int $missingArt = null, + ?array $shadows = null): void { $sheets ??= ['A2' => 'Graphics/Tilesets/Home_A2.png', 'B' => 'Graphics/Tilesets/Home_B.png']; writeTilesetTestPng($root . '/assets/Graphics/Tilesets/Home_A2.png', 32, 24); @@ -40,7 +41,7 @@ function writeTestTileset(string $root, string $id = 'home', ?array $sheets = nu mkdir($root . '/assets/Data/Tilesets', 0o777, true); } $data = ['name' => ucfirst($id), 'sheets' => $sheets] + ($pieces === [] ? [] : ['pieces' => $pieces]) - + ($missingArt === null ? [] : ['missingArt' => $missingArt]); + + ($missingArt === null ? [] : ['missingArt' => $missingArt]) + ($shadows === null ? [] : ['shadows' => $shadows]); file_put_contents($root . "/assets/Data/Tilesets/{$id}.php", "and(fn() => $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[0, 0]], 9000))->toThrow(SessionRefusal::class, "'9000' is not an RPG Maker tile identity"); }); +it('redraws wall shadows from the tiles after every stroke, with no shadow data to place and none in the palette', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, shadows: ['casters' => [5], 'width' => 0.5, 'opacity' => 0.4]); + $session = EditorSession::open($root); + $shadows = static function () use ($session): array { + $cells = []; + foreach ($session->readWorld('test-map')['operations'] as $operation) { + if ($operation['op'] === 'worldTiles' && str_ends_with($operation['layerId'], ':shadows')) { + $cells[$operation['rows'][0]['row']] = array_column($operation['rows'][0]['cells'], 'column'); + } + } + ksort($cells); + return $cells; + }; + + // Decor casters at (1, 0) and (3, 1): the first shades the floor to its right; the second stands at the map edge. + expect($shadows())->toBe([0 => [2]]); + $extended = $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'decor', [[2, 0]], 5); + expect($shadows())->toBe([0 => [3]]); + $session->paintTiles('test-map', $extended['revision'], 'decor', [[1, 0], [2, 0]], 0, 'Erase tiles'); + expect($shadows())->toBe([]) + ->and($session->undo()['label'])->toBe('Erase tiles') + ->and($shadows())->toBe([0 => [3]]) + ->and(json_encode($session->readTilePalette('test-map')))->not->toContain('shadows'); +}); + it('saves placed tiles into the tile layer file the Engine reads', function () { $root = mapGraphicsProject(); $session = EditorSession::open($root); From 06dc344e98e3b1b41c24e3f3552486b74c7e9df1 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 05:39:18 +0200 Subject: [PATCH 136/344] fix(session): send wall shadows only to a client that asks map.world now includes tile shadows only when the request sets tileShadows, as a game renderer negotiates tile_shadows. A GUI editor built against a renderer that cannot paint shadow layers keeps receiving the world it can draw. --- src/Session/EditorSession.php | 8 +++++--- src/Session/SessionHost.php | 3 ++- tests/Unit/Session/EditorSessionTest.php | 20 ++++++++++++++++++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 8724943..5d611a3 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -284,12 +284,14 @@ public function readMap(string $mapId): array * unsaved edits included. `layerIds` maps each glyph layer's id to its * world layer id. Graphics never decide whether a map shows: when the * game would refuse them, the world holds glyphs only and - * `graphicsIssue` says why. + * `graphicsIssue` says why. Wall shadows come only to an interface whose + * renderer paints them and asks with `$tileShadows`, as a game renderer + * negotiates `tile_shadows`; any other receives the world without them. * * @return array{map: string, revision: int, assetRoot: string, operations: list>, layerIds: array, animated: bool, graphicsIssue: ?string} * @throws SessionRefusal When the map is unknown or its layers cannot be presented. */ - public function readWorld(string $mapId): array + public function readWorld(string $mapId, bool $tileShadows = false): array { $map = $this->requireMap($mapId); $issue = null; @@ -315,7 +317,7 @@ public function readWorld(string $mapId): array 'map' => $map->mapId, 'revision' => $map->stateVersion(), 'assetRoot' => $map->getAssetRoot(), - 'operations' => $world->getOperations(true, true), + 'operations' => $world->getOperations(true, $tileShadows), 'layerIds' => $layerIds, 'animated' => $world->animated, 'graphicsIssue' => $issue, diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index a56bd45..57a9a2b 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -107,7 +107,8 @@ private function dispatch(string $method, array $params): array ), 'map.delete' => $session->deleteMap(self::requireString($params, 'map'), ($params['confirm'] ?? false) === true), 'map.read' => $session->readMap(self::requireString($params, 'map')), - 'map.world' => $session->readWorld(self::requireString($params, 'map')), + 'map.world' => $session->readWorld(self::requireString($params, 'map'), + is_bool($params['tileShadows'] ?? false) ? ($params['tileShadows'] ?? false) : throw new InvalidRequest('"tileShadows" must be a boolean.')), 'tiles.palette' => $session->readTilePalette(self::requireString($params, 'map')), 'tiles.read' => $session->readTiles(self::requireString($params, 'map')), 'tiles.stamp' => $session->stampTiles( diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index 8c6f855..9b3d31a 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -283,7 +283,7 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a $session = EditorSession::open($root); $shadows = static function () use ($session): array { $cells = []; - foreach ($session->readWorld('test-map')['operations'] as $operation) { + foreach ($session->readWorld('test-map', tileShadows: true)['operations'] as $operation) { if ($operation['op'] === 'worldTiles' && str_ends_with($operation['layerId'], ':shadows')) { $cells[$operation['rows'][0]['row']] = array_column($operation['rows'][0]['cells'], 'column'); } @@ -293,7 +293,9 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a }; // Decor casters at (1, 0) and (3, 1): the first shades the floor to its right; the second stands at the map edge. - expect($shadows())->toBe([0 => [2]]); + expect($shadows())->toBe([0 => [2]]) + // An interface whose renderer does not paint shadows never receives them. + ->and(json_encode($session->readWorld('test-map')))->not->toContain('shadows'); $extended = $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'decor', [[2, 0]], 5); expect($shadows())->toBe([0 => [3]]); $session->paintTiles('test-map', $extended['revision'], 'decor', [[1, 0], [2, 0]], 0, 'Erase tiles'); @@ -303,6 +305,20 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a ->and(json_encode($session->readTilePalette('test-map')))->not->toContain('shadows'); }); +it('asks for wall shadows over the line protocol only by an explicit boolean', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, shadows: ['casters' => [5], 'width' => 0.5, 'opacity' => 0.4]); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(int $id, string $method, array $params = []): array => + $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params])); + $request(1, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + + expect(json_encode($request(2, 'map.world', ['map' => 'test-map'])['result']))->not->toContain('shadows') + ->and(json_encode($request(3, 'map.world', ['map' => 'test-map', 'tileShadows' => true])['result']))->toContain('tiles:decor:shadows') + ->and($request(4, 'map.world', ['map' => 'test-map', 'tileShadows' => 'yes'])['error']) + ->toBe(['kind' => 'request', 'message' => '"tileShadows" must be a boolean.']); +}); + it('saves placed tiles into the tile layer file the Engine reads', function () { $root = mapGraphicsProject(); $session = EditorSession::open($root); From 0d5960d7a3d5869421c24e879bf352d00311f336 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 06:19:48 +0200 Subject: [PATCH 137/344] feat(cutscenes): author a battle image's attachment and pivot An effect image track offers the Engine's battle attachment (center, head or ground) and its pivot, the point of the sheet cell that sits on that placement. The pivot is one x, y row from 0 to 1, stored whole or removed whole, so a half-written pivot can never reach the Engine. --- docs/manual.md | 11 ++++-- src/Database/CutsceneSchemas.php | 13 ++++++- src/Database/ProjectRecordDatabase.php | 18 +++++++++ src/Database/RecordFieldCodec.php | 6 +++ .../Cutscenes/EffectTimelineAuthoringTest.php | 38 +++++++++++++++++++ 5 files changed, 81 insertions(+), 5 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 95778a3..42e6576 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1853,13 +1853,18 @@ command's result lands: at the `end`, a `frame` or a `cue`) and `terminal` or `graphical`), an anchor (`target`, `caster` or `screen`) and, for a battle stroke drawn one way, `Facing` (`west` or `east`; the empty choice removes it, and the stroke stays as drawn). An image track names its -PNG through the asset picker, its sheet columns and rows, its cell size and -its depth; its keyframes give the sheet frame, the position and, in battle, +PNG through the asset picker, its sheet columns and rows, its cell size, its +depth and, in battle, its `Attachment` (`center`, `head` or `ground` on the +battler; the empty choice removes it, so the image is centred) and `Pivot` +(the point of the sheet cell that sits there, `x, y` from 0 to 1, such as +`0.5, 0.92` for a ring at the cell's foot; empty removes it, so the cell's +middle sits there); its keyframes give the sheet frame, the position and, in battle, `Flip Horizontally` and `Flip Vertically` for an authored reverse stroke. Glyph and text keyframes give frame, duration, position `x` and `y`, content (the multiline editor), asset id, color, visibility, z-index and a payload. Cues are `playSound` on the field and any battle cue in battle. -Impact timing, facing, flips, flashes and shakes are battle-only; +Impact timing, facing, image attachment and pivot, flips, flashes and shakes +are battle-only; validation names the place that uses an effect where they are refused. **Sequence** says how the effect is drawn. A flat effect has one sequence diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index 9ab0521..6f0144c 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -8,6 +8,7 @@ use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Animations\Timelines\EffectCadence; +use Ichiloto\Engine\Animations\Timelines\EffectImageAttachment; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; @@ -436,8 +437,9 @@ public static function cameraPointList(): RecordSubList * sequence being edited as fields, its tracks and cues as lists opened * as frames. Every row is a key the Engine's effect library reads; an * effect with separate terminal and graphical sequences is edited one - * sequence at a time. Battle-only keys (impact timing, facing, flips, - * flash and shake) are offered for every effect; validation says where + * sequence at a time. Battle-only keys (impact timing, facing, image + * attachment and pivot, flips, flash and shake) are offered for every + * effect; validation says where * an effect is used that refuses them. */ public static function effects(): RecordSchema @@ -520,6 +522,13 @@ public static function effectTrackList(): RecordSubList new RecordField('cells.width', 'Cell Width', InputControlType::INTEGER, displayDefault: '1'), new RecordField('cells.height', 'Cell Height', InputControlType::INTEGER, displayDefault: '1'), new RecordField('depth', 'Depth', options: ['front', 'behind'], removeWhenEmpty: true, displayDefault: 'front'), + // Battle only: the battler point the image is placed on (the empty + // choice removes it: its centre), and the point of the sheet cell that + // sits there, x and y from 0 to 1 (removed: the cell's middle). + new RecordField('attachment', 'Attachment', + options: ['', ...array_map(static fn(EffectImageAttachment $attachment): string => $attachment->value, EffectImageAttachment::cases())], + removeWhenEmpty: true, displayDefault: '(center)'), + new RecordField('pivot', 'Pivot', codec: RecordFieldCodec::NORMALIZED_POINT, removeWhenEmpty: true, displayDefault: '0.5, 0.5'), ], ], variantKey: 'type', diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 3f0c449..b093ef7 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -2848,6 +2848,21 @@ private static function coerce(RecordField $field, string $rawValue): mixed return [intval($parts[0]), intval($parts[1])]; } + if ($field->codec === RecordFieldCodec::NORMALIZED_POINT) { + if ($trimmed === '' && $field->removeWhenEmpty) { + return null; + } + + $parts = array_map(trim(...), explode(',', $trimmed)); + + if (count($parts) !== 2 || ! is_numeric($parts[0]) || ! is_numeric($parts[1]) + || min(floatval($parts[0]), floatval($parts[1])) < 0.0 || max(floatval($parts[0]), floatval($parts[1])) > 1.0) { + throw new \InvalidArgumentException(sprintf('%s must be two numbers from 0 to 1, x and y, separated by a comma.', $field->label)); + } + + return ['x' => floatval($parts[0]), 'y' => floatval($parts[1])]; + } + if ($field->codec === RecordFieldCodec::CONDITIONS) { $conditions = ConditionCodec::decodeAllStrictly($trimmed); @@ -2964,6 +2979,9 @@ private static function displayValue(RecordField $field, mixed $value): string RecordFieldCodec::POINT => is_array($value) ? implode(', ', array_map(strval(...), array_values($value))) : ProjectRecord::stringify($value), + RecordFieldCodec::NORMALIZED_POINT => is_array($value) + ? sprintf('%s, %s', ProjectRecord::stringify($value['x'] ?? ''), ProjectRecord::stringify($value['y'] ?? '')) + : ProjectRecord::stringify($value), RecordFieldCodec::NONE => ProjectRecord::stringify($value), }; } diff --git a/src/Database/RecordFieldCodec.php b/src/Database/RecordFieldCodec.php index 5779a89..63cc80a 100644 --- a/src/Database/RecordFieldCodec.php +++ b/src/Database/RecordFieldCodec.php @@ -50,4 +50,10 @@ enum RecordFieldCodec: string * A point stored as `[x, y]`, shown as `x, y`. */ case POINT = 'point'; + + /** + * A point within a whole, stored as `['x' => x, 'y' => y]` with each + * from 0 to 1, shown as `x, y`: an image's pivot. Both or neither. + */ + case NORMALIZED_POINT = 'normalized_point'; } diff --git a/tests/Unit/Cutscenes/EffectTimelineAuthoringTest.php b/tests/Unit/Cutscenes/EffectTimelineAuthoringTest.php index 06e891c..cff9a12 100644 --- a/tests/Unit/Cutscenes/EffectTimelineAuthoringTest.php +++ b/tests/Unit/Cutscenes/EffectTimelineAuthoringTest.php @@ -7,6 +7,7 @@ use Ichiloto\Editor\Editor; use Ichiloto\Editor\UI\CutscenesScreen; use Ichiloto\Engine\Animations\Timelines\EffectCadence; +use Ichiloto\Engine\Animations\Timelines\EffectImageAttachment; use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Battle\BattleTurnTimings; @@ -112,6 +113,43 @@ function selectEffect(Editor $editor, string $id): void ->and($asset->isDirty())->toBeFalse(); }); +it("sets and removes a battle image's attachment and pivot as whole fields, and the Engine still plays it", function () { + $root = effectProject(); + $editor = effectsEditor($root); + selectEffect($editor, 'dusk-slash'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'dusk-slash'); + setCutsceneField($editor, '@sequence', 'graphical'); + $track = static fn(): array => $asset->partner()['presentations']['graphical']['tracks'][0]; + + selectCutsceneField($editor, 'commandListTracks'); + pressKeys($editor, "\n"); + $find = static fn(string $suffix): string => array_values(array_filter(cutsceneFieldIds($editor), + static fn(string $id): bool => str_ends_with(strtolower($id), $suffix)))[0]; + [$attachment, $pivot] = [$find('attachment'), $find('pivot')]; + + expect(cutsceneField($editor, $attachment)['options']) + ->toBe(['', ...array_map(static fn(EffectImageAttachment $case): string => $case->value, EffectImageAttachment::cases())]); + + // The ring at the cell's foot sits on the ground beneath the battler. + setCutsceneField($editor, $attachment, 'ground'); + setCutsceneField($editor, $pivot, '0.5, 0.92'); + expect($track()['attachment'])->toBe('ground') + ->and($track()['pivot'])->toBe(['x' => 0.5, 'y' => 0.92]) + ->and(cutsceneField($editor, $pivot)['value'])->toBe('0.5, 0.92') + ->and($asset->compiledEffect(EffectPresentation::GRAPHICAL, true))->not->toBeNull(); + + // A pivot outside the cell, or half of one, is refused and nothing changes. + foreach (['1.2, 0.5', '0.5', 'left, top'] as $invalid) { + setCutsceneField($editor, $pivot, $invalid); + expect($track()['pivot'])->toBe(['x' => 0.5, 'y' => 0.92]); + } + + setCutsceneField($editor, $pivot, ''); + setCutsceneField($editor, $attachment, ''); + expect($track())->not->toHaveKey('pivot')->not->toHaveKey('attachment') + ->and($asset->isDirty())->toBeFalse(); +}); + it('adds tracks, keyframes and cues from the timeline rows with the blanks the effect schema owns', function () { $root = effectProject(); $editor = effectsEditor($root); From ab2dd9c32cf2cf19753a27ad6506856c975eb4dc Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 06:55:13 +0200 Subject: [PATCH 138/344] fix(session): keep a read-only row's key A record or NPC row that names a field keeps its key whether or not it can be edited. Read-only rows now say editable false, and the session had read that as having no key, so an interface could no longer name an id row. Editability only makes the row info, which every edit refuses. --- src/Session/EditorSession.php | 9 ++++++--- tests/Unit/Session/DatabaseSessionTest.php | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 5d611a3..86dbfbf 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1068,12 +1068,14 @@ public function readDatabaseRecord(string $category, int $index, array $frame = 'frameLabel' => $frame === [] ? null : $database->describeFramePath($frame), 'editable' => $database->isEditable(), 'readOnly' => $database->getReadOnlyReason(), + // A row that names a field keeps its key whether or not it can be + // edited; a read-only one is an `info` row the edit refuses. 'rows' => array_map(static fn(array $field): array => self::describeRecordRow( $database, $index, $frame, $field, - isset($field['field']) && ($field['editable'] ?? true) !== false ? 'record' : null, + isset($field['field']) ? 'record' : null, ), $fields), ]; } @@ -1589,9 +1591,10 @@ private function collectNpcFields(NpcInspector $inspector, int $index, array $fr private static function describeNpcRow(ProjectRecordDatabase $records, int $index, array $field, array $frame): array { $fieldId = $field['field'] ?? null; - $editable = is_string($fieldId) && $fieldId !== NpcInspector::ASSIGN_ID_FIELD && ($field['editable'] ?? true) !== false; + // Keyed whether or not it can be edited; a read-only row is `info`. + $named = is_string($fieldId) && $fieldId !== NpcInspector::ASSIGN_ID_FIELD; - return self::describeRecordRow($records, $index, $frame, $field, $editable ? 'npc' : null); + return self::describeRecordRow($records, $index, $frame, $field, $named ? 'npc' : null); } /** diff --git a/tests/Unit/Session/DatabaseSessionTest.php b/tests/Unit/Session/DatabaseSessionTest.php index 74960ff..8616d85 100644 --- a/tests/Unit/Session/DatabaseSessionTest.php +++ b/tests/Unit/Session/DatabaseSessionTest.php @@ -35,6 +35,7 @@ function databaseSessionRow(array $read, string $fieldId): array ->and($types)->toMatchArray(['editable' => false, 'canCreate' => false, 'canDelete' => false]) ->and($root)->toMatchArray(['frame' => [], 'frameLabel' => null, 'editable' => true, 'readOnly' => null]) ->and(databaseSessionRow($root, '__scriptId'))->not->toHaveKey('item') + ->and(databaseSessionRow($root, '__scriptId')['kind'])->toBe('info') ->and(databaseSessionRow($root, 'command3Then'))->toMatchArray(['frame' => [3, 'then'], 'item' => true, 'itemNoun' => 'command']) ->and($then['frameLabel'])->toBe('Commands › Branch 4 › Then') ->and(databaseSessionRow($then, 'command0Text'))->toMatchArray([ From b70bc61bd1679c9b41969e8403f186e9c9d946bb Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 08:33:10 +0200 Subject: [PATCH 139/344] feat(maps): choose a map's region from the project's region names Region was a text field. It is now a reference picker over the region names the project's maps already use, so one region is spelled one way. Choosing a region changes only the map data's region; the map stays in its folder. --- docs/manual.md | 8 ++++++++ src/Database/ReferenceCatalog.php | 25 ++++++++++++++++++++++++ src/Editor.php | 2 +- src/Inspector/MapInspector.php | 4 +++- tests/Unit/Session/EditorSessionTest.php | 24 +++++++++++++++++++++++ 5 files changed, 61 insertions(+), 2 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 01a9e18..810aa3e 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -783,6 +783,14 @@ scrolls by rows, keeping the selected row's first line in view. The Destination row on an event is a reference: `Ctrl+G` follows it. +### Map region + +`Region` is the place name the game shows for where the party is, such as +Happyville or Garden of Roads. Enter opens a picker of the region names the +project's maps already use, so a region is spelled one way everywhere; it is +never typed. Choosing one changes only the map data's `region`: the map stays +in its folder under `assets/Maps`. + ### Map kind `Kind`, under `Region`, is the setting the map draws: one of the project's diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index aa2597b..e9698e0 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -73,6 +73,7 @@ final class ReferenceCatalog 'event_markers', 'tilesets', 'effects', + 'map_regions', ]; /** @@ -192,12 +193,36 @@ public function valuesFor(string $category): array 'event_markers' => $this->currentMap?->getEventMarkers() ?? [], // A map's kind is one of the project's tilesets, by file stem. 'tilesets' => array_keys($this->loadTilesetNames()), + // A region is the display name the game shows for where the party + // is; choosing from the names the maps already use keeps one + // region spelled one way. + 'map_regions' => $this->mapRegions(), // Effect timelines are folders the Engine lists by stable id. 'effects' => new EffectTimelineLibrary($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets')->findTimelineIds(), default => $this->recordValues($category), }; } + /** + * The region names the project's maps use, each once, in name order. + * + * @return string[] + */ + private function mapRegions(): array + { + $regions = []; + foreach ($this->workspace->maps as $map) { + $region = $map->getMapField('region'); + if (is_string($region) && trim($region) !== '') { + $regions[trim($region)] = true; + } + } + $regions = array_keys($regions); + natcasesort($regions); + + return array_values($regions); + } + /** * Returns the ids of the current cinematic's cast members of the given * kinds, staged actors declared in the data file and actors staged by a diff --git a/src/Editor.php b/src/Editor.php index b2f71c4..1f5a1f9 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -14296,7 +14296,7 @@ private function openInspectorReferenceDialog(array $field): bool $category = (string) ($field['reference'] ?? ''); $target = (string) ($field['target'] ?? ''); - if ($category === '' || ! in_array($target, ['map-data', 'map-kind', 'map-encounters', 'map-bgm-variants'], true)) { + if ($category === '' || ! in_array($target, ['map', 'map-data', 'map-kind', 'map-encounters', 'map-bgm-variants'], true)) { return false; } diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index 260a7e8..d788fe9 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -80,10 +80,12 @@ public function getMapFields(ProjectMap $map): array 'target' => 'map', 'field' => 'name', ], + // Chosen from the region names the project's maps use, never typed. [ 'label' => 'Region', 'value' => $map->getRegion(), - 'control' => new InputControl(InputControlType::TEXT, $map->getRegion()), + 'selectedValue' => $map->getRegion(), + 'reference' => 'map_regions', 'target' => 'map', 'field' => 'region', ], diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index 9b3d31a..6d4eb23 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -148,6 +148,30 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a ->and($inspector['revision'])->toBe($session->readMap('test-map')['revision']); }); +it('chooses a map\'s region from the names the project\'s maps use, never by typing, and leaves the map where it is', function () { + $root = makeTemporaryProject(); + $maps = $root . '/assets/Maps'; + $data = $maps . '/test-map/test-map.data.php'; + file_put_contents($data, str_replace("'region' => ''", "'region' => 'Happyville'", (string) file_get_contents($data))); + mkdir($maps . '/somewheretown/inn', 0o777, true); + foreach (['data', 'event', 'map'] as $kind) { + $source = (string) file_get_contents("{$maps}/test-map/test-map.{$kind}.php"); + file_put_contents("{$maps}/somewheretown/inn/inn.{$kind}.php", + $kind === 'data' ? str_replace("'region' => 'Happyville'", "'region' => 'Somewheretown'", $source) : $source); + } + $session = EditorSession::open($root); + $region = inspectorRow($session->readInspector('test-map'), 'Region'); + + expect($region)->toMatchArray(['kind' => 'reference', 'reference' => 'map_regions', 'value' => 'Happyville']) + ->and(array_column($session->listReferences('test-map', 'map_regions'), 'value'))->toBe(['Happyville', 'Somewheretown']); + + $session->applyInspector('test-map', $session->readInspector('test-map')['revision'], $region['key'], 'Somewheretown'); + expect(inspectorRow($session->readInspector('test-map'), 'Region')['value'])->toBe('Somewheretown') + ->and(array_column($session->describeMaps(), 'id'))->toContain('test-map', 'somewheretown/inn') + ->and($session->undo()['maps'])->toBe(['test-map']) + ->and(inspectorRow($session->readInspector('test-map'), 'Region')['value'])->toBe('Happyville'); +}); + it('applies an inspector edit as one undo step and refuses a stale or read-only row', function () { $session = EditorSession::open(makeTemporaryProject()); $inspector = $session->readInspector('test-map'); From 00bbf3da340e44016b00a76921584fffaea803ec Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 08:39:23 +0200 Subject: [PATCH 140/344] feat(database): save plain data files by editing their own source A data file whose returned value is an array literal of plain data is saved through the shared source writer: only changed values are edited where they sit, so comments, nowdocs and layout inside the data survive. Such a file with interior comments is no longer read-only; Last Legend's animations.php and troops.php become editable. A change the source cannot express is refused rather than flattened, and a file changed on disk since it was read is refused rather than overwritten. Files that hold objects keep their constructor-argument edits or regeneration. --- docs/manual.md | 12 +++-- src/Database/PhpDataFile.php | 73 +++++++++++++++++++++++++------ tests/Unit/RecordDatabaseTest.php | 43 +++++++++++++++--- 3 files changed, 104 insertions(+), 24 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 810aa3e..a4dcca9 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1000,8 +1000,12 @@ resolved-stat preview: the game does not resolve them as layered stats. They Whether a category can be written is *detected*, not assumed. On load the editor evaluates the authored file and asks two questions: can every value be -written back out losslessly, and would a rewrite drop a comment? A category is -editable only when both answers are safe. Anything else is browsable, and the +written back out losslessly, and would a save drop a comment? A file of plain +data (an array literal of values, with no objects) is saved by editing its own +source, so comments, nowdocs and layout inside the data stay as written, a +change the source cannot express is refused rather than flattened, and a file +changed on disk since it was read is refused rather than overwritten. A category +is editable only when both answers are safe. Anything else is browsable, and the status line says exactly why. | Category | Backing file | Status | @@ -1034,8 +1038,8 @@ status line says exactly why. | Terms | `config.php` (`vocab`, `messages`) | Literal terms editable; comments and unrelated expressions preserved | Why a category can still turn out read-only: a file the editor cannot -evaluate, a value it could not write back out, or a comment sitting inside -the returned data are each a reason, and the status line names it. You can +evaluate, a value it could not write back out, or a comment inside returned +data that holds objects or is not an array literal are each a reason, and the status line names it. You can still browse everything: an enemy shows its level, every stat, its sprite, its battle rewards, and its element affinities. diff --git a/src/Database/PhpDataFile.php b/src/Database/PhpDataFile.php index e4ea88a..8032a48 100644 --- a/src/Database/PhpDataFile.php +++ b/src/Database/PhpDataFile.php @@ -4,6 +4,9 @@ namespace Ichiloto\Editor\Database; +use Ichiloto\Editor\Cutscenes\Source\ArraySourceWriter; +use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; +use Ichiloto\Editor\Cutscenes\Source\SourceUnreadable; use Ichiloto\Editor\IO\AtomicFile; use Ichiloto\Editor\ProjectDirectoryContext; use RuntimeException; @@ -12,18 +15,21 @@ /** * One authored PHP data file, loaded with its source header preserved. * - * The editor's contract with an author's file is deliberately narrow: it - * regenerates **only the returned expression**. Everything from `path, $reason)); } + $document = $this->source === null ? null : self::parseArraySource($this->source); + + // Plain data is edited in its own source; objects keep the regeneration + // (or the constructor-argument edits) their files were written for. + if ($document !== null && is_array($this->payload) && is_array($payload) + && ! self::holdsObject($this->payload) && ! self::holdsObject($payload)) { + if (@file_get_contents($this->path) !== $this->source) { + throw new RuntimeException(sprintf( + 'Refusing to overwrite %s: it changed outside the editor since it was read. Reload it first.', + $this->path, + )); + } + $contents = ArraySourceWriter::rewrite($document, $this->payload, $payload)->source; + } else { + $contents = $this->header . 'return ' . PhpValueExporter::export($payload) . ";\n"; + } + $directory = dirname($this->path); if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { throw new RuntimeException("Unable to create {$directory}."); } - AtomicFile::write( - $this->path, - $this->header . 'return ' . PhpValueExporter::export($payload) . ";\n", - ); + AtomicFile::write($this->path, $contents); + // The file now reads as this payload, so the next save edits from here. + $this->payload = $payload; + $this->source = $contents; } /** diff --git a/tests/Unit/RecordDatabaseTest.php b/tests/Unit/RecordDatabaseTest.php index 81360cf..62a04a5 100644 --- a/tests/Unit/RecordDatabaseTest.php +++ b/tests/Unit/RecordDatabaseTest.php @@ -24,7 +24,7 @@ expect(PhpValueExporter::findUnexportableClass(['bad' => $carriesState]))->toBe('stdClass'); }); -it('keeps a data file header verbatim and regenerates only the returned value', function (): void { +it('keeps a data file header verbatim and edits only the returned value', function (): void { $root = makeTemporaryProject(); $path = $root . '/assets/Events/dresser-note.php'; @@ -36,7 +36,6 @@ $contents = (string) file_get_contents($path); expect($contents)->toContain('// A small demo cutscene: reading the note on the dresser.'); - expect($contents)->toContain("return [\n [\n 'type' => 'text',"); expect(require $path)->toBe([['type' => 'text', 'text' => 'Rewritten.']]); removeDirectoryRecursively($root); @@ -123,15 +122,47 @@ } }); -it('refuses to rewrite a file whose data carries comments', function (): void { +it('edits a data file whose data carries comments in its own source, keeping every comment', function (): void { $root = makeTemporaryProject(); $path = $root . '/assets/Data/states.php'; - file_put_contents($path, " 'poison'],\n];\n"); + $source = " 'poison', 'turns' => 3], // and me\n /* and this */ ['id' => 'sleep'],\n];\n"; + file_put_contents($path, $source); $file = PhpDataFile::load($path); + expect($file->isEditable())->toBeTrue(); + + $file->save([['id' => 'poison', 'turns' => 4], ['id' => 'sleep'], ['id' => 'burn']]); + $written = (string) file_get_contents($path); + expect($written)->toContain('// keep me', '// and me', '/* and this */', "'turns' => 4") + ->and(require $path)->toBe([['id' => 'poison', 'turns' => 4], ['id' => 'sleep'], ['id' => 'burn']]); + + // A second save edits from what the first wrote. + $file->save([['id' => 'poison', 'turns' => 4], ['id' => 'sleep']]); + expect(require $path)->toBe([['id' => 'poison', 'turns' => 4], ['id' => 'sleep']]) + ->and((string) file_get_contents($path))->toContain('// keep me', '// and me', '/* and this */'); + + // Another hand changed the file since it was read: the save refuses rather than overwrite it. + file_put_contents($path, (string) file_get_contents($path) . "// outside edit\n"); + expect(fn() => $file->save([['id' => 'sleep']]))->toThrow(RuntimeException::class, 'changed outside the editor') + ->and((string) file_get_contents($path))->toEndWith("// outside edit\n"); + + removeDirectoryRecursively($root); +}); + +it('still refuses to regenerate commented data that holds objects or is not an array literal', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/states.php'; + + foreach ([ + " \\" . Color::class . "::RED],\n];\n", + " 'poison']],\n);\n", + ] as $source) { + file_put_contents($path, $source); + $file = PhpDataFile::load($path); - expect($file->isEditable())->toBeFalse(); - expect($file->readOnlyReason)->toContain('comments inside its data'); + expect($file->isEditable())->toBeFalse() + ->and($file->readOnlyReason)->toContain('comments inside its data'); + } removeDirectoryRecursively($root); }); From 204bbbf2e1d6777d6709c9e784622befe52c9d60 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 08:53:42 +0200 Subject: [PATCH 141/344] feat(session): read a troop's graphical formation for arranging troops.formation returns the battle canvas, the project's arenas with the one previewed and its background, the party's slots with the starting party's names, and each member's enemy and battle placement. The arena is a preview only and writes nothing. A troop member's graphicalPlacement is now a Battle Placement row, one x, y, width, height value, so moving a member is one edit and one undo step; the terminal position is separate and untouched. --- src/Database/ProjectRecordDatabase.php | 20 +++++ src/Database/RecordFieldCodec.php | 7 ++ src/Database/RecordSchemaCatalog.php | 3 + src/Session/EditorSession.php | 76 +++++++++++++++++ src/Session/SessionHost.php | 2 + .../Session/TroopFormationSessionTest.php | 85 +++++++++++++++++++ 6 files changed, 193 insertions(+) create mode 100644 tests/Unit/Session/TroopFormationSessionTest.php diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 25ebbb6..6caed10 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -2852,6 +2852,23 @@ private static function coerce(RecordField $field, string $rawValue): mixed return [intval($parts[0]), intval($parts[1])]; } + if ($field->codec === RecordFieldCodec::RECT) { + if ($trimmed === '' && $field->removeWhenEmpty) { + return null; + } + + $parts = array_map(trim(...), explode(',', $trimmed)); + + if (count($parts) !== 4 || array_filter($parts, static fn(string $part): bool => ! is_numeric($part)) !== []) { + throw new \InvalidArgumentException(sprintf('%s must be four numbers: x, y, width and height.', $field->label)); + } + + // Whole numbers stay integers, as an author writes them. + $numbers = array_map(static fn(string $part): int|float => floor((float) $part) === (float) $part ? (int) $part : (float) $part, $parts); + + return array_combine(['x', 'y', 'width', 'height'], $numbers); + } + if ($field->codec === RecordFieldCodec::NORMALIZED_POINT) { if ($trimmed === '' && $field->removeWhenEmpty) { return null; @@ -2983,6 +3000,9 @@ private static function displayValue(RecordField $field, mixed $value): string RecordFieldCodec::POINT => is_array($value) ? implode(', ', array_map(strval(...), array_values($value))) : ProjectRecord::stringify($value), + RecordFieldCodec::RECT => is_array($value) + ? implode(', ', array_map(static fn(string $key): string => ProjectRecord::stringify($value[$key] ?? ''), ['x', 'y', 'width', 'height'])) + : ProjectRecord::stringify($value), RecordFieldCodec::NORMALIZED_POINT => is_array($value) ? sprintf('%s, %s', ProjectRecord::stringify($value['x'] ?? ''), ProjectRecord::stringify($value['y'] ?? '')) : ProjectRecord::stringify($value), diff --git a/src/Database/RecordFieldCodec.php b/src/Database/RecordFieldCodec.php index 63cc80a..e45f17b 100644 --- a/src/Database/RecordFieldCodec.php +++ b/src/Database/RecordFieldCodec.php @@ -56,4 +56,11 @@ enum RecordFieldCodec: string * from 0 to 1, shown as `x, y`: an image's pivot. Both or neither. */ case NORMALIZED_POINT = 'normalized_point'; + + /** + * A rectangle stored as `['x' => x, 'y' => y, 'width' => w, 'height' => h]`, + * shown as `x, y, width, height`: a battler's graphical placement. Whole + * or not at all, so one edit moves it as one step. + */ + case RECT = 'rect'; } diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 46546de..62c9ee8 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -198,6 +198,9 @@ private static function troops(): RecordSchema RecordField::reference('enemy', 'Enemy', 'enemies'), new RecordField('position.0', 'X', InputControlType::INTEGER), new RecordField('position.1', 'Y', InputControlType::INTEGER), + // The graphical battle's own placement: the point its feet stand on + // and its contain limits, never the terminal position above. + new RecordField('graphicalPlacement', 'Battle Placement', codec: RecordFieldCodec::RECT, removeWhenEmpty: true), ], blank: ['enemy' => 'Regular Bat', 'position' => [15, 7]], ), diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 86dbfbf..c8b6ceb 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -48,6 +48,8 @@ use Ichiloto\Editor\Validation\MapValidator; use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; use Ichiloto\Engine\Rendering\Presentation\PresentationWorld; +use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasImage; use Closure; use InvalidArgumentException; use RuntimeException; @@ -1013,6 +1015,80 @@ public function listReferences(string $mapId, string $category): array ], array_values($catalog->valuesFor($category))); } + /** + * A troop's graphical formation as an arranger draws it: the battle + * canvas, the project's arenas with the one previewed and its background, + * the party's slots with the starting party's names, and each member's + * enemy and placement (null where the troop has none yet). The arena is a + * preview only, as in RPG Maker's Troops tab: a troop is placed once for + * every arena, and choosing one writes nothing. + * + * @return array{assetRoot: string, canvas: array{width: int, height: int}, arenas: list, arena: ?string, background: ?array, party: list, members: list}>} + * @throws SessionRefusal When the troop is unknown or the project has no graphical battle. + */ + public function readTroopFormation(int $index, ?string $arena = null): array + { + $record = $this->requireRecordDatabase('troops')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('troops has no record %d.', $index)); + $assetRoot = $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; + try { + $catalog = BattlePresentationCatalog::load($assetRoot); + } catch (\Throwable $error) { + throw new SessionRefusal('The battle presentation cannot be read: ' . $error->getMessage(), previous: $error); + } + $layout = $catalog?->ui ?? throw new SessionRefusal('This project has no graphical battle layout to arrange troops on.'); + $arenas = []; + foreach ($catalog->arenas as $id => $definition) { + $arenas[] = ['id' => (string) $id, 'name' => $definition->name]; + } + $arena ??= $catalog->defaultArena ?? array_key_first($catalog->arenas); + if ($arena !== null && ! isset($catalog->arenas[$arena])) { + throw new SessionRefusal(sprintf('There is no battle arena %s.', $arena)); + } + $party = []; + $starting = $this->workspace->systemDatabase->getField('startingParty'); + $names = []; + foreach ($this->workspace->actorDatabase->getActors() as $actor) { + $names[$actor->getDefinitionId()] = $actor->getName(); + } + foreach ($layout->partySlots as $slotIndex => $slot) { + $member = is_array($starting) ? ($starting[$slotIndex] ?? null) : null; + $party[] = ['name' => is_string($member) ? ($names[$member] ?? $member) : '', 'x' => $slot->x, 'y' => $slot->y]; + } + $members = []; + foreach ($record->getSubList('enemies') as $entry) { + $placement = is_array($entry) ? ($entry['graphicalPlacement'] ?? null) : null; + $members[] = ['enemy' => is_array($entry) ? (string) ($entry['enemy'] ?? '') : '', 'placement' => is_array($placement) ? $placement : null]; + } + + return [ + 'assetRoot' => $assetRoot, + 'canvas' => ['width' => $layout->width, 'height' => $layout->height], + 'arenas' => $arenas, + 'arena' => $arena, + 'background' => $arena === null ? null : self::describeCanvasImage($catalog->arenas[$arena]->background), + 'party' => $party, + 'members' => $members, + ]; + } + + /** @return array An image's asset, where it is drawn and the part of the asset it shows. */ + private static function describeCanvasImage(CanvasImage $image): array + { + $destination = $image->destination; + + return array_filter([ + 'asset' => $image->asset, + 'x' => $destination->x, 'y' => $destination->y, 'width' => $destination->width, 'height' => $destination->height, + 'source' => $image->sourceRect === null ? null : [ + 'x' => $image->sourceRect->x, 'y' => $image->sourceRect->y, + 'width' => $image->sourceRect->width, 'height' => $image->sourceRect->height, + ], + 'flipX' => $image->flipX ?: null, + 'flipY' => $image->flipY ?: null, + ], static fn(mixed $value): bool => $value !== null); + } + /** * A database category's records, as its list shows them, whether they * can be edited and have unsaved changes, and which record operations diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 57a9a2b..1c9c723 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -262,6 +262,8 @@ private function dispatch(string $method, array $params): array self::requireInt($params, 'y'), ), 'database.records' => $session->listDatabaseRecords(self::requireString($params, 'category')), + 'troops.formation' => $session->readTroopFormation(self::requireInt($params, 'index'), + is_string($params['arena'] ?? null) ? $params['arena'] : null), 'database.record' => $session->readDatabaseRecord( self::requireString($params, 'category'), self::requireInt($params, 'index'), diff --git a/tests/Unit/Session/TroopFormationSessionTest.php b/tests/Unit/Session/TroopFormationSessionTest.php new file mode 100644 index 0000000..71e9850 --- /dev/null +++ b/tests/Unit/Session/TroopFormationSessionTest.php @@ -0,0 +1,85 @@ + new BattleArenaDefinition($name, + new CanvasImage('arena', 'Graphics/Battlebacks/' . $file, new CanvasRectangle(0, 0, 1350, 720))); +return new BattlePresentationCatalog( + arenas: ['arena.road' => $arena('Road', 'Road.png'), 'arena.cave' => $arena('Cave', 'Cave.png')], + actors: [], + enemies: [], + ui: new BattleCanvasLayout(1350, 720, partySlots: [new BattlerSlot(1050, 420, 180, 260), new BattlerSlot(1180, 560, 180, 260)]), + defaultArena: 'arena.cave', +); +PHP); + file_put_contents($root . '/assets/Data/troops.php', <<<'PHP' + 'Pair', + 'enemies' => [ + // The leader, placed for the graphical battle. + ['enemy' => 'Regular Bat', 'position' => [15, 7], 'graphicalPlacement' => ['x' => 260, 'y' => 300, 'width' => 275, 'height' => 190]], + ['enemy' => 'Regular Bat', 'position' => [15, 20]], + ], + ], +]; +PHP); + + return $root; +} + +it('reads a troop formation over a previewed arena, the party in its slots and each member placed or not yet placed', function () { + $session = EditorSession::open(troopFormationProject()); + $formation = $session->readTroopFormation(0); + + expect($formation['canvas'])->toBe(['width' => 1350, 'height' => 720]) + ->and($formation['arenas'])->toBe([['id' => 'arena.road', 'name' => 'Road'], ['id' => 'arena.cave', 'name' => 'Cave']]) + ->and($formation['arena'])->toBe('arena.cave') + ->and($formation['background'])->toMatchArray(['asset' => 'Graphics/Battlebacks/Cave.png', 'width' => 1350.0, 'height' => 720.0]) + ->and(array_map(static fn(array $slot): array => [$slot['x'], $slot['y']], $formation['party']))->toBe([[1050.0, 420.0], [1180.0, 560.0]]) + ->and($formation['members'])->toBe([ + ['enemy' => 'Regular Bat', 'placement' => ['x' => 260, 'y' => 300, 'width' => 275, 'height' => 190]], + ['enemy' => 'Regular Bat', 'placement' => null], + ]) + // The arena is only previewed: choosing another writes nothing. + ->and($session->readTroopFormation(0, 'arena.road')['background']['asset'])->toBe('Graphics/Battlebacks/Road.png') + ->and($session->listUnsavedChanges())->toBe([]) + ->and(fn() => $session->readTroopFormation(0, 'arena.moon'))->toThrow(SessionRefusal::class, 'no battle arena arena.moon'); +}); + +it('moves a member by its battle placement as one undo step, keeping the troop file\'s comments and the terminal position', function () { + $root = troopFormationProject(); + $session = EditorSession::open($root); + $read = $session->readDatabaseRecord('troops', 0); + $placement = array_find($read['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'member0GraphicalPlacement'); + + expect($placement)->toMatchArray(['kind' => 'text', 'value' => '260, 300, 275, 190']); + $session->applyDatabaseRecord('troops', 0, $placement['key'], '410, 330, 275, 190'); + expect($session->readTroopFormation(0)['members'][0]['placement'])->toBe(['x' => 410, 'y' => 330, 'width' => 275, 'height' => 190]) + ->and(fn() => $session->applyDatabaseRecord('troops', 0, $placement['key'], '410, 330'))->toThrow(SessionRefusal::class, 'four numbers'); + + $session->saveDatabase('troops'); + $written = (string) file_get_contents($root . '/assets/Data/troops.php'); + expect($written)->toContain('// The leader, placed for the graphical battle.', "'position' => [15, 7]", "'x' => 410", "'y' => 330") + ->and($session->undo()['label'] ?? null)->not->toBeNull() + ->and($session->readTroopFormation(0)['members'][0]['placement']['x'])->toBe(260); +}); From a152ac564454cbf2fbf49d137fee01e47ce62649 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 09:13:51 +0200 Subject: [PATCH 142/344] refactor(database): edit animations through the shared record schema Animations become a schema-driven category over assets/Data/animations.php, edited through the shared record database in the terminal editor and the GUI session alike. A record names its caster and target effect timelines from the effects picker, and its battle roles as a list picked a member at a time; a role another animation holds is refused, naming it, and the picker labels say who holds each role. A new animation takes the next numeric id. Saving rewrites only changed values, keeping the file's comments, and an older record's own frames, cues and unknown fields are kept exactly as written. Removed: the bespoke animation screen and ProjectAnimationDatabase. The terminal frame-grid painter, its brush rows, the frame and cue panes' animation content, frame sound and flash rows, and the Shift+P frame preview are gone; an older record's frames and cues are shown read-only and still played by the Engine's importer, and new animation is authored as effect timelines. The database's frames and preview panes no longer take focus. Shared fixes: a schema record edit the record refused, or that left it unchanged, no longer records an empty undo step; the session passes the multi-pick flag so an interface toggles list members; back-navigation restores every schema category's selection. --- docs/manual.md | 35 +- src/AnimationPreviewRenderer.php | 100 -- src/Database/ProjectRecordDatabase.php | 82 +- src/Database/RecordField.php | 4 + src/Database/RecordSchemaCatalog.php | 46 + src/Database/ReferenceCatalog.php | 39 +- src/Editor.php | 858 +----------------- src/ProjectAnimationDatabase.php | 521 ----------- src/ProjectWorkspace.php | 7 +- src/Session/EditorSession.php | 6 +- .../AnimationReferenceValidator.php | 20 +- src/Validation/EffectValidator.php | 10 +- tests/Unit/AnimationDatabaseRoundTripTest.php | 38 +- tests/Unit/AnimationRolesTest.php | 76 +- tests/Unit/EditorSafetyTest.php | 27 +- tests/Unit/ListNavigationTest.php | 22 - tests/Unit/ReferencePickerTest.php | 34 - tests/Unit/Session/DatabaseSessionTest.php | 10 + 18 files changed, 295 insertions(+), 1640 deletions(-) delete mode 100644 src/AnimationPreviewRenderer.php delete mode 100644 src/ProjectAnimationDatabase.php diff --git a/docs/manual.md b/docs/manual.md index a4dcca9..6c8a606 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -110,8 +110,7 @@ prefer. Arrow keys always act inside the focused panel. Every selection list wraps: `Down` on the last row selects the first, and `Up` on the first row selects the last. This holds for the asset list, the -Database categories, entries, settings rows and animation frames, dialog and -picker lists, the command palette, option-value cycling, and the Cutscenes +Database categories, entries and settings rows, dialog and picker lists, the command palette, option-value cycling, and the Cutscenes lists. Map cursors, scrolling panes and text editing keep ordinary bounds - wrapping is for choosing from a list, not for walking a canvas. @@ -1129,9 +1128,17 @@ skill effects use, with `$target` bound to the afflicted battler. ### Animations -Below an animation's frame and cue rows are its **Roles**: which battle -actions play it when they name no animation of their own. There is one row -per role the Engine supports, switched on or off with `Left` / `Right`: +An animation names what plays on the caster and the target of a battle +action: its **Caster Effect** and **Target Effect**, each one of the +project's effect timelines (authored on the Cutscenes screen, where +terminal glyph frames are timeline tracks), picked from a list. `Id` is +read-only; a new animation takes the next number after the largest. Like +every Database category, edits are undoable and only the values you change +are rewritten in `assets/Data/animations.php`, so its comments stay. + +**Roles** say which battle actions play the animation when they name no +animation of their own. Enter opens a picker of every role the Engine +supports; each pick adds or removes one role: - `attack-sword`, `attack-staff`, `attack-flail` and the other weapon types: a party member's basic attack with that weapon equipped; @@ -1140,13 +1147,17 @@ per role the Engine supports, switched on or off with `Left` / `Right`: - `skill` and `restorative`: skills and restorative magic with no animation of their own. -A role plays only when exactly one animation holds it, so a role row held by -another animation names it, and switching it on here is refused until it is -switched off there. Changes are undoable and saved with the animation; every -other field of the entry is kept as written. Validation reports a role the -Engine does not support (the runtime then skips the whole animation), a role -held twice, and a role the project's battles reach with no animation: the -neutral attack, the unarmed attack, and each weapon type its weapons use. +A role plays only when exactly one animation holds it, so the picker names +the animation that holds each role, and taking one held elsewhere is refused +until it is removed there. Validation reports a role the Engine does not +support (the runtime then skips the whole animation), a role held twice, and +a role the project's battles reach with no animation: the neutral attack, +the unarmed attack, and each weapon type its weapons use. + +An older animation that carries its own frames and cues keeps them exactly as +written, shown read-only as **Legacy Frames** and **Legacy Cues** beside its +**Legacy Position**; the Engine still plays them through its importer. New +animation is authored as effect timelines. ### Troops diff --git a/src/AnimationPreviewRenderer.php b/src/AnimationPreviewRenderer.php deleted file mode 100644 index f53bd40..0000000 --- a/src/AnimationPreviewRenderer.php +++ /dev/null @@ -1,100 +0,0 @@ -, cue: AnimationCue|null} - */ - public static function build(Animation $animation, int $frameIndex, int $width, int $height): array - { - $origin = self::resolveOrigin($animation->position, $width, $height); - $cells = []; - - foreach (self::buildTargetCells($origin['x'], $origin['y'], $animation->position) as $cell) { - $cells[] = $cell; - } - - foreach ($animation->getFrame($frameIndex)->getCells() as $cell) { - $cells[] = [ - 'x' => $origin['x'] + $cell->x, - 'y' => $origin['y'] + $cell->y, - 'symbol' => $cell->symbol, - 'color' => $cell->color, - ]; - } - - return [ - 'cells' => array_values(array_filter( - $cells, - static fn(array $cell): bool => $cell['x'] >= 0 - && $cell['x'] < $width - && $cell['y'] >= 0 - && $cell['y'] < $height - )), - 'cue' => $animation->getCue($frameIndex), - ]; - } - - /** - * Returns the anchor point for the preview target. - * - * @param AnimationTargetPosition $position The animation position. - * @param int $width The preview width. - * @param int $height The preview height. - * @return array{x: int, y: int} - */ - public static function resolveOrigin(AnimationTargetPosition $position, int $width, int $height): array - { - $x = intdiv(max(1, $width), 2); - $y = intdiv(max(1, $height), 2); - - return match ($position) { - AnimationTargetPosition::HEAD => ['x' => $x, 'y' => max(1, $y - 2)], - AnimationTargetPosition::FEET => ['x' => $x, 'y' => min(max(1, $height - 2), $y + 2)], - AnimationTargetPosition::SCREEN, - AnimationTargetPosition::CENTER => ['x' => $x, 'y' => $y], - }; - } - - /** - * Returns the preview target silhouette. - * - * @param int $x The origin x coordinate. - * @param int $y The origin y coordinate. - * @param AnimationTargetPosition $position The target anchor. - * @return array - */ - private static function buildTargetCells(int $x, int $y, AnimationTargetPosition $position): array - { - if ($position === AnimationTargetPosition::SCREEN) { - return []; - } - - return [ - ['x' => $x, 'y' => $y - 1, 'symbol' => 'o', 'color' => null], - ['x' => $x - 1, 'y' => $y, 'symbol' => '/', 'color' => null], - ['x' => $x, 'y' => $y, 'symbol' => '|', 'color' => null], - ['x' => $x + 1, 'y' => $y, 'symbol' => '\\', 'color' => null], - ['x' => $x - 1, 'y' => $y + 1, 'symbol' => '/', 'color' => null], - ['x' => $x + 1, 'y' => $y + 1, 'symbol' => '\\', 'color' => null], - ]; - } -} diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 6caed10..830640d 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -295,22 +295,26 @@ public function getRecordByIndex(int $index): ?ProjectRecord */ public function getEntryLabels(): array { - return array_map(function (ProjectRecord $record): string { - if ($this->schema->labelFor !== null) { - // A record whose name is made of its parts -- the scope a - // weight vector applies at, what an exclusion excludes -- - // rather than stored under a key of its own. - return strval(($this->schema->labelFor)((array) $record->toArray())); - } + return array_map($this->getEntryLabel(...), $this->getRecords()); + } + + /** The entry-list label of one record. */ + public function getEntryLabel(ProjectRecord $record): string + { + if ($this->schema->labelFor !== null) { + // A record whose name is made of its parts -- the scope a + // weight vector applies at, what an exclusion excludes -- + // rather than stored under a key of its own. + return strval(($this->schema->labelFor)((array) $record->toArray())); + } - $label = $record->getDisplayValue($this->schema->labelKey); + $label = $record->getDisplayValue($this->schema->labelKey); - if ($label === '' && $this->schema->identityKey !== null) { - $label = $record->getDisplayValue($this->schema->identityKey); - } + if ($label === '' && $this->schema->identityKey !== null) { + $label = $record->getDisplayValue($this->schema->identityKey); + } - return $label === '' ? ($record->recordId ?: '(unnamed)') : $label; - }, $this->getRecords()); + return $label === '' ? ($record->recordId ?: '(unnamed)') : $label; } /** @@ -682,13 +686,44 @@ public function setField(int $index, string $fieldId, string $rawValue): void return; } - $record->set($field->key, self::coerce($field, $rawValue)); + $value = self::coerce($field, $rawValue); + if ($field->uniqueAcrossRecords) { + $this->assertMembersUnclaimed($record, $field, is_array($value) ? $value : []); + } + $record->set($field->key, $value); $this->touchState(); return; } } + /** + * Refuses a list member another record of the category already holds, + * naming that record, before anything changes. + * + * @param list $members The list this record would hold. + * @throws \InvalidArgumentException When another record holds one of them. + */ + private function assertMembersUnclaimed(ProjectRecord $record, RecordField $field, array $members): void + { + $held = $record->get($field->key); + $added = array_diff($members, is_array($held) ? $held : []); + + foreach ($this->getRecords() as $other) { + $theirs = $other === $record ? null : $other->get($field->key); + $taken = is_array($theirs) ? array_values(array_intersect($added, $theirs)) : []; + + if ($taken !== []) { + throw new \InvalidArgumentException(sprintf( + '%s already has %s %s; take it off there first.', + $this->getEntryLabel($other), + mb_strtolower($field->label), + implode(', ', array_map(strval(...), $taken)), + )); + } + } + } + /** * Returns whether this category can take a new record at all. Terms are * the leaves of an existing config tree and listings are informational: @@ -759,7 +794,7 @@ public function addRecord(): ?int } if (is_array($payload) && $identityKey !== null && array_key_exists($identityKey, $payload)) { - $payload[$identityKey] = $this->makeUniqueIdentity(strval($payload[$identityKey])); + $payload[$identityKey] = $this->makeUniqueIdentity($payload[$identityKey]); $recordId = strval($payload[$identityKey]); } @@ -973,7 +1008,7 @@ public function duplicateRecord(int $index): ?int $recordId = ''; if ($identityKey !== null && array_key_exists($identityKey, $payload)) { - $payload[$identityKey] = $this->makeUniqueIdentity(strval($payload[$identityKey])); + $payload[$identityKey] = $this->makeUniqueIdentity($payload[$identityKey]); $recordId = strval($payload[$identityKey]); } @@ -3929,9 +3964,22 @@ private function makeUniqueName(string $preferred): string return $candidate; } - private function makeUniqueIdentity(string $preferred): string + private function makeUniqueIdentity(mixed $preferred): int|string { $identityKey = $this->schema->identityKey ?? 'id'; + + if (is_int($preferred)) { + // A numeric identity (an animation's id) is the next number after + // the largest any record holds, so it never reuses one. + $numbers = array_filter( + array_map(static fn(ProjectRecord $record): mixed => $record->get($identityKey), $this->getRecords()), + is_int(...), + ); + + return in_array($preferred, $numbers, true) ? max($numbers) + 1 : $preferred; + } + + $preferred = strval($preferred); $existing = array_map( static fn(ProjectRecord $record): string => $record->getDisplayValue($identityKey), $this->getRecords(), diff --git a/src/Database/RecordField.php b/src/Database/RecordField.php index da49499..0704790 100644 --- a/src/Database/RecordField.php +++ b/src/Database/RecordField.php @@ -40,6 +40,9 @@ * types this surface may author, when the runtime restricts it to fewer * than the writer's full vocabulary (a transactional boundary rejects * quest acceptance). Null offers everything the writer applies. + * @param bool $uniqueAcrossRecords For a list field, whether a member may + * be held by one record of the category at most (an animation role plays + * one animation), so taking one another record holds is refused. */ public function __construct( public string $key, @@ -56,6 +59,7 @@ public function __construct( public ?string $displayDefault = null, public ?string $blankLabel = null, public ?array $writeTypes = null, + public bool $uniqueAcrossRecords = false, ) { } diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 62c9ee8..9745d24 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -20,6 +20,7 @@ use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Editor\PermanentGrowthCatalog; use Ichiloto\Engine\Entities\States\StateDisposition; +use Ichiloto\Engine\Animations\AnimationTargetPosition; use Ichiloto\Editor\Events\ProjectScriptCommands; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandDefinition; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandField; @@ -72,6 +73,7 @@ public static function all(): array $schemas = [ self::states(), self::troops(), + self::animations(), self::battleEntryRules(), self::items(), self::weapons(), @@ -156,6 +158,50 @@ private static function states(): RecordSchema * * @return RecordSchema */ + /** + * Animations -- `assets/Data/animations.php`, the list the Engine's + * AnimationLibrary reads. A record names the effect timelines it plays on + * the caster and the target, and the battle roles it plays for; a role + * plays one animation, so taking one another record holds is refused. + * An older record's own frames and cues are kept exactly as written and + * shown read-only beside the position they play at: the Engine still + * plays them through its importer, and new animation is authored as + * effect timelines. + */ + private static function animations(): RecordSchema + { + $fields = [ + new RecordField('id', 'Id', InputControlType::INTEGER, isReadOnly: true), + new RecordField('name', 'Name'), + RecordField::reference('sourceEffect', 'Caster Effect', 'effects', allowsNone: true, noneLabel: '(none)'), + RecordField::reference('targetEffect', 'Target Effect', 'effects', allowsNone: true, noneLabel: '(none)'), + new RecordField('roles', 'Roles', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true, + reference: 'animation_roles', uniqueAcrossRecords: true), + ]; + $legacy = [ + new RecordField('position', 'Legacy Position', + options: array_map(static fn(AnimationTargetPosition $position): string => $position->value, AnimationTargetPosition::cases()), + removeWhenEmpty: true), + new RecordField('maxFrames', 'Legacy Frame Count', InputControlType::INTEGER, isReadOnly: true), + new RecordField('frames', 'Legacy Frames', isReadOnly: true), + new RecordField('cues', 'Legacy Cues', isReadOnly: true), + ]; + + return new RecordSchema( + key: 'animations', + entryNoun: 'animation', + storage: RecordStorage::LIST_FILE, + relativePath: 'assets/Data/animations.php', + fields: $fields, + labelKey: 'name', + identityKey: 'id', + // A numeric identity: a new animation takes the next free number. + blank: ['id' => 1, 'name' => 'New Animation'], + fieldsFor: static fn(array $row): array => array_key_exists('frames', $row) || array_key_exists('cues', $row) + ? [...$fields, ...$legacy] : $fields, + ); + } + private static function troops(): RecordSchema { return new RecordSchema( diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index e9698e0..34092b8 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -74,6 +74,7 @@ final class ReferenceCatalog 'tilesets', 'effects', 'map_regions', + 'animation_roles', ]; /** @@ -171,13 +172,14 @@ public function valuesFor(string $category): array // read live from the collection, a just-created NPC is offered // at once and a deleted one is gone. 'map_npcs' => $this->currentMap?->getNpcs()->ids() ?? [], + // Older references name an animation; current ones store its id. 'animations' => array_map( - static fn(object $animation): string => $animation->name ?? '', - $this->workspace->animationDatabase->getAnimations() + static fn(ProjectRecord $animation): string => strval($animation->get('name')), + $this->animationRecords(), ), 'animation_ids' => array_map( - static fn(object $animation): string => (string) $animation->id, - $this->workspace->animationDatabase->getAnimations() + static fn(ProjectRecord $animation): string => strval($animation->get('id')), + $this->animationRecords(), ), // Cutscenes are folders, so the stable id is the folder name. 'cinematics' => $this->workspace->cutscenes?->ids(CutsceneType::CINEMATIC) ?? [], @@ -197,12 +199,20 @@ public function valuesFor(string $category): array // is; choosing from the names the maps already use keeps one // region spelled one way. 'map_regions' => $this->mapRegions(), + // The battle roles an animation can play, as the Engine resolves them. + 'animation_roles' => \Ichiloto\Engine\Animations\ActionAnimationResolver::getSupportedRoles(), // Effect timelines are folders the Engine lists by stable id. 'effects' => new EffectTimelineLibrary($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets')->findTimelineIds(), default => $this->recordValues($category), }; } + /** @return ProjectRecord[] The project's animations, in file order. */ + private function animationRecords(): array + { + return $this->workspace->getRecordDatabase('animations')?->getRecords() ?? []; + } + /** * The region names the project's maps use, each once, in name order. * @@ -376,12 +386,29 @@ public function labelsFor(string $category): array if ($category === 'animation_ids') { $labels = []; - foreach ($this->workspace->animationDatabase->getAnimations() as $animation) { - $labels[(string) $animation->id] = sprintf('%s (%d)', $animation->name, $animation->id); + foreach ($this->animationRecords() as $animation) { + $labels[strval($animation->get('id'))] = sprintf('%s (%s)', strval($animation->get('name')), strval($animation->get('id'))); } return $labels; } + if ($category === 'animation_roles') { + // A role plays one animation, so the picker says which holds it. + $database = $this->workspace->getRecordDatabase('animations'); + $owners = []; + foreach ($database?->getRecords() ?? [] as $record) { + foreach ((array) $record->get('roles') as $role) { + $owners[(string) $role] ??= $database->getEntryLabel($record); + } + } + $labels = []; + foreach ($this->valuesFor('animation_roles') as $role) { + $labels[$role] = isset($owners[$role]) ? sprintf('%s (on %s)', $role, $owners[$role]) : $role; + } + + return $labels; + } + if ($category === 'elements_or_any') { return ['*' => '* (whichever element it was)']; } diff --git a/src/Editor.php b/src/Editor.php index 1f5a1f9..591b4ec 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -94,8 +94,6 @@ use Ichiloto\Editor\UI\TextFieldKeyResult; use Ichiloto\Editor\Storage\WorkspaceSave; use Ichiloto\Editor\Validation\MapValidator; -use Ichiloto\Engine\Animations\ActionAnimationResolver; -use Ichiloto\Engine\Animations\AnimationTargetPosition; use Ichiloto\Engine\Entities\Enumerations\ItemScopeNumber; use Ichiloto\Engine\Entities\Enumerations\ItemScopeSide; use Ichiloto\Engine\Entities\Enumerations\ItemScopeStatus; @@ -115,9 +113,6 @@ use Ichiloto\Engine\Rendering\Tilesets\TileId; use Ichiloto\Engine\Rendering\Tilesets\Tileset; use RuntimeException; -if (! class_exists(__NAMESPACE__ . chr(92) . 'Animation', false)) { class_alias('Ichiloto' . chr(92) . 'Engine' . chr(92) . 'Animations' . chr(92) . 'Animation', __NAMESPACE__ . chr(92) . 'Animation'); } -if (! class_exists(__NAMESPACE__ . chr(92) . 'AnimationCue', false)) { class_alias('Ichiloto' . chr(92) . 'Engine' . chr(92) . 'Animations' . chr(92) . 'AnimationCue', __NAMESPACE__ . chr(92) . 'AnimationCue'); } -if (! class_exists(__NAMESPACE__ . chr(92) . 'AnimationPlayer', false)) { class_alias('Ichiloto' . chr(92) . 'Engine' . chr(92) . 'Animations' . chr(92) . 'AnimationPlayer', __NAMESPACE__ . chr(92) . 'AnimationPlayer'); } use Throwable; /** @@ -144,11 +139,6 @@ final class Editor * subprocess, so it must never run per frame). */ private const float TERMINAL_SIZE_PROBE_INTERVAL_SECONDS = 0.25; - /** - * Seconds each animation preview frame stays on screen (matches the - * engine AnimationPlayer default cadence). - */ - private const float PREVIEW_SECONDS_PER_FRAME = 0.12; private const string FOCUS_ASSETS = 'assets'; private const string FOCUS_CANVAS = 'canvas'; @@ -168,8 +158,6 @@ final class Editor private const string DATABASE_FOCUS_CATEGORIES = 'database_categories'; private const string DATABASE_FOCUS_LIST = 'database_list'; private const string DATABASE_FOCUS_SETTINGS = 'database_settings'; - private const string DATABASE_FOCUS_FRAMES = 'database_frames'; - private const string DATABASE_FOCUS_PREVIEW = 'database_preview'; private const int CHARACTER_MAP_COLUMNS = 8; /** Rows or columns one wheel tick scrolls the canvas viewport. */ @@ -618,7 +606,6 @@ final class Editor private int $databaseSelectedClassIndex = 0; private int $databaseSelectedSkillIndex = 0; private int $databaseSelectedQuestIndex = 0; - private int $databaseSelectedAnimationIndex = 0; /** * Selected entry index per schema-driven category, keyed by category key. * @@ -629,9 +616,6 @@ final class Editor */ private array $databaseSelectedRecordIndexes = []; private int $databaseSelectedSettingIndex = 0; - private int $databaseSelectedFrameIndex = 1; - private int $databasePreviewCursorX = 0; - private int $databasePreviewCursorY = 0; /** * Legacy views over the database field editor; see the inspector hooks. */ @@ -657,15 +641,6 @@ final class Editor $this->databaseFieldEditor->caret = $value; } } - private string $databaseSelectedPaintSymbol = '*'; - private ?string $databaseSelectedPaintColor = 'white'; - private bool $isDatabasePreviewPlaying = false; - private int $databasePlaybackFrameIndex = 1; - /** - * When the non-blocking animation preview should advance to its next frame. - */ - private float $databasePlaybackNextFrameAt = 0.0; - private ?string $databasePlaybackFlashColor = null; /** * The idle footer message shown once every queued status expires. */ @@ -837,10 +812,7 @@ function (): void { DatabaseScreen::PANE_SETTINGS => fn(array $layout) => $this->createDatabaseSettingsWindow($layout)->render(), DatabaseScreen::PANE_CUE => fn(array $layout) => $this->createDatabaseCueWindow($layout)->render(), DatabaseScreen::PANE_FRAMES => fn(array $layout) => $this->createDatabaseFramesWindow($layout)->render(), - DatabaseScreen::PANE_PREVIEW => function (array $layout): void { - $this->createDatabasePreviewWindow($layout)->render(); - $this->renderDatabasePreview($layout); - }, + DatabaseScreen::PANE_PREVIEW => fn(array $layout) => $this->createDatabasePreviewWindow($layout)->render(), ], $this->renderDatabaseEditCursor(...), fn(): bool => $this->isDatabaseEditing, @@ -948,17 +920,9 @@ private function boot(): void $this->databaseSelectedSkillIndex = 0; $this->databaseSelectedQuestIndex = 0; $this->databaseSelectedSettingIndex = 0; - $this->databaseSelectedFrameIndex = 1; - $this->databasePreviewCursorX = 0; - $this->databasePreviewCursorY = 0; $this->isDatabaseEditing = false; $this->databaseEditBuffer = ''; $this->databaseEditCursorIndex = 0; - $this->databaseSelectedPaintSymbol = '*'; - $this->databaseSelectedPaintColor = 'white'; - $this->isDatabasePreviewPlaying = false; - $this->databasePlaybackFrameIndex = 1; - $this->databasePlaybackFlashColor = null; $this->statusMessage = self::STATUS_IDLE_MESSAGE; $this->statusDetailTitle = ''; $this->statusDetailLines = []; @@ -1027,7 +991,6 @@ private function shutdown(): void private function update(): void { $this->syncTerminalSizeIfNeeded(); - $this->tickDatabaseAnimationPreview(); $this->tickCutscenePreview(); $this->tickStatusExpiry(); } @@ -2876,13 +2839,9 @@ private function openDatabaseWindow(): void $this->databaseSelectedSkillIndex = 0; $this->databaseSelectedQuestIndex = 0; $this->databaseSelectedSettingIndex = 0; - $this->databaseSelectedFrameIndex = 1; $this->isDatabaseEditing = false; $this->databaseEditBuffer = ''; $this->databaseEditCursorIndex = 0; - $this->databasePlaybackFrameIndex = $this->databaseSelectedFrameIndex; - $this->databasePlaybackFlashColor = null; - $this->centerDatabasePreviewCursor(); $this->statusMessage = 'Database open.'; $this->renderDatabaseArea(includeRoot: true); } @@ -2898,8 +2857,6 @@ private function closeDatabaseWindow(): void $this->isDatabaseEditing = false; $this->databaseEditBuffer = ''; $this->databaseEditCursorIndex = 0; - $this->isDatabasePreviewPlaying = false; - $this->databasePlaybackFlashColor = null; $this->statusMessage = 'Database closed.'; $this->requestFullRender(); } @@ -3095,15 +3052,6 @@ private function handleDatabaseInput(string $input, string $normalizedInput): vo return; } - if ($this->databaseFocus === self::DATABASE_FOCUS_PREVIEW && ($input === "\n" || $input === "\r")) { - $this->paintDatabasePreviewSymbol($this->databaseSelectedPaintSymbol); - return; - } - - if ($this->isShiftLetterShortcut($input, 'P')) { - $this->playDatabaseAnimationPreview(); - return; - } if (str_contains($input, "\033[A")) { $this->moveDatabaseSelection(0, -1); @@ -3134,15 +3082,6 @@ private function handleDatabaseInput(string $input, string $normalizedInput): vo $this->moveDatabaseSelection(1, 0); return; } - - if ($this->databaseFocus === self::DATABASE_FOCUS_PREVIEW) { - if ($input === "\177" || $input === "\010") { - $this->paintDatabasePreviewSymbol(' '); - return; - } - - $this->handleDatabaseTypedSymbolInput($input); - } } /** @@ -3157,8 +3096,6 @@ private function cycleDatabaseFocus(int $step): void self::DATABASE_FOCUS_CATEGORIES, self::DATABASE_FOCUS_LIST, self::DATABASE_FOCUS_SETTINGS, - self::DATABASE_FOCUS_FRAMES, - self::DATABASE_FOCUS_PREVIEW, ]; $currentIndex = array_search($this->databaseFocus, $paneOrder, true); $currentIndex = is_int($currentIndex) ? $currentIndex : 0; @@ -3195,16 +3132,6 @@ private function moveDatabaseSelection(int $deltaX, int $deltaY): void if ($this->databaseFocus === self::DATABASE_FOCUS_SETTINGS) { $this->moveDatabaseSettingsSelection($deltaY); - return; - } - - if ($this->databaseFocus === self::DATABASE_FOCUS_FRAMES) { - $this->moveDatabaseFrameSelection($deltaY); - return; - } - - if ($this->databaseFocus === self::DATABASE_FOCUS_PREVIEW) { - $this->moveDatabasePreviewCursor($deltaX, $deltaY); } } @@ -3257,12 +3184,8 @@ private function moveDatabaseListSelection(int $step): void return; } - if ($this->isAnimationsDatabaseSelected()) { - $this->moveDatabaseAnimationSelection($step); - return; - } - $this->moveDatabaseRecordSelection($step); + } /** @@ -3369,39 +3292,6 @@ private function moveDatabaseQuestSelection(int $step): void $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); } - /** - * Moves the selected animation entry. - * - * @param int $step The entry step. - * @return void - */ - private function moveDatabaseAnimationSelection(int $step): void - { - if (! $this->isAnimationsDatabaseSelected()) { - return; - } - - $animations = $this->workspace?->animationDatabase->getAnimations() ?? []; - - if ($animations === []) { - return; - } - - $nextIndex = $this->resolveDatabaseSelectionStep($this->databaseSelectedAnimationIndex, $step); - - if ($nextIndex === $this->databaseSelectedAnimationIndex) { - return; - } - - $this->databaseSelectedAnimationIndex = $nextIndex; - $this->databaseSelectedFrameIndex = 1; - $this->databaseSelectedSettingIndex = 0; - $this->databasePlaybackFrameIndex = 1; - $this->centerDatabasePreviewCursor(); - $this->statusMessage = sprintf('Selected animation %s.', $animations[$nextIndex]->name); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - } - /** * Moves the selected settings field. * @@ -3426,54 +3316,6 @@ private function moveDatabaseSettingsSelection(int $step): void $this->renderDatabasePanes(['settings']); } - /** - * Moves the selected frame index. - * - * @param int $step The frame step. - * @return void - */ - private function moveDatabaseFrameSelection(int $step): void - { - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - return; - } - - $nextIndex = ListNavigation::step($this->databaseSelectedFrameIndex - 1, $step, $animation->maxFrames) + 1; - - if ($nextIndex === $this->databaseSelectedFrameIndex) { - return; - } - - $this->databaseSelectedFrameIndex = $nextIndex; - $this->databasePlaybackFrameIndex = $nextIndex; - $this->statusMessage = sprintf('Frame #%03d selected.', $nextIndex); - $this->renderDatabasePanes(['settings', 'cue', 'frames', 'preview']); - } - - /** - * Moves the animation preview cursor. - * - * @param int $deltaX The horizontal movement amount. - * @param int $deltaY The vertical movement amount. - * @return void - */ - private function moveDatabasePreviewCursor(int $deltaX, int $deltaY): void - { - $previewSize = $this->getDatabasePreviewSize(); - $nextX = max(0, min($previewSize['width'] - 1, $this->databasePreviewCursorX + $deltaX)); - $nextY = max(0, min($previewSize['height'] - 1, $this->databasePreviewCursorY + $deltaY)); - - if ($nextX === $this->databasePreviewCursorX && $nextY === $this->databasePreviewCursorY) { - return; - } - - $this->databasePreviewCursorX = $nextX; - $this->databasePreviewCursorY = $nextY; - $this->renderDatabasePanes(['preview']); - } - /** * Cycles focus between editor panes. * @@ -5990,14 +5832,8 @@ private function getDatabaseEntryLabels(): array ); } - if ($this->isAnimationsDatabaseSelected()) { - return array_map( - static fn(Animation $animation): string => $animation->name, - $this->workspace?->animationDatabase->getAnimations() ?? [], - ); - } - return $this->getSelectedRecordDatabase()?->getEntryLabels() ?? []; + } /** @@ -6052,7 +5888,6 @@ private function getSelectedDatabaseEntryIndex(): int $this->isClassesDatabaseSelected() => $this->databaseSelectedClassIndex, $this->isSkillsDatabaseSelected() => $this->databaseSelectedSkillIndex, $this->isQuestsDatabaseSelected() => $this->databaseSelectedQuestIndex, - $this->isAnimationsDatabaseSelected() => $this->databaseSelectedAnimationIndex, default => $this->getSelectedRecordIndex(), }; } @@ -6072,7 +5907,6 @@ private function setSelectedDatabaseEntryIndex(int $index): void $this->isClassesDatabaseSelected() => $this->databaseSelectedClassIndex = $index, $this->isSkillsDatabaseSelected() => $this->databaseSelectedSkillIndex = $index, $this->isQuestsDatabaseSelected() => $this->databaseSelectedQuestIndex = $index, - $this->isAnimationsDatabaseSelected() => $this->databaseSelectedAnimationIndex = $index, default => $this->setSelectedRecordIndex($index), }; } @@ -6307,11 +6141,7 @@ private function confirmDatabaseEntryDeletion(): void fn(): ?object => $workspace->questDatabase->removeQuest($index), static fn(object $entry) => $workspace->questDatabase->insertQuest($index, $entry), ), - self::DATABASE_CATEGORY_ANIMATIONS => $this->buildDatabaseDeletionCommand( - sprintf('Delete animation %s', $label), - fn(): ?object => $workspace->animationDatabase->removeAnimation($index), - static fn(object $entry) => $workspace->animationDatabase->insertAnimation($index, $entry), - ), + default => $this->buildRecordDeletionCommand($pending['category'], $index), }; @@ -6515,11 +6345,13 @@ private function goToSkillAnimation(): void $animationIndex = null; $matchedName = ''; + $animations = $this->workspace?->getRecordDatabase(self::DATABASE_CATEGORY_ANIMATIONS); + if ($skill->animationId !== null) { - foreach ($this->workspace?->animationDatabase->getAnimations() ?? [] as $index => $animation) { - if ($animation->id === $skill->animationId) { + foreach ($animations?->getRecords() ?? [] as $index => $animation) { + if ($animation->get('id') === $skill->animationId) { $animationIndex = $index; - $matchedName = $animation->name; + $matchedName = $animations->getEntryLabel($animation); break; } } @@ -6546,7 +6378,7 @@ private function goToSkillAnimation(): void $this->pushNavigationOrigin(sprintf('skill %s', $skill->getName())); $this->databaseFilter->clear(); $this->databaseCategoryIndex = DatabaseCatalog::indexOf(self::DATABASE_CATEGORY_ANIMATIONS); - $this->databaseSelectedAnimationIndex = $animationIndex; + $this->setSelectedRecordIndex($animationIndex); $this->databaseFocus = self::DATABASE_FOCUS_LIST; $this->databaseSelectedSettingIndex = 0; $this->setStatus(sprintf('Went to animation %s (Ctrl+B goes back).', $matchedName), StatusLevel::INFO); @@ -6666,8 +6498,8 @@ private function findDatabaseAnimationIndex(string $animationName): ?int return null; } - foreach ($this->workspace?->animationDatabase->getAnimations() ?? [] as $index => $animation) { - if (mb_strtolower($animation->name) === mb_strtolower($animationName)) { + foreach ($this->workspace?->getRecordDatabase(self::DATABASE_CATEGORY_ANIMATIONS)?->getRecords() ?? [] as $index => $animation) { + if (mb_strtolower(strval($animation->get('name'))) === mb_strtolower($animationName)) { return $index; } } @@ -6701,7 +6533,7 @@ private function captureNavigationRestorer(): Closure 'class' => $this->databaseSelectedClassIndex, 'skill' => $this->databaseSelectedSkillIndex, 'quest' => $this->databaseSelectedQuestIndex, - 'animation' => $this->databaseSelectedAnimationIndex, + 'records' => $this->databaseSelectedRecordIndexes, 'setting' => $this->databaseSelectedSettingIndex, 'assetIndex' => $this->selectedAssetIndex, 'cursorX' => $this->cursorX, @@ -6721,7 +6553,7 @@ private function captureNavigationRestorer(): Closure $this->databaseSelectedClassIndex = $snapshot['class']; $this->databaseSelectedSkillIndex = $snapshot['skill']; $this->databaseSelectedQuestIndex = $snapshot['quest']; - $this->databaseSelectedAnimationIndex = $snapshot['animation']; + $this->databaseSelectedRecordIndexes = $snapshot['records']; $this->databaseSelectedSettingIndex = $snapshot['setting']; $this->selectedAssetIndex = $snapshot['assetIndex']; $this->cursorX = $snapshot['cursorX']; @@ -8728,16 +8560,6 @@ private function isSkillsDatabaseSelected(): bool { return $this->getSelectedDatabaseCategoryDefinition()->key === self::DATABASE_CATEGORY_SKILLS; } - /** - * Returns whether the Animations database is active. - * - * @return bool - */ - private function isAnimationsDatabaseSelected(): bool - { - return $this->getSelectedDatabaseCategoryDefinition()->key === self::DATABASE_CATEGORY_ANIMATIONS; - } - /** * Returns whether the Quests database is active. * @@ -9471,20 +9293,6 @@ private function getSelectedClass(): ?ProjectClass return $this->workspace?->classDatabase->getClassByIndex($this->databaseSelectedClassIndex); } - /** - * Returns the selected animation from the project database. - * - * @return Animation|null - */ - private function getSelectedAnimation() - { - if (! $this->isAnimationsDatabaseSelected()) { - return null; - } - - return $this->workspace?->animationDatabase->getAnimationByIndex($this->databaseSelectedAnimationIndex); - } - /** * Creates a new entry in the active Database category. * @@ -9516,11 +9324,6 @@ private function createDatabaseEntry(): void return; } - if ($this->isAnimationsDatabaseSelected()) { - $this->createDatabaseAnimation(); - return; - } - $this->createDatabaseRecord(); } @@ -9795,27 +9598,6 @@ private function removeDatabaseQuestObjective(): void $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); } - /** - * Creates a new animation entry in the project database. - * - * @return void - */ - private function createDatabaseAnimation(): void - { - if (! $this->workspace instanceof ProjectWorkspace || ! $this->isAnimationsDatabaseSelected()) { - return; - } - - $this->databaseSelectedAnimationIndex = $this->workspace->animationDatabase->addAnimation(); - $this->databaseSelectedFrameIndex = 1; - $this->databaseSelectedSettingIndex = 0; - $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; - $this->centerDatabasePreviewCursor(); - $this->statusMessage = "Created a new animation."; - $this->renderDatabasePanes(["list", "settings", "cue", "frames", "preview"]); - $this->beginDatabaseEdit(); - } - /** * Saves the active Database category. * @@ -9844,10 +9626,6 @@ private function saveActiveDatabase(): void $this->backupBeforeSave(...$this->workspace->questDatabase->getBackupPaths()); $this->workspace->questDatabase->save(); $this->setStatus('Quest database saved.', StatusLevel::SUCCESS); - } elseif ($this->isAnimationsDatabaseSelected()) { - $this->backupBeforeSave(...$this->workspace->animationDatabase->getBackupPaths()); - $this->workspace->animationDatabase->save(); - $this->setStatus('Animation database saved.', StatusLevel::SUCCESS); } elseif ($this->isSystemDatabaseSelected()) { if ($this->workspace->config?->isDirty()) { $this->backupBeforeSave($this->workspace->config->path); @@ -9942,108 +9720,7 @@ private function getDatabaseSettingsFields(): array return $recordDatabase->getSettingsFields($this->getSelectedRecordIndex()); } - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - return []; - } - - $cue = $animation->getCue($this->databaseSelectedFrameIndex); - - return [ - [ - 'label' => 'Name', - 'value' => $animation->name, - 'control' => new InputControl(InputControlType::TEXT, $animation->name), - 'field' => 'name', - ], - [ - 'label' => 'Position', - 'value' => ucfirst($animation->position->value), - 'options' => array_map( - static fn(AnimationTargetPosition $position): string => $position->value, - AnimationTargetPosition::cases() - ), - 'field' => 'position', - ], - [ - 'label' => 'Max Frames', - 'value' => (string) $animation->maxFrames, - 'control' => new InputControl(InputControlType::INTEGER, (string) $animation->maxFrames), - 'field' => 'maxFrames', - ], - [ - 'label' => 'Brush Symbol', - 'value' => $this->databaseSelectedPaintSymbol === ' ' ? 'Space' : $this->databaseSelectedPaintSymbol, - 'control' => new InputControl(InputControlType::TEXT, $this->databaseSelectedPaintSymbol), - 'field' => 'brushSymbol', - ], - [ - 'label' => 'Brush Color', - 'value' => ucfirst((string) ($this->databaseSelectedPaintColor ?? 'none')), - 'options' => ['none', 'white', 'red', 'green', 'blue', 'yellow', 'cyan', 'magenta'], - 'field' => 'brushColor', - ], - [ - 'label' => 'Frame Sound', - 'value' => $cue?->soundEffect ?? '', - 'reference' => 'sfx', - 'allowsNone' => true, - 'noneLabel' => '(No sound)', - 'field' => 'frameSound', - ], - [ - 'label' => 'Flash Color', - 'value' => ucfirst((string) ($cue?->flashColor ?? 'none')), - 'options' => ['none', 'white', 'red', 'green', 'blue', 'yellow', 'cyan', 'magenta'], - 'field' => 'flashColor', - ], - [ - 'label' => 'Flash Frames', - 'value' => (string) ($cue?->flashDurationFrames ?? 0), - 'control' => new InputControl(InputControlType::INTEGER, (string) ($cue?->flashDurationFrames ?? 0)), - 'field' => 'flashDurationFrames', - ], - ...$this->getAnimationRoleFields(), - ]; - } - - /** - * Returns the selected animation's role rows: which battle actions play it - * when they name no animation of their own. One row per role the Engine - * supports, switched on or off; a role bound elsewhere names its holder. - * - * @return array> - */ - private function getAnimationRoleFields(): array - { - $database = $this->workspace?->animationDatabase; - - if ($database === null || ! $this->getSelectedAnimation() instanceof Animation) { - return []; - } - - $roles = $database->getRoles($this->databaseSelectedAnimationIndex); - $fields = [[ - 'label' => 'Roles', - 'value' => $roles === [] ? '(none)' : implode(', ', $roles), - 'editable' => false, - 'field' => '', - ]]; - - foreach (ActionAnimationResolver::getSupportedRoles() as $role) { - $owner = $database->findRoleOwner($role); - $holder = $owner === null || $owner === $this->databaseSelectedAnimationIndex - ? '' : sprintf(' (on %s)', $database->getAnimationByIndex($owner)?->name ?? '?'); - $fields[] = [ - 'label' => ' ' . $role . $holder, - 'value' => in_array($role, $roles, true) ? 'Yes' : 'No', - 'options' => ['no', 'yes'], - 'field' => 'role:' . $role, - ]; - } - - return $fields; + return []; } @@ -12266,8 +11943,6 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) 'class' => $this->databaseSelectedClassIndex, 'skill' => $this->databaseSelectedSkillIndex, 'quest' => $this->databaseSelectedQuestIndex, - 'animation' => $this->databaseSelectedAnimationIndex, - 'frame' => $this->databaseSelectedFrameIndex, 'records' => $this->databaseSelectedRecordIndexes, ]; @@ -12277,8 +11952,18 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) $before = $record?->toArray(); $actor = $this->isActorsDatabaseSelected() ? $this->getSelectedActor() : null; $actorBefore = $actor?->getData(); + // A schema record's edit is one step only when it changed the record: + // a value the record refused, or the value it already held, leaves none. + $schemaRecord = $record === null && $actor === null + ? $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()) + : null; + $schemaBefore = $schemaRecord?->toArray(); $this->applyDatabaseFieldValue($fieldId, $rawValue); + if ($schemaRecord !== null && $schemaRecord->toArray() === $schemaBefore) { + return; + } + if ($actor !== null && $actorBefore !== null) { $actorAfter = $actor->getData(); if ($actorBefore !== $actorAfter) { @@ -12303,16 +11988,7 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) return; } - if (in_array($fieldId, ['brushSymbol', 'brushColor'], true) || $oldRawValue === $rawValue) { - return; - } - - // A role row shows Yes/No and sends yes/no. Choosing what it already - // shows, or a refused change, changes nothing and leaves no undo step. - if (str_starts_with($fieldId, 'role:') - && (strcasecmp($oldRawValue, $rawValue) === 0 - || in_array(substr($fieldId, 5), $this->workspace?->animationDatabase->getRoles($this->databaseSelectedAnimationIndex) ?? [], true) - !== (strtolower(trim($rawValue)) === 'yes'))) { + if ($oldRawValue === $rawValue) { return; } @@ -12358,7 +12034,7 @@ private function applyRecordFieldValue(ProjectRecordDatabase $database, string $ * Applies a database value onto a pinned entry identity, restoring the * live selection afterwards. * - * @param array{category: int, actor: int, class: int, skill: int, quest: int, animation: int, frame: int, records?: array} $identity The pinned selection. + * @param array{category: int, actor: int, class: int, skill: int, quest: int, records?: array} $identity The pinned selection. * @param string $field The field identifier. * @param string $rawValue The raw value to apply. * @return void @@ -12371,8 +12047,6 @@ private function applyDatabaseFieldValueAt(array $identity, string $field, strin $this->databaseSelectedClassIndex, $this->databaseSelectedSkillIndex, $this->databaseSelectedQuestIndex, - $this->databaseSelectedAnimationIndex, - $this->databaseSelectedFrameIndex, $this->databaseSelectedRecordIndexes, ]; $this->databaseCategoryIndex = $identity['category']; @@ -12380,8 +12054,6 @@ private function applyDatabaseFieldValueAt(array $identity, string $field, strin $this->databaseSelectedClassIndex = $identity['class']; $this->databaseSelectedSkillIndex = $identity['skill']; $this->databaseSelectedQuestIndex = $identity['quest']; - $this->databaseSelectedAnimationIndex = $identity['animation']; - $this->databaseSelectedFrameIndex = $identity['frame']; $this->databaseSelectedRecordIndexes = $identity['records'] ?? $this->databaseSelectedRecordIndexes; try { @@ -12393,8 +12065,6 @@ private function applyDatabaseFieldValueAt(array $identity, string $field, strin $this->databaseSelectedClassIndex, $this->databaseSelectedSkillIndex, $this->databaseSelectedQuestIndex, - $this->databaseSelectedAnimationIndex, - $this->databaseSelectedFrameIndex, $this->databaseSelectedRecordIndexes, ] = $liveSelection; } @@ -12523,97 +12193,9 @@ private function applyDatabaseFieldValue(string $field, string $rawValue): void // The schema owns coercion, so no per-category intval/trim rules // are needed here. This path keeps no undo step. $this->applyRecordFieldValue($recordDatabase, $field, $rawValue, $field); - return; - } - - match ($field) { - 'name', 'position', 'maxFrames' => $this->workspace->animationDatabase->setField( - $this->databaseSelectedAnimationIndex, - $field, - $field === 'maxFrames' ? max(1, intval($rawValue)) : trim($rawValue) - ), - 'brushSymbol' => $this->databaseSelectedPaintSymbol = $this->normalizeDatabaseSymbol($rawValue), - 'brushColor' => $this->databaseSelectedPaintColor = $this->normalizeDatabaseColor($rawValue), - 'frameSound', 'flashColor', 'flashDurationFrames' => $this->applyDatabaseCueFieldValue($field, $rawValue), - default => str_starts_with($field, 'role:') ? $this->applyAnimationRoleValue(substr($field, 5), $rawValue) : null, - }; - - $animation = $this->getSelectedAnimation(); - - if ($animation instanceof Animation) { - $this->databaseSelectedFrameIndex = max(1, min($animation->maxFrames, $this->databaseSelectedFrameIndex)); - $this->databasePlaybackFrameIndex = $this->databaseSelectedFrameIndex; - } - - $this->centerDatabasePreviewCursor(); - } - - /** - * Binds or unbinds a role on the selected animation, saying why when the - * database refuses. - * - * @param string $role The role. - * @param string $rawValue `yes` to bind, anything else to unbind. - * @return void - */ - private function applyAnimationRoleValue(string $role, string $rawValue): void - { - $refusal = $this->workspace?->animationDatabase->setRole( - $this->databaseSelectedAnimationIndex, - $role, - strtolower(trim($rawValue)) === 'yes', - ); - - if ($refusal !== null) { - $this->setStatus($refusal, StatusLevel::WARN); } } - /** - * Applies cue-field edits for the selected frame. - * - * @param string $field The field identifier. - * @param string $rawValue The raw field value. - * @return void - */ - private function applyDatabaseCueFieldValue(string $field, string $rawValue): void - { - if (! $this->workspace instanceof ProjectWorkspace) { - return; - } - - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - return; - } - - $cue = $animation->getCue($this->databaseSelectedFrameIndex) ?? new AnimationCue(); - $soundEffect = $cue->soundEffect; - $flashColor = $cue->flashColor; - $flashDurationFrames = $cue->flashDurationFrames; - - if ($field === 'frameSound') { - $soundEffect = trim($rawValue); - } - - if ($field === 'flashColor') { - $flashColor = $this->normalizeDatabaseColor($rawValue); - } - - if ($field === 'flashDurationFrames') { - $flashDurationFrames = max(0, intval($rawValue)); - } - - $this->workspace->animationDatabase->setFrameCue( - $this->databaseSelectedAnimationIndex, - $this->databaseSelectedFrameIndex, - $soundEffect, - $flashColor, - $flashDurationFrames, - ); - } - /** * Cycles option-based settings fields with the arrow keys. * @@ -12655,231 +12237,6 @@ private function adjustDatabaseOptionField(int $step): void $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); } - /** - * Handles typed painting input inside the database preview. - * - * @param string $input The raw input. - * @return void - */ - private function handleDatabaseTypedSymbolInput(string $input): void - { - if (str_contains($input, "\033")) { - return; - } - - if (preg_match('/^\X/u', $input, $matches) !== 1) { - return; - } - - $symbol = $matches[0]; - - if ($symbol === "\n" || $symbol === "\r" || $symbol === "\t") { - return; - } - - $this->databaseSelectedPaintSymbol = $this->normalizeDatabaseSymbol($symbol); - $this->paintDatabasePreviewSymbol($this->databaseSelectedPaintSymbol, true); - } - - /** - * Paints the selected symbol onto the current animation frame. - * - * @param string $symbol The symbol to paint. - * @return void - */ - private function paintDatabasePreviewSymbol(string $symbol, bool $refreshSettings = false): void - { - if (! $this->workspace instanceof ProjectWorkspace) { - return; - } - - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - return; - } - - $previewSize = $this->getDatabasePreviewSize(); - $origin = AnimationPreviewRenderer::resolveOrigin($animation->position, $previewSize['width'], $previewSize['height']); - $cellX = $this->databasePreviewCursorX - $origin['x']; - $cellY = $this->databasePreviewCursorY - $origin['y']; - $color = trim($symbol) === '' ? null : $this->databaseSelectedPaintColor; - $animationDatabase = $this->workspace->animationDatabase; - $animationIndex = $this->databaseSelectedAnimationIndex; - $frameIndex = $this->databaseSelectedFrameIndex; - $oldCell = $animation->getFrame($frameIndex)->getCellAt($cellX, $cellY); - $oldSymbol = $oldCell?->symbol ?? ' '; - $oldColor = $oldCell?->color; - - $animationDatabase->setFrameCell($animationIndex, $frameIndex, $cellX, $cellY, $symbol, $color); - - $newCell = $animation->getFrame($frameIndex)->getCellAt($cellX, $cellY); - - if (($newCell?->symbol ?? ' ') !== $oldSymbol || ($newCell?->color) !== $oldColor) { - $this->recordCommand(new GenericCommand( - 'Frame paint', - static fn() => $animationDatabase->setFrameCell($animationIndex, $frameIndex, $cellX, $cellY, $symbol, $color), - static fn() => $animationDatabase->setFrameCell($animationIndex, $frameIndex, $cellX, $cellY, $oldSymbol, $oldColor), - )); - } - - $this->databasePlaybackFrameIndex = $this->databaseSelectedFrameIndex; - $this->statusMessage = sprintf('Animation frame #%03d updated.', $this->databaseSelectedFrameIndex); - $this->renderDatabasePanes($refreshSettings ? ['settings', 'preview'] : ['preview']); - } - - /** - * Plays the selected animation inside the Database preview pane. - * - * @return void - */ - private function playDatabaseAnimationPreview(): void - { - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - return; - } - - if ($this->isDatabasePreviewPlaying) { - // Shift+P toggles: a second press stops the running preview. - $this->stopDatabaseAnimationPreview('Preview stopped.'); - return; - } - - $this->isDatabasePreviewPlaying = true; - $this->databasePlaybackFrameIndex = 1; - $this->databasePlaybackNextFrameAt = microtime(true); - $this->statusMessage = sprintf('Playing %s.', $animation->name); - $this->renderDatabasePanes(['preview']); - } - - /** - * Advances the non-blocking animation preview from the frame loop. - * - * Playback is a state ticked from update() rather than a blocking call so - * the editor keeps accepting input while an animation plays. - * - * @return void - */ - private function tickDatabaseAnimationPreview(): void - { - if (! $this->isDatabasePreviewPlaying) { - return; - } - - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - $this->stopDatabaseAnimationPreview('Preview stopped.'); - return; - } - - $now = microtime(true); - - if ($now < $this->databasePlaybackNextFrameAt) { - return; - } - - if ($this->databasePlaybackFrameIndex > $animation->maxFrames) { - $this->stopDatabaseAnimationPreview('Preview complete.'); - return; - } - - $cue = $animation->getCue($this->databasePlaybackFrameIndex); - $this->databasePlaybackFlashColor = $cue?->flashColor; - $this->renderDatabasePanes(['preview']); - $this->databasePlaybackFrameIndex++; - $this->databasePlaybackNextFrameAt = $now + self::PREVIEW_SECONDS_PER_FRAME; - } - - /** - * Ends the animation preview and restores the selected frame. - * - * @param string $statusMessage The status line to show. - * @return void - */ - private function stopDatabaseAnimationPreview(string $statusMessage): void - { - $this->isDatabasePreviewPlaying = false; - $this->databasePlaybackFlashColor = null; - $this->databasePlaybackFrameIndex = $this->databaseSelectedFrameIndex; - $this->statusMessage = $statusMessage; - $this->renderDatabasePanes(['preview']); - } - - /** - * Centers the database preview cursor on the current animation target. - * - * @return void - */ - private function centerDatabasePreviewCursor(): void - { - $animation = $this->getSelectedAnimation(); - $previewSize = $this->getDatabasePreviewSize(); - - if (! $animation instanceof Animation) { - $this->databasePreviewCursorX = intdiv($previewSize['width'], 2); - $this->databasePreviewCursorY = intdiv($previewSize['height'], 2); - return; - } - - $origin = AnimationPreviewRenderer::resolveOrigin($animation->position, $previewSize['width'], $previewSize['height']); - $this->databasePreviewCursorX = $origin['x']; - $this->databasePreviewCursorY = $origin['y']; - } - - /** - * Returns the size of the database preview grid. - * - * @return array{width: int, height: int} - */ - private function getDatabasePreviewSize(): array - { - $layout = $this->resolveDatabaseLayout($this->resolveLayout()); - - return [ - 'width' => max(10, $layout['previewWidth'] - 2 - (self::WINDOW_HORIZONTAL_PADDING * 2)), - 'height' => max(6, $layout['previewHeight'] - 2), - ]; - } - - /** - * Normalizes a paint symbol down to one visible grapheme. - * - * @param string $symbol The raw symbol. - * @return string - */ - private function normalizeDatabaseSymbol(string $symbol): string - { - if (trim($symbol) === '') { - return ' '; - } - - if (preg_match('/^\X/u', $symbol, $matches) !== 1) { - return ' '; - } - - return $matches[0]; - } - - /** - * Normalizes a stored animation color. - * - * @param string|null $color The raw color value. - * @return string|null - */ - private function normalizeDatabaseColor(?string $color): ?string - { - $normalized = strtolower(trim((string) $color)); - - if ($normalized === '' || $normalized === 'none') { - return null; - } - - return $normalized; - } - /** * Writes one music variant row as one undo step (see * {@see MapInspector::applyMapBgmVariantValue()}). @@ -14698,47 +14055,6 @@ private function drawDatabasePanes(array $panes, ?array $layout = null, bool $in $this->databaseScreen->draw($panes, $layout, $includeRoot); } - /** - * Renders the animation preview content inside the preview window. - * - * @param array $layout The Database layout. - * @return void - */ - private function renderDatabasePreview(array $layout): void - { - if (! $this->isAnimationsDatabaseSelected()) { - return; - } - - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - return; - } - - $previewLeft = $layout['innerX'] + $layout['categoryWidth'] + $layout['listWidth'] + $layout['framesWidth'] + ($layout['gutter'] * 4); - $previewTop = $layout['innerY'] + $layout['topHeight'] + $layout['gutter']; - $previewWidth = max(10, $layout['previewWidth'] - 2 - (self::WINDOW_HORIZONTAL_PADDING * 2)); - $previewHeight = $this->getDatabasePreviewSize()['height']; - $frameIndex = $this->isDatabasePreviewPlaying ? $this->databasePlaybackFrameIndex : $this->databaseSelectedFrameIndex; - $preview = AnimationPreviewRenderer::build($animation, $frameIndex, $previewWidth, $previewHeight); - - foreach ($preview['cells'] as $cell) { - $x = $previewLeft + 1 + self::WINDOW_HORIZONTAL_PADDING + $cell['x']; - $y = $previewTop + 1 + $cell['y']; - Console::cursor()->moveTo($x, $y); - echo $this->resolveAnimationColor($cell['color'])->value . $cell['symbol'] . Color::RESET->value; - } - - if ($this->databaseFocus === self::DATABASE_FOCUS_PREVIEW && ! $this->isDatabasePreviewPlaying) { - Console::cursor()->moveTo( - $previewLeft + 1 + self::WINDOW_HORIZONTAL_PADDING + $this->databasePreviewCursorX, - $previewTop + 1 + $this->databasePreviewCursorY, - ); - echo Color::LIGHT_BLUE->value . '▣' . Color::RESET->value; - } - } - /** * Renders the live cursor for editing Database settings. * @@ -14878,8 +14194,7 @@ private function createDatabaseListWindow(array $layout): EditorWindow $supportsEntries = $this->isActorsDatabaseSelected() || $this->isClassesDatabaseSelected() || $this->isSkillsDatabaseSelected() - || $this->isQuestsDatabaseSelected() - || $this->isAnimationsDatabaseSelected(); + || $this->isQuestsDatabaseSelected(); return new EditorWindow( // The category dirty marker rides the title so categories whose @@ -15030,7 +14345,7 @@ private function createDatabaseFramesWindow(array $layout): EditorWindow position: ["x" => $layout["innerX"] + $layout["categoryWidth"] + $layout["listWidth"] + ($layout["gutter"] * 2), "y" => $layout["innerY"] + $layout["topHeight"] + $layout["gutter"]], width: $layout["framesWidth"], height: $layout["previewHeight"], - foregroundColor: $this->resolveDatabasePaneColor(self::DATABASE_FOCUS_FRAMES), + foregroundColor: Color::WHITE, content: $this->fitLines( $this->framesPaneFitsContent() ? $this->wrapLines($this->getDatabaseFrameLines(), $this->getWindowContentWidth($layout["framesWidth"])) @@ -15055,7 +14370,7 @@ private function createDatabasePreviewWindow(array $layout): EditorWindow position: ["x" => $layout["innerX"] + $layout["categoryWidth"] + $layout["listWidth"] + $layout["framesWidth"] + ($layout["gutter"] * 3), "y" => $layout["innerY"] + $layout["topHeight"] + $layout["gutter"]], width: $layout["previewWidth"], height: $layout["previewHeight"], - foregroundColor: $this->resolveDatabasePaneColor(self::DATABASE_FOCUS_PREVIEW), + foregroundColor: Color::WHITE, content: ($this->isActorsDatabaseSelected() || $this->isClassesDatabaseSelected() || $this->isSkillsDatabaseSelected() || $this->isQuestsDatabaseSelected() || $this->isSystemDatabaseSelected()) ? $this->fitLines( $this->getDatabasePreviewLines(), @@ -15093,10 +14408,6 @@ private function getDatabaseListLines(): array return $this->getDatabaseSystemListLines(); } - if ($this->isAnimationsDatabaseSelected()) { - return $this->getDatabaseAnimationListLines(); - } - if ($this->getSelectedRecordDatabase() instanceof ProjectRecordDatabase) { return $this->getDatabaseRecordListLines(); } @@ -15312,38 +14623,6 @@ private function getDatabaseQuestListLines(): array return $lines === [] ? ['No matches.'] : $lines; } - /** - * Returns the animation list lines. - * - * @return string[] - */ - private function getDatabaseAnimationListLines(): array - { - $animations = $this->workspace?->animationDatabase->getAnimations() ?? []; - - if ($animations === []) { - return ['No animations yet.', '', 'Shift+A to create one.']; - } - - $lines = []; - $isDirty = $this->workspace?->animationDatabase->isDirty() === true; - - foreach ($this->getVisibleDatabaseEntryIndexes() as $index) { - $animation = $animations[$index] ?? null; - - if (! $animation instanceof Animation) { - continue; - } - - $prefix = $index === $this->databaseSelectedAnimationIndex ? '> ' : ' '; - // The animation database tracks dirtiness per file, not per - // entry, so the marker is honest about the whole list. - $lines[] = sprintf('%s%04d %s%s', $prefix, $animation->id, $animation->name, $isDirty ? ' *' : ''); - } - - return $lines === [] ? ['No matches.'] : $lines; - } - /** * Returns the system list lines. * @@ -15540,45 +14819,7 @@ private function getDatabaseCueLines(): array return $this->getDatabaseBattleEntryCueLines(); } - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - $category = $this->getSelectedDatabaseCategoryDefinition(); - - if (! $category->isImplemented) { - return ['No timing data yet.']; - } - - return ['No animation selected.']; - } - - $lines = ['No. SE Flash']; - $hasCue = false; - - for ($frameIndex = 1; $frameIndex <= $animation->maxFrames; $frameIndex++) { - $cue = $animation->getCue($frameIndex); - - if (! $cue instanceof AnimationCue || $cue->isEmpty()) { - continue; - } - - $hasCue = true; - $lines[] = sprintf( - '#%03d %-8s %s', - $frameIndex, - $cue->soundEffect !== '' ? $cue->soundEffect : '-', - $cue->flashColor !== null - ? sprintf('%s (%d)', ucfirst($cue->flashColor), $cue->flashDurationFrames) - : '-' - ); - } - - if (! $hasCue) { - $lines[] = ''; - $lines[] = 'No cues on this animation.'; - } - - return $lines; + return $this->getSelectedDatabaseCategoryDefinition()->isImplemented ? ['-'] : ['No timing data yet.']; } /** @@ -15776,22 +15017,7 @@ private function getDatabaseFrameLines(): array } - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - $category = $this->getSelectedDatabaseCategoryDefinition(); - - return $category->isImplemented ? ['-'] : ['No entry frames.']; - } - - $lines = []; - - for ($frameIndex = 1; $frameIndex <= $animation->maxFrames; $frameIndex++) { - $prefix = $frameIndex === $this->databaseSelectedFrameIndex ? '> ' : ' '; - $lines[] = sprintf('%s#%03d', $prefix, $frameIndex); - } - - return $lines; + return $this->getSelectedDatabaseCategoryDefinition()->isImplemented ? ['-'] : ['No entry frames.']; } /** @@ -16156,7 +15382,6 @@ private function isDatabaseCategoryDirty(string $categoryKey): bool self::DATABASE_CATEGORY_ACTORS => $this->workspace->actorDatabase->isDirty(), self::DATABASE_CATEGORY_CLASSES => $this->workspace->classDatabase->isDirty(), self::DATABASE_CATEGORY_SKILLS => $this->workspace->skillDatabase->isDirty(), - self::DATABASE_CATEGORY_ANIMATIONS => $this->workspace->animationDatabase->isDirty(), self::DATABASE_CATEGORY_SYSTEM => $this->workspace->systemDatabase->isDirty() || ($this->workspace->config?->isDirty() ?? false), self::DATABASE_CATEGORY_QUESTS => $this->workspace->questDatabase->isDirty(), default => false, @@ -16174,25 +15399,6 @@ private function resolveDatabasePaneColor(string $pane): Color return $this->databaseFocus === $pane ? Color::LIGHT_BLUE : Color::WHITE; } - /** - * Resolves the termutil color for a stored animation color name. - * - * @param string|null $color The stored color name. - * @return Color - */ - private function resolveAnimationColor(?string $color): Color - { - return match (strtolower((string) $color)) { - 'red' => Color::LIGHT_RED, - 'green' => Color::LIGHT_GREEN, - 'blue' => Color::LIGHT_BLUE, - 'yellow' => Color::YELLOW, - 'cyan' => Color::LIGHT_CYAN, - 'magenta' => Color::LIGHT_PURPLE, - default => Color::WHITE, - }; - } - /** * Returns the active character palette. * diff --git a/src/ProjectAnimationDatabase.php b/src/ProjectAnimationDatabase.php deleted file mode 100644 index 6091623..0000000 --- a/src/ProjectAnimationDatabase.php +++ /dev/null @@ -1,521 +0,0 @@ -> Each loaded animation's authored entry, keyed by object id. - */ - private array $authoredEntries = []; - - /** - * @var array> Roles changed since loading, keyed by object id. - * The Engine's Animation keeps its roles fixed, so edits live here and are - * written over the authored entry's `roles` on save. - */ - private array $editedRoles = []; - - /** The file as read, so its header is kept and an unsafe file is refused. */ - private ?PhpDataFile $file = null; - - /** - * @param Animation[] $animations - */ - public function __construct( - public readonly string $path, - private array $animations = [], - bool $isDirty = false, - ) { - if (! $isDirty) { - $this->captureBaseline(); - } - } - - /** - * Loads the animation database from the project. - * - * @param string $projectRoot The project root. - * @return self - */ - public static function fromProject(string $projectRoot): self - { - $path = rtrim($projectRoot, DIRECTORY_SEPARATOR) - . DIRECTORY_SEPARATOR - . 'assets' - . DIRECTORY_SEPARATOR - . 'Data' - . DIRECTORY_SEPARATOR - . 'animations.php'; - - if (! is_file($path)) { - return new self($path, []); - } - - $file = PhpDataFile::load($path, $projectRoot); - $payload = $file->payload; - - if (! is_array($payload)) { - throw new RuntimeException("Unable to parse {$path}."); - } - - $entries = array_values(array_filter($payload, 'is_array')); - // Roles are the database's to read and edit (getRoles, setRole); the - // Engine object is built without them, so a role the Engine refuses is - // reported by validation instead of keeping the database from loading. - $animations = array_map(static fn(array $entry): Animation => Animation::fromArray(array_diff_key($entry, ['roles' => true])), $entries); - $database = new self($path, $animations, isDirty: true); - $database->file = $file; - - foreach ($animations as $index => $animation) { - $database->authoredEntries[spl_object_id($animation)] = $entries[$index]; - } - - $database->captureBaseline(); - - return $database; - } - - /** - * Returns the stored animations. - * - * @return Animation[] - */ - public function getAnimations(): array - { - return array_values($this->animations); - } - - /** - * Returns whether the database has unsaved changes. - * - * @return bool - */ - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - return "getPersistedEntries()) . ";\n"; - } - - /** - * Returns an animation's entry as authored, with the fields the editor - * edits as they now stand, or null for one the database does not hold. - * - * @return array|null - */ - public function getAuthoredEntry(Animation $animation): ?array - { - $index = array_search($animation, $this->getAnimations(), true); - - return $index === false ? null : $this->getPersistedEntries()[$index]; - } - - /** - * Returns the entries to write: each authored entry with the fields the - * editor edits replaced, so nothing else it holds is lost. - * - * @return list> - */ - private function getPersistedEntries(): array - { - return array_map(function (Animation $animation): array { - $edited = $animation->toArray(); - $entry = $this->authoredEntries[spl_object_id($animation)] ?? $edited; - - foreach (self::EDITED_FIELDS as $field) { - if (array_key_exists($field, $edited)) { - $entry[$field] = $edited[$field]; - } - } - - // Roles the editor changed replace the authored ones; untouched - // roles stay as written. - $roles = $this->editedRoles[spl_object_id($animation)] ?? null; - - if ($roles !== null) { - unset($entry['roles']); - - if ($roles !== []) { - $entry['roles'] = $roles; - } - } - - return $entry; - }, $this->getAnimations()); - } - - /** - * Returns the roles an animation is bound to: which battle actions play - * it when they name no animation of their own. - * - * @param int $index The animation index. - * @return list - */ - public function getRoles(int $index): array - { - $animation = $this->getAnimationByIndex($index); - - if (! $animation instanceof Animation) { - return []; - } - - $id = spl_object_id($animation); - $authored = $this->authoredEntries[$id]['roles'] ?? []; - - return $this->editedRoles[$id] ?? array_values(array_filter((array) $authored, is_string(...))); - } - - /** - * Returns the index of the animation a role is bound to, if any. - * - * @param string $role The role. - * @return int|null - */ - public function findRoleOwner(string $role): ?int - { - foreach (array_keys($this->getAnimations()) as $index) { - if (in_array($role, $this->getRoles($index), true)) { - return $index; - } - } - - return null; - } - - /** - * Binds or unbinds a role on an animation. - * - * Refused, changing nothing, for a role the Engine does not support or - * one already bound to another animation: the runtime plays a role only - * when exactly one animation holds it. - * - * @param int $index The animation index. - * @param string $role The role. - * @param bool $isBound Whether the animation should hold the role. - * @return string|null Why the change was refused, or null when it was made. - */ - public function setRole(int $index, string $role, bool $isBound): ?string - { - $animation = $this->getAnimationByIndex($index); - - if (! $animation instanceof Animation) { - return 'No animation is selected.'; - } - - if (! in_array($role, ActionAnimationResolver::getSupportedRoles(), true)) { - return sprintf('The Engine has no animation role "%s".', $role); - } - - $roles = $this->getRoles($index); - $owner = $this->findRoleOwner($role); - - if ($isBound && $owner !== null && $owner !== $index) { - return sprintf('Role %s is already bound to %s; unbind it there first.', $role, $this->getAnimationByIndex($owner)?->name ?? 'another animation'); - } - - $next = $isBound - ? array_values(array_unique([...$roles, $role])) - : array_values(array_diff($roles, [$role])); - - if ($next !== $roles) { - $this->editedRoles[spl_object_id($animation)] = $next; - $this->touchState(); - } - - return null; - } - - /** - * Returns the animation at the given index. - * - * @param int $index The animation index. - * @return Animation|null - */ - public function getAnimationByIndex(int $index): ?Animation - { - return $this->animations[$index] ?? null; - } - - /** - * Adds a blank animation and returns its index. - * - * @param string $name The new animation name. - * @return int - */ - public function addAnimation(string $name = 'New Animation'): int - { - $nextId = 1; - - foreach ($this->animations as $animation) { - $nextId = max($nextId, $animation->id + 1); - } - - $animation = new Animation( - id: $nextId, - name: $name, - position: AnimationTargetPosition::CENTER, - maxFrames: 5, - ); - $this->animations[] = $animation; - $this->touchState(); - - return count($this->animations) - 1; - } - - /** - * Removes the animation at the given index (rewritten to disk on save, - * so re-inserting at the same index is a complete undo). - * - * @param int $index The animation index. - * @return Animation|null The removed animation, or null when the index is unknown. - */ - public function removeAnimation(int $index): ?Animation - { - $animations = array_values($this->animations); - $animation = $animations[$index] ?? null; - - if (! $animation instanceof Animation) { - return null; - } - - array_splice($animations, $index, 1); - $this->animations = $animations; - $this->touchState(); - - return $animation; - } - - /** - * Re-inserts a previously removed animation (the undo of removeAnimation()). - * - * @param int $index The index to restore the animation at. - * @param Animation $animation The animation to restore. - * @return void - */ - public function insertAnimation(int $index, Animation $animation): void - { - $animations = array_values($this->animations); - $index = max(0, min(count($animations), $index)); - array_splice($animations, $index, 0, [$animation]); - $this->animations = $animations; - $this->touchState(); - } - - /** - * Updates a basic animation field. - * - * @param int $index The animation index. - * @param string $field The field name. - * @param mixed $value The replacement value. - * @return void - */ - public function setField(int $index, string $field, mixed $value): void - { - $animation = $this->getAnimationByIndex($index); - - if (! $animation instanceof Animation) { - return; - } - - match ($field) { - 'name' => $animation->name = strval($value), - 'position' => $animation->position = AnimationTargetPosition::fromValue(strval($value)), - 'maxFrames' => $animation->ensureFrameCount(max(1, intval($value))), - default => null, - }; - - $this->touchState(); - } - - /** - * Updates one cell in the selected animation. - * - * @param int $index The animation index. - * @param int $frameIndex The frame number. - * @param int $x The cell x offset. - * @param int $y The cell y offset. - * @param string $symbol The symbol to store. - * @param string|null $color The optional color name. - * @return void - */ - public function setFrameCell( - int $index, - int $frameIndex, - int $x, - int $y, - string $symbol, - ?string $color = null, - ): void { - $animation = $this->getAnimationByIndex($index); - - if (! $animation instanceof Animation) { - return; - } - - $animation->setCell($frameIndex, $x, $y, $symbol, $color); - $this->touchState(); - } - - /** - * Replaces the cue attached to one frame. - * - * @param int $index The animation index. - * @param int $frameIndex The frame number. - * @param string $soundEffect The sound-effect cue. - * @param string|null $flashColor The flash color. - * @param int $flashDurationFrames The flash duration. - * @return void - */ - public function setFrameCue( - int $index, - int $frameIndex, - string $soundEffect, - ?string $flashColor, - int $flashDurationFrames, - ): void { - $animation = $this->getAnimationByIndex($index); - - if (! $animation instanceof Animation) { - return; - } - - $animation->setCue( - $frameIndex, - new AnimationCue( - soundEffect: $soundEffect, - flashColor: $flashColor, - flashDurationFrames: $flashDurationFrames, - ), - ); - $this->touchState(); - } - - /** - * Writes the database asset back to disk. - * - * @return void - */ - public function save(): void - { - $directory = dirname($this->path); - - if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { - throw new RuntimeException("Unable to create {$directory}."); - } - - if (! $this->isDirty() && is_file($this->path)) { - return; - } - - $entries = $this->getPersistedEntries(); - - if ($this->file !== null) { - $this->file->save($entries); - } else { - AtomicFile::write($this->path, $this->buildPersistedPayload()); - } - - foreach ($this->getAnimations() as $index => $animation) { - $this->authoredEntries[spl_object_id($animation)] = $entries[$index]; - } - - // The written entries now hold every edited role. - $this->editedRoles = []; - $this->captureBaseline(); - } - - /** - * Writes a file using a temp-file swap. - * - * @param string $path The destination file. - * @param string $contents The file contents. - * @return void - */ - private static function writeFileTransactionally(string $path, string $contents): void - { - $temporaryPath = $path . '.tmp'; - - if (file_put_contents($temporaryPath, $contents) === false) { - throw new RuntimeException("Unable to write temporary file for {$path}."); - } - - if (! rename($temporaryPath, $path)) { - @unlink($temporaryPath); - throw new RuntimeException("Unable to replace {$path}."); - } - } - - /** - * Exports a PHP value using short-array syntax. - * - * @param mixed $value The value to export. - * @param int $indentLevel The indentation depth. - * @return string - */ - private static function exportPhpValue(mixed $value, int $indentLevel = 0): string - { - if (! is_array($value)) { - return var_export($value, true); - } - - if ($value === []) { - return '[]'; - } - - $indent = str_repeat(' ', $indentLevel); - $nextIndent = str_repeat(' ', $indentLevel + 1); - $isList = array_is_list($value); - $lines = ['[']; - - foreach ($value as $key => $item) { - $exportedItem = self::exportPhpValue($item, $indentLevel + 1); - - if ($isList) { - $lines[] = "{$nextIndent}{$exportedItem},"; - continue; - } - - $exportedKey = var_export($key, true); - $lines[] = "{$nextIndent}{$exportedKey} => {$exportedItem},"; - } - - $lines[] = "{$indent}]"; - - return implode("\n", $lines); - } - - /** - * Returns the files a save of this database would overwrite. - * - * @return string[] - */ - public function getBackupPaths(): array - { - return [$this->path]; - } -} diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index ce2b1ba..a3c64ac 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -45,7 +45,6 @@ public function __construct( public ProjectActorDatabase $actorDatabase, public ProjectClassDatabase $classDatabase, public ProjectSkillDatabase $skillDatabase, - public ProjectAnimationDatabase $animationDatabase, public ProjectSystemDatabase $systemDatabase, public ProjectQuestDatabase $questDatabase, public array $recordDatabases = [], @@ -173,7 +172,6 @@ private static function loadFromProject(string $projectRoot): self actorDatabase: ProjectActorDatabase::fromProject($projectRoot), classDatabase: ProjectClassDatabase::fromProject($projectRoot), skillDatabase: ProjectSkillDatabase::fromProject($projectRoot), - animationDatabase: ProjectAnimationDatabase::fromProject($projectRoot), systemDatabase: ProjectSystemDatabase::fromProject($projectRoot), questDatabase: ProjectQuestDatabase::fromProject($projectRoot), recordDatabases: array_map( @@ -285,7 +283,6 @@ public function hasUnsavedChanges(): bool return $this->actorDatabase->isDirty() || $this->classDatabase->isDirty() || $this->skillDatabase->isDirty() - || $this->animationDatabase->isDirty() || $this->systemDatabase->isDirty() || $this->questDatabase->isDirty() || ($this->config?->isDirty() ?? false) @@ -326,7 +323,7 @@ public function listUnsavedChanges(): array * Read-only record categories are left out: they hold no edits, and * asking them to save would raise instead of doing nothing. * - * @return array + * @return array */ public function listSaveableDatabases(): array { @@ -335,7 +332,6 @@ public function listSaveableDatabases(): array 'Classes' => $this->classDatabase, 'Skills' => $this->skillDatabase, 'Quests' => $this->questDatabase, - 'Animations' => $this->animationDatabase, 'System' => $this->systemDatabase, ]; @@ -416,7 +412,6 @@ private function withMaps(array $maps): self actorDatabase: $this->actorDatabase, classDatabase: $this->classDatabase, skillDatabase: $this->skillDatabase, - animationDatabase: $this->animationDatabase, systemDatabase: $this->systemDatabase, questDatabase: $this->questDatabase, recordDatabases: $this->recordDatabases, diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index c8b6ceb..8baa497 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1093,8 +1093,8 @@ private static function describeCanvasImage(CanvasImage $image): array * A database category's records, as its list shows them, whether they * can be edited and have unsaved changes, and which record operations * the category takes. The bespoke categories (actors, classes, skills, - * animations, quests, system) still build their rows in the terminal - * editor, so they are refused here until those builders are shared. + * quests, system) still build their rows in the terminal editor, so + * they are refused here until those builders are shared. * * @return array{category: string, editable: bool, readOnly: ?string, dirty: bool, canCreate: bool, canDuplicate: bool, canDelete: bool, canReorder: bool, records: list} * @throws SessionRefusal When the category is unknown or not yet served. @@ -1938,6 +1938,8 @@ private static function describeRow(array $field): array 'destination' => 'maps', default => null, }, + // A list picked a member at a time: each pick toggles one member. + 'multi' => $kind === 'reference' && ($field['multi'] ?? false) === true ? true : null, 'list' => is_array($list) && $target !== null ? ['index' => (int) ($list['index'] ?? 0)] : null, // Conditions and writes are built a part at a time from these. 'entries' => match ($kind) { diff --git a/src/Validation/AnimationReferenceValidator.php b/src/Validation/AnimationReferenceValidator.php index cc53ef9..74b5083 100644 --- a/src/Validation/AnimationReferenceValidator.php +++ b/src/Validation/AnimationReferenceValidator.php @@ -15,9 +15,9 @@ final class AnimationReferenceValidator /** @return Issue[] */ public function validate(ProjectWorkspace $workspace): array { - $animations = $workspace->animationDatabase->getAnimations(); - $ids = array_map(static fn($animation): int => $animation->id, $animations); - $names = array_map(static fn($animation): string => $animation->name, $animations); + $animations = $workspace->getRecordDatabase('animations')?->getRecords() ?? []; + $ids = array_map(static fn($animation): mixed => $animation->get('id'), $animations); + $names = array_map(static fn($animation): string => strval($animation->get('name')), $animations); $issues = []; foreach ($workspace->skillDatabase->getSkills() as $skill) { @@ -67,18 +67,18 @@ private function checkRoles(ProjectWorkspace $workspace): array { $where = 'assets/Data/animations.php'; $supported = ActionAnimationResolver::getSupportedRoles(); - $database = $workspace->animationDatabase; $holders = []; $issues = []; - foreach ($database->getAnimations() as $index => $animation) { - foreach ($database->getRoles($index) as $role) { + foreach ($workspace->getRecordDatabase('animations')?->getRecords() ?? [] as $animation) { + $name = strval($animation->get('name')); + foreach ((array) ($animation->get('roles') ?? []) as $role) { if (! in_array($role, $supported, true)) { - $issues[] = Issue::error("{$where}: {$animation->name}", sprintf('Its role "%s" is not one the Engine supports, so the whole animation is skipped.', $role), - 'Choose its roles in the Roles rows, which offer only supported roles.'); + $issues[] = Issue::error("{$where}: {$name}", sprintf('Its role "%s" is not one the Engine supports, so the whole animation is skipped.', strval($role)), + 'Choose its roles in the Roles picker, which offers only supported roles.'); continue; } - $holders[$role][] = $animation->name; + $holders[$role][] = $name; } } @@ -106,7 +106,7 @@ private function checkRoles(ProjectWorkspace $workspace): array foreach ($reached as $role => $reason) { if (! isset($holders[$role])) { $issues[] = Issue::warning($where, sprintf('No animation holds role %s, so %s play no effect.', $role, $reason), - 'Switch the role on for the animation that should play.'); + 'Pick the role in the Roles of the animation that should play.'); } } diff --git a/src/Validation/EffectValidator.php b/src/Validation/EffectValidator.php index f3846d9..719a165 100644 --- a/src/Validation/EffectValidator.php +++ b/src/Validation/EffectValidator.php @@ -103,13 +103,11 @@ public static function findUses(ProjectWorkspace $workspace, array &$issues = [] $uses[$effect][$battle ? 'battle' : 'field'][] = $where; }; - foreach ($workspace->animationDatabase->getAnimations() as $animation) { - // Read from the authored entry, which keeps the bindings whatever the Engine's Animation carries. - $entry = $workspace->animationDatabase->getAuthoredEntry($animation) ?? []; - - foreach (['sourceEffect' => $entry['sourceEffect'] ?? null, 'targetEffect' => $entry['targetEffect'] ?? null] as $field => $effect) { + foreach ($workspace->getRecordDatabase('animations')?->getRecords() ?? [] as $animation) { + foreach (['sourceEffect', 'targetEffect'] as $field) { + $effect = $animation->get($field); if (is_string($effect) && $effect !== '') { - $use($effect, true, sprintf('assets/Data/animations.php: %s %s', $animation->name, $field)); + $use($effect, true, sprintf('assets/Data/animations.php: %s %s', strval($animation->get('name')), $field)); } } } diff --git a/tests/Unit/AnimationDatabaseRoundTripTest.php b/tests/Unit/AnimationDatabaseRoundTripTest.php index 7adba8e..714ff06 100644 --- a/tests/Unit/AnimationDatabaseRoundTripTest.php +++ b/tests/Unit/AnimationDatabaseRoundTripTest.php @@ -2,14 +2,15 @@ declare(strict_types=1); -use Ichiloto\Editor\ProjectAnimationDatabase; +use Ichiloto\Editor\ProjectWorkspace; /** - * The editor edits an animation's name, position, frames and cues. Everything - * else an entry holds (effect bindings, roles, fields the Engine adds later) - * and the file's header must survive a save untouched. + * Animations are edited through the shared record database: a record's name, + * effects and roles. Everything else an entry holds (an older record's own + * frames and cues, fields the Engine adds later), the file's comments and its + * header survive a save untouched, and a new animation takes the next id. */ -it('keeps every field it does not edit, and the file header, when it saves an animation', function () { +it('keeps every field it does not edit, comments and the header, and numbers a new animation', function () { $root = makeTemporaryProject(); $path = $root . '/assets/Data/animations.php'; file_put_contents($path, <<<'PHP' @@ -24,6 +25,7 @@ 'name' => 'Slash', 'position' => AnimationTargetPosition::CENTER->value, 'maxFrames' => 2, + // Played for sword attacks. 'roles' => ['attack'], 'sourceEffect' => 'battle-blade-slash', 'targetEffect' => 'battle-physical-impact', @@ -31,10 +33,19 @@ 'frames' => [['index' => 1, 'cells' => [['symbol' => '/', 'x' => 0, 'y' => 0, 'color' => 'red']]]], 'cues' => [], ], + ['id' => 4, 'name' => 'Spark', 'targetEffect' => 'battle-physical-impact'], ]; PHP); - $database = ProjectAnimationDatabase::fromProject($root); + $database = ProjectWorkspace::fromProject($root)->getRecordDatabase('animations'); + $legacy = array_column($database->getSettingsFields(0), null, 'label'); + $current = array_column($database->getSettingsFields(1), 'label'); + + // An older record shows its own frames and cues read-only; a current one has none. + expect($legacy['Legacy Frames'])->toMatchArray(['value' => '(1)', 'editable' => false]) + ->and($legacy['Legacy Position']['value'])->toBe('center') + ->and($current)->toBe(['Id', 'Name', 'Caster Effect', 'Target Effect', 'Roles']); + $database->setField(0, 'name', 'Blade Slash'); $database->save(); $saved = require $path; @@ -43,19 +54,16 @@ expect($saved[0]['name'])->toBe('Blade Slash') ->and($saved[0]['roles'])->toBe(['attack']) ->and($saved[0]['sourceEffect'])->toBe('battle-blade-slash') - ->and($saved[0]['targetEffect'])->toBe('battle-physical-impact') ->and($saved[0]['futureField'])->toBe(['kept' => true]) ->and($saved[0]['frames'][0]['cells'][0]['symbol'])->toBe('/') - ->and($source)->toContain('// Battle and field animations.') - ->and($source)->toContain('use Ichiloto\Engine\Animations\AnimationTargetPosition;'); + ->and($source)->toContain('// Battle and field animations.', '// Played for sword attacks.', 'use Ichiloto\Engine\Animations\AnimationTargetPosition;'); - // A new animation has only what the editor writes, and saving twice changes nothing more. - $database->addAnimation('Spark'); + // A new animation takes the next number after the largest, and only what the editor writes. + $index = $database->addRecord(); $database->save(); $again = require $path; - expect($again)->toHaveCount(2) - ->and($again[0]['targetEffect'])->toBe('battle-physical-impact') - ->and($again[1]['name'])->toBe('Spark') - ->and($again[1])->not->toHaveKey('targetEffect'); + expect($again)->toHaveCount(3) + ->and($again[$index])->toBe(['id' => 5, 'name' => 'New Animation']) + ->and($again[0]['targetEffect'])->toBe('battle-physical-impact'); }); diff --git a/tests/Unit/AnimationRolesTest.php b/tests/Unit/AnimationRolesTest.php index 39d61b8..fb626b4 100644 --- a/tests/Unit/AnimationRolesTest.php +++ b/tests/Unit/AnimationRolesTest.php @@ -3,9 +3,9 @@ declare(strict_types=1); use Ichiloto\Editor\Database\DatabaseCatalog; +use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Editor; use Ichiloto\Editor\History\CommandHistory; -use Ichiloto\Editor\ProjectAnimationDatabase; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Validation\AnimationReferenceValidator; use Ichiloto\Engine\Animations\ActionAnimationResolver; @@ -14,10 +14,9 @@ function writeRoleAnimations(string $root, array $bladeRoles = ['attack-sword'], array $impactRoles = ['attack', 'attack-unarmed']): void { $entry = static fn(int $id, string $name, array $roles): array => [ - 'id' => $id, 'name' => $name, 'position' => 'center', 'maxFrames' => 1, + 'id' => $id, 'name' => $name, ...($roles === [] ? [] : ['roles' => $roles]), 'targetEffect' => $id === 1 ? 'battle-blade-slash' : 'battle-physical-impact', - 'frames' => [], 'cues' => [], ]; file_put_contents($root . '/assets/Data/animations.php', '> The role rows keyed by field. */ -function roleRows(Editor $editor): array -{ - $rows = []; - - foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $field) { - if (str_starts_with((string) ($field['field'] ?? ''), 'role:')) { - $rows[(string) $field['field']] = $field; - } - } - - return $rows; -} - /** @return list The validation messages about animations. */ function roleIssues(string $root): array { @@ -59,72 +44,67 @@ function roleIssues(string $root): array ); } -it('binds and unbinds roles, writing them over the authored entry and keeping everything else', function () { +it('binds and unbinds roles as one list on the record, keeping everything else', function () { $root = makeTemporaryProject(); writeRoleAnimations($root); - $database = ProjectAnimationDatabase::fromProject($root); - - expect($database->getRoles(0))->toBe(['attack-sword']) - ->and($database->findRoleOwner('attack-unarmed'))->toBe(1) - ->and($database->setRole(0, 'attack-dagger', true))->toBeNull() - ->and($database->setRole(1, 'attack-unarmed', false))->toBeNull() - ->and($database->setRole(1, 'attack', false))->toBeNull(); + $database = ProjectWorkspace::fromProject($root)->getRecordDatabase('animations'); + $database->setField(0, 'roles', 'attack-sword, attack-dagger'); + $database->setField(1, 'roles', ''); $database->save(); $saved = require $root . '/assets/Data/animations.php'; expect($saved[0]['roles'])->toBe(['attack-sword', 'attack-dagger']) ->and($saved[1])->not->toHaveKey('roles') - ->and($saved[0]['targetEffect'])->toBe('battle-blade-slash') - ->and(ProjectAnimationDatabase::fromProject($root)->getRoles(0))->toBe(['attack-sword', 'attack-dagger']); + ->and($saved[0]['targetEffect'])->toBe('battle-blade-slash'); }); -it('refuses a role the Engine does not support or one already bound elsewhere, changing nothing', function () { +it('refuses a role another animation holds, naming it and changing nothing', function () { $root = makeTemporaryProject(); writeRoleAnimations($root); - $database = ProjectAnimationDatabase::fromProject($root); + $database = ProjectWorkspace::fromProject($root)->getRecordDatabase('animations'); - expect($database->setRole(1, 'attack-sword', true))->toBe('Role attack-sword is already bound to Blade Slash; unbind it there first.') - ->and($database->setRole(0, 'attack-lance', true))->toBe('The Engine has no animation role "attack-lance".') - ->and($database->getRoles(0))->toBe(['attack-sword']) - ->and($database->getRoles(1))->toBe(['attack', 'attack-unarmed']) + expect(fn() => $database->setField(1, 'roles', 'attack, attack-unarmed, attack-sword')) + ->toThrow(InvalidArgumentException::class, 'Blade Slash already has roles attack-sword; take it off there first.') + ->and($database->getRecordByIndex(1)->get('roles'))->toBe(['attack', 'attack-unarmed']) ->and($database->isDirty())->toBeFalse(); }); -it('offers every supported role as a row, naming the animation that holds it', function () { +it('offers every supported role in the roles picker, naming the animation that holds it', function () { $root = makeTemporaryProject(); writeRoleAnimations($root); - $rows = roleRows(roleEditor($root)); + $catalog = new ReferenceCatalog(ProjectWorkspace::fromProject($root)); + $row = array_find(callEditorMethod(roleEditor($root), 'getDatabaseSettingsFields'), static fn(array $field): bool => ($field['field'] ?? null) === 'roles'); - expect(array_keys($rows))->toBe(array_map(static fn(string $role): string => 'role:' . $role, ActionAnimationResolver::getSupportedRoles())) - ->and($rows['role:attack-sword']['value'])->toBe('Yes') - ->and($rows['role:attack-sword']['options'])->toBe(['no', 'yes']) - ->and(trim((string) $rows['role:attack-unarmed']['label']))->toBe('attack-unarmed (on Hit Spark)') - ->and($rows['role:attack-unarmed']['value'])->toBe('No'); + expect($row)->toMatchArray(['reference' => 'animation_roles', 'multi' => true, 'value' => 'attack-sword']) + ->and($catalog->valuesFor('animation_roles'))->toBe(ActionAnimationResolver::getSupportedRoles()) + ->and($catalog->labelsFor('animation_roles')['attack-unarmed'])->toBe('attack-unarmed (on Hit Spark)') + ->and($catalog->labelsFor('animation_roles')['attack-axe'])->toBe('attack-axe'); }); -it('switches a role on from its row and undoes it, and leaves no undo step when refused', function () { +it('takes a role from the picker as one undo step, and leaves no step when refused', function () { $root = makeTemporaryProject(); writeRoleAnimations($root); $editor = roleEditor($root); /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); - $database = getEditorProperty($editor, 'workspace')->animationDatabase; + $database = getEditorProperty($editor, 'workspace')->getRecordDatabase('animations'); + $roles = static fn(): array => array_find(callEditorMethod($editor, 'getDatabaseSettingsFields'), static fn(array $field): bool => ($field['field'] ?? null) === 'roles'); - callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', roleRows($editor)['role:attack-axe'], 'yes'); - expect($database->getRoles(0))->toBe(['attack-sword', 'attack-axe']); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $roles(), 'attack-sword, attack-axe'); + expect($database->getRecordByIndex(0)->get('roles'))->toBe(['attack-sword', 'attack-axe']); $history->undo(); - expect($database->getRoles(0))->toBe(['attack-sword']); + expect($database->getRecordByIndex(0)->get('roles'))->toBe(['attack-sword']); $history->redo(); $history->undo(); $steps = new ReflectionProperty(CommandHistory::class, 'undoStack')->getValue($history); - callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', roleRows($editor)['role:attack-unarmed'], 'yes'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $roles(), 'attack-sword, attack-unarmed'); - expect($database->getRoles(0))->toBe(['attack-sword']) + expect($database->getRecordByIndex(0)->get('roles'))->toBe(['attack-sword']) ->and(new ReflectionProperty(CommandHistory::class, 'undoStack')->getValue($history))->toBe($steps) - ->and(getEditorProperty($editor, 'statusMessage'))->toContain('already bound to Hit Spark'); + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Hit Spark already has roles attack-unarmed'); }); it('reports roles the runtime cannot play and roles the project reaches with no animation', function () { diff --git a/tests/Unit/EditorSafetyTest.php b/tests/Unit/EditorSafetyTest.php index 8cd36ff..98f0ba4 100644 --- a/tests/Unit/EditorSafetyTest.php +++ b/tests/Unit/EditorSafetyTest.php @@ -120,19 +120,17 @@ function workspaceEditor(): Editor expect($workspace->getAssetLines(0))->toBe(['Maps', '> test-map *']); }); -it('builds the animation settings fields without a class error', function () { - // Regression: AnimationTargetPosition used to resolve to the missing class - // Ichiloto\Editor\AnimationTargetPosition and fatal at Editor.php:3457. +it('builds the animation rows from the shared schema, an older record showing its own frames read-only', function () { $editor = workspaceEditor(); setEditorProperty($editor, 'databaseCategoryIndex', DatabaseCatalog::indexOf('animations')); $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); - $labels = array_column($fields, 'label'); - $positionField = $fields[array_search('Position', $labels, true)]; + $rows = array_column($fields, null, 'label'); - expect($labels)->toContain('Name', 'Position', 'Max Frames') - ->and($positionField['options'])->toBe(['center', 'head', 'feet', 'screen']) - ->and($positionField['value'])->toBe('Center'); + expect(array_keys($rows))->toContain('Name', 'Caster Effect', 'Target Effect', 'Roles', 'Legacy Position', 'Legacy Frames') + ->and($rows['Legacy Position']['options'])->toBe(['center', 'head', 'feet', 'screen']) + ->and($rows['Legacy Frames']['editable'])->toBeFalse() + ->and($rows['Target Effect']['reference'])->toBe('effects'); }); it('undoes database field edits against the pinned entry', function () { @@ -140,24 +138,23 @@ function workspaceEditor(): Editor setEditorProperty($editor, 'databaseCategoryIndex', DatabaseCatalog::indexOf('animations')); $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); - $labels = array_column($fields, 'label'); - $nameField = $fields[array_search('Name', $labels, true)]; + $nameField = $fields[array_search('Name', array_column($fields, 'label'), true)]; callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $nameField, 'Mega Slash'); /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - $animation = $workspace->animationDatabase->getAnimationByIndex(0); + $animation = $workspace->getRecordDatabase('animations')->getRecordByIndex(0); - expect($animation->name)->toBe('Mega Slash'); + expect($animation->get('name'))->toBe('Mega Slash'); // Move the selection elsewhere: undo must still hit animation 0. - setEditorProperty($editor, 'databaseSelectedAnimationIndex', 5); + setEditorProperty($editor, 'databaseSelectedRecordIndexes', ['animations' => 5]); /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); $history->undo(); - expect($animation->name)->toBe('Slash') - ->and(getEditorProperty($editor, 'databaseSelectedAnimationIndex'))->toBe(5); + expect($animation->get('name'))->toBe('Slash') + ->and(getEditorProperty($editor, 'databaseSelectedRecordIndexes'))->toBe(['animations' => 5]); }); diff --git a/tests/Unit/ListNavigationTest.php b/tests/Unit/ListNavigationTest.php index 97f66ac..e1f80be 100644 --- a/tests/Unit/ListNavigationTest.php +++ b/tests/Unit/ListNavigationTest.php @@ -112,28 +112,6 @@ } }); -it('wraps the animation frame list', function (): void { - $root = makeTemporaryProject(); - - try { - $editor = deletionEditor($root); - openDatabaseCategory($editor, 'animations'); - $animation = callEditorMethod($editor, 'getSelectedAnimation'); - $maxFrames = $animation->maxFrames; - - expect($maxFrames)->toBeGreaterThan(1) - ->and(getEditorProperty($editor, 'databaseSelectedFrameIndex'))->toBe(1); - - callEditorMethod($editor, 'moveDatabaseFrameSelection', -1); - expect(getEditorProperty($editor, 'databaseSelectedFrameIndex'))->toBe($maxFrames); - - callEditorMethod($editor, 'moveDatabaseFrameSelection', 1); - expect(getEditorProperty($editor, 'databaseSelectedFrameIndex'))->toBe(1); - } finally { - removeDirectoryRecursively($root); - } -}); - it('wraps option values when cycling past either end', function (): void { $root = makeTemporaryProject(); diff --git a/tests/Unit/ReferencePickerTest.php b/tests/Unit/ReferencePickerTest.php index b9c4593..b9fea4d 100644 --- a/tests/Unit/ReferencePickerTest.php +++ b/tests/Unit/ReferencePickerTest.php @@ -146,37 +146,3 @@ ->and($memberField)->not->toHaveKey('control'); }); -it('chooses and clears animation frame sounds from project SFX', function () { - $root = makeTemporaryProject(); - mkdir($root . '/assets/Audio/SFX', 0o777, true); - touch($root . '/assets/Audio/SFX/chime.wav'); - $editor = createEditorForTesting($root); - setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); - setEditorProperty($editor, 'lastTerminalSize', ['width' => 120, 'height' => 40]); - setEditorProperty($editor, 'isRunning', true); - openDatabaseCategory($editor, 'animations'); - - $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); - $index = array_search('frameSound', array_column($fields, 'field'), true); - expect($index)->toBeInt(); - $field = $fields[$index]; - expect($field['reference'] ?? null)->toBe('sfx') - ->and($field['allowsNone'] ?? false)->toBeTrue() - ->and($field)->not->toHaveKey('control'); - - setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); - setEditorProperty($editor, 'databaseFocus', 'database_settings'); - callEditorMethod($editor, 'dispatchInput', "\n"); - $picker = getEditorProperty($editor, 'referencePicker'); - expect($picker->matches())->toBe(['(No sound)', 'chime']); - - callEditorMethod($editor, 'dispatchInput', "\033[B"); - callEditorMethod($editor, 'dispatchInput', "\n"); - $animation = getEditorProperty($editor, 'workspace')->animationDatabase->getAnimations()[0]; - expect($animation->getCue(1)?->soundEffect)->toBe('chime'); - - callEditorMethod($editor, 'dispatchInput', "\n"); - callEditorMethod($editor, 'dispatchInput', "\033[A"); - callEditorMethod($editor, 'dispatchInput', "\n"); - expect($animation->getCue(1))->toBeNull(); -}); diff --git a/tests/Unit/Session/DatabaseSessionTest.php b/tests/Unit/Session/DatabaseSessionTest.php index 8616d85..1917f91 100644 --- a/tests/Unit/Session/DatabaseSessionTest.php +++ b/tests/Unit/Session/DatabaseSessionTest.php @@ -224,3 +224,13 @@ function databaseSessionRow(array $read, string $fieldId): array ->and(ProjectWorkspace::fromProject($root)->maps[0]->getLayerSymbol($map['baseLayer'], 1, 1))->toBe('#') ->and(loadRecordDatabase($root, 'states')->getEntryLabels())->toBe(['Venom', 'Stun']); }); + +it('serves animations as a schema category, its roles a list picked a member at a time', function () { + $root = makeTemporaryProject(); + file_put_contents($root . '/assets/Data/animations.php', " 1, 'name' => 'Hit Spark', 'roles' => ['attack'], 'targetEffect' => 'battle-physical-impact']];"); + $session = EditorSession::open($root); + $roles = databaseSessionRow($session->readDatabaseRecord('animations', 0), 'roles'); + + expect($session->listDatabaseRecords('animations')['records'])->toBe(['Hit Spark']) + ->and($roles)->toMatchArray(['kind' => 'reference', 'reference' => 'animation_roles', 'multi' => true, 'value' => 'attack']); +}); From f90bf118e2ac126dc93cf2cd25cf814c62084bd7 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 09:24:27 +0200 Subject: [PATCH 143/344] feat(session): compose a troop formation through the Engine's layout troops.formation now asks the Engine's BattleFormationLayout, the placement the battle itself uses, for the formation: the arenas and the previewed one's backgrounds, the starting party in its slots, and for each placed member where its feet stand, the bounds its idle art is drawn in, that art and its body span at battle scale. A placement the Engine refuses is reported on its member. --- src/Session/EditorSession.php | 93 +++++++++++++------ .../Session/TroopFormationSessionTest.php | 28 ++++-- 2 files changed, 84 insertions(+), 37 deletions(-) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 8baa497..b99d365 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -49,6 +49,9 @@ use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; use Ichiloto\Engine\Rendering\Presentation\PresentationWorld; use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; +use Ichiloto\Engine\Battle\Presentation\BattleFormationBattler; +use Ichiloto\Engine\Battle\Presentation\BattleFormationLayout; +use Ichiloto\Engine\Battle\Presentation\BattlerSlot; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasImage; use Closure; use InvalidArgumentException; @@ -1016,14 +1019,16 @@ public function listReferences(string $mapId, string $category): array } /** - * A troop's graphical formation as an arranger draws it: the battle - * canvas, the project's arenas with the one previewed and its background, - * the party's slots with the starting party's names, and each member's - * enemy and placement (null where the troop has none yet). The arena is a - * preview only, as in RPG Maker's Troops tab: a troop is placed once for - * every arena, and choosing one writes nothing. + * A troop's graphical formation as an arranger draws it, composed by the + * Engine's BattleFormationLayout, the placement the battle itself uses: + * the battle canvas, the project's arenas with the one previewed and its + * backgrounds, the starting party in its slots, and each member's enemy, + * battle placement and (once placed) where its feet stand, the bounds its + * idle art is drawn in, that art, and its body span at battle scale. The + * arena is a preview only, as in RPG Maker's Troops tab: a troop is placed + * once for every arena, and choosing one writes nothing. * - * @return array{assetRoot: string, canvas: array{width: int, height: int}, arenas: list, arena: ?string, background: ?array, party: list, members: list}>} + * @return array * @throws SessionRefusal When the troop is unknown or the project has no graphical battle. */ public function readTroopFormation(int $index, ?string $arena = null): array @@ -1036,39 +1041,73 @@ public function readTroopFormation(int $index, ?string $arena = null): array } catch (\Throwable $error) { throw new SessionRefusal('The battle presentation cannot be read: ' . $error->getMessage(), previous: $error); } - $layout = $catalog?->ui ?? throw new SessionRefusal('This project has no graphical battle layout to arrange troops on.'); - $arenas = []; - foreach ($catalog->arenas as $id => $definition) { - $arenas[] = ['id' => (string) $id, 'name' => $definition->name]; + if ($catalog?->ui === null) { + throw new SessionRefusal('This project has no graphical battle layout to arrange troops on.'); } - $arena ??= $catalog->defaultArena ?? array_key_first($catalog->arenas); - if ($arena !== null && ! isset($catalog->arenas[$arena])) { - throw new SessionRefusal(sprintf('There is no battle arena %s.', $arena)); + + $members = $placed = $placedMembers = []; + foreach ($record->getSubList('enemies') as $memberIndex => $entry) { + $enemy = is_array($entry) ? (string) ($entry['enemy'] ?? '') : ''; + $placement = is_array($entry) && is_array($entry['graphicalPlacement'] ?? null) ? $entry['graphicalPlacement'] : null; + $members[$memberIndex] = ['enemy' => $enemy, 'placement' => $placement, 'battler' => null]; + if ($placement === null) { + continue; + } + try { + $placed[] = ['enemyId' => $enemy, 'slot' => BattlerSlot::fromArray($placement, 'Battle Placement')]; + $placedMembers[] = $memberIndex; + } catch (\InvalidArgumentException $error) { + $members[$memberIndex]['issue'] = $error->getMessage(); + } } - $party = []; - $starting = $this->workspace->systemDatabase->getField('startingParty'); $names = []; foreach ($this->workspace->actorDatabase->getActors() as $actor) { $names[$actor->getDefinitionId()] = $actor->getName(); } - foreach ($layout->partySlots as $slotIndex => $slot) { - $member = is_array($starting) ? ($starting[$slotIndex] ?? null) : null; - $party[] = ['name' => is_string($member) ? ($names[$member] ?? $member) : '', 'x' => $slot->x, 'y' => $slot->y]; + $starting = $this->workspace->systemDatabase->getField('startingParty'); + $partyIds = array_values(array_slice(array_filter(is_array($starting) ? $starting : [], is_string(...)), 0, count($catalog->ui->partySlots))); + + try { + $formation = BattleFormationLayout::compose($catalog, $arena, $placed, $partyIds, $assetRoot); + } catch (\InvalidArgumentException|\RuntimeException $error) { + throw new SessionRefusal('The formation cannot be composed: ' . $error->getMessage(), previous: $error); } - $members = []; - foreach ($record->getSubList('enemies') as $entry) { - $placement = is_array($entry) ? ($entry['graphicalPlacement'] ?? null) : null; - $members[] = ['enemy' => is_array($entry) ? (string) ($entry['enemy'] ?? '') : '', 'placement' => is_array($placement) ? $placement : null]; + foreach ($formation->enemies as $position => $battler) { + $members[$placedMembers[$position]]['battler'] = self::describeFormationBattler($battler); } + $party = []; + foreach ($formation->party as $position => $battler) { + $party[] = ['name' => $names[$partyIds[$position]] ?? $partyIds[$position], ...self::describeFormationBattler($battler)]; + } + $arenas = []; + foreach ($formation->arenaChoices as $id => $name) { + $arenas[] = ['id' => (string) $id, 'name' => $name]; + } + $chosen = $formation->arena === null ? false : array_search($formation->arena, $catalog->arenas, true); return [ 'assetRoot' => $assetRoot, - 'canvas' => ['width' => $layout->width, 'height' => $layout->height], + 'canvas' => ['width' => $formation->layout->width, 'height' => $formation->layout->height], 'arenas' => $arenas, - 'arena' => $arena, - 'background' => $arena === null ? null : self::describeCanvasImage($catalog->arenas[$arena]->background), + 'arena' => $chosen === false ? null : (string) $chosen, + 'backgrounds' => array_map(self::describeCanvasImage(...), $formation->backgrounds), 'party' => $party, - 'members' => $members, + 'members' => array_values($members), + ]; + } + + /** @return array Where a battler stands and is drawn, its art and its body at battle scale. */ + private static function describeFormationBattler(BattleFormationBattler $battler): array + { + $bounds = $battler->bounds; + + return [ + 'ground' => ['x' => $battler->ground->x, 'y' => $battler->ground->y], + 'bounds' => ['x' => $bounds->x, 'y' => $bounds->y, 'width' => $bounds->width, 'height' => $bounds->height], + 'image' => $battler->image === null ? null : self::describeCanvasImage($battler->image), + 'bodySpan' => $battler->bodySpan, + 'horizontal' => $battler->horizontal, + 'diagnostics' => array_values(array_map(strval(...), $battler->diagnostics)), ]; } diff --git a/tests/Unit/Session/TroopFormationSessionTest.php b/tests/Unit/Session/TroopFormationSessionTest.php index 71e9850..857b163 100644 --- a/tests/Unit/Session/TroopFormationSessionTest.php +++ b/tests/Unit/Session/TroopFormationSessionTest.php @@ -43,27 +43,35 @@ function troopFormationProject(): string ], ]; PHP); + file_put_contents($root . '/assets/Data/system.php', " ['Kaelion']];"); + writeTilesetTestPng($root . '/assets/Graphics/Battlebacks/Cave.png', 8, 8); return $root; } -it('reads a troop formation over a previewed arena, the party in its slots and each member placed or not yet placed', function () { +it('reads a troop formation as the Engine composes it over a previewed arena, the party in its slots', function () { $session = EditorSession::open(troopFormationProject()); $formation = $session->readTroopFormation(0); + $leader = $formation['members'][0]; expect($formation['canvas'])->toBe(['width' => 1350, 'height' => 720]) ->and($formation['arenas'])->toBe([['id' => 'arena.road', 'name' => 'Road'], ['id' => 'arena.cave', 'name' => 'Cave']]) ->and($formation['arena'])->toBe('arena.cave') - ->and($formation['background'])->toMatchArray(['asset' => 'Graphics/Battlebacks/Cave.png', 'width' => 1350.0, 'height' => 720.0]) - ->and(array_map(static fn(array $slot): array => [$slot['x'], $slot['y']], $formation['party']))->toBe([[1050.0, 420.0], [1180.0, 560.0]]) - ->and($formation['members'])->toBe([ - ['enemy' => 'Regular Bat', 'placement' => ['x' => 260, 'y' => 300, 'width' => 275, 'height' => 190]], - ['enemy' => 'Regular Bat', 'placement' => null], - ]) - // The arena is only previewed: choosing another writes nothing. - ->and($session->readTroopFormation(0, 'arena.road')['background']['asset'])->toBe('Graphics/Battlebacks/Road.png') + ->and($formation['backgrounds'])->toHaveCount(1) + ->and($formation['backgrounds'][0])->toMatchArray(['asset' => 'Graphics/Battlebacks/Cave.png']) + // The party stands where the battle's slots put it, named for the starting party. + ->and($formation['party'])->toHaveCount(1) + ->and($formation['party'][0])->toMatchArray(['name' => 'Kaelion', 'ground' => ['x' => 1050.0, 'y' => 420.0]]) + // A placed member stands on its placement's ground point; without art it is drawn by its bounds alone. + ->and($leader['placement'])->toBe(['x' => 260, 'y' => 300, 'width' => 275, 'height' => 190]) + ->and($leader['battler'])->toMatchArray(['ground' => ['x' => 260.0, 'y' => 300.0], 'image' => null, 'bodySpan' => null]) + ->and($leader['battler']['diagnostics'])->toBe(['Base battler artwork unavailable: Regular Bat']) + ->and($formation['members'][1])->toBe(['enemy' => 'Regular Bat', 'placement' => null, 'battler' => null]) + // The arena is only previewed: choosing another writes nothing; a background with no file is left out. + ->and($session->readTroopFormation(0, 'arena.road')['backgrounds'])->toBe([]) + ->and($session->readTroopFormation(0, 'arena.road')['arena'])->toBe('arena.road') ->and($session->listUnsavedChanges())->toBe([]) - ->and(fn() => $session->readTroopFormation(0, 'arena.moon'))->toThrow(SessionRefusal::class, 'no battle arena arena.moon'); + ->and(fn() => $session->readTroopFormation(0, 'arena.moon'))->toThrow(SessionRefusal::class, 'arena.moon'); }); it('moves a member by its battle placement as one undo step, keeping the troop file\'s comments and the terminal position', function () { From a0c6d7a0468c37bd92334533d56e814f80733ab1 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 10:02:14 +0200 Subject: [PATCH 144/344] fix(database): keep numeric ids and one-record members unique on add and copy A new or duplicated record with a numeric identity now always takes the next number after the largest any record holds or held this session, so it never reuses an id something may still name. Adding or duplicating a record leaves out list members another record holds alone, such as an animation's roles: the copy starts without them and the original keeps them, so each role still plays exactly one animation. --- src/Database/ProjectRecordDatabase.php | 57 ++++++++++++++++++++++++-- tests/Unit/AnimationRolesTest.php | 32 +++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 830640d..0e6c6a0 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -33,6 +33,14 @@ final class ProjectRecordDatabase { use TracksPersistedState { isDirty as private isRecordStateDirty; } + /** + * Numeric identities of records removed this session, so a new record never + * takes one something may still name. + * + * @var list + */ + private array $retiredIdentities = []; + /** * @param RecordSchema $schema The category schema. * @param string $path The file or directory backing the category. @@ -697,6 +705,36 @@ public function setField(int $index, string $fieldId, string $rawValue): void } } + /** + * A new record's payload without the list members other records already + * hold, for every field held by one record at most. + * + * @param array $payload The record about to be added. + * @return array + */ + private function withoutClaimedMembers(array $payload): array + { + foreach ($this->schema->fieldsFor($payload) as $field) { + $members = $field->uniqueAcrossRecords ? ($payload[$field->key] ?? null) : null; + if (! is_array($members)) { + continue; + } + $held = []; + foreach ($this->getRecords() as $record) { + $theirs = $record->get($field->key); + $held = [...$held, ...(is_array($theirs) ? $theirs : [])]; + } + $free = array_values(array_diff($members, $held)); + if ($free === [] && $field->removeWhenEmpty) { + unset($payload[$field->key]); + } else { + $payload[$field->key] = $free; + } + } + + return $payload; + } + /** * Refuses a list member another record of the category already holds, * naming that record, before anything changes. @@ -797,6 +835,9 @@ public function addRecord(): ?int $payload[$identityKey] = $this->makeUniqueIdentity($payload[$identityKey]); $recordId = strval($payload[$identityKey]); } + if (is_array($payload)) { + $payload = $this->withoutClaimedMembers($payload); + } if ($this->schema->recordFilter !== null && ! ($this->schema->recordFilter)($payload)) { // Never append what the save merge would drop: a blank that is not @@ -851,6 +892,10 @@ public function removeRecord(int $index): ?ProjectRecord array_splice($records, $index, 1); $this->records = $records; $this->touchState(); + $identity = $record->get($this->schema->identityKey ?? 'id'); + if (is_int($identity)) { + $this->retiredIdentities[] = $identity; + } if ($record->sourcePath !== null && is_file($record->sourcePath)) { $this->stagedDeletions[$record->sourcePath] = $record->sourcePath; @@ -1011,6 +1056,9 @@ public function duplicateRecord(int $index): ?int $payload[$identityKey] = $this->makeUniqueIdentity($payload[$identityKey]); $recordId = strval($payload[$identityKey]); } + // A copy cannot hold what one record holds alone (an animation's + // roles): it starts without them, and the original keeps them. + $payload = $this->withoutClaimedMembers($payload); if ($this->schema->recordFilter !== null && ! ($this->schema->recordFilter)($payload)) { return null; @@ -3970,13 +4018,14 @@ private function makeUniqueIdentity(mixed $preferred): int|string if (is_int($preferred)) { // A numeric identity (an animation's id) is the next number after - // the largest any record holds, so it never reuses one. - $numbers = array_filter( + // the largest any record holds or held this session, so it never + // reuses one something may still name. + $numbers = [...array_filter( array_map(static fn(ProjectRecord $record): mixed => $record->get($identityKey), $this->getRecords()), is_int(...), - ); + ), ...$this->retiredIdentities]; - return in_array($preferred, $numbers, true) ? max($numbers) + 1 : $preferred; + return $numbers === [] ? $preferred : max($numbers) + 1; } $preferred = strval($preferred); diff --git a/tests/Unit/AnimationRolesTest.php b/tests/Unit/AnimationRolesTest.php index fb626b4..b9e8105 100644 --- a/tests/Unit/AnimationRolesTest.php +++ b/tests/Unit/AnimationRolesTest.php @@ -129,3 +129,35 @@ function roleIssues(string $root): array expect(array_filter(roleIssues($root), static fn(string $message): bool => str_contains($message, 'role')))->toBe([]); }); + +it('numbers a new animation after the largest id, even one deleted this session', function () { + $root = makeTemporaryProject(); + file_put_contents($root . '/assets/Data/animations.php', " 2, 'name' => 'Spark'], ['id' => 4, 'name' => 'Burst']];"); + $database = ProjectWorkspace::fromProject($root)->getRecordDatabase('animations'); + + $added = $database->addRecord(); + expect($database->getRecordByIndex($added)->get('id'))->toBe(5); + + $database->removeRecord($added); + $database->removeRecord(1); + $again = $database->addRecord(); + // 4 and 5 were deleted, and something may still name them. + expect($database->getRecordByIndex($again)->get('id'))->toBe(6); +}); + +it('copies an animation without the roles it holds alone, so each role still plays one animation', function () { + $root = makeTemporaryProject(); + writeRoleAnimations($root); + $database = ProjectWorkspace::fromProject($root)->getRecordDatabase('animations'); + + $copy = $database->duplicateRecord(0); + expect($database->getRecordByIndex($copy)->toArray())->toBe(['id' => 3, 'name' => 'Blade Slash', 'targetEffect' => 'battle-blade-slash']) + ->and($database->getRecordByIndex(0)->get('roles'))->toBe(['attack-sword']); + + $database->save(); + $saved = require $root . '/assets/Data/animations.php'; + $holders = array_filter($saved, static fn(array $entry): bool => in_array('attack-sword', $entry['roles'] ?? [], true)); + + expect($holders)->toHaveCount(1) + ->and(array_filter(roleIssues($root), static fn(string $message): bool => str_contains($message, 'bound to')))->toBe([]); +}); From cb1881edf5f732790f4619e98a22718ba8b06f73 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 10:18:10 +0200 Subject: [PATCH 145/344] fix(database): let comments block only a save that rewrites the file A data file with comments inside its data was read-only even when its entries are constructor calls the record database edits argument by argument, which keeps every comment. Comments now refuse only a save that would regenerate the returned expression; a file edited in place stays editable. --- src/Database/PhpDataFile.php | 31 +++++++++++++--- .../Unit/InventorySourcePreservationTest.php | 35 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/Database/PhpDataFile.php b/src/Database/PhpDataFile.php index 8032a48..d173218 100644 --- a/src/Database/PhpDataFile.php +++ b/src/Database/PhpDataFile.php @@ -28,8 +28,11 @@ * - every leaf of the returned value is a scalar, array, or enum case * (a `new Item(...)` payload cannot be regenerated without inventing * source, so those files are browsed, never written); and - * - any comment *inside* the returned expression sits in an array literal - * the source can be edited in, since a regeneration would drop it. + * - any comment *inside* the returned expression sits where the source is + * edited in place: plain data, or a list of constructor calls whose + * arguments the record database edits one at a time. A save that would + * instead regenerate the returned expression is refused, since it would + * drop the comment. * * When either fails the file reports a read-only reason instead, and the * editor surfaces that reason rather than risking the author's work. @@ -43,6 +46,7 @@ final class PhpDataFile * @param bool $exists Whether the file is present on disk. * @param string|null $readOnlyReason Why the file cannot be rewritten. * @param string|null $source The file's bytes as read, which a save edits. + * @param bool $hasInteriorComment Whether a comment sits inside the returned data. */ private function __construct( public readonly string $path, @@ -51,6 +55,7 @@ private function __construct( public readonly bool $exists, public readonly ?string $readOnlyReason, private ?string $source = null, + private bool $hasInteriorComment = false, ) { } @@ -490,7 +495,7 @@ public static function load(string $path, ?string $workingDirectory = null): sel return new self($path, $payload, $header, true, sprintf('%s contains %s', basename($path), $reason)); } - if ($hasInteriorComment && (self::holdsObject($payload) || self::parseArraySource($source) === null)) { + if ($hasInteriorComment && ! self::isEditedInPlace($source, $payload)) { return new self( $path, $payload, @@ -500,7 +505,20 @@ public static function load(string $path, ?string $workingDirectory = null): sel ); } - return new self($path, $payload, $header, true, null, $source); + return new self($path, $payload, $header, true, null, $source, $hasInteriorComment); + } + + /** + * Whether saves edit the file's own source rather than regenerate it: + * plain data, or a list of constructor calls edited argument by argument. + */ + private static function isEditedInPlace(string $source, mixed $payload): bool + { + if (! self::holdsObject($payload) && self::parseArraySource($source) !== null) { + return true; + } + + return array_filter(PhpSourceDocument::parse($source)->entryClasses(), static fn(string $class): bool => trim($class) !== '') !== []; } /** Whether a value holds an object anywhere, an enum case included. */ @@ -564,6 +582,11 @@ public function save(mixed $payload): void )); } $contents = ArraySourceWriter::rewrite($document, $this->payload, $payload)->source; + } elseif ($this->hasInteriorComment) { + throw new RuntimeException(sprintf( + 'Refusing to rewrite %s: it has comments inside its data, which rewriting it would drop. Edit the entries one value at a time, or edit the file directly.', + $this->path, + )); } else { $contents = $this->header . 'return ' . PhpValueExporter::export($payload) . ";\n"; } diff --git a/tests/Unit/InventorySourcePreservationTest.php b/tests/Unit/InventorySourcePreservationTest.php index d31e128..fc21c07 100644 --- a/tests/Unit/InventorySourcePreservationTest.php +++ b/tests/Unit/InventorySourcePreservationTest.php @@ -287,3 +287,38 @@ function inventoryDatabase(string $root, string $category): ProjectRecordDatabas expect($emptied->entryCount())->toBe(1) ->and($emptied->argumentSource(0, 'name'))->toBe("'Two'"); }); + +it('edits a constructor-call file with comments inside its data in place, keeping every comment', function () { + $root = makeTemporaryProject('ichiloto-source-'); + $path = $root . '/assets/Data/items.php'; + $source = <<<'PHP' +isEditable())->toBeTrue()->and($items->getReadOnlyReason())->toBeNull(); + + $items->setField(1, 'price', '95'); + $items->save(); + + expect((string) file_get_contents($path))->toBe(str_replace('price: 80', 'price: 95', $source)); +}); + +it('refuses to regenerate a commented file it cannot edit in place, rather than drop the comments', function () { + $root = makeTemporaryProject('ichiloto-source-'); + $path = $root . '/assets/Data/states.php'; + file_put_contents($path, " 'poison', 'name' => 'Poison']],\n);\n"); + $file = \Ichiloto\Editor\Database\PhpDataFile::load($path); + + expect($file->isEditable())->toBeFalse() + ->and($file->readOnlyReason)->toContain('comments inside its data'); +}); From 425e725fb85035d3f91c2329958e8d173c5dce7f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 11:07:52 +0200 Subject: [PATCH 146/344] feat(database): edit enemies as one data record per file The enemies category reads assets/Data/Enemies, each file returning ['class' => Enemy::class, 'data' => [...]], in the form the engine's EnemyRecord reads. Values are edited in the file's own source, action patterns name catalogue skills with conditions as data, speed and state resistances are now editable, and new or duplicated enemies get a file named after them. Per-file categories now list a file that cannot be read as a record, read-only with the reason, instead of leaving it out, and can duplicate a record into a file of its own. The guide no longer says items are not writable. --- docs/manual.md | 24 ++- .../guide/tooling-loop/the-database.md | 8 +- src/Database/ProjectRecord.php | 4 +- src/Database/ProjectRecordDatabase.php | 82 ++++++-- src/Database/RecordFieldCodec.php | 3 +- src/Database/RecordSchema.php | 4 + src/Database/RecordSchemaCatalog.php | 83 +++++--- tests/Unit/EnemyRecordFilesTest.php | 192 ++++++++++++++++++ 8 files changed, 344 insertions(+), 56 deletions(-) create mode 100644 tests/Unit/EnemyRecordFilesTest.php diff --git a/docs/manual.md b/docs/manual.md index 6c8a606..082feec 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1015,7 +1015,7 @@ status line says exactly why. | Items | `assets/Data/items.php` | Editable - authored as `new Item(...)` calls, edited entry by entry | | Weapons | `assets/Data/items.php` | Editable - authored as `new Weapon(...)` calls, edited entry by entry | | Armors | `assets/Data/items.php` | Editable - authored as `new Armor(...)` calls, edited entry by entry | -| Enemies | `assets/Data/enemies.php` | Editable - authored as `new Enemy(...)` calls, edited entry by entry | +| Enemies | `assets/Data/Enemies/*.php` | Editable - one data record per file, which `enemies.php` loads | | Troops | `assets/Data/troops.php` | Editable | | Battle Entry | `assets/Data/battle-entry-rules.php` | Editable | | States | `assets/Data/states.php` | Editable | @@ -1044,8 +1044,8 @@ its battle rewards, and its element affinities. ### How A File Of Constructor Calls Is Written -`items.php` and `enemies.php` are not data, they are PHP code that *builds* -data: `new Item(...)` and `new Enemy(...)` calls with named arguments, +`items.php` is not data, it is PHP code that *builds* data: `new Item(...)`, +`new Weapon(...)` and `new Armor(...)` calls with named arguments, imports, comments, and enum expressions the author chose. Regenerating such a file from loaded values would reorder arguments, spell out defaults nobody wrote, and rewrite every entry to change one. So the editor does not @@ -1062,8 +1062,8 @@ regenerate it. It edits the author's own source, entry by entry: ahead of the entry that follows it in the list - so the file reads in the order the editor does. -Every entry is found by the identity it declares - an item's stable id, an -enemy's name - looked up in a fresh reading of the file at the moment of +Every entry is found by the identity it declares - an item's stable id - +looked up in a fresh reading of the file at the moment of writing, never by where it happened to sit when it was loaded. That is what lets three categories share one file: Items, Weapons and Armors are three views of `items.php`, and saving one of them, or all of them with `Ctrl+A`, @@ -1087,6 +1087,20 @@ data, keeping everything from ` Enemy::class, 'data' => [...]]` with plain values: name, +level, sprite, stats, rewards, element affinities, state resistances, its +knowledge subject, and action patterns that name a skill from the skill +catalogue with a rating and an optional condition. `enemies.php` is the +barrel that loads the folder, so the game and every other reader get the +same enemies. Editing a value changes only that value in that enemy's file, +keeping its imports and comments. A new enemy gets a file named after it +and starts on the project's first enemy sprite; a duplicate gets a file and +a name of its own. A file in the folder that is not an enemy record stays +in the list, read-only, saying why. + ### Project-Owned Parameters A weapon or armor's **Special Property** parameters and a Permanent Growth diff --git a/docs/website/guide/tooling-loop/the-database.md b/docs/website/guide/tooling-loop/the-database.md index 6938265..41e1780 100644 --- a/docs/website/guide/tooling-loop/the-database.md +++ b/docs/website/guide/tooling-loop/the-database.md @@ -60,13 +60,11 @@ The types are `quest`, `switch`, `event`, `item`, `key_item`, and `variable`. Un Whether a category can be written is *detected*, not assumed. When a category loads, the editor asks two questions about its file: can every value be written back out losslessly, and would a rewrite drop a comment? Only when both answers are safe does the category accept edits. -Editable today: Actors, Classes, Skills, Troops, States, Animations, Quests, Skits, Common Events, System, and Terms. +Editable today: Actors, Classes, Skills, Items, Weapons, Armors, Enemies, Troops, States, Animations, Quests, Skits, Common Events, System, and Terms. -Browsable but not writable: Items, Weapons, Armors, and Enemies. +Enemies are one data file each under `assets/Data/Enemies`, and `enemies.php` loads them. An enemy's action patterns name skills from your skill catalogue, so you pick the skill rather than rebuild it. -The reason is worth understanding, because it is a property of your project rather than a missing feature. `items.php` and `enemies.php` are not data files — they are PHP that *builds* data, constructing effect objects inline and sharing skills between enemies through local variables. Regenerating such a file from the values the editor loaded would mean inventing source, and anything the editor did not recognise would be silently lost. - -So the editor shows you the values and refuses to write. An enemy displays its level, every stat, its sprite, its battle rewards, and its element affinities — you simply edit the file itself when you want to change them. +`items.php` is PHP that *builds* data with `new Item(...)`, `new Weapon(...)` and `new Armor(...)` calls. The editor never regenerates it: a change is patched into the argument it belongs to, so your imports, comments and the other entries stay exactly as you wrote them. Re-author one of those files as a plain array and the editor picks it up as editable automatically. Nothing in the editor needs to change. diff --git a/src/Database/ProjectRecord.php b/src/Database/ProjectRecord.php index 25745b2..e8bd676 100644 --- a/src/Database/ProjectRecord.php +++ b/src/Database/ProjectRecord.php @@ -28,6 +28,7 @@ final class ProjectRecord * @param string|null $sourcePath The file backing this record, for one-file-per-record categories. * @param string $recordId The record identity, when it comes from the filename rather than the payload. * @param PhpDataFile|null $file The loaded source file, for one-file-per-record categories. + * @param string|null $loadProblem Why the file could not be read as a record, which leaves it listed but read-only. */ public function __construct( private array|object $payload, @@ -35,6 +36,7 @@ public function __construct( public readonly ?string $sourcePath = null, public readonly string $recordId = '', public readonly ?PhpDataFile $file = null, + private readonly ?string $loadProblem = null, ) { if (! $isDirty) { $this->captureBaseline(); @@ -67,7 +69,7 @@ public function getReadOnlyReason(): ?string ); } - return $this->file?->readOnlyReason; + return $this->loadProblem ?? $this->file?->readOnlyReason; } /** diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 0e6c6a0..9ff2dc6 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -826,7 +826,8 @@ public function addRecord(): ?int return null; } - if (! is_object($payload)) { + // A data record's blank is its data; a constructor list's, the object. + if ($this->schema->recordClass !== null ? ! is_array($payload) : ! is_object($payload)) { return null; } } @@ -850,13 +851,7 @@ public function addRecord(): ?int $file = null; if ($this->schema->storage === RecordStorage::DIRECTORY) { - $recordId = $recordId !== '' ? $recordId : $this->makeUniqueFileStem('new-' . str_replace(' ', '-', $this->schema->entryNoun)); - $sourcePath = $this->path . DIRECTORY_SEPARATOR . $recordId . '.php'; - $file = PhpDataFile::load($sourcePath); - - if ($this->schema->listPayloadKey !== null) { - $payload['__scriptId'] = $recordId; - } + [$recordId, $sourcePath, $file, $payload] = $this->prepareRecordFile($recordId, $payload); } // A record the file has never held: it has no authored identity or @@ -1022,23 +1017,23 @@ public function moveRecord(int $from, int $to): bool public function duplicateRecordSupported(): bool { return $this->isEditable() - && $this->schema->storage === RecordStorage::LIST_FILE + && in_array($this->schema->storage, [RecordStorage::LIST_FILE, RecordStorage::DIRECTORY], true) && ! $this->isConstructorAuthored(); } /** * Duplicates a record below itself, with a fresh unique identity. * - * Only list-file categories duplicate: a per-file or object-backed - * record's copy would need its own source decisions, which nothing - * requires yet. + * List-file and per-file categories duplicate; a per-file copy gets a + * file of its own. Object-backed records' copies would need their own + * source decisions, which nothing requires yet. * * @param int $index The record to copy. * @return int|null The copy's index, or null when nothing was copied. */ public function duplicateRecord(int $index): ?int { - if (! $this->isEditable() || $this->schema->storage !== RecordStorage::LIST_FILE) { + if (! $this->duplicateRecordSupported()) { return null; } @@ -1046,7 +1041,7 @@ public function duplicateRecord(int $index): ?int $source = $records[$index] ?? null; $payload = $source?->toArray(); - if (! $source instanceof ProjectRecord || ! is_array($payload)) { + if (! $source instanceof ProjectRecord || ! $source->isEditable() || ! is_array($payload)) { return null; } $identityKey = $this->schema->identityKey; @@ -1066,7 +1061,14 @@ public function duplicateRecord(int $index): ?int // A record the file has never held: no authored identity or values, // which is what tells the source writer to insert it. - $copy = new ProjectRecord($payload, true, null, $recordId); + $sourcePath = null; + $file = null; + + if ($this->schema->storage === RecordStorage::DIRECTORY) { + [$recordId, $sourcePath, $file, $payload] = $this->prepareRecordFile($recordId !== '' ? $recordId : $source->recordId, $payload); + } + + $copy = new ProjectRecord($payload, true, $sourcePath, $recordId, $file); array_splice($records, $index + 1, 0, [$copy]); $this->records = $records; $this->touchState(); @@ -1389,6 +1391,28 @@ private static function loadDirectory(string $path, RecordSchema $schema, ?strin ]; } + $problem = $payload === null ? $file->readOnlyReason : null; + + if ($problem === null && $schema->recordClass !== null) { + if (is_array($payload) && is_array($payload['data'] ?? null) + && ltrim(strval($payload['class'] ?? ''), '\\') === $schema->recordClass) { + $payload = $payload['data']; + } else { + $problem = sprintf( + "%s does not return ['class' => %s::class, 'data' => [...]]", + basename($filename), + substr(strrchr('\\' . $schema->recordClass, '\\') ?: '', 1), + ); + } + } + + if ($problem !== null) { + // A file that cannot be read as a record stays in the list, + // read-only with the reason, rather than vanishing from it. + $records[] = new ProjectRecord([$schema->labelKey => $stem], false, $filename, $stem, $file, $problem); + continue; + } + if (! is_array($payload)) { continue; } @@ -2377,6 +2401,10 @@ private function saveDirectory(): void $payload = array_values((array) ($payload[$this->schema->listPayloadKey] ?? [])); } + if ($this->schema->recordClass !== null) { + $payload = ['class' => $this->schema->recordClass, 'data' => $payload]; + } + $file->save($payload); } @@ -2929,7 +2957,7 @@ private static function coerce(RecordField $field, string $rawValue): mixed $parts = array_map(trim(...), explode(',', $trimmed)); if (count($parts) !== 2 || ! is_numeric($parts[0]) || ! is_numeric($parts[1])) { - throw new \InvalidArgumentException(sprintf('%s must be two numbers, x and y, separated by a comma.', $field->label)); + throw new \InvalidArgumentException(sprintf('%s must be two whole numbers separated by a comma.', $field->label)); } return [intval($parts[0]), intval($parts[1])]; @@ -4044,6 +4072,28 @@ private function makeUniqueIdentity(mixed $preferred): int|string return $candidate; } + /** + * Chooses the file a new per-file record is written to: named after its + * identity, or after the category when it has none, and never an existing + * file. + * + * @param string $identity The record's identity, or an empty string. + * @param array|object $payload The record payload. + * @return array{0: string, 1: string, 2: PhpDataFile, 3: array|object} The file stem, + * path, (not yet written) file and payload. + */ + private function prepareRecordFile(string $identity, array|object $payload): array + { + $stem = $this->makeUniqueFileStem(Slug::of($identity) ?: 'new-' . Slug::of($this->schema->entryNoun)); + $sourcePath = $this->path . DIRECTORY_SEPARATOR . $stem . '.php'; + + if ($this->schema->listPayloadKey !== null && is_array($payload)) { + $payload['__scriptId'] = $stem; + } + + return [$stem, $sourcePath, PhpDataFile::load($sourcePath), $payload]; + } + /** * Returns a file stem not already present in the category directory. * diff --git a/src/Database/RecordFieldCodec.php b/src/Database/RecordFieldCodec.php index e45f17b..656c366 100644 --- a/src/Database/RecordFieldCodec.php +++ b/src/Database/RecordFieldCodec.php @@ -47,7 +47,8 @@ enum RecordFieldCodec: string case LINES = 'lines'; /** - * A point stored as `[x, y]`, shown as `x, y`. + * Two whole numbers stored as `[first, second]`, shown as `first, second`: + * a staged position's `[x, y]`, or a condition's `[minimum, maximum]`. */ case POINT = 'point'; diff --git a/src/Database/RecordSchema.php b/src/Database/RecordSchema.php index 7776be3..09cd4d9 100644 --- a/src/Database/RecordSchema.php +++ b/src/Database/RecordSchema.php @@ -44,6 +44,9 @@ * both exclusions, but they are not picked from the same list. * @param Closure(array): string|null $labelFor The entry * label, when a record's name is made of its parts rather than stored. + * @param string|null $recordClass For one-file-per-record categories whose + * files return `['class' => $recordClass, 'data' => [...]]`: the record is + * the data, and saves write it back inside the same envelope. */ public function __construct( public string $key, @@ -65,6 +68,7 @@ public function __construct( public ?RecordProjection $projection = null, public ?Closure $fieldsFor = null, public ?Closure $labelFor = null, + public ?string $recordClass = null, ) { } diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 9745d24..7298cb9 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -28,6 +28,8 @@ use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; use Ichiloto\Engine\Entities\Enemies\Enemy; +use Ichiloto\Engine\Entities\Enemies\EnemyCatalog; +use Ichiloto\Engine\Entities\Enumerations\ActionConditionType; use Ichiloto\Engine\Entities\Enumerations\ItemUserType; use Ichiloto\Engine\Progress\Knowledge\KnowledgeProgressService; use Ichiloto\Engine\Entities\Inventory\Accessory; @@ -39,8 +41,6 @@ use Ichiloto\Engine\Entities\Enumerations\WeaponType; use Ichiloto\Engine\Entities\Character; use Ichiloto\Engine\Entities\ParameterChanges; -use Ichiloto\Engine\Entities\Stats; -use Ichiloto\Engine\Battle\BattleRewards; /** * The schemas behind the Database categories added in Phase 6. @@ -49,8 +49,8 @@ * when the file loads (see `PhpDataFile`). What these schemas declare is * where records live, what an entry is called, and which fields the settings * pane shows. Categories whose authored files are PHP constructor calls - * (`items.php`, `enemies.php`) still get real field lists, because browsing - * an enemy's stats is useful even when the editor refuses to rewrite them. + * (`items.php`) still get real field lists, because browsing an item is + * useful even when the editor refuses to rewrite it. */ final class RecordSchemaCatalog { @@ -820,11 +820,13 @@ private static function optimizeExclusions(): RecordSchema } /** - * Enemies — `assets/Data/enemies.php`. + * Enemies — one record per file under `assets/Data/Enemies`, the form the + * engine's `EnemyRecord` reads. Each file returns + * `['class' => Enemy::class, 'data' => [...]]`; `enemies.php` is the + * barrel that loads them. * - * The file builds `new Enemy(...)` objects and shares skill instances - * between them through local variables, so it cannot be regenerated from - * the loaded values. Edited by rebuilding the entry. + * Action patterns name skills in the project's skill catalogue, so a + * pattern is edited by picking the skill rather than rebuilding it. * * @return RecordSchema */ @@ -833,32 +835,54 @@ private static function enemies(): RecordSchema return new RecordSchema( key: 'enemies', entryNoun: 'enemy', - storage: RecordStorage::LIST_FILE, - relativePath: 'assets/Data/enemies.php', + storage: RecordStorage::DIRECTORY, + relativePath: 'assets/Data/' . EnemyCatalog::DIRECTORY, fields: [ - new RecordField('name', 'Name'), + new RecordField('name', 'Name', uniqueAcrossRecords: true), new RecordField('level', 'Level', InputControlType::INTEGER), RecordField::reference('imagePath', 'Sprite', 'enemy_sprites'), - new RecordField('stats.totalHp', 'HP', InputControlType::INTEGER), - new RecordField('stats.totalMp', 'MP', InputControlType::INTEGER), + new RecordField('stats.maxHp', 'Max HP', InputControlType::INTEGER), + new RecordField('stats.maxMp', 'Max MP', InputControlType::INTEGER), new RecordField('stats.attack', 'Attack', InputControlType::INTEGER), new RecordField('stats.defence', 'Defence', InputControlType::INTEGER), new RecordField('stats.magicAttack', 'Magic Attack', InputControlType::INTEGER), new RecordField('stats.magicDefence', 'Magic Defence', InputControlType::INTEGER), + new RecordField('stats.speed', 'Speed', InputControlType::INTEGER), new RecordField('stats.grace', 'Grace', InputControlType::INTEGER), new RecordField('stats.evasion', 'Evasion', InputControlType::INTEGER), new RecordField('rewards.experience', 'Reward EXP', InputControlType::INTEGER), new RecordField('rewards.gold', 'Reward Gold', InputControlType::INTEGER), - new RecordField('elementAffinities', 'Element Affinities', codec: RecordFieldCodec::AFFINITIES), - new RecordField('actionPatterns', 'Action Patterns', isReadOnly: true), + new RecordField('elementAffinities', 'Element Affinities', removeWhenEmpty: true, codec: RecordFieldCodec::AFFINITIES), + new RecordField('stateResistances', 'State Resistances', removeWhenEmpty: true, codec: RecordFieldCodec::KEY_VALUES), + RecordField::reference('knowledgeSubjectId', 'Knowledge Subject', 'knowledge_subjects', allowsNone: true), ], labelKey: 'name', identityKey: 'name', - recordFilter: static fn(mixed $entry): bool => $entry instanceof Enemy, - // An Enemy loads its sprite in its constructor, so a blank needs a - // real file to point at; a project with no enemy sprites cannot - // author an enemy yet, and creation refuses rather than crashing. - makeBlank: static function (string $name, string $projectRoot): ?object { + subList: new RecordSubList( + key: 'actionPatterns', + prefix: 'pattern', + singular: 'action pattern', + fields: [ + RecordField::reference('skill', 'Skill', 'skills'), + new RecordField('rating', 'Rating', InputControlType::INTEGER), + new RecordField( + 'condition.type', + 'Condition', + options: array_map(static fn(ActionConditionType $type): string => $type->value, ActionConditionType::cases()), + removeWhenEmpty: true, + displayDefault: ActionConditionType::ALWAYS->value, + ), + new RecordField('condition.range', 'Range (min, max)', removeWhenEmpty: true, codec: RecordFieldCodec::POINT), + new RecordField('condition.a', 'Condition A', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('condition.b', 'Condition B', InputControlType::INTEGER, removeWhenEmpty: true), + ], + blank: ['skill' => '', 'rating' => 5], + ), + // A new enemy needs a real sprite to load, so it starts on the + // project's first one; a project with no enemy sprites cannot + // author an enemy yet, and creation refuses rather than writing a + // record the engine would reject. + makeBlank: static function (string $name, string $projectRoot): ?array { $spriteDirectory = rtrim($projectRoot, DIRECTORY_SEPARATOR) . '/assets/Graphics/Enemies'; $sprites = is_dir($spriteDirectory) ? array_values(array_filter( @@ -873,17 +897,20 @@ private static function enemies(): RecordSchema return null; } - return new Enemy( - $name, - 1, - new Stats(currentHp: 10, attack: 5, defence: 5, speed: 5), + return [ + 'name' => $name, + 'level' => 1, // graphics() appends the extension itself, so the stem is // what an imagePath stores. - pathinfo($sprites[0], PATHINFO_FILENAME), - new BattleRewards(1, 1, []), - [], - ); + 'imagePath' => pathinfo($sprites[0], PATHINFO_FILENAME), + 'stats' => [ + 'maxHp' => 10, 'maxMp' => 10, 'attack' => 5, 'defence' => 5, 'magicAttack' => 5, + 'magicDefence' => 5, 'speed' => 5, 'grace' => 1, 'evasion' => 0, + ], + 'rewards' => ['experience' => 1, 'gold' => 1], + ]; }, + recordClass: Enemy::class, ); } diff --git a/tests/Unit/EnemyRecordFilesTest.php b/tests/Unit/EnemyRecordFilesTest.php new file mode 100644 index 0000000..2821abb --- /dev/null +++ b/tests/Unit/EnemyRecordFilesTest.php @@ -0,0 +1,192 @@ + $records File contents keyed by file name. + */ +function enemyRecordProject(array $records = []): string +{ + $root = rememberTemporaryProject(sys_get_temp_dir() . '/ichiloto-enemy-records-' . bin2hex(random_bytes(4))); + mkdir($root . '/assets/Data/Enemies', 0777, true); + mkdir($root . '/assets/Graphics/Enemies', 0777, true); + file_put_contents($root . '/assets/Graphics/Enemies/blob.txt', "(o)\n"); + file_put_contents($root . '/assets/Graphics/Enemies/wisp.txt', "~*~\n"); + file_put_contents($root . '/assets/Data/skills.php', " $contents) { + file_put_contents($root . '/assets/Data/Enemies/' . $file, $contents); + } + + return $root; +} + +/** A record file as the conversion and an author write it: the class named through its import. */ +function blobRecordFile(): string +{ + return <<<'PHP' + Enemy::class, + 'data' => [ + 'name' => 'Blob', + 'level' => 2, + 'imagePath' => 'blob', + 'stats' => [ + 'maxHp' => 40, + 'maxMp' => 0, + 'attack' => 9, + 'defence' => 4, + 'magicAttack' => 3, + 'magicDefence' => 5, + 'speed' => 7, + 'grace' => 2, + 'evasion' => 1, + ], + 'rewards' => [ + 'experience' => 12, + 'gold' => 30, + ], + 'actionPatterns' => [ + [ + 'skill' => 'Nip', + 'rating' => 5, + ], + ], + 'elementAffinities' => [ + 'Fire' => 2.0, + ], + ], + ]; + + PHP; +} + +/** Reads the project's enemies the way the game does, from its own directory. */ +function loadEnemyCatalog(string $root): EnemyCatalog +{ + $previous = (string) getcwd(); + chdir($root); + + try { + return EnemyCatalog::load($root . '/assets'); + } finally { + chdir($previous); + } +} + +function enemiesDatabase(string $root): ProjectRecordDatabase +{ + return ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('enemies')); +} + +it('lists each enemy record file as an editable record of its data', function () { + $database = enemiesDatabase(enemyRecordProject(['blob.php' => blobRecordFile()])); + $record = $database->getRecords()[0]; + + expect($database->getEntryLabels())->toBe(['Blob']) + ->and($database->isEditable())->toBeTrue() + ->and($record->isEditable())->toBeTrue() + ->and($record->get('stats.maxHp'))->toBe(40) + ->and($record->get('actionPatterns'))->toBe([['skill' => 'Nip', 'rating' => 5]]); +}); + +it('edits a value in its own source, keeping the envelope, import and comment', function () { + $root = enemyRecordProject(['blob.php' => blobRecordFile()]); + $database = enemiesDatabase($root); + + $database->setField(0, 'stats.maxHp', '55'); + $database->setField(0, 'stats.speed', '9'); + $database->save(); + + expect(file_get_contents($root . '/assets/Data/Enemies/blob.php'))->toBe(str_replace( + ["'maxHp' => 40,", "'speed' => 7,"], + ["'maxHp' => 55,", "'speed' => 9,"], + blobRecordFile(), + )) + ->and(loadEnemyCatalog($root)->findEnemy('Blob')?->stats->totalHp)->toBe(55); +}); + +it('authors action patterns by naming catalogue skills and conditions as data', function () { + $root = enemyRecordProject(['blob.php' => blobRecordFile()]); + $database = enemiesDatabase($root); + + $entry = $database->addSubItem(0); + $database->setField(0, ProjectRecordDatabase::subFieldId('pattern', $entry, 'skill'), 'Brace'); + $database->setField(0, ProjectRecordDatabase::subFieldId('pattern', $entry, 'rating'), '7'); + $database->setField(0, ProjectRecordDatabase::subFieldId('pattern', $entry, 'condition.type'), 'HP'); + $database->setField(0, ProjectRecordDatabase::subFieldId('pattern', $entry, 'condition.range'), '0, 50'); + $database->save(); + + $pattern = loadEnemyCatalog($root)->findEnemy('Blob')?->actionPatterns[1]; + + expect($pattern?->skill->name)->toBe('Brace') + ->and($pattern?->rating)->toBe(7) + ->and($pattern?->condition->type)->toBe(ActionConditionType::HP) + ->and([$pattern?->condition->range->min, $pattern?->condition->range->max])->toBe([0, 50]); +}); + +it('creates a new enemy in a file of its own that the game loads', function () { + $root = enemyRecordProject(['blob.php' => blobRecordFile()]); + $database = enemiesDatabase($root); + + $index = $database->addRecord(); + $database->save(); + $catalog = loadEnemyCatalog($root); + + expect($index)->toBe(1) + ->and(is_file($root . '/assets/Data/Enemies/new-enemy.php'))->toBeTrue() + ->and($catalog->getProblems())->toBe([]) + ->and($catalog->getSourceFile('New Enemy'))->toBe('Enemies/new-enemy.php') + ->and($catalog->findEnemy('New Enemy')?->imagePath)->toBe('blob'); +}); + +it('refuses to create an enemy while the project has no enemy sprite to give it', function () { + $root = enemyRecordProject(); + array_map(unlink(...), glob($root . '/assets/Graphics/Enemies/*') ?: []); + + expect(enemiesDatabase($root)->addRecord())->toBeNull(); +}); + +it('duplicates an enemy into its own file under a name of its own', function () { + $root = enemyRecordProject(['blob.php' => blobRecordFile()]); + $database = enemiesDatabase($root); + + $copy = $database->duplicateRecord(0); + $database->save(); + $catalog = loadEnemyCatalog($root); + + expect($copy)->toBe(1) + ->and($catalog->getProblems())->toBe([]) + ->and($catalog->getSourceFile('Blob-2'))->toBe('Enemies/blob-2.php') + ->and($catalog->findEnemy('Blob-2')?->stats->totalHp)->toBe(40) + ->and(file_get_contents($root . '/assets/Data/Enemies/blob.php'))->toBe(blobRecordFile()); +}); + +it('keeps a file that is not an enemy record listed, read-only with the reason', function () { + $database = enemiesDatabase(enemyRecordProject([ + 'blob.php' => blobRecordFile(), + 'stray.php' => " 'Stray'];\n", + ])); + [$blob, $stray] = $database->getRecords(); + + expect($database->getEntryLabels())->toBe(['Blob', 'stray']) + ->and($blob->isEditable())->toBeTrue() + ->and($stray->isEditable())->toBeFalse() + ->and($stray->getReadOnlyReason())->toBe("stray.php does not return ['class' => Enemy::class, 'data' => [...]]") + ->and($database->duplicateRecord(1))->toBeNull(); +}); From da5fde1257517a0bec518df30f8850e4531de759 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 11:20:24 +0200 Subject: [PATCH 147/344] feat(session): preview an enemy as its terminal sprite and at battle scale enemies.preview reads the open enemy's sprite by the game's rule, from the record as it is now, and composes its battle art with the starting party through BattleFormationLayout, standing opposite the lead party member for comparison. Troop formations and the preview share one composition. A project without a graphical battle still gets the sprite. --- src/Session/EditorSession.php | 116 +++++++++++++++--- src/Session/SessionHost.php | 2 + tests/Pest.php | 2 + tests/Support/BattleFormationFixtures.php | 47 +++++++ .../Unit/Session/EnemyPreviewSessionTest.php | 67 ++++++++++ .../Session/TroopFormationSessionTest.php | 44 ------- 6 files changed, 218 insertions(+), 60 deletions(-) create mode 100644 tests/Support/BattleFormationFixtures.php create mode 100644 tests/Unit/Session/EnemyPreviewSessionTest.php diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index b99d365..5260609 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -7,6 +7,7 @@ use Ichiloto\Editor\Backup\BackupSettings; use Ichiloto\Editor\Backup\BackupWriter; use Ichiloto\Editor\Canvas\CanvasEditor; +use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\Canvas\PieceRole; use Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal; use Ichiloto\Editor\Database\ConditionCodec; @@ -1035,15 +1036,7 @@ public function readTroopFormation(int $index, ?string $arena = null): array { $record = $this->requireRecordDatabase('troops')->getRecordByIndex($index) ?? throw new SessionRefusal(sprintf('troops has no record %d.', $index)); - $assetRoot = $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; - try { - $catalog = BattlePresentationCatalog::load($assetRoot); - } catch (\Throwable $error) { - throw new SessionRefusal('The battle presentation cannot be read: ' . $error->getMessage(), previous: $error); - } - if ($catalog?->ui === null) { - throw new SessionRefusal('This project has no graphical battle layout to arrange troops on.'); - } + $catalog = $this->requireBattleLayoutCatalog('arrange troops on'); $members = $placed = $placedMembers = []; foreach ($record->getSubList('enemies') as $memberIndex => $entry) { @@ -1060,6 +1053,101 @@ public function readTroopFormation(int $index, ?string $arena = null): array $members[$memberIndex]['issue'] = $error->getMessage(); } } + [$formation, $view] = $this->composeFormation($catalog, $arena, $placed); + foreach ($formation->enemies as $position => $battler) { + $members[$placedMembers[$position]]['battler'] = self::describeFormationBattler($battler); + } + + return [...$view, 'members' => array_values($members)]; + } + + /** + * An enemy as Database > Enemies shows it: its terminal sprite, read the + * way the game reads it, and its battle art composed by the Engine's + * BattleFormationLayout at battle scale beside the starting party. Battle + * layouts define where the party stands, not enemies (troops place those), + * so the enemy stands opposite the lead party member, its slot mirrored + * across the canvas: a size comparison, not a battle position. Both read + * the record as it is now, unsaved edits included. A project without a + * graphical battle still gets the sprite, with the reason there is no art. + * + * @return array + * @throws SessionRefusal When the enemy is unknown. + */ + public function readEnemyPreview(int $index, ?string $arena = null): array + { + $record = $this->requireRecordDatabase('enemies')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('enemies has no record %d.', $index)); + $name = trim((string) $record->get('name')); + $preview = ['name' => $name, 'sprite' => $this->readEnemySprite($record->get('imagePath')), 'formation' => null, 'formationIssue' => null]; + + try { + $catalog = $this->requireBattleLayoutCatalog('preview enemies at battle scale on'); + $lead = $catalog->ui->partySlots[0] ?? throw new SessionRefusal('The battle layout has no party slot to stand an enemy beside.'); + $slot = new BattlerSlot($catalog->ui->width - $lead->x, $lead->y, $lead->width, $lead->height); + [$formation, $view] = $this->composeFormation($catalog, $arena, [['enemyId' => $name, 'slot' => $slot]]); + $preview['formation'] = [...$view, 'members' => [[ + 'enemy' => $name, + 'placement' => null, + 'battler' => self::describeFormationBattler($formation->enemies[0]), + ]]]; + } catch (SessionRefusal $refusal) { + $preview['formationIssue'] = $refusal->getMessage(); + } + + return $preview; + } + + /** + * An enemy's terminal sprite rows, read by the game's own rule + * (`Graphics/Enemies/.txt`), never from outside that folder. + * + * @return array{lines?: list, issue?: string} + */ + private function readEnemySprite(mixed $imagePath): array + { + if (! is_string($imagePath) || trim($imagePath) === '') { + return ['issue' => 'No sprite is chosen.']; + } + $root = $this->workspace->projectRoot; + $folder = realpath($root . '/assets/Graphics/Enemies'); + $file = realpath($root . '/assets/Graphics/Enemies/' . $imagePath . '.txt'); + if ($folder === false || $file === false || ! str_starts_with($file, $folder . DIRECTORY_SEPARATOR)) { + return ['issue' => sprintf('Graphics/Enemies/%s.txt is not a sprite in this project.', $imagePath)]; + } + + return ['lines' => array_values(array_map(strval(...), (array) ProjectDirectoryContext::run( + $root, + static fn(): array => (array) graphics('Enemies/' . $imagePath), + )))]; + } + + /** The project's battle presentation, when it lays battles out on a graphical canvas. */ + private function requireBattleLayoutCatalog(string $purpose): BattlePresentationCatalog + { + try { + $catalog = BattlePresentationCatalog::load($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); + } catch (\Throwable $error) { + throw new SessionRefusal('The battle presentation cannot be read: ' . $error->getMessage(), previous: $error); + } + if ($catalog?->ui === null) { + throw new SessionRefusal(sprintf('This project has no graphical battle layout to %s.', $purpose)); + } + + return $catalog; + } + + /** + * Composes enemies with the starting party over an arena, and describes + * what every formation view shares: the canvas, the arenas with the one + * previewed and its backgrounds, and the party in its slots. + * + * @param list $enemies + * @return array{0: BattleFormationLayout, 1: array} + */ + private function composeFormation(BattlePresentationCatalog $catalog, ?string $arena, array $enemies): array + { + $assetRoot = $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; $names = []; foreach ($this->workspace->actorDatabase->getActors() as $actor) { $names[$actor->getDefinitionId()] = $actor->getName(); @@ -1068,13 +1156,10 @@ public function readTroopFormation(int $index, ?string $arena = null): array $partyIds = array_values(array_slice(array_filter(is_array($starting) ? $starting : [], is_string(...)), 0, count($catalog->ui->partySlots))); try { - $formation = BattleFormationLayout::compose($catalog, $arena, $placed, $partyIds, $assetRoot); + $formation = BattleFormationLayout::compose($catalog, $arena, $enemies, $partyIds, $assetRoot); } catch (\InvalidArgumentException|\RuntimeException $error) { throw new SessionRefusal('The formation cannot be composed: ' . $error->getMessage(), previous: $error); } - foreach ($formation->enemies as $position => $battler) { - $members[$placedMembers[$position]]['battler'] = self::describeFormationBattler($battler); - } $party = []; foreach ($formation->party as $position => $battler) { $party[] = ['name' => $names[$partyIds[$position]] ?? $partyIds[$position], ...self::describeFormationBattler($battler)]; @@ -1085,15 +1170,14 @@ public function readTroopFormation(int $index, ?string $arena = null): array } $chosen = $formation->arena === null ? false : array_search($formation->arena, $catalog->arenas, true); - return [ + return [$formation, [ 'assetRoot' => $assetRoot, 'canvas' => ['width' => $formation->layout->width, 'height' => $formation->layout->height], 'arenas' => $arenas, 'arena' => $chosen === false ? null : (string) $chosen, 'backgrounds' => array_map(self::describeCanvasImage(...), $formation->backgrounds), 'party' => $party, - 'members' => array_values($members), - ]; + ]]; } /** @return array Where a battler stands and is drawn, its art and its body at battle scale. */ diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 1c9c723..527f822 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -264,6 +264,8 @@ private function dispatch(string $method, array $params): array 'database.records' => $session->listDatabaseRecords(self::requireString($params, 'category')), 'troops.formation' => $session->readTroopFormation(self::requireInt($params, 'index'), is_string($params['arena'] ?? null) ? $params['arena'] : null), + 'enemies.preview' => $session->readEnemyPreview(self::requireInt($params, 'index'), + is_string($params['arena'] ?? null) ? $params['arena'] : null), 'database.record' => $session->readDatabaseRecord( self::requireString($params, 'category'), self::requireInt($params, 'index'), diff --git a/tests/Pest.php b/tests/Pest.php index bff5e7b..38dd544 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -372,3 +372,5 @@ function plainTextOfFrame(string $frame, int $width, int $height): string require_once __DIR__ . '/Support/BattleEntryFixtures.php'; require_once __DIR__ . '/Support/MapMetadataFixtures.php'; + +require_once __DIR__ . '/Support/BattleFormationFixtures.php'; diff --git a/tests/Support/BattleFormationFixtures.php b/tests/Support/BattleFormationFixtures.php new file mode 100644 index 0000000..ed5c8ed --- /dev/null +++ b/tests/Support/BattleFormationFixtures.php @@ -0,0 +1,47 @@ + new BattleArenaDefinition($name, + new CanvasImage('arena', 'Graphics/Battlebacks/' . $file, new CanvasRectangle(0, 0, 1350, 720))); +return new BattlePresentationCatalog( + arenas: ['arena.road' => $arena('Road', 'Road.png'), 'arena.cave' => $arena('Cave', 'Cave.png')], + actors: [], + enemies: [], + ui: new BattleCanvasLayout(1350, 720, partySlots: [new BattlerSlot(1050, 420, 180, 260), new BattlerSlot(1180, 560, 180, 260)]), + defaultArena: 'arena.cave', +); +PHP); + file_put_contents($root . '/assets/Data/troops.php', <<<'PHP' + 'Pair', + 'enemies' => [ + // The leader, placed for the graphical battle. + ['enemy' => 'Regular Bat', 'position' => [15, 7], 'graphicalPlacement' => ['x' => 260, 'y' => 300, 'width' => 275, 'height' => 190]], + ['enemy' => 'Regular Bat', 'position' => [15, 20]], + ], + ], +]; +PHP); + file_put_contents($root . '/assets/Data/system.php', " ['Kaelion']];"); + writeTilesetTestPng($root . '/assets/Graphics/Battlebacks/Cave.png', 8, 8); + + return $root; +} diff --git a/tests/Unit/Session/EnemyPreviewSessionTest.php b/tests/Unit/Session/EnemyPreviewSessionTest.php new file mode 100644 index 0000000..e80e88b --- /dev/null +++ b/tests/Unit/Session/EnemyPreviewSessionTest.php @@ -0,0 +1,67 @@ + Enemy::class, 'data' => " + . var_export(['name' => 'Regular Bat', 'level' => 2, 'imagePath' => 'bat', 'rewards' => ['experience' => 1, 'gold' => 1], 'stats' => [ + 'maxHp' => 10, 'maxMp' => 0, 'attack' => 3, 'defence' => 2, 'magicAttack' => 1, 'magicDefence' => 1, 'speed' => 2, 'grace' => 1, 'evasion' => 1, + ]], true) . "];\n"); + + return $root; +} + +/** Sets the open enemy's sprite through its row, as an author would, without saving. */ +function chooseEnemySprite(EditorSession $session, string $sprite): void +{ + $row = array_find($session->readDatabaseRecord('enemies', 0)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'imagePath'); + $session->applyDatabaseRecord('enemies', 0, $row['key'], $sprite); +} + +it('previews an enemy as its terminal sprite and at battle scale opposite the lead party member', function () { + $session = EditorSession::open(enemyPreviewProject()); + $preview = $session->readEnemyPreview(0); + $bat = $preview['formation']['members'][0]; + + expect($preview['name'])->toBe('Regular Bat') + ->and($preview['sprite'])->toBe(['lines' => ['/\\o/\\', " ' '"]]) + ->and($preview['formationIssue'])->toBeNull() + ->and($preview['formation']['arena'])->toBe('arena.cave') + ->and($preview['formation']['party'][0])->toMatchArray(['name' => 'Kaelion', 'ground' => ['x' => 1050.0, 'y' => 420.0]]) + // The lead slot mirrored across the 1350 wide canvas: a size comparison, not a placement. + ->and($bat)->toMatchArray(['enemy' => 'Regular Bat', 'placement' => null]) + ->and($bat['battler']['ground'])->toBe(['x' => 300.0, 'y' => 420.0]) + ->and($bat['battler']['diagnostics'])->toBe(['Base battler artwork unavailable: Regular Bat']) + ->and($session->readEnemyPreview(0, 'arena.road')['formation']['arena'])->toBe('arena.road') + ->and($session->listUnsavedChanges())->toBe([]); +}); + +it('shows the sprite the record holds now, and never a file outside the enemy sprites', function () { + $session = EditorSession::open(enemyPreviewProject()); + + chooseEnemySprite($session, 'wisp'); + expect($session->readEnemyPreview(0)['sprite'])->toBe(['lines' => ['~*~']]); + + chooseEnemySprite($session, '../../secret'); + expect($session->readEnemyPreview(0)['sprite'])->toBe(['issue' => 'Graphics/Enemies/../../secret.txt is not a sprite in this project.']); +}); + +it('still shows the sprite when the project has no graphical battle, saying why there is no art', function () { + $root = enemyPreviewProject(); + unlink($root . '/assets/Data/Presentation/battle.php'); + $preview = EditorSession::open($root)->readEnemyPreview(0); + + expect($preview['sprite'])->toBe(['lines' => ['/\\o/\\', " ' '"]]) + ->and($preview['formation'])->toBeNull() + ->and($preview['formationIssue'])->toBe('This project has no graphical battle layout to preview enemies at battle scale on.'); +}); diff --git a/tests/Unit/Session/TroopFormationSessionTest.php b/tests/Unit/Session/TroopFormationSessionTest.php index 857b163..46587b5 100644 --- a/tests/Unit/Session/TroopFormationSessionTest.php +++ b/tests/Unit/Session/TroopFormationSessionTest.php @@ -5,50 +5,6 @@ use Ichiloto\Editor\Session\EditorSession; use Ichiloto\Editor\Session\SessionRefusal; -/** A project whose battle has two arenas, two party slots and a troop of two. */ -function troopFormationProject(): string -{ - $root = makeTemporaryProject(); - @mkdir($root . '/assets/Data/Presentation', 0o777, true); - file_put_contents($root . '/assets/Data/Presentation/battle.php', <<<'PHP' - new BattleArenaDefinition($name, - new CanvasImage('arena', 'Graphics/Battlebacks/' . $file, new CanvasRectangle(0, 0, 1350, 720))); -return new BattlePresentationCatalog( - arenas: ['arena.road' => $arena('Road', 'Road.png'), 'arena.cave' => $arena('Cave', 'Cave.png')], - actors: [], - enemies: [], - ui: new BattleCanvasLayout(1350, 720, partySlots: [new BattlerSlot(1050, 420, 180, 260), new BattlerSlot(1180, 560, 180, 260)]), - defaultArena: 'arena.cave', -); -PHP); - file_put_contents($root . '/assets/Data/troops.php', <<<'PHP' - 'Pair', - 'enemies' => [ - // The leader, placed for the graphical battle. - ['enemy' => 'Regular Bat', 'position' => [15, 7], 'graphicalPlacement' => ['x' => 260, 'y' => 300, 'width' => 275, 'height' => 190]], - ['enemy' => 'Regular Bat', 'position' => [15, 20]], - ], - ], -]; -PHP); - file_put_contents($root . '/assets/Data/system.php', " ['Kaelion']];"); - writeTilesetTestPng($root . '/assets/Graphics/Battlebacks/Cave.png', 8, 8); - - return $root; -} - it('reads a troop formation as the Engine composes it over a previewed arena, the party in its slots', function () { $session = EditorSession::open(troopFormationProject()); $formation = $session->readTroopFormation(0); From 5b7ed95a32d842d55bf4ef104dc4aea9bcc166ce Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 13:10:50 +0200 Subject: [PATCH 148/344] docs(roadmap): plan database completion across the terminal and GUI editors --- docs/roadmap.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index 36ee4e0..d466c4f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -252,6 +252,40 @@ Acceptance criteria: name twice, as validation already reports it. - Round-trip comments, expressions and unknown fields in all three files, and refuse unsupported source rather than flattening it. + +### Database completion across TUI and GUI (in progress, 2026-10-04) + +Every Database category is edited through one shared record service, so the +terminal editor and the GUI editor read and write it the same way. Today 21 +categories are schema-driven (`RecordSchemaCatalog` over +`ProjectRecordDatabase`) and reach both editors. Actors, Classes, Skills, +Quests and System are built by hand inside `Editor.php` over their own +database classes, so the GUI session refuses them. + +Order of work, each a complete slice before the next: + +1. Classes, Quests, System, Actors and Skills move onto the shared record + service. The terminal keeps its workflow and every field and preview it has + now (experience and stat curves, actor stats); the GUI gains the category. +2. Data the record files hold but no editor reaches: item and skill effects, + skill kind, enemy drops, battle animation, the rest of `system.php` (title, + currency, elements, starting party, inventory and positions, battle + opening). +3. Skills become one declarative record per file, as enemies are, so spells + and abilities in `magic.php` and `abilities.php` are edited too (the + section above). Items, weapons and armors follow. +4. Tilesets: an editor for `Data/Tilesets/.php` (sheets, pieces, tables, + shadows); the "no tileset system" note is stale. Types: element names live + in `system.php`; the enum files stay read-only. +5. The manual's category table gains a GUI column and states each category's + real coverage. + +Acceptance criteria: + +- Every category opens, edits, saves and undoes in both editors through the + same service, with source-preserving saves. +- No field or preview the terminal editor offers today is lost. + ### Phase 1 — The responsiveness sprint (quick wins, no redesign) ✅ *shipped 2026-08* > Status: implemented. `InputDecoder` (src/IO/InputDecoder.php, unit-tested) > replaces the blocking reader: non-blocking drained stdin, offline escape From 4d2811e2118e20d86f7364e79f8c6c2e0744ad04 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 13:21:09 +0200 Subject: [PATCH 149/344] refactor(database): edit classes through the shared record service Classes were built by hand in the terminal editor over their own database, which regenerated classes.php on every save and which the GUI session could not open. They are now a schema-driven category, so both editors edit them the same way and saves change only what was edited. Every stat curve's growth and per-level values, grace and evasion bases, equipment types and skills learned are now editable as well. The terminal's experience and stat curve panes read the shared record. The terminal list now offers Shift+A on every category that can create an entry, and marks unsaved record categories, which it did not before. --- docs/manual.md | 2 +- src/Database/RecordSchemaCatalog.php | 84 ++++++++ src/Database/ReferenceCatalog.php | 12 +- src/Editor.php | 256 ++--------------------- src/ProjectClass.php | 154 +------------- src/ProjectClassDatabase.php | 290 --------------------------- src/ProjectWorkspace.php | 7 +- tests/Unit/ClassRecordsTest.php | 62 ++++++ tests/Unit/DatabaseDeletionTest.php | 16 +- tests/Unit/ListFilterTest.php | 4 +- tests/Unit/NavigationStackTest.php | 2 +- tests/Unit/SaveIntegrityTest.php | 1 - 12 files changed, 186 insertions(+), 704 deletions(-) delete mode 100644 src/ProjectClassDatabase.php create mode 100644 tests/Unit/ClassRecordsTest.php diff --git a/docs/manual.md b/docs/manual.md index 082feec..f884da1 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1010,7 +1010,7 @@ status line says exactly why. | Category | Backing file | Status | | --- | --- | --- | | Actors | `assets/Data/Actors/*.php` | Editable | -| Classes | `assets/Data/classes.php` | Editable | +| Classes | `assets/Data/classes.php` | Editable - levels, the experience curve, every stat curve, equipment types and skills learned | | Skills | `assets/Data/skills.php` | Editable - spells and abilities in `magic.php` and `abilities.php` are validated and offered as references, not yet edited here | | Items | `assets/Data/items.php` | Editable - authored as `new Item(...)` calls, edited entry by entry | | Weapons | `assets/Data/items.php` | Editable - authored as `new Weapon(...)` calls, edited entry by entry | diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 7298cb9..f659faa 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -63,6 +63,15 @@ final class RecordSchemaCatalog */ public const array EVENT_COMMAND_TYPES = EventInterpreter::COMMAND_TYPES; + /** + * The stats a class grows by a curve, in `ClassStore`'s order, with the + * abbreviations their rows are labelled by. + */ + private const array CLASS_CURVE_STATS = [ + 'totalHp' => 'HP', 'totalMp' => 'MP', 'attack' => 'ATK', 'defence' => 'DEF', 'magicAttack' => 'MAT', + 'magicDefence' => 'MDF', 'speed' => 'SPD', 'grace' => 'GRC', 'evasion' => 'EVA', + ]; + /** * Returns every schema-driven category, keyed by Database category key. * @@ -71,6 +80,7 @@ final class RecordSchemaCatalog public static function all(): array { $schemas = [ + self::classes(), self::states(), self::troops(), self::animations(), @@ -819,6 +829,80 @@ private static function optimizeExclusions(): RecordSchema ); } + /** + * Classes — `assets/Data/classes.php`, the plain data the engine's + * `ClassStore` reads: growth curves, equipment types, skills learned by + * level. A class is addressed by its numeric id; new ones take the next. + * + * @return RecordSchema + */ + private static function classes(): RecordSchema + { + $curves = []; + + foreach (self::CLASS_CURVE_STATS as $stat => $label) { + $curves[] = new RecordField("parameterCurves.{$stat}.baseValue", "{$label} Base", InputControlType::INTEGER); + $curves[] = new RecordField("parameterCurves.{$stat}.extraGrowth", "{$label} Growth", InputControlType::INTEGER); + $curves[] = new RecordField("parameterCurves.{$stat}.flatIncrement", "{$label} Per Level", InputControlType::INTEGER); + } + + return new RecordSchema( + key: 'classes', + entryNoun: 'class', + storage: RecordStorage::LIST_FILE, + relativePath: 'assets/Data/classes.php', + fields: [ + new RecordField('id', 'Id', InputControlType::INTEGER, isReadOnly: true), + new RecordField('name', 'Name', uniqueAcrossRecords: true), + new RecordField('description', 'Description'), + new RecordField('note', 'Note'), + new RecordField('initialLevel', 'Initial Level', InputControlType::INTEGER), + new RecordField('maxLevel', 'Max Level', InputControlType::INTEGER), + new RecordField('equipment.weapons', 'Weapon Types', codec: RecordFieldCodec::CSV_LIST, reference: 'weapon_types'), + new RecordField('equipment.armor', 'Armor Types', codec: RecordFieldCodec::CSV_LIST, reference: 'armor_types'), + new RecordField('experienceCurve.baseValue', 'EXP Base', InputControlType::INTEGER), + new RecordField('experienceCurve.extraValue', 'EXP Extra', InputControlType::INTEGER), + new RecordField('experienceCurve.accelerationA', 'EXP Accel A', InputControlType::INTEGER), + new RecordField('experienceCurve.accelerationB', 'EXP Accel B', InputControlType::INTEGER), + ...$curves, + ], + labelKey: 'name', + identityKey: 'id', + blank: [ + 'id' => 1, + 'name' => 'New Class', + 'description' => '', + 'initialLevel' => 1, + 'maxLevel' => 99, + 'equipment' => ['weapons' => [], 'armor' => []], + 'skillsToLearn' => [], + 'experienceCurve' => ['baseValue' => 30, 'extraValue' => 20, 'accelerationA' => 30, 'accelerationB' => 30], + 'parameterCurves' => [ + 'totalHp' => ['baseValue' => 120, 'extraGrowth' => 500, 'flatIncrement' => 40], + 'totalMp' => ['baseValue' => 12, 'extraGrowth' => 100, 'flatIncrement' => 10], + 'attack' => ['baseValue' => 10, 'extraGrowth' => 50, 'flatIncrement' => 1], + 'defence' => ['baseValue' => 10, 'extraGrowth' => 30, 'flatIncrement' => 1], + 'magicAttack' => ['baseValue' => 10, 'extraGrowth' => 50, 'flatIncrement' => 1], + 'magicDefence' => ['baseValue' => 10, 'extraGrowth' => 30, 'flatIncrement' => 1], + 'speed' => ['baseValue' => 10, 'extraGrowth' => 20, 'flatIncrement' => 1], + 'grace' => ['baseValue' => 10, 'extraGrowth' => 15, 'flatIncrement' => 1], + 'evasion' => ['baseValue' => 5, 'extraGrowth' => 10, 'flatIncrement' => 1], + ], + ], + subList: new RecordSubList( + key: 'skillsToLearn', + prefix: 'learn', + singular: 'skill to learn', + fields: [ + new RecordField('level', 'Level', InputControlType::INTEGER), + RecordField::reference('skill', 'Skill', 'skills'), + new RecordField('note', 'Note', removeWhenEmpty: true), + ], + blank: ['level' => 2, 'skill' => ''], + ), + ); + } + /** * Enemies — one record per file under `assets/Data/Enemies`, the form the * engine's `EnemyRecord` reads. Each file returns diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 34092b8..b5446aa 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -6,7 +6,6 @@ use Ichiloto\Editor\PermanentGrowthCatalog; use Ichiloto\Editor\ProjectActor; -use Ichiloto\Editor\ProjectClass; use Ichiloto\Editor\ProjectQuest; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\Cutscenes\CutsceneAsset; @@ -14,6 +13,8 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Rendering\Tilesets\Tileset; +use Ichiloto\Engine\Entities\Enumerations\ArmorType; +use Ichiloto\Engine\Entities\Enumerations\WeaponType; /** * What a field pointing at another resource may point at. @@ -34,6 +35,8 @@ final class ReferenceCatalog 'actors', 'actor_ids', 'classes', + 'weapon_types', + 'armor_types', 'skills', 'quests', 'maps', @@ -127,10 +130,9 @@ public function valuesFor(string $category): array array_values(array_filter($this->workspace->actorDatabase->getActors(), static fn(ProjectActor $actor): bool => $actor->hasDefinitionId())) ), - 'classes' => array_map( - static fn(ProjectClass $class): string => $class->getName(), - $this->workspace->classDatabase->getClasses() - ), + // A class restricts what its members equip by the engine's type names. + 'weapon_types' => array_map(static fn(WeaponType $type): string => $type->value, WeaponType::cases()), + 'armor_types' => array_map(static fn(ArmorType $type): string => $type->value, ArmorType::cases()), // Spells and abilities may be authored in any of the Engine's // skill files; a reference names the skill wherever it lives. 'skills' => $this->workspace->getSkillNames(), diff --git a/src/Editor.php b/src/Editor.php index 591b4ec..e2556c9 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -603,7 +603,6 @@ final class Editor private int $databaseCategoryIndex = 9; private string $databaseFocus = self::DATABASE_FOCUS_CATEGORIES; private int $databaseSelectedActorIndex = 0; - private int $databaseSelectedClassIndex = 0; private int $databaseSelectedSkillIndex = 0; private int $databaseSelectedQuestIndex = 0; /** @@ -916,7 +915,6 @@ private function boot(): void $this->databaseCategoryIndex = DatabaseCatalog::indexOf(self::DATABASE_CATEGORY_ACTORS); $this->databaseFocus = self::DATABASE_FOCUS_CATEGORIES; $this->databaseSelectedActorIndex = 0; - $this->databaseSelectedClassIndex = 0; $this->databaseSelectedSkillIndex = 0; $this->databaseSelectedQuestIndex = 0; $this->databaseSelectedSettingIndex = 0; @@ -2835,7 +2833,6 @@ private function openDatabaseWindow(): void $this->databaseCategoryIndex = DatabaseCatalog::indexOf(self::DATABASE_CATEGORY_ACTORS); $this->databaseFocus = self::DATABASE_FOCUS_CATEGORIES; $this->databaseSelectedActorIndex = 0; - $this->databaseSelectedClassIndex = 0; $this->databaseSelectedSkillIndex = 0; $this->databaseSelectedQuestIndex = 0; $this->databaseSelectedSettingIndex = 0; @@ -3169,11 +3166,6 @@ private function moveDatabaseListSelection(int $step): void return; } - if ($this->isClassesDatabaseSelected()) { - $this->moveDatabaseClassSelection($step); - return; - } - if ($this->isSkillsDatabaseSelected()) { $this->moveDatabaseSkillSelection($step); return; @@ -3214,31 +3206,6 @@ private function moveDatabaseActorSelection(int $step): void $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); } - /** - * Moves the selected class entry. - * - * @param int $step The entry step. - * @return void - */ - private function moveDatabaseClassSelection(int $step): void - { - $classes = $this->workspace?->classDatabase->getClasses() ?? []; - - if ($classes === []) { - return; - } - - $nextIndex = $this->resolveDatabaseSelectionStep($this->databaseSelectedClassIndex, $step); - - if ($nextIndex === $this->databaseSelectedClassIndex) { - return; - } - - $this->databaseSelectedClassIndex = $nextIndex; - $this->databaseSelectedSettingIndex = 0; - $this->statusMessage = sprintf('Selected class %s.', $classes[$nextIndex]->getName()); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - } /** * Moves the selected skill entry. * @@ -5811,13 +5778,6 @@ private function getDatabaseEntryLabels(): array ); } - if ($this->isClassesDatabaseSelected()) { - return array_map( - static fn(ProjectClass $class): string => $class->getName(), - $this->workspace?->classDatabase->getClasses() ?? [], - ); - } - if ($this->isSkillsDatabaseSelected()) { return array_map( static fn(ProjectSkill $skill): string => $skill->getName(), @@ -5885,7 +5845,6 @@ private function getSelectedDatabaseEntryIndex(): int { return match (true) { $this->isActorsDatabaseSelected() => $this->databaseSelectedActorIndex, - $this->isClassesDatabaseSelected() => $this->databaseSelectedClassIndex, $this->isSkillsDatabaseSelected() => $this->databaseSelectedSkillIndex, $this->isQuestsDatabaseSelected() => $this->databaseSelectedQuestIndex, default => $this->getSelectedRecordIndex(), @@ -5904,7 +5863,6 @@ private function setSelectedDatabaseEntryIndex(int $index): void match (true) { $this->isActorsDatabaseSelected() => $this->databaseSelectedActorIndex = $index, - $this->isClassesDatabaseSelected() => $this->databaseSelectedClassIndex = $index, $this->isSkillsDatabaseSelected() => $this->databaseSelectedSkillIndex = $index, $this->isQuestsDatabaseSelected() => $this->databaseSelectedQuestIndex = $index, default => $this->setSelectedRecordIndex($index), @@ -6126,11 +6084,6 @@ private function confirmDatabaseEntryDeletion(): void fn(): ?object => $workspace->actorDatabase->removeActor($index), static fn(object $entry) => $workspace->actorDatabase->insertActor($index, $entry), ), - self::DATABASE_CATEGORY_CLASSES => $this->buildDatabaseDeletionCommand( - sprintf('Delete class %s', $label), - fn(): ?object => $workspace->classDatabase->removeClass($index), - static fn(object $entry) => $workspace->classDatabase->insertClass($index, $entry), - ), self::DATABASE_CATEGORY_SKILLS => $this->buildDatabaseDeletionCommand( sprintf('Delete skill %s', $label), fn(): ?object => $workspace->skillDatabase->removeSkill($index), @@ -6321,7 +6274,7 @@ private function goToActorClass(): void $this->pushNavigationOrigin(sprintf('actor %s', $actor->getName())); $this->databaseFilter->clear(); $this->databaseCategoryIndex = DatabaseCatalog::indexOf(self::DATABASE_CATEGORY_CLASSES); - $this->databaseSelectedClassIndex = $classIndex; + $this->setSelectedRecordIndex($classIndex); $this->databaseFocus = self::DATABASE_FOCUS_LIST; $this->databaseSelectedSettingIndex = 0; $this->setStatus(sprintf('Went to class %s (Ctrl+B goes back).', $className), StatusLevel::INFO); @@ -6477,8 +6430,8 @@ private function resolveGoToDestinationMapId(ProjectMap $selectedMap, ?string $m */ private function findDatabaseClassIndex(string $className): ?int { - foreach ($this->workspace?->classDatabase->getClasses() ?? [] as $index => $class) { - if (mb_strtolower($class->getName()) === mb_strtolower($className)) { + foreach ($this->workspace?->getRecordDatabase(self::DATABASE_CATEGORY_CLASSES)?->getEntryLabels() ?? [] as $index => $name) { + if (mb_strtolower($name) === mb_strtolower($className)) { return $index; } } @@ -6530,7 +6483,6 @@ private function captureNavigationRestorer(): Closure 'databaseCategoryIndex' => $this->databaseCategoryIndex, 'databaseFocus' => $this->databaseFocus, 'actor' => $this->databaseSelectedActorIndex, - 'class' => $this->databaseSelectedClassIndex, 'skill' => $this->databaseSelectedSkillIndex, 'quest' => $this->databaseSelectedQuestIndex, 'records' => $this->databaseSelectedRecordIndexes, @@ -6550,7 +6502,6 @@ private function captureNavigationRestorer(): Closure $this->databaseCategoryIndex = $snapshot['databaseCategoryIndex']; $this->databaseFocus = $snapshot['databaseFocus']; $this->databaseSelectedActorIndex = $snapshot['actor']; - $this->databaseSelectedClassIndex = $snapshot['class']; $this->databaseSelectedSkillIndex = $snapshot['skill']; $this->databaseSelectedQuestIndex = $snapshot['quest']; $this->databaseSelectedRecordIndexes = $snapshot['records']; @@ -9290,7 +9241,10 @@ private function getSelectedClass(): ?ProjectClass return null; } - return $this->workspace?->classDatabase->getClassByIndex($this->databaseSelectedClassIndex); + // The terminal's curve panes read the class as the shared record holds it, unsaved edits included. + $record = $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()); + + return $record === null ? null : ProjectClass::fromArray((array) $record->toArray(), $this->getSelectedRecordIndex() + 1); } /** @@ -9314,11 +9268,6 @@ private function createDatabaseEntry(): void return; } - if ($this->isClassesDatabaseSelected()) { - $this->createDatabaseClass(); - return; - } - if ($this->isQuestsDatabaseSelected()) { $this->createDatabaseQuest(); return; @@ -9346,25 +9295,6 @@ private function createDatabaseActor(): void $this->beginDatabaseEdit(); } - /** - * Creates a new class entry in the project database. - * - * @return void - */ - private function createDatabaseClass(): void - { - if (! $this->workspace instanceof ProjectWorkspace) { - return; - } - - $this->databaseSelectedClassIndex = $this->workspace->classDatabase->addClass(); - $this->databaseSelectedSettingIndex = 0; - $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; - $this->statusMessage = "Created a new class."; - $this->renderDatabasePanes(["list", "settings", "cue", "frames", "preview"]); - $this->beginDatabaseEdit(); - } - /** * Creates a new skill entry in the project database. * @@ -9614,10 +9544,6 @@ private function saveActiveDatabase(): void $this->backupBeforeSave(...$this->workspace->actorDatabase->getBackupPaths()); $this->workspace->actorDatabase->save(); $this->setStatus('Actor database saved.', StatusLevel::SUCCESS); - } elseif ($this->isClassesDatabaseSelected()) { - $this->backupBeforeSave(...$this->workspace->classDatabase->getBackupPaths()); - $this->workspace->classDatabase->save(); - $this->setStatus('Class database saved.', StatusLevel::SUCCESS); } elseif ($this->isSkillsDatabaseSelected()) { $this->backupBeforeSave(...$this->workspace->skillDatabase->getBackupPaths()); $this->workspace->skillDatabase->save(); @@ -9677,10 +9603,6 @@ private function getDatabaseSettingsFields(): array return $this->getDatabaseActorSettingsFields(); } - if ($this->isClassesDatabaseSelected()) { - return $this->getDatabaseClassSettingsFields(); - } - if ($this->isSkillsDatabaseSelected()) { return $this->getDatabaseSkillSettingsFields(); } @@ -10159,78 +10081,7 @@ private function getActorClassOptions(): array { return [ ProjectActor::CLASS_NONE, - ...array_map( - static fn(ProjectClass $class): string => $class->getName(), - $this->workspace?->classDatabase->getClasses() ?? [], - ), - ]; - } - - /** - * Returns the editable settings fields for the selected class. - * - * @return array> - */ - private function getDatabaseClassSettingsFields(): array - { - $class = $this->getSelectedClass(); - - if (! $class instanceof ProjectClass) { - return []; - } - - $experienceCurve = $class->getExperienceCurve(); - - return [ - [ - 'label' => 'Name', - 'value' => $class->getName(), - 'control' => new InputControl(InputControlType::TEXT, $class->getName()), - 'field' => 'name', - ], - [ - 'label' => 'Description', - 'value' => $class->getDescription(), - 'control' => new InputControl(InputControlType::TEXT, $class->getDescription()), - 'field' => 'description', - ], - [ - 'label' => 'Initial Level', - 'value' => (string) $class->getInitialLevel(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $class->getInitialLevel()), - 'field' => 'initialLevel', - ], - [ - 'label' => 'Max Level', - 'value' => (string) $class->getMaxLevel(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $class->getMaxLevel()), - 'field' => 'maxLevel', - ], - [ - 'label' => 'EXP Base', - 'value' => (string) $experienceCurve['baseValue'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $experienceCurve['baseValue']), - 'field' => 'expBaseValue', - ], - [ - 'label' => 'EXP Extra', - 'value' => (string) $experienceCurve['extraValue'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $experienceCurve['extraValue']), - 'field' => 'expExtraValue', - ], - [ - 'label' => 'EXP Accel A', - 'value' => (string) $experienceCurve['accelerationA'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $experienceCurve['accelerationA']), - 'field' => 'expAccelerationA', - ], - [ - 'label' => 'EXP Accel B', - 'value' => (string) $experienceCurve['accelerationB'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $experienceCurve['accelerationB']), - 'field' => 'expAccelerationB', - ], - ...$this->getDatabaseClassBaseValueFields($class), + ...$this->workspace?->getRecordDatabase(self::DATABASE_CATEGORY_CLASSES)?->getEntryLabels() ?? [], ]; } @@ -10390,39 +10241,6 @@ private function getDatabaseQuestSettingsFields(): array return $fields; } - /** - * Returns the editable class base-value fields. - * - * @param ProjectClass $class The selected class. - * @return array> - */ - private function getDatabaseClassBaseValueFields(ProjectClass $class): array - { - $fieldMap = [ - 'HP Base' => ['field' => 'totalHpBaseValue', 'curve' => 'totalHp'], - 'MP Base' => ['field' => 'totalMpBaseValue', 'curve' => 'totalMp'], - 'ATK Base' => ['field' => 'attackBaseValue', 'curve' => 'attack'], - 'DEF Base' => ['field' => 'defenceBaseValue', 'curve' => 'defence'], - 'MAT Base' => ['field' => 'magicAttackBaseValue', 'curve' => 'magicAttack'], - 'MDF Base' => ['field' => 'magicDefenceBaseValue', 'curve' => 'magicDefence'], - 'SPD Base' => ['field' => 'speedBaseValue', 'curve' => 'speed'], - ]; - $fields = []; - - foreach ($fieldMap as $label => $definition) { - $curve = $class->getParameterCurve($definition['curve']); - $value = (string) $curve['baseValue']; - $fields[] = [ - 'label' => $label, - 'value' => $value, - 'control' => new InputControl(InputControlType::INTEGER, $value), - 'field' => $definition['field'], - ]; - } - - return $fields; - } - /** * Returns the input control for a database settings field when editable. * @@ -11940,7 +11758,6 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) $identity = [ 'category' => $this->databaseCategoryIndex, 'actor' => $this->databaseSelectedActorIndex, - 'class' => $this->databaseSelectedClassIndex, 'skill' => $this->databaseSelectedSkillIndex, 'quest' => $this->databaseSelectedQuestIndex, 'records' => $this->databaseSelectedRecordIndexes, @@ -12034,7 +11851,7 @@ private function applyRecordFieldValue(ProjectRecordDatabase $database, string $ * Applies a database value onto a pinned entry identity, restoring the * live selection afterwards. * - * @param array{category: int, actor: int, class: int, skill: int, quest: int, records?: array} $identity The pinned selection. + * @param array{category: int, actor: int, skill: int, quest: int, records?: array} $identity The pinned selection. * @param string $field The field identifier. * @param string $rawValue The raw value to apply. * @return void @@ -12044,14 +11861,12 @@ private function applyDatabaseFieldValueAt(array $identity, string $field, strin $liveSelection = [ $this->databaseCategoryIndex, $this->databaseSelectedActorIndex, - $this->databaseSelectedClassIndex, $this->databaseSelectedSkillIndex, $this->databaseSelectedQuestIndex, $this->databaseSelectedRecordIndexes, ]; $this->databaseCategoryIndex = $identity['category']; $this->databaseSelectedActorIndex = $identity['actor']; - $this->databaseSelectedClassIndex = $identity['class']; $this->databaseSelectedSkillIndex = $identity['skill']; $this->databaseSelectedQuestIndex = $identity['quest']; $this->databaseSelectedRecordIndexes = $identity['records'] ?? $this->databaseSelectedRecordIndexes; @@ -12062,7 +11877,6 @@ private function applyDatabaseFieldValueAt(array $identity, string $field, strin [ $this->databaseCategoryIndex, $this->databaseSelectedActorIndex, - $this->databaseSelectedClassIndex, $this->databaseSelectedSkillIndex, $this->databaseSelectedQuestIndex, $this->databaseSelectedRecordIndexes, @@ -12143,16 +11957,6 @@ private function applyDatabaseFieldValue(string $field, string $rawValue): void return; } - if ($this->isClassesDatabaseSelected()) { - $this->workspace->classDatabase->setField( - $this->databaseSelectedClassIndex, - $field, - in_array($field, ['name', 'description', 'note'], true) ? trim($rawValue) : max(0, intval($rawValue)), - ); - - return; - } - if ($this->isSkillsDatabaseSelected()) { $value = in_array($field, ["cost", "cooldown", "invocationSpeed", "invocationAccuracy", "invocationRepeat", "invocationApGain"], true) ? max(0, intval($rawValue)) @@ -14191,10 +13995,12 @@ private function createDatabaseCategoryWindow(array $layout): EditorWindow */ private function createDatabaseListWindow(array $layout): EditorWindow { + // Every category that can take a new entry says how; record categories + // ask their database rather than being listed here. $supportsEntries = $this->isActorsDatabaseSelected() - || $this->isClassesDatabaseSelected() || $this->isSkillsDatabaseSelected() - || $this->isQuestsDatabaseSelected(); + || $this->isQuestsDatabaseSelected() + || ($this->getSelectedRecordDatabase()?->supportsRecordCreation() ?? false); return new EditorWindow( // The category dirty marker rides the title so categories whose @@ -14392,10 +14198,6 @@ private function getDatabaseListLines(): array return $this->getDatabaseActorListLines(); } - if ($this->isClassesDatabaseSelected()) { - return $this->getDatabaseClassListLines(); - } - if ($this->isSkillsDatabaseSelected()) { return $this->getDatabaseSkillListLines(); } @@ -14510,35 +14312,6 @@ private function getDatabaseActorListLines(): array return $lines === [] ? ['No matches.'] : $lines; } - /** - * Returns the class list lines. - * - * @return string[] - */ - private function getDatabaseClassListLines(): array - { - $classes = $this->workspace?->classDatabase->getClasses() ?? []; - - if ($classes === []) { - return ['No classes yet.', '', 'Shift+A to create one.']; - } - - $lines = []; - - foreach ($this->getVisibleDatabaseEntryIndexes() as $index) { - $class = $classes[$index] ?? null; - - if (! $class instanceof ProjectClass) { - continue; - } - - $prefix = $index === $this->databaseSelectedClassIndex ? '> ' : ' '; - $dirty = $class->isDirty() ? ' *' : ''; - $lines[] = sprintf('%s%04d %s%s', $prefix, $class->id, $class->getName(), $dirty); - } - - return $lines === [] ? ['No matches.'] : $lines; - } /** * Returns the skill list lines. * @@ -15380,11 +15153,10 @@ private function isDatabaseCategoryDirty(string $categoryKey): bool return match ($categoryKey) { self::DATABASE_CATEGORY_ACTORS => $this->workspace->actorDatabase->isDirty(), - self::DATABASE_CATEGORY_CLASSES => $this->workspace->classDatabase->isDirty(), self::DATABASE_CATEGORY_SKILLS => $this->workspace->skillDatabase->isDirty(), self::DATABASE_CATEGORY_SYSTEM => $this->workspace->systemDatabase->isDirty() || ($this->workspace->config?->isDirty() ?? false), self::DATABASE_CATEGORY_QUESTS => $this->workspace->questDatabase->isDirty(), - default => false, + default => $this->workspace->getRecordDatabase($categoryKey)?->isDirty() ?? false, }; } diff --git a/src/ProjectClass.php b/src/ProjectClass.php index 509def6..de53915 100644 --- a/src/ProjectClass.php +++ b/src/ProjectClass.php @@ -4,26 +4,21 @@ namespace Ichiloto\Editor; -use Ichiloto\Editor\History\TracksPersistedState; - /** - * Represents one editable class entry inside the project database. + * A class record as its curves read: the terminal editor's experience and + * stat curve panes chart it. The record itself is edited through the shared + * record database (the `classes` schema); this only reads it, filling what + * the record leaves out the way the engine's ClassStore does. */ -final class ProjectClass +final readonly class ProjectClass { - use TracksPersistedState; - /** * @param array $payload */ public function __construct( - public readonly int $id, + public int $id, private array $payload, - bool $isDirty = false, ) { - if (! $isDirty) { - $this->captureBaseline(); - } } /** @@ -41,60 +36,11 @@ public static function fromArray(array $payload, int $fallbackId): self ); } - /** - * Creates a blank class definition. - * - * @param int $id The class id. - * @param string $name The display name. - * @return self - */ - public static function createBlank(int $id, string $name = 'New Class'): self - { - return new self( - id: $id, - payload: [ - 'id' => $id, - 'name' => $name, - 'description' => '', - 'initialLevel' => 1, - 'maxLevel' => 99, - 'note' => '', - 'traits' => [], - 'experienceCurve' => [ - 'baseValue' => 30, - 'extraValue' => 20, - 'accelerationA' => 30, - 'accelerationB' => 30, - ], - 'parameterCurves' => [ - 'totalHp' => ['baseValue' => 120, 'extraGrowth' => 500, 'flatIncrement' => 40], - 'totalMp' => ['baseValue' => 12, 'extraGrowth' => 100, 'flatIncrement' => 10], - 'attack' => ['baseValue' => 10, 'extraGrowth' => 50, 'flatIncrement' => 1], - 'defence' => ['baseValue' => 10, 'extraGrowth' => 30, 'flatIncrement' => 1], - 'magicAttack' => ['baseValue' => 10, 'extraGrowth' => 50, 'flatIncrement' => 1], - 'magicDefence' => ['baseValue' => 10, 'extraGrowth' => 30, 'flatIncrement' => 1], - 'speed' => ['baseValue' => 10, 'extraGrowth' => 20, 'flatIncrement' => 1], - 'grace' => ['baseValue' => 10, 'extraGrowth' => 15, 'flatIncrement' => 1], - 'evasion' => ['baseValue' => 5, 'extraGrowth' => 10, 'flatIncrement' => 1], - ], - ], - isDirty: true, - ); - } - /** * Returns whether the class has unsaved changes. * * @return bool */ - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - return serialize($this->toArray()); - } - /** * Returns the class name. * @@ -225,92 +171,4 @@ public function getParameterCurves(): array ]; } - /** - * Updates one editable class field. - * - * @param string $field The field identifier. - * @param mixed $value The replacement value. - * @return void - */ - public function setField(string $field, mixed $value): void - { - if (in_array($field, ['name', 'description', 'note'], true)) { - $this->payload[$field] = (string) $value; - $this->touchState(); - return; - } - - if ($field === 'initialLevel') { - $this->payload['initialLevel'] = max(1, (int) $value); - $this->payload['maxLevel'] = max( - (int) ($this->payload['maxLevel'] ?? 99), - (int) $this->payload['initialLevel'], - ); - $this->touchState(); - return; - } - - if ($field === 'maxLevel') { - $this->payload['maxLevel'] = max($this->getInitialLevel(), (int) $value); - $this->touchState(); - return; - } - - $experienceMap = [ - 'expBaseValue' => 'baseValue', - 'expExtraValue' => 'extraValue', - 'expAccelerationA' => 'accelerationA', - 'expAccelerationB' => 'accelerationB', - ]; - - if (isset($experienceMap[$field])) { - if (! isset($this->payload['experienceCurve']) || ! is_array($this->payload['experienceCurve'])) { - $this->payload['experienceCurve'] = []; - } - - $this->payload['experienceCurve'][$experienceMap[$field]] = max(0, (int) $value); - $this->touchState(); - return; - } - - $parameterMap = [ - 'totalHpBaseValue' => 'totalHp', - 'totalMpBaseValue' => 'totalMp', - 'attackBaseValue' => 'attack', - 'defenceBaseValue' => 'defence', - 'magicAttackBaseValue' => 'magicAttack', - 'magicDefenceBaseValue' => 'magicDefence', - 'speedBaseValue' => 'speed', - 'graceBaseValue' => 'grace', - 'evasionBaseValue' => 'evasion', - ]; - - if (isset($parameterMap[$field])) { - if (! isset($this->payload['parameterCurves']) || ! is_array($this->payload['parameterCurves'])) { - $this->payload['parameterCurves'] = []; - } - - $curveKey = $parameterMap[$field]; - - if (! isset($this->payload['parameterCurves'][$curveKey]) || ! is_array($this->payload['parameterCurves'][$curveKey])) { - $this->payload['parameterCurves'][$curveKey] = []; - } - - $this->payload['parameterCurves'][$curveKey]['baseValue'] = max(0, (int) $value); - $this->touchState(); - } - } - - /** - * Returns the normalized payload for persistence. - * - * @return array - */ - public function toArray(): array - { - $payload = $this->payload; - $payload['id'] = $this->id; - - return $payload; - } } diff --git a/src/ProjectClassDatabase.php b/src/ProjectClassDatabase.php deleted file mode 100644 index e8cda09..0000000 --- a/src/ProjectClassDatabase.php +++ /dev/null @@ -1,290 +0,0 @@ -captureBaseline(); - } - } - - /** - * Loads the class database from the project. - * - * @param string $projectRoot The project root. - * @return self - */ - public static function fromProject(string $projectRoot): self - { - $path = rtrim($projectRoot, DIRECTORY_SEPARATOR) - . DIRECTORY_SEPARATOR - . 'assets' - . DIRECTORY_SEPARATOR - . 'Data' - . DIRECTORY_SEPARATOR - . 'classes.php'; - - if (! is_file($path)) { - return new self($path, []); - } - - $payload = require $path; - - if (! is_array($payload)) { - throw new RuntimeException("Unable to parse {$path}."); - } - - $classes = []; - - foreach (array_values(array_filter($payload, 'is_array')) as $index => $classPayload) { - $classes[] = ProjectClass::fromArray($classPayload, $index + 1); - } - - return new self($path, $classes); - } - - /** - * Returns the stored class records. - * - * @return ProjectClass[] - */ - public function getClasses(): array - { - return array_values($this->classes); - } - - /** - * Returns whether the database has unsaved changes. - * - * @return bool - */ - - /** - * Returns the class at the requested index. - * - * @param int $index The class index. - * @return ProjectClass|null - */ - public function getClassByIndex(int $index): ?ProjectClass - { - return $this->classes[$index] ?? null; - } - - /** - * Adds a blank class entry and returns its index. - * - * @param string $name The default class name. - * @return int - */ - public function addClass(string $name = 'New Class'): int - { - $nextId = 1; - - foreach ($this->classes as $class) { - $nextId = max($nextId, $class->id + 1); - } - - $this->classes[] = ProjectClass::createBlank($nextId, $name); - $this->touchState(); - - return count($this->classes) - 1; - } - - /** - * Removes the class at the given index. - * - * The database asset is only rewritten on save, so undo (re-insert at - * the same index) fully restores the entry. - * - * @param int $index The class index. - * @return ProjectClass|null The removed class, or null when the index is unknown. - */ - public function removeClass(int $index): ?ProjectClass - { - $classes = array_values($this->classes); - $class = $classes[$index] ?? null; - - if (! $class instanceof ProjectClass) { - return null; - } - - array_splice($classes, $index, 1); - $this->classes = $classes; - $this->touchState(); - - return $class; - } - - /** - * Re-inserts a previously removed class (the undo of removeClass()). - * - * @param int $index The index to restore the class at. - * @param ProjectClass $class The class to restore. - * @return void - */ - public function insertClass(int $index, ProjectClass $class): void - { - $classes = array_values($this->classes); - $index = max(0, min(count($classes), $index)); - array_splice($classes, $index, 0, [$class]); - $this->classes = $classes; - $this->touchState(); - } - - /** - * Updates one class field. - * - * @param int $index The class index. - * @param string $field The field identifier. - * @param mixed $value The replacement value. - * @return void - */ - public function setField(int $index, string $field, mixed $value): void - { - $class = $this->getClassByIndex($index); - - if (! $class instanceof ProjectClass) { - return; - } - - $class->setField($field, $value); - $this->touchState(); - } - - /** - * Writes the database asset back to disk. - * - * @return void - */ - public function save(): void - { - $directory = dirname($this->path); - - if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { - throw new RuntimeException("Unable to create {$directory}."); - } - - if (! $this->isDirty() && is_file($this->path)) { - return; - } - - AtomicFile::write($this->path, $this->buildPersistedPayload()); - $this->captureBaseline(); - } - - /** - * @inheritDoc - */ - protected function dependencyVersion(): string - { - $versions = []; - - foreach ($this->classes as $class) { - $versions[] = $class->stateVersion(); - } - - return count($this->classes) . ':' . implode(',', $versions); - } - - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - return " $class->toArray(), - $this->getClasses() - )) . ";\n"; - } - - /** - * Writes a file using a temp-file swap. - * - * @param string $path The destination file. - * @param string $contents The file contents. - * @return void - */ - private static function writeFileTransactionally(string $path, string $contents): void - { - $temporaryPath = $path . '.tmp'; - - if (file_put_contents($temporaryPath, $contents) === false) { - throw new RuntimeException("Unable to write temporary file for {$path}."); - } - - if (! rename($temporaryPath, $path)) { - @unlink($temporaryPath); - throw new RuntimeException("Unable to replace {$path}."); - } - } - - /** - * Exports a PHP value using short-array syntax. - * - * @param mixed $value The value to export. - * @param int $indentLevel The indentation depth. - * @return string - */ - private static function exportPhpValue(mixed $value, int $indentLevel = 0): string - { - if (! is_array($value)) { - return var_export($value, true); - } - - if ($value === []) { - return '[]'; - } - - $indent = str_repeat(' ', $indentLevel); - $nextIndent = str_repeat(' ', $indentLevel + 1); - $isList = array_is_list($value); - $lines = ['[']; - - foreach ($value as $key => $item) { - $exportedItem = self::exportPhpValue($item, $indentLevel + 1); - - if ($isList) { - $lines[] = "{$nextIndent}{$exportedItem},"; - continue; - } - - $exportedKey = var_export($key, true); - $lines[] = "{$nextIndent}{$exportedKey} => {$exportedItem},"; - } - - $lines[] = "{$indent}]"; - - return implode("\n", $lines); - } - - /** - * Returns the files a save of this database would overwrite. - * - * @return string[] - */ - public function getBackupPaths(): array - { - return [$this->path]; - } -} diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index a3c64ac..f4bada9 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -43,7 +43,6 @@ public function __construct( public array $maps, public array $mapIds, public ProjectActorDatabase $actorDatabase, - public ProjectClassDatabase $classDatabase, public ProjectSkillDatabase $skillDatabase, public ProjectSystemDatabase $systemDatabase, public ProjectQuestDatabase $questDatabase, @@ -170,7 +169,6 @@ private static function loadFromProject(string $projectRoot): self maps: $maps = self::discoverMaps($projectRoot), mapIds: array_map(static fn(ProjectMap $map): string => $map->mapId, $maps), actorDatabase: ProjectActorDatabase::fromProject($projectRoot), - classDatabase: ProjectClassDatabase::fromProject($projectRoot), skillDatabase: ProjectSkillDatabase::fromProject($projectRoot), systemDatabase: ProjectSystemDatabase::fromProject($projectRoot), questDatabase: ProjectQuestDatabase::fromProject($projectRoot), @@ -281,7 +279,6 @@ public function hasUnsavedChanges(): bool } return $this->actorDatabase->isDirty() - || $this->classDatabase->isDirty() || $this->skillDatabase->isDirty() || $this->systemDatabase->isDirty() || $this->questDatabase->isDirty() @@ -323,13 +320,12 @@ public function listUnsavedChanges(): array * Read-only record categories are left out: they hold no edits, and * asking them to save would raise instead of doing nothing. * - * @return array + * @return array */ public function listSaveableDatabases(): array { $databases = [ 'Actors' => $this->actorDatabase, - 'Classes' => $this->classDatabase, 'Skills' => $this->skillDatabase, 'Quests' => $this->questDatabase, 'System' => $this->systemDatabase, @@ -410,7 +406,6 @@ private function withMaps(array $maps): self maps: $maps, mapIds: array_map(static fn(ProjectMap $workspaceMap): string => $workspaceMap->mapId, $maps), actorDatabase: $this->actorDatabase, - classDatabase: $this->classDatabase, skillDatabase: $this->skillDatabase, systemDatabase: $this->systemDatabase, questDatabase: $this->questDatabase, diff --git a/tests/Unit/ClassRecordsTest.php b/tests/Unit/ClassRecordsTest.php new file mode 100644 index 0000000..de0c68d --- /dev/null +++ b/tests/Unit/ClassRecordsTest.php @@ -0,0 +1,62 @@ +listDatabaseRecords('classes'); + + expect($records['records'])->toBe(['Vanguard', 'Oracle']) + ->and($records['editable'])->toBeTrue() + ->and($records['canCreate'])->toBeTrue(); +}); + +it('edits what the engine reads in its own source: curves, equipment types and skills learned', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/classes.php'; + file_put_contents($path, str_replace(" 'name' => 'Oracle',", " // The healers.\n 'name' => 'Oracle',", (string) file_get_contents($path))); + $classes = classRecords($root); + + $classes->setField(1, 'parameterCurves.speed.extraGrowth', '18'); + $classes->setField(1, 'equipment.weapons', 'Staff, Wand'); + $learn = $classes->addSubItem(1); + $classes->setField(1, ProjectRecordDatabase::subFieldId('learn', $learn, 'skill'), 'Fire'); + $classes->setField(1, ProjectRecordDatabase::subFieldId('learn', $learn, 'level'), '3'); + $classes->save(); + + $oracle = (require $path)[1]; + + expect((string) file_get_contents($path))->toContain('// The healers.') + ->and($oracle['parameterCurves']['speed']['extraGrowth'])->toBe(18) + ->and($oracle['equipment']['weapons'])->toBe(['Staff', 'Wand']) + ->and($oracle['skillsToLearn'])->toBe([['level' => 3, 'skill' => 'Fire']]) + ->and($oracle['parameterCurves']['totalHp'])->toBe(['baseValue' => 90, 'extraGrowth' => 360, 'flatIncrement' => 28]) + ->and((require $path)[0])->toBe((require makeTemporaryProject() . '/assets/Data/classes.php')[0]); +}); + +it('gives a new class the next id and a name of its own', function () { + $classes = classRecords(makeTemporaryProject()); + $index = $classes->addRecord(); + $record = $classes->getRecordByIndex($index); + + expect($record?->get('id'))->toBe(3) + ->and($classes->getEntryLabels())->toBe(['Vanguard', 'Oracle', 'New Class']); +}); + +it('offers the engine\'s weapon and armor types for a class\'s equipment', function () { + $session = EditorSession::open(makeTemporaryProject()); + $map = $session->describeMaps()[0]['id'] ?? ''; + $values = static fn(string $category): array => array_column($session->listReferences($map, $category), 'value'); + + expect($values('weapon_types'))->toContain('Sword', 'Staff') + ->and($values('armor_types'))->toContain('Heavy Armor', 'Large Shield') + ->and($values('classes'))->toBe(['Vanguard', 'Oracle']); +}); diff --git a/tests/Unit/DatabaseDeletionTest.php b/tests/Unit/DatabaseDeletionTest.php index 5f89bb3..18918e8 100644 --- a/tests/Unit/DatabaseDeletionTest.php +++ b/tests/Unit/DatabaseDeletionTest.php @@ -20,8 +20,8 @@ $workspace = getEditorProperty($editor, 'workspace'); expect(getEditorProperty($editor, 'isDatabaseEntryDeleteConfirmationOpen'))->toBeFalse() - ->and($workspace->classDatabase->getClasses())->toHaveCount(2) - ->and($workspace->classDatabase->isDirty())->toBeFalse() + ->and($workspace->getRecordDatabase('classes')->getRecords())->toHaveCount(2) + ->and($workspace->getRecordDatabase('classes')->isDirty())->toBeFalse() ->and(getEditorProperty($editor, 'pendingDatabaseDeletion'))->toBeNull(); } finally { removeDirectoryRecursively($root); @@ -42,7 +42,7 @@ $workspace = getEditorProperty($editor, 'workspace'); expect(getEditorProperty($editor, 'isDatabaseEntryDeleteConfirmationOpen'))->toBeFalse() - ->and($workspace->classDatabase->getClasses())->toHaveCount(2); + ->and($workspace->getRecordDatabase('classes')->getRecords())->toHaveCount(2); } } finally { removeDirectoryRecursively($root); @@ -55,21 +55,21 @@ try { $editor = deletionEditor($root); openDatabaseCategory($editor, 'classes'); - setEditorProperty($editor, 'databaseSelectedClassIndex', 1); + setEditorProperty($editor, 'databaseSelectedRecordIndexes', ['classes' => 1]); callEditorMethod($editor, 'dispatchInput', "\033[3~"); callEditorMethod($editor, 'dispatchInput', 'y'); /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - $names = array_map(static fn(object $class): string => $class->getName(), $workspace->classDatabase->getClasses()); + $names = $workspace->getRecordDatabase('classes')->getEntryLabels(); expect($names)->toBe(['Vanguard']) - ->and($workspace->classDatabase->isDirty())->toBeTrue() - ->and(getEditorProperty($editor, 'databaseSelectedClassIndex'))->toBe(0); + ->and($workspace->getRecordDatabase('classes')->isDirty())->toBeTrue() + ->and(getEditorProperty($editor, 'databaseSelectedRecordIndexes')['classes'] ?? 0)->toBe(0); callEditorMethod($editor, 'dispatchInput', "\x1a"); - $restored = array_map(static fn(object $class): string => $class->getName(), $workspace->classDatabase->getClasses()); + $restored = $workspace->getRecordDatabase('classes')->getEntryLabels(); expect($restored)->toBe(['Vanguard', 'Oracle']); } finally { diff --git a/tests/Unit/ListFilterTest.php b/tests/Unit/ListFilterTest.php index 948100e..7b14263 100644 --- a/tests/Unit/ListFilterTest.php +++ b/tests/Unit/ListFilterTest.php @@ -149,12 +149,12 @@ function filterEditor(): Editor // Oracle is index 1 in classes.php; the selection follows the filter. expect(callEditorMethod($editor, 'getVisibleDatabaseEntryIndexes'))->toBe([1]) - ->and(getEditorProperty($editor, 'databaseSelectedClassIndex'))->toBe(1); + ->and(getEditorProperty($editor, 'databaseSelectedRecordIndexes')['classes'] ?? 0)->toBe(1); // Down inside a one-row filter cannot walk onto a hidden entry. callEditorMethod($editor, 'dispatchInput', "\033[B"); - expect(getEditorProperty($editor, 'databaseSelectedClassIndex'))->toBe(1); + expect(getEditorProperty($editor, 'databaseSelectedRecordIndexes')['classes'] ?? 0)->toBe(1); callEditorMethod($editor, 'dispatchInput', "\033"); diff --git a/tests/Unit/NavigationStackTest.php b/tests/Unit/NavigationStackTest.php index 35bcb43..daaa9bd 100644 --- a/tests/Unit/NavigationStackTest.php +++ b/tests/Unit/NavigationStackTest.php @@ -90,7 +90,7 @@ function navigationEditor(): Editor callEditorMethod($editor, 'dispatchInput', "\x07"); expect(getEditorProperty($editor, 'databaseCategoryIndex'))->toBe(DatabaseCatalog::indexOf('classes')) - ->and(getEditorProperty($editor, 'databaseSelectedClassIndex'))->toBe(1); + ->and(getEditorProperty($editor, 'databaseSelectedRecordIndexes')['classes'] ?? 0)->toBe(1); /** @var NavigationStack $navigation */ $navigation = getEditorProperty($editor, 'navigation'); diff --git a/tests/Unit/SaveIntegrityTest.php b/tests/Unit/SaveIntegrityTest.php index bc314a6..1883e05 100644 --- a/tests/Unit/SaveIntegrityTest.php +++ b/tests/Unit/SaveIntegrityTest.php @@ -319,7 +319,6 @@ function hashTree(string $directory): array } $workspace->actorDatabase->save(); - $workspace->classDatabase->save(); $workspace->skillDatabase->save(); $workspace->questDatabase->save(); $workspace->systemDatabase->save(); From d056d558ac654bc0eb0a4e73798dbedbd4f22d93 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 13:22:58 +0200 Subject: [PATCH 150/344] fix(editor): list numbered records by number and name in the terminal The class list showed each class's id before its name until classes moved onto the shared record service, which listed names only. Every record category identified by a number now lists it before the name, classes and animations alike. --- src/Editor.php | 9 +++++++-- tests/Unit/ClassRecordsTest.php | 7 +++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Editor.php b/src/Editor.php index e2556c9..4516a27 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -14264,8 +14264,13 @@ private function getDatabaseRecordListLines(): array } $prefix = $index === $selectedIndex ? '> ' : ' '; - $dirty = $database->getRecordByIndex($index)?->isDirty() ? ' *' : ''; - $lines[] = sprintf('%s%s%s', $prefix, $label, $dirty); + $record = $database->getRecordByIndex($index); + $dirty = $record?->isDirty() ? ' *' : ''; + // Entries identified by a number (classes, animations) list it + // before their name. + $identity = $database->schema->identityKey === null ? null : $record?->get($database->schema->identityKey); + $number = is_int($identity) ? sprintf('%04d ', $identity) : ''; + $lines[] = sprintf('%s%s%s%s', $prefix, $number, $label, $dirty); } if ($lines === []) { diff --git a/tests/Unit/ClassRecordsTest.php b/tests/Unit/ClassRecordsTest.php index de0c68d..60d12a7 100644 --- a/tests/Unit/ClassRecordsTest.php +++ b/tests/Unit/ClassRecordsTest.php @@ -60,3 +60,10 @@ function classRecords(string $root): ProjectRecordDatabase ->and($values('armor_types'))->toContain('Heavy Armor', 'Large Shield') ->and($values('classes'))->toBe(['Vanguard', 'Oracle']); }); + +it('lists classes in the terminal by their number and name', function () { + $editor = deletionEditor(makeTemporaryProject()); + openDatabaseCategory($editor, 'classes'); + + expect(callEditorMethod($editor, 'getDatabaseListLines'))->toBe(['> 0001 Vanguard', ' 0002 Oracle']); +}); From dc88ede71edd35e23ce03530f48668cd74d7f0ae Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 13:49:01 +0200 Subject: [PATCH 151/344] refactor(database): edit quests through the shared record service Quests were built by hand in the terminal editor over their own database, which rewrote quests.php on every save and which the GUI session could not open. They are now a schema-driven category, so both editors edit them the same way and saves change only what was edited. Optional and reward item quantities are now editable too. The record service gains what quests needed, for every category: - A record can hold lists beside its own, each under a heading row that is where an entry is added to it (a quest's reward items). - A list entry authored as a bare value reads as that value's field and is written back bare while it holds nothing else, and a list can drop its key when it empties. - An identity can follow its label while nothing refers to it; the workspace says what refers to a quest. The rename note reaches both editors. - A unique field now refuses a single value another record has, ignoring case, not only list members. Two classes or two enemies can no longer share a name. Changed in the terminal: Shift+O and Shift+X act on the row under the cursor, adding after or removing that entry in whichever list it is in, instead of always the end of the record's own list; objective rows read Objective 1 Type rather than Obj 1 Type. The GUI names the record's own list for its add button and shows only the add button on a list heading. --- docs/manual.md | 2 +- src/Database/ProjectRecord.php | 38 +- src/Database/ProjectRecordDatabase.php | 169 +++++- src/Database/QuestReferences.php | 4 +- src/Database/RecordAuthoring.php | 77 ++- src/Database/RecordChange.php | 2 + src/Database/RecordItem.php | 7 +- src/Database/RecordSchema.php | 27 + src/Database/RecordSchemaCatalog.php | 95 +++ src/Database/RecordSubList.php | 9 + src/Database/ReferenceCatalog.php | 5 +- src/Editor.php | 567 ++---------------- src/ProjectQuest.php | 511 +--------------- src/ProjectQuestDatabase.php | 467 --------------- src/ProjectWorkspace.php | 27 +- src/Session/EditorSession.php | 20 +- src/Validation/ProjectValidator.php | 2 +- src/Validation/SaveCompatibilityValidator.php | 2 +- tests/Unit/QuestDatabaseTest.php | 147 ++--- tests/Unit/QuestIdentityTest.php | 49 +- tests/Unit/QuestRewardItemsTest.php | 136 +++-- tests/Unit/SaveIntegrityTest.php | 1 - 22 files changed, 663 insertions(+), 1701 deletions(-) delete mode 100644 src/ProjectQuestDatabase.php diff --git a/docs/manual.md b/docs/manual.md index f884da1..f3aa9e9 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1022,7 +1022,7 @@ status line says exactly why. | Animations | `assets/Data/animations.php` | Editable | | Tilesets | - | Read-only - the engine has no tileset system | | Common Events | `assets/Events/*.php` | Editable | -| Quests | `assets/Data/quests.php` | Editable | +| Quests | `assets/Data/quests.php` | Editable - objectives (the target picked by type), reward items with quantities, prerequisites, optional; the id follows the name until something refers to it | | Skits | `assets/Data/Skits/*.php` | Editable | | Knowledge | `assets/Data/knowledge.php` | Editable | | Knowledge Reports | `assets/Data/knowledge.php` | Editable | diff --git a/src/Database/ProjectRecord.php b/src/Database/ProjectRecord.php index e8bd676..89d2855 100644 --- a/src/Database/ProjectRecord.php +++ b/src/Database/ProjectRecord.php @@ -393,11 +393,26 @@ private static function withArrayValue(array $array, array $path, mixed $value): * @param string $key The payload key holding the list. * @return array> */ - public function getSubList(string $key): array + public function getSubList(string $key, ?string $scalarKey = null): array { $list = $this->get($key); - return is_array($list) ? array_values(array_filter($list, is_array(...))) : []; + if (! is_array($list)) { + return []; + } + + $entries = []; + + foreach ($list as $entry) { + if (is_array($entry)) { + $entries[] = $entry; + } elseif ($scalarKey !== null && is_scalar($entry)) { + // An entry authored as a bare value is that value's field. + $entries[] = [$scalarKey => $entry]; + } + } + + return $entries; } /** @@ -407,8 +422,25 @@ public function getSubList(string $key): array * @param array> $list The new list. * @return void */ - public function setSubList(string $key, array $list): void + public function setSubList(string $key, array $list, ?string $scalarKey = null, bool $removeWhenEmpty = false): void { + if ($list === [] && $removeWhenEmpty) { + // An empty list would read as "none, explicitly"; absence is how + // such a list is authored. + $this->set($key, null); + + return; + } + + if ($scalarKey !== null) { + // An entry holding nothing but that field stays a bare value, as + // it was authored; one with more is written as its fields. + $list = array_map( + static fn(array $entry): mixed => array_keys($entry) === [$scalarKey] ? $entry[$scalarKey] : $entry, + $list, + ); + } + $this->set($key, array_values($list)); } diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 9ff2dc6..97d8aec 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -61,6 +61,7 @@ private function __construct( private ?\Closure $writeBack = null, private ?string $projectRoot = null, private ?ProjectConfig $config = null, + private ?\Closure $identityReferences = null, ) { if ($schema->storage === RecordStorage::LIST_FILE && $schema->projection === null) { // What the file declares for each record -- the identity it is @@ -480,14 +481,21 @@ public function getSettingsFields(int $index): array ]; } - $subList = $this->schema->subList; - - if ($subList === null) { - return $fields; - } + foreach ($this->schema->getInlineSubLists() as $subList) { + if ($subList !== $this->schema->subList) { + // A list beside the record's own is headed by its name and + // count; its heading is where an entry is added to it. + $fields[] = [ + 'label' => $subList->heading, + 'value' => sprintf('%d', count($record->getSubList($subList->key, $subList->scalarKey))), + 'field' => $subList->prefix . 'List', + 'listHeading' => true, + ]; + } - foreach ($record->getSubList($subList->key) as $entryIndex => $entry) { - $fields = [...$fields, ...$this->describeSubEntryFields($subList, $entryIndex, $entry, $isEditable, [])]; + foreach ($record->getSubList($subList->key, $subList->scalarKey) as $entryIndex => $entry) { + $fields = [...$fields, ...$this->describeSubEntryFields($subList, $entryIndex, $entry, $isEditable, [])]; + } } return $fields; @@ -669,9 +677,11 @@ public function setField(int $index, string $fieldId, string $rawValue): void } } - if ($subList !== null && preg_match('/^' . preg_quote($subList->prefix, '/') . '(\d+)([A-Za-z][A-Za-z0-9]*)$/', $fieldId, $matches) === 1) { - $this->setSubField($record, $subList, intval($matches[1]), $matches[2], $rawValue); - return; + foreach ($this->schema->getInlineSubLists() as $inlineList) { + if (preg_match('/^' . preg_quote($inlineList->prefix, '/') . '(\d+)([A-Za-z][A-Za-z0-9]*)$/', $fieldId, $matches) === 1) { + $this->setSubField($record, $inlineList, intval($matches[1]), $matches[2], $rawValue); + return; + } } foreach ($this->schema->fieldsFor($record->toArray()) as $field) { @@ -696,9 +706,15 @@ public function setField(int $index, string $fieldId, string $rawValue): void $value = self::coerce($field, $rawValue); if ($field->uniqueAcrossRecords) { - $this->assertMembersUnclaimed($record, $field, is_array($value) ? $value : []); + is_array($value) + ? $this->assertMembersUnclaimed($record, $field, $value) + : $this->assertValueUnclaimed($record, $field, $value); } + $previousIdentity = $this->schema->identityKey === null ? null : $record->get($this->schema->identityKey); $record->set($field->key, $value); + if ($field->key === $this->schema->labelKey && $this->schema->identityFollowsLabel) { + $this->followLabelWithIdentity($record, strval($previousIdentity)); + } $this->touchState(); return; @@ -735,6 +751,67 @@ private function withoutClaimedMembers(array $payload): array return $payload; } + /** + * Refuses a value another record of the category already has, ignoring + * case, before anything changes: two classes or enemies by one name. + * + * @throws \InvalidArgumentException When another record has it. + */ + private function assertValueUnclaimed(ProjectRecord $record, RecordField $field, mixed $value): void + { + if (! is_scalar($value) || trim(strval($value)) === '') { + return; + } + + foreach ($this->getRecords() as $other) { + $theirs = $other === $record ? null : $other->get($field->key); + + if (is_scalar($theirs) && mb_strtolower(trim(strval($theirs))) === mb_strtolower(trim(strval($value)))) { + throw new \InvalidArgumentException(sprintf( + 'Another %s already has %s %s.', + $this->schema->entryNoun, + mb_strtolower($field->label), + trim(strval($value)), + )); + } + } + } + + /** + * Keeps a record's identity in step with its label, as the identity's + * slug, while nothing refers to the identity it has; once something does, + * the identity stays, so the reference keeps resolving. + */ + private function followLabelWithIdentity(ProjectRecord $record, string $previousIdentity): void + { + $identityKey = (string) $this->schema->identityKey; + + if ($previousIdentity !== '' && $this->identityReferences !== null && ($this->identityReferences)($previousIdentity)) { + return; + } + + $taken = []; + foreach ($this->getRecords() as $other) { + if ($other !== $record) { + $taken[] = $other->getDisplayValue($identityKey); + } + } + + $record->set($identityKey, Slug::unique(strval($record->get($this->schema->labelKey)), $taken, Slug::of($this->schema->entryNoun))); + } + + /** + * Says whether something in the project refers to a record by an + * identity, for a category whose identities follow their labels. The + * workspace supplies it: what refers to a record lives across the project. + * + * @param \Closure(string): bool $isReferenced + */ + public function useIdentityReferences(\Closure $isReferenced): void + { + $this->identityReferences = $isReferenced; + } + /** * Refuses a list member another record of the category already holds, * naming that record, before anything changes. @@ -1085,19 +1162,19 @@ public function duplicateRecord(int $index): ?int * @param int|null $at Where to insert it, or null for the end. * @return int|null The new entry index. */ - public function addSubItem(int $index, ?array $entry = null, ?int $at = null): ?int + public function addSubItem(int $index, ?array $entry = null, ?int $at = null, ?string $listKey = null): ?int { $record = $this->getRecordByIndex($index); - $subList = $this->schema->subList; + $subList = $this->schema->findInlineSubList($listKey); if ($subList === null || ! $record instanceof ProjectRecord || ! $this->isEditable() || ! $record->isEditable()) { return null; } - $entries = $record->getSubList($subList->key); + $entries = $record->getSubList($subList->key, $subList->scalarKey); $position = $at === null ? count($entries) : max(0, min(count($entries), $at)); array_splice($entries, $position, 0, [$entry ?? $subList->blank]); - $record->setSubList($subList->key, $entries); + $record->setSubList($subList->key, $entries, $subList->scalarKey, $subList->removeWhenEmpty); $this->touchState(); return $position; @@ -1110,23 +1187,23 @@ public function addSubItem(int $index, ?array $entry = null, ?int $at = null): ? * @param int $entryIndex The entry index. * @return array|null The removed entry payload. */ - public function removeSubItem(int $index, int $entryIndex): ?array + public function removeSubItem(int $index, int $entryIndex, ?string $listKey = null): ?array { $record = $this->getRecordByIndex($index); - $subList = $this->schema->subList; + $subList = $this->schema->findInlineSubList($listKey); if ($subList === null || ! $record instanceof ProjectRecord || ! $this->isEditable() || ! $record->isEditable()) { return null; } - $entries = $record->getSubList($subList->key); + $entries = $record->getSubList($subList->key, $subList->scalarKey); if (! array_key_exists($entryIndex, $entries)) { return null; } [$removed] = array_splice($entries, $entryIndex, 1); - $record->setSubList($subList->key, $entries); + $record->setSubList($subList->key, $entries, $subList->scalarKey, $subList->removeWhenEmpty); $this->touchState(); return $removed; @@ -1140,19 +1217,19 @@ public function removeSubItem(int $index, int $entryIndex): ?array * @param array $entry The entry payload. * @return void */ - public function insertSubItem(int $index, int $entryIndex, array $entry): void + public function insertSubItem(int $index, int $entryIndex, array $entry, ?string $listKey = null): void { $record = $this->getRecordByIndex($index); - $subList = $this->schema->subList; + $subList = $this->schema->findInlineSubList($listKey); if ($subList === null || ! $record instanceof ProjectRecord) { return; } - $entries = $record->getSubList($subList->key); + $entries = $record->getSubList($subList->key, $subList->scalarKey); $entryIndex = max(0, min(count($entries), $entryIndex)); array_splice($entries, $entryIndex, 0, [$entry]); - $record->setSubList($subList->key, $entries); + $record->setSubList($subList->key, $entries, $subList->scalarKey, $subList->removeWhenEmpty); $this->touchState(); } @@ -1162,15 +1239,29 @@ public function insertSubItem(int $index, int $entryIndex, array $entry): void * @param int $index The record index. * @return int */ - public function countSubItems(int $index): int + public function countSubItems(int $index, ?string $listKey = null): int { - $subList = $this->schema->subList; + $subList = $this->schema->findInlineSubList($listKey); if ($subList === null) { return 0; } - return count($this->getRecordByIndex($index)?->getSubList($subList->key) ?? []); + return count($this->getRecordByIndex($index)?->getSubList($subList->key, $subList->scalarKey) ?? []); + } + + /** + * Returns the entries of one of the lists a record shows inline. + * + * @param int $index The record index. + * @param string|null $listKey The list; the record's own when null. + * @return array> + */ + public function getSubItems(int $index, ?string $listKey = null): array + { + $subList = $this->schema->findInlineSubList($listKey); + + return $subList === null ? [] : ($this->getRecordByIndex($index)?->getSubList($subList->key, $subList->scalarKey) ?? []); } /** @@ -1192,7 +1283,7 @@ public function nestedSubListContext(int $index, string $fieldId): ?array } $parentIndex = intval($parentMatch[1]); - $entry = $record->getSubList($subList->key)[$parentIndex] ?? null; + $entry = $record->getSubList($subList->key, $subList->scalarKey)[$parentIndex] ?? null; $nestedList = is_array($entry) ? $subList->nestedListFor($entry) : null; if ($nestedList === null) { @@ -2427,7 +2518,7 @@ private function saveDirectory(): void */ private function setSubField(ProjectRecord $record, RecordSubList $subList, int $entryIndex, string $token, string $rawValue): void { - $entries = $record->getSubList($subList->key); + $entries = $record->getSubList($subList->key, $subList->scalarKey); if (! array_key_exists($entryIndex, $entries)) { return; @@ -2444,7 +2535,7 @@ private function setSubField(ProjectRecord $record, RecordSubList $subList, int self::coerce($field, $rawValue), ); $entries[$entryIndex] = $subList->removeConflictingFields($entries[$entryIndex], $field->key); - $record->setSubList($subList->key, $entries); + $record->setSubList($subList->key, $entries, $subList->scalarKey, $subList->removeWhenEmpty); $this->touchState(); return; @@ -2461,7 +2552,7 @@ private function setNestedSubField( string $fieldToken, string $rawValue, ): void { - $entries = $record->getSubList($subList->key); + $entries = $record->getSubList($subList->key, $subList->scalarKey); $entry = $entries[$entryIndex] ?? null; if (! is_array($entry)) { @@ -2472,7 +2563,7 @@ private function setNestedSubField( if ($written !== null) { $entries[$entryIndex] = $written; - $record->setSubList($subList->key, $entries); + $record->setSubList($subList->key, $entries, $subList->scalarKey, $subList->removeWhenEmpty); $this->touchState(); } } @@ -2599,6 +2690,18 @@ public function frameNestedContext(int $recordIndex, array $framePath, string $f */ public function locateItem(int $recordIndex, array $framePath, string $fieldId): ?RecordItem { + if ($framePath === []) { + foreach ($this->schema->subLists as $inlineList) { + if ($fieldId === $inlineList->prefix . 'List') { + return new RecordItem(RecordItem::LIST, [], -1, null, $inlineList->heading, RecordItem::ENTRY, $inlineList->singular, $inlineList->key); + } + + if (preg_match('/^' . preg_quote($inlineList->prefix, '/') . '(\d+)[A-Za-z]/', $fieldId, $matches) === 1) { + return new RecordItem(RecordItem::ENTRY, [], intval($matches[1]), null, $inlineList->singular, listKey: $inlineList->key); + } + } + } + $list = $this->getFrameSubList($framePath); $entries = $this->getFrameCommands($recordIndex, $framePath); @@ -2764,7 +2867,7 @@ private function nestedListForParent(int $index, int $parentIndex): ?array return null; } - $entries = $record->getSubList($subList->key); + $entries = $record->getSubList($subList->key, $subList->scalarKey); $parent = $entries[$parentIndex] ?? null; $nestedList = is_array($parent) ? $subList->nestedListFor($parent) : null; @@ -2797,7 +2900,7 @@ private function writeNestedSubList(array $context, array $nestedEntries): void $entries = $context['entries']; $entries[$context['parentIndex']][$context['list']->key] = array_values($nestedEntries); - $context['record']->setSubList($subList->key, $entries); + $context['record']->setSubList($subList->key, $entries, $subList->scalarKey, $subList->removeWhenEmpty); $this->touchState(); } diff --git a/src/Database/QuestReferences.php b/src/Database/QuestReferences.php index 8012cb1..c89535e 100644 --- a/src/Database/QuestReferences.php +++ b/src/Database/QuestReferences.php @@ -67,8 +67,8 @@ private function inQuests(string $questId): array { $found = []; - foreach ($this->workspace->questDatabase->getQuests() as $quest) { - if (! $quest instanceof ProjectQuest || $quest->getId() === $questId) { + foreach ($this->workspace->getQuests() as $quest) { + if ($quest->getId() === $questId) { continue; } diff --git a/src/Database/RecordAuthoring.php b/src/Database/RecordAuthoring.php index fbac6c0..bfe62c2 100644 --- a/src/Database/RecordAuthoring.php +++ b/src/Database/RecordAuthoring.php @@ -70,7 +70,34 @@ public function applyField( sprintf('%s edit', $label), static fn() => $record->restorePayload($after), static fn() => $record->restorePayload($before), - ), $index); + ), $index, note: self::describeIdentityFollow($database, $fieldId, $framePath, $before, $after)); + } + + /** + * What a rename did to an identity that follows its label: moved with it, + * or stayed because something refers to it. + * + * @param array|object $before + * @param array|object $after + */ + private static function describeIdentityFollow(ProjectRecordDatabase $database, string $fieldId, array $framePath, array|object $before, array|object $after): ?string + { + $schema = $database->schema; + + if (! $schema->identityFollowsLabel || $framePath !== [] || $fieldId !== $schema->labelKey || ! is_array($before) || ! is_array($after)) { + return null; + } + + $was = strval($before[$schema->identityKey] ?? ''); + $is = strval($after[$schema->identityKey] ?? ''); + + if ($was !== $is) { + return sprintf('Its id is now %s.', $is); + } + + return Slug::of(strval($after[$schema->labelKey] ?? '')) === $is + ? null + : sprintf('Its id stays %s, which other things point at.', $is); } /** @@ -86,7 +113,9 @@ public function applyField( */ public function addItem(ProjectRecordDatabase $database, int $index, array $framePath, ?string $fieldId, bool $child = false): RecordChange { - $this->requireList($database, $index, $framePath); + // The list is the row's, so it is required where the row is found. + $this->requireRecord($database, $index); + $this->requireFrame($database, $index, $framePath); if ($fieldId === null) { if ($child) { @@ -99,6 +128,11 @@ public function addItem(ProjectRecordDatabase $database, int $index, array $fram $item = $database->locateItem($index, $framePath, $fieldId) ?? throw new RecordRefusal('That row belongs to no item to add after; add at the end of the list instead.'); + if ($item->kind === RecordItem::LIST) { + // A list's heading: the entry goes first in that list. + return $this->addEntry($database, $index, $framePath, -1, $item->listKey); + } + if ($child) { return match ($item->kind === RecordItem::ENTRY ? $item->childKind : null) { RecordItem::NESTED => $this->addNestedItem($database, $index, $framePath, $item->entryIndex), @@ -110,7 +144,7 @@ public function addItem(ProjectRecordDatabase $database, int $index, array $fram return match ($item->kind) { RecordItem::NESTED => $this->addNestedItem($database, $index, $framePath, $item->entryIndex, $item->childIndex), RecordItem::OPTION => $this->addOption($database, $index, $framePath, $item->entryIndex, $item->childIndex), - default => $this->addEntry($database, $index, $framePath, $item->entryIndex), + default => $this->addEntry($database, $index, $framePath, $item->entryIndex, $item->listKey), }; } @@ -124,14 +158,15 @@ public function addItem(ProjectRecordDatabase $database, int $index, array $fram */ public function removeItem(ProjectRecordDatabase $database, int $index, array $framePath, string $fieldId): RecordChange { - $this->requireList($database, $index, $framePath); + $this->requireRecord($database, $index); + $this->requireFrame($database, $index, $framePath); $item = $database->locateItem($index, $framePath, $fieldId); return match ($item?->kind) { - null => new RecordChange(null), + null, RecordItem::LIST => new RecordChange(null), RecordItem::NESTED => $this->removeNestedItem($database, $index, $framePath, $item->entryIndex, (int) $item->childIndex), RecordItem::OPTION => $this->removeOption($database, $index, $framePath, $item->entryIndex, (int) $item->childIndex), - default => $this->removeEntry($database, $index, $framePath, $item->entryIndex), + default => $this->removeEntry($database, $index, $framePath, $item->entryIndex, $item->listKey), }; } @@ -140,16 +175,17 @@ public function removeItem(ProjectRecordDatabase $database, int $index, array $f * frame: after an entry, or at the end. * * @param array $framePath The frame; [] for the record's own list. - * @param int|null $after The entry it follows; null for the end. + * @param int|null $after The entry it follows; null for the end, -1 for the start. + * @param string|null $listKey At the record's root, the inline list; the record's own when null. * @throws RecordRefusal When the category or record is read-only or gone, or there is no such list. */ - public function addEntry(ProjectRecordDatabase $database, int $index, array $framePath, ?int $after = null): RecordChange + public function addEntry(ProjectRecordDatabase $database, int $index, array $framePath, ?int $after = null, ?string $listKey = null): RecordChange { - $list = $this->requireList($database, $index, $framePath); + $list = $this->requireList($database, $index, $framePath, $listKey); $at = $framePath === [] - ? $database->addSubItem($index, at: $after === null ? null : $after + 1) + ? $database->addSubItem($index, at: $after === null ? null : $after + 1, listKey: $listKey) : $database->addFrameCommand($index, $framePath, $after); - $entry = $at === null ? null : ($database->getFrameCommands($index, $framePath)[$at] ?? null); + $entry = $at === null ? null : (($framePath === [] ? $database->getSubItems($index, $listKey) : $database->getFrameCommands($index, $framePath))[$at] ?? null); if ($at === null || ! is_array($entry)) { return new RecordChange(null); @@ -158,10 +194,10 @@ public function addEntry(ProjectRecordDatabase $database, int $index, array $fra return new RecordChange(new GenericCommand( sprintf('%s add', ucfirst($list->singular)), $framePath === [] - ? static fn() => $database->insertSubItem($index, $at, $entry) + ? static fn() => $database->insertSubItem($index, $at, $entry, $listKey) : static fn() => $database->insertFrameCommand($index, $framePath, $at, $entry), $framePath === [] - ? static fn() => $database->removeSubItem($index, $at) + ? static fn() => $database->removeSubItem($index, $at, $listKey) : static fn() => $database->removeFrameCommand($index, $framePath, $at), ), $at); } @@ -171,13 +207,14 @@ public function addEntry(ProjectRecordDatabase $database, int $index, array $fra * with everything under it. * * @param array $framePath The frame; [] for the record's own list. + * @param string|null $listKey At the record's root, the inline list; the record's own when null. * @throws RecordRefusal When the category or record is read-only or gone, or there is no such list. */ - public function removeEntry(ProjectRecordDatabase $database, int $index, array $framePath, int $entryIndex): RecordChange + public function removeEntry(ProjectRecordDatabase $database, int $index, array $framePath, int $entryIndex, ?string $listKey = null): RecordChange { - $list = $this->requireList($database, $index, $framePath); + $list = $this->requireList($database, $index, $framePath, $listKey); $removed = $framePath === [] - ? $database->removeSubItem($index, $entryIndex) + ? $database->removeSubItem($index, $entryIndex, $listKey) : $database->removeFrameCommand($index, $framePath, $entryIndex); if ($removed === null) { @@ -187,10 +224,10 @@ public function removeEntry(ProjectRecordDatabase $database, int $index, array $ return new RecordChange(new GenericCommand( sprintf('%s remove', ucfirst($list->singular)), $framePath === [] - ? static fn() => $database->removeSubItem($index, $entryIndex) + ? static fn() => $database->removeSubItem($index, $entryIndex, $listKey) : static fn() => $database->removeFrameCommand($index, $framePath, $entryIndex), $framePath === [] - ? static fn() => $database->insertSubItem($index, $entryIndex, $removed) + ? static fn() => $database->insertSubItem($index, $entryIndex, $removed, $listKey) : static fn() => $database->insertFrameCommand($index, $framePath, $entryIndex, $removed), ), $entryIndex, $removed); } @@ -441,12 +478,12 @@ private function requireFrame(ProjectRecordDatabase $database, int $index, array * @param array $framePath * @throws RecordRefusal */ - private function requireList(ProjectRecordDatabase $database, int $index, array $framePath): RecordSubList + private function requireList(ProjectRecordDatabase $database, int $index, array $framePath, ?string $listKey = null): RecordSubList { $this->requireRecord($database, $index); $this->requireFrame($database, $index, $framePath); - return $database->getFrameSubList($framePath) + return ($framePath === [] && $listKey !== null ? $database->schema->findInlineSubList($listKey) : $database->getFrameSubList($framePath)) ?? throw new RecordRefusal(sprintf('A %s has no list of its own to add to.', $database->schema->entryNoun)); } diff --git a/src/Database/RecordChange.php b/src/Database/RecordChange.php index bd481c1..90b38aa 100644 --- a/src/Database/RecordChange.php +++ b/src/Database/RecordChange.php @@ -18,11 +18,13 @@ * @param Command|null $command The applied change's command; null when nothing changed. * @param int|null $index Where it landed: the record for a record operation (the one to select next after a delete, null when none is left), the item for an item operation. * @param array|null $removed What an item removal took out, as the list held it. + * @param string|null $note What else the change did that the author should hear about (a renamed quest's id). */ public function __construct( public ?Command $command, public ?int $index = null, public ?array $removed = null, + public ?string $note = null, ) { } } diff --git a/src/Database/RecordItem.php b/src/Database/RecordItem.php index de4d796..17b1021 100644 --- a/src/Database/RecordItem.php +++ b/src/Database/RecordItem.php @@ -26,14 +26,18 @@ /** An option of a choice command. */ public const string OPTION = 'option'; + /** A list's heading: entries are added to it, and it is never removed. */ + public const string LIST = 'list'; + /** - * @param string $kind One of {@see ENTRY}, {@see NESTED} or {@see OPTION}. + * @param string $kind One of {@see ENTRY}, {@see NESTED}, {@see OPTION} or {@see LIST}. * @param array $framePath The frame whose list holds the entry. * @param int $entryIndex The entry, or the entry a nested item or option belongs to. * @param int|null $childIndex The nested item or option; null for an entry. * @param string $noun What the item is called, singular. * @param string|null $childKind What an entry holds beneath it, when it can hold more: {@see NESTED} for its nested list's entries, {@see OPTION} for a choice's options. * @param string|null $childNoun What those are called, singular. + * @param string|null $listKey The inline list the item is in, when it is not the record's own. */ public function __construct( public string $kind, @@ -43,6 +47,7 @@ public function __construct( public string $noun, public ?string $childKind = null, public ?string $childNoun = null, + public ?string $listKey = null, ) { } } diff --git a/src/Database/RecordSchema.php b/src/Database/RecordSchema.php index 09cd4d9..f78b5a4 100644 --- a/src/Database/RecordSchema.php +++ b/src/Database/RecordSchema.php @@ -47,6 +47,11 @@ * @param string|null $recordClass For one-file-per-record categories whose * files return `['class' => $recordClass, 'data' => [...]]`: the record is * the data, and saves write it back inside the same envelope. + * @param list $subLists Lists a record holds beside its own + * (`subList`), each shown inline under its own heading: a quest's reward + * items beside its objectives. Every list's prefix is its own. + * @param bool $identityFollowsLabel Whether the identity is its label's slug, kept + * in step on rename while nothing refers to it (a quest's id follows its name). */ public function __construct( public string $key, @@ -69,9 +74,31 @@ public function __construct( public ?Closure $fieldsFor = null, public ?Closure $labelFor = null, public ?string $recordClass = null, + public array $subLists = [], + public bool $identityFollowsLabel = false, ) { } + /** + * The lists a record's rows show inline, its own first. + * + * @return list + */ + public function getInlineSubLists(): array + { + return $this->subList === null ? $this->subLists : [$this->subList, ...$this->subLists]; + } + + /** The inline list held under a payload key; the record's own when the key is null. */ + public function findInlineSubList(?string $key): ?RecordSubList + { + if ($key === null) { + return $this->subList; + } + + return array_find($this->getInlineSubLists(), static fn(RecordSubList $list): bool => $list->key === $key); + } + /** * Returns the fields one record offers. * diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index f659faa..b169061 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -29,6 +29,7 @@ use Ichiloto\Engine\Events\Interpreter\EventInterpreter; use Ichiloto\Engine\Entities\Enemies\Enemy; use Ichiloto\Engine\Entities\Enemies\EnemyCatalog; +use Ichiloto\Engine\Quests\QuestObjectiveType; use Ichiloto\Engine\Entities\Enumerations\ActionConditionType; use Ichiloto\Engine\Entities\Enumerations\ItemUserType; use Ichiloto\Engine\Progress\Knowledge\KnowledgeProgressService; @@ -81,6 +82,7 @@ public static function all(): array { $schemas = [ self::classes(), + self::quests(), self::states(), self::troops(), self::animations(), @@ -829,6 +831,99 @@ private static function optimizeExclusions(): RecordSchema ); } + /** + * Quests — `assets/Data/quests.php`, as the engine's `Quest::fromArray` + * reads it. A quest's id is its name's slug and follows a rename while + * nothing refers to it (the workspace knows what does). Objectives are + * its own list; the target an objective is picked from follows its type. + * Reward items are a second list, each a bare item name until it is given + * a quantity, the two forms the engine reads. + * + * @return RecordSchema + */ + private static function quests(): RecordSchema + { + $objectiveFields = static fn(?string $reference): array => [ + $reference === null + ? new RecordField('target', 'Target') + : RecordField::reference('target', 'Target', $reference), + new RecordField('quantity', 'Quantity', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('description', 'Text', removeWhenEmpty: true), + new RecordField('revealedDescription', 'Revealed', removeWhenEmpty: true), + new RecordField('revealConditions', 'Reveal When', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), + ]; + $variants = []; + + foreach (QuestObjectiveType::cases() as $type) { + $variants[$type->value] = $objectiveFields(match ($type) { + // Collecting is not limited to consumables: a quest may ask + // for a weapon or a piece of armor, and the runtime resolves + // all three from one catalogue. + QuestObjectiveType::COLLECT => 'inventory', + QuestObjectiveType::DEFEAT => 'enemies', + QuestObjectiveType::REACH_MAP => 'maps', + QuestObjectiveType::TALK_TO => 'actors', + default => null, + }); + } + + return new RecordSchema( + key: 'quests', + entryNoun: 'quest', + storage: RecordStorage::LIST_FILE, + relativePath: 'assets/Data/quests.php', + fields: [ + new RecordField('id', 'Id', isReadOnly: true), + new RecordField('name', 'Name'), + new RecordField('description', 'Description'), + new RecordField('giver', 'Giver'), + RecordField::boolean('optional', 'Optional'), + new RecordField('rewards.gold', 'Reward Gold', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('rewards.experience', 'Reward EXP', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('prerequisites', 'Prereqs', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), + ], + labelKey: 'name', + identityKey: 'id', + blank: [ + 'id' => 'new-quest', + 'name' => 'New Quest', + 'description' => '', + 'objectives' => [['type' => QuestObjectiveType::TALK_TO->value, 'target' => 'New Target']], + ], + subList: new RecordSubList( + key: 'objectives', + prefix: 'objective', + singular: 'objective', + fields: [ + new RecordField( + 'type', + 'Type', + options: array_map(static fn(QuestObjectiveType $type): string => $type->value, QuestObjectiveType::cases()), + ), + ], + blank: ['type' => QuestObjectiveType::TALK_TO->value, 'target' => 'New Target'], + variants: $variants, + variantKey: 'type', + ), + subLists: [ + new RecordSubList( + key: 'rewards.items', + prefix: 'reward', + singular: 'reward item', + fields: [ + RecordField::reference('item', 'Item', 'inventory'), + new RecordField('quantity', 'Quantity', InputControlType::INTEGER, removeWhenEmpty: true), + ], + blank: ['item' => ''], + heading: 'Reward Items', + scalarKey: 'item', + removeWhenEmpty: true, + ), + ], + identityFollowsLabel: true, + ); + } + /** * Classes — `assets/Data/classes.php`, the plain data the engine's * `ClassStore` reads: growth curves, equipment types, skills learned by diff --git a/src/Database/RecordSubList.php b/src/Database/RecordSubList.php index 903b57b..892b2e3 100644 --- a/src/Database/RecordSubList.php +++ b/src/Database/RecordSubList.php @@ -36,6 +36,12 @@ * only some variants carry, variant => (key => label): a cinematic * `sequence` owns its `commands`, a `choice` its `cancel` arm. * @param list> $exclusiveFields Field groups where authoring one removes the others. + * @param string|null $scalarKey The field an entry authored as a bare value is (a + * reward item's `item`): read as that field, and written back bare while it holds nothing else. + * @param bool $removeWhenEmpty Whether the list's key is dropped when its last entry goes, + * as files that omit an empty list author it (a quest's reward items). + * @param string $heading What a list beside the record's own is headed as ("Reward Items"): its + * heading row names it, counts it, and is where an entry is added to it. */ public function __construct( public string $key, @@ -49,6 +55,9 @@ public function __construct( public array $commandArms = [], public array $variantArms = [], public array $exclusiveFields = [], + public string $heading = '', + public ?string $scalarKey = null, + public bool $removeWhenEmpty = false, ) { } diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index b5446aa..525a04e 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -136,10 +136,7 @@ public function valuesFor(string $category): array // Spells and abilities may be authored in any of the Engine's // skill files; a reference names the skill wherever it lives. 'skills' => $this->workspace->getSkillNames(), - 'quests' => array_map( - static fn(ProjectQuest $quest): string => $quest->getId(), - $this->workspace->questDatabase->getQuests() - ), + 'quests' => array_map(static fn(ProjectQuest $quest): string => $quest->getId(), $this->workspace->getQuests()), 'maps' => $this->workspace->mapIds, // A shop's stock is whatever the engine's ItemStore holds, and // that is everything in items.php: items, weapons and armors diff --git a/src/Editor.php b/src/Editor.php index 4516a27..483bda7 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -604,7 +604,6 @@ final class Editor private string $databaseFocus = self::DATABASE_FOCUS_CATEGORIES; private int $databaseSelectedActorIndex = 0; private int $databaseSelectedSkillIndex = 0; - private int $databaseSelectedQuestIndex = 0; /** * Selected entry index per schema-driven category, keyed by category key. * @@ -916,7 +915,6 @@ private function boot(): void $this->databaseFocus = self::DATABASE_FOCUS_CATEGORIES; $this->databaseSelectedActorIndex = 0; $this->databaseSelectedSkillIndex = 0; - $this->databaseSelectedQuestIndex = 0; $this->databaseSelectedSettingIndex = 0; $this->isDatabaseEditing = false; $this->databaseEditBuffer = ''; @@ -2834,7 +2832,6 @@ private function openDatabaseWindow(): void $this->databaseFocus = self::DATABASE_FOCUS_CATEGORIES; $this->databaseSelectedActorIndex = 0; $this->databaseSelectedSkillIndex = 0; - $this->databaseSelectedQuestIndex = 0; $this->databaseSelectedSettingIndex = 0; $this->isDatabaseEditing = false; $this->databaseEditBuffer = ''; @@ -3013,26 +3010,16 @@ private function handleDatabaseInput(string $input, string $normalizedInput): vo return; } - if ($this->isQuestsDatabaseSelected() && $this->isShiftLetterShortcut($input, 'O')) { - $this->addDatabaseQuestObjective(); - return; - } - - if ($this->isQuestsDatabaseSelected() && $this->isShiftLetterShortcut($input, 'X')) { - $this->removeDatabaseQuestObjective(); - return; - } - // Shift+O/Shift+X are the one sub-list idiom: quest objectives, skit // beats, troop members, and event-script commands all use them. - if ($this->getSelectedRecordDatabase()?->schema->subList !== null && $this->isShiftLetterShortcut($input, 'O')) { + if (($this->getSelectedRecordDatabase()?->schema->getInlineSubLists() ?? []) !== [] && $this->isShiftLetterShortcut($input, 'O')) { $this->selectedDatabaseNestedContext() !== null ? $this->addDatabaseNestedSubItem() : $this->addDatabaseRecordSubItem(); return; } - if ($this->getSelectedRecordDatabase()?->schema->subList !== null && $this->isShiftLetterShortcut($input, 'X')) { + if (($this->getSelectedRecordDatabase()?->schema->getInlineSubLists() ?? []) !== [] && $this->isShiftLetterShortcut($input, 'X')) { $this->selectedDatabaseNestedContext() !== null ? $this->removeDatabaseNestedSubItem() : $this->removeDatabaseRecordSubItem(); @@ -3171,11 +3158,6 @@ private function moveDatabaseListSelection(int $step): void return; } - if ($this->isQuestsDatabaseSelected()) { - $this->moveDatabaseQuestSelection($step); - return; - } - $this->moveDatabaseRecordSelection($step); } @@ -3233,32 +3215,6 @@ private function moveDatabaseSkillSelection(int $step): void } - /** - * Moves the selected quest entry. - * - * @param int $step The entry step. - * @return void - */ - private function moveDatabaseQuestSelection(int $step): void - { - $quests = $this->workspace?->questDatabase->getQuests() ?? []; - - if ($quests === []) { - return; - } - - $nextIndex = $this->resolveDatabaseSelectionStep($this->databaseSelectedQuestIndex, $step); - - if ($nextIndex === $this->databaseSelectedQuestIndex) { - return; - } - - $this->databaseSelectedQuestIndex = $nextIndex; - $this->databaseSelectedSettingIndex = 0; - $this->statusMessage = sprintf('Selected quest %s.', $quests[$nextIndex]->getName()); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - } - /** * Moves the selected settings field. * @@ -5785,13 +5741,6 @@ private function getDatabaseEntryLabels(): array ); } - if ($this->isQuestsDatabaseSelected()) { - return array_map( - static fn(ProjectQuest $quest): string => $quest->getName(), - $this->workspace?->questDatabase->getQuests() ?? [], - ); - } - return $this->getSelectedRecordDatabase()?->getEntryLabels() ?? []; } @@ -5846,7 +5795,6 @@ private function getSelectedDatabaseEntryIndex(): int return match (true) { $this->isActorsDatabaseSelected() => $this->databaseSelectedActorIndex, $this->isSkillsDatabaseSelected() => $this->databaseSelectedSkillIndex, - $this->isQuestsDatabaseSelected() => $this->databaseSelectedQuestIndex, default => $this->getSelectedRecordIndex(), }; } @@ -5864,7 +5812,6 @@ private function setSelectedDatabaseEntryIndex(int $index): void match (true) { $this->isActorsDatabaseSelected() => $this->databaseSelectedActorIndex = $index, $this->isSkillsDatabaseSelected() => $this->databaseSelectedSkillIndex = $index, - $this->isQuestsDatabaseSelected() => $this->databaseSelectedQuestIndex = $index, default => $this->setSelectedRecordIndex($index), }; } @@ -6089,11 +6036,6 @@ private function confirmDatabaseEntryDeletion(): void fn(): ?object => $workspace->skillDatabase->removeSkill($index), static fn(object $entry) => $workspace->skillDatabase->insertSkill($index, $entry), ), - self::DATABASE_CATEGORY_QUESTS => $this->buildDatabaseDeletionCommand( - sprintf('Delete quest %s', $label), - fn(): ?object => $workspace->questDatabase->removeQuest($index), - static fn(object $entry) => $workspace->questDatabase->insertQuest($index, $entry), - ), default => $this->buildRecordDeletionCommand($pending['category'], $index), }; @@ -6484,7 +6426,6 @@ private function captureNavigationRestorer(): Closure 'databaseFocus' => $this->databaseFocus, 'actor' => $this->databaseSelectedActorIndex, 'skill' => $this->databaseSelectedSkillIndex, - 'quest' => $this->databaseSelectedQuestIndex, 'records' => $this->databaseSelectedRecordIndexes, 'setting' => $this->databaseSelectedSettingIndex, 'assetIndex' => $this->selectedAssetIndex, @@ -6503,7 +6444,6 @@ private function captureNavigationRestorer(): Closure $this->databaseFocus = $snapshot['databaseFocus']; $this->databaseSelectedActorIndex = $snapshot['actor']; $this->databaseSelectedSkillIndex = $snapshot['skill']; - $this->databaseSelectedQuestIndex = $snapshot['quest']; $this->databaseSelectedRecordIndexes = $snapshot['records']; $this->databaseSelectedSettingIndex = $snapshot['setting']; $this->selectedAssetIndex = $snapshot['assetIndex']; @@ -8913,6 +8853,12 @@ private function activeRecordIndex(): int : $this->getSelectedRecordIndex(); } + /** The field id of the settings row under the cursor, or an empty string. */ + private function getSelectedDatabaseFieldId(): string + { + return (string) ($this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex]['field'] ?? ''); + } + /** * Leaves the open command frame for the one enclosing it. * @@ -8964,7 +8910,7 @@ private function leaveCommandFrame(): void private function addDatabaseRecordSubItem(): void { $database = $this->getSelectedRecordDatabase(); - $subList = $database?->schema->subList; + $subList = $database?->schema->getInlineSubLists()[0] ?? null; if (! $database instanceof ProjectRecordDatabase || $subList === null) { return; @@ -8982,7 +8928,15 @@ private function addDatabaseRecordSubItem(): void } $recordIndex = $this->getSelectedRecordIndex(); - $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->addEntry($database, $recordIndex, [])); + // The row under the cursor says which list and where: after its + // entry, or first in the list whose heading it is. Elsewhere, at the + // end of the record's own list. + $fieldId = $this->getSelectedDatabaseFieldId(); + $item = $database->locateItem($recordIndex, [], $fieldId); + $subList = $database->schema->findInlineSubList($item?->listKey) ?? $subList; + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $item === null + ? $authoring->addEntry($database, $recordIndex, []) + : $authoring->addItem($database, $recordIndex, [], $fieldId)); if ($change?->command === null) { return; @@ -9016,7 +8970,7 @@ private function selectNewSkitActor(int $entryIndex): void private function removeDatabaseRecordSubItem(): void { $database = $this->getSelectedRecordDatabase(); - $subList = $database?->schema->subList; + $subList = $database?->schema->getInlineSubLists()[0] ?? null; if (! $database instanceof ProjectRecordDatabase || $subList === null) { return; @@ -9034,7 +8988,12 @@ private function removeDatabaseRecordSubItem(): void } $recordIndex = $this->getSelectedRecordIndex(); - $entryIndex = $database->countSubItems($recordIndex) - 1; + // The entry under the cursor, in whichever list it is; elsewhere the + // last entry of the record's own list. + $item = $database->locateItem($recordIndex, [], $this->getSelectedDatabaseFieldId()); + $listKey = $item?->kind === RecordItem::ENTRY ? $item->listKey : null; + $subList = $database->schema->findInlineSubList($listKey) ?? $subList; + $entryIndex = $item?->kind === RecordItem::ENTRY ? $item->entryIndex : $database->countSubItems($recordIndex) - 1; if ($entryIndex < 0) { $this->setStatus(sprintf('No %s to remove.', $subList->singular), StatusLevel::WARN); @@ -9042,7 +9001,7 @@ private function removeDatabaseRecordSubItem(): void return; } - $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->removeEntry($database, $recordIndex, [], $entryIndex)); + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->removeEntry($database, $recordIndex, [], $entryIndex, $listKey)); if ($change?->command === null) { return; @@ -9227,7 +9186,10 @@ private function getSelectedQuest(): ?ProjectQuest return null; } - return $this->workspace?->questDatabase->getQuestByIndex($this->databaseSelectedQuestIndex); + // The terminal's journal panes read the quest as the shared record holds it, unsaved edits included. + $record = $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()); + + return $record === null ? null : new ProjectQuest((array) $record->toArray()); } /** @@ -9268,11 +9230,6 @@ private function createDatabaseEntry(): void return; } - if ($this->isQuestsDatabaseSelected()) { - $this->createDatabaseQuest(); - return; - } - $this->createDatabaseRecord(); } @@ -9314,220 +9271,6 @@ private function createDatabaseSkill(): void $this->beginDatabaseEdit(); } - /** - * Creates a new quest entry in the project database. - * - * @return void - */ - private function createDatabaseQuest(): void - { - if (! $this->workspace instanceof ProjectWorkspace) { - return; - } - - $this->databaseSelectedQuestIndex = $this->workspace->questDatabase->addQuest(); - $this->databaseSelectedSettingIndex = 0; - $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; - $this->statusMessage = "Created a new quest."; - $this->renderDatabasePanes(["list", "settings", "cue", "frames", "preview"]); - $this->beginDatabaseEdit(); - } - - /** - * Appends an objective to the selected quest and records it for undo. - * - * @return void - */ - /** - * Returns the reward slot the settings cursor is on, if it is on one. - * - * @return int|null The slot. - */ - private function selectedQuestRewardSlot(): ?int - { - $field = $this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex] ?? null; - - if (is_array($field) && preg_match('/^rewardItem(\d+)$/', strval($field['field'] ?? '')) === 1) { - return intval(substr(strval($field['field']), strlen('rewardItem'))); - } - - return null; - } - - /** - * Determines whether the cursor sits on the quest's reward fields, which - * is where adding a first reward item should work from. - * - * @return bool True when it does. - */ - private function isQuestRewardListSelected(): bool - { - $field = $this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex] ?? null; - - return is_array($field) - && in_array($field['field'] ?? '', ['rewardGold', 'rewardExperience', 'rewardItems'], true); - } - - /** - * Adds a reward item slot, below the cursor's slot when it is on one. - * - * @return void - */ - private function addDatabaseQuestRewardItem(): void - { - if (! $this->workspace instanceof ProjectWorkspace) { - return; - } - - $questDatabase = $this->workspace->questDatabase; - $questIndex = $this->databaseSelectedQuestIndex; - $slot = $questDatabase->addRewardItem($questIndex, $this->selectedQuestRewardSlot()); - - if ($slot === null) { - return; - } - - $this->recordCommand(new GenericCommand( - 'Reward item add', - static fn() => $questDatabase->insertRewardItem($questIndex, $slot, 'S-Potion'), - static fn() => $questDatabase->removeRewardItem($questIndex, $slot), - )); - - // Land on the new row and open its picker: an unchosen placeholder - // is not what anyone wanted to add. - foreach ($this->getDatabaseSettingsFields() as $index => $field) { - if (($field['field'] ?? null) === sprintf('rewardItem%d', $slot)) { - $this->databaseSelectedSettingIndex = $index; - break; - } - } - - $this->setStatus(sprintf('Reward item %d added.', $slot + 1), StatusLevel::INFO); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - $this->beginDatabaseEdit(); - } - - /** - * Removes the reward item slot the cursor is on. - * - * @return void - */ - private function removeDatabaseQuestRewardItem(): void - { - $slot = $this->selectedQuestRewardSlot(); - - if ($slot === null || ! $this->workspace instanceof ProjectWorkspace) { - return; - } - - $questDatabase = $this->workspace->questDatabase; - $questIndex = $this->databaseSelectedQuestIndex; - $removed = $questDatabase->removeRewardItem($questIndex, $slot); - - if ($removed === null) { - return; - } - - $this->databaseSelectedSettingIndex = min( - $this->databaseSelectedSettingIndex, - max(0, count($this->getDatabaseSettingsFields()) - 1) - ); - $this->recordCommand(new GenericCommand( - 'Reward item remove', - static fn() => $questDatabase->removeRewardItem($questIndex, $slot), - static fn() => $questDatabase->insertRewardItem($questIndex, $slot, $removed), - )); - $this->setStatus(sprintf('Reward item %d removed (%s).', $slot + 1, $removed), StatusLevel::INFO); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - } - - private function addDatabaseQuestObjective(): void - { - if ($this->selectedQuestRewardSlot() !== null || $this->isQuestRewardListSelected()) { - $this->addDatabaseQuestRewardItem(); - - return; - } - - if (! $this->workspace instanceof ProjectWorkspace || ! $this->getSelectedQuest() instanceof ProjectQuest) { - return; - } - - $questDatabase = $this->workspace->questDatabase; - $questIndex = $this->databaseSelectedQuestIndex; - $objectiveIndex = $questDatabase->addObjective($questIndex); - - if ($objectiveIndex === null) { - return; - } - - $objective = $questDatabase->getQuestByIndex($questIndex)?->getObjectives()[$objectiveIndex] ?? []; - $this->recordCommand(new GenericCommand( - 'Quest objective add', - static fn() => $questDatabase->insertObjective($questIndex, $objectiveIndex, $objective), - static fn() => $questDatabase->removeObjective($questIndex, $objectiveIndex), - )); - $this->setStatus(sprintf('Objective %d added.', $objectiveIndex + 1), StatusLevel::INFO); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - } - - /** - * Removes the selected quest objective and records it for undo. - * - * The objective under the highlighted settings field is removed when one - * is highlighted; the last objective otherwise. - * - * @return void - */ - private function removeDatabaseQuestObjective(): void - { - if ($this->selectedQuestRewardSlot() !== null) { - $this->removeDatabaseQuestRewardItem(); - - return; - } - - $quest = $this->getSelectedQuest(); - - if (! $this->workspace instanceof ProjectWorkspace || ! $quest instanceof ProjectQuest) { - return; - } - - $objectiveCount = count($quest->getObjectives()); - - if ($objectiveCount === 0) { - return; - } - - $objectiveIndex = $objectiveCount - 1; - $fields = $this->getDatabaseSettingsFields(); - $selectedField = (string) ($fields[$this->databaseSelectedSettingIndex]['field'] ?? ''); - - if (preg_match('/^objective(\d+)/', $selectedField, $matches) === 1) { - $objectiveIndex = min($objectiveCount - 1, intval($matches[1])); - } - - $questDatabase = $this->workspace->questDatabase; - $questIndex = $this->databaseSelectedQuestIndex; - $removed = $questDatabase->removeObjective($questIndex, $objectiveIndex); - - if ($removed === null) { - return; - } - - $this->databaseSelectedSettingIndex = min( - $this->databaseSelectedSettingIndex, - max(0, count($this->getDatabaseSettingsFields()) - 1) - ); - $this->recordCommand(new GenericCommand( - 'Quest objective remove', - static fn() => $questDatabase->removeObjective($questIndex, $objectiveIndex), - static fn() => $questDatabase->insertObjective($questIndex, $objectiveIndex, $removed), - )); - $this->setStatus(sprintf('Objective %d removed.', $objectiveIndex + 1), StatusLevel::INFO); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - } - /** * Saves the active Database category. * @@ -9548,10 +9291,6 @@ private function saveActiveDatabase(): void $this->backupBeforeSave(...$this->workspace->skillDatabase->getBackupPaths()); $this->workspace->skillDatabase->save(); $this->setStatus('Skill database saved.', StatusLevel::SUCCESS); - } elseif ($this->isQuestsDatabaseSelected()) { - $this->backupBeforeSave(...$this->workspace->questDatabase->getBackupPaths()); - $this->workspace->questDatabase->save(); - $this->setStatus('Quest database saved.', StatusLevel::SUCCESS); } elseif ($this->isSystemDatabaseSelected()) { if ($this->workspace->config?->isDirty()) { $this->backupBeforeSave($this->workspace->config->path); @@ -9607,10 +9346,6 @@ private function getDatabaseSettingsFields(): array return $this->getDatabaseSkillSettingsFields(); } - if ($this->isQuestsDatabaseSelected()) { - return $this->getDatabaseQuestSettingsFields(); - } - if ($this->isSystemDatabaseSelected()) { return $this->getDatabaseSystemSettingsFields(); } @@ -10140,107 +9875,6 @@ private function getDatabaseSkillSettingsFields(): array } return $fields; } - /** - * Returns the editable settings fields for the selected quest. - * - * Objectives are flattened into per-objective field groups (type, - * target, quantity, spoiler-safe text, and conditional revealed text) so - * the flat settings pane can edit the nested list; Shift+O / Shift+X add - * and remove objectives. - * - * @return array> - */ - private function getDatabaseQuestSettingsFields(): array - { - $quest = $this->getSelectedQuest(); - - if (! $quest instanceof ProjectQuest) { - return []; - } - - $fields = [ - // Derived from the name, so an id is never invented or mistyped. - ['label' => 'Id', 'value' => $quest->getId(), 'field' => 'id'], - ['label' => 'Name', 'value' => $quest->getName(), 'control' => new InputControl(InputControlType::TEXT, $quest->getName()), 'field' => 'name'], - ['label' => 'Description', 'value' => $quest->getDescription(), 'control' => new InputControl(InputControlType::TEXT, $quest->getDescription()), 'field' => 'description'], - ['label' => 'Giver', 'value' => $quest->getGiver(), 'control' => new InputControl(InputControlType::TEXT, $quest->getGiver()), 'field' => 'giver'], - ['label' => 'Reward Gold', 'value' => (string) $quest->getRewardGold(), 'control' => new InputControl(InputControlType::INTEGER, (string) $quest->getRewardGold()), 'field' => 'rewardGold'], - ['label' => 'Reward EXP', 'value' => (string) $quest->getRewardExperience(), 'control' => new InputControl(InputControlType::INTEGER, (string) $quest->getRewardExperience()), 'field' => 'rewardExperience'], - ['label' => 'Prereqs', 'value' => $quest->getPrerequisitesString(), 'conditions' => true, 'field' => 'prerequisites'], - ]; - - // One row per reward item, picked from the inventory the engine's - // store resolves them against. Shift+O on a row adds a slot below - // it; Shift+X or Del removes the one the cursor is on. - foreach ($quest->getRewardItems() as $slot => $item) { - $fields[] = [ - 'label' => sprintf('Reward Item %d', $slot + 1), - 'value' => $item, - 'reference' => 'inventory', - 'field' => sprintf('rewardItem%d', $slot), - ]; - } - - foreach ($quest->getObjectives() as $index => $objective) { - $label = sprintf('Obj %d', $index + 1); - $type = strval($objective['type'] ?? QuestObjectiveType::TALK_TO->value); - $target = strval($objective['target'] ?? ''); - $quantity = (string) max(1, intval($objective['quantity'] ?? 1)); - $description = strval($objective['description'] ?? ''); - $revealedDescription = strval($objective['revealedDescription'] ?? ''); - $revealConditions = ConditionCodec::encodeAll((array) ($objective['revealConditions'] ?? [])); - $fields[] = [ - 'label' => $label . ' Type', - 'value' => $type, - 'options' => array_map(static fn(QuestObjectiveType $objectiveType): string => $objectiveType->value, QuestObjectiveType::cases()), - 'field' => sprintf('objective%dType', $index), - ]; - $reference = self::questObjectiveReference($type); - $targetField = [ - 'label' => $label . ' Target', - 'value' => $target, - 'field' => sprintf('objective%dTarget', $index), - ]; - - // What a target may be depends on what the objective asks for, so - // the picker follows the type: an item to collect, an enemy to - // defeat, a map to reach. - if ($reference !== null) { - $targetField['reference'] = $reference; - } else { - $targetField['control'] = new InputControl(InputControlType::TEXT, $target); - } - - $fields[] = $targetField; - $fields[] = [ - 'label' => $label . ' Qty', - 'value' => $quantity, - 'control' => new InputControl(InputControlType::INTEGER, $quantity), - 'field' => sprintf('objective%dQuantity', $index), - ]; - $fields[] = [ - 'label' => $label . ' Text', - 'value' => $description, - 'control' => new InputControl(InputControlType::TEXT, $description), - 'field' => sprintf('objective%dDescription', $index), - ]; - $fields[] = [ - 'label' => $label . ' Revealed', - 'value' => $revealedDescription, - 'control' => new InputControl(InputControlType::TEXT, $revealedDescription), - 'field' => sprintf('objective%dRevealedDescription', $index), - ]; - $fields[] = [ - 'label' => $label . ' Reveal When', - 'value' => $revealConditions, - 'conditions' => true, - 'field' => sprintf('objective%dRevealConditions', $index), - ]; - } - - return $fields; - } - /** * Returns the input control for a database settings field when editable. * @@ -10506,8 +10140,7 @@ private function fitHelp(int $windowWidth, string ...$candidates): string */ private function hasDatabaseSubList(): bool { - return $this->isQuestsDatabaseSelected() - || $this->getSelectedRecordDatabase()?->schema->subList !== null; + return ($this->getSelectedRecordDatabase()?->schema->getInlineSubLists() ?? []) !== []; } /** @@ -10517,68 +10150,13 @@ private function hasDatabaseSubList(): bool */ private function removeDatabaseSubItem(): void { - if ($this->isQuestsDatabaseSelected()) { - $this->removeDatabaseQuestObjective(); - - return; - } - - if ($this->getSelectedRecordDatabase()?->schema->subList !== null) { + if (($this->getSelectedRecordDatabase()?->schema->getInlineSubLists() ?? []) !== []) { $this->selectedDatabaseNestedContext() !== null ? $this->removeDatabaseNestedSubItem() : $this->removeDatabaseRecordSubItem(); } } - /** - * Renames the selected quest, and its id with it where that is safe. - * - * @param string $name The new name. - * @return void - */ - private function renameSelectedQuest(string $name): void - { - if (! $this->workspace instanceof ProjectWorkspace) { - return; - } - - $questDatabase = $this->workspace->questDatabase; - $questIndex = $this->databaseSelectedQuestIndex; - $quest = $questDatabase->getQuestByIndex($questIndex); - - if (! $quest instanceof ProjectQuest) { - return; - } - - $previousName = $quest->getName(); - $previousId = $quest->getId(); - $isReferenced = new QuestReferences($this->workspace)->exist($previousId); - $newId = $questDatabase->renameQuest($questIndex, $name, ! $isReferenced); - - $this->recordCommand(new GenericCommand( - 'Quest rename', - static fn() => $questDatabase->renameQuest($questIndex, $name, ! $isReferenced), - static function () use ($questDatabase, $questIndex, $previousName, $previousId): void { - $questDatabase->renameQuest($questIndex, $previousName, false); - $questDatabase->setField($questIndex, 'id', $previousId); - }, - )); - - if (is_string($newId)) { - $this->setStatus(sprintf('Renamed. Its id is now %s.', $newId), StatusLevel::INFO); - - return; - } - - if ($isReferenced) { - // Saying so beats an id that silently stops matching its name. - $this->setStatus( - sprintf('Renamed. Its id stays %s, which other things point at.', $previousId), - StatusLevel::INFO - ); - } - } - /** * Opens the world-write editor on a field that holds a `sets` list. * @@ -11742,6 +11320,9 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) if ($change?->command !== null) { $this->recordCommand($change->command); } + if ($change?->note !== null) { + $this->setStatus($change->note, StatusLevel::INFO); + } return; } @@ -11759,7 +11340,6 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) 'category' => $this->databaseCategoryIndex, 'actor' => $this->databaseSelectedActorIndex, 'skill' => $this->databaseSelectedSkillIndex, - 'quest' => $this->databaseSelectedQuestIndex, 'records' => $this->databaseSelectedRecordIndexes, ]; @@ -11851,7 +11431,7 @@ private function applyRecordFieldValue(ProjectRecordDatabase $database, string $ * Applies a database value onto a pinned entry identity, restoring the * live selection afterwards. * - * @param array{category: int, actor: int, skill: int, quest: int, records?: array} $identity The pinned selection. + * @param array{category: int, actor: int, skill: int, records?: array} $identity The pinned selection. * @param string $field The field identifier. * @param string $rawValue The raw value to apply. * @return void @@ -11862,13 +11442,11 @@ private function applyDatabaseFieldValueAt(array $identity, string $field, strin $this->databaseCategoryIndex, $this->databaseSelectedActorIndex, $this->databaseSelectedSkillIndex, - $this->databaseSelectedQuestIndex, $this->databaseSelectedRecordIndexes, ]; $this->databaseCategoryIndex = $identity['category']; $this->databaseSelectedActorIndex = $identity['actor']; $this->databaseSelectedSkillIndex = $identity['skill']; - $this->databaseSelectedQuestIndex = $identity['quest']; $this->databaseSelectedRecordIndexes = $identity['records'] ?? $this->databaseSelectedRecordIndexes; try { @@ -11878,7 +11456,6 @@ private function applyDatabaseFieldValueAt(array $identity, string $field, strin $this->databaseCategoryIndex, $this->databaseSelectedActorIndex, $this->databaseSelectedSkillIndex, - $this->databaseSelectedQuestIndex, $this->databaseSelectedRecordIndexes, ] = $liveSelection; } @@ -11965,20 +11542,6 @@ private function applyDatabaseFieldValue(string $field, string $rawValue): void return; } - if ($this->isQuestsDatabaseSelected()) { - if ($field === 'name') { - $this->renameSelectedQuest(trim($rawValue)); - - return; - } - - $isIntegerField = in_array($field, ['rewardGold', 'rewardExperience'], true) - || preg_match('/^objective\d+Quantity$/', $field) === 1; - $value = $isIntegerField ? max(0, intval($rawValue)) : trim($rawValue); - $this->workspace->questDatabase->setField($this->databaseSelectedQuestIndex, $field, $value); - return; - } - if ($this->isSystemDatabaseSelected()) { if ($field === ProjectConfig::FIELD_ZOOM) { $this->workspace->config?->setFieldZoom($rawValue); @@ -14202,10 +13765,6 @@ private function getDatabaseListLines(): array return $this->getDatabaseSkillListLines(); } - if ($this->isQuestsDatabaseSelected()) { - return $this->getDatabaseQuestListLines(); - } - if ($this->isSystemDatabaseSelected()) { return $this->getDatabaseSystemListLines(); } @@ -14348,59 +13907,6 @@ private function getDatabaseSkillListLines(): array } - /** - * Returns what an objective of the given type points at. - * - * A flag names a switch or story event the world sets, which is authored - * text rather than a record, so it stays typed. - * - * @param string $type The objective type. - * @return string|null The kind of reference, or null when it is free text. - */ - private static function questObjectiveReference(string $type): ?string - { - return match (QuestObjectiveType::tryFrom($type)) { - // Collecting is not limited to consumables: a quest may ask for - // a weapon or a piece of armor, and the runtime resolves all - // three from one catalogue. - QuestObjectiveType::COLLECT => 'inventory', - QuestObjectiveType::DEFEAT => 'enemies', - QuestObjectiveType::REACH_MAP => 'maps', - QuestObjectiveType::TALK_TO => 'actors', - default => null, - }; - } - - /** - * Returns the quest list lines. - * - * @return string[] - */ - private function getDatabaseQuestListLines(): array - { - $quests = $this->workspace?->questDatabase->getQuests() ?? []; - - if ($quests === []) { - return ['No quests yet.', '', 'Shift+A to create one.']; - } - - $lines = []; - - foreach ($this->getVisibleDatabaseEntryIndexes() as $index) { - $quest = $quests[$index] ?? null; - - if (! $quest instanceof ProjectQuest) { - continue; - } - - $prefix = $index === $this->databaseSelectedQuestIndex ? '> ' : ' '; - $dirty = $quest->isDirty() ? ' *' : ''; - $lines[] = sprintf('%s%s%s', $prefix, $quest->getName(), $dirty); - } - - return $lines === [] ? ['No matches.'] : $lines; - } - /** * Returns the system list lines. * @@ -15160,7 +14666,6 @@ private function isDatabaseCategoryDirty(string $categoryKey): bool self::DATABASE_CATEGORY_ACTORS => $this->workspace->actorDatabase->isDirty(), self::DATABASE_CATEGORY_SKILLS => $this->workspace->skillDatabase->isDirty(), self::DATABASE_CATEGORY_SYSTEM => $this->workspace->systemDatabase->isDirty() || ($this->workspace->config?->isDirty() ?? false), - self::DATABASE_CATEGORY_QUESTS => $this->workspace->questDatabase->isDirty(), default => $this->workspace->getRecordDatabase($categoryKey)?->isDirty() ?? false, }; } diff --git a/src/ProjectQuest.php b/src/ProjectQuest.php index 6ce9210..c0ff5b8 100644 --- a/src/ProjectQuest.php +++ b/src/ProjectQuest.php @@ -4,66 +4,21 @@ namespace Ichiloto\Editor; -use Ichiloto\Editor\History\TracksPersistedState; - use Ichiloto\Editor\Database\ConditionCodec; use Ichiloto\Engine\Quests\QuestObjectiveType; /** - * One editable quest entry backed by its raw quests.php payload. - * - * The payload array is kept verbatim so untouched keys (optional rewards, - * prerequisites, omitted objective quantities) round-trip losslessly. + * A quest record as validation and the terminal's journal panes read it. The + * record itself is edited through the shared record database (the `quests` + * schema); this only reads it, the way the engine's `Quest::fromArray` does. */ -final class ProjectQuest +final readonly class ProjectQuest { - use TracksPersistedState; - /** - * @param array $payload The raw quest entry. - * @param bool $isDirty Whether the quest holds unsaved edits. + * @param array $payload The quest entry as quests.php holds it. */ - public function __construct( - private array $payload, - bool $isDirty = false, - ) { - if (! $isDirty) { - $this->captureBaseline(); - } - } - - /** - * Creates a blank quest with one placeholder objective. - * - * @param string $id The quest id. - * @param string $name The quest name. - * @return self - */ - public static function createBlank(string $id, string $name = 'New Quest'): self + public function __construct(private array $payload) { - return new self([ - 'id' => $id, - 'name' => $name, - 'description' => '', - 'giver' => '', - 'objectives' => [ - ['type' => QuestObjectiveType::TALK_TO->value, 'target' => 'New Target'], - ], - ], true); - } - - - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - return serialize($this->payload); - } - - public function markClean(): void - { - $this->captureBaseline(); } public function getId(): string @@ -117,9 +72,7 @@ public function getRewardExperience(): int } /** - * Returns the reward item names as a comma-separated string. - * - * @return string + * Returns the reward items as a comma-separated string. */ public function getRewardItemsString(): string { @@ -127,78 +80,26 @@ public function getRewardItemsString(): string } /** - * @return string[] The reward item names, as the engine's store resolves them. - */ - public function getRewardItems(): array - { - return array_values(array_filter( - array_map(strval(...), (array) ($this->getRewards()['items'] ?? [])), - static fn(string $item): bool => trim($item) !== '' - )); - } - - /** - * Stores the reward item list, dropping the key when it empties. + * The reward items as the journal lists them: each item's name, with + * its quantity when it gives more than one. The engine reads an item + * either as a bare name or as `['item' => ..., 'quantity' => ...]`. * - * @param string[] $items The item names. - * @return void - */ - public function setRewardItems(array $items): void - { - $items = array_values(array_filter( - array_map(static fn(mixed $item): string => trim(strval($item)), $items), - static fn(string $item): bool => $item !== '' - )); - $rewards = $this->getRewards(); - - if ($items === []) { - unset($rewards['items']); - } else { - $rewards['items'] = $items; - } - - $this->setRewards($rewards); - } - - /** - * Adds a reward item slot after the given one, or at the end. - * - * @param int|null $afterIndex The slot to insert after. - * @return int The new slot's index. + * @return string[] */ - public function addRewardItem(?int $afterIndex = null): int + public function getRewardItems(): array { - $items = $this->getRewardItems(); - $position = $afterIndex === null ? count($items) : min(count($items), $afterIndex + 1); - - // A placeholder name, so the slot exists to be picked into; the - // picker replaces it before anything resolves it. - array_splice($items, $position, 0, ['S-Potion']); - $rewards = $this->getRewards(); - $rewards['items'] = $items; - $this->setRewards($rewards); - - return $position; - } + $items = []; - /** - * Removes a reward item slot. - * - * @param int $index The slot. - * @return string|null The removed name, or null when the slot is unknown. - */ - public function removeRewardItem(int $index): ?string - { - $items = $this->getRewardItems(); + foreach ((array) ($this->getRewards()['items'] ?? []) as $item) { + $name = trim(strval(is_array($item) ? ($item['item'] ?? '') : $item)); + $quantity = is_array($item) ? max(1, intval($item['quantity'] ?? 1)) : 1; - if (! array_key_exists($index, $items)) { - return null; + if ($name !== '') { + $items[] = $quantity > 1 ? sprintf('%s x%d', $name, $quantity) : $name; + } } - [$removed] = array_splice($items, $index, 1); - $this->setRewardItems($items); - - return $removed; + return $items; } /** @@ -211,132 +112,6 @@ public function getPrerequisites(): array return is_array($prerequisites) ? array_values(array_filter($prerequisites, is_array(...))) : []; } - /** - * Applies one flat settings-field edit. - * - * Objective fields arrive as - * `objective{index}{Type|Target|Quantity|Description|RevealedDescription|RevealConditions}` - * with a zero-based objective index. - * - * @param string $field The field identifier. - * @param mixed $value The edited value. - * @return void - */ - public function setField(string $field, mixed $value): void - { - if (preg_match('/^objective(\d+)(Type|Target|Quantity|Description|RevealedDescription|RevealConditions)$/', $field, $matches) === 1) { - $this->setObjectiveField(intval($matches[1]), $matches[2], $value); - return; - } - - if (preg_match('/^rewardItem(\d+)$/', $field, $matches) === 1) { - $items = $this->getRewardItems(); - $items[intval($matches[1])] = trim(strval($value)); - $this->setRewardItems($items); - return; - } - - switch ($field) { - case 'id': - $id = trim(strval($value)); - - if ($id !== '') { - $this->payload['id'] = $id; - $this->touchState(); - } - - return; - case 'name': - case 'giver': - case 'description': - $this->payload[$field] = trim(strval($value)); - $this->touchState(); - return; - case 'rewardGold': - $this->setRewardAmount('gold', max(0, intval($value))); - return; - case 'rewardExperience': - $this->setRewardAmount('experience', max(0, intval($value))); - return; - case 'rewardItems': - $items = array_values(array_filter(array_map(trim(...), explode(',', strval($value))), static fn(string $item): bool => $item !== '')); - $rewards = $this->getRewards(); - - if ($items === []) { - unset($rewards['items']); - } else { - $rewards['items'] = $items; - } - - $this->setRewards($rewards); - return; - case 'prerequisites': - $prerequisites = self::decodePrerequisites(strval($value)); - - if ($prerequisites === []) { - unset($this->payload['prerequisites']); - } else { - $this->payload['prerequisites'] = $prerequisites; - } - - $this->touchState(); - return; - } - } - - /** - * Appends a placeholder objective. - * - * @param array|null $objective The objective payload; a placeholder when null. - * @return int The new objective index. - */ - public function addObjective(?array $objective = null): int - { - $objectives = $this->getObjectives(); - $objectives[] = $objective ?? ['type' => QuestObjectiveType::TALK_TO->value, 'target' => 'New Target']; - $this->payload['objectives'] = $objectives; - $this->touchState(); - - return count($objectives) - 1; - } - - /** - * Removes one objective by index. - * - * @param int $index The objective index. - * @return array|null The removed objective payload. - */ - public function removeObjective(int $index): ?array - { - $objectives = $this->getObjectives(); - - if (! array_key_exists($index, $objectives)) { - return null; - } - - [$removed] = array_splice($objectives, $index, 1); - $this->payload['objectives'] = $objectives; - $this->touchState(); - - return $removed; - } - - /** - * Re-inserts an objective at a specific index (undo support). - * - * @param int $index The target objective index. - * @param array $objective The objective payload. - * @return void - */ - public function insertObjective(int $index, array $objective): void - { - $objectives = $this->getObjectives(); - $index = max(0, min(count($objectives), $index)); - array_splice($objectives, $index, 0, [$objective]); - $this->payload['objectives'] = $objectives; - $this->touchState(); - } - /** * Returns journal-style objective summary lines. * @@ -373,24 +148,15 @@ public function getPrerequisiteSummaryLines(): array { $prerequisites = $this->getPrerequisites(); - if ($prerequisites === []) { - return ['None']; - } - - return array_map(static fn(array $prerequisite): string => self::encodePrerequisite($prerequisite), $prerequisites); + return $prerequisites === [] ? ['None'] : array_map(ConditionCodec::encode(...), $prerequisites); } /** - * Encodes the prerequisites into the editable one-line form. - * - * @return string + * Encodes the prerequisites into the one-line condition form. */ public function getPrerequisitesString(): string { - return implode('; ', array_map( - static fn(array $prerequisite): string => self::encodePrerequisite($prerequisite), - $this->getPrerequisites() - )); + return ConditionCodec::encodeAll($this->getPrerequisites()); } /** @@ -400,235 +166,4 @@ public function toArray(): array { return $this->payload; } - - /** - * Sets or removes a numeric reward amount. - * - * @param string $key The reward key. - * @param int $amount The amount; zero removes the key. - * @return void - */ - private function setRewardAmount(string $key, int $amount): void - { - $rewards = $this->getRewards(); - - if ($amount <= 0) { - unset($rewards[$key]); - } else { - $rewards[$key] = $amount; - } - - $this->setRewards($rewards); - } - - /** - * Stores the rewards array, dropping it entirely when empty. - * - * @param array $rewards The rewards payload. - * @return void - */ - private function setRewards(array $rewards): void - { - if ($rewards === []) { - unset($this->payload['rewards']); - } else { - $this->payload['rewards'] = $rewards; - } - - $this->touchState(); - } - - /** - * Applies one objective sub-field edit. - * - * @param int $index The objective index. - * @param string $key The objective key. - * @param mixed $value The edited value. - * @return void - */ - private function setObjectiveField(int $index, string $key, mixed $value): void - { - $objectives = $this->getObjectives(); - - if (! array_key_exists($index, $objectives)) { - return; - } - - $objective = $objectives[$index]; - - switch ($key) { - case 'Type': - $type = QuestObjectiveType::tryFrom(strval($value)); - - if (! $type instanceof QuestObjectiveType) { - return; - } - - $objective['type'] = $type->value; - break; - case 'Target': - $target = trim(strval($value)); - - if ($target === '') { - return; - } - - $objective['target'] = $target; - break; - case 'Quantity': - $quantity = max(1, intval($value)); - - if ($quantity > 1) { - $objective['quantity'] = $quantity; - } else { - unset($objective['quantity']); - } - - break; - case 'Description': - $description = trim(strval($value)); - - if ($description === '') { - unset($objective['description']); - } else { - $objective['description'] = $description; - } - - break; - case 'RevealedDescription': - $description = trim(strval($value)); - - if ($description === '') { - unset($objective['revealedDescription']); - } else { - $objective['revealedDescription'] = $description; - } - - break; - case 'RevealConditions': - $conditions = ConditionCodec::decodeAll(strval($value)); - - if ($conditions === []) { - unset($objective['revealConditions']); - } else { - $objective['revealConditions'] = $conditions; - } - - break; - } - - $objectives[$index] = $objective; - $this->payload['objectives'] = $objectives; - $this->touchState(); - } - - /** - * Encodes one prerequisite as `[!]type:name[:extras]`. - * - * @param array $prerequisite The prerequisite payload. - * @return string - */ - private static function encodePrerequisite(array $prerequisite): string - { - $type = strval($prerequisite['type'] ?? ''); - $name = strval($prerequisite['name'] ?? ''); - $parts = [$type, $name]; - - switch ($type) { - case 'quest': - $parts[] = strval($prerequisite['status'] ?? 'completed'); - break; - case 'switch': - if (($prerequisite['value'] ?? true) === false) { - $parts[] = 'false'; - } - - break; - case 'item': - if (intval($prerequisite['quantity'] ?? 1) > 1) { - $parts[] = strval(intval($prerequisite['quantity'])); - } - - break; - case 'variable': - $parts[] = strval($prerequisite['op'] ?? '=='); - $parts[] = strval($prerequisite['value'] ?? 0); - break; - } - - $negate = ($prerequisite['negate'] ?? false) === true ? '!' : ''; - - return $negate . implode(':', $parts); - } - - /** - * Decodes the one-line prerequisite form back into condition arrays. - * - * Entries are separated by `;`. Each entry is `[!]type:name[:extras]` - * where extras depend on the type (quest status, switch `false`, item - * quantity, or variable `op:value`). - * - * @param string $value The encoded prerequisites. - * @return array> - */ - private static function decodePrerequisites(string $value): array - { - $prerequisites = []; - - foreach (explode(';', $value) as $segment) { - $segment = trim($segment); - - if ($segment === '') { - continue; - } - - $negate = str_starts_with($segment, '!'); - $segment = ltrim($segment, '!'); - $parts = array_map(trim(...), explode(':', $segment)); - $type = strtolower($parts[0] ?? ''); - $name = $parts[1] ?? ''; - - // The runtime owns which world conditions exist, so this asks it - // rather than keeping a copy of the list. A hardcoded copy was - // how key_item came to be dropped here while the condition line - // and the validator both accepted it. - if ($name === '' || ! in_array($type, ConditionCodec::types(), true)) { - continue; - } - - $prerequisite = ['type' => $type, 'name' => $name]; - - switch ($type) { - case 'quest': - $status = strtolower($parts[2] ?? 'completed'); - $prerequisite['status'] = in_array($status, ['completed', 'active'], true) ? $status : 'completed'; - break; - case 'switch': - if (strtolower($parts[2] ?? 'true') === 'false') { - $prerequisite['value'] = false; - } - - break; - case 'item': - if (intval($parts[2] ?? 1) > 1) { - $prerequisite['quantity'] = intval($parts[2]); - } - - break; - case 'variable': - $prerequisite['op'] = $parts[2] ?? '=='; - $rawValue = $parts[3] ?? '0'; - $prerequisite['value'] = is_numeric($rawValue) ? intval($rawValue) : $rawValue; - break; - } - - if ($negate) { - $prerequisite['negate'] = true; - } - - $prerequisites[] = $prerequisite; - } - - return $prerequisites; - } } diff --git a/src/ProjectQuestDatabase.php b/src/ProjectQuestDatabase.php deleted file mode 100644 index 523f903..0000000 --- a/src/ProjectQuestDatabase.php +++ /dev/null @@ -1,467 +0,0 @@ -captureBaseline(); - } - } - - /** - * Loads the quest database from a project root. - * - * @param string $projectRoot The project root. - * @return self - */ - public static function fromProject(string $projectRoot): self - { - $path = rtrim($projectRoot, DIRECTORY_SEPARATOR) - . DIRECTORY_SEPARATOR . 'assets' - . DIRECTORY_SEPARATOR . 'Data' - . DIRECTORY_SEPARATOR . 'quests.php'; - - if (! is_file($path)) { - return new self($path, []); - } - - $source = (string) file_get_contents($path); - $payload = require $path; - - if (! is_array($payload)) { - throw new RuntimeException("Unable to parse {$path}."); - } - - $quests = []; - - foreach (array_values($payload) as $entry) { - if (is_array($entry)) { - $quests[] = new ProjectQuest($entry); - } - } - - return new self( - $path, - $quests, - sourceHeader: PhpDataSource::inspect($source)['header'], - ); - } - - /** - * @return ProjectQuest[] - */ - public function getQuests(): array - { - return array_values($this->quests); - } - - public function getQuestByIndex(int $index): ?ProjectQuest - { - return $this->quests[$index] ?? null; - } - - - - /** - * Appends a blank quest with a unique id. - * - * @param string $name The quest name. - * @return int The new quest index. - */ - public function addQuest(string $name = 'New Quest'): int - { - $this->quests[] = ProjectQuest::createBlank(Slug::unique($name, $this->getQuestIds(), 'quest'), $name); - $this->touchState(); - - return count($this->quests) - 1; - } - - /** - * Returns every quest id in the project. - * - * @return string[] The ids. - */ - public function getQuestIds(): array - { - return array_map(static fn(ProjectQuest $quest): string => $quest->getId(), $this->quests); - } - - /** - * Renames a quest, taking the id with it while that is still safe. - * - * An id is what triggers grant and conditions wait on, and renaming the - * quest does not rewrite those. So the id keeps up with the name until - * something points at it, and holds still afterwards -- which is the - * point at which changing it would break the game rather than tidy it. - * - * @param int $index The quest index. - * @param string $name The new name. - * @param bool $mayChangeId Whether nothing points at the current id. - * @return string|null The new id when it changed, or null when it did not. - */ - public function renameQuest(int $index, string $name, bool $mayChangeId): ?string - { - $quest = $this->getQuestByIndex($index); - - if (! $quest instanceof ProjectQuest) { - return null; - } - - $quest->setField('name', $name); - $this->touchState(); - - if (! $mayChangeId) { - return null; - } - - $taken = array_values(array_filter( - $this->getQuestIds(), - static fn(string $id): bool => $id !== $quest->getId() - )); - $id = Slug::unique($name, $taken, 'quest'); - - if ($id === $quest->getId()) { - return null; - } - - $quest->setField('id', $id); - - return $id; - } - - /** - * Removes the quest at the given index (rewritten to disk on save, so - * re-inserting at the same index is a complete undo). - * - * @param int $index The quest index. - * @return ProjectQuest|null The removed quest, or null when the index is unknown. - */ - public function removeQuest(int $index): ?ProjectQuest - { - $quests = array_values($this->quests); - $quest = $quests[$index] ?? null; - - if (! $quest instanceof ProjectQuest) { - return null; - } - - array_splice($quests, $index, 1); - $this->quests = $quests; - $this->touchState(); - - return $quest; - } - - /** - * Re-inserts a previously removed quest (the undo of removeQuest()). - * - * @param int $index The index to restore the quest at. - * @param ProjectQuest $quest The quest to restore. - * @return void - */ - public function insertQuest(int $index, ProjectQuest $quest): void - { - $quests = array_values($this->quests); - $index = max(0, min(count($quests), $index)); - array_splice($quests, $index, 0, [$quest]); - $this->quests = $quests; - $this->touchState(); - } - - /** - * Applies one flat settings-field edit onto a quest. - * - * @param int $index The quest index. - * @param string $field The field identifier. - * @param mixed $value The edited value. - * @return void - */ - public function setField(int $index, string $field, mixed $value): void - { - $quest = $this->getQuestByIndex($index); - - if ($quest instanceof ProjectQuest) { - $quest->setField($field, $value); - $this->touchState(); - } - } - - /** - * Appends an objective to a quest. - * - * @param int $index The quest index. - * @param array|null $objective The objective payload; a placeholder when null. - * @return int|null The new objective index. - */ - public function addObjective(int $index, ?array $objective = null): ?int - { - $quest = $this->getQuestByIndex($index); - - if (! $quest instanceof ProjectQuest) { - return null; - } - - $this->touchState(); - - return $quest->addObjective($objective); - } - - /** - * Removes an objective from a quest. - * - * @param int $index The quest index. - * @param int $objectiveIndex The objective index. - * @return array|null The removed objective payload. - */ - public function removeObjective(int $index, int $objectiveIndex): ?array - { - $quest = $this->getQuestByIndex($index); - - if (! $quest instanceof ProjectQuest) { - return null; - } - - $removed = $quest->removeObjective($objectiveIndex); - - if ($removed !== null) { - $this->touchState(); - } - - return $removed; - } - - /** - * Re-inserts an objective at a specific index (undo support). - * - * @param int $index The quest index. - * @param int $objectiveIndex The target objective index. - * @param array $objective The objective payload. - * @return void - */ - public function insertObjective(int $index, int $objectiveIndex, array $objective): void - { - $quest = $this->getQuestByIndex($index); - - if ($quest instanceof ProjectQuest) { - $quest->insertObjective($objectiveIndex, $objective); - $this->touchState(); - } - } - - /** - * Adds a reward item slot to a quest. - * - * @param int $index The quest index. - * @param int|null $afterSlot The slot to insert after, or null for the end. - * @return int|null The new slot, or null when the quest is unknown. - */ - public function addRewardItem(int $index, ?int $afterSlot = null): ?int - { - $quest = $this->getQuestByIndex($index); - - if (! $quest instanceof ProjectQuest) { - return null; - } - - $this->touchState(); - - return $quest->addRewardItem($afterSlot); - } - - /** - * Removes a reward item slot from a quest. - * - * @param int $index The quest index. - * @param int $slot The slot. - * @return string|null The removed item name. - */ - public function removeRewardItem(int $index, int $slot): ?string - { - $quest = $this->getQuestByIndex($index); - - if (! $quest instanceof ProjectQuest) { - return null; - } - - $removed = $quest->removeRewardItem($slot); - - if ($removed !== null) { - $this->touchState(); - } - - return $removed; - } - - /** - * Puts a removed reward item back where it was. - * - * @param int $index The quest index. - * @param int $slot The slot it held. - * @param string $item The item name. - * @return void - */ - public function insertRewardItem(int $index, int $slot, string $item): void - { - $quest = $this->getQuestByIndex($index); - - if (! $quest instanceof ProjectQuest) { - return; - } - - $items = $quest->getRewardItems(); - array_splice($items, min($slot, count($items)), 0, [$item]); - $quest->setRewardItems($items); - $this->touchState(); - } - - /** - * Writes the quest asset back to disk. - * - * @return void - */ - public function save(): void - { - $directory = dirname($this->path); - - if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { - throw new RuntimeException("Unable to create {$directory}."); - } - - if (! $this->isDirty() && is_file($this->path)) { - return; - } - - AtomicFile::write($this->path, $this->buildPersistedPayload()); - - foreach ($this->quests as $quest) { - $quest->markClean(); - } - - $this->captureBaseline(); - } - - /** - * @inheritDoc - */ - protected function dependencyVersion(): string - { - $versions = []; - - foreach ($this->quests as $quest) { - $versions[] = $quest->stateVersion(); - } - - return count($this->quests) . ':' . implode(',', $versions); - } - - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - return $this->sourceHeader . 'return ' . self::exportPhpValue(array_map( - static fn(ProjectQuest $quest): array => $quest->toArray(), - $this->getQuests() - )) . ";\n"; - } - - /** - * Writes a file using a temp-file swap. - * - * @param string $path The destination file. - * @param string $contents The file contents. - * @return void - */ - private static function writeFileTransactionally(string $path, string $contents): void - { - $temporaryPath = $path . '.tmp'; - - if (file_put_contents($temporaryPath, $contents) === false) { - throw new RuntimeException("Unable to write temporary file for {$path}."); - } - - if (! rename($temporaryPath, $path)) { - @unlink($temporaryPath); - throw new RuntimeException("Unable to replace {$path}."); - } - } - - /** - * Exports a PHP value using short-array syntax. - * - * @param mixed $value The value to export. - * @param int $indentLevel The indentation depth. - * @return string - */ - private static function exportPhpValue(mixed $value, int $indentLevel = 0): string - { - if (! is_array($value)) { - return var_export($value, true); - } - - if ($value === []) { - return '[]'; - } - - $indent = str_repeat(' ', $indentLevel); - $nextIndent = str_repeat(' ', $indentLevel + 1); - $isList = array_is_list($value); - $lines = ['[']; - - foreach ($value as $key => $item) { - $exportedItem = self::exportPhpValue($item, $indentLevel + 1); - - if ($isList) { - $lines[] = "{$nextIndent}{$exportedItem},"; - continue; - } - - $exportedKey = var_export($key, true); - $lines[] = "{$nextIndent}{$exportedKey} => {$exportedItem},"; - } - - $lines[] = "{$indent}]"; - - return implode("\n", $lines); - } - - /** - * Returns the files a save of this database would overwrite. - * - * @return string[] - */ - public function getBackupPaths(): array - { - return [$this->path]; - } -} diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index f4bada9..58bc998 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -8,7 +8,9 @@ use Ichiloto\Editor\Cutscenes\CutsceneLibrary; use Ichiloto\Editor\Database\DatabaseCatalog; use Ichiloto\Editor\Database\EngineDataBootstrap; +use Ichiloto\Editor\Database\ProjectRecord; use Ichiloto\Editor\Database\ProjectRecordDatabase; +use Ichiloto\Editor\Database\QuestReferences; use Ichiloto\Editor\Database\RecordSchema; use Ichiloto\Editor\Database\RecordSchemaCatalog; use Ichiloto\Editor\Events\ProjectScriptCommands; @@ -45,12 +47,29 @@ public function __construct( public ProjectActorDatabase $actorDatabase, public ProjectSkillDatabase $skillDatabase, public ProjectSystemDatabase $systemDatabase, - public ProjectQuestDatabase $questDatabase, public array $recordDatabases = [], public ?CutsceneLibrary $cutscenes = null, public ?ProjectConfig $config = null, public ?ProjectScriptCommands $scriptCommands = null, ) { + // A quest's id follows its name until something refers to it, and + // what refers to a quest lives across the whole project. + $this->getRecordDatabase('quests')?->useIdentityReferences( + fn(string $questId): bool => new QuestReferences($this)->exist($questId), + ); + } + + /** + * The project's quests as validation and the journal panes read them. + * + * @return list + */ + public function getQuests(): array + { + return array_map( + static fn(ProjectRecord $record): ProjectQuest => new ProjectQuest((array) $record->toArray()), + array_values($this->getRecordDatabase('quests')?->getRecords() ?? []), + ); } /** @@ -171,7 +190,6 @@ private static function loadFromProject(string $projectRoot): self actorDatabase: ProjectActorDatabase::fromProject($projectRoot), skillDatabase: ProjectSkillDatabase::fromProject($projectRoot), systemDatabase: ProjectSystemDatabase::fromProject($projectRoot), - questDatabase: ProjectQuestDatabase::fromProject($projectRoot), recordDatabases: array_map( static fn(RecordSchema $schema): ProjectRecordDatabase => ProjectRecordDatabase::fromProject($projectRoot, $schema, $projectConfig), RecordSchemaCatalog::all(), @@ -281,7 +299,6 @@ public function hasUnsavedChanges(): bool return $this->actorDatabase->isDirty() || $this->skillDatabase->isDirty() || $this->systemDatabase->isDirty() - || $this->questDatabase->isDirty() || ($this->config?->isDirty() ?? false) || ($this->cutscenes?->hasUnsavedChanges() ?? false); } @@ -320,14 +337,13 @@ public function listUnsavedChanges(): array * Read-only record categories are left out: they hold no edits, and * asking them to save would raise instead of doing nothing. * - * @return array + * @return array */ public function listSaveableDatabases(): array { $databases = [ 'Actors' => $this->actorDatabase, 'Skills' => $this->skillDatabase, - 'Quests' => $this->questDatabase, 'System' => $this->systemDatabase, ]; @@ -408,7 +424,6 @@ private function withMaps(array $maps): self actorDatabase: $this->actorDatabase, skillDatabase: $this->skillDatabase, systemDatabase: $this->systemDatabase, - questDatabase: $this->questDatabase, recordDatabases: $this->recordDatabases, cutscenes: $this->cutscenes, config: $this->config, diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 5260609..48d872f 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -14,6 +14,7 @@ use Ichiloto\Editor\Database\ConditionEditor; use Ichiloto\Editor\Database\DatabaseCatalog; use Ichiloto\Editor\Database\ProjectRecordDatabase; +use Ichiloto\Editor\Database\RecordItem; use Ichiloto\Editor\Database\RecordAuthoring; use Ichiloto\Editor\Database\RecordChange; use Ichiloto\Editor\Database\RecordRefusal; @@ -1248,10 +1249,13 @@ public function listDatabaseRecords(string $category): array * it to edit the commands inside. A row of an item the record's lists * hold carries `item` and `itemNoun`, and `childNoun` when the item holds * a list of its own (a route's steps, a choice's options): add and - * remove act on it. + * remove act on it. The heading of a list beside the record's own (a + * quest's reward items) carries `listHeading`: adding to it adds that + * list's first entry. `listNoun` names an entry of the list an add with + * no row goes to. * * @param array $frame The frame; [] for the record itself. - * @return array{category: string, index: int, frame: list, frameLabel: ?string, editable: bool, readOnly: ?string, rows: list>} + * @return array{category: string, index: int, frame: list, frameLabel: ?string, editable: bool, readOnly: ?string, listNoun: ?string, rows: list>} * @throws SessionRefusal When the category, record or frame is unknown. */ public function readDatabaseRecord(string $category, int $index, array $frame = []): array @@ -1267,6 +1271,9 @@ public function readDatabaseRecord(string $category, int $index, array $frame = 'frameLabel' => $frame === [] ? null : $database->describeFramePath($frame), 'editable' => $database->isEditable(), 'readOnly' => $database->getReadOnlyReason(), + // What an entry of the list an add with no row goes to is called: + // the record's own list, or the open frame's. + 'listNoun' => $database->getFrameSubList($frame)?->singular, // A row that names a field keeps its key whether or not it can be // edited; a read-only one is an `info` row the edit refuses. 'rows' => array_map(static fn(array $field): array => self::describeRecordRow( @@ -1285,7 +1292,7 @@ public function readDatabaseRecord(string $category, int $index, array $frame = * field cannot take is refused with what is wrong with it. * * @param array $key The row's key, as `database.record` gave it. - * @return array{changed: bool, records: list} Whether it changed, and the labels afterwards (a rename shows). + * @return array{changed: bool, records: list, note?: string} Whether it changed, the labels afterwards (a rename shows), and what else it did. * @throws SessionRefusal When the category or record is unknown or read-only, the row is gone or read-only, or the value is refused. */ public function applyDatabaseRecord(string $category, int $index, array $key, string $value): array @@ -1308,7 +1315,8 @@ public function applyDatabaseRecord(string $category, int $index, array $key, st $database, $index, $frame, $fieldId, $value, (string) ($field['label'] ?? 'Database field'), )); - return ['changed' => $change->command !== null, 'records' => self::listRecordLabels($database)]; + return array_filter(['changed' => $change->command !== null, 'records' => self::listRecordLabels($database), 'note' => $change->note], + static fn(mixed $value): bool => $value !== null); } /** @@ -1521,6 +1529,10 @@ private static function describeRecordRow(ProjectRecordDatabase $records, int $i if ($item !== null) { $row['item'] = true; $row['itemNoun'] = $item->noun; + if ($item->kind === RecordItem::LIST) { + // A list's heading: entries are added to it, never removed with it. + $row['listHeading'] = true; + } if ($item->childNoun !== null) { $row['childNoun'] = $item->childNoun; } diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index c1c3c48..92f2799 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -2927,7 +2927,7 @@ protected function isFollowedByArrow(array $tokens, int $index): bool */ protected function checkQuests(ProjectWorkspace $workspace): array { - $quests = $workspace->questDatabase->getQuests(); + $quests = $workspace->getQuests(); if ($quests === []) { return []; diff --git a/src/Validation/SaveCompatibilityValidator.php b/src/Validation/SaveCompatibilityValidator.php index e548970..3b356c9 100644 --- a/src/Validation/SaveCompatibilityValidator.php +++ b/src/Validation/SaveCompatibilityValidator.php @@ -324,7 +324,7 @@ private function knownTargets(ProjectWorkspace $workspace, ContentReferenceCateg ContentReferenceCategory::ONE_SHOT_EVENT => $this->oneShotEventIds($workspace), ContentReferenceCategory::QUEST => array_map( static fn(object $quest): string => $quest->getId(), - $workspace->questDatabase->getQuests() + $workspace->getQuests() ), // The durable identity a save reconstructs an actor by, which // is the definition id where one is declared and the display diff --git a/tests/Unit/QuestDatabaseTest.php b/tests/Unit/QuestDatabaseTest.php index f8dcce9..6417fdd 100644 --- a/tests/Unit/QuestDatabaseTest.php +++ b/tests/Unit/QuestDatabaseTest.php @@ -4,7 +4,9 @@ use Ichiloto\Editor\Database\DatabaseCatalog; use Ichiloto\Editor\History\CommandHistory; -use Ichiloto\Editor\ProjectQuestDatabase; +use Ichiloto\Editor\Database\ProjectRecordDatabase; +use Ichiloto\Editor\Database\RecordSchemaCatalog; +use Ichiloto\Editor\ProjectQuest; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Quests\Quest; @@ -22,6 +24,18 @@ function questsEditor(): \Ichiloto\Editor\Editor return $editor; } +/** The quests category of a project, through the shared record service. */ +function questRecords(string $root): ProjectRecordDatabase +{ + return ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('quests')); +} + +/** @return list */ +function questViews(ProjectRecordDatabase $database): array +{ + return array_map(static fn($record): ProjectQuest => new ProjectQuest((array) $record->toArray()), $database->getRecords()); +} + /** * Copies the fixture project into a scratch directory so saves never touch * the shared fixture. @@ -37,8 +51,8 @@ function scratchQuestProject(): string } it('loads the quests from the fixture project', function () { - $database = ProjectQuestDatabase::fromProject(fixturePath('sample-project')); - $quests = $database->getQuests(); + $database = questRecords(fixturePath('sample-project')); + $quests = questViews($database); expect($quests)->toHaveCount(2) ->and($quests[0]->getId())->toBe('breakfast-duty') @@ -52,11 +66,11 @@ function scratchQuestProject(): string it('tolerates a missing quests.php and creates it on first save', function () { $root = rememberTemporaryProject(sys_get_temp_dir() . '/ichiloto-quests-empty-' . uniqid()); mkdir($root, 0777, true); - $database = ProjectQuestDatabase::fromProject($root); + $database = questRecords($root); - expect($database->getQuests())->toBe([]); + expect($database->getRecords())->toBe([]); - $index = $database->addQuest(); + $index = $database->addRecord(); $database->setField($index, 'objective0Target', 'Mom'); $database->save(); @@ -71,16 +85,16 @@ function scratchQuestProject(): string it('round-trips the example file shape losslessly through save', function () { $root = scratchQuestProject(); - $original = require $root . '/assets/Data/quests.php'; + $original = (string) file_get_contents($root . '/assets/Data/quests.php'); - $database = ProjectQuestDatabase::fromProject($root); + $database = questRecords($root); + $database->setField(0, 'giver', 'Grandma'); + $database->setField(0, 'giver', 'Mom'); $database->save(); - $saved = require $root . '/assets/Data/quests.php'; + expect((string) file_get_contents($root . '/assets/Data/quests.php'))->toBe($original); - expect($saved)->toBe($original); - - foreach ($saved as $entry) { + foreach (require $root . '/assets/Data/quests.php' as $entry) { expect(Quest::fromArray($entry))->toBeInstanceOf(Quest::class); } }); @@ -101,35 +115,33 @@ function scratchQuestProject(): string file_put_contents($path, $header . 'return ' . var_export($quests, true) . ";\n"); $original = require $path; - $database = ProjectQuestDatabase::fromProject($root); + $database = questRecords($root); + $database->setField(1, 'giver', 'Noticeboard'); $database->save(); $savedSource = (string) file_get_contents($path); $saved = require $path; - $reloaded = ProjectQuestDatabase::fromProject($root); + $original[1]['giver'] = 'Noticeboard'; expect($saved)->toBe($original) ->and($savedSource)->toStartWith($header) - ->and($savedSource)->toContain('// Production quest definitions. Keep this file-level header.') - ->and($saved[0]['productionMetadata'])->toBe(['owner' => 'narrative', 'revision' => 3]) - ->and(array_map( - static fn(\Ichiloto\Editor\ProjectQuest $quest): array => $quest->toArray(), - $reloaded->getQuests(), - ))->toBe($original); + ->and($saved[0]['productionMetadata'])->toBe(['owner' => 'narrative', 'revision' => 3]); }); it('builds the quest settings fields with the five objective types', function () { - // Regression guard mirroring the AnimationTargetPosition test: the field - // builder must resolve QuestObjectiveType and flatten the objectives. $editor = questsEditor(); $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); $labels = array_column($fields, 'label'); - $typeField = $fields[array_search('Obj 1 Type', $labels, true)]; + $typeField = $fields[array_search('Objective 1 Type', $labels, true)]; - expect($labels)->toContain('Id', 'Name', 'Description', 'Giver', 'Reward Gold', 'Reward EXP', 'Prereqs', 'Obj 1 Type', 'Obj 1 Target', 'Obj 1 Qty', 'Obj 1 Text', 'Obj 1 Revealed', 'Obj 1 Reveal When', 'Obj 2 Type') + expect($labels)->toContain('Id', 'Name', 'Description', 'Giver', 'Optional', 'Reward Gold', 'Reward EXP', 'Prereqs', + 'Objective 1 Type', 'Objective 1 Target', 'Objective 1 Quantity', 'Objective 1 Text', 'Objective 1 Revealed', + 'Objective 1 Reveal When', 'Objective 2 Type', 'Reward Items') ->and($typeField['options'])->toBe(['talk_to', 'collect', 'defeat', 'reach_map', 'flag']) - ->and($typeField['value'])->toBe('reach_map'); + ->and($typeField['value'])->toBe('reach_map') + // The target is picked from what the type asks for: a map to reach. + ->and($fields[array_search('Objective 1 Target', $labels, true)]['reference'] ?? null)->toBe('maps'); }); it('edits, saves, and reloads a quest through the editor', function () { @@ -139,37 +151,42 @@ function scratchQuestProject(): string setEditorProperty($editor, 'lastTerminalSize', ['width' => 120, 'height' => 40]); setEditorProperty($editor, 'databaseCategoryIndex', DatabaseCatalog::indexOf('quests')); - $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); - $labels = array_column($fields, 'label'); + $field = static function (string $label) use ($editor): array { + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); + + return $fields[array_search($label, array_column($fields, 'label'), true)]; + }; - callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $fields[array_search('Name', $labels, true)], 'Morning Errand'); - callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $fields[array_search('Reward Gold', $labels, true)], '350'); - callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $fields[array_search('Obj 1 Target', $labels, true)], 'happyville/plaza'); - callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $fields[array_search('Obj 1 Revealed', $labels, true)], 'Return to the east gate'); - callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $fields[array_search('Obj 1 Reveal When', $labels, true)], 'event:east_gate_identified'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field('Name'), 'Morning Errand'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field('Reward Gold'), '350'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field('Objective 1 Target'), 'happyville/plaza'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field('Objective 1 Revealed'), 'Return to the east gate'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field('Objective 1 Reveal When'), 'event:east_gate_identified'); /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); + $database = $workspace->getRecordDatabase('quests'); - expect($workspace->questDatabase->isDirty())->toBeTrue() + expect($database->isDirty())->toBeTrue() ->and(callEditorMethod($editor, 'isDatabaseCategoryDirty', 'quests'))->toBeTrue(); - $workspace->questDatabase->save(); + $database->save(); - expect($workspace->questDatabase->isDirty())->toBeFalse(); + expect($database->isDirty())->toBeFalse(); - $reloaded = ProjectQuestDatabase::fromProject($root); - $quest = $reloaded->getQuestByIndex(0); + $quest = questViews(questRecords($root))[0]; expect($quest->getName())->toBe('Morning Errand') + // Something points at breakfast-duty, so its id stayed. + ->and($quest->getId())->toBe('breakfast-duty') ->and($quest->getRewardGold())->toBe(350) ->and($quest->getObjectives()[0]['target'])->toBe('happyville/plaza') ->and($quest->getObjectives()[0]['description'])->toBe('Visit the Happyville town center') ->and($quest->getObjectives()[0]['revealedDescription'])->toBe('Return to the east gate') ->and($quest->getObjectives()[0]['revealConditions'])->toBe([['type' => 'event', 'name' => 'east_gate_identified']]); - foreach ($reloaded->getQuests() as $entry) { - expect(Quest::fromArray($entry->toArray()))->toBeInstanceOf(Quest::class); + foreach (require $root . '/assets/Data/quests.php' as $entry) { + expect(Quest::fromArray($entry))->toBeInstanceOf(Quest::class); } }); @@ -183,23 +200,23 @@ function scratchQuestProject(): string /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - $quest = $workspace->questDatabase->getQuestByIndex(0); + $record = $workspace->getRecordDatabase('quests')->getRecordByIndex(0); - expect($quest->getGiver())->toBe('Grandma'); + expect($record->get('giver'))->toBe('Grandma'); // Move the selection elsewhere: undo must still hit quest 0. - setEditorProperty($editor, 'databaseSelectedQuestIndex', 1); + setEditorProperty($editor, 'databaseSelectedRecordIndexes', ['quests' => 1]); /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); $history->undo(); - expect($quest->getGiver())->toBe('Mom') - ->and(getEditorProperty($editor, 'databaseSelectedQuestIndex'))->toBe(1); + expect($record->get('giver'))->toBe('Mom') + ->and(getEditorProperty($editor, 'databaseSelectedRecordIndexes')['quests'])->toBe(1); $history->redo(); - expect($quest->getGiver())->toBe('Grandma'); + expect($record->get('giver'))->toBe('Grandma'); }); it('adds and removes objectives with undo support', function () { @@ -207,57 +224,53 @@ function scratchQuestProject(): string /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - $quest = $workspace->questDatabase->getQuestByIndex(0); + $database = $workspace->getRecordDatabase('quests'); + $objectives = static fn(): array => $database->getSubItems(0); - expect($quest->getObjectives())->toHaveCount(2); + expect($objectives())->toHaveCount(2); // The handlers repaint the Database panes; swallow the ANSI output. ob_start(); - callEditorMethod($editor, 'addDatabaseQuestObjective'); + callEditorMethod($editor, 'addDatabaseRecordSubItem'); - expect($quest->getObjectives())->toHaveCount(3) - ->and($quest->getObjectives()[2]['type'])->toBe('talk_to'); + expect($objectives())->toHaveCount(3) + ->and($objectives()[2]['type'])->toBe('talk_to'); /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); $history->undo(); - expect($quest->getObjectives())->toHaveCount(2); + expect($objectives())->toHaveCount(2); $history->redo(); - expect($quest->getObjectives())->toHaveCount(3); + expect($objectives())->toHaveCount(3); - callEditorMethod($editor, 'removeDatabaseQuestObjective'); + callEditorMethod($editor, 'removeDatabaseRecordSubItem'); ob_end_clean(); - expect($quest->getObjectives())->toHaveCount(2); + expect($objectives())->toHaveCount(2); $history->undo(); - expect($quest->getObjectives())->toHaveCount(3); + expect($objectives())->toHaveCount(3); }); it('round-trips prerequisites through the one-line editable form', function () { - $editor = questsEditor(); - setEditorProperty($editor, 'databaseSelectedQuestIndex', 1); - - /** @var ProjectWorkspace $workspace */ - $workspace = getEditorProperty($editor, 'workspace'); - $quest = $workspace->questDatabase->getQuestByIndex(1); + $database = questRecords(fixturePath('sample-project')); - expect($quest->getPrerequisitesString())->toBe('quest:breakfast-duty:completed'); + expect(questViews($database)[1]->getPrerequisitesString())->toBe('quest:breakfast-duty:completed'); - $quest->setField('prerequisites', 'quest:breakfast-duty:active; !switch:dark-mode; item:Rusty Key:2'); + $database->setField(1, 'prerequisites', 'quest:breakfast-duty:active; !switch:dark-mode; item:Rusty Key:2'); - expect($quest->getPrerequisites())->toBe([ + expect(questViews($database)[1]->getPrerequisites())->toBe([ ['type' => 'quest', 'name' => 'breakfast-duty', 'status' => 'active'], ['type' => 'switch', 'name' => 'dark-mode', 'negate' => true], ['type' => 'item', 'name' => 'Rusty Key', 'quantity' => 2], ]); - $quest->setField('prerequisites', ''); + $database->setField(1, 'prerequisites', ''); - expect($quest->getPrerequisites())->toBe([]) - ->and(array_key_exists('prerequisites', $quest->toArray()))->toBeFalse(); + expect(questViews($database)[1]->getPrerequisites())->toBe([]) + ->and(array_key_exists('prerequisites', questViews($database)[1]->toArray()))->toBeFalse(); }); diff --git a/tests/Unit/QuestIdentityTest.php b/tests/Unit/QuestIdentityTest.php index f30dc83..344061f 100644 --- a/tests/Unit/QuestIdentityTest.php +++ b/tests/Unit/QuestIdentityTest.php @@ -4,7 +4,7 @@ use Ichiloto\Editor\Database\QuestReferences; use Ichiloto\Editor\Database\Slug; -use Ichiloto\Editor\ProjectQuestDatabase; +use Ichiloto\Editor\Database\RecordAuthoring; use Ichiloto\Editor\ProjectWorkspace; it('makes an id out of a name', function () { @@ -23,43 +23,54 @@ }); it('gives a new quest an id from its name', function () { - $database = ProjectQuestDatabase::fromProject(makeTemporaryProject()); + $database = ProjectWorkspace::fromProject(makeTemporaryProject())->getRecordDatabase('quests'); + $first = $database->addRecord(); + $second = $database->addRecord(); + $database->setField($first, 'name', 'Feed The Cat'); + $database->setField($second, 'name', 'Breakfast Duty'); - expect($database->getQuestByIndex($database->addQuest('Feed The Cat'))->getId())->toBe('feed-the-cat') + expect($database->getRecordByIndex($first)->get('id'))->toBe('feed-the-cat') // The fixture already has a breakfast-duty, which is the collision // the numbering is for. - ->and($database->getQuestByIndex($database->addQuest('Breakfast Duty'))->getId())->toBe('breakfast-duty-2'); + ->and($database->getRecordByIndex($second)->get('id'))->toBe('breakfast-duty-2'); }); it('keeps the id in step with the name while nothing points at it', function () { - $database = ProjectQuestDatabase::fromProject(makeTemporaryProject()); - $index = $database->addQuest('New Quest'); + $database = ProjectWorkspace::fromProject(makeTemporaryProject())->getRecordDatabase('quests'); + $index = $database->addRecord(); - $id = $database->renameQuest($index, 'Feed The Cat', mayChangeId: true); + $change = new RecordAuthoring()->applyField($database, $index, [], 'name', 'Feed The Cat', 'Name'); - expect($id)->toBe('feed-the-cat') - ->and($database->getQuestByIndex($index)->getName())->toBe('Feed The Cat'); + expect($database->getRecordByIndex($index)->get('id'))->toBe('feed-the-cat') + ->and($database->getRecordByIndex($index)->get('name'))->toBe('Feed The Cat') + ->and($change->note)->toBe('Its id is now feed-the-cat.'); + + $change->command->undo(); + + expect($database->getRecordByIndex($index)->get('id'))->toBe('new-quest'); }); it('holds the id still once something points at it', function () { - $database = ProjectQuestDatabase::fromProject(makeTemporaryProject()); - $index = $database->addQuest('New Quest'); + // The fixture's skit waits on breakfast-duty. + $database = ProjectWorkspace::fromProject(makeTemporaryProject())->getRecordDatabase('quests'); - $id = $database->renameQuest($index, 'Feed The Cat', mayChangeId: false); + $change = new RecordAuthoring()->applyField($database, 0, [], 'name', 'Feed The Cat', 'Name'); // Renaming does not rewrite the triggers and conditions that name it, so // the id is what stays put. - expect($id)->toBeNull() - ->and($database->getQuestByIndex($index)->getId())->toBe('new-quest') - ->and($database->getQuestByIndex($index)->getName())->toBe('Feed The Cat'); + expect($database->getRecordByIndex(0)->get('id'))->toBe('breakfast-duty') + ->and($database->getRecordByIndex(0)->get('name'))->toBe('Feed The Cat') + ->and($change->note)->toBe('Its id stays breakfast-duty, which other things point at.'); }); it('numbers a renamed quest away from one that has the name already', function () { - $database = ProjectQuestDatabase::fromProject(makeTemporaryProject()); - $database->addQuest('Feed The Cat'); - $second = $database->addQuest('New Quest'); + $database = ProjectWorkspace::fromProject(makeTemporaryProject())->getRecordDatabase('quests'); + $first = $database->addRecord(); + $second = $database->addRecord(); + $database->setField($first, 'name', 'Feed The Cat'); + $database->setField($second, 'name', 'Feed The Cat'); - expect($database->renameQuest($second, 'Feed The Cat', mayChangeId: true))->toBe('feed-the-cat-2'); + expect($database->getRecordByIndex($second)->get('id'))->toBe('feed-the-cat-2'); }); it('finds what points at a quest', function () { diff --git a/tests/Unit/QuestRewardItemsTest.php b/tests/Unit/QuestRewardItemsTest.php index b34291d..76afd0f 100644 --- a/tests/Unit/QuestRewardItemsTest.php +++ b/tests/Unit/QuestRewardItemsTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use Ichiloto\Editor\ProjectQuestDatabase; use Ichiloto\Editor\ProjectWorkspace; /** @@ -40,13 +39,21 @@ function selectQuestField(object $editor, string $fieldId): void throw new RuntimeException("No field {$fieldId} in the settings pane."); } -it('shows each reward item as a picked row, not a line to type', function () { - $root = makeTemporaryProject(); - $database = ProjectQuestDatabase::fromProject($root); - $database->getQuestByIndex(0)->setRewardItems(['S-Potion', 'Wooden Sword']); - $database->save(); +/** The open quest's reward items, as the record holds them. */ +function questRewardItems(object $editor): mixed +{ + return getEditorProperty($editor, 'workspace')->getRecordDatabase('quests')->getRecordByIndex(0)->get('rewards.items'); +} - $editor = editorOnQuests($root); +/** Gives the first quest reward items, authored as bare names. */ +function giveQuestRewards(object $editor, array $items): void +{ + getEditorProperty($editor, 'workspace')->getRecordDatabase('quests')->getRecordByIndex(0)->set('rewards.items', $items); +} + +it('shows each reward item as a picked row, not a line to type', function () { + $editor = editorOnQuests(makeTemporaryProject()); + giveQuestRewards($editor, ['S-Potion', 'Wooden Sword']); $byField = []; foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $field) { @@ -55,80 +62,105 @@ function selectQuestField(object $editor, string $fieldId): void // Names resolve through the engine's item store, so a misspelling is a // reward that silently never arrives. Chosen, never spelled. - expect($byField['rewardItem0']['reference'] ?? null)->toBe('inventory') - ->and($byField['rewardItem0']['value'] ?? null)->toBe('S-Potion') - ->and($byField['rewardItem1']['value'] ?? null)->toBe('Wooden Sword') - ->and($byField['rewardItem0'] ?? [])->not->toHaveKey('control') - ->and($byField)->not->toHaveKey('rewardItems'); + expect($byField['reward0Item']['reference'] ?? null)->toBe('inventory') + ->and($byField['reward0Item']['value'] ?? null)->toBe('S-Potion') + ->and($byField['reward1Item']['value'] ?? null)->toBe('Wooden Sword') + ->and($byField['reward0Item'] ?? [])->not->toHaveKey('control') + ->and($byField['rewardList']['value'] ?? null)->toBe('2'); }); -it('adds a reward slot from the reward rows and removes it again', function () { - $root = makeTemporaryProject(); - $editor = editorOnQuests($root); - - // Adding the first slot works from the Reward Gold row, since there is - // no reward row to stand on yet. - selectQuestField($editor, 'rewardGold'); - callEditorMethod($editor, 'addDatabaseQuestObjective'); +it('adds a reward item from the reward heading and removes it again', function () { + $editor = editorOnQuests(makeTemporaryProject()); - $quest = getEditorProperty($editor, 'workspace')->questDatabase->getQuestByIndex(0); + // The heading is where the first one is added, as there is no reward + // row to stand on yet. + selectQuestField($editor, 'rewardList'); + callEditorMethod($editor, 'addDatabaseRecordSubItem'); - expect($quest->getRewardItems())->toHaveCount(1); + expect(questRewardItems($editor))->toHaveCount(1); - selectQuestField($editor, 'rewardItem0'); - callEditorMethod($editor, 'removeDatabaseQuestObjective'); + selectQuestField($editor, 'reward0Item'); + callEditorMethod($editor, 'removeDatabaseRecordSubItem'); - expect($quest->getRewardItems())->toBe([]); + expect(questRewardItems($editor))->toBeNull(); }); it('still adds an objective when the cursor is not on a reward row', function () { - $root = makeTemporaryProject(); - $editor = editorOnQuests($root); - $quest = getEditorProperty($editor, 'workspace')->questDatabase->getQuestByIndex(0); - $objectivesBefore = count($quest->getObjectives()); + $editor = editorOnQuests(makeTemporaryProject()); + $database = getEditorProperty($editor, 'workspace')->getRecordDatabase('quests'); + $objectivesBefore = count($database->getSubItems(0)); selectQuestField($editor, 'name'); - callEditorMethod($editor, 'addDatabaseQuestObjective'); + callEditorMethod($editor, 'addDatabaseRecordSubItem'); - expect(count($quest->getObjectives()))->toBe($objectivesBefore + 1) - ->and($quest->getRewardItems())->toBe([]); + expect(count($database->getSubItems(0)))->toBe($objectivesBefore + 1) + ->and(questRewardItems($editor))->toBeNull(); }); it('puts back what undo removed, in the same slot', function () { - $root = makeTemporaryProject(); - $editor = editorOnQuests($root); - $database = getEditorProperty($editor, 'workspace')->questDatabase; - $database->getQuestByIndex(0)->setRewardItems(['S-Potion', 'Wooden Sword', 'S-Mana']); + $editor = editorOnQuests(makeTemporaryProject()); + giveQuestRewards($editor, ['S-Potion', 'Wooden Sword', 'S-Mana']); - selectQuestField($editor, 'rewardItem1'); - callEditorMethod($editor, 'removeDatabaseQuestObjective'); + selectQuestField($editor, 'reward1Item'); + callEditorMethod($editor, 'removeDatabaseRecordSubItem'); - expect($database->getQuestByIndex(0)->getRewardItems())->toBe(['S-Potion', 'S-Mana']); + expect(questRewardItems($editor))->toBe(['S-Potion', 'S-Mana']); callEditorMethod($editor, 'performUndo'); - expect($database->getQuestByIndex(0)->getRewardItems())->toBe(['S-Potion', 'Wooden Sword', 'S-Mana']); + expect(questRewardItems($editor))->toBe(['S-Potion', 'Wooden Sword', 'S-Mana']); }); -it('round-trips picked rewards through the saved file', function () { +it('round-trips picked rewards through the saved file, a bare name until given a quantity', function () { $root = makeTemporaryProject(); - $database = ProjectQuestDatabase::fromProject($root); - $quest = $database->getQuestByIndex(0); - $quest->addRewardItem(); - $quest->setField('rewardItem0', 'Wooden Sword'); + $database = ProjectWorkspace::fromProject($root)->getRecordDatabase('quests'); + $first = $database->addSubItem(0, listKey: 'rewards.items'); + $database->setField(0, 'reward' . $first . 'Item', 'Wooden Sword'); + $second = $database->addSubItem(0, listKey: 'rewards.items'); + $database->setField(0, 'reward' . $second . 'Item', 'S-Potion'); + $database->setField(0, 'reward' . $second . 'Quantity', '3'); $database->save(); - $reloaded = ProjectQuestDatabase::fromProject($root); - - expect($reloaded->getQuestByIndex(0)->getRewardItems())->toBe(['Wooden Sword']); + expect((require $root . '/assets/Data/quests.php')[0]['rewards']['items']) + ->toBe(['Wooden Sword', ['item' => 'S-Potion', 'quantity' => 3]]); }); it('drops the items key entirely when the last reward goes', function () { - $quest = ProjectQuestDatabase::fromProject(makeTemporaryProject())->getQuestByIndex(0); - $quest->setRewardItems(['S-Potion']); - $quest->removeRewardItem(0); + $editor = editorOnQuests(makeTemporaryProject()); + giveQuestRewards($editor, ['S-Potion']); + + selectQuestField($editor, 'reward0Item'); + callEditorMethod($editor, 'removeDatabaseRecordSubItem'); // An empty list would read as "rewards nothing, explicitly"; absence is // what the authored files use. - expect($quest->getRewards())->not->toHaveKey('items'); + expect(getEditorProperty($editor, 'workspace')->getRecordDatabase('quests')->getRecordByIndex(0)->get('rewards')) + ->not->toHaveKey('items'); +}); + +it('lets the GUI add to a list beside the record\'s own from its heading, and rename with a note', function () { + $session = \Ichiloto\Editor\Session\EditorSession::open(makeTemporaryProject()); + $rows = $session->readDatabaseRecord('quests', 0)['rows']; + $heading = array_find($rows, static fn(array $row): bool => ($row['key']['field'] ?? null) === 'rewardList'); + + expect($heading)->toMatchArray(['label' => 'Reward Items', 'value' => '0', 'item' => true, 'listHeading' => true, 'childNoun' => 'reward item']); + + $session->addDatabaseItem('quests', 0, $heading['key'], true); + $rows = $session->readDatabaseRecord('quests', 0)['rows']; + $item = array_find($rows, static fn(array $row): bool => ($row['key']['field'] ?? null) === 'reward0Item'); + + expect($item)->toMatchArray(['item' => true, 'itemNoun' => 'reward item', 'reference' => 'inventory']) + ->and($item)->not->toHaveKey('listHeading'); + + $name = array_find($rows, static fn(array $row): bool => ($row['key']['field'] ?? null) === 'name'); + + expect($session->applyDatabaseRecord('quests', 0, $name['key'], 'Morning Errand')['note'] ?? null) + ->toBe('Its id stays breakfast-duty, which other things point at.'); +}); + +it('names the record\'s own list for an add with no row, not the list beside it', function () { + $session = \Ichiloto\Editor\Session\EditorSession::open(makeTemporaryProject()); + + expect($session->readDatabaseRecord('quests', 0)['listNoun'])->toBe('objective') + ->and($session->readDatabaseRecord('states', 0)['listNoun'] ?? null)->toBeNull(); }); diff --git a/tests/Unit/SaveIntegrityTest.php b/tests/Unit/SaveIntegrityTest.php index 1883e05..901e19b 100644 --- a/tests/Unit/SaveIntegrityTest.php +++ b/tests/Unit/SaveIntegrityTest.php @@ -320,7 +320,6 @@ function hashTree(string $directory): array $workspace->actorDatabase->save(); $workspace->skillDatabase->save(); - $workspace->questDatabase->save(); $workspace->systemDatabase->save(); foreach ($workspace->recordDatabases as $database) { From b2f9cc38064255269225ca35304b5b2eafa5416e Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 14:57:05 +0200 Subject: [PATCH 152/344] feat(actors): choose the basic skill an actor attacks with An actor's Attack Skill row picks the battle-usable basic skill its Attack command uses, from the skill catalogue, or leaves it to the Engine's built-in attack. Validation reports one that is not such a skill. --- src/Database/ReferenceCatalog.php | 17 ++++++++ src/Editor.php | 14 +++++- src/ProjectActor.php | 27 ++++++++++++ src/Validation/ProjectValidator.php | 11 +++++ tests/Unit/ActorAttackSkillTest.php | 66 +++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/ActorAttackSkillTest.php diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 525a04e..2c124b8 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -14,6 +14,9 @@ use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Ichiloto\Engine\Entities\Enumerations\ArmorType; +use Ichiloto\Engine\Entities\Enumerations\Occasion; +use Ichiloto\Engine\Entities\Skills\BasicSkill; +use Ichiloto\Engine\Entities\Skills\Skill; use Ichiloto\Engine\Entities\Enumerations\WeaponType; /** @@ -35,6 +38,7 @@ final class ReferenceCatalog 'actors', 'actor_ids', 'classes', + 'attack_skills', 'weapon_types', 'armor_types', 'skills', @@ -130,6 +134,9 @@ public function valuesFor(string $category): array array_values(array_filter($this->workspace->actorDatabase->getActors(), static fn(ProjectActor $actor): bool => $actor->hasDefinitionId())) ), + // What an actor's Attack command may use: a basic skill from the + // catalogue that can be used in battle. + 'attack_skills' => $this->attackSkillNames(), // A class restricts what its members equip by the engine's type names. 'weapon_types' => array_map(static fn(WeaponType $type): string => $type->value, WeaponType::cases()), 'armor_types' => array_map(static fn(ArmorType $type): string => $type->value, ArmorType::cases()), @@ -206,6 +213,16 @@ public function valuesFor(string $category): array }; } + /** @return string[] The catalogue's basic skills usable in battle, in catalogue order. */ + private function attackSkillNames(): array + { + return array_keys(array_filter( + $this->workspace->loadSkillCatalog()->getSkills(), + static fn(Skill $skill): bool => $skill instanceof BasicSkill + && in_array($skill->occasion, [Occasion::ALWAYS, Occasion::BATTLE_SCREEN], true), + )); + } + /** @return ProjectRecord[] The project's animations, in file order. */ private function animationRecords(): array { diff --git a/src/Editor.php b/src/Editor.php index 483bda7..a429a87 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -551,6 +551,8 @@ final class Editor * What the growth row reads when the preview assumes nothing was earned. */ private const string NO_ASSUMED_GROWTH = '(none earned yet)'; + /** What the Attack Skill row reads when the actor uses the Engine's own attack. */ + private const string BUILT_IN_ATTACK = '(Built-in attack)'; /** * What the growth row reads when the preview assumes all of it was. @@ -9716,6 +9718,16 @@ private function getDatabaseActorSettingsFields(): array 'field' => 'attackStyle', 'hint' => 'own weapon when none is equipped; no stats', ], + [ + // The basic skill the Attack command uses; a battle-usable + // basic skill from the catalogue, or the Engine's own attack. + 'label' => 'Attack Skill', + 'value' => $actor->getAttackSkill() === '' ? self::BUILT_IN_ATTACK : $actor->getAttackSkill(), + 'reference' => 'attack_skills', + 'allowsNone' => true, + 'noneLabel' => self::BUILT_IN_ATTACK, + 'field' => 'attackSkill', + ], ...$this->actorSummonFields($actor), [ 'label' => 'Level', @@ -11474,7 +11486,7 @@ private function applyDatabaseFieldValueAt(array $identity, string $field, strin */ private function coerceActorFieldValue(string $field, string $rawValue): string|int { - if (in_array($field, ['name', 'description', 'class', 'attackStyle', 'id', 'defaultNaturalVariantId', 'summons'], true)) { + if (in_array($field, ['name', 'description', 'class', 'attackStyle', 'attackSkill', 'id', 'defaultNaturalVariantId', 'summons'], true)) { return trim($rawValue); } diff --git a/src/ProjectActor.php b/src/ProjectActor.php index 432e8e4..52c3207 100644 --- a/src/ProjectActor.php +++ b/src/ProjectActor.php @@ -194,6 +194,18 @@ public function getAttackStyle(): string return is_string($style) ? $style : ''; } + /** + * Returns the basic skill the actor attacks with, from `data.attackSkill`. + * + * @return string The skill's name, or an empty string for the Engine's built-in attack. + */ + public function getAttackSkill(): string + { + $skill = $this->getData()['attackSkill'] ?? ''; + + return is_string($skill) ? $skill : ''; + } + /** * Returns the Engine weapon type an attack style names, read by the * Engine's own rule, or null when it names none. @@ -597,6 +609,21 @@ public function setField(string $field, mixed $value): void return; } + if ($field === 'attackSkill') { + // The built-in attack is the absence of a skill, so it removes + // the key; any other value names a basic skill in the catalogue. + $skill = trim((string) $value); + + if ($skill === '') { + unset($this->payload['data']['attackSkill']); + } else { + $this->payload['data']['attackSkill'] = $skill; + } + + $this->touchState(); + return; + } + if ($field === 'summons') { // A list of stable summon ids, each at most once, in the order // chosen; an empty list removes the key so the actor reads as diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 92f2799..5ab37ce 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -281,6 +281,9 @@ protected function checkActorDefinitions(ProjectWorkspace $workspace): array $seen = []; $statKeys = ActorStatPreview::statKeys(); + // What an actor's Attack command may use, by the rule its picker offers. + $attackSkills = new ReferenceCatalog($workspace)->valuesFor('attack_skills'); + foreach ($workspace->actorDatabase->getActors() as $actor) { $where = sprintf('actor %s', $actor->getName()); $id = $actor->getDefinitionId(); @@ -309,6 +312,14 @@ protected function checkActorDefinitions(ProjectWorkspace $workspace): array ); } + if ($actor->getAttackSkill() !== '' && ! in_array($actor->getAttackSkill(), $attackSkills, true)) { + $issues[] = Issue::error( + $where, + sprintf('Its attack skill "%s" is not a basic skill usable in battle.', $actor->getAttackSkill()), + 'Choose one on the Attack Skill row, or clear it for the built-in attack.' + ); + } + $variants = $actor->getNaturalVariants(); $default = $actor->getDefaultNaturalVariantId(); diff --git a/tests/Unit/ActorAttackSkillTest.php b/tests/Unit/ActorAttackSkillTest.php new file mode 100644 index 0000000..c7b3efe --- /dev/null +++ b/tests/Unit/ActorAttackSkillTest.php @@ -0,0 +1,66 @@ +valuesFor('attack_skills'))->toBe(['Attack']); +}); + +it('authors an actor\'s attack skill as a picked reference, the built-in attack when unset', function () { + $root = attackSkillProject(); + $editor = createEditorForTesting($root); + setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); + callEditorMethod($editor, 'openDatabaseAtCategory', 'actors'); + $row = static fn(): array => array_find(callEditorMethod($editor, 'getDatabaseSettingsFields'), + static fn(array $field): bool => ($field['field'] ?? null) === 'attackSkill'); + + expect($row())->toMatchArray(['label' => 'Attack Skill', 'value' => '(Built-in attack)', 'reference' => 'attack_skills', 'allowsNone' => true]) + ->and($row())->not->toHaveKey('control'); + + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $row(), 'Attack'); + $actors = getEditorProperty($editor, 'workspace')->actorDatabase; + $actors->save(); + $path = glob($root . '/assets/Data/Actors/*.php')[0]; + + expect($row()['value'])->toBe('Attack') + ->and((require $path)['data']['attackSkill'])->toBe('Attack'); + + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $row(), ''); + $actors->save(); + + expect((require $path)['data'])->not->toHaveKey('attackSkill'); +}); + +it('reports an attack skill that is not a basic skill usable in battle', function () { + $root = attackSkillProject(); + $workspace = ProjectWorkspace::fromProject($root); + $workspace->actorDatabase->setField(0, 'attackSkill', 'Fire'); + $issues = array_map(static fn(object $issue): string => $issue->message, new \Ichiloto\Editor\Validation\ProjectValidator()->validate($workspace)); + + expect($issues)->toContain('Its attack skill "Fire" is not a basic skill usable in battle.'); +}); From 0086b858497345ef90bea3adf3a31292b466919b Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 15:16:59 +0200 Subject: [PATCH 153/344] refactor(database): edit System and Configuration through the shared record service System was built by hand in the terminal editor over its own database, which regenerated system.php on every save, offered only the battle engine and ATB fields, and could not be opened in the GUI. It is now a schema-driven category over the whole of system.php: title, starting gold, starting party (bare actor ids) and inventory, starting map, position and facing, elements, battle engine, openings and every ATB setting, saved by editing only what changed. Field Zoom moves from System to a new Configuration category over the rest of config.php (save, accessibility, ui, graphics, audio, inn): one row per setting, typed by what it holds, enum settings offered as their cases by name and written back as cases. A setting the engine reads with a default is listed where the file leaves it out; choosing the default writes nothing. ProjectConfig owns the zoom's range for both editors. For every category, an edit the file could not be saved with, such as a value the author wrote as an expression, is now refused when it is made rather than when the category is saved; and a value written as an enum case's value (MovementHeading::SOUTH->value) is rewritten as the new case of the same enum rather than refused. Enum fields accept a case by value or name and work for enums without values. --- docs/manual.md | 27 +- src/Cutscenes/Source/ArraySourceWriter.php | 54 ++++ src/Database/DatabaseCatalog.php | 1 + src/Database/PhpDataFile.php | 38 ++- src/Database/ProjectRecordDatabase.php | 87 +++++- .../Projections/WholeFileProjection.php | 53 ++++ src/Database/RecordAuthoring.php | 10 + src/Database/RecordSchemaCatalog.php | 129 +++++++- src/Database/SharedFileTransaction.php | 41 ++- src/Editor.php | 154 ++-------- src/Maps/MapReferences.php | 2 +- src/ProjectConfig.php | 61 ++-- src/ProjectSystemDatabase.php | 288 ------------------ src/ProjectWorkspace.php | 16 +- src/Session/EditorSession.php | 2 +- src/Validation/ActorReferenceValidator.php | 2 +- src/Validation/ProjectValidator.php | 2 +- tests/Unit/ProjectConfigTest.php | 106 +++++-- tests/Unit/ProjectSystemDatabaseTest.php | 142 ++++++--- tests/Unit/SaveIntegrityTest.php | 1 - 20 files changed, 675 insertions(+), 541 deletions(-) create mode 100644 src/Database/Projections/WholeFileProjection.php delete mode 100644 src/ProjectSystemDatabase.php diff --git a/docs/manual.md b/docs/manual.md index f3aa9e9..656dbdd 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1032,7 +1032,8 @@ status line says exactly why. | Optimize Weights | `assets/Data/equipment-optimization.php` | Editable | | Optimize Outcomes | `assets/Data/equipment-optimization.php` | Editable | | Optimize Exclusions | `assets/Data/equipment-optimization.php` | Editable | -| System | `assets/Data/system.php` | Editable | +| System | `assets/Data/system.php` | Editable - title, starting gold, party, inventory and position, elements, battle engine, openings and ATB settings | +| Configuration | `config.php` (`save`, `accessibility`, `ui`, `graphics`, `audio`, `inn`) | Editable - one row per setting, typed by what it holds; field zoom is kept from 1 to 8 | | Types | `assets/Data/Types/*.php` | Read-only - PHP enum declarations | | Terms | `config.php` (`vocab`, `messages`) | Literal terms editable; comments and unrelated expressions preserved | @@ -1252,7 +1253,7 @@ The `vocab` and `messages` trees of the project's `config.php`, flattened to one row per term with its dotted path - `vocab.game.new_game`, `messages.confirm.quit`, and so on. -Terms and System's field zoom share one source-preserving configuration owner. +Terms and Configuration share one source-preserving configuration owner. Saving either writes all pending configuration edits in one atomic operation. Only edited literal values are patched; comments, spacing, enum references and unrelated executable expressions remain untouched. An opaque term expression @@ -1260,13 +1261,23 @@ is individually read-only, and ambiguous parent arrays or nonliteral returned configuration are refused rather than regenerated. A config changed on disk must be reloaded before saving, so newer external edits are not overwritten. -### Field Zoom +### Configuration -Open **Database > System > Field Zoom (GPUI only)**. Enter a finite number from -`1` through `8` (`2.5` is valid); the default is `1`. This writes the game's -`config.php` at `graphics.field.zoom`, not `system.php` or `ichiloto.json`. -It magnifies the GPUI field only: terminal maps, menus and UI sizing do not -change. `Ctrl+S` saves, `Ctrl+Z` undoes, and `Ctrl+Y` redoes. Undoing the first +The rest of the project's `config.php`: the `save`, `accessibility`, `ui`, +`graphics`, `audio` and `inn` settings, one row per setting with its dotted +path. A switch is a switch, a number a number, and a setting authored as an +enum case (a selection colour, a window position) is a choice of that enum, +written back as the case. A setting the engine reads with a default is listed +even where the file leaves it out, showing that default; choosing the default +writes nothing. + +#### Field Zoom + +Open **Database > Configuration > graphics.field.zoom**. Enter a finite number +from `1` through `8` (`2.5` is valid); the default is `1`. This writes the +game's `config.php` at `graphics.field.zoom`, not `system.php` or +`ichiloto.json`. It magnifies the GPUI field only: terminal maps, menus and UI +sizing do not change. `Ctrl+S` saves, `Ctrl+Z` undoes, and `Ctrl+Y` redoes. Undoing the first edit restores an originally absent setting without leaving a default entry. The same pending Terms edits are included when this configuration is saved. diff --git a/src/Cutscenes/Source/ArraySourceWriter.php b/src/Cutscenes/Source/ArraySourceWriter.php index 7ebb8f7..d19ebaa 100644 --- a/src/Cutscenes/Source/ArraySourceWriter.php +++ b/src/Cutscenes/Source/ArraySourceWriter.php @@ -113,6 +113,14 @@ private function diff(SourceNode $node, mixed $old, mixed $new, array $path): vo return; } + // An enum case's value, written as `Enum::CASE->value`, stays written + // that way: the new value is the matching case of the same enum. + if ($node->kind === SourceNode::EXPRESSION && ($literal = $this->findEnumValueExpression($node, $new)) !== null) { + $this->edits[] = $this->document->replaceValueEdit($path, $literal); + + return; + } + // A scalar or null now sits here. match ($node->kind) { SourceNode::SCALAR => $this->edits[] = $this->replacementFor($node, $path, $new), @@ -677,6 +685,52 @@ private static function identityOf(mixed $entry): ?string * @param array $path * @return array{0: int, 1: int, 2: string} */ + /** + * The expression for a new value where the file writes an enum case's + * value as `Enum::CASE->value`: the same enum, the case holding the new + * value. Null when the expression is anything else, or no case holds it. + */ + private function findEnumValueExpression(SourceNode $node, mixed $new): ?string + { + $text = trim(substr($this->document->source, $node->start, $node->end - $node->start)); + + if ((! is_string($new) && ! is_int($new)) + || preg_match('/^(\\\\?[A-Za-z_][A-Za-z0-9_\\\\]*)::[A-Za-z_][A-Za-z0-9_]*->value$/', $text, $match) !== 1) { + return null; + } + + $class = $this->resolveClassName($match[1]); + + if ($class === null || ! is_subclass_of($class, \BackedEnum::class)) { + return null; + } + + $case = $class::tryFrom($new); + + return $case === null ? null : $match[1] . '::' . $case->name . '->value'; + } + + /** A class name as the file writes it, resolved through its own imports. */ + private function resolveClassName(string $name): ?string + { + if (str_starts_with($name, '\\')) { + return ltrim($name, '\\'); + } + + $first = explode('\\', $name)[0]; + preg_match_all('/^\s*use\s+([A-Za-z0-9_\\\\]+)(?:\s+as\s+([A-Za-z0-9_]+))?\s*;/m', $this->document->source, $uses, PREG_SET_ORDER); + + foreach ($uses as $use) { + $alias = ($use[2] ?? '') !== '' ? $use[2] : substr((string) strrchr('\\' . $use[1], '\\'), 1); + + if ($alias === $first) { + return $use[1] . substr($name, strlen($first)); + } + } + + return null; + } + private function replacementFor(SourceNode $node, array $path, mixed $value): array { return $this->document->replaceValueEdit($path, $this->literalFor($value, $path)); diff --git a/src/Database/DatabaseCatalog.php b/src/Database/DatabaseCatalog.php index abb2e31..688f33c 100644 --- a/src/Database/DatabaseCatalog.php +++ b/src/Database/DatabaseCatalog.php @@ -41,6 +41,7 @@ public static function all(): array new DatabaseCategoryDefinition('optimize_outcomes', 'Optimize Outcomes', 'Weight elemental outcomes and special properties.', true), new DatabaseCategoryDefinition('optimize_exclusions', 'Optimize Exclusions', 'Keep gear out of automatic selection.', true), new DatabaseCategoryDefinition('system', 'System', 'Configure system-wide project settings.', true), + new DatabaseCategoryDefinition('configuration', 'Configuration', 'Adjust saving, accessibility, interface, graphics, audio and inn settings.', true), new DatabaseCategoryDefinition('types', 'Types', 'Manage element and weapon-type tables.', true), new DatabaseCategoryDefinition('terms', 'Terms', 'Customize UI labels and message terms.', true), ]; diff --git a/src/Database/PhpDataFile.php b/src/Database/PhpDataFile.php index d173218..a21ccbe 100644 --- a/src/Database/PhpDataFile.php +++ b/src/Database/PhpDataFile.php @@ -558,6 +558,31 @@ public function isEditable(): bool * @return void */ public function save(mixed $payload): void + { + $contents = $this->composeContents($payload, checkDisk: true); + $directory = dirname($this->path); + + if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { + throw new RuntimeException("Unable to create {$directory}."); + } + + AtomicFile::write($this->path, $contents); + // The file now reads as this payload, so the next save edits from here. + $this->payload = $payload; + $this->source = $contents; + } + + /** + * Returns what saving a value would write, refusing what the file's own + * source cannot take, without writing anything: an edit can be refused + * when it is made rather than when it is saved. + * + * @param mixed $payload The value to return from the file. + * @param bool $checkDisk Whether a file changed outside the editor is refused too, as a save does. + * @return string The file's contents. + * @throws RuntimeException When the file or the value cannot be written. + */ + public function composeContents(mixed $payload, bool $checkDisk = false): string { if (! $this->isEditable()) { throw new RuntimeException(sprintf('Refusing to overwrite %s: %s.', $this->path, $this->readOnlyReason)); @@ -575,7 +600,7 @@ public function save(mixed $payload): void // (or the constructor-argument edits) their files were written for. if ($document !== null && is_array($this->payload) && is_array($payload) && ! self::holdsObject($this->payload) && ! self::holdsObject($payload)) { - if (@file_get_contents($this->path) !== $this->source) { + if ($checkDisk && @file_get_contents($this->path) !== $this->source) { throw new RuntimeException(sprintf( 'Refusing to overwrite %s: it changed outside the editor since it was read. Reload it first.', $this->path, @@ -591,16 +616,7 @@ public function save(mixed $payload): void $contents = $this->header . 'return ' . PhpValueExporter::export($payload) . ";\n"; } - $directory = dirname($this->path); - - if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { - throw new RuntimeException("Unable to create {$directory}."); - } - - AtomicFile::write($this->path, $contents); - // The file now reads as this payload, so the next save edits from here. - $this->payload = $payload; - $this->source = $contents; + return $contents; } /** diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 97d8aec..3784430 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -12,6 +12,7 @@ use Ichiloto\Editor\History\TracksPersistedState; use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\ProjectConfig; +use Ichiloto\Editor\Database\Projections\WholeFileProjection; use Ichiloto\Editor\Inspector\InputControl; use Ichiloto\Editor\Inspector\InputControlType; use RuntimeException; @@ -705,6 +706,14 @@ public function setField(int $index, string $fieldId, string $rawValue): void } $value = self::coerce($field, $rawValue); + if ($this->schema->storage === RecordStorage::CONFIG_SUBTREE) { + $this->config?->assertAcceptableValue(strval($record->get('path')), $value); + // A setting the file leaves out already reads as its default: + // choosing that default writes nothing. + if ($record->get('value') === null && ! $this->config?->holds(strval($record->get('path'))) && $value == $record->get('default')) { + return; + } + } if ($field->uniqueAcrossRecords) { is_array($value) ? $this->assertMembersUnclaimed($record, $field, $value) @@ -849,10 +858,17 @@ private function assertMembersUnclaimed(ProjectRecord $record, RecordField $fiel public function supportsRecordCreation(): bool { return $this->isEditable() + && ! $this->holdsOneRecord() && $this->schema->storage !== RecordStorage::CONFIG_SUBTREE && $this->schema->storage !== RecordStorage::FILE_LISTING; } + /** Whether the file is the category's one record (system.php), only ever edited. */ + private function holdsOneRecord(): bool + { + return $this->schema->projection instanceof WholeFileProjection; + } + /** * Returns whether this category can let a record go. A term is a leaf of * the config tree it belongs to, not an entry of its own. @@ -861,7 +877,7 @@ public function supportsRecordCreation(): bool */ public function supportsRecordDeletion(): bool { - return $this->isEditable() && $this->schema->storage !== RecordStorage::CONFIG_SUBTREE; + return $this->isEditable() && ! $this->holdsOneRecord() && $this->schema->storage !== RecordStorage::CONFIG_SUBTREE; } /** @@ -1094,6 +1110,7 @@ public function moveRecord(int $from, int $to): bool public function duplicateRecordSupported(): bool { return $this->isEditable() + && ! $this->holdsOneRecord() && in_array($this->schema->storage, [RecordStorage::LIST_FILE, RecordStorage::DIRECTORY], true) && ! $this->isConstructorAuthored(); } @@ -2471,6 +2488,48 @@ private function mergeIntoFilePayload(): array * * @return void */ + /** + * What a one-file-per-record category writes for a record: its command + * list bare for an event script, its data in its envelope for a record + * class, the record itself otherwise. + */ + private function composeRecordFilePayload(ProjectRecord $record): mixed + { + $payload = $record->toArray(); + + if ($this->schema->listPayloadKey !== null && is_array($payload)) { + $payload = array_values((array) ($payload[$this->schema->listPayloadKey] ?? [])); + } + + if ($this->schema->recordClass !== null) { + $payload = ['class' => $this->schema->recordClass, 'data' => $payload]; + } + + return $payload; + } + + /** + * Refuses an edit the record's file could not be saved with - a value + * the author wrote as an expression the editor cannot rewrite - by working + * out exactly what a save would write, and writing nothing. + * + * @throws \Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal|SourceIdentityConflict|RuntimeException When a save would be refused. + */ + public function assertSourceAccepts(int $index): void + { + $record = $this->getRecordByIndex($index); + + if (! $record instanceof ProjectRecord || ! $record->isDirty()) { + return; + } + + match ($this->schema->storage) { + RecordStorage::LIST_FILE => SharedFileTransaction::preview([$this]), + RecordStorage::DIRECTORY => $record->file?->composeContents($this->composeRecordFilePayload($record)), + default => null, + }; + } + private function saveDirectory(): void { if (! is_dir($this->path) && ! mkdir($this->path, 0777, true) && ! is_dir($this->path)) { @@ -2486,17 +2545,7 @@ private function saveDirectory(): void continue; } - $payload = $record->toArray(); - - if ($this->schema->listPayloadKey !== null && is_array($payload)) { - $payload = array_values((array) ($payload[$this->schema->listPayloadKey] ?? [])); - } - - if ($this->schema->recordClass !== null) { - $payload = ['class' => $this->schema->recordClass, 'data' => $payload]; - } - - $file->save($payload); + $file->save($this->composeRecordFilePayload($record)); } foreach ($this->stagedDeletions as $path) { @@ -3145,11 +3194,14 @@ private static function coerce(RecordField $field, string $rawValue): mixed return $items === [] && $field->removeWhenEmpty ? null : $items; } - if ($field->enumClass !== null && is_subclass_of($field->enumClass, BackedEnum::class)) { + if ($field->enumClass !== null && enum_exists($field->enumClass)) { // The stored value is the enum case, not its string: an object // rebuild hands it straight back to a typed constructor argument. + // A case is chosen by its value or by its name, whichever the + // field offers. foreach ($field->enumClass::cases() as $case) { - if (mb_strtolower(strval($case->value)) === mb_strtolower($trimmed)) { + if (($case instanceof BackedEnum && mb_strtolower(strval($case->value)) === mb_strtolower($trimmed)) + || mb_strtolower($case->name) === mb_strtolower($trimmed)) { return $case; } } @@ -3189,6 +3241,13 @@ private static function coerce(RecordField $field, string $rawValue): mixed */ private static function displayValue(RecordField $field, mixed $value): string { + // An enum case reads as the field offers it: by value, or by name + // where the field offers names (a value that is not readable text). + if ($value instanceof \UnitEnum && $field->enumClass !== null + && (! $value instanceof BackedEnum || ! in_array(strval($value->value), array_map(strval(...), $field->options), true))) { + return $value->name; + } + // An absent key reads as what the runtime will do with it, and an // empty string as what the runtime means by it, when the schema says // so -- neither is left as a blank for the author to decode. diff --git a/src/Database/Projections/WholeFileProjection.php b/src/Database/Projections/WholeFileProjection.php new file mode 100644 index 0000000..d11f799 --- /dev/null +++ b/src/Database/Projections/WholeFileProjection.php @@ -0,0 +1,53 @@ +assertSourceAccepts($index); + } catch (SourcePreservationRefusal|SourceIdentityConflict|RuntimeException $refused) { + // The file could not be saved with it: refused now, not at save. + $record->restorePayload($before); + + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + return new RecordChange(new GenericCommand( sprintf('%s edit', $label), static fn() => $record->restorePayload($after), diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index b169061..eef487b 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -30,6 +30,9 @@ use Ichiloto\Engine\Entities\Enemies\Enemy; use Ichiloto\Engine\Entities\Enemies\EnemyCatalog; use Ichiloto\Engine\Quests\QuestObjectiveType; +use Ichiloto\Engine\Battle\Enumerations\BattleEngineType; +use Ichiloto\Engine\Core\Enumerations\MovementHeading; +use Ichiloto\Editor\Database\Projections\WholeFileProjection; use Ichiloto\Engine\Entities\Enumerations\ActionConditionType; use Ichiloto\Engine\Entities\Enumerations\ItemUserType; use Ichiloto\Engine\Progress\Knowledge\KnowledgeProgressService; @@ -83,6 +86,7 @@ public static function all(): array $schemas = [ self::classes(), self::quests(), + self::system(), self::states(), self::troops(), self::animations(), @@ -102,6 +106,7 @@ public static function all(): array self::optimizeExclusions(), self::commonEvents(), self::terms(), + self::configuration(), self::types(), self::tilesets(), ]; @@ -831,6 +836,75 @@ private static function optimizeExclusions(): RecordSchema ); } + /** + * System — `assets/Data/system.php`, one map the engine reads at start: + * the title, starting gold, party, inventory and position, the project's + * elements, and battle settings. The file is the category's one record, + * only ever edited. The starting party and inventory are lists of their + * own; a party member is a bare actor id, as the file authors it. + * + * @return RecordSchema + */ + private static function system(): RecordSchema + { + return new RecordSchema( + key: 'system', + entryNoun: 'system settings', + storage: RecordStorage::LIST_FILE, + relativePath: 'assets/Data/system.php', + fields: [ + new RecordField('title', 'Title'), + new RecordField('currency.amount', 'Starting Gold', InputControlType::INTEGER), + RecordField::reference('startingPositions.player.destinationMap', 'Start Map', 'maps'), + new RecordField('startingPositions.player.spawnPoint.x', 'Start X', InputControlType::INTEGER), + new RecordField('startingPositions.player.spawnPoint.y', 'Start Y', InputControlType::INTEGER), + new RecordField( + 'startingPositions.player.spawnSprite.0', + 'Start Facing', + options: array_map(static fn(MovementHeading $heading): string => $heading->value, MovementHeading::cases()), + ), + new RecordField('elements', 'Elements', codec: RecordFieldCodec::CSV_LIST), + new RecordField( + 'battle.engine', + 'Battle Engine', + options: array_map(static fn(BattleEngineType $engine): string => $engine->value, BattleEngineType::cases()), + ), + new RecordField('battle.opening.preemptiveChancePercent', 'Preemptive Chance %', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('battle.opening.ambushChancePercent', 'Ambush Chance %', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('battle.activeTime.mode', 'ATB Mode', options: ['wait']), + new RecordField('battle.activeTime.baseFillRate', 'ATB Base Fill Rate', InputControlType::INTEGER), + new RecordField('battle.activeTime.speedFactorPercent', 'ATB Speed Factor %', InputControlType::INTEGER), + new RecordField('battle.activeTime.openingVariance', 'ATB Opening Variance', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('battle.activeTime.openingSpeedFactorPercent', 'ATB Opening Speed %', InputControlType::INTEGER, removeWhenEmpty: true), + ], + labelKey: 'title', + identityKey: null, + projection: new WholeFileProjection(), + subLists: [ + new RecordSubList( + key: 'startingParty', + prefix: 'member', + singular: 'party member', + fields: [RecordField::reference('actor', 'Actor', 'actor_ids')], + blank: ['actor' => ''], + heading: 'Starting Party', + scalarKey: 'actor', + ), + new RecordSubList( + key: 'startingInventory', + prefix: 'stock', + singular: 'starting item', + fields: [ + RecordField::reference('item', 'Item', 'inventory'), + new RecordField('quantity', 'Quantity', InputControlType::INTEGER), + ], + blank: ['item' => '', 'quantity' => 1], + heading: 'Starting Inventory', + ), + ], + ); + } + /** * Quests — `assets/Data/quests.php`, as the engine's `Quest::fromArray` * reads it. A quest's id is its name's slug and follows a rename while @@ -1172,11 +1246,64 @@ private static function commonEvents(): RecordSchema ); } + /** + * Configuration — the settings in the project's `config.php` that are not + * UI vocabulary: saving, accessibility, interface, graphics, audio and + * the inn. One row per setting, typed by what it holds (a switch, a + * number, a choice of the enum it is authored as). A setting the engine + * reads with a default is offered even where the file leaves it out. + * ProjectConfig owns the file and the rules a value must meet, such as + * the field zoom's range; Terms shares the same owner. + * + * @return RecordSchema + */ + private static function configuration(): RecordSchema + { + return new RecordSchema( + key: 'configuration', + entryNoun: 'setting', + storage: RecordStorage::CONFIG_SUBTREE, + relativePath: 'config.php', + fields: [ + new RecordField('path', 'Setting', isReadOnly: true), + new RecordField('value', 'Value'), + ], + labelKey: 'path', + identityKey: 'path', + configPath: ['save', 'accessibility', 'ui', 'graphics', 'audio', 'inn'], + fieldsFor: static function (array $row): array { + // A setting the engine knows is the type of its default, so an + // authored value of another type is repaired on edit. + $value = array_key_exists('default', $row) ? $row['default'] : ($row['value'] ?? null); + $default = array_key_exists('default', $row) ? ProjectRecord::stringify($row['default']) : null; + + return [ + new RecordField('path', 'Setting', isReadOnly: true), + match (true) { + // An enum setting is a choice of its cases by name: some + // enums' values (a colour's terminal code) are not text + // an author can read. + $value instanceof \UnitEnum => new RecordField( + 'value', + 'Value', + options: array_map(static fn(\UnitEnum $case): string => $case->name, $value::cases()), + enumClass: $value::class, + ), + is_bool($value) => RecordField::boolean('value', 'Value', removeWhenEmpty: false, displayDefault: $default), + is_int($value) => new RecordField('value', 'Value', InputControlType::INTEGER, displayDefault: $default), + is_float($value) => new RecordField('value', 'Value', InputControlType::FLOAT, displayDefault: $default), + default => new RecordField('value', 'Value', displayDefault: $default), + }, + ]; + }, + ); + } + /** * UI vocabulary — the `vocab` and `messages` trees of the project's * `config.php`, flattened to one editable row per term. * - * ProjectConfig shares these records with System's field zoom and + * ProjectConfig shares these records with Configuration's settings and * patches literal leaves only. Unrelated comments and expressions stay * untouched; opaque term values are individually read-only. * diff --git a/src/Database/SharedFileTransaction.php b/src/Database/SharedFileTransaction.php index 7a00c2b..1734468 100644 --- a/src/Database/SharedFileTransaction.php +++ b/src/Database/SharedFileTransaction.php @@ -71,6 +71,29 @@ public static function groupByPath(array $databases): array * @throws RuntimeException When the write is refused or fails. */ public static function commit(array $databases): bool + { + return self::compose($databases, write: true); + } + + /** + * Works out exactly what {@see commit()} would write, refusing what it + * would refuse, and writes nothing: an edit the file's own source cannot + * take is refused when it is made, not when it is saved. + * + * @param array $databases The categories sharing one file. + * @throws SourceIdentityConflict When an entry cannot be addressed with certainty. + * @throws RuntimeException When the write would be refused. + */ + public static function preview(array $databases): void + { + self::compose($databases, write: false); + } + + /** + * @param array $databases + * @param bool $write Whether to write the result and adopt it, or only work it out. + */ + private static function compose(array $databases, bool $write): bool { $dirty = array_values(array_filter( $databases, @@ -90,8 +113,10 @@ public static function commit(array $databases): bool // One snapshot of the source, and every category's wants resolved // against it before a byte is written. - if (self::commitToSource($dirty, $file)) { - self::adopt($dirty); + if (self::commitToSource($dirty, $file, $write)) { + if ($write) { + self::adopt($dirty); + } return true; } @@ -113,6 +138,12 @@ public static function commit(array $databases): bool $payload = $database->foldInto($payload); } + if (! $write) { + $file->composeContents($payload); + + return false; + } + $file->save($payload); self::adopt($dirty); @@ -127,7 +158,7 @@ public static function commit(array $databases): bool * @param PhpDataFile $file The file, read once. * @return bool True when the file was written this way. */ - private static function commitToSource(array $dirty, PhpDataFile $file): bool + private static function commitToSource(array $dirty, PhpDataFile $file, bool $write = true): bool { if (! is_file($file->path)) { return false; @@ -198,7 +229,9 @@ private static function commitToSource(array $dirty, PhpDataFile $file): bool $document = $document->withNewEntry($insertion['class'], $insertion['arguments']); } - AtomicFile::write($file->path, $document->source); + if ($write) { + AtomicFile::write($file->path, $document->source); + } return true; } diff --git a/src/Editor.php b/src/Editor.php index a429a87..0b19fd5 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -9293,14 +9293,6 @@ private function saveActiveDatabase(): void $this->backupBeforeSave(...$this->workspace->skillDatabase->getBackupPaths()); $this->workspace->skillDatabase->save(); $this->setStatus('Skill database saved.', StatusLevel::SUCCESS); - } elseif ($this->isSystemDatabaseSelected()) { - if ($this->workspace->config?->isDirty()) { - $this->backupBeforeSave($this->workspace->config->path); - $this->workspace->config->save(); - } - $this->backupBeforeSave(...$this->workspace->systemDatabase->getBackupPaths()); - $this->workspace->systemDatabase->save(); - $this->setStatus('System database saved.', StatusLevel::SUCCESS); } elseif (($recordDatabase = $this->getSelectedRecordDatabase()) instanceof ProjectRecordDatabase) { if (! $recordDatabase->isEditable()) { $this->setStatus($this->describeRecordReadOnly($recordDatabase), StatusLevel::WARN); @@ -9348,10 +9340,6 @@ private function getDatabaseSettingsFields(): array return $this->getDatabaseSkillSettingsFields(); } - if ($this->isSystemDatabaseSelected()) { - return $this->getDatabaseSystemSettingsFields(); - } - $recordDatabase = $this->getSelectedRecordDatabase(); if ($recordDatabase instanceof ProjectRecordDatabase) { @@ -11239,57 +11227,6 @@ private function commitDatabaseEdit(): void $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); } - /** - * Returns the editable settings fields for the project system. - * - * @return array> - */ - private function getDatabaseSystemSettingsFields(): array - { - if (! $this->workspace instanceof ProjectWorkspace) { - return []; - } - - $system = $this->workspace->systemDatabase; - $config = $this->workspace->config; - $zoomIssue = $config?->getFieldIssue(ProjectConfig::FIELD_ZOOM); - $zoom = $config === null ? [] : [[ - 'label' => 'Field Zoom (GPUI only)', - 'value' => ProjectRecord::stringify($config->getFieldZoom()), - 'field' => ProjectConfig::FIELD_ZOOM, - ...($zoomIssue === null ? ['control' => new InputControl(InputControlType::FLOAT, ProjectRecord::stringify($config->getFieldZoom()))] : []), - ], ['label' => $zoomIssue === null ? 'Field Zoom Range' : 'Read-only', - 'value' => $zoomIssue ?? '1 to 8; default 1. Terminal and UI size stay unchanged.', 'editable' => false]]; - - return [ - [ - 'label' => 'Battle Engine', - 'value' => $system->getBattleEngine(), - 'options' => ['traditional', 'active_time'], - 'field' => 'battleEngine', - ], - [ - 'label' => 'ATB Mode', - 'value' => $system->getAtbMode(), - 'options' => ['wait'], - 'field' => 'atbMode', - ], - [ - 'label' => 'ATB Base Fill Rate', - 'value' => (string) $system->getAtbBaseFillRate(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $system->getAtbBaseFillRate()), - 'field' => 'atbBaseFillRate', - ], - [ - 'label' => 'ATB Speed Factor %', - 'value' => (string) $system->getAtbSpeedFactorPercent(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $system->getAtbSpeedFactorPercent()), - 'field' => 'atbSpeedFactorPercent', - ], - ...$zoom, - ]; - } - /** * Applies one database settings value and records it for undo/redo. * @@ -11355,15 +11292,11 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) 'records' => $this->databaseSelectedRecordIndexes, ]; - $record = $this->isSystemDatabaseSelected() && $fieldId === ProjectConfig::FIELD_ZOOM - ? $this->workspace?->config?->getRecord(ProjectConfig::FIELD_ZOOM) - : null; - $before = $record?->toArray(); $actor = $this->isActorsDatabaseSelected() ? $this->getSelectedActor() : null; $actorBefore = $actor?->getData(); // A schema record's edit is one step only when it changed the record: // a value the record refused, or the value it already held, leaves none. - $schemaRecord = $record === null && $actor === null + $schemaRecord = $actor === null ? $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()) : null; $schemaBefore = $schemaRecord?->toArray(); @@ -11385,18 +11318,6 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) return; } - if ($record !== null && $before !== null) { - $after = $record->toArray(); - if ($before !== $after) { - $this->recordCommand(new GenericCommand( - sprintf('%s edit', $field['label'] ?? 'Database field'), - static fn() => $record->restorePayload($after), - static fn() => $record->restorePayload($before), - )); - } - return; - } - if ($oldRawValue === $rawValue) { return; } @@ -11554,18 +11475,6 @@ private function applyDatabaseFieldValue(string $field, string $rawValue): void return; } - if ($this->isSystemDatabaseSelected()) { - if ($field === ProjectConfig::FIELD_ZOOM) { - $this->workspace->config?->setFieldZoom($rawValue); - return; - } - $value = in_array($field, ['battleEngine', 'atbMode'], true) - ? trim($rawValue) - : max(0, intval($rawValue)); - $this->workspace->systemDatabase->setField($field, $value); - return; - } - $recordDatabase = $this->getSelectedRecordDatabase(); if ($recordDatabase instanceof ProjectRecordDatabase) { @@ -13777,10 +13686,6 @@ private function getDatabaseListLines(): array return $this->getDatabaseSkillListLines(); } - if ($this->isSystemDatabaseSelected()) { - return $this->getDatabaseSystemListLines(); - } - if ($this->getSelectedRecordDatabase() instanceof ProjectRecordDatabase) { return $this->getDatabaseRecordListLines(); } @@ -13919,18 +13824,6 @@ private function getDatabaseSkillListLines(): array } - /** - * Returns the system list lines. - * - * @return string[] - */ - private function getDatabaseSystemListLines(): array - { - $dirty = $this->workspace?->systemDatabase->isDirty() === true || $this->workspace?->config?->isDirty() === true ? ' *' : ''; - - return [sprintf('> Project System%s', $dirty)]; - } - /** * Returns the current Database settings lines. * @@ -14118,6 +14011,26 @@ private function getDatabaseCueLines(): array return $this->getSelectedDatabaseCategoryDefinition()->isImplemented ? ['-'] : ['No timing data yet.']; } + /** + * The project's battle settings as the system record holds them, with + * the defaults the engine reads where it holds none. + * + * @return array{engine: string, mode: string, baseFillRate: int, speedFactorPercent: int} + */ + private function getSystemBattleSettings(): array + { + $battle = $this->workspace?->getSystemField('battle'); + $battle = is_array($battle) ? $battle : []; + $activeTime = is_array($battle['activeTime'] ?? null) ? $battle['activeTime'] : []; + + return [ + 'engine' => strval($battle['engine'] ?? 'traditional'), + 'mode' => strval($activeTime['mode'] ?? 'wait'), + 'baseFillRate' => max(1, intval($activeTime['baseFillRate'] ?? 35)), + 'speedFactorPercent' => max(0, intval($activeTime['speedFactorPercent'] ?? 35)), + ]; + } + /** * Returns the system battle summary lines. * @@ -14129,13 +14042,13 @@ private function getDatabaseSystemCueLines(): array return ['No system settings loaded.']; } - $system = $this->workspace->systemDatabase; + $system = $this->getSystemBattleSettings(); return [ - sprintf('Engine: %s', $system->getBattleEngine()), - sprintf('ATB Mode: %s', $system->getAtbMode()), - sprintf('Base Fill Rate: %d', $system->getAtbBaseFillRate()), - sprintf('Speed Factor: %d%%', $system->getAtbSpeedFactorPercent()), + sprintf('Engine: %s', $system['engine']), + sprintf('ATB Mode: %s', $system['mode']), + sprintf('Base Fill Rate: %d', $system['baseFillRate']), + sprintf('Speed Factor: %d%%', $system['speedFactorPercent']), ]; } @@ -14417,9 +14330,9 @@ private function getDatabaseSystemFrameLines(): array return ['No system settings loaded.']; } - $system = $this->workspace->systemDatabase; + $system = $this->getSystemBattleSettings(); - if ($system->getBattleEngine() !== 'active_time') { + if ($system['engine'] !== 'active_time') { return [ 'Traditional turn-based battles.', 'ATB settings are stored but inactive.', @@ -14581,17 +14494,17 @@ private function getDatabaseSystemPreviewLines(): array return ['No system settings loaded.']; } - $system = $this->workspace->systemDatabase; - $engine = $system->getBattleEngine(); + $system = $this->getSystemBattleSettings(); + $engine = $system['engine']; if ($engine === 'active_time') { return [ 'Battle Engine', 'Active Time Battle', '', - sprintf('Mode: %s', $system->getAtbMode()), - sprintf('Base Fill Rate: %d', $system->getAtbBaseFillRate()), - sprintf('Speed Factor: %d%%', $system->getAtbSpeedFactorPercent()), + sprintf('Mode: %s', $system['mode']), + sprintf('Base Fill Rate: %d', $system['baseFillRate']), + sprintf('Speed Factor: %d%%', $system['speedFactorPercent']), '', 'This engine fills battler gauges', 'continuously and resolves actions', @@ -14677,7 +14590,6 @@ private function isDatabaseCategoryDirty(string $categoryKey): bool return match ($categoryKey) { self::DATABASE_CATEGORY_ACTORS => $this->workspace->actorDatabase->isDirty(), self::DATABASE_CATEGORY_SKILLS => $this->workspace->skillDatabase->isDirty(), - self::DATABASE_CATEGORY_SYSTEM => $this->workspace->systemDatabase->isDirty() || ($this->workspace->config?->isDirty() ?? false), default => $this->workspace->getRecordDatabase($categoryKey)?->isDirty() ?? false, }; } diff --git a/src/Maps/MapReferences.php b/src/Maps/MapReferences.php index be3f101..4dad57d 100644 --- a/src/Maps/MapReferences.php +++ b/src/Maps/MapReferences.php @@ -28,7 +28,7 @@ public function __construct(private ProjectWorkspace $workspace) public function describe(string $mapId): array { $found = []; - $positions = $this->workspace->systemDatabase->getField('startingPositions'); + $positions = $this->workspace->getSystemField('startingPositions'); if (is_array($positions)) { foreach ($positions as $role => $position) { if (is_array($position) && ($position['destinationMap'] ?? null) === $mapId) { diff --git a/src/ProjectConfig.php b/src/ProjectConfig.php index 7862823..f2bf7b5 100644 --- a/src/ProjectConfig.php +++ b/src/ProjectConfig.php @@ -17,10 +17,16 @@ use RuntimeException; use Throwable; -/** One source owner for config.php, shared by Terms and System's field zoom. */ +/** One source owner for config.php, shared by Terms and Configuration. */ final class ProjectConfig { public const string FIELD_ZOOM = 'graphics.field.zoom'; + + /** + * Settings the engine reads with a default when the file leaves them + * out, by path, with that default. + */ + private const array ENGINE_DEFAULTS = [self::FIELD_ZOOM => FieldViewport::DEFAULT_ZOOM]; private ?PhpArraySourceDocument $document = null; private ?string $issue = null; private array $payload = []; @@ -56,7 +62,11 @@ public function getRecord(string $path): ProjectRecord if (! isset($this->records[$path])) { $value = self::getValue($this->payload, $path); $this->originalValues[$path] = $value; - $this->records[$path] = new ProjectRecord(['path' => $path, 'value' => $value]); + // A setting the engine knows carries the default it reads when + // the file holds none, which is what the setting reads as here. + $this->records[$path] = new ProjectRecord(array_key_exists($path, self::ENGINE_DEFAULTS) + ? ['path' => $path, 'value' => $value, 'default' => self::ENGINE_DEFAULTS[$path]] + : ['path' => $path, 'value' => $value]); } return $this->records[$path]; } @@ -70,7 +80,7 @@ public function getTermRecords(array $roots): array $path = $prefix . '.' . $key; $hasDottedKey = $ambiguous || str_contains((string) $key, '.'); if (is_array($value)) { $visit($value, $path, $hasDottedKey); } - elseif (! is_object($value)) { + elseif (! is_object($value) || $value instanceof \UnitEnum) { if ($hasDottedKey) { $this->fieldIssues[$path] = $path . ' contains a dotted literal key, ambiguous with a nested path; it is preserved read-only.'; $records[] = new ProjectRecord(['path' => $path, 'value' => $value]); @@ -81,6 +91,14 @@ public function getTermRecords(array $roots): array foreach ($roots as $root) { if (is_array($this->payload[$root] ?? null)) { $visit($this->payload[$root], $root); } } + // A setting the engine knows is offered even where the file leaves it + // out, so it can be set without being typed in by hand. + foreach (array_keys(self::ENGINE_DEFAULTS) as $path) { + $root = explode('.', $path)[0]; + if (in_array($root, $roots, true) && self::getValue($this->payload, $path) === null && $this->issue === null) { + $records[] = $this->getRecord($path); + } + } return $records; } @@ -89,6 +107,20 @@ public function getReadOnlyReason(): ?string return $this->issue; } + /** + * Refuses a value the engine would not accept at a path, before anything + * changes: a field zoom outside its range. + * + * @throws \InvalidArgumentException + */ + public function assertAcceptableValue(string $path, mixed $value): void + { + if ($path === self::FIELD_ZOOM && (! is_int($value) && ! is_float($value) + || ! is_finite((float) $value) || $value < FieldViewport::MIN_ZOOM || $value > FieldViewport::MAX_ZOOM)) { + throw new \InvalidArgumentException(sprintf('Field zoom must be a finite number from %g to %g.', FieldViewport::MIN_ZOOM, FieldViewport::MAX_ZOOM)); + } + } + public function getFieldIssue(string $path): ?string { if (isset($this->fieldIssues[$path])) { return $this->fieldIssues[$path]; } @@ -100,23 +132,10 @@ public function getFieldIssue(string $path): ?string } } - public function getFieldZoom(): mixed + /** Whether the file authors a setting at all, as opposed to leaving it to its default. */ + public function holds(string $path): bool { - $value = $this->getRecord(self::FIELD_ZOOM)->get('value'); - return $value === null && $this->document?->nodeAt(explode('.', self::FIELD_ZOOM)) === null - ? FieldViewport::DEFAULT_ZOOM : $value; - } - - public function setFieldZoom(string $raw): void - { - $value = filter_var($raw, FILTER_VALIDATE_FLOAT); - if ($value === false || ! is_finite($value) || $value < FieldViewport::MIN_ZOOM || $value > FieldViewport::MAX_ZOOM) { - throw new RuntimeException(sprintf('Field zoom must be a finite number from %g to %g.', FieldViewport::MIN_ZOOM, FieldViewport::MAX_ZOOM)); - } - if (($issue = $this->getFieldIssue(self::FIELD_ZOOM)) !== null) { throw new SourcePreservationRefusal($issue); } - $current = $this->getFieldZoom(); - if ((is_int($current) || is_float($current)) && $value == $current) { return; } - $this->getRecord(self::FIELD_ZOOM)->set('value', $value); + return $this->document?->nodeAt(explode('.', $path)) !== null; } public function isDirty(): bool @@ -187,7 +206,9 @@ private function createFieldEdit(?PhpArraySourceDocument $document, string $path $walked[] = $key; $node = $entry->value; } - if ($node->kind !== SourceNode::SCALAR) { + // An enum case is written as one: replacing it with another case of + // its enum keeps the file reading the same kind of value. + if ($node->kind !== SourceNode::SCALAR && ! self::getValue($this->payload, $path) instanceof \UnitEnum) { throw new SourcePreservationRefusal($path . ' is an authored expression, not an editable literal.'); } return $document->replaceValueEdit($walked, $literal); diff --git a/src/ProjectSystemDatabase.php b/src/ProjectSystemDatabase.php deleted file mode 100644 index 52039fc..0000000 --- a/src/ProjectSystemDatabase.php +++ /dev/null @@ -1,288 +0,0 @@ - $data - */ - public function __construct( - public readonly string $path, - private array $data = [], - bool $isDirty = false, - ) { - if (! $isDirty) { - $this->captureBaseline(); - } - } - - /** - * Loads the system database from the project. - * - * @param string $projectRoot The project root. - * @return self - */ - public static function fromProject(string $projectRoot): self - { - $path = rtrim($projectRoot, DIRECTORY_SEPARATOR) - . DIRECTORY_SEPARATOR - . 'assets' - . DIRECTORY_SEPARATOR - . 'Data' - . DIRECTORY_SEPARATOR - . 'system.php'; - - if (! is_file($path)) { - return new self($path, self::normalize([])); - } - - $payload = require $path; - - if (! is_array($payload)) { - throw new RuntimeException("Unable to parse {$path}."); - } - - return new self($path, self::normalize($payload)); - } - - /** - * Returns one top-level field as loaded and normalized. - * - * @param string $field The field name. - * @return mixed The value, or null when the project does not set it. - */ - public function getField(string $field): mixed - { - return $this->data[$field] ?? null; - } - - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - return "data)) . ";\n"; - } - - /** - * Returns the configured battle engine id. - * - * @return string - */ - public function getBattleEngine(): string - { - return strval($this->data['battle']['engine'] ?? 'traditional'); - } - - /** - * Returns the configured ATB mode. - * - * @return string - */ - public function getAtbMode(): string - { - return strval($this->data['battle']['activeTime']['mode'] ?? 'wait'); - } - - /** - * Returns the configured ATB base fill rate. - * - * @return int - */ - public function getAtbBaseFillRate(): int - { - return max(1, intval($this->data['battle']['activeTime']['baseFillRate'] ?? 35)); - } - - /** - * Returns the configured ATB speed factor percent. - * - * @return int - */ - public function getAtbSpeedFactorPercent(): int - { - return max(0, intval($this->data['battle']['activeTime']['speedFactorPercent'] ?? 35)); - } - - /** - * Applies one editable system field. - * - * @param string $field The field identifier. - * @param mixed $value The replacement value. - * @return void - */ - public function setField(string $field, mixed $value): void - { - $this->data = self::normalize($this->data); - - switch ($field) { - case 'battleEngine': - $this->data['battle']['engine'] = strval($value) === 'active_time' - ? 'active_time' - : 'traditional'; - break; - - case 'atbMode': - $this->data['battle']['activeTime']['mode'] = 'wait'; - break; - - case 'atbBaseFillRate': - $this->data['battle']['activeTime']['baseFillRate'] = max(1, intval($value)); - break; - - case 'atbSpeedFactorPercent': - $this->data['battle']['activeTime']['speedFactorPercent'] = max(0, intval($value)); - break; - - default: - return; - } - - $this->touchState(); - } - - /** - * Writes the system asset back to disk. - * - * @return void - */ - public function save(): void - { - $directory = dirname($this->path); - - if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { - throw new RuntimeException("Unable to create {$directory}."); - } - - if (! $this->isDirty() && is_file($this->path)) { - return; - } - - AtomicFile::write($this->path, $this->buildPersistedPayload()); - $this->captureBaseline(); - } - - /** - * Normalizes the loaded system data. - * - * @param array $data The raw system data. - * @return array - */ - private static function normalize(array $data): array - { - $data['title'] ??= basename(dirname(dirname(dirname($data['path'] ?? 'project')))); - $data['currency'] = is_array($data['currency'] ?? null) - ? $data['currency'] - : ['amount' => 0]; - $data['startingParty'] = is_array($data['startingParty'] ?? null) - ? $data['startingParty'] - : []; - $data['startingInventory'] = is_array($data['startingInventory'] ?? null) - ? $data['startingInventory'] - : []; - $data['startingPositions'] = is_array($data['startingPositions'] ?? null) - ? $data['startingPositions'] - : [ - 'player' => [ - 'destinationMap' => 'overworld', - 'spawnPoint' => ['x' => 0, 'y' => 0], - 'spawnSprite' => ['^'], - ], - ]; - $battle = is_array($data['battle'] ?? null) ? $data['battle'] : []; - $activeTime = is_array($battle['activeTime'] ?? null) ? $battle['activeTime'] : []; - $battle['engine'] = strval($battle['engine'] ?? 'traditional') === 'active_time' - ? 'active_time' - : 'traditional'; - $activeTime['mode'] = 'wait'; - $activeTime['baseFillRate'] = max(1, intval($activeTime['baseFillRate'] ?? 35)); - $activeTime['speedFactorPercent'] = max(0, intval($activeTime['speedFactorPercent'] ?? 35)); - $battle['activeTime'] = $activeTime; - $data['battle'] = $battle; - - return $data; - } - - /** - * Writes a file using a temp-file swap. - * - * @param string $path The destination file. - * @param string $contents The file contents. - * @return void - */ - private static function writeFileTransactionally(string $path, string $contents): void - { - $temporaryPath = $path . '.tmp'; - - if (file_put_contents($temporaryPath, $contents) === false) { - throw new RuntimeException("Unable to write temporary file for {$path}."); - } - - if (! rename($temporaryPath, $path)) { - @unlink($temporaryPath); - throw new RuntimeException("Unable to replace {$path}."); - } - } - - /** - * Exports a PHP value using short-array syntax. - * - * @param mixed $value The value to export. - * @param int $indentLevel The indentation depth. - * @return string - */ - private static function exportPhpValue(mixed $value, int $indentLevel = 0): string - { - if (! is_array($value)) { - return var_export($value, true); - } - - if ($value === []) { - return '[]'; - } - - $indent = str_repeat(' ', $indentLevel); - $nextIndent = str_repeat(' ', $indentLevel + 1); - $isList = array_is_list($value); - $lines = ['[']; - - foreach ($value as $key => $item) { - $exportedItem = self::exportPhpValue($item, $indentLevel + 1); - - if ($isList) { - $lines[] = "{$nextIndent}{$exportedItem},"; - continue; - } - - $exportedKey = var_export($key, true); - $lines[] = "{$nextIndent}{$exportedKey} => {$exportedItem},"; - } - - $lines[] = "{$indent}]"; - - return implode("\n", $lines); - } - - /** - * Returns the files a save of this database would overwrite. - * - * @return string[] - */ - public function getBackupPaths(): array - { - return [$this->path]; - } -} diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index 58bc998..3d5fee1 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -46,7 +46,6 @@ public function __construct( public array $mapIds, public ProjectActorDatabase $actorDatabase, public ProjectSkillDatabase $skillDatabase, - public ProjectSystemDatabase $systemDatabase, public array $recordDatabases = [], public ?CutsceneLibrary $cutscenes = null, public ?ProjectConfig $config = null, @@ -59,6 +58,15 @@ public function __construct( ); } + /** + * One setting of `system.php` as the System record holds it, unsaved + * edits included: the starting party, the starting positions. + */ + public function getSystemField(string $key): mixed + { + return $this->getRecordDatabase('system')?->getRecordByIndex(0)?->get($key); + } + /** * The project's quests as validation and the journal panes read them. * @@ -189,7 +197,6 @@ private static function loadFromProject(string $projectRoot): self mapIds: array_map(static fn(ProjectMap $map): string => $map->mapId, $maps), actorDatabase: ProjectActorDatabase::fromProject($projectRoot), skillDatabase: ProjectSkillDatabase::fromProject($projectRoot), - systemDatabase: ProjectSystemDatabase::fromProject($projectRoot), recordDatabases: array_map( static fn(RecordSchema $schema): ProjectRecordDatabase => ProjectRecordDatabase::fromProject($projectRoot, $schema, $projectConfig), RecordSchemaCatalog::all(), @@ -298,7 +305,6 @@ public function hasUnsavedChanges(): bool return $this->actorDatabase->isDirty() || $this->skillDatabase->isDirty() - || $this->systemDatabase->isDirty() || ($this->config?->isDirty() ?? false) || ($this->cutscenes?->hasUnsavedChanges() ?? false); } @@ -337,14 +343,13 @@ public function listUnsavedChanges(): array * Read-only record categories are left out: they hold no edits, and * asking them to save would raise instead of doing nothing. * - * @return array + * @return array */ public function listSaveableDatabases(): array { $databases = [ 'Actors' => $this->actorDatabase, 'Skills' => $this->skillDatabase, - 'System' => $this->systemDatabase, ]; if ($this->config !== null) { @@ -423,7 +428,6 @@ private function withMaps(array $maps): self mapIds: array_map(static fn(ProjectMap $workspaceMap): string => $workspaceMap->mapId, $maps), actorDatabase: $this->actorDatabase, skillDatabase: $this->skillDatabase, - systemDatabase: $this->systemDatabase, recordDatabases: $this->recordDatabases, cutscenes: $this->cutscenes, config: $this->config, diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 48d872f..c7eb536 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1153,7 +1153,7 @@ private function composeFormation(BattlePresentationCatalog $catalog, ?string $a foreach ($this->workspace->actorDatabase->getActors() as $actor) { $names[$actor->getDefinitionId()] = $actor->getName(); } - $starting = $this->workspace->systemDatabase->getField('startingParty'); + $starting = $this->workspace->getSystemField('startingParty'); $partyIds = array_values(array_slice(array_filter(is_array($starting) ? $starting : [], is_string(...)), 0, count($catalog->ui->partySlots))); try { diff --git a/src/Validation/ActorReferenceValidator.php b/src/Validation/ActorReferenceValidator.php index 2299858..1912720 100644 --- a/src/Validation/ActorReferenceValidator.php +++ b/src/Validation/ActorReferenceValidator.php @@ -20,7 +20,7 @@ public function validate(ProjectWorkspace $workspace): array foreach ($workspace->actorDatabase->getActors() as $actor) { if ($actor->hasDefinitionId()) { $ids[] = $actor->getDefinitionId(); } } - $payloads = ['assets/Data/system.php' => ['startingParty' => $workspace->systemDatabase->getField('startingParty') ?? []]]; + $payloads = ['assets/Data/system.php' => ['startingParty' => $workspace->getSystemField('startingParty') ?? []]]; foreach (['skits', 'troops', 'common_events', 'battle_entry_rules'] as $category) { $database = $workspace->getRecordDatabase($category); if ($database === null) { continue; } diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 5ab37ce..1fcf45b 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -2624,7 +2624,7 @@ protected function checkNpcCollisions(ProjectMap $map, ProjectWorkspace $workspa protected function spawnTilesOn(ProjectMap $map, ProjectWorkspace $workspace): array { $spawns = []; - $positions = $workspace->systemDatabase->getField('startingPositions'); + $positions = $workspace->getSystemField('startingPositions'); $start = is_array($positions) ? ($positions['player'] ?? null) : null; if ( diff --git a/tests/Unit/ProjectConfigTest.php b/tests/Unit/ProjectConfigTest.php index 1f375ae..6a593a9 100644 --- a/tests/Unit/ProjectConfigTest.php +++ b/tests/Unit/ProjectConfigTest.php @@ -13,6 +13,25 @@ function createZoomProject(string $graphics = ''): string return $root; } +/** Sets a setting through the Configuration category, as either editor does. */ +function setConfiguration(ProjectWorkspace $workspace, string $path, string $value): void +{ + $database = $workspace->getRecordDatabase('configuration'); + $index = array_search($path, $database->getEntryLabels(), true); + + if ($index === false) { + throw new RuntimeException("No setting {$path}."); + } + + $database->setField($index, 'value', $value); +} + +/** The field zoom a project's config.php holds, or null when it leaves it to the default. */ +function savedZoom(string $root): mixed +{ + return (require $root . '/config.php')['graphics']['field']['zoom'] ?? null; +} + it('shares pending Terms and field zoom in one source-preserving atomic config save', function () { $root = createZoomProject(); $original = file_get_contents($root . '/config.php'); @@ -20,10 +39,11 @@ function createZoomProject(string $graphics = ''): string $config = $workspace->config; $terms = $workspace->getRecordDatabase('terms'); $before = $config->getRecord(ProjectConfig::FIELD_ZOOM)->toArray(); - expect($config->getFieldZoom())->toBe(1.0)->and($terms->isEditable())->toBeTrue(); - $config->setFieldZoom('1'); + expect($before['default'])->toBe(1.0)->and($terms->isEditable())->toBeTrue(); + // Choosing the default for a setting the file leaves out writes nothing. + setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '1'); expect($workspace->hasUnsavedChanges())->toBeFalse(); - $config->setFieldZoom('2.5'); + setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '2.5'); $terms->setField(0, 'value', 'Welcome'); expect(file_get_contents($config->path))->toBe($original); $terms->save(); @@ -39,24 +59,27 @@ function createZoomProject(string $graphics = ''): string }); it('refuses invalid zoom before mutation instead of clamping', function (string $value) { - $config = new ProjectConfig(createZoomProject()); - expect(fn() => $config->setFieldZoom($value))->toThrow(RuntimeException::class) - ->and($config->isDirty())->toBeFalse()->and($config->getFieldZoom())->toBe(1.0); + $root = createZoomProject(); + $workspace = ProjectWorkspace::fromProject($root); + expect(fn() => setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, $value))->toThrow(InvalidArgumentException::class) + ->and($workspace->config->isDirty())->toBeFalse()->and(savedZoom($root))->toBeNull(); })->with(['0', '0.5', '-1', '8.01', 'INF', 'NAN', '1e309', 'no', '']); it('repairs explicit null or string zoom rather than mistaking them for an absent numeric default', function (string $literal) { - $config = new ProjectConfig(createZoomProject(" 'graphics' => ['field' => ['zoom' => " . $literal . "]],\n")); - $config->setFieldZoom('1'); - expect($config->isDirty())->toBeTrue(); - $config->save(); - expect((require $config->path)['graphics']['field']['zoom'])->toBe(1.0); + $root = createZoomProject(" 'graphics' => ['field' => ['zoom' => " . $literal . "]],\n"); + $workspace = ProjectWorkspace::fromProject($root); + setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '1'); + expect($workspace->config->isDirty())->toBeTrue(); + $workspace->config->save(); + expect(savedZoom($root))->toBe(1.0); })->with(['null', "'1'"]); it('refuses opaque or ambiguous zoom containers without flattening unrelated config', function (string $graphics) { - $config = new ProjectConfig(createZoomProject($graphics)); - $original = file_get_contents($config->path); - expect(fn() => $config->setFieldZoom('3'))->toThrow(RuntimeException::class) - ->and(file_get_contents($config->path))->toBe($original)->and($config->isDirty())->toBeFalse(); + $root = createZoomProject($graphics); + $workspace = ProjectWorkspace::fromProject($root); + $original = file_get_contents($workspace->config->path); + expect(fn() => setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '3'))->toThrow(RuntimeException::class) + ->and(file_get_contents($workspace->config->path))->toBe($original)->and($workspace->config->isDirty())->toBeFalse(); })->with([ " 'graphics' => array_merge([], ['field' => ['zoom' => 2]]),\n", " 'graphics' => ['field' => ['zoom' => 1 + 1]],\n", @@ -68,7 +91,7 @@ function createZoomProject(string $graphics = ''): string $workspace = ProjectWorkspace::fromProject(createZoomProject()); $config = $workspace->config; $workspace->getRecordDatabase('terms')->setField(0, 'value', 'Pending'); - $config->setFieldZoom('8'); + setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '8'); $newSource = str_replace('Keep header.', 'New user header.', file_get_contents($config->path)); file_put_contents($config->path, $newSource); expect(fn() => $config->save())->toThrow(RuntimeException::class, 'changed on disk') @@ -81,7 +104,7 @@ function createZoomProject(string $graphics = ''): string $config = $workspace->config; touch($config->path, 1000000000); $source = file_get_contents($config->path); - $config->setFieldZoom('8'); + setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '8'); $terms = $workspace->getRecordDatabase('terms'); $terms->setField(0, 'value', 'Pending'); expect(fn() => $config->save(new FailingFileSetOperations(failures: ['move' => [$config->path]])))->toThrow(RuntimeException::class) @@ -100,7 +123,7 @@ function createZoomProject(string $graphics = ''): string expect($terms->getSettingsFields(1)[2]['value'])->toContain('authored expression'); expect(fn() => $terms->setField(1, 'value', 'Hidden rewrite'))->toThrow(RuntimeException::class); $terms->setField(0, 'value', 'Literal edit'); - $workspace->config->setFieldZoom('2'); + setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '2'); $terms->save(); expect(file_get_contents($path))->toContain("'bye' => strtoupper('Bye')", "'title' => 'Literal edit'"); }); @@ -120,12 +143,12 @@ function createZoomProject(string $graphics = ''): string foreach ([0, 1] as $index) { expect(fn() => $terms->setField($index, 'value', 'Wrong target'))->toThrow(RuntimeException::class, 'dotted literal key'); } - $workspace->config->setFieldZoom('2'); + setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '2'); $workspace->config->save(); expect((require $root . '/config.php')['vocab'])->toBe(['a.b' => 'Literal', 'a' => ['b' => 'Nested']]); }); -it('authors field zoom through System numeric input and shares Terms undo save and reload', function () { +it('authors field zoom through the Configuration category and shares Terms undo save and reload', function () { $root = createZoomProject(); $editor = createEditorForTesting($root); $workspace = ProjectWorkspace::fromProject($root); @@ -133,22 +156,55 @@ function createZoomProject(string $graphics = ''): string setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]); setEditorProperty($editor, 'isRunning', true); callEditorMethod($editor, 'openDatabaseWindow'); - setEditorProperty($editor, 'databaseCategoryIndex', DatabaseCatalog::indexOf('system')); + setEditorProperty($editor, 'databaseCategoryIndex', DatabaseCatalog::indexOf('configuration')); + $zoom = array_search(ProjectConfig::FIELD_ZOOM, $workspace->getRecordDatabase('configuration')->getEntryLabels(), true); + setEditorProperty($editor, 'databaseSelectedRecordIndexes', ['configuration' => $zoom]); setEditorProperty($editor, 'databaseFocus', 'database_settings'); foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $index => $field) { - if (($field['field'] ?? '') === ProjectConfig::FIELD_ZOOM) { setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); } + if (($field['field'] ?? '') === 'value') { setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); } } callEditorMethod($editor, 'dispatchInput', "\r"); foreach (["\177", '2', '.', '5', "\r"] as $key) { callEditorMethod($editor, 'dispatchInput', $key); } - expect($workspace->config->getFieldZoom())->toBe(2.5); + expect($workspace->config->getRecord(ProjectConfig::FIELD_ZOOM)->get('value'))->toBe(2.5); $workspace->getRecordDatabase('terms')->setField(0, 'value', 'Concurrent term'); callEditorMethod($editor, 'dispatchInput', "\x13"); - expect((require $workspace->config->path)['graphics']['field']['zoom'])->toBe(2.5); + expect(savedZoom($root))->toBe(2.5); callEditorMethod($editor, 'dispatchInput', "\x1a"); callEditorMethod($editor, 'dispatchInput', "\x13"); $saved = require $workspace->config->path; expect($saved)->not->toHaveKey('graphics')->and($saved['vocab']['title'])->toBe('Concurrent term'); callEditorMethod($editor, 'dispatchInput', "\x19"); callEditorMethod($editor, 'dispatchInput', "\x13"); - expect((new ProjectConfig($root))->getFieldZoom())->toBe(2.5); + expect(savedZoom($root))->toBe(2.5); +}); + +it('edits an enum setting as a choice of its enum, written back as the enum case', function () { + $root = createZoomProject(" 'ui' => ['battle' => ['selection_color' => \\Ichiloto\\Engine\\IO\\Enumerations\\Color::LIGHT_BLUE]],\n"); + $workspace = ProjectWorkspace::fromProject($root); + $database = $workspace->getRecordDatabase('configuration'); + $index = array_search('ui.battle.selection_color', $database->getEntryLabels(), true); + $value = array_find($database->getSettingsFields($index), static fn(array $field): bool => ($field['field'] ?? null) === 'value'); + + expect($value['options'] ?? [])->toContain('RED')->and($value['value'])->toBe('LIGHT_BLUE'); + + $database->setField($index, 'value', 'RED'); + $workspace->config->save(); + + expect((string) file_get_contents($root . '/config.php'))->toContain("'selection_color' => \\Ichiloto\\Engine\\IO\\Enumerations\\Color::RED") + ->and((require $root . '/config.php')['ui']['battle']['selection_color'])->toBe(\Ichiloto\Engine\IO\Enumerations\Color::RED); +}); + +it('edits a setting authored as a case of an enum with no values, by the case\'s name', function () { + $root = createZoomProject(" 'ui' => ['dialogue' => ['window' => ['position' => \\Ichiloto\\Engine\\UI\\Windows\\Enumerations\\WindowPosition::TOP]]],\n"); + $workspace = ProjectWorkspace::fromProject($root); + $database = $workspace->getRecordDatabase('configuration'); + $index = array_search('ui.dialogue.window.position', $database->getEntryLabels(), true); + + expect($index)->not->toBeFalse(); + + $database->setField($index, 'value', 'BOTTOM'); + $workspace->config->save(); + + expect((require $root . '/config.php')['ui']['dialogue']['window']['position']) + ->toBe(\Ichiloto\Engine\UI\Windows\Enumerations\WindowPosition::BOTTOM); }); diff --git a/tests/Unit/ProjectSystemDatabaseTest.php b/tests/Unit/ProjectSystemDatabaseTest.php index 386bad4..64e2ed2 100644 --- a/tests/Unit/ProjectSystemDatabaseTest.php +++ b/tests/Unit/ProjectSystemDatabaseTest.php @@ -2,7 +2,9 @@ declare(strict_types=1); -use Ichiloto\Editor\ProjectSystemDatabase; +use Ichiloto\Editor\Database\ProjectRecordDatabase; +use Ichiloto\Editor\Database\RecordSchemaCatalog; +use Ichiloto\Editor\Session\EditorSession; function scratchSystemProject(): string { @@ -12,10 +14,15 @@ function scratchSystemProject(): string return $root; } -it('round-trips every system field including extended and unknown ATB settings', function (): void { - $root = scratchSystemProject(); - $path = $root . '/assets/Data/system.php'; - $original = [ +function systemRecords(string $root): ProjectRecordDatabase +{ + return ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('system')); +} + +/** @return array */ +function productionSystem(): array +{ + return [ 'title' => 'Production Fixture', 'currency' => ['amount' => 400, 'symbol' => 'G'], 'startingParty' => ['Kaelion', 'Liora', 'Drazek', 'Seraphis'], @@ -36,54 +43,113 @@ function scratchSystemProject(): string 'speedFactorPercent' => 100, 'openingVariance' => 24, 'openingSpeedFactorPercent' => 250, - 'surpriseAttackChancePercent' => 8, - 'backAttackChancePercent' => 6, 'futureTuningKey' => 17, ], ], 'unknownSystemSetting' => ['preserve' => 'yes'], ]; - file_put_contents($path, "setField(0, 'title', 'Changed'); + $database->setField(0, 'title', 'Production Fixture'); $database->save(); - $saved = require $path; - $reloaded = ProjectSystemDatabase::fromProject($root); - $reloaded->save(); - $reloadedAgain = require $path; - - expect($saved)->toBe($original) - ->and($saved['battle']['activeTime'])->toBe($original['battle']['activeTime']) - ->and($saved['battle']['unknownBattleSetting'])->toBe(['future' => true]) - ->and($saved['unknownSystemSetting'])->toBe(['preserve' => 'yes']) - ->and($reloadedAgain)->toBe($original); + expect((string) file_get_contents($path))->toBe($source); }); it('merges an edited system field without removing unedited settings', function (): void { $root = scratchSystemProject(); $path = $root . '/assets/Data/system.php'; - $original = [ - 'title' => 'Production Fixture', - 'battle' => [ - 'engine' => 'active_time', - 'activeTime' => [ - 'mode' => 'wait', - 'baseFillRate' => 35, - 'speedFactorPercent' => 100, - 'openingVariance' => 24, - 'futureTuningKey' => 17, - ], - ], - ]; - file_put_contents($path, "setField('atbBaseFillRate', 42); + $database = systemRecords($root); + $database->setField(0, 'battle.activeTime.baseFillRate', '42'); + $database->setField(0, 'startingPositions.player.spawnSprite.0', 'West'); $database->save(); $saved = require $path; + $expected = productionSystem(); + $expected['battle']['activeTime']['baseFillRate'] = 42; + $expected['startingPositions']['player']['spawnSprite'] = ['West']; + + expect($saved)->toBe($expected); +}); + +it('edits the starting party as actor ids and the starting inventory as items with quantities', function (): void { + $root = scratchSystemProject(); + $path = $root . '/assets/Data/system.php'; + file_put_contents($path, "removeSubItem(0, 1, 'startingParty'); + $stock = $database->addSubItem(0, listKey: 'startingInventory'); + $database->setField(0, "stock{$stock}Item", 'Ether'); + $database->setField(0, "stock{$stock}Quantity", '3'); + $database->save(); + $saved = require $path; + + // A party member stays a bare id, as the file authors it. + expect($saved['startingParty'])->toBe(['Kaelion', 'Drazek', 'Seraphis']) + ->and($saved['startingInventory'])->toBe([['item' => 'S-Potion', 'quantity' => 10], ['item' => 'Ether', 'quantity' => 3]]); +}); + +it('keeps the file as the category\'s one record, only ever edited', function (): void { + $root = scratchSystemProject(); + file_put_contents($root . '/assets/Data/system.php', "getEntryLabels())->toBe(['Production Fixture']) + ->and($database->isEditable())->toBeTrue() + ->and($database->supportsRecordCreation())->toBeFalse() + ->and($database->supportsRecordDeletion())->toBeFalse() + ->and($database->duplicateRecordSupported())->toBeFalse() + ->and($database->addRecord())->toBeNull(); +}); + +it('opens System in the GUI session with its lists under their headings', function (): void { + $session = EditorSession::open(makeTemporaryProject()); + $records = $session->listDatabaseRecords('system'); + $rows = $session->readDatabaseRecord('system', 0)['rows']; + $labels = array_column($rows, 'label'); + + expect($records['editable'])->toBeTrue() + ->and($records['canCreate'])->toBeFalse() + ->and($labels)->toContain('Title', 'Starting Gold', 'Start Map', 'Battle Engine', 'Starting Party', 'Starting Inventory'); +}); + +it('keeps a value written as an enum case\'s value written that way, as the new case', function (): void { + $root = scratchSystemProject(); + $path = $root . '/assets/Data/system.php'; + file_put_contents($path, " 'Fixture',\n 'startingPositions' => ['player' => ['spawnSprite' => [MovementHeading::SOUTH->value]]],\n];\n"); + + $database = systemRecords($root); + new \Ichiloto\Editor\Database\RecordAuthoring()->applyField($database, 0, [], 'startingPositions.player.spawnSprite.0', 'West', 'Start Facing'); + $database->save(); + + expect((string) file_get_contents($path))->toContain('[MovementHeading::WEST->value]') + ->and((require $path)['startingPositions']['player']['spawnSprite'])->toBe(['West']); +}); + +it('refuses an edit to a value the file writes as an expression when it is made, not at save', function (): void { + $root = scratchSystemProject(); + $path = $root . '/assets/Data/system.php'; + file_put_contents($path, " strtoupper('fixture'), 'currency' => ['amount' => 5]];\n"); + $database = systemRecords($root); + + expect(fn() => new \Ichiloto\Editor\Database\RecordAuthoring()->applyField($database, 0, [], 'title', 'Renamed', 'Title')) + ->toThrow(\Ichiloto\Editor\Database\RecordRefusal::class, 'expression') + ->and($database->getRecordByIndex(0)->get('title'))->toBe('FIXTURE') + ->and($database->isDirty())->toBeFalse(); + + // Values written as plain data stay editable beside it. + new \Ichiloto\Editor\Database\RecordAuthoring()->applyField($database, 0, [], 'currency.amount', '9', 'Starting Gold'); + $database->save(); - expect($saved['battle']['activeTime']['baseFillRate'])->toBe(42) - ->and($saved['battle']['activeTime']['openingVariance'])->toBe(24) - ->and($saved['battle']['activeTime']['futureTuningKey'])->toBe(17); + expect((string) file_get_contents($path))->toContain("strtoupper('fixture')", "'amount' => 9"); }); diff --git a/tests/Unit/SaveIntegrityTest.php b/tests/Unit/SaveIntegrityTest.php index 901e19b..fb99779 100644 --- a/tests/Unit/SaveIntegrityTest.php +++ b/tests/Unit/SaveIntegrityTest.php @@ -320,7 +320,6 @@ function hashTree(string $directory): array $workspace->actorDatabase->save(); $workspace->skillDatabase->save(); - $workspace->systemDatabase->save(); foreach ($workspace->recordDatabases as $database) { if ($database->isEditable()) { From fe9cc7988fb3e0843afa6b469507bf4753299c51 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 15:41:06 +0200 Subject: [PATCH 154/344] feat(database): edit actors in both editors through one actor service ActorAuthoring owns an actor's rows, previews, edits, creation, deletion and identity freeze, with what the panes show (variant, assumed growth, optimize slot) as its own view state. The terminal editor calls it in place of its private actor code; actor creation is now an undo step. The session reaches every category through DatabaseCategory: RecordCategory for schema-driven ones, ActorCategory for actors. Rows gain an action kind (the identity freeze) and the none choice's label. Undo and redo name actors when a step changed them. --- docs/roadmap.md | 20 +- src/Actors/ActorAuthoring.php | 667 ++++++++++++++++++++ src/Actors/ActorCategory.php | 197 ++++++ src/Database/DatabaseCategory.php | 109 ++++ src/Database/RecordCategory.php | 143 +++++ src/Editor.php | 633 ++----------------- src/ProjectActorDatabase.php | 16 + src/Session/EditorSession.php | 137 ++-- tests/Unit/ActorClassFieldTest.php | 8 +- tests/Unit/ActorIdentityMigrationUiTest.php | 6 +- tests/Unit/ActorNaturalCompositionTest.php | 8 +- tests/Unit/ActorNaturalStatsTest.php | 8 +- tests/Unit/AnimationReferenceTest.php | 2 +- tests/Unit/EquipmentOptimizationTest.php | 4 +- tests/Unit/PermanentGrowthTest.php | 4 +- tests/Unit/Session/ActorSessionTest.php | 164 +++++ tests/Unit/Session/DatabaseSessionTest.php | 2 +- tests/Unit/Session/EditorSessionTest.php | 2 +- 18 files changed, 1459 insertions(+), 671 deletions(-) create mode 100644 src/Actors/ActorAuthoring.php create mode 100644 src/Actors/ActorCategory.php create mode 100644 src/Database/DatabaseCategory.php create mode 100644 src/Database/RecordCategory.php create mode 100644 tests/Unit/Session/ActorSessionTest.php diff --git a/docs/roadmap.md b/docs/roadmap.md index d466c4f..97582ba 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -255,12 +255,20 @@ Acceptance criteria: ### Database completion across TUI and GUI (in progress, 2026-10-04) -Every Database category is edited through one shared record service, so the -terminal editor and the GUI editor read and write it the same way. Today 21 -categories are schema-driven (`RecordSchemaCatalog` over -`ProjectRecordDatabase`) and reach both editors. Actors, Classes, Skills, -Quests and System are built by hand inside `Editor.php` over their own -database classes, so the GUI session refuses them. +Every Database category is edited through one shared service, so the +terminal editor and the GUI editor read and write it the same way. The GUI +session reaches a category through `DatabaseCategory`: schema-driven ones +(`RecordSchemaCatalog` over `ProjectRecordDatabase`) through `RecordCategory`, +actors through `ActorCategory` over `ActorAuthoring`, the actor service the +terminal editor uses too. + +Progress: Classes, Quests, System, a new Configuration category and Actors +are done in both editors. Skills is the one category the GUI still refuses. +Known gap: an actor authored without an id whose identity freeze also needs +other files rewritten (the cross-file repair `SourceSetCommand` writes at +once) is refused in the GUI with a pointer to the terminal's repair dialog; +the single-file freeze works in both editors. The session would need its +workspace replacement to keep loaded maps for the repair's undo. Order of work, each a complete slice before the next: diff --git a/src/Actors/ActorAuthoring.php b/src/Actors/ActorAuthoring.php new file mode 100644 index 0000000..db831f8 --- /dev/null +++ b/src/Actors/ActorAuthoring.php @@ -0,0 +1,667 @@ + Which variant each actor's rows are editing. */ + private array $variantSelections = []; + + /** @var array Which growth each actor's preview assumes. */ + private array $growthSelections = []; + + /** @var array Which slot each actor's Optimize preview fills. */ + private array $optimizeSlots = []; + + /** + * Applies one actor row: a choice of what the panes show, which records + * nothing, or a value of the actor, as one undo step. A value the actor's + * own rules refuse - renaming an actor that has no identity yet, an + * attack style the Engine does not know - leaves the actor as it was. + * + * @param ProjectWorkspace $workspace The project. + * @param int $index The actor. + * @param string $field The row's field id. + * @param string $rawValue The value as entered. + * @param string $label The row's label, which names the undo step. + * @throws RecordRefusal When the actor is gone or the value is refused. + */ + public function applyField(ProjectWorkspace $workspace, int $index, string $field, string $rawValue, string $label): RecordChange + { + $actor = $workspace->actorDatabase->getActorByIndex($index) + ?? throw new RecordRefusal(sprintf('actors has no record %d.', $index)); + + // Choices about the panes, not values the project stores. + if ($field === self::VARIANT_FIELD) { + $this->variantSelections[$actor->getDefinitionId()] = trim($rawValue); + + return new RecordChange(null, $index); + } + + if ($field === self::GROWTH_FIELD) { + $this->growthSelections[$actor->getDefinitionId()] = trim($rawValue); + + return new RecordChange(null, $index); + } + + if ($field === self::OPTIMIZE_SLOT_FIELD) { + $this->optimizeSlots[$actor->getDefinitionId()] = trim($rawValue); + + return new RecordChange(null, $index); + } + + $before = $actor->getData(); + + try { + $workspace->actorDatabase->setField($index, $field, self::coerceActorFieldValue($field, $rawValue)); + } catch (RuntimeException $refused) { + $actor->restoreData($before); + + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + + $after = $actor->getData(); + + if ($after === $before) { + return new RecordChange(null, $index); + } + + return new RecordChange(new GenericCommand( + sprintf('%s edit', $label), + static fn() => $actor->restoreData($after), + static fn() => $actor->restoreData($before), + ), $index); + } + + /** + * Creates a blank actor at the end of the list, under a fresh identity, + * as one undo step. Its file is written on save. + * + * @return RecordChange The new actor's index. + */ + public function createActor(ProjectWorkspace $workspace): RecordChange + { + $database = $workspace->actorDatabase; + $index = $database->addActor(); + $actor = $database->getActorByIndex($index) + ?? throw new RecordRefusal('The new actor could not be created.'); + + return new RecordChange(new GenericCommand( + 'Create actor', + static fn() => $database->insertActor($index, $actor), + static fn() => $database->removeActor($index), + ), $index); + } + + /** + * Deletes an actor as one undo step. Its file goes on save, which is + * what makes the undo honest. The change's index is the actor to select + * next: the one before it, or null when none is left. + * + * @throws RecordRefusal When the actor is gone. + */ + public function deleteActor(ProjectWorkspace $workspace, int $index): RecordChange + { + $database = $workspace->actorDatabase; + $actor = $database->removeActor($index) + ?? throw new RecordRefusal(sprintf('actors has no record %d.', $index)); + + return new RecordChange(new GenericCommand( + sprintf('Delete actor %s', $actor->getName()), + static fn() => $database->removeActor($index), + static fn() => $database->insertActor($index, $actor), + ), $database->getActors() === [] ? null : max(0, $index - 1)); + } + + /** + * Freezes the current name of an actor authored without an id as its + * permanent id, as one undo step written on save. Later renames keep + * that identity, so saves and references stay valid. + * + * @throws RecordRefusal When the actor is gone, already has an id, or its name would collide. + */ + public function freezeIdentity(ProjectWorkspace $workspace, int $index): RecordChange + { + $actor = $workspace->actorDatabase->getActorByIndex($index) + ?? throw new RecordRefusal(sprintf('actors has no record %d.', $index)); + $before = $actor->getData(); + + try { + ActorIdentityMigration::freezeCurrentName($workspace->actorDatabase, $actor); + } catch (RuntimeException $refused) { + $actor->restoreData($before); + + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + $after = $actor->getData(); + + return new RecordChange(new GenericCommand( + 'Freeze actor identity', + static fn() => $actor->restoreData($after), + static fn() => $actor->restoreData($before), + ), $index); + } + + /** + * The rows that say which actor this is to a save. + * + * @param ProjectActor $actor The actor. + * @return array> The rows. + */ + private function getActorIdentityFields(ProjectActor $actor): array + { + $missingId = ! array_key_exists('id', $actor->getData()); + return [ + ['label' => 'Identity', 'value' => '', 'editable' => false, 'field' => ''], + [ + 'label' => 'Definition Id', + 'value' => $actor->hasDefinitionId() ? $actor->getDefinitionId() : '', + 'editable' => $missingId, + // Without an id the row's edit is the one-time freeze of its name. + 'action' => $missingId ? sprintf('Freeze "%s" as the permanent id', $actor->getName()) : null, + 'field' => 'id', + 'displayDefault' => $missingId + ? sprintf('Enter to freeze the current name (%s) as its permanent id', $actor->getName()) + : 'Malformed explicit id: correct the authored actor file; automatic replacement is not allowed.', + ], + ]; + } + + /** + * The rows for an actor's own nature: the adjustments it makes to its + * class baseline, and the named variants of that nature. + * + * While an actor declares variants the runtime reads the selected + * variant's adjustments and ignores the fixed ones, so the rows edit + * whichever set is actually in force. + * + * @param ProjectActor $actor The actor. + * @return array> The rows. + */ + private function actorNatureFields(ProjectActor $actor): array + { + $variants = $actor->getNaturalVariants(); + $selected = $this->getSelectedVariantId($actor); + $rows = [['label' => 'Nature', 'value' => '', 'editable' => false, 'field' => '']]; + + if ($variants !== []) { + $rows[] = [ + 'label' => 'Default Variant', + 'value' => (string) $actor->getDefaultNaturalVariantId(), + 'options' => array_keys($variants), + 'field' => 'defaultNaturalVariantId', + ]; + $rows[] = [ + 'label' => 'Editing Variant', + 'value' => $selected ?? '', + 'options' => array_keys($variants), + 'field' => self::VARIANT_FIELD, + ]; + } + + // Both layers are real: the runtime adds the selected variant on top + // of the fixed adjustments rather than replacing them, so both are + // shown and both are editable. + $inForce = $actor->getNaturalAdjustmentsFor($selected); + $fixed = $actor->getActorNaturalAdjustments(); + $rows = [...$rows, ...$this->actorAdjustmentRows( + $variants === [] ? 'Adjustments' : 'Fixed, always applied', + 'actorNaturalAdjustments', + $fixed, + )]; + + if ($variants !== [] && $selected !== null) { + $rows = [...$rows, ...$this->actorAdjustmentRows( + sprintf('Variant %s, added on top', $selected), + sprintf('naturalVariants.%s', $selected), + $variants[$selected] ?? [], + )]; + $rows[] = [ + 'label' => ' In force', + 'value' => $this->describeAdjustments($inForce), + 'editable' => false, + 'field' => '', + ]; + } + + return $rows; + } + + /** + * The rows for one layer of an actor's nature. + * + * @param string $heading What the layer is. + * @param string $prefix The payload path the rows write to. + * @param array $adjustments The layer's adjustments. + * @return array> The rows. + */ + private function actorAdjustmentRows(string $heading, string $prefix, array $adjustments): array + { + $rows = [['label' => ' ' . $heading, 'value' => '', 'editable' => false, 'field' => '']]; + + foreach (ActorStatPreview::statKeys() as $key) { + $amount = $adjustments[$key] ?? 0; + $rows[] = [ + 'label' => ' ' . ucfirst(strtolower((string) preg_replace('/(? (string) $amount, + 'control' => new InputControl(InputControlType::INTEGER, (string) $amount), + 'field' => $prefix . '.' . $key, + ]; + } + + return $rows; + } + + /** + * Describes what an actor's nature comes to once composed. + * + * @param array $adjustments The composed adjustments. + * @return string The description. + */ + private function describeAdjustments(array $adjustments): string + { + $parts = []; + + foreach ($adjustments as $key => $amount) { + if ($amount !== 0) { + $parts[] = sprintf('%+d %s', $amount, $key); + } + } + + return $parts === [] ? 'nothing adjusted' : implode(', ', $parts); + } + + /** + * The read-only rows showing what each stat actually comes to. + * + * @param ProjectActor $actor The actor. + * @return array> The rows. + */ + private function actorStatPreviewFields(ProjectWorkspace $workspace, ProjectActor $actor): array + { + if (! ActorStatPreview::isAvailable()) { + return []; + } + + $catalog = PermanentGrowthCatalog::fromProject($workspace->projectRoot); + $assumed = $this->assumedGrowthFor($actor); + $rows = [ + ['label' => 'Resolved Stats', 'value' => '', 'editable' => false, 'field' => ''], + [ + // Earned growth is save state. What a preview can do is + // assume some of it, say that it is assuming, and write + // nothing. + 'label' => ' Assumed Growth', + 'value' => $assumed, + 'options' => [ + self::NO_ASSUMED_GROWTH, + self::ALL_ASSUMED_GROWTH, + ...$catalog->ids(), + ], + 'field' => self::GROWTH_FIELD, + 'displayDefault' => 'assumed for this preview only; the party earns growth in play', + ], + ]; + + $permanent = match ($assumed) { + self::NO_ASSUMED_GROWTH => [], + self::ALL_ASSUMED_GROWTH => $catalog->totalsFor(), + default => $catalog->totalsFor([$assumed]), + }; + + foreach (ActorStatPreview::resolve($actor, $this->getSelectedVariantId($actor), $permanent) as $row) { + $rows[] = [ + 'label' => ' ' . $row['stat'], + 'value' => ActorStatPreview::describeRow($row), + 'editable' => false, + 'field' => '', + ]; + } + + return [...$rows, ...$this->actorOptimizeFields($workspace, $actor)]; + } + + /** + * Returns which permanent growth this actor's preview assumes. + * + * @param ProjectActor $actor The actor. + * @return string The selection. + */ + private function assumedGrowthFor(ProjectActor $actor): string + { + return $this->growthSelections[$actor->getDefinitionId()] ?? self::NO_ASSUMED_GROWTH; + } + + /** + * Returns which slot this actor's Optimize preview fills. + * + * @param ProjectActor $actor The actor. + * @return string The semantic slot. + */ + private function optimizeSlotFor(ProjectActor $actor): string + { + $slots = EquipmentOptimizationPolicy::slotKeys(); + $selected = $this->optimizeSlots[$actor->getDefinitionId()] ?? ''; + + return in_array($selected, $slots, true) ? $selected : ($slots[0] ?? 'weapon'); + } + + /** + * The read-only rows showing what Optimize would choose, and why. + * + * @param ProjectActor $actor The actor. + * @return array> The rows. + */ + private function actorOptimizeFields(ProjectWorkspace $workspace, ProjectActor $actor): array + { + if (! EquipmentOptimizationPolicy::isAvailable()) { + return []; + } + + $root = $workspace->projectRoot; + $slot = $this->optimizeSlotFor($actor); + $rows = [ + ['label' => 'Optimize Preview', 'value' => '', 'editable' => false, 'field' => ''], + [ + // Which policy is scoring is the first thing to know: a + // project that has declared none is not being scored by its + // own rules at all. + 'label' => ' Policy', + 'value' => EquipmentOptimizationPolicy::describeSource($root), + 'editable' => false, + 'field' => '', + ], + [ + 'label' => ' Slot', + 'value' => $slot, + 'options' => EquipmentOptimizationPolicy::slotKeys(), + 'field' => self::OPTIMIZE_SLOT_FIELD, + ], + ]; + $ranked = EquipmentOptimizationPolicy::rank($workspace, $actor, $slot); + + if ($ranked === []) { + $rows[] = [ + 'label' => ' (nothing)', + 'value' => 'no equipment this project has fits that slot', + 'editable' => false, + 'field' => '', + ]; + + return $rows; + } + + foreach ($ranked as $position => $candidate) { + $rows[] = [ + 'label' => sprintf(' %d. %s', $position + 1, $candidate['name']), + 'value' => sprintf( + '%d · %s', + $candidate['value'], + EquipmentOptimizationPolicy::describeRow($candidate), + ), + 'editable' => false, + 'field' => '', + ]; + } + + return $rows; + } + + /** + * Returns which natural variant the actor rows are editing. + * + * @param ProjectActor $actor The actor. + * @return string|null The variant id, or null when the actor has none. + */ + public function getSelectedVariantId(ProjectActor $actor): ?string + { + $variants = $actor->getNaturalVariants(); + + if ($variants === []) { + return null; + } + + $selected = $this->variantSelections[$actor->getDefinitionId()] ?? null; + + return $selected !== null && isset($variants[$selected]) + ? $selected + : ($actor->getDefaultNaturalVariantId() ?? array_key_first($variants)); + } + + /** + * The rows an actor shows, in the order the editors list them. + * + * @return array> + */ + public function describeFields(ProjectWorkspace $workspace, ProjectActor $actor): array + { + return [ + ...$this->getActorIdentityFields($actor), + [ + 'label' => 'Name', + 'value' => $actor->getName(), + 'control' => new InputControl(InputControlType::TEXT, $actor->getName()), + 'editable' => $actor->hasDefinitionId(), + 'field' => 'name', + ], + [ + 'label' => 'Description', + 'value' => $actor->getDescription(), + 'control' => new InputControl(InputControlType::TEXT, $actor->getDescription()), + 'field' => 'description', + ], + [ + // The character-class reference: a name from + // assets/Data/classes.php, written to the actor's + // data['class'] key (the engine's ClassStore hydrates it + // into a CharacterRole). ←/→ cycles the picker; Ctrl+G jumps + // to the class entry. + 'label' => 'Class', + 'value' => $actor->getClassName() === '' ? ProjectActor::CLASS_NONE : $actor->getClassName(), + 'options' => $this->getActorClassOptions($workspace), + 'field' => 'class', + ], + [ + // The character's own weapon, part of who they are and with + // no stats: what their attack looks like when no weapon is + // equipped. An equipped weapon's type takes its place. + 'label' => 'Attack Style', + 'value' => $actor->getAttackStyle() === '' ? 'Unarmed' : $actor->getAttackStyle(), + 'options' => [ProjectActor::ATTACK_STYLE_UNARMED, ...array_map( + static fn(WeaponType $type): string => $type->value, WeaponType::cases())], + 'field' => 'attackStyle', + 'hint' => 'own weapon when none is equipped; no stats', + ], + [ + // The basic skill the Attack command uses; a battle-usable + // basic skill from the catalogue, or the Engine's own attack. + 'label' => 'Attack Skill', + 'value' => $actor->getAttackSkill() === '' ? self::BUILT_IN_ATTACK : $actor->getAttackSkill(), + 'reference' => 'attack_skills', + 'allowsNone' => true, + 'noneLabel' => self::BUILT_IN_ATTACK, + 'field' => 'attackSkill', + ], + ...$this->actorSummonFields($workspace, $actor), + [ + 'label' => 'Level', + 'value' => (string) $actor->getLevel(), + 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getLevel()), + 'field' => 'level', + ], + [ + 'label' => 'Current Exp', + 'value' => (string) $actor->getCurrentExp(), + 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getCurrentExp()), + 'field' => 'currentExp', + ], + [ + 'label' => 'Current HP', + 'value' => (string) $actor->getStat('currentHp'), + 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getStat('currentHp')), + 'field' => 'currentHp', + ], + [ + 'label' => 'Current MP', + 'value' => (string) $actor->getStat('currentMp'), + 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getStat('currentMp')), + 'field' => 'currentMp', + ], + [ + 'label' => 'Current AP', + 'value' => (string) $actor->getStat('currentAp'), + 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getStat('currentAp')), + 'field' => 'currentAp', + ], + ...$this->actorNatureFields($actor), + ...$this->actorStatPreviewFields($workspace, $actor), + ]; + } + + /** + * Returns the actor's starting summon assignments: a multi-pick over the + * project's summons, and one verdict row per assignment judged by the + * same rules the validator applies (existence, wielder eligibility, + * story locks, duplicates, exclusive tenancy across the cast). + * + * @return array> + */ + private function actorSummonFields(ProjectWorkspace $workspace, ProjectActor $actor): array + { + $assignments = $actor->getSummons(); + $list = is_array($assignments) ? array_values(array_filter(array_map(static fn(mixed $id): string => is_string($id) ? trim($id) : '', $assignments), static fn(string $id): bool => $id !== '')) : []; + $rows = [ + [ + 'label' => 'Summons', + 'value' => implode(', ', $list), + 'reference' => 'summons', + 'multi' => true, + 'noneLabel' => '(None)', + 'field' => 'summons', + ], + ]; + $diagnostics = SummonAssignmentDiagnostics::fromLibrary($workspace->cutscenes); + $holders = []; + + foreach ($workspace->actorDatabase->getActors() ?? [] as $other) { + if ($other === $actor) { + continue; + } + + foreach ((array) $other->getSummons() as $id) { + if (is_string($id) && trim($id) !== '') { + $holders[strtolower(trim($id))][] = $other->getName(); + } + } + } + + foreach ($diagnostics->forActor($actor->getName(), $actor->getClassName(), $assignments) as $row) { + $verdict = SummonAssignmentDiagnostics::describe($row); + $id = strtolower($row['id']); + + if ($row['problems'] === [] && $diagnostics->isExclusive($id) && isset($holders[$id])) { + $verdict = sprintf('✗ %s: exclusive, also held by %s.', $row['id'], implode(', ', $holders[$id])); + } + + $rows[] = ['label' => ' ' . $verdict, 'value' => '', 'editable' => false, 'field' => '']; + } + + return $rows; + } + + /** + * Returns the actor class picker options. + * + * The list is the project's own class names from + * `assets/Data/classes.php`, prefixed with the "none" sentinel that + * clears the reference. + * + * @return string[] + */ + private function getActorClassOptions(ProjectWorkspace $workspace): array + { + return [ + ProjectActor::CLASS_NONE, + ...$workspace->getRecordDatabase('classes')?->getEntryLabels() ?? [], + ]; + } + + /** + * Returns an edited actor value as the field's own type. + * + * Most actor numbers are quantities that cannot go below zero, but an + * actor's nature is an adjustment: being slower than the class baseline + * is a legitimate thing to author, so those keep their sign. + * + * @param string $field The field identifier. + * @param string $rawValue The raw edited value. + * @return string|int The coerced value. + */ + private static function coerceActorFieldValue(string $field, string $rawValue): string|int + { + if (in_array($field, ['name', 'description', 'class', 'attackStyle', 'attackSkill', 'id', 'defaultNaturalVariantId', 'summons'], true)) { + return trim($rawValue); + } + + if (str_starts_with($field, 'actorNaturalAdjustments.') || str_starts_with($field, 'naturalVariants.')) { + return intval(trim($rawValue)); + } + + return max(0, intval($rawValue)); + } +} diff --git a/src/Actors/ActorCategory.php b/src/Actors/ActorCategory.php new file mode 100644 index 0000000..a4c5b7c --- /dev/null +++ b/src/Actors/ActorCategory.php @@ -0,0 +1,197 @@ +key = 'actors'; + } + + public function isEditable(): bool + { + return true; + } + + public function getReadOnlyReason(): ?string + { + return null; + } + + public function isDirty(): bool + { + return $this->workspace->actorDatabase->isDirty(); + } + + public function supportsRecordCreation(): bool + { + return true; + } + + public function supportsRecordDuplication(): bool + { + return false; + } + + public function supportsRecordDeletion(): bool + { + return true; + } + + public function supportsDurableReorder(): bool + { + return false; + } + + public function getRecordLabels(): array + { + return array_map(static fn(ProjectActor $actor): string => $actor->getName(), $this->workspace->actorDatabase->getActors()); + } + + public function getRecordRows(int $index, array $frame): array + { + self::refuseFrame($frame); + + return $this->authoring->describeFields($this->workspace, $this->requireActor($index)); + } + + public function describeFrame(array $frame): ?string + { + return null; + } + + public function getListNoun(array $frame): ?string + { + return null; + } + + public function locateItem(int $index, array $frame, string $fieldId): ?RecordItem + { + return null; + } + + public function applyField(int $index, array $frame, string $fieldId, string $value, string $label): RecordChange + { + self::refuseFrame($frame); + $actor = $this->requireActor($index); + + if ($fieldId === 'id' && ! array_key_exists('id', $actor->getData())) { + // An actor authored without an id: the edit is the one-time + // freeze of its name, as the terminal's Enter on the row is. + $this->refuseWiderRepair($actor); + + return $this->authoring->freezeIdentity($this->workspace, $index); + } + + return $this->authoring->applyField($this->workspace, $index, $fieldId, $value, $label); + } + + public function addItem(int $index, array $frame, ?string $fieldId, bool $child): RecordChange + { + throw new RecordRefusal('An actor has no list to add to.'); + } + + public function removeItem(int $index, array $frame, string $fieldId): RecordChange + { + throw new RecordRefusal('An actor has no list to remove from.'); + } + + public function createRecord(): RecordChange + { + return $this->authoring->createActor($this->workspace); + } + + public function duplicateRecord(int $index): RecordChange + { + throw new RecordRefusal('Actors cannot be duplicated: each one is a character with its own identity. Create a new actor instead.'); + } + + public function deleteRecord(int $index): RecordChange + { + return $this->authoring->deleteActor($this->workspace, $index); + } + + public function moveRecord(int $index, int $step): RecordChange + { + throw new RecordRefusal('Each actor is its own file; the list shows them in file order, which a move cannot change.'); + } + + public function getBackupPaths(): array + { + return array_values($this->workspace->actorDatabase->getBackupPaths()); + } + + public function save(): void + { + try { + $this->workspace->actorDatabase->save(); + } catch (RuntimeException $failure) { + // The actors that could not be written stay unsaved, and the + // message names the first one and why. + throw new RecordRefusal($failure->getMessage(), previous: $failure); + } + } + + /** @throws RecordRefusal */ + private function requireActor(int $index): ProjectActor + { + return $this->workspace->actorDatabase->getActorByIndex($index) + ?? throw new RecordRefusal(sprintf('actors has no record %d.', $index)); + } + + /** + * Freezing one actor's name is the whole repair only when no other file + * needs it too. A repair across files rewrites them at once rather than + * on save, so it is made from the terminal editor's repair dialog. + * + * @throws RecordRefusal + */ + private function refuseWiderRepair(ProjectActor $actor): void + { + try { + $paths = ActorIdentityMigration::planProject($this->workspace->projectRoot)->getChangedPaths(); + } catch (Throwable $failure) { + throw new RecordRefusal(sprintf('The actor identity repair could not be planned: %s', $failure->getMessage()), previous: $failure); + } + + if ($paths !== [$actor->path]) { + throw new RecordRefusal(sprintf( + 'Freezing this id also needs %d other file(s) repaired at once. Repair actor identities and references from Database > Actors in the terminal editor.', + max(0, count($paths) - 1), + )); + } + } + + /** + * @param list $frame + * @throws RecordRefusal + */ + private static function refuseFrame(array $frame): void + { + if ($frame !== []) { + throw new RecordRefusal('Actors have no command frames.'); + } + } +} diff --git a/src/Database/DatabaseCategory.php b/src/Database/DatabaseCategory.php new file mode 100644 index 0000000..0284ecd --- /dev/null +++ b/src/Database/DatabaseCategory.php @@ -0,0 +1,109 @@ + The records' labels, as the list shows them. */ + public function getRecordLabels(): array; + + /** + * A record's rows in a frame of its commands ([] for the record itself), + * as the terminal editor's record pane builds them. + * + * @param list $frame + * @return array> + * @throws RecordRefusal When the record or frame is gone. + */ + public function getRecordRows(int $index, array $frame): array; + + /** + * How a frame reads, or null at the record itself. + * + * @param list $frame + */ + public function describeFrame(array $frame): ?string; + + /** + * What an entry of the list an add with no row goes to is called, or + * null when there is none. + * + * @param list $frame + */ + public function getListNoun(array $frame): ?string; + + /** + * The item a row belongs to, or null when it belongs to none. + * + * @param list $frame + */ + public function locateItem(int $index, array $frame, string $fieldId): ?RecordItem; + + /** + * @param list $frame + * @throws RecordRefusal When the record is gone or read-only, or the value is refused. + */ + public function applyField(int $index, array $frame, string $fieldId, string $value, string $label): RecordChange; + + /** + * @param list $frame + * @throws RecordRefusal When the record is gone or read-only, or the row's item cannot take it. + */ + public function addItem(int $index, array $frame, ?string $fieldId, bool $child): RecordChange; + + /** + * @param list $frame + * @throws RecordRefusal When the record is gone or read-only. + */ + public function removeItem(int $index, array $frame, string $fieldId): RecordChange; + + /** @throws RecordRefusal When the category takes no new records. */ + public function createRecord(): RecordChange; + + /** @throws RecordRefusal When the record cannot be duplicated. */ + public function duplicateRecord(int $index): RecordChange; + + /** @throws RecordRefusal When the record cannot be deleted. */ + public function deleteRecord(int $index): RecordChange; + + /** + * @param int $step -1 for up, 1 for down. + * @throws RecordRefusal When the record is gone or the file would not keep the order. + */ + public function moveRecord(int $index, int $step): RecordChange; + + /** @return list The files a save would overwrite. */ + public function getBackupPaths(): array; + + /** @throws RecordRefusal When the source cannot take the save, with why. */ + public function save(): void; +} diff --git a/src/Database/RecordCategory.php b/src/Database/RecordCategory.php new file mode 100644 index 0000000..5ae21af --- /dev/null +++ b/src/Database/RecordCategory.php @@ -0,0 +1,143 @@ +key = $database->schema->key; + } + + public function isEditable(): bool + { + return $this->database->isEditable(); + } + + public function getReadOnlyReason(): ?string + { + return $this->database->getReadOnlyReason(); + } + + public function isDirty(): bool + { + return $this->database->isDirty(); + } + + public function supportsRecordCreation(): bool + { + return $this->database->supportsRecordCreation(); + } + + public function supportsRecordDuplication(): bool + { + return $this->database->duplicateRecordSupported(); + } + + public function supportsRecordDeletion(): bool + { + return $this->database->supportsRecordDeletion(); + } + + public function supportsDurableReorder(): bool + { + return $this->database->supportsDurableReorder(); + } + + public function getRecordLabels(): array + { + return array_values(array_map('strval', $this->database->getEntryLabels())); + } + + public function getRecordRows(int $index, array $frame): array + { + if ($this->database->getRecordByIndex($index) === null) { + throw new RecordRefusal(sprintf('%s has no record %d.', $this->key, $index)); + } + if ($frame !== [] && (! $this->database->hasCommandFrames() || $this->database->getFrameCommands($index, $frame) === null)) { + throw new RecordRefusal(sprintf('%s is no longer there; read the record again.', $this->database->describeFramePath($frame))); + } + + return $this->database->getFrameSettingsFields($index, $frame); + } + + public function describeFrame(array $frame): ?string + { + return $frame === [] ? null : $this->database->describeFramePath($frame); + } + + public function getListNoun(array $frame): ?string + { + return $this->database->getFrameSubList($frame)?->singular; + } + + public function locateItem(int $index, array $frame, string $fieldId): ?RecordItem + { + return $this->database->locateItem($index, $frame, $fieldId); + } + + public function applyField(int $index, array $frame, string $fieldId, string $value, string $label): RecordChange + { + return $this->authoring->applyField($this->database, $index, $frame, $fieldId, $value, $label); + } + + public function addItem(int $index, array $frame, ?string $fieldId, bool $child): RecordChange + { + return $this->authoring->addItem($this->database, $index, $frame, $fieldId, $child); + } + + public function removeItem(int $index, array $frame, string $fieldId): RecordChange + { + return $this->authoring->removeItem($this->database, $index, $frame, $fieldId); + } + + public function createRecord(): RecordChange + { + return $this->authoring->createRecord($this->database); + } + + public function duplicateRecord(int $index): RecordChange + { + return $this->authoring->duplicateRecord($this->database, $index); + } + + public function deleteRecord(int $index): RecordChange + { + return $this->authoring->deleteRecord($this->database, $index); + } + + public function moveRecord(int $index, int $step): RecordChange + { + return $this->authoring->moveRecord($this->database, $index, $step); + } + + public function getBackupPaths(): array + { + return array_values($this->database->getBackupPaths()); + } + + public function save(): void + { + if (! $this->database->isEditable()) { + throw new RecordRefusal(sprintf('Read-only: %s.', $this->database->getReadOnlyReason() ?? 'this category cannot be written')); + } + + try { + $this->database->save(); + } catch (SourceIdentityConflict|SourcePreservationRefusal $refusal) { + throw new RecordRefusal($refusal->getMessage(), previous: $refusal); + } + } +} diff --git a/src/Editor.php b/src/Editor.php index 0b19fd5..f127a39 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -48,6 +48,7 @@ use Ichiloto\Editor\Database\ElementAffinityCodec; use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Database\SummonAssignmentDiagnostics; +use Ichiloto\Editor\Actors\ActorAuthoring; use Ichiloto\Editor\Database\RecordSubList; use Ichiloto\Editor\Database\ReferencePicker; use Ichiloto\Editor\Debug\Debug; @@ -521,53 +522,12 @@ final class Editor */ private const string NPC_SELECT_FIELD = '__npc_select'; - /** - * The row that chooses which natural variant the actor rows edit. It is - * a view of the pane, not a value the project stores. - */ - private const string ACTOR_VARIANT_FIELD = '__actor_variant'; - - /** - * @var array Which variant each actor's rows are editing. - */ - private array $actorVariantSelections = []; - - /** - * The row that chooses which permanent growth the preview assumes the - * party has earned. Earned growth lives in a save, not in a project, so - * this is a fixture for looking at and nothing the editor writes. - */ - private const string ACTOR_GROWTH_FIELD = '__actor_growth'; + /** How actors are authored: their rows, their edits, and what their panes show. */ + private readonly ActorAuthoring $actorAuthoring; /** * The picker row that clears a map's background music. */ - /** - * The row that chooses which kind of slot the Optimize preview fills. - */ - private const string ACTOR_OPTIMIZE_SLOT_FIELD = '__actor_optimize_slot'; - - /** - * What the growth row reads when the preview assumes nothing was earned. - */ - private const string NO_ASSUMED_GROWTH = '(none earned yet)'; - /** What the Attack Skill row reads when the actor uses the Engine's own attack. */ - private const string BUILT_IN_ATTACK = '(Built-in attack)'; - - /** - * What the growth row reads when the preview assumes all of it was. - */ - private const string ALL_ASSUMED_GROWTH = '(everything defined)'; - - /** - * @var array Which growth each actor's preview assumes. - */ - private array $actorGrowthSelections = []; - - /** - * @var array Which slot each actor's Optimize preview fills. - */ - private array $actorOptimizeSlots = []; /** * The Inspector row that assigns a stable id to an NPC authored without @@ -756,6 +716,7 @@ final class Editor public function __construct(private readonly string $projectRoot) { + $this->actorAuthoring = new ActorAuthoring(); $this->toasts = new ToastQueue(); $this->commandPalette = new CommandPalette(); $this->clipboard = new Clipboard(); @@ -6028,11 +5989,7 @@ private function confirmDatabaseEntryDeletion(): void $index = $pending['index']; $label = $pending['label']; $command = match ($pending['category']) { - self::DATABASE_CATEGORY_ACTORS => $this->buildDatabaseDeletionCommand( - sprintf('Delete actor %s', $label), - fn(): ?object => $workspace->actorDatabase->removeActor($index), - static fn(object $entry) => $workspace->actorDatabase->insertActor($index, $entry), - ), + self::DATABASE_CATEGORY_ACTORS => $this->actorAuthoring->deleteActor($workspace, $index)->command, self::DATABASE_CATEGORY_SKILLS => $this->buildDatabaseDeletionCommand( sprintf('Delete skill %s', $label), fn(): ?object => $workspace->skillDatabase->removeSkill($index), @@ -9246,7 +9203,11 @@ private function createDatabaseActor(): void return; } - $this->databaseSelectedActorIndex = $this->workspace->actorDatabase->addActor(); + $change = $this->actorAuthoring->createActor($this->workspace); + if ($change->command !== null) { + $this->recordCommand($change->command); + } + $this->databaseSelectedActorIndex = (int) $change->index; $this->databaseSelectedSettingIndex = 0; $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; $this->statusMessage = 'Created a new actor.'; @@ -9333,7 +9294,11 @@ private function getDatabaseSettingsFields(): array } if ($this->isActorsDatabaseSelected()) { - return $this->getDatabaseActorSettingsFields(); + $actor = $this->getSelectedActor(); + + return $actor instanceof ProjectActor && $this->workspace instanceof ProjectWorkspace + ? $this->actorAuthoring->describeFields($this->workspace, $actor) + : []; } if ($this->isSkillsDatabaseSelected()) { @@ -9371,455 +9336,6 @@ private function getDatabaseSettingsFields(): array } - /** - * Returns the editable settings fields for the selected actor. - * - * @return array> - */ - /** - * The rows that say which actor this is to a save. - * - * @param ProjectActor $actor The actor. - * @return array> The rows. - */ - private function getActorIdentityFields(ProjectActor $actor): array - { - $missingId = ! array_key_exists('id', $actor->getData()); - return [ - ['label' => 'Identity', 'value' => '', 'editable' => false, 'field' => ''], - [ - 'label' => 'Definition Id', - 'value' => $actor->hasDefinitionId() ? $actor->getDefinitionId() : '', - 'editable' => $missingId, - 'actorIdentityMigration' => $missingId ? $actor : null, - 'field' => 'id', - 'displayDefault' => $missingId - ? sprintf('Enter to freeze the current name (%s) as its permanent id', $actor->getName()) - : 'Malformed explicit id: correct the authored actor file; automatic replacement is not allowed.', - ], - ]; - } - - /** - * The rows for an actor's own nature: the adjustments it makes to its - * class baseline, and the named variants of that nature. - * - * While an actor declares variants the runtime reads the selected - * variant's adjustments and ignores the fixed ones, so the rows edit - * whichever set is actually in force. - * - * @param ProjectActor $actor The actor. - * @return array> The rows. - */ - private function actorNatureFields(ProjectActor $actor): array - { - $variants = $actor->getNaturalVariants(); - $selected = $this->selectedActorVariantId($actor); - $rows = [['label' => 'Nature', 'value' => '', 'editable' => false, 'field' => '']]; - - if ($variants !== []) { - $rows[] = [ - 'label' => 'Default Variant', - 'value' => (string) $actor->getDefaultNaturalVariantId(), - 'options' => array_keys($variants), - 'field' => 'defaultNaturalVariantId', - ]; - $rows[] = [ - 'label' => 'Editing Variant', - 'value' => $selected ?? '', - 'options' => array_keys($variants), - 'field' => self::ACTOR_VARIANT_FIELD, - ]; - } - - // Both layers are real: the runtime adds the selected variant on top - // of the fixed adjustments rather than replacing them, so both are - // shown and both are editable. - $inForce = $actor->getNaturalAdjustmentsFor($selected); - $fixed = $actor->getActorNaturalAdjustments(); - $rows = [...$rows, ...$this->actorAdjustmentRows( - $variants === [] ? 'Adjustments' : 'Fixed, always applied', - 'actorNaturalAdjustments', - $fixed, - )]; - - if ($variants !== [] && $selected !== null) { - $rows = [...$rows, ...$this->actorAdjustmentRows( - sprintf('Variant %s, added on top', $selected), - sprintf('naturalVariants.%s', $selected), - $variants[$selected] ?? [], - )]; - $rows[] = [ - 'label' => ' In force', - 'value' => $this->describeAdjustments($inForce), - 'editable' => false, - 'field' => '', - ]; - } - - return $rows; - } - - /** - * The rows for one layer of an actor's nature. - * - * @param string $heading What the layer is. - * @param string $prefix The payload path the rows write to. - * @param array $adjustments The layer's adjustments. - * @return array> The rows. - */ - private function actorAdjustmentRows(string $heading, string $prefix, array $adjustments): array - { - $rows = [['label' => ' ' . $heading, 'value' => '', 'editable' => false, 'field' => '']]; - - foreach (ActorStatPreview::statKeys() as $key) { - $amount = $adjustments[$key] ?? 0; - $rows[] = [ - 'label' => ' ' . ucfirst(strtolower((string) preg_replace('/(? (string) $amount, - 'control' => new InputControl(InputControlType::INTEGER, (string) $amount), - 'field' => $prefix . '.' . $key, - ]; - } - - return $rows; - } - - /** - * Describes what an actor's nature comes to once composed. - * - * @param array $adjustments The composed adjustments. - * @return string The description. - */ - private function describeAdjustments(array $adjustments): string - { - $parts = []; - - foreach ($adjustments as $key => $amount) { - if ($amount !== 0) { - $parts[] = sprintf('%+d %s', $amount, $key); - } - } - - return $parts === [] ? 'nothing adjusted' : implode(', ', $parts); - } - - /** - * The read-only rows showing what each stat actually comes to. - * - * @param ProjectActor $actor The actor. - * @return array> The rows. - */ - private function actorStatPreviewFields(ProjectActor $actor): array - { - if (! ActorStatPreview::isAvailable()) { - return []; - } - - $catalog = PermanentGrowthCatalog::fromProject($this->workspace->projectRoot); - $assumed = $this->assumedGrowthFor($actor); - $rows = [ - ['label' => 'Resolved Stats', 'value' => '', 'editable' => false, 'field' => ''], - [ - // Earned growth is save state. What a preview can do is - // assume some of it, say that it is assuming, and write - // nothing. - 'label' => ' Assumed Growth', - 'value' => $assumed, - 'options' => [ - self::NO_ASSUMED_GROWTH, - self::ALL_ASSUMED_GROWTH, - ...$catalog->ids(), - ], - 'field' => self::ACTOR_GROWTH_FIELD, - 'displayDefault' => 'assumed for this preview only; the party earns growth in play', - ], - ]; - - $permanent = match ($assumed) { - self::NO_ASSUMED_GROWTH => [], - self::ALL_ASSUMED_GROWTH => $catalog->totalsFor(), - default => $catalog->totalsFor([$assumed]), - }; - - foreach (ActorStatPreview::resolve($actor, $this->selectedActorVariantId($actor), $permanent) as $row) { - $rows[] = [ - 'label' => ' ' . $row['stat'], - 'value' => ActorStatPreview::describeRow($row), - 'editable' => false, - 'field' => '', - ]; - } - - return [...$rows, ...$this->actorOptimizeFields($actor)]; - } - - /** - * Returns which permanent growth this actor's preview assumes. - * - * @param ProjectActor $actor The actor. - * @return string The selection. - */ - private function assumedGrowthFor(ProjectActor $actor): string - { - return $this->actorGrowthSelections[$actor->getDefinitionId()] ?? self::NO_ASSUMED_GROWTH; - } - - /** - * Returns which slot this actor's Optimize preview fills. - * - * @param ProjectActor $actor The actor. - * @return string The semantic slot. - */ - private function optimizeSlotFor(ProjectActor $actor): string - { - $slots = EquipmentOptimizationPolicy::slotKeys(); - $selected = $this->actorOptimizeSlots[$actor->getDefinitionId()] ?? ''; - - return in_array($selected, $slots, true) ? $selected : ($slots[0] ?? 'weapon'); - } - - /** - * The read-only rows showing what Optimize would choose, and why. - * - * @param ProjectActor $actor The actor. - * @return array> The rows. - */ - private function actorOptimizeFields(ProjectActor $actor): array - { - if (! $this->workspace instanceof ProjectWorkspace || ! EquipmentOptimizationPolicy::isAvailable()) { - return []; - } - - $root = $this->workspace->projectRoot; - $slot = $this->optimizeSlotFor($actor); - $rows = [ - ['label' => 'Optimize Preview', 'value' => '', 'editable' => false, 'field' => ''], - [ - // Which policy is scoring is the first thing to know: a - // project that has declared none is not being scored by its - // own rules at all. - 'label' => ' Policy', - 'value' => EquipmentOptimizationPolicy::describeSource($root), - 'editable' => false, - 'field' => '', - ], - [ - 'label' => ' Slot', - 'value' => $slot, - 'options' => EquipmentOptimizationPolicy::slotKeys(), - 'field' => self::ACTOR_OPTIMIZE_SLOT_FIELD, - ], - ]; - $ranked = EquipmentOptimizationPolicy::rank($this->workspace, $actor, $slot); - - if ($ranked === []) { - $rows[] = [ - 'label' => ' (nothing)', - 'value' => 'no equipment this project has fits that slot', - 'editable' => false, - 'field' => '', - ]; - - return $rows; - } - - foreach ($ranked as $position => $candidate) { - $rows[] = [ - 'label' => sprintf(' %d. %s', $position + 1, $candidate['name']), - 'value' => sprintf( - '%d · %s', - $candidate['value'], - EquipmentOptimizationPolicy::describeRow($candidate), - ), - 'editable' => false, - 'field' => '', - ]; - } - - return $rows; - } - - /** - * Returns which natural variant the actor rows are editing. - * - * @param ProjectActor $actor The actor. - * @return string|null The variant id, or null when the actor has none. - */ - private function selectedActorVariantId(ProjectActor $actor): ?string - { - $variants = $actor->getNaturalVariants(); - - if ($variants === []) { - return null; - } - - $selected = $this->actorVariantSelections[$actor->getDefinitionId()] ?? null; - - return $selected !== null && isset($variants[$selected]) - ? $selected - : ($actor->getDefaultNaturalVariantId() ?? array_key_first($variants)); - } - - private function getDatabaseActorSettingsFields(): array - { - $actor = $this->getSelectedActor(); - - if (! $actor instanceof ProjectActor) { - return []; - } - - return [ - ...$this->getActorIdentityFields($actor), - [ - 'label' => 'Name', - 'value' => $actor->getName(), - 'control' => new InputControl(InputControlType::TEXT, $actor->getName()), - 'editable' => $actor->hasDefinitionId(), - 'field' => 'name', - ], - [ - 'label' => 'Description', - 'value' => $actor->getDescription(), - 'control' => new InputControl(InputControlType::TEXT, $actor->getDescription()), - 'field' => 'description', - ], - [ - // The character-class reference: a name from - // assets/Data/classes.php, written to the actor's - // data['class'] key (the engine's ClassStore hydrates it - // into a CharacterRole). ←/→ cycles the picker; Ctrl+G jumps - // to the class entry. - 'label' => 'Class', - 'value' => $actor->getClassName() === '' ? ProjectActor::CLASS_NONE : $actor->getClassName(), - 'options' => $this->getActorClassOptions(), - 'field' => 'class', - ], - [ - // The character's own weapon, part of who they are and with - // no stats: what their attack looks like when no weapon is - // equipped. An equipped weapon's type takes its place. - 'label' => 'Attack Style', - 'value' => $actor->getAttackStyle() === '' ? 'Unarmed' : $actor->getAttackStyle(), - 'options' => [ProjectActor::ATTACK_STYLE_UNARMED, ...array_map( - static fn(WeaponType $type): string => $type->value, WeaponType::cases())], - 'field' => 'attackStyle', - 'hint' => 'own weapon when none is equipped; no stats', - ], - [ - // The basic skill the Attack command uses; a battle-usable - // basic skill from the catalogue, or the Engine's own attack. - 'label' => 'Attack Skill', - 'value' => $actor->getAttackSkill() === '' ? self::BUILT_IN_ATTACK : $actor->getAttackSkill(), - 'reference' => 'attack_skills', - 'allowsNone' => true, - 'noneLabel' => self::BUILT_IN_ATTACK, - 'field' => 'attackSkill', - ], - ...$this->actorSummonFields($actor), - [ - 'label' => 'Level', - 'value' => (string) $actor->getLevel(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getLevel()), - 'field' => 'level', - ], - [ - 'label' => 'Current Exp', - 'value' => (string) $actor->getCurrentExp(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getCurrentExp()), - 'field' => 'currentExp', - ], - [ - 'label' => 'Current HP', - 'value' => (string) $actor->getStat('currentHp'), - 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getStat('currentHp')), - 'field' => 'currentHp', - ], - [ - 'label' => 'Current MP', - 'value' => (string) $actor->getStat('currentMp'), - 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getStat('currentMp')), - 'field' => 'currentMp', - ], - [ - 'label' => 'Current AP', - 'value' => (string) $actor->getStat('currentAp'), - 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getStat('currentAp')), - 'field' => 'currentAp', - ], - ...$this->actorNatureFields($actor), - ...$this->actorStatPreviewFields($actor), - ]; - } - - /** - * Returns the actor's starting summon assignments: a multi-pick over the - * project's summons, and one verdict row per assignment judged by the - * same rules the validator applies (existence, wielder eligibility, - * story locks, duplicates, exclusive tenancy across the cast). - * - * @return array> - */ - private function actorSummonFields(ProjectActor $actor): array - { - $assignments = $actor->getSummons(); - $list = is_array($assignments) ? array_values(array_filter(array_map(static fn(mixed $id): string => is_string($id) ? trim($id) : '', $assignments), static fn(string $id): bool => $id !== '')) : []; - $rows = [ - [ - 'label' => 'Summons', - 'value' => implode(', ', $list), - 'reference' => 'summons', - 'multi' => true, - 'noneLabel' => '(None)', - 'field' => 'summons', - ], - ]; - $diagnostics = SummonAssignmentDiagnostics::fromLibrary($this->workspace?->cutscenes); - $holders = []; - - foreach ($this->workspace?->actorDatabase->getActors() ?? [] as $other) { - if ($other === $actor) { - continue; - } - - foreach ((array) $other->getSummons() as $id) { - if (is_string($id) && trim($id) !== '') { - $holders[strtolower(trim($id))][] = $other->getName(); - } - } - } - - foreach ($diagnostics->forActor($actor->getName(), $actor->getClassName(), $assignments) as $row) { - $verdict = SummonAssignmentDiagnostics::describe($row); - $id = strtolower($row['id']); - - if ($row['problems'] === [] && $diagnostics->isExclusive($id) && isset($holders[$id])) { - $verdict = sprintf('✗ %s: exclusive, also held by %s.', $row['id'], implode(', ', $holders[$id])); - } - - $rows[] = ['label' => ' ' . $verdict, 'value' => '', 'editable' => false, 'field' => '']; - } - - return $rows; - } - - /** - * Returns the actor class picker options. - * - * The list is the project's own class names from - * `assets/Data/classes.php`, prefixed with the "none" sentinel that - * clears the reference. - * - * @return string[] - */ - private function getActorClassOptions(): array - { - return [ - ProjectActor::CLASS_NONE, - ...$this->workspace?->getRecordDatabase(self::DATABASE_CATEGORY_CLASSES)?->getEntryLabels() ?? [], - ]; - } - /** * Returns the editable settings fields for the selected skill. * @@ -9907,8 +9423,9 @@ private function beginDatabaseEdit(): void return; } - if (($field['actorIdentityMigration'] ?? null) instanceof ProjectActor) { - $this->openActorIdentityMigration($field['actorIdentityMigration']); + if ($this->isActorsDatabaseSelected() && is_string($field['action'] ?? null) && ($actor = $this->getSelectedActor()) instanceof ProjectActor) { + // An actor without an id: its row's edit is the one-time freeze. + $this->openActorIdentityMigration($actor); return; } @@ -10002,14 +9519,14 @@ private function confirmActorIdentityMigration(ProjectActor $actor, bool $confir return; } try { - $before = $actor->getData(); - ActorIdentityMigration::freezeCurrentName($this->workspace->actorDatabase, $actor); - $after = $actor->getData(); - $this->recordCommand(new GenericCommand( - 'Freeze actor identity', - static fn() => $actor->restoreData($after), - static fn() => $actor->restoreData($before), - )); + $index = array_search($actor, $this->workspace->actorDatabase->getActors(), true); + if (! is_int($index)) { + throw new RecordRefusal('The actor is no longer in the project database.'); + } + $change = $this->actorAuthoring->freezeIdentity($this->workspace, $index); + if ($change->command !== null) { + $this->recordCommand($change->command); + } $this->closeEventOptionDialog('Actor identity frozen. Save the project to write it.'); } catch (Throwable $failure) { $this->closeEventOptionDialog(''); @@ -11276,6 +10793,26 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) return; } + if ($this->isActorsDatabaseSelected()) { + if (! $this->workspace instanceof ProjectWorkspace) { + return; + } + + try { + $change = $this->actorAuthoring->applyField($this->workspace, $this->databaseSelectedActorIndex, $fieldId, $rawValue, (string) ($field['label'] ?? 'Actor field')); + } catch (RecordRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::ERROR); + + return; + } + + if ($change->command !== null) { + $this->recordCommand($change->command); + } + + return; + } + $control = $this->getDatabaseFieldControl($field); // Option values keep their authored case (the actor class picker // writes `Vanguard`); matching them is case-insensitive instead. @@ -11292,13 +10829,9 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) 'records' => $this->databaseSelectedRecordIndexes, ]; - $actor = $this->isActorsDatabaseSelected() ? $this->getSelectedActor() : null; - $actorBefore = $actor?->getData(); // A schema record's edit is one step only when it changed the record: // a value the record refused, or the value it already held, leaves none. - $schemaRecord = $actor === null - ? $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()) - : null; + $schemaRecord = $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()); $schemaBefore = $schemaRecord?->toArray(); $this->applyDatabaseFieldValue($fieldId, $rawValue); @@ -11306,18 +10839,6 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) return; } - if ($actor !== null && $actorBefore !== null) { - $actorAfter = $actor->getData(); - if ($actorBefore !== $actorAfter) { - $this->recordCommand(new GenericCommand( - sprintf('%s edit', $field['label'] ?? 'Actor field'), - static fn() => $actor->restoreData($actorAfter), - static fn() => $actor->restoreData($actorBefore), - )); - } - return; - } - if ($oldRawValue === $rawValue) { return; } @@ -11394,30 +10915,6 @@ private function applyDatabaseFieldValueAt(array $identity, string $field, strin } } - /** - * Returns an edited actor value as the field's own type. - * - * Most actor numbers are quantities that cannot go below zero, but an - * actor's nature is an adjustment: being slower than the class baseline - * is a legitimate thing to author, so those keep their sign. - * - * @param string $field The field identifier. - * @param string $rawValue The raw edited value. - * @return string|int The coerced value. - */ - private function coerceActorFieldValue(string $field, string $rawValue): string|int - { - if (in_array($field, ['name', 'description', 'class', 'attackStyle', 'attackSkill', 'id', 'defaultNaturalVariantId', 'summons'], true)) { - return trim($rawValue); - } - - if (str_starts_with($field, 'actorNaturalAdjustments.') || str_starts_with($field, 'naturalVariants.')) { - return intval(trim($rawValue)); - } - - return max(0, intval($rawValue)); - } - /** * Applies one database settings value. * @@ -11431,42 +10928,6 @@ private function applyDatabaseFieldValue(string $field, string $rawValue): void return; } - if ($this->isActorsDatabaseSelected() && in_array($field, [self::ACTOR_GROWTH_FIELD, self::ACTOR_OPTIMIZE_SLOT_FIELD], true)) { - // Both are assumptions the preview makes, not values the project - // stores: neither marks anything dirty and neither is written. - $actor = $this->getSelectedActor(); - - if ($actor instanceof ProjectActor && $field === self::ACTOR_GROWTH_FIELD) { - $this->actorGrowthSelections[$actor->getDefinitionId()] = trim($rawValue); - } elseif ($actor instanceof ProjectActor) { - $this->actorOptimizeSlots[$actor->getDefinitionId()] = trim($rawValue); - } - - return; - } - - if ($this->isActorsDatabaseSelected() && $field === self::ACTOR_VARIANT_FIELD) { - // Which variant the rows edit is a choice about the pane, not a - // value the project stores. - $actor = $this->getSelectedActor(); - - if ($actor instanceof ProjectActor) { - $this->actorVariantSelections[$actor->getDefinitionId()] = trim($rawValue); - } - - return; - } - - if ($this->isActorsDatabaseSelected()) { - $this->workspace->actorDatabase->setField( - $this->databaseSelectedActorIndex, - $field, - $this->coerceActorFieldValue($field, $rawValue), - ); - - return; - } - if ($this->isSkillsDatabaseSelected()) { $value = in_array($field, ["cost", "cooldown", "invocationSpeed", "invocationAccuracy", "invocationRepeat", "invocationApGain"], true) ? max(0, intval($rawValue)) diff --git a/src/ProjectActorDatabase.php b/src/ProjectActorDatabase.php index 809bfec..ae186d3 100644 --- a/src/ProjectActorDatabase.php +++ b/src/ProjectActorDatabase.php @@ -106,6 +106,22 @@ public function isDirty(): bool return false; } + /** + * Returns a key that changes whenever an actor is added, removed or + * edited, so a caller can tell whether a step touched the actors + * without reading them. + * + * @return string The key, exact rather than summed: a removal and an + * edit never cancel out to the same key. + */ + public function getContentVersion(): string + { + return implode(',', array_map( + static fn(ProjectActor $actor): string => spl_object_id($actor) . ':' . $actor->getContentVersion(), + $this->getActors(), + )) . '|' . implode(',', $this->pendingDeletions); + } + /** * Returns the actor at the requested index. * diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index c7eb536..f0d0f02 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -9,17 +9,18 @@ use Ichiloto\Editor\Canvas\CanvasEditor; use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\Canvas\PieceRole; -use Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal; use Ichiloto\Editor\Database\ConditionCodec; use Ichiloto\Editor\Database\ConditionEditor; use Ichiloto\Editor\Database\DatabaseCatalog; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\RecordItem; -use Ichiloto\Editor\Database\RecordAuthoring; use Ichiloto\Editor\Database\RecordChange; use Ichiloto\Editor\Database\RecordRefusal; use Ichiloto\Editor\Database\ReferenceCatalog; -use Ichiloto\Editor\Database\SourceIdentityConflict; +use Ichiloto\Editor\Database\DatabaseCategory; +use Ichiloto\Editor\Database\RecordCategory; +use Ichiloto\Editor\Actors\ActorAuthoring; +use Ichiloto\Editor\Actors\ActorCategory; use Ichiloto\Editor\Database\WorldWriteCodec; use Ichiloto\Editor\Database\WorldWriteEditor; use Ichiloto\Editor\Events\EventAuthoring; @@ -79,12 +80,16 @@ final class EditorSession /** The playtest running in the background, or the last one, to report how it ended. */ private ?PlaytestRun $playtest = null; + + /** How actors are authored, with what this editor's actor panes show. */ + private readonly ActorAuthoring $actorAuthoring; private function __construct( ProjectWorkspace $workspace, private readonly CommandHistory $history, private readonly BackupWriter $backups, ) { $this->workspace = $workspace; + $this->actorAuthoring = new ActorAuthoring(); } /** Opens the project at a root, as the terminal editor opens it. */ @@ -1216,16 +1221,15 @@ private static function describeCanvasImage(CanvasImage $image): array /** * A database category's records, as its list shows them, whether they * can be edited and have unsaved changes, and which record operations - * the category takes. The bespoke categories (actors, classes, skills, - * quests, system) still build their rows in the terminal editor, so - * they are refused here until those builders are shared. + * the category takes. A category whose rows only the terminal editor + * builds yet is refused. * * @return array{category: string, editable: bool, readOnly: ?string, dirty: bool, canCreate: bool, canDuplicate: bool, canDelete: bool, canReorder: bool, records: list} * @throws SessionRefusal When the category is unknown or not yet served. */ public function listDatabaseRecords(string $category): array { - $database = $this->requireRecordDatabase($category); + $database = $this->requireCategory($category); return [ 'category' => $category, @@ -1233,11 +1237,11 @@ public function listDatabaseRecords(string $category): array 'readOnly' => $database->getReadOnlyReason(), 'dirty' => $database->isDirty(), 'canCreate' => $database->supportsRecordCreation(), - 'canDuplicate' => $database->duplicateRecordSupported(), + 'canDuplicate' => $database->supportsRecordDuplication(), 'canDelete' => $database->supportsRecordDeletion(), // Only where the file keeps the order; a move elsewhere is refused with why. 'canReorder' => $database->supportsDurableReorder(), - 'records' => self::listRecordLabels($database), + 'records' => $database->getRecordLabels(), ]; } @@ -1260,7 +1264,7 @@ public function listDatabaseRecords(string $category): array */ public function readDatabaseRecord(string $category, int $index, array $frame = []): array { - $database = $this->requireRecordDatabase($category); + $database = $this->requireCategory($category); $frame = self::requireFrame($frame, 'database.record'); $fields = $this->collectRecordFields($database, $index, $frame); @@ -1268,12 +1272,12 @@ public function readDatabaseRecord(string $category, int $index, array $frame = 'category' => $category, 'index' => $index, 'frame' => $frame, - 'frameLabel' => $frame === [] ? null : $database->describeFramePath($frame), + 'frameLabel' => $database->describeFrame($frame), 'editable' => $database->isEditable(), 'readOnly' => $database->getReadOnlyReason(), // What an entry of the list an add with no row goes to is called: // the record's own list, or the open frame's. - 'listNoun' => $database->getFrameSubList($frame)?->singular, + 'listNoun' => $database->getListNoun($frame), // A row that names a field keeps its key whether or not it can be // edited; a read-only one is an `info` row the edit refuses. 'rows' => array_map(static fn(array $field): array => self::describeRecordRow( @@ -1297,7 +1301,7 @@ public function readDatabaseRecord(string $category, int $index, array $frame = */ public function applyDatabaseRecord(string $category, int $index, array $key, string $value): array { - $database = $this->requireRecordDatabase($category); + $database = $this->requireCategory($category); $frame = self::requireFrame($key['frame'] ?? [], 'database.record'); $fieldId = $key['field'] ?? null; // Field ids are unique within a frame, so the field and frame name the row. @@ -1311,11 +1315,11 @@ public function applyDatabaseRecord(string $category, int $index, array $key, st throw new SessionRefusal(sprintf('%s cannot be edited here.', trim((string) ($field['label'] ?? 'That row')))); } - $change = $this->changeRecord(static fn(RecordAuthoring $authoring): RecordChange => $authoring->applyField( - $database, $index, $frame, $fieldId, $value, (string) ($field['label'] ?? 'Database field'), + $change = $this->changeRecord(static fn(): RecordChange => $database->applyField( + $index, $frame, $fieldId, $value, (string) ($field['label'] ?? 'Database field'), )); - return array_filter(['changed' => $change->command !== null, 'records' => self::listRecordLabels($database), 'note' => $change->note], + return array_filter(['changed' => $change->command !== null, 'records' => $database->getRecordLabels(), 'note' => $change->note], static fn(mixed $value): bool => $value !== null); } @@ -1331,7 +1335,7 @@ public function applyDatabaseRecord(string $category, int $index, array $key, st */ public function addDatabaseItem(string $category, int $index, array $key, bool $child = false): array { - $database = $this->requireRecordDatabase($category); + $database = $this->requireCategory($category); $frame = self::requireFrame($key['frame'] ?? [], 'database.record'); $fieldId = $key['field'] ?? null; @@ -1339,9 +1343,9 @@ public function addDatabaseItem(string $category, int $index, array $key, bool $ throw new SessionRefusal('A row key names its field as a string, as database.record gave it.'); } - $change = $this->changeRecord(static fn(RecordAuthoring $authoring): RecordChange => $authoring->addItem($database, $index, $frame, $fieldId, $child)); + $change = $this->changeRecord(static fn(): RecordChange => $database->addItem($index, $frame, $fieldId, $child)); - return ['changed' => $change->command !== null, 'records' => self::listRecordLabels($database)]; + return ['changed' => $change->command !== null, 'records' => $database->getRecordLabels()]; } /** @@ -1356,7 +1360,7 @@ public function addDatabaseItem(string $category, int $index, array $key, bool $ */ public function removeDatabaseItem(string $category, int $index, array $key): array { - $database = $this->requireRecordDatabase($category); + $database = $this->requireCategory($category); $frame = self::requireFrame($key['frame'] ?? [], 'database.record'); $fieldId = $key['field'] ?? null; @@ -1364,9 +1368,9 @@ public function removeDatabaseItem(string $category, int $index, array $key): ar throw new SessionRefusal('Name the row whose item to remove by its key, as database.record gave it.'); } - $change = $this->changeRecord(static fn(RecordAuthoring $authoring): RecordChange => $authoring->removeItem($database, $index, $frame, $fieldId)); + $change = $this->changeRecord(static fn(): RecordChange => $database->removeItem($index, $frame, $fieldId)); - return ['changed' => $change->command !== null, 'records' => self::listRecordLabels($database)]; + return ['changed' => $change->command !== null, 'records' => $database->getRecordLabels()]; } /** @@ -1377,10 +1381,10 @@ public function removeDatabaseItem(string $category, int $index, array $key): ar */ public function createDatabaseRecord(string $category): array { - $database = $this->requireRecordDatabase($category); - $change = $this->changeRecord(static fn(RecordAuthoring $authoring): RecordChange => $authoring->createRecord($database)); + $database = $this->requireCategory($category); + $change = $this->changeRecord(static fn(): RecordChange => $database->createRecord()); - return ['index' => (int) $change->index, 'records' => self::listRecordLabels($database)]; + return ['index' => (int) $change->index, 'records' => $database->getRecordLabels()]; } /** @@ -1391,10 +1395,10 @@ public function createDatabaseRecord(string $category): array */ public function duplicateDatabaseRecord(string $category, int $index): array { - $database = $this->requireRecordDatabase($category); - $change = $this->changeRecord(static fn(RecordAuthoring $authoring): RecordChange => $authoring->duplicateRecord($database, $index)); + $database = $this->requireCategory($category); + $change = $this->changeRecord(static fn(): RecordChange => $database->duplicateRecord($index)); - return ['index' => (int) $change->index, 'records' => self::listRecordLabels($database)]; + return ['index' => (int) $change->index, 'records' => $database->getRecordLabels()]; } /** @@ -1405,10 +1409,10 @@ public function duplicateDatabaseRecord(string $category, int $index): array */ public function deleteDatabaseRecord(string $category, int $index): array { - $database = $this->requireRecordDatabase($category); - $change = $this->changeRecord(static fn(RecordAuthoring $authoring): RecordChange => $authoring->deleteRecord($database, $index)); + $database = $this->requireCategory($category); + $change = $this->changeRecord(static fn(): RecordChange => $database->deleteRecord($index)); - return ['index' => $change->index, 'records' => self::listRecordLabels($database)]; + return ['index' => $change->index, 'records' => $database->getRecordLabels()]; } /** @@ -1421,15 +1425,15 @@ public function deleteDatabaseRecord(string $category, int $index): array */ public function moveDatabaseRecord(string $category, int $index, string $direction): array { - $database = $this->requireRecordDatabase($category); + $database = $this->requireCategory($category); $step = match ($direction) { 'up' => -1, 'down' => 1, default => throw new SessionRefusal(sprintf('A record moves "up" or "down", not "%s".', $direction)), }; - $change = $this->changeRecord(static fn(RecordAuthoring $authoring): RecordChange => $authoring->moveRecord($database, $index, $step)); + $change = $this->changeRecord(static fn(): RecordChange => $database->moveRecord($index, $step)); - return ['index' => (int) $change->index, 'changed' => $change->command !== null, 'records' => self::listRecordLabels($database)]; + return ['index' => (int) $change->index, 'changed' => $change->command !== null, 'records' => $database->getRecordLabels()]; } /** @@ -1443,7 +1447,7 @@ public function moveDatabaseRecord(string $category, int $index, string $directi */ public function saveDatabase(string $category): array { - $database = $this->requireRecordDatabase($category); + $database = $this->requireCategory($category); if (! $database->isEditable()) { throw new SessionRefusal(sprintf('Read-only: %s.', $database->getReadOnlyReason() ?? 'this category cannot be written')); } @@ -1455,7 +1459,7 @@ public function saveDatabase(string $category): array try { $database->save(); - } catch (SourceIdentityConflict|SourcePreservationRefusal $refusal) { + } catch (RecordRefusal $refusal) { throw new SessionRefusal($refusal->getMessage(), previous: $refusal); } @@ -1465,13 +1469,13 @@ public function saveDatabase(string $category): array /** * Makes one record change, records it, and turns a refusal into the session's. * - * @param callable(RecordAuthoring): RecordChange $change + * @param callable(): RecordChange $change * @throws SessionRefusal */ private function changeRecord(callable $change): RecordChange { try { - $applied = $change(new RecordAuthoring()); + $applied = $change(); } catch (RecordRefusal $refusal) { throw new SessionRefusal($refusal->getMessage(), previous: $refusal); } @@ -1489,22 +1493,13 @@ private function changeRecord(callable $change): RecordChange * @return array> * @throws SessionRefusal */ - private function collectRecordFields(ProjectRecordDatabase $database, int $index, array $frame): array + private function collectRecordFields(DatabaseCategory $database, int $index, array $frame): array { - if ($database->getRecordByIndex($index) === null) { - throw new SessionRefusal(sprintf('%s has no record %d.', $database->schema->key, $index)); - } - if ($frame !== [] && (! $database->hasCommandFrames() || $database->getFrameCommands($index, $frame) === null)) { - throw new SessionRefusal(sprintf('%s is no longer there; read the record again.', $database->describeFramePath($frame))); + try { + return $database->getRecordRows($index, $frame); + } catch (RecordRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); } - - return $database->getFrameSettingsFields($index, $frame); - } - - /** @return list A category's record labels, as its list shows them. */ - private static function listRecordLabels(ProjectRecordDatabase $database): array - { - return array_values(array_map('strval', $database->getEntryLabels())); } /** @@ -1518,7 +1513,7 @@ private static function listRecordLabels(ProjectRecordDatabase $database): array * @param string|null $target The key's target, or null for a row no edit applies through. * @return array */ - private static function describeRecordRow(ProjectRecordDatabase $records, int $index, array $frame, array $field, ?string $target): array + private static function describeRecordRow(ProjectRecordDatabase|DatabaseCategory $records, int $index, array $frame, array $field, ?string $target): array { $row = self::describeRow([...$field, 'target' => $target]); @@ -1991,7 +1986,7 @@ public function saveAll(): array private function traverseHistory(callable $step): array { $before = array_map(static fn(ProjectMap $map): int => $map->stateVersion(), $this->workspace->maps); - $databases = array_map(static fn(ProjectRecordDatabase $database): string => $database->getContentVersion(), $this->workspace->recordDatabases); + $databases = $this->listDatabaseVersions(); $command = $step(); $changed = $revisions = []; foreach ($this->workspace->maps as $index => $map) { @@ -2000,12 +1995,20 @@ private function traverseHistory(callable $step): array $revisions[$map->mapId] = $map->stateVersion(); } } - $changedDatabases = array_keys(array_filter($this->workspace->recordDatabases, - static fn(ProjectRecordDatabase $database, string $key): bool => $database->getContentVersion() !== $databases[$key], ARRAY_FILTER_USE_BOTH)); + $changedDatabases = array_keys(array_diff_assoc($this->listDatabaseVersions(), $databases)); return ['label' => $command?->label, 'maps' => $changed, 'revisions' => $revisions, 'databases' => array_values(array_map('strval', $changedDatabases))]; } + /** @return array Each category the session edits, by key, with its content version. */ + private function listDatabaseVersions(): array + { + return [ + 'actors' => $this->workspace->actorDatabase->getContentVersion(), + ...array_map(static fn(ProjectRecordDatabase $database): string => $database->getContentVersion(), $this->workspace->recordDatabases), + ]; + } + /** @return array> */ private function collectInspectorFields(ProjectMap $map, ?string $marker): array { @@ -2032,6 +2035,9 @@ private static function describeRow(array $field): array $choices = MapInspector::findChoiceValues($field); $kind = match (true) { ($field['editable'] ?? true) === false, $target === null => 'info', + // A row whose edit is one action (freezing an actor's identity), + // made by applying it with no value. + is_string($field['action'] ?? null) => 'action', ($field['mapConditions'] ?? false) === true, ($field['conditions'] ?? false) === true => 'conditions', ($field['worldWrites'] ?? false) === true => 'writes', ($field['destination'] ?? false) === true => 'destination', @@ -2073,6 +2079,9 @@ private static function describeRow(array $field): array 'destination' => 'maps', default => null, }, + // How the choice of nothing reads, where the row says (the Engine's own attack). + 'noneLabel' => $kind === 'reference' && is_string($field['noneLabel'] ?? null) ? $field['noneLabel'] : null, + 'action' => $kind === 'action' ? $field['action'] : null, // A list picked a member at a time: each pick toggles one member. 'multi' => $kind === 'reference' && ($field['multi'] ?? false) === true ? true : null, 'list' => is_array($list) && $target !== null ? ['index' => (int) ($list['index'] ?? 0)] : null, @@ -2124,6 +2133,22 @@ private function requireRecordDatabase(string $category): ProjectRecordDatabase ?? throw new SessionRefusal(sprintf('The %s database is edited in the terminal editor for now.', $category)); } + /** + * A Database category as this session edits it: actors through the + * actor service, with what this editor's actor panes show, and every + * schema-driven category through the shared record rules. + * + * @throws SessionRefusal When the category is unknown or not yet served. + */ + private function requireCategory(string $category): DatabaseCategory + { + if ($category === 'actors') { + return new ActorCategory($this->workspace, $this->actorAuthoring); + } + + return new RecordCategory($this->requireRecordDatabase($category)); + } + /** @return array */ private function getMapsById(): array { diff --git a/tests/Unit/ActorClassFieldTest.php b/tests/Unit/ActorClassFieldTest.php index c3dd832..b7ade79 100644 --- a/tests/Unit/ActorClassFieldTest.php +++ b/tests/Unit/ActorClassFieldTest.php @@ -30,9 +30,7 @@ function actorClassEditor(string $root): Editor try { $editor = actorClassEditor($root); - expect(callEditorMethod($editor, 'getActorClassOptions'))->toBe(['none', 'Vanguard', 'Oracle']); - - $fields = callEditorMethod($editor, 'getDatabaseActorSettingsFields'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); $classField = null; foreach ($fields as $field) { @@ -58,7 +56,7 @@ function actorClassEditor(string $root): Editor /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - $fields = callEditorMethod($editor, 'getDatabaseActorSettingsFields'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); $classIndex = array_search('class', array_column($fields, 'field'), true); setEditorProperty($editor, 'databaseSelectedSettingIndex', $classIndex); @@ -92,7 +90,7 @@ function actorClassEditor(string $root): Editor /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - $fields = callEditorMethod($editor, 'getDatabaseActorSettingsFields'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); setEditorProperty($editor, 'databaseSelectedSettingIndex', array_search('class', array_column($fields, 'field'), true)); callEditorMethod($editor, 'dispatchInput', "\033[C"); callEditorMethod($editor, 'dispatchInput', "\x13"); diff --git a/tests/Unit/ActorIdentityMigrationUiTest.php b/tests/Unit/ActorIdentityMigrationUiTest.php index b7ae9af..e81c590 100644 --- a/tests/Unit/ActorIdentityMigrationUiTest.php +++ b/tests/Unit/ActorIdentityMigrationUiTest.php @@ -15,7 +15,7 @@ $editor = deletionEditor($root); openDatabaseCategory($editor, 'actors'); setEditorProperty($editor, 'databaseFocus', 'database_settings'); - $fields = callEditorMethod($editor, 'getDatabaseActorSettingsFields'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); $index = array_search('id', array_column($fields, 'field'), true); setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); $workspace = getEditorProperty($editor, 'workspace'); @@ -151,11 +151,11 @@ $editor = deletionEditor($root); openDatabaseCategory($editor, 'actors'); setEditorProperty($editor, 'databaseFocus', 'database_settings'); - $fields = callEditorMethod($editor, 'getDatabaseActorSettingsFields'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); $index = array_search('id', array_column($fields, 'field'), true); expect($fields[$index]['editable'])->toBeFalse() ->and($fields[$index]['displayDefault'])->toContain('Malformed explicit id') - ->and($fields[$index]['actorIdentityMigration'])->toBeNull(); + ->and($fields[$index]['action'])->toBeNull(); setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); callEditorMethod($editor, 'dispatchInput', "\r"); expect(getEditorProperty($editor, 'isDatabaseEditing'))->toBeFalse() diff --git a/tests/Unit/ActorNaturalCompositionTest.php b/tests/Unit/ActorNaturalCompositionTest.php index b0581d0..63d65d1 100644 --- a/tests/Unit/ActorNaturalCompositionTest.php +++ b/tests/Unit/ActorNaturalCompositionTest.php @@ -150,7 +150,7 @@ function actorWithNature(array $nature): array callEditorMethod($editor, 'openDatabaseAtCategory', 'actors'); $rowFor = static function (Editor $editor, string $field): array { - foreach (callEditorMethod($editor, 'getDatabaseActorSettingsFields') as $row) { + foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $row) { if (($row['field'] ?? null) === $field) { return $row; } @@ -158,7 +158,7 @@ function actorWithNature(array $nature): array return []; }; - $labels = array_column(callEditorMethod($editor, 'getDatabaseActorSettingsFields'), 'label'); + $labels = array_column(callEditorMethod($editor, 'getDatabaseSettingsFields'), 'label'); // Both layers are on the pane, named for what they do, with what they // come to beside them. @@ -168,7 +168,7 @@ function actorWithNature(array $nature): array ->and($rowFor($editor, 'actorNaturalAdjustments.attack')['value'])->toBe('4') ->and($rowFor($editor, 'naturalVariants.awakened.attack')['value'])->toBe('12'); - foreach (callEditorMethod($editor, 'getDatabaseActorSettingsFields') as $row) { + foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $row) { if (($row['label'] ?? null) === ' In force') { expect($row['value'])->toContain('+16 attack'); } @@ -179,7 +179,7 @@ function actorWithNature(array $nature): array // Editing the fixed layer moves what is in force, without touching the // variant that is added to it. - callEditorMethod($editor, 'applyDatabaseFieldValue', 'actorNaturalAdjustments.attack', '10'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', array_values(array_filter(callEditorMethod($editor, 'getDatabaseSettingsFields'), static fn(array $row): bool => ($row['field'] ?? null) === 'actorNaturalAdjustments.attack'))[0], '10'); /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); diff --git a/tests/Unit/ActorNaturalStatsTest.php b/tests/Unit/ActorNaturalStatsTest.php index 0ff9b0b..a9e99f6 100644 --- a/tests/Unit/ActorNaturalStatsTest.php +++ b/tests/Unit/ActorNaturalStatsTest.php @@ -42,7 +42,7 @@ function actorEditorOn(string $root): Editor */ function actorRow(Editor $editor, string $fieldId): array { - foreach (callEditorMethod($editor, 'getDatabaseActorSettingsFields') as $field) { + foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $field) { if (($field['field'] ?? null) === $fieldId) { return $field; } @@ -266,7 +266,7 @@ function actorRow(Editor $editor, string $fieldId): array $database->save(); $editor = actorEditorOn($root); - $labels = array_column(callEditorMethod($editor, 'getDatabaseActorSettingsFields'), 'label'); + $labels = array_column(callEditorMethod($editor, 'getDatabaseSettingsFields'), 'label'); expect($labels)->toContain('Identity') ->toContain('Nature') @@ -276,7 +276,7 @@ function actorRow(Editor $editor, string $fieldId): array // The rows edit the default variant until another is chosen. expect(actorRow($editor, 'naturalVariants.awakened.attack')['value'])->toBe('12'); - callEditorMethod($editor, 'applyDatabaseFieldValue', '__actor_variant', 'dormant'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', actorRow($editor, '__actor_variant'), 'dormant'); expect(actorRow($editor, 'naturalVariants.dormant.attack')['value'])->toBe('1'); @@ -287,6 +287,6 @@ function actorRow(Editor $editor, string $fieldId): array expect($workspace->actorDatabase->isDirty())->toBeFalse(); // Editing through the pane writes into that variant. - callEditorMethod($editor, 'applyDatabaseFieldValue', 'naturalVariants.dormant.attack', '7'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', actorRow($editor, 'naturalVariants.dormant.attack'), '7'); expect($workspace->actorDatabase->getActors()[0]->getNaturalVariants()['dormant'])->toBe(['attack' => 7]); }); diff --git a/tests/Unit/AnimationReferenceTest.php b/tests/Unit/AnimationReferenceTest.php index 42256cb..6b48eec 100644 --- a/tests/Unit/AnimationReferenceTest.php +++ b/tests/Unit/AnimationReferenceTest.php @@ -178,7 +178,7 @@ $editor = deletionEditor($root); openDatabaseCategory($editor, 'actors'); setEditorProperty($editor, 'databaseFocus', 'database_settings'); - $fields = callEditorMethod($editor, 'getDatabaseActorSettingsFields'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); $index = array_search('id', array_column($fields, 'field'), true); expect($index)->not->toBeFalse()->and($fields[$index]['editable'])->toBeFalse(); setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); diff --git a/tests/Unit/EquipmentOptimizationTest.php b/tests/Unit/EquipmentOptimizationTest.php index 91ceb64..38fafc4 100644 --- a/tests/Unit/EquipmentOptimizationTest.php +++ b/tests/Unit/EquipmentOptimizationTest.php @@ -344,7 +344,7 @@ function optimizeIssues(string $root, ?Severity $severity = null): array setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]); callEditorMethod($editor, 'openDatabaseAtCategory', 'actors'); - $rows = static fn(Editor $editor): array => callEditorMethod($editor, 'getDatabaseActorSettingsFields'); + $rows = static fn(Editor $editor): array => callEditorMethod($editor, 'getDatabaseSettingsFields'); $valueOf = static function (Editor $editor, string $label) use ($rows): string { foreach ($rows($editor) as $field) { if (($field['label'] ?? null) === $label) { @@ -362,7 +362,7 @@ function optimizeIssues(string $root, ?Severity $severity = null): array // Nothing this project has is worn on the head, and the preview says so // rather than showing an empty list. - callEditorMethod($editor, 'applyDatabaseFieldValue', '__actor_optimize_slot', 'head'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', array_values(array_filter(callEditorMethod($editor, 'getDatabaseSettingsFields'), static fn(array $row): bool => ($row['field'] ?? null) === '__actor_optimize_slot'))[0], 'head'); expect($valueOf($editor, ' (nothing)'))->toContain('no equipment this project has fits that slot') ->and(sourceHashTree($root))->toBe($before); diff --git a/tests/Unit/PermanentGrowthTest.php b/tests/Unit/PermanentGrowthTest.php index 361979f..60e698f 100644 --- a/tests/Unit/PermanentGrowthTest.php +++ b/tests/Unit/PermanentGrowthTest.php @@ -191,7 +191,7 @@ function growthIssues(string $root, ?Severity $severity = null): array callEditorMethod($editor, 'openDatabaseAtCategory', 'actors'); $rowFor = static function (Editor $editor, string $label): array { - foreach (callEditorMethod($editor, 'getDatabaseActorSettingsFields') as $field) { + foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $field) { if (($field['label'] ?? null) === $label) { return $field; } @@ -205,7 +205,7 @@ function growthIssues(string $root, ?Severity $severity = null): array ->and($rowFor($editor, ' Assumed Growth')['options'])->toContain('growth.vigour') ->and($attack($editor))->not->toContain('growth'); - callEditorMethod($editor, 'applyDatabaseFieldValue', '__actor_growth', 'growth.vigour'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', array_values(array_filter(callEditorMethod($editor, 'getDatabaseSettingsFields'), static fn(array $row): bool => ($row['field'] ?? null) === '__actor_growth'))[0], 'growth.vigour'); expect($attack($editor))->toContain('+7 growth') // Assuming it is a view of the pane. Nothing was granted, nothing diff --git a/tests/Unit/Session/ActorSessionTest.php b/tests/Unit/Session/ActorSessionTest.php new file mode 100644 index 0000000..6590b9d --- /dev/null +++ b/tests/Unit/Session/ActorSessionTest.php @@ -0,0 +1,164 @@ + ($row['key']['field'] ?? null) === $fieldId) + ?? throw new RuntimeException("No actor row {$fieldId}."); +} + +/** Writes a legacy actor authored without an id into a fixture project. */ +function writeLegacyActor(string $root, string $name): string +{ + $path = $root . '/assets/Data/Actors/' . $name . '.php'; + file_put_contents($path, << Character::class, + 'data' => [ + 'name' => '{$name}', + 'level' => 1, + ] + ]; + + PHP); + + return $path; +} + +it('lists actors with what the category can do, and reads an actor\'s rows and previews', function () { + $session = EditorSession::open(makeTemporaryProject()); + $actors = $session->listDatabaseRecords('actors'); + $read = $session->readDatabaseRecord('actors', 0); + + expect($actors)->toMatchArray([ + 'editable' => true, 'dirty' => false, 'canCreate' => true, 'canDuplicate' => false, 'canDelete' => true, 'canReorder' => false, + 'records' => ['Kaelion'], + ]) + ->and($read)->toMatchArray(['frame' => [], 'frameLabel' => null, 'editable' => true, 'listNoun' => null]) + ->and(actorSessionRow($read, 'name'))->toMatchArray(['kind' => 'text', 'value' => 'Kaelion']) + ->and(actorSessionRow($read, 'class')['kind'])->toBe('options') + ->and(actorSessionRow($read, 'attackSkill'))->toMatchArray(['kind' => 'reference', 'reference' => 'attack_skills', 'noneLabel' => '(Built-in attack)']) + ->and(actorSessionRow($read, 'id')['kind'])->toBe('info') + ->and(array_column($read['rows'], 'label'))->toContain('Resolved Stats', 'Optimize Preview') + ->and(fn() => $session->readDatabaseRecord('actors', 4))->toThrow(SessionRefusal::class, 'actors has no record 4.') + ->and(fn() => $session->readDatabaseRecord('actors', 0, [0]))->toThrow(SessionRefusal::class, 'Actors have no command frames.'); +}); + +it('applies an actor row as one undo step that names the actors, and saves only what changed', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + $original = (string) file_get_contents($path); + $session = EditorSession::open($root); + $name = actorSessionRow($session->readDatabaseRecord('actors', 0), 'name'); + + expect($session->applyDatabaseRecord('actors', 0, $name['key'], 'Kaelion the Bold'))->toBe(['changed' => true, 'records' => ['Kaelion the Bold']]) + ->and($session->applyDatabaseRecord('actors', 0, $name['key'], 'Kaelion the Bold')['changed'])->toBeFalse() + ->and($session->listDatabaseRecords('actors')['dirty'])->toBeTrue(); + + $undone = $session->undo(); + expect($undone['databases'])->toBe(['actors']) + ->and($session->listDatabaseRecords('actors')['records'])->toBe(['Kaelion']) + ->and($session->listDatabaseRecords('actors')['dirty'])->toBeFalse(); + + $session->redo(); + expect($session->saveDatabase('actors')['saved'])->toBeTrue() + ->and(file_get_contents($path))->toBe(str_replace("'name' => 'Kaelion',", "'name' => 'Kaelion the Bold',", $original)) + ->and($session->listDatabaseRecords('actors')['dirty'])->toBeFalse() + ->and($session->saveDatabase('actors')['saved'])->toBeFalse(); +}); + +it('keeps choices about the actor panes out of the project and out of the history', function () { + $session = EditorSession::open(makeTemporaryProject()); + $slot = actorSessionRow($session->readDatabaseRecord('actors', 0), '__actor_optimize_slot'); + $other = array_values(array_filter($slot['options'], static fn(string $option): bool => $option !== $slot['value']))[0]; + + expect($session->applyDatabaseRecord('actors', 0, $slot['key'], $other)['changed'])->toBeFalse() + ->and(actorSessionRow($session->readDatabaseRecord('actors', 0), '__actor_optimize_slot')['value'])->toBe($other) + ->and($session->listDatabaseRecords('actors')['dirty'])->toBeFalse() + ->and($session->undo()['label'])->toBeNull(); +}); + +it('creates and deletes actors as undo steps, and refuses the operations a file per actor cannot keep', function () { + $root = makeTemporaryProject(); + $session = EditorSession::open($root); + + $created = $session->createDatabaseRecord('actors'); + expect($created)->toBe(['index' => 1, 'records' => ['Kaelion', 'New Actor']]); + + $deleted = $session->deleteDatabaseRecord('actors', 0); + expect($deleted)->toBe(['index' => 0, 'records' => ['New Actor']]); + + $session->undo(); + expect($session->listDatabaseRecords('actors')['records'])->toBe(['Kaelion', 'New Actor']) + ->and(fn() => $session->duplicateDatabaseRecord('actors', 0))->toThrow(SessionRefusal::class, 'Actors cannot be duplicated') + ->and(fn() => $session->moveDatabaseRecord('actors', 0, 'down'))->toThrow(SessionRefusal::class, 'file order') + ->and(fn() => $session->addDatabaseItem('actors', 0, ['frame' => []]))->toThrow(SessionRefusal::class, 'no list'); + + $session->saveDatabase('actors'); + expect(is_file($root . '/assets/Data/Actors/NewActor.php'))->toBeTrue() + ->and(is_file($root . '/assets/Data/Actors/Kaelion.php'))->toBeTrue(); +}); + +it('offers the one-time identity freeze of an actor authored without an id as the row\'s action', function () { + $root = makeTemporaryProject(); + // A project whose only identity repair is this actor's: no skit names + // actors by the names a repair would rewrite. + removeDirectoryRecursively($root . '/assets/Data/Skits'); + $path = writeLegacyActor($root, 'Mira'); + $session = EditorSession::open($root); + $index = array_search('Mira', $session->listDatabaseRecords('actors')['records'], true); + $id = actorSessionRow($session->readDatabaseRecord('actors', $index), 'id'); + + expect($id)->toMatchArray(['kind' => 'action', 'action' => 'Freeze "Mira" as the permanent id']) + ->and($session->applyDatabaseRecord('actors', $index, $id['key'], '')['changed'])->toBeTrue() + ->and(actorSessionRow($session->readDatabaseRecord('actors', $index), 'id'))->toMatchArray(['kind' => 'info', 'value' => 'Mira']); + + $session->saveDatabase('actors'); + expect(file_get_contents($path))->toContain("'id' => 'Mira'"); +}); + +it('refuses an identity freeze that needs other files repaired at once, naming where that is done', function () { + $root = makeTemporaryProject(); + writeLegacyActor($root, 'Mira'); + writeLegacyActor($root, 'Tobin'); + $session = EditorSession::open($root); + $index = array_search('Mira', $session->listDatabaseRecords('actors')['records'], true); + $id = actorSessionRow($session->readDatabaseRecord('actors', $index), 'id'); + + expect(fn() => $session->applyDatabaseRecord('actors', $index, $id['key'], '')) + ->toThrow(SessionRefusal::class, 'other file(s) repaired at once') + ->and($session->listDatabaseRecords('actors')['dirty'])->toBeFalse(); +}); + +it('serves actors over the line protocol', function () { + $root = makeTemporaryProject(); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(int $id, string $method, array $params = []): array => + $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params])); + $request(1, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + + $read = $request(2, 'database.record', ['category' => 'actors', 'index' => 0, 'frame' => []])['result']; + $applied = $request(3, 'database.apply', ['category' => 'actors', 'index' => 0, 'key' => actorSessionRow($read, 'description')['key'], 'value' => 'A swordsman.'])['result']; + $undone = $request(4, 'history.undo')['result']; + + expect($request(5, 'database.records', ['category' => 'actors'])['result']['records'])->toBe(['Kaelion']) + ->and($applied['changed'])->toBeTrue() + ->and($undone['databases'])->toBe(['actors']); +}); diff --git a/tests/Unit/Session/DatabaseSessionTest.php b/tests/Unit/Session/DatabaseSessionTest.php index 1917f91..855cf17 100644 --- a/tests/Unit/Session/DatabaseSessionTest.php +++ b/tests/Unit/Session/DatabaseSessionTest.php @@ -46,7 +46,7 @@ function databaseSessionRow(array $read, string $fieldId): array ->and(fn() => $session->readDatabaseRecord('common_events', 0, [9, 'then']))->toThrow(SessionRefusal::class, 'is no longer there') ->and(fn() => $session->readDatabaseRecord('common_events', 0, ['then' => 3]))->toThrow(SessionRefusal::class, 'A frame must be a list') ->and(fn() => $session->readDatabaseRecord('states', 9))->toThrow(SessionRefusal::class, 'states has no record 9.') - ->and(fn() => $session->readDatabaseRecord('actors', 0))->toThrow(SessionRefusal::class, 'edited in the terminal editor for now'); + ->and(fn() => $session->readDatabaseRecord('skills', 0))->toThrow(SessionRefusal::class, 'edited in the terminal editor for now'); }); it('applies a row as one undo step, history naming the category, and refuses what the field cannot take', function () { diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index 6d4eb23..b13c6d4 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -220,7 +220,7 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a ->and(array_column($record['rows'], 'label'))->toContain('Name') ->and(array_find($record['rows'], static fn(array $row): bool => $row['label'] === 'Name')['value'])->toBe($items['records'][0]) ->and(fn() => $session->readDatabaseRecord('items', 999))->toThrow(SessionRefusal::class, 'items has no record 999.') - ->and(fn() => $session->listDatabaseRecords('actors'))->toThrow(SessionRefusal::class, 'edited in the terminal editor for now') + ->and(fn() => $session->listDatabaseRecords('skills'))->toThrow(SessionRefusal::class, 'edited in the terminal editor for now') ->and(fn() => $session->listDatabaseRecords('nope'))->toThrow(SessionRefusal::class, 'There is no database category nope.'); }); From 44cf9241667c72ba256b07a98ef6bd49b73e231b Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 15:53:04 +0200 Subject: [PATCH 155/344] feat(database): author enemy drops and validate every record's references Enemies gain a Drops list (rewards.items): an inventory item and its chance, the list removed with its last entry. RecordReferenceValidator checks every schema record's fields and list entries that name a shared definition, leaving the lists a check with more context owns to it. The validator's clean baseline defines the enemies the fixture's troops name. --- docs/manual.md | 5 +- docs/roadmap.md | 9 +- src/Database/RecordSchemaCatalog.php | 16 ++ src/Validation/ProjectValidator.php | 1 + src/Validation/RecordReferenceValidator.php | 164 ++++++++++++++++++++ tests/Unit/EnemyRecordFilesTest.php | 20 +++ tests/Unit/ProjectValidatorTest.php | 74 ++++++++- 7 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 src/Validation/RecordReferenceValidator.php diff --git a/docs/manual.md b/docs/manual.md index 656dbdd..c3ea475 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1094,7 +1094,10 @@ An enemy is one file under `assets/Data/Enemies`, returning `['class' => Enemy::class, 'data' => [...]]` with plain values: name, level, sprite, stats, rewards, element affinities, state resistances, its knowledge subject, and action patterns that name a skill from the skill -catalogue with a rating and an optional condition. `enemies.php` is the +catalogue with a rating and an optional condition. Its drops are a list +under the Drops heading: each picks an item, weapon or armor and gives its +chance from 0 to 1. Validation reports a drop that names no item or one the +project does not define, as it does for any record's references. `enemies.php` is the barrel that loads the folder, so the game and every other reader get the same enemies. Editing a value changes only that value in that enemy's file, keeping its imports and comments. A new enemy gets a file named after it diff --git a/docs/roadmap.md b/docs/roadmap.md index 97582ba..d3622c9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -276,9 +276,12 @@ Order of work, each a complete slice before the next: service. The terminal keeps its workflow and every field and preview it has now (experience and stat curves, actor stats); the GUI gains the category. 2. Data the record files hold but no editor reaches: item and skill effects, - skill kind, enemy drops, battle animation, the rest of `system.php` (title, - currency, elements, starting party, inventory and positions, battle - opening). + skill kind (both after step 3's per-record move). Enemy drops are done, + and every schema record's references, list entries included, are now + validated. Enemy `position` and `battleAnimation` are not authored: troops + own battle positions (`Troop` overwrites the enemy's), and no runtime code + reads an enemy's `battleAnimation`; attacks animate through their skills. + The rest of `system.php` is done (System and Configuration). 3. Skills become one declarative record per file, as enemies are, so spells and abilities in `magic.php` and `abilities.php` are edited too (the section above). Items, weapons and armors follow. diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index eef487b..02d1bab 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -1131,6 +1131,22 @@ private static function enemies(): RecordSchema ], blank: ['skill' => '', 'rating' => 5], ), + subLists: [ + // What the enemy may drop when defeated, each with its chance + // (0 to 1). The Engine resolves the item by its definition id. + new RecordSubList( + key: 'rewards.items', + prefix: 'drop', + singular: 'drop', + fields: [ + RecordField::reference('item', 'Item', 'inventory'), + new RecordField('rate', 'Drop Rate', InputControlType::FLOAT), + ], + blank: ['item' => '', 'rate' => 0.1], + heading: 'Drops', + removeWhenEmpty: true, + ), + ], // A new enemy needs a real sprite to load, so it starts on the // project's first one; a project with no enemy sprites cannot // author an enemy yet, and creation refuses rather than writing a diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 1fcf45b..9c9a910 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -138,6 +138,7 @@ public function validate(ProjectWorkspace $workspace): array ...$this->checkDefinitionIdentities($workspace), ...$this->checkActorDefinitions($workspace), ...new ActorReferenceValidator()->validate($workspace), + ...new RecordReferenceValidator()->validate($workspace), ...$this->checkKnowledgeCatalog($workspace), ...$this->checkSpecialProperties($workspace), ...$this->checkPermanentGrowth($workspace), diff --git a/src/Validation/RecordReferenceValidator.php b/src/Validation/RecordReferenceValidator.php new file mode 100644 index 0000000..0d196dd --- /dev/null +++ b/src/Validation/RecordReferenceValidator.php @@ -0,0 +1,164 @@ + 'class', + 'common_events' => 'common event', + 'effects' => 'effect', + 'enemies' => 'enemy', + 'inventory' => 'item', + 'knowledge_subjects' => 'knowledge subject', + 'maps' => 'map', + 'quests' => 'quest', + 'skills' => 'skill', + 'troops' => 'troop', + ]; + + /** + * Categories, or lists of them, whose references a check with more + * context owns (null: the whole category), so nothing is reported twice. + */ + private const array OWNED_ELSEWHERE = [ + // ProjectValidator::checkSkitReferences: the map and every beat. + 'skits' => null, + // ProjectValidator::checkScriptReferences, with the map and cast each script runs with. + 'common_events' => null, + // ProjectValidator::checkQuests: what an objective names depends on its type. + 'quests' => ['objectives'], + ]; + + /** @var array> What each kind holds, read once per validation. */ + private array $known = []; + + /** Resolves an inventory reference as the Engine's item store does: by id or by name. */ + private ?InventoryCatalog $inventory = null; + + /** @return Issue[] */ + public function validate(ProjectWorkspace $workspace): array + { + $catalog = new ReferenceCatalog($workspace); + $this->inventory = InventoryCatalog::fromWorkspace($workspace); + $this->known = []; + foreach (array_keys(self::KINDS) as $kind) { + $this->known[$kind] = array_map('strval', array_values($catalog->valuesFor($kind))); + } + + $issues = []; + foreach ($workspace->recordDatabases as $key => $database) { + if (array_key_exists($key, self::OWNED_ELSEWHERE) && self::OWNED_ELSEWHERE[$key] === null) { + continue; + } + foreach ($database->getRecords() as $record) { + $issues = [...$issues, ...$this->checkRecord($database, $record)]; + } + } + + return $issues; + } + + /** @return Issue[] */ + private function checkRecord(ProjectRecordDatabase $database, ProjectRecord $record): array + { + $schema = $database->schema; + $label = trim(strval($record->get($schema->labelKey) ?? '')) ?: '(unnamed)'; + $where = sprintf('%s %s', $schema->entryNoun, $label); + $payload = $record->toArray(); + $issues = []; + + foreach ($schema->fieldsFor($payload) as $field) { + $issue = $this->checkValue($field, $record->get($field->key), $where, false); + if ($issue !== null) { + $issues[] = $issue; + } + } + + foreach ($schema->getInlineSubLists() as $list) { + if (in_array($list->key, self::OWNED_ELSEWHERE[$schema->key] ?? [], true)) { + continue; + } + foreach ($record->getSubList($list->key, $list->scalarKey) as $position => $entry) { + $entryWhere = sprintf('%s, %s %d', $where, $list->singular, $position + 1); + foreach ($list->fieldsFor($entry) as $field) { + $issue = $this->checkValue($field, self::readPath($entry, $field->key), $entryWhere, true); + if ($issue !== null) { + $issues[] = $issue; + } + } + } + } + + return $issues; + } + + /** + * The problem with one value that names a definition, or null when it + * names one that exists, or may name none. A list entry exists to name + * something, so in one an empty value is a problem even where the field + * is optional elsewhere. + */ + private function checkValue(RecordField $field, mixed $value, string $where, bool $inEntry): ?Issue + { + $kind = $field->reference; + if ($kind === null || ! array_key_exists($kind, self::KINDS) || ($value !== null && ! is_scalar($value))) { + return null; + } + + $noun = self::KINDS[$kind]; + $text = trim(strval($value ?? '')); + + if ($text === '') { + return $inEntry && ! $field->allowsNone + ? Issue::error($where, sprintf('Its %s names no %s.', strtolower($field->label), $noun), sprintf('Choose a %s, or remove the entry.', $noun)) + : null; + } + + $exists = $kind === 'inventory' && $this->inventory !== null + ? $this->inventory->has($text) + : in_array($text, $this->known[$kind], true); + + return $exists + ? null + : Issue::error($where, sprintf('Its %s names the %s "%s", which the project does not define.', strtolower($field->label), $noun, $text), + sprintf('Choose one of the project\'s %ss.', $noun)); + } + + /** @param array $entry */ + private static function readPath(array $entry, string $key): mixed + { + $current = $entry; + foreach (explode('.', $key) as $segment) { + if (! is_array($current) || ! array_key_exists($segment, $current)) { + return null; + } + $current = $current[$segment]; + } + + return $current; + } +} diff --git a/tests/Unit/EnemyRecordFilesTest.php b/tests/Unit/EnemyRecordFilesTest.php index 2821abb..5be91a9 100644 --- a/tests/Unit/EnemyRecordFilesTest.php +++ b/tests/Unit/EnemyRecordFilesTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Ichiloto\Editor\Database\ProjectRecordDatabase; +use Ichiloto\Editor\Database\RecordAuthoring; use Ichiloto\Editor\Database\RecordSchemaCatalog; use Ichiloto\Engine\Entities\Enemies\EnemyCatalog; use Ichiloto\Engine\Entities\Enumerations\ActionConditionType; @@ -190,3 +191,22 @@ function enemiesDatabase(string $root): ProjectRecordDatabase ->and($stray->getReadOnlyReason())->toBe("stray.php does not return ['class' => Enemy::class, 'data' => [...]]") ->and($database->duplicateRecord(1))->toBeNull(); }); + +it('authors drops as item ids with their chance, and drops the list with its last entry', function () { + $root = enemyRecordProject(['blob.php' => blobRecordFile()]); + $database = enemiesDatabase($root); + $authoring = new RecordAuthoring(); + $rewards = static fn(): array => (require $root . '/assets/Data/Enemies/blob.php')['data']['rewards']; + + $added = $authoring->addItem($database, 0, [], 'dropList'); + $database->setField(0, ProjectRecordDatabase::subFieldId('drop', (int) $added->index, 'item'), 'item.tonic'); + $database->setField(0, ProjectRecordDatabase::subFieldId('drop', (int) $added->index, 'rate'), '0.25'); + $database->save(); + + expect($rewards())->toBe(['experience' => 12, 'gold' => 30, 'items' => [['item' => 'item.tonic', 'rate' => 0.25]]]); + + $authoring->removeItem($database, 0, [], ProjectRecordDatabase::subFieldId('drop', 0, 'item')); + $database->save(); + + expect($rewards())->toBe(['experience' => 12, 'gold' => 30]); +}); diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index 9bd482a..a71c423 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -61,6 +61,38 @@ function writeConsistentSkits(string $root): void PHP); } +/** + * Defines the enemies the temporary project's troops field. + * + * Same story again: the fixture's troops name a bigger project's enemies, + * and a troop whose enemy is not defined cannot load. + * + * @param string $root The project root. + * @return void + */ +function writeConsistentEnemies(string $root): void +{ + is_dir($root . '/assets/Data/Enemies') || mkdir($root . '/assets/Data/Enemies', 0777, true); + + foreach (['regular-bat' => 'Regular Bat', 'sewer-rat' => 'Sewer Rat'] as $file => $name) { + file_put_contents($root . '/assets/Data/Enemies/' . $file . '.php', << \\Ichiloto\\Engine\\Entities\\Enemies\\Enemy::class, + 'data' => [ + 'name' => '{$name}', + 'level' => 1, + 'imagePath' => '{$file}', + 'stats' => ['maxHp' => 10, 'maxMp' => 0, 'attack' => 3, 'defence' => 2, 'magicAttack' => 1, 'magicDefence' => 1, 'speed' => 4, 'grace' => 1, 'evasion' => 0], + 'rewards' => ['experience' => 1, 'gold' => 1], + ], + ]; + + PHP); + } +} + /** Writes a save compatibility manifest into a disposable project. */ function writeSaveCompatibilityManifest(string $root, string $source): void { @@ -102,6 +134,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $root = makeTemporaryProject(); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); expect(validateProject($root))->toBe([]); }); @@ -110,6 +143,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $root = makeTemporaryProject(); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); editTestMapData( $root, static fn(string $source): string => str_replace( @@ -126,6 +160,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $root = makeTemporaryProject(); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); editTestMapData( $root, static fn(string $source): string => str_replace( @@ -148,10 +183,12 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $issues = validateProject(fixturePath('sample-project')); // The map is named twice: a quest objective goes there, and the skit - // plays there. + // plays there. The rat is named twice too: a quest objective defeats it, + // and a troop fields it, as another troop fields the bat. expect(issuesMentioning($issues, 'happyville/town-center'))->toHaveCount(2) ->and(issuesMentioning($issues, 'S-Mana'))->toHaveCount(1) - ->and(issuesMentioning($issues, 'Sewer Rat'))->toHaveCount(1) + ->and(issuesMentioning($issues, 'Sewer Rat'))->toHaveCount(2) + ->and(issuesMentioning($issues, 'Regular Bat'))->toHaveCount(2) // The skit's own condition waits on a quest the fixture does define, // and a reference that resolves is not a finding. ->and(issuesMentioning($issues, 'breakfast-duty'))->toHaveCount(0); @@ -216,6 +253,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $root = makeTemporaryProject(); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); writeValidatorSummon($root, 'unsafe-summon', [ 'id' => 'unsafe-summon', 'name' => 'Unsafe Summon', @@ -242,6 +280,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $root = makeTemporaryProject(); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); writeValidatorSummon($root, 'bound-summon', [ 'id' => 'bound-summon', 'name' => 'Bound Summon', @@ -268,6 +307,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $root = makeTemporaryProject(); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); writeValidatorSummon($root, 'malformed-summon', [ 'id' => 'malformed-summon', 'name' => 'Malformed Summon', @@ -287,6 +327,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $root = makeTemporaryProject(); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); writeValidatorSummon($root, 'malformed-fields', [ 'id' => 'malformed-fields', 'name' => 'Malformed Fields', @@ -307,6 +348,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $root = makeTemporaryProject(); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); writeValidatorSummon($root, 'signature-summon', [ 'id' => 'Signature-Summon', 'name' => 'Signature Summon', @@ -322,6 +364,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $root = makeTemporaryProject(); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); writeValidatorSummon($root, 'shared-summon', [ 'id' => 'shared-summon', 'name' => 'Shared Summon', @@ -581,6 +624,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $root = makeTemporaryProject(); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); writeSaveCompatibilityManifest($root, <<<'PHP' new MagicSkill('Cleanse', 'Again.', '', 3, 0),\n"); $issues = issuesMentioning(validateProject($root), '"Cleanse" is defined in both skills.php and magic.php'); @@ -843,6 +889,7 @@ function writeTransitionCatalog(string $root, string $asset, string $battle = 's $root = makeTemporaryProject(); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); @mkdir($root . '/assets/Graphics/System', 0777, true); $image = imagecreatetruecolor(4, 4); imagepng($image, $root . '/assets/Graphics/System/Sweep.png'); @@ -901,6 +948,7 @@ function reachabilityIssueLines(string $root): array $root = makeTemporaryProject(); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); // The chest E sits in a sealed pocket. writeReachableProject($root, "############\n# ~~~ ### #\n# #E# #\n# ### #\n############", @@ -921,6 +969,7 @@ function reachabilityIssueLines(string $root): array $root = makeTemporaryProject(); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); writeReachableProject($root, "############\n# ~~~ #\n# #\n# #\n############", " \n E \n \n \n "); @@ -968,6 +1017,7 @@ function effectIssueLines(string $root): array $root = makeTemporaryProject(); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); @mkdir($root . '/assets/Graphics/System', 0777, true); $image = imagecreatetruecolor(9, 8); imagepng($image, $root . '/assets/Graphics/System/Cue.png'); @@ -1003,3 +1053,23 @@ function effectIssueLines(string $root): array 'test-map: Its fieldEffects cannot be read: fieldEffects must be a list of at most 256 map-owned effects.', ]); }); + +it('reports a record list entry that names nothing, or a definition the project lacks', function () { + $root = makeTemporaryProject(); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeConsistentEnemies($root); + $path = $root . '/assets/Data/Enemies/sewer-rat.php'; + file_put_contents($path, str_replace( + "'rewards' => ['experience' => 1, 'gold' => 1],", + "'rewards' => ['experience' => 1, 'gold' => 1, 'items' => [['item' => '', 'rate' => 0.5], ['item' => 'Moon Tonic', 'rate' => 0.1], ['item' => 'S-Potion', 'rate' => 0.2]]],", + (string) file_get_contents($path), + )); + + $issues = array_map(static fn(Issue $issue): string => $issue->where . ': ' . $issue->message, validateProject($root)); + + expect($issues)->toBe([ + 'enemy Sewer Rat, drop 1: Its item names no item.', + 'enemy Sewer Rat, drop 2: Its item names the item "Moon Tonic", which the project does not define.', + ]); +}); From 3e28ea286aa5dc748179014a5be9ce4be4bf63d2 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 16:08:12 +0200 Subject: [PATCH 156/344] feat(database): edit the project's elements in Types, where the game reads them Types now edits the elements list of system.php, one element per row, and System no longer carries a second copy. The element pickers read the same identities the game knows (ElementRegistry::getCanonicalIdentities), not the unused enum under assets/Data/Types; validation reports a list the game would refuse. Categories sharing system.php each own their top-level keys: WholeFileProjection reads and writes only those, so a save no longer carries a stale reading of another category's settings over the file. A schema whose fields leave its owned keys is refused when declared. Tests that needed a read-only category use a file the editor cannot rewrite. --- docs/manual.md | 4 +- docs/roadmap.md | 5 +- .../guide/tooling-loop/the-database.md | 2 +- .../Projections/WholeFileProjection.php | 50 ++++++++++++++++-- src/Database/RecordSchema.php | 14 +++++ src/Database/RecordSchemaCatalog.php | 52 +++++++++++-------- src/Database/ReferenceCatalog.php | 31 +++-------- src/ProjectWorkspace.php | 17 ++++++ src/Validation/ProjectValidator.php | 19 +++++++ tests/Pest.php | 12 +++++ tests/Unit/DatabaseCategoryListTest.php | 6 +-- tests/Unit/DatabaseDeletionTest.php | 7 +-- tests/Unit/ElementalAuthoringTest.php | 52 +++++++++++++------ tests/Unit/ProjectSystemDatabaseTest.php | 27 ++++++++++ tests/Unit/RecordAuthoringTest.php | 5 +- tests/Unit/RecordCategoryEditorTest.php | 10 ++-- tests/Unit/RecordDatabaseTest.php | 12 ++--- tests/Unit/Session/DatabaseSessionTest.php | 15 ++++-- 18 files changed, 243 insertions(+), 97 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index c3ea475..fce1fd7 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1032,9 +1032,9 @@ status line says exactly why. | Optimize Weights | `assets/Data/equipment-optimization.php` | Editable | | Optimize Outcomes | `assets/Data/equipment-optimization.php` | Editable | | Optimize Exclusions | `assets/Data/equipment-optimization.php` | Editable | -| System | `assets/Data/system.php` | Editable - title, starting gold, party, inventory and position, elements, battle engine, openings and ATB settings | +| System | `assets/Data/system.php` | Editable - title, starting gold, party, inventory and position, battle engine, openings and ATB settings | | Configuration | `config.php` (`save`, `accessibility`, `ui`, `graphics`, `audio`, `inn`) | Editable - one row per setting, typed by what it holds; field zoom is kept from 1 to 8 | -| Types | `assets/Data/Types/*.php` | Read-only - PHP enum declarations | +| Types | `assets/Data/system.php` | Editable - the project's elements, one per row; an empty list means the Engine's defaults. Weapon, armor and equipment types are the Engine's own | | Terms | `config.php` (`vocab`, `messages`) | Literal terms editable; comments and unrelated expressions preserved | Why a category can still turn out read-only: a file the editor cannot diff --git a/docs/roadmap.md b/docs/roadmap.md index d3622c9..eaf6b58 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -286,8 +286,9 @@ Order of work, each a complete slice before the next: and abilities in `magic.php` and `abilities.php` are edited too (the section above). Items, weapons and armors follow. 4. Tilesets: an editor for `Data/Tilesets/.php` (sheets, pieces, tables, - shadows); the "no tileset system" note is stale. Types: element names live - in `system.php`; the enum files stay read-only. + shadows); the "no tileset system" note is stale. Types is done: it edits + the elements `system.php` declares, which the element pickers now read + (they read an unused enum under `assets/Data/Types` before). 5. The manual's category table gains a GUI column and states each category's real coverage. diff --git a/docs/website/guide/tooling-loop/the-database.md b/docs/website/guide/tooling-loop/the-database.md index 41e1780..e4d91ff 100644 --- a/docs/website/guide/tooling-loop/the-database.md +++ b/docs/website/guide/tooling-loop/the-database.md @@ -68,7 +68,7 @@ Enemies are one data file each under `assets/Data/Enemies`, and `enemies.php` lo Re-author one of those files as a plain array and the editor picks it up as editable automatically. Nothing in the editor needs to change. -`Types` is read-only for a related reason: those files are PHP enum declarations, not data the engine loads. `Tilesets` is empty because the engine has no tileset system — map tiles are painted directly on the canvas. +`Types` holds your elements: the list in `system.php` that the game knows every element by, and that skills, equipment and affinities pick from. System and Types share `system.php`, and each saves only its own settings. `Tilesets` is empty because the engine has no tileset system: map tiles are painted directly on the canvas. ## What saving preserves diff --git a/src/Database/Projections/WholeFileProjection.php b/src/Database/Projections/WholeFileProjection.php index d11f799..5a27f83 100644 --- a/src/Database/Projections/WholeFileProjection.php +++ b/src/Database/Projections/WholeFileProjection.php @@ -11,16 +11,29 @@ * settings as a single map, edited as the category's only record. It is * never created, duplicated, moved or deleted, only edited. * + * Categories that share such a file each own some of its top-level keys + * (System its start and battle settings, Types its elements). A category + * reads only the keys it owns and writes only those back, so a save never + * carries its older reading of another category's keys, or of keys no + * category edits, over the file as it is now. + * * @package Ichiloto\Editor\Database\Projections */ final readonly class WholeFileProjection implements RecordProjection { + /** + * @param list $keys The top-level keys this category owns. + */ + public function __construct(public array $keys) + { + } + /** * @inheritDoc */ public function read(array $whole): array { - return [$whole]; + return [array_intersect_key($whole, array_flip($this->keys))]; } /** @@ -28,9 +41,21 @@ public function read(array $whole): array */ public function write(array $whole, array $rows): array { - $record = $rows[0] ?? $whole; + $record = $rows[0] ?? null; + + if (! is_array($record)) { + return $whole; + } + + foreach ($this->keys as $key) { + if (array_key_exists($key, $record)) { + $whole[$key] = $record[$key]; + } else { + unset($whole[$key]); + } + } - return is_array($record) ? $record : $whole; + return $whole; } /** @@ -50,4 +75,23 @@ public function ordersRecords(): bool { return false; } + + /** + * The key of a field or list path this projection would not read or + * write, or null when it owns every one. + * + * @param list $paths Dotted field and list paths. + */ + public function findUnownedKey(array $paths): ?string + { + foreach ($paths as $path) { + $key = explode('.', $path, 2)[0]; + + if (! in_array($key, $this->keys, true)) { + return $key; + } + } + + return null; + } } diff --git a/src/Database/RecordSchema.php b/src/Database/RecordSchema.php index f78b5a4..0ea65d4 100644 --- a/src/Database/RecordSchema.php +++ b/src/Database/RecordSchema.php @@ -5,6 +5,8 @@ namespace Ichiloto\Editor\Database; use Closure; +use Ichiloto\Editor\Database\Projections\WholeFileProjection; +use LogicException; /** * Declares one Database category: where its records live, what an entry is @@ -77,6 +79,18 @@ public function __construct( public array $subLists = [], public bool $identityFollowsLabel = false, ) { + if ($projection instanceof WholeFileProjection) { + // A field outside the keys the category owns would read blank and + // its edits would never reach the file. + $unowned = $projection->findUnownedKey([ + ...array_map(static fn(RecordField $field): string => $field->key, $fields), + ...array_map(static fn(RecordSubList $list): string => $list->key, $this->getInlineSubLists()), + ]); + + if ($unowned !== null) { + throw new LogicException(sprintf('The %s category edits "%s", a key its file projection does not own.', $key, $unowned)); + } + } } /** diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 02d1bab..f7ff927 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -838,8 +838,8 @@ private static function optimizeExclusions(): RecordSchema /** * System — `assets/Data/system.php`, one map the engine reads at start: - * the title, starting gold, party, inventory and position, the project's - * elements, and battle settings. The file is the category's one record, + * the title, starting gold, party, inventory and position, and battle + * settings. Its elements are the Types category's. The file is the category's one record, * only ever edited. The starting party and inventory are lists of their * own; a party member is a bare actor id, as the file authors it. * @@ -863,7 +863,6 @@ private static function system(): RecordSchema 'Start Facing', options: array_map(static fn(MovementHeading $heading): string => $heading->value, MovementHeading::cases()), ), - new RecordField('elements', 'Elements', codec: RecordFieldCodec::CSV_LIST), new RecordField( 'battle.engine', 'Battle Engine', @@ -879,7 +878,8 @@ private static function system(): RecordSchema ], labelKey: 'title', identityKey: null, - projection: new WholeFileProjection(), + // The keys System owns; its elements are the Types category's. + projection: new WholeFileProjection(['title', 'currency', 'startingPositions', 'startingParty', 'startingInventory', 'battle']), subLists: [ new RecordSubList( key: 'startingParty', @@ -1343,13 +1343,12 @@ private static function terms(): RecordSchema } /** - * Element/equipment type tables — `assets/Data/Types`. - * - * These are PHP enum *declarations*, not data: the engine reads its own - * `Entities\Enumerations` enums and never loads this directory. The - * editor lists the files so they are discoverable, and does not evaluate - * them (requiring a class declaration into the editor's process would - * risk a redeclaration fatal). + * Types: the project's elements, the `elements` list of + * `assets/Data/system.php` that the Engine's element registry reads (its + * own defaults when the list is empty). Weapon, armor and equipment + * types are the Engine's own enums, not project data, and nothing reads + * `assets/Data/Types`. The category shares `system.php` with System, + * each saving only what it changed. * * @return RecordSchema */ @@ -1358,17 +1357,28 @@ private static function types(): RecordSchema return new RecordSchema( key: 'types', entryNoun: 'type table', - storage: RecordStorage::FILE_LISTING, - relativePath: 'assets/Data/Types', - fields: [ - new RecordField('file', 'File', isReadOnly: true), - new RecordField('kind', 'Kind', isReadOnly: true), - new RecordField('lines', 'Lines', InputControlType::INTEGER, isReadOnly: true), - ], - labelKey: 'file', + storage: RecordStorage::LIST_FILE, + relativePath: 'assets/Data/system.php', + fields: [], + labelKey: 'title', identityKey: null, - isAlwaysReadOnly: true, - readOnlyNote: 'element and equipment types are PHP enum declarations, not data the engine loads — edit them in your IDE', + projection: new WholeFileProjection(['elements']), + subLists: [ + // An element is one name, as the file authors it. A list the + // game would refuse (a name twice, an empty one) is reported + // by validation; an empty list means the Engine's defaults. + new RecordSubList( + key: 'elements', + prefix: 'element', + singular: 'element', + fields: [new RecordField('name', 'Element')], + blank: ['name' => 'New Element'], + heading: 'Elements', + scalarKey: 'name', + removeWhenEmpty: true, + ), + ], + labelFor: static fn(array $payload): string => 'Elements', ); } diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 2c124b8..25be5e1 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -18,6 +18,7 @@ use Ichiloto\Engine\Entities\Skills\BasicSkill; use Ichiloto\Engine\Entities\Skills\Skill; use Ichiloto\Engine\Entities\Enumerations\WeaponType; +use InvalidArgumentException; /** * What a field pointing at another resource may point at. @@ -667,36 +668,18 @@ private function knowledgeCatalog(): array } /** - * Returns the elements the project's own element enum declares. + * Returns the elements the game knows ({@see ProjectWorkspace::getElementIdentities()}). + * A list the game would refuse offers nothing; validation says why. * - * A project defines its elements as a PHP enum under assets/Data/Types - * (the Types category), and the engine matches them by their string - * values. The cases are read from the source, so the list is available - * whether or not the enum is loaded. - * - * @return string[] The element values, in declaration order. + * @return string[] The element identities, in authored order. */ private function elementValues(): array { - $directory = rtrim($this->workspace->projectRoot, '/') . '/assets/Data/Types'; - - if (! is_dir($directory)) { + try { + return $this->workspace->getElementIdentities(); + } catch (InvalidArgumentException) { return []; } - - foreach (glob($directory . '/*.php') ?: [] as $path) { - $source = (string) file_get_contents($path); - - if (preg_match('/^\s*enum\s+\w*Element\w*\s*:/m', $source) !== 1) { - continue; - } - - preg_match_all("/^\\s*case\\s+\\w+\\s*=\\s*'([^']+)'\\s*;/m", $source, $matches); - - return $matches[1]; - } - - return []; } /** diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index 3d5fee1..6c2a65f 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -18,9 +18,11 @@ use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Editor\Storage\FilesystemFileSetOperations; use Ichiloto\Engine\Core\ProjectFormat; +use Ichiloto\Engine\Entities\Elements\ElementRegistry; use Ichiloto\Engine\Entities\Skills\SkillCatalog; use Ichiloto\Engine\Field\MapGraphics; use RecursiveDirectoryIterator; +use InvalidArgumentException; use RecursiveIteratorIterator; use RuntimeException; use Throwable; @@ -67,6 +69,21 @@ public function getSystemField(string $key): mixed return $this->getRecordDatabase('system')?->getRecordByIndex(0)?->get($key); } + /** + * The elements the game knows, as the Types record holds them, unsaved + * edits included: the list `system.php` declares, or the Engine's + * defaults when it lists none, read as the Engine's registry reads them. + * + * @return list The element identities, in authored order. + * @throws InvalidArgumentException When the list names an element twice or names an empty one. + */ + public function getElementIdentities(): array + { + $authored = $this->getRecordDatabase('types')?->getRecordByIndex(0)?->get('elements'); + + return ElementRegistry::getCanonicalIdentities(is_array($authored) ? $authored : []); + } + /** * The project's quests as validation and the journal panes read them. * diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 9c9a910..2079c8e 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -141,6 +141,7 @@ public function validate(ProjectWorkspace $workspace): array ...new RecordReferenceValidator()->validate($workspace), ...$this->checkKnowledgeCatalog($workspace), ...$this->checkSpecialProperties($workspace), + ...$this->checkElements($workspace), ...$this->checkPermanentGrowth($workspace), ...$this->checkOptimizationPolicy($workspace), ...new SaveCompatibilityValidator()->validate($workspace), @@ -370,6 +371,24 @@ protected function checkActorDefinitions(ProjectWorkspace $workspace): array * @param ProjectWorkspace $workspace The project. * @return Issue[] The issues found. */ + /** + * Checks the project's elements as the game reads them at start: a name + * twice, or an empty one, stops the game from starting at all. + * + * @param ProjectWorkspace $workspace The project. + * @return Issue[] The issues found. + */ + protected function checkElements(ProjectWorkspace $workspace): array + { + try { + $workspace->getElementIdentities(); + } catch (InvalidArgumentException $refused) { + return [Issue::error('assets/Data/system.php elements', $refused->getMessage(), 'Give each element one distinct name in Types.')]; + } + + return []; + } + /** * Checks the special properties a project's equipment declares. * diff --git a/tests/Pest.php b/tests/Pest.php index 38dd544..dc2143d 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -242,6 +242,18 @@ function getEditorProperty(Editor $editor, string $property): mixed return new ReflectionProperty(Editor::class, $property)->getValue($editor); } +/** + * Makes a temporary project's System category one the editor cannot write, + * as a real project's can be: its file returns a list, not the one map of + * settings the editor rewrites. Returns the category's key. + */ +function makeUnwritableCategory(string $root): string +{ + file_put_contents($root . '/assets/Data/system.php', "isEditable())->toBeFalse(); - $text = implode("\n", databaseListLinesFor($root, 'types')); + $text = implode("\n", databaseListLinesFor($root, $category)); expect($text)->toContain($database->getReadOnlyReason() ?? 'read only'); }); diff --git a/tests/Unit/DatabaseDeletionTest.php b/tests/Unit/DatabaseDeletionTest.php index 18918e8..815723f 100644 --- a/tests/Unit/DatabaseDeletionTest.php +++ b/tests/Unit/DatabaseDeletionTest.php @@ -123,16 +123,17 @@ it('refuses deletion in a read-only category before prompting', function () { $root = makeTemporaryProject(); - $typesPath = $root . '/assets/Data/Types'; + $category = makeUnwritableCategory($root); + $typesPath = $root . '/assets/Data'; $hash = static fn(): string => md5(implode('', array_map( static fn(string $file): string => (string) file_get_contents($file), - glob($typesPath . '/*.php') ?: [] + glob($typesPath . '/system.php') ?: [] ))); $before = $hash(); try { $editor = deletionEditor($root); - openDatabaseCategory($editor, 'types'); + openDatabaseCategory($editor, $category); // The category lists real entries, so this is not an "empty" refusal. expect(callEditorMethod($editor, 'getDatabaseEntryLabels'))->not->toBeEmpty(); diff --git a/tests/Unit/ElementalAuthoringTest.php b/tests/Unit/ElementalAuthoringTest.php index c4844f9..0bce765 100644 --- a/tests/Unit/ElementalAuthoringTest.php +++ b/tests/Unit/ElementalAuthoringTest.php @@ -11,27 +11,21 @@ use Ichiloto\Engine\Entities\Inventory\Armor; /** - * Writes an element enum into a project the way a real one declares it. + * Declares a project's elements the way a real one does, in system.php. * * @param string $root The project root. * @return void */ -function writeElementTypes(string $root): void +function writeProjectElements(string $root): void { - if (! is_dir($root . '/assets/Data/Types')) { - mkdir($root . '/assets/Data/Types', 0o777, true); - } - - // Overwrites any fixture enum so the expected cases are exactly these. - file_put_contents($root . '/assets/Data/Types/ElementType.php', <<<'PHP' + // The list system.php declares, which the game's element registry reads. + file_put_contents($root . '/assets/Data/system.php', <<<'PHP' 'Elements Test', + 'elements' => ['Fire', 'Ice', 'Water'], + ]; PHP); } @@ -55,7 +49,7 @@ enum ElementType: string it('reads the elements a project declares', function () { $root = makeTemporaryProject(); - writeElementTypes($root); + writeProjectElements($root); $catalog = new ReferenceCatalog(ProjectWorkspace::fromProject($root)); @@ -90,7 +84,7 @@ enum ElementType: string it('authors an armor ward end to end, into the engine object', function () { $root = makeTemporaryProject(); - writeElementTypes($root); + writeProjectElements($root); $database = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('armors')); $index = $database->addRecord(); @@ -110,7 +104,7 @@ enum ElementType: string it('gives a weapon an attack element and takes it away again', function () { $root = makeTemporaryProject(); - writeElementTypes($root); + writeProjectElements($root); $database = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('weapons')); $index = $database->addRecord(); @@ -143,3 +137,27 @@ enum ElementType: string ->and($fields['enemies']['elementAffinities']->isReadOnly)->toBeFalse() ->and($fields['enemies']['elementAffinities']->codec->value)->toBe('affinities'); }); + +it('authors elements in Types, offering them before they are saved, the Engine\'s own when none are listed', function () { + $root = makeTemporaryProject(); + writeProjectElements($root); + $workspace = ProjectWorkspace::fromProject($root); + $types = $workspace->getRecordDatabase('types'); + $authoring = new Ichiloto\Editor\Database\RecordAuthoring(); + + $added = $authoring->addItem($types, 0, [], 'elementList'); + $types->setField(0, ProjectRecordDatabase::subFieldId('element', (int) $added->index, 'name'), 'Shadow'); + + expect($types->getEntryLabels())->toBe(['Elements']) + ->and(new ReferenceCatalog($workspace)->valuesFor('elements'))->toBe(['Shadow', 'Fire', 'Ice', 'Water']); + + $types->save(); + expect((require $root . '/assets/Data/system.php')['elements'])->toBe(['Shadow', 'Fire', 'Ice', 'Water']); + + foreach ([3, 2, 1, 0] as $index) { + $authoring->removeItem($types, 0, [], ProjectRecordDatabase::subFieldId('element', $index, 'name')); + } + + expect(new ReferenceCatalog($workspace)->valuesFor('elements')) + ->toBe(array_map(static fn($element): string => $element->value, Ichiloto\Engine\Entities\Enumerations\ElementType::cases())); +}); diff --git a/tests/Unit/ProjectSystemDatabaseTest.php b/tests/Unit/ProjectSystemDatabaseTest.php index 64e2ed2..a13a228 100644 --- a/tests/Unit/ProjectSystemDatabaseTest.php +++ b/tests/Unit/ProjectSystemDatabaseTest.php @@ -153,3 +153,30 @@ function productionSystem(): array expect((string) file_get_contents($path))->toContain("strtoupper('fixture')", "'amount' => 9"); }); + +it('saves System and Types, which share system.php, each with only its own keys', function (): void { + $root = scratchSystemProject(); + $path = $root . '/assets/Data/system.php'; + file_put_contents($path, " ['Fire', 'Ice']], true) . ";\n"); + $system = systemRecords($root); + $types = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('types')); + + $types->setField(0, ProjectRecordDatabase::subFieldId('element', 1, 'name'), 'Frost'); + $system->setField(0, 'currency.amount', '900'); + $system->save(); + $types->save(); + $saved = require $path; + + expect($saved['currency']['amount'])->toBe(900) + ->and($saved['elements'])->toBe(['Fire', 'Frost']) + ->and($saved['unknownSystemSetting'])->toBe(['preserve' => 'yes']) + ->and(array_keys($system->getRecordByIndex(0)?->toArray() ?? []))->not->toContain('elements') + ->and(fn() => new Ichiloto\Editor\Database\RecordSchema( + key: 'stray', + entryNoun: 'stray', + storage: Ichiloto\Editor\Database\RecordStorage::LIST_FILE, + relativePath: 'assets/Data/system.php', + fields: [new Ichiloto\Editor\Database\RecordField('title', 'Title')], + projection: new Ichiloto\Editor\Database\Projections\WholeFileProjection(['elements']), + ))->toThrow(LogicException::class, 'The stray category edits "title", a key its file projection does not own.'); +}); diff --git a/tests/Unit/RecordAuthoringTest.php b/tests/Unit/RecordAuthoringTest.php index 0ba7d0e..e77eca4 100644 --- a/tests/Unit/RecordAuthoringTest.php +++ b/tests/Unit/RecordAuthoringTest.php @@ -211,11 +211,12 @@ function authoringScript(ProjectRecordDatabase $events, RecordAuthoring $authori }); it('refuses every change to a read-only category with its reason', function () { - $types = loadRecordDatabase(makeTemporaryProject(), 'types'); + $root = makeTemporaryProject(); + $types = loadRecordDatabase($root, makeUnwritableCategory($root)); $authoring = new RecordAuthoring(); $reason = sprintf('Read-only: %s.', $types->getReadOnlyReason()); - expect(fn() => $authoring->applyField($types, 0, [], 'file', 'x', 'File'))->toThrow(RecordRefusal::class, $reason) + expect(fn() => $authoring->applyField($types, 0, [], 'title', 'x', 'Title'))->toThrow(RecordRefusal::class, $reason) ->and(fn() => $authoring->createRecord($types))->toThrow(RecordRefusal::class, $reason) ->and(fn() => $authoring->deleteRecord($types, 0))->toThrow(RecordRefusal::class, $reason) ->and(fn() => $authoring->addItem($types, 0, [], null))->toThrow(RecordRefusal::class, $reason); diff --git a/tests/Unit/RecordCategoryEditorTest.php b/tests/Unit/RecordCategoryEditorTest.php index 0ac2622..9020b02 100644 --- a/tests/Unit/RecordCategoryEditorTest.php +++ b/tests/Unit/RecordCategoryEditorTest.php @@ -121,23 +121,23 @@ it('warns instead of editing when the category is read-only', function (): void { $root = makeTemporaryProject(); - $typesPath = $root . '/assets/Data/Types'; + $category = makeUnwritableCategory($root); + $typesPath = $root . '/assets/Data'; $before = md5(implode('', array_map( static fn(string $file): string => (string) file_get_contents($file), - glob($typesPath . '/*.php') ?: [] + glob($typesPath . '/system.php') ?: [] ))); try { $editor = deletionEditor($root); - // Element and weapon types are PHP enum declarations, not data. - openDatabaseCategory($editor, 'types'); + openDatabaseCategory($editor, $category); callEditorMethod($editor, 'saveActiveDatabase'); $after = md5(implode('', array_map( static fn(string $file): string => (string) file_get_contents($file), - glob($typesPath . '/*.php') ?: [] + glob($typesPath . '/system.php') ?: [] ))); expect(getEditorProperty($editor, 'statusMessage'))->toContain('read-only') diff --git a/tests/Unit/RecordDatabaseTest.php b/tests/Unit/RecordDatabaseTest.php index 62a04a5..7fe0552 100644 --- a/tests/Unit/RecordDatabaseTest.php +++ b/tests/Unit/RecordDatabaseTest.php @@ -325,12 +325,11 @@ it('refuses every write path on a category it cannot rewrite', function (): void { $root = makeTemporaryProject(); - $database = loadRecordDatabase($root, 'types'); + $database = loadRecordDatabase($root, makeUnwritableCategory($root)); - // Element and weapon types are PHP enum declarations, not data. expect($database->isEditable())->toBeFalse(); - $database->setField(0, 'value', 'Tampered'); + $database->setField(0, 'title', 'Tampered'); expect($database->addRecord())->toBeNull(); expect($database->removeRecord(0))->toBeNull(); @@ -401,16 +400,11 @@ removeDirectoryRecursively($root); }); -it('lists type tables without evaluating them and explains tilesets', function (): void { +it('explains tilesets', function (): void { $root = makeTemporaryProject(); - $types = loadRecordDatabase($root, 'types'); $tilesets = loadRecordDatabase($root, 'tilesets'); - expect($types->isEditable())->toBeFalse(); - expect($types->getReadOnlyReason())->toContain('PHP enum declarations'); - expect($types->getEntryLabels())->toBe(['equipment.php']); - expect($tilesets->isEditable())->toBeFalse(); expect($tilesets->getReadOnlyReason())->toContain('no tileset system'); expect($tilesets->getEntryLabels())->toBe([]); diff --git a/tests/Unit/Session/DatabaseSessionTest.php b/tests/Unit/Session/DatabaseSessionTest.php index 855cf17..eb8b214 100644 --- a/tests/Unit/Session/DatabaseSessionTest.php +++ b/tests/Unit/Session/DatabaseSessionTest.php @@ -25,9 +25,11 @@ function databaseSessionRow(array $read, string $fieldId): array } it('lists a category with what it can do, and reads a frame\'s rows with the items they belong to', function () { - $session = EditorSession::open(makeTemporaryProject()); + $root = makeTemporaryProject(); + $unwritable = makeUnwritableCategory($root); + $session = EditorSession::open($root); $states = $session->listDatabaseRecords('states'); - $types = $session->listDatabaseRecords('types'); + $types = $session->listDatabaseRecords($unwritable); $root = $session->readDatabaseRecord('common_events', 0); $then = $session->readDatabaseRecord('common_events', 0, [3, 'then']); @@ -104,14 +106,16 @@ function databaseSessionRow(array $read, string $fieldId): array }); it('creates, duplicates, moves and deletes records, each one undo step', function () { - $session = EditorSession::open(makeTemporaryProject()); + $root = makeTemporaryProject(); + $unwritable = makeUnwritableCategory($root); + $session = EditorSession::open($root); expect($session->createDatabaseRecord('states'))->toBe(['index' => 2, 'records' => ['Poison', 'Stun', 'New State']]) ->and($session->duplicateDatabaseRecord('states', 0))->toBe(['index' => 1, 'records' => ['Poison', 'Poison', 'Stun', 'New State']]) ->and($session->deleteDatabaseRecord('states', 1))->toBe(['index' => 0, 'records' => ['Poison', 'Stun', 'New State']]) ->and(fn() => $session->moveDatabaseRecord('states', 0, 'down'))->toThrow(SessionRefusal::class, 'would not survive reopening') ->and(fn() => $session->createDatabaseRecord('terms'))->toThrow(SessionRefusal::class, 'Term entries cannot be created from the editor.') - ->and(fn() => $session->deleteDatabaseRecord('types', 0))->toThrow(SessionRefusal::class, 'Read-only: '); + ->and(fn() => $session->deleteDatabaseRecord($unwritable, 0))->toThrow(SessionRefusal::class, 'Read-only: '); expect(array_column([$session->undo(), $session->undo(), $session->undo()], 'label')) ->toBe(['Delete state Poison', 'State duplicate', 'State create']) @@ -129,6 +133,7 @@ function databaseSessionRow(array $read, string $fieldId): array it('saves a category through its own file, and refuses one that cannot be written', function () { $root = makeTemporaryProject(); + $unwritable = makeUnwritableCategory($root); $session = EditorSession::open($root); expect($session->saveDatabase('states'))->toBe(['saved' => false, 'warnings' => [], 'backupFailures' => []]); @@ -140,7 +145,7 @@ function databaseSessionRow(array $read, string $fieldId): array ->and(loadRecordDatabase($root, 'states')->getEntryLabels())->toBe(['Venom', 'Stun']) ->and($session->listDatabaseRecords('states')['dirty'])->toBeFalse() ->and($session->hasUnsavedChanges())->toBeFalse() - ->and(fn() => $session->saveDatabase('types'))->toThrow(SessionRefusal::class, 'Read-only: '); + ->and(fn() => $session->saveDatabase($unwritable))->toThrow(SessionRefusal::class, 'Read-only: '); }); it('serves database authoring over the line protocol', function () { From eef3da87932d0bd38e4cacc72a07c545bb0fa1d0 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 16:37:11 +0200 Subject: [PATCH 157/344] feat(database): edit skills as one record per numbered file in both editors Skills move onto the shared record service over assets/Data/Skills, so attacks, abilities and spells, with their effects, are edited in the terminal and the GUI alike. A skill's kind is a value; a spell alone offers its effect type, and a changed kind or effect type drops what only the old one read. ProjectSkill and ProjectSkillDatabase are gone; the terminal's Effects, Scope and preview panes read the record. Records gain numberedFiles: a new or copied record takes the next number and is listed last, as the Engine's file-order catalogue lists it. A record whose field set depends on it drops the fields its old shape alone offered, and an inline list's type change drops the old type's values, as command frames already did. Validation covers every skill file. --- docs/manual.md | 24 +- docs/roadmap.md | 15 +- src/Database/ProjectRecordDatabase.php | 62 ++- src/Database/RecordSchema.php | 4 + src/Database/RecordSchemaCatalog.php | 116 +++++ src/Database/ReferenceCatalog.php | 11 + src/Editor.php | 430 ++++++---------- src/ProjectSkill.php | 172 ------- src/ProjectSkillDatabase.php | 464 ------------------ src/ProjectWorkspace.php | 32 +- src/Session/EditorSession.php | 9 +- .../AnimationReferenceValidator.php | 24 +- src/Validation/ProjectValidator.php | 10 +- tests/Pest.php | 27 + tests/Support/CutsceneFixtures.php | 39 +- tests/Unit/ActorAttackSkillTest.php | 18 +- tests/Unit/ActorIdentityMigrationUiTest.php | 8 +- tests/Unit/AnimationReferenceTest.php | 119 ++--- tests/Unit/DatabaseDeletionTest.php | 42 -- tests/Unit/EnemyRecordFilesTest.php | 3 +- tests/Unit/ProjectValidatorTest.php | 31 +- tests/Unit/Session/DatabaseSessionTest.php | 2 +- tests/Unit/Session/EditorSessionTest.php | 1 - tests/Unit/SkillRecordFilesTest.php | 70 +++ tests/Unit/SkillSourcePreservationTest.php | 229 --------- 25 files changed, 576 insertions(+), 1386 deletions(-) delete mode 100644 src/ProjectSkill.php delete mode 100644 src/ProjectSkillDatabase.php create mode 100644 tests/Unit/SkillRecordFilesTest.php delete mode 100644 tests/Unit/SkillSourcePreservationTest.php diff --git a/docs/manual.md b/docs/manual.md index fce1fd7..203f058 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1011,7 +1011,7 @@ status line says exactly why. | --- | --- | --- | | Actors | `assets/Data/Actors/*.php` | Editable | | Classes | `assets/Data/classes.php` | Editable - levels, the experience curve, every stat curve, equipment types and skills learned | -| Skills | `assets/Data/skills.php` | Editable - spells and abilities in `magic.php` and `abilities.php` are validated and offered as references, not yet edited here | +| Skills | `assets/Data/Skills/*.php` | Editable - one data record per numbered file, attacks, abilities and spells alike, with their effects; `skills.php` loads them | | Items | `assets/Data/items.php` | Editable - authored as `new Item(...)` calls, edited entry by entry | | Weapons | `assets/Data/items.php` | Editable - authored as `new Weapon(...)` calls, edited entry by entry | | Armors | `assets/Data/items.php` | Editable - authored as `new Armor(...)` calls, edited entry by entry | @@ -1105,6 +1105,19 @@ and starts on the project's first enemy sprite; a duplicate gets a file and a name of its own. A file in the folder that is not an enemy record stays in the list, read-only, saying why. +A skill is one file under `assets/Data/Skills`, returning +`['class' => Skill::class, 'data' => [...]]`. Its kind is a value: `basic` +(an attack), `special` (an ability) or `magic` (a spell, which also states +its effect type). The rest is cost, cooldown, occasion, scope, how it is +invoked, its animation, and its effects: a list under the Effects heading, +each a type with that type's own values (a formula with its element and +variance for damage and recovery, a state for adding one, the states to +remove, or a stat and its stages). Changing an effect's type, or a skill's +kind, drops the values only the old one read. Files are numbered +(`0001-attack.php`) because menus list skills in file order; a new or copied +skill takes the next number and goes last. `skills.php` is the barrel that +loads the folder. + ### Project-Owned Parameters A weapon or armor's **Special Property** parameters and a Permanent Growth @@ -2145,11 +2158,10 @@ spawn points outside the map. The warnings appear in the status footer; `Ctrl+E` shows the full text. The project-wide pass (`ichiloto validate`) also covers every NPC field, as described under [NPC Mode](#npc-mode). -Skills are read as one catalogue across `skills.php`, `abilities.php` and -`magic.php`, as the game reads them: a skill name defined twice, an entry that -is not a skill, or a key that differs from its skill's name is an error, and -save aliases for spells must point at spells and those for abilities at -abilities. +Skills are read as one catalogue from their record files, as the game reads +them: a skill name defined twice, or a record the game cannot read, is an +error naming its file, and save aliases for spells must point at spells and +those for abilities at abilities. Save migration steps are read as the Engine reads them: each declares a project class, or declared position edits, `mapShifts` for inserted rows and diff --git a/docs/roadmap.md b/docs/roadmap.md index eaf6b58..5f6252d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -262,8 +262,9 @@ session reaches a category through `DatabaseCategory`: schema-driven ones actors through `ActorCategory` over `ActorAuthoring`, the actor service the terminal editor uses too. -Progress: Classes, Quests, System, a new Configuration category and Actors -are done in both editors. Skills is the one category the GUI still refuses. +Progress: Classes, Quests, System, a new Configuration category, Actors, +Types and Skills are done in both editors; the GUI session now serves every +category. Known gap: an actor authored without an id whose identity freeze also needs other files rewritten (the cross-file repair `SourceSetCommand` writes at once) is refused in the GUI with a pointer to the terminal's repair dialog; @@ -275,16 +276,16 @@ Order of work, each a complete slice before the next: 1. Classes, Quests, System, Actors and Skills move onto the shared record service. The terminal keeps its workflow and every field and preview it has now (experience and stat curves, actor stats); the GUI gains the category. -2. Data the record files hold but no editor reaches: item and skill effects, - skill kind (both after step 3's per-record move). Enemy drops are done, +2. Data the record files hold but no editor reaches: item effects (after + items' per-record move). Skill effects and kind are done. Enemy drops are done, and every schema record's references, list entries included, are now validated. Enemy `position` and `battleAnimation` are not authored: troops own battle positions (`Troop` overwrites the enemy's), and no runtime code reads an enemy's `battleAnimation`; attacks animate through their skills. The rest of `system.php` is done (System and Configuration). -3. Skills become one declarative record per file, as enemies are, so spells - and abilities in `magic.php` and `abilities.php` are edited too (the - section above). Items, weapons and armors follow. +3. Skills are one declarative record per numbered file under + `assets/Data/Skills` (done; `abilities.php` and `magic.php` are gone). + Items, weapons and armors follow. 4. Tilesets: an editor for `Data/Tilesets/.php` (sheets, pieces, tables, shadows); the "no tileset system" note is stale. Types is done: it edits the elements `system.php` declares, which the element pickers now read diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 3784430..6f01012 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -720,16 +720,40 @@ public function setField(int $index, string $fieldId, string $rawValue): void : $this->assertValueUnclaimed($record, $field, $value); } $previousIdentity = $this->schema->identityKey === null ? null : $record->get($this->schema->identityKey); + $before = $record->toArray(); $record->set($field->key, $value); if ($field->key === $this->schema->labelKey && $this->schema->identityFollowsLabel) { $this->followLabelWithIdentity($record, strval($previousIdentity)); } + $this->dropStaleShapeFields($record, $before); $this->touchState(); return; } } + /** + * Drops the values only a record's old shape offered, when an edit + * changes which fields it offers: a spell turned into an ability keeps + * no spell effect type for the Engine to refuse. As an entry's variant + * does ({@see withoutStaleVariantFields()}). + * + * @param array|object $before The record before the edit. + */ + private function dropStaleShapeFields(ProjectRecord $record, array|object $before): void + { + if ($this->schema->fieldsFor === null || ! is_array($before)) { + return; + } + + $keys = static fn(array $fields): array => array_map(static fn(RecordField $field): string => $field->key, $fields); + $offered = $keys($this->schema->fieldsFor($record->toArray())); + + foreach (array_diff($keys($this->schema->fieldsFor($before)), $offered) as $stale) { + $record->set($stale, null); + } + } + /** * A new record's payload without the list members other records already * hold, for every field held by one record at most. @@ -1163,11 +1187,14 @@ public function duplicateRecord(int $index): ?int } $copy = new ProjectRecord($payload, true, $sourcePath, $recordId, $file); - array_splice($records, $index + 1, 0, [$copy]); + // A numbered copy takes the next number, so it goes last, where + // reopening the folder lists it. + $at = $this->schema->numberedFiles ? count($records) : $index + 1; + array_splice($records, $at, 0, [$copy]); $this->records = $records; $this->touchState(); - return $index + 1; + return $at; } /** @@ -2578,12 +2605,19 @@ private function setSubField(ProjectRecord $record, RecordSubList $subList, int continue; } + $before = $entries[$entryIndex]; $entries[$entryIndex] = self::writeNested( $entries[$entryIndex], explode('.', $field->key), self::coerce($field, $rawValue), ); $entries[$entryIndex] = $subList->removeConflictingFields($entries[$entryIndex], $field->key); + + if ($field->key === $subList->variantKey) { + // A new type keeps nothing only the old one read. + $entries[$entryIndex] = self::withoutStaleVariantFields($subList, $before, $entries[$entryIndex]); + } + $record->setSubList($subList->key, $entries, $subList->scalarKey, $subList->removeWhenEmpty); $this->touchState(); @@ -4246,7 +4280,8 @@ private function makeUniqueIdentity(mixed $preferred): int|string */ private function prepareRecordFile(string $identity, array|object $payload): array { - $stem = $this->makeUniqueFileStem(Slug::of($identity) ?: 'new-' . Slug::of($this->schema->entryNoun)); + $slug = Slug::of($identity) ?: 'new-' . Slug::of($this->schema->entryNoun); + $stem = $this->makeUniqueFileStem($this->schema->numberedFiles ? sprintf('%04d-%s', $this->findNextFileNumber(), $slug) : $slug); $sourcePath = $this->path . DIRECTORY_SEPARATOR . $stem . '.php'; if ($this->schema->listPayloadKey !== null && is_array($payload)) { @@ -4256,6 +4291,27 @@ private function prepareRecordFile(string $identity, array|object $payload): arr return [$stem, $sourcePath, PhpDataFile::load($sourcePath), $payload]; } + /** + * The number after the highest a record file of this category carries, + * on disk or not yet written, so a new record is listed last. + */ + private function findNextFileNumber(): int + { + $stems = [ + ...array_map(static fn(string $file): string => basename($file, '.php'), glob($this->path . DIRECTORY_SEPARATOR . '*.php') ?: []), + ...array_map(static fn(ProjectRecord $record): string => $record->recordId, $this->records), + ]; + $highest = 0; + + foreach ($stems as $stem) { + if (preg_match('/\A(\d+)-/', $stem, $match) === 1) { + $highest = max($highest, (int) $match[1]); + } + } + + return $highest + 1; + } + /** * Returns a file stem not already present in the category directory. * diff --git a/src/Database/RecordSchema.php b/src/Database/RecordSchema.php index 0ea65d4..8059091 100644 --- a/src/Database/RecordSchema.php +++ b/src/Database/RecordSchema.php @@ -54,6 +54,9 @@ * items beside its objectives. Every list's prefix is its own. * @param bool $identityFollowsLabel Whether the identity is its label's slug, kept * in step on rename while nothing refers to it (a quest's id follows its name). + * @param bool $numberedFiles For one-file-per-record categories listed in file + * order (skills): a file is named `0001-.php`, a new or copied record + * takes the next number, and so goes last, as reopening lists it. */ public function __construct( public string $key, @@ -78,6 +81,7 @@ public function __construct( public ?string $recordClass = null, public array $subLists = [], public bool $identityFollowsLabel = false, + public bool $numberedFiles = false, ) { if ($projection instanceof WholeFileProjection) { // A field outside the keys the category owns would read blank and diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index f7ff927..dc1d081 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -29,6 +29,14 @@ use Ichiloto\Engine\Events\Interpreter\EventInterpreter; use Ichiloto\Engine\Entities\Enemies\Enemy; use Ichiloto\Engine\Entities\Enemies\EnemyCatalog; +use Ichiloto\Engine\Entities\Enumerations\ItemScopeNumber; +use Ichiloto\Engine\Entities\Enumerations\ItemScopeSide; +use Ichiloto\Engine\Entities\Enumerations\ItemScopeStatus; +use Ichiloto\Engine\Entities\Enumerations\Occasion; +use Ichiloto\Engine\Entities\Skills\Skill; +use Ichiloto\Engine\Entities\Skills\SkillCatalog; +use Ichiloto\Engine\Entities\Skills\SkillRecord; +use Ichiloto\Engine\Entities\Skills\SkillResolutionScope; use Ichiloto\Engine\Quests\QuestObjectiveType; use Ichiloto\Engine\Battle\Enumerations\BattleEngineType; use Ichiloto\Engine\Core\Enumerations\MovementHeading; @@ -95,6 +103,7 @@ public static function all(): array self::weapons(), self::armors(), self::enemies(), + self::skills(), self::skits(), self::knowledgeSubjects(), self::knowledgeReports(), @@ -1183,6 +1192,113 @@ private static function enemies(): RecordSchema ); } + /** + * Skills: one record per file under `assets/Data/Skills`, in the form + * the Engine's SkillRecord reads, numbered in the order menus list them. + * A skill's kind (an attack, an ability or a spell) is a value of the + * record; a spell also states its effect type. Effects are a list, each + * a `type` with that type's own values. `skills.php` is the barrel that + * returns them. + * + * @return RecordSchema + */ + private static function skills(): RecordSchema + { + $fields = [ + new RecordField('kind', 'Kind', options: array_keys(SkillRecord::KINDS)), + new RecordField('name', 'Name', uniqueAcrossRecords: true), + new RecordField('description', 'Description'), + new RecordField('icon', 'Icon'), + new RecordField('cost', 'Cost', InputControlType::INTEGER), + new RecordField('cooldown', 'Cooldown', InputControlType::INTEGER), + new RecordField('occasion', 'Occasion', options: array_map(static fn(Occasion $occasion): string => $occasion->value, Occasion::cases())), + new RecordField('scope.side', 'Scope Side', options: array_map(static fn(ItemScopeSide $side): string => $side->value, ItemScopeSide::cases())), + new RecordField('scope.number', 'Scope Number', options: array_map(static fn(ItemScopeNumber $number): string => $number->value, ItemScopeNumber::cases())), + new RecordField('scope.status', 'Scope Status', options: array_map(static fn(ItemScopeStatus $status): string => $status->value, ItemScopeStatus::cases())), + new RecordField('scope.targetCount', 'Target Count', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: 'Auto'), + new RecordField('invocation.message', 'Invoke Text'), + new RecordField('invocation.speed', 'Invoke Speed', InputControlType::INTEGER), + new RecordField('invocation.accuracy', 'Accuracy', InputControlType::INTEGER), + new RecordField('invocation.repeat', 'Repeat', InputControlType::INTEGER), + new RecordField('invocation.apGain', 'AP Gain', InputControlType::INTEGER), + new RecordField('invocation.hitScope', 'Hit Roll', reference: 'resolution_scopes', removeWhenEmpty: true, allowsNone: true, displayDefault: SkillResolutionScope::PER_HIT->value), + new RecordField('invocation.criticalScope', 'Critical Roll', reference: 'resolution_scopes', removeWhenEmpty: true, allowsNone: true, displayDefault: SkillResolutionScope::PER_HIT->value), + new RecordField( + 'animationId', + 'Animation', + InputControlType::INTEGER, + reference: 'animation_ids', + removeWhenEmpty: true, + allowsNone: true, + displayDefault: '(Legacy fallback)', + ), + ]; + // Only a spell has an effect type; left out, the Engine infers it + // from the spell's effects. + $effectType = new RecordField('effectType', 'Effect Type', reference: 'magic_effect_types', removeWhenEmpty: true, allowsNone: true, displayDefault: '(from its effects)'); + $formula = static fn(bool $resolves): array => [ + new RecordField('formula', 'Formula'), + new RecordField('element', 'Element', reference: 'elements', removeWhenEmpty: true, allowsNone: true, displayDefault: '(none)'), + new RecordField('variance', 'Variance', InputControlType::FLOAT), + new RecordField('isCriticalHit', 'Can Critical', InputControlType::BOOLEAN, removeWhenEmpty: true), + ...($resolves ? [new RecordField('resolutionKind', 'Resolves As', reference: 'resolution_kinds', removeWhenEmpty: true, allowsNone: true, displayDefault: '(by effect)')] : []), + ]; + $variants = []; + + foreach (array_keys(SkillRecord::FORMULA_EFFECTS) as $type) { + $variants[$type] = $formula($type === 'hp_damage'); + } + + $variants['add_state'] = [ + RecordField::reference('stateId', 'State', 'states'), + new RecordField('chancePercent', 'Chance %', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '100'), + ]; + $variants['remove_state'] = [ + new RecordField('stateIds', 'States', reference: 'states', codec: RecordFieldCodec::CSV_LIST), + ]; + $variants['modify_stat_stage'] = [ + new RecordField('stat', 'Stat', options: Character::buffableStats()), + new RecordField('delta', 'Stages', InputControlType::INTEGER), + new RecordField('affectsUser', 'On User', InputControlType::BOOLEAN, removeWhenEmpty: true), + ]; + + return new RecordSchema( + key: 'skills', + entryNoun: 'skill', + storage: RecordStorage::DIRECTORY, + relativePath: 'assets/Data/' . SkillCatalog::DIRECTORY, + fields: $fields, + labelKey: 'name', + identityKey: 'name', + blank: [ + 'kind' => 'special', + 'name' => 'New Skill', + 'description' => '', + 'icon' => '', + 'cost' => 0, + 'cooldown' => 0, + 'occasion' => Occasion::BATTLE_SCREEN->value, + 'scope' => ['side' => ItemScopeSide::ENEMY->value, 'number' => ItemScopeNumber::ONE->value, 'status' => ItemScopeStatus::ALIVE->value], + 'invocation' => ['message' => '$1 uses $2!', 'speed' => 0, 'accuracy' => 100, 'repeat' => 1, 'apGain' => 10], + 'effects' => [], + ], + subList: new RecordSubList( + key: 'effects', + prefix: 'effect', + singular: 'effect', + fields: [ + new RecordField('type', 'Type', options: [...array_keys(SkillRecord::FORMULA_EFFECTS), ...array_keys(SkillRecord::STATE_EFFECTS)]), + ], + blank: ['type' => 'hp_damage', 'formula' => '$user->stats->attack * 2', 'variance' => 0.2], + variants: $variants, + variantKey: 'type', + ), + fieldsFor: static fn(array $row): array => strval($row['kind'] ?? '') === 'magic' ? [...$fields, $effectType] : $fields, + recordClass: Skill::class, + numberedFiles: true, + ); + } + /** * Skits — one file per skit under `assets/Data/Skits`, scanned by the * engine's `SkitManager`. Fully editable, beats included. diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 25be5e1..f392802 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -18,6 +18,9 @@ use Ichiloto\Engine\Entities\Skills\BasicSkill; use Ichiloto\Engine\Entities\Skills\Skill; use Ichiloto\Engine\Entities\Enumerations\WeaponType; +use Ichiloto\Engine\Battle\Resolution\ResolutionKind; +use Ichiloto\Engine\Entities\Magic\MagicEffectType; +use Ichiloto\Engine\Entities\Skills\SkillResolutionScope; use InvalidArgumentException; /** @@ -40,6 +43,9 @@ final class ReferenceCatalog 'actor_ids', 'classes', 'attack_skills', + 'resolution_kinds', + 'resolution_scopes', + 'magic_effect_types', 'weapon_types', 'armor_types', 'skills', @@ -139,6 +145,11 @@ public function valuesFor(string $category): array // catalogue that can be used in battle. 'attack_skills' => $this->attackSkillNames(), // A class restricts what its members equip by the engine's type names. + // How a skill effect resolves, how often a skill rolls, and what + // kind of spell it is: the Engine's own vocabularies. + 'resolution_kinds' => array_map(static fn(ResolutionKind $kind): string => $kind->value, ResolutionKind::cases()), + 'resolution_scopes' => array_map(static fn(SkillResolutionScope $scope): string => $scope->value, SkillResolutionScope::cases()), + 'magic_effect_types' => array_map(static fn(MagicEffectType $type): string => $type->value, MagicEffectType::cases()), 'weapon_types' => array_map(static fn(WeaponType $type): string => $type->value, WeaponType::cases()), 'armor_types' => array_map(static fn(ArmorType $type): string => $type->value, ArmorType::cases()), // Spells and abilities may be authored in any of the Engine's diff --git a/src/Editor.php b/src/Editor.php index f127a39..80b21a4 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -107,7 +107,10 @@ use Ichiloto\Engine\Entities\Magic\MagicEffectType; use Ichiloto\Engine\Entities\Roles\ExperienceCurveGenerator; use Ichiloto\Engine\Entities\Roles\ParameterCurveGenerator; +use Ichiloto\Engine\Entities\Skills\MagicSkill; +use Ichiloto\Engine\Entities\Skills\Skill; use Ichiloto\Engine\Entities\Skills\SkillCatalog; +use Ichiloto\Engine\Entities\Skills\SkillRecord; use Ichiloto\Engine\Events\Enumerations\ChestType; use Ichiloto\Engine\Events\Enumerations\LootType; use Ichiloto\Engine\Quests\QuestObjectiveType; @@ -115,6 +118,7 @@ use Ichiloto\Engine\Rendering\Tilesets\Tileset; use RuntimeException; use Throwable; +use InvalidArgumentException; /** * Launches the Ichiloto terminal editor shell. @@ -565,7 +569,6 @@ final class Editor private int $databaseCategoryIndex = 9; private string $databaseFocus = self::DATABASE_FOCUS_CATEGORIES; private int $databaseSelectedActorIndex = 0; - private int $databaseSelectedSkillIndex = 0; /** * Selected entry index per schema-driven category, keyed by category key. * @@ -877,7 +880,6 @@ private function boot(): void $this->databaseCategoryIndex = DatabaseCatalog::indexOf(self::DATABASE_CATEGORY_ACTORS); $this->databaseFocus = self::DATABASE_FOCUS_CATEGORIES; $this->databaseSelectedActorIndex = 0; - $this->databaseSelectedSkillIndex = 0; $this->databaseSelectedSettingIndex = 0; $this->isDatabaseEditing = false; $this->databaseEditBuffer = ''; @@ -2794,7 +2796,6 @@ private function openDatabaseWindow(): void $this->databaseCategoryIndex = DatabaseCatalog::indexOf(self::DATABASE_CATEGORY_ACTORS); $this->databaseFocus = self::DATABASE_FOCUS_CATEGORIES; $this->databaseSelectedActorIndex = 0; - $this->databaseSelectedSkillIndex = 0; $this->databaseSelectedSettingIndex = 0; $this->isDatabaseEditing = false; $this->databaseEditBuffer = ''; @@ -3116,11 +3117,6 @@ private function moveDatabaseListSelection(int $step): void return; } - if ($this->isSkillsDatabaseSelected()) { - $this->moveDatabaseSkillSelection($step); - return; - } - $this->moveDatabaseRecordSelection($step); } @@ -3151,33 +3147,6 @@ private function moveDatabaseActorSelection(int $step): void $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); } - /** - * Moves the selected skill entry. - * - * @param int $step The entry step. - * @return void - */ - private function moveDatabaseSkillSelection(int $step): void - { - $skills = $this->workspace?->skillDatabase->getSkills() ?? []; - - if ($skills === []) { - return; - } - - $nextIndex = $this->resolveDatabaseSelectionStep($this->databaseSelectedSkillIndex, $step); - - if ($nextIndex === $this->databaseSelectedSkillIndex) { - return; - } - - $this->databaseSelectedSkillIndex = $nextIndex; - $this->databaseSelectedSettingIndex = 0; - $this->statusMessage = sprintf("Selected skill %s.", $skills[$nextIndex]->getName()); - $this->renderDatabasePanes(["list", "settings", "cue", "frames", "preview"]); - } - - /** * Moves the selected settings field. * @@ -5697,13 +5666,6 @@ private function getDatabaseEntryLabels(): array ); } - if ($this->isSkillsDatabaseSelected()) { - return array_map( - static fn(ProjectSkill $skill): string => $skill->getName(), - $this->workspace?->skillDatabase->getSkills() ?? [], - ); - } - return $this->getSelectedRecordDatabase()?->getEntryLabels() ?? []; } @@ -5757,7 +5719,6 @@ private function getSelectedDatabaseEntryIndex(): int { return match (true) { $this->isActorsDatabaseSelected() => $this->databaseSelectedActorIndex, - $this->isSkillsDatabaseSelected() => $this->databaseSelectedSkillIndex, default => $this->getSelectedRecordIndex(), }; } @@ -5774,7 +5735,6 @@ private function setSelectedDatabaseEntryIndex(int $index): void match (true) { $this->isActorsDatabaseSelected() => $this->databaseSelectedActorIndex = $index, - $this->isSkillsDatabaseSelected() => $this->databaseSelectedSkillIndex = $index, default => $this->setSelectedRecordIndex($index), }; } @@ -5913,15 +5873,6 @@ private function openDatabaseEntryDeleteConfirmation(): void return; } - if ($this->getSelectedDatabaseCategoryDefinition()->key === self::DATABASE_CATEGORY_SKILLS) { - $reason = $this->workspace->skillDatabase->getReadOnlyReason($index); - if ($reason !== null) { - $this->setStatus($reason, StatusLevel::WARN); - $this->renderFooter(); - return; - } - } - $this->pendingDatabaseDeletion = [ 'category' => $this->getSelectedDatabaseCategoryDefinition()->key, 'index' => $index, @@ -5990,12 +5941,6 @@ private function confirmDatabaseEntryDeletion(): void $label = $pending['label']; $command = match ($pending['category']) { self::DATABASE_CATEGORY_ACTORS => $this->actorAuthoring->deleteActor($workspace, $index)->command, - self::DATABASE_CATEGORY_SKILLS => $this->buildDatabaseDeletionCommand( - sprintf('Delete skill %s', $label), - fn(): ?object => $workspace->skillDatabase->removeSkill($index), - static fn(object $entry) => $workspace->skillDatabase->insertSkill($index, $entry), - ), - default => $this->buildRecordDeletionCommand($pending['category'], $index), }; @@ -6191,11 +6136,13 @@ private function goToSkillAnimation(): void { $skill = $this->getSelectedSkill(); - if (! $skill instanceof ProjectSkill) { + if (! $skill instanceof Skill) { + $this->setStatus('This skill does not read as one yet; validation says why.', StatusLevel::WARN); + $this->renderFooter(); return; } - $candidates = array_values(array_filter([$skill->getName(), $skill->getEffectType() ?? ''])); + $candidates = array_values(array_filter([$skill->name, $skill instanceof MagicSkill ? ($skill->effectType?->value ?? '') : ''])); $animationIndex = null; $matchedName = ''; @@ -6222,14 +6169,14 @@ private function goToSkillAnimation(): void if ($animationIndex === null) { $this->setStatus( - sprintf('No animation found for "%s". Choose its Animation in the settings picker.', $skill->getName()), + sprintf('No animation found for "%s". Choose its Animation in the settings picker.', $skill->name), StatusLevel::WARN, ); $this->renderFooter(); return; } - $this->pushNavigationOrigin(sprintf('skill %s', $skill->getName())); + $this->pushNavigationOrigin(sprintf('skill %s', $skill->name)); $this->databaseFilter->clear(); $this->databaseCategoryIndex = DatabaseCatalog::indexOf(self::DATABASE_CATEGORY_ANIMATIONS); $this->setSelectedRecordIndex($animationIndex); @@ -6384,7 +6331,6 @@ private function captureNavigationRestorer(): Closure 'databaseCategoryIndex' => $this->databaseCategoryIndex, 'databaseFocus' => $this->databaseFocus, 'actor' => $this->databaseSelectedActorIndex, - 'skill' => $this->databaseSelectedSkillIndex, 'records' => $this->databaseSelectedRecordIndexes, 'setting' => $this->databaseSelectedSettingIndex, 'assetIndex' => $this->selectedAssetIndex, @@ -6402,7 +6348,6 @@ private function captureNavigationRestorer(): Closure $this->databaseCategoryIndex = $snapshot['databaseCategoryIndex']; $this->databaseFocus = $snapshot['databaseFocus']; $this->databaseSelectedActorIndex = $snapshot['actor']; - $this->databaseSelectedSkillIndex = $snapshot['skill']; $this->databaseSelectedRecordIndexes = $snapshot['records']; $this->databaseSelectedSettingIndex = $snapshot['setting']; $this->selectedAssetIndex = $snapshot['assetIndex']; @@ -9121,17 +9066,143 @@ private function getSelectedActor(): ?ProjectActor } /** - * Returns the selected skill from the project database. + * The selected skill's record data, as the shared record holds it, + * unsaved edits included. * - * @return ProjectSkill|null + * @return array|null */ - private function getSelectedSkill(): ?ProjectSkill + private function getSelectedSkillData(): ?array { if (! $this->isSkillsDatabaseSelected()) { return null; } - return $this->workspace?->skillDatabase->getSkillByIndex($this->databaseSelectedSkillIndex); + $record = $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()); + + return $record === null ? null : (array) $record->toArray(); + } + + /** + * The selected skill as the game builds it from its record, or null when + * none is selected or its record does not read as a skill yet. + */ + private function getSelectedSkill(): ?Skill + { + $data = $this->getSelectedSkillData(); + + try { + return $data === null ? null : SkillRecord::readSkill($data); + } catch (InvalidArgumentException) { + return null; + } + } + + /** + * One effect of a skill record, as the Effects pane lists it. + * + * @param array $effect The effect's record data. + */ + private static function describeSkillEffect(array $effect): string + { + $parts = match (true) { + isset($effect['formula']) => [strval($effect['formula'])], + isset($effect['stateId']) => [sprintf('%s, %d%%', strval($effect['stateId']), intval($effect['chancePercent'] ?? 100))], + isset($effect['stateIds']) && is_array($effect['stateIds']) => [implode(', ', array_map('strval', $effect['stateIds']))], + isset($effect['stat']) => [sprintf('%s %+d%s', strval($effect['stat']), intval($effect['delta'] ?? 0), ($effect['affectsUser'] ?? false) === true ? ' on the user' : '')], + default => [], + }; + + foreach (['element', 'resolutionKind'] as $key) { + if (isset($effect[$key])) { + $parts[] = strval($effect[$key]); + } + } + + if (isset($effect['variance'])) { + $parts[] = sprintf('±%d%%', (int) round(floatval($effect['variance']) * 100)); + } + + return sprintf('%s: %s', ucfirst(str_replace('_', ' ', strval($effect['type'] ?? 'effect'))), implode(' · ', $parts)); + } + + /** + * The selected skill's effects, one line each. + * + * @return string[] + */ + private function getDatabaseSkillCueLines(): array + { + $data = $this->getSelectedSkillData(); + + if ($data === null) { + return ['No skill selected.']; + } + + $effects = array_filter((array) ($data['effects'] ?? []), is_array(...)); + + return $effects === [] ? ['No effects configured.'] : array_values(array_map(self::describeSkillEffect(...), $effects)); + } + + /** + * Whom the selected skill reaches, when, and how often it rolls. + * + * @return string[] + */ + private function getDatabaseSkillFrameLines(): array + { + $data = $this->getSelectedSkillData(); + + if ($data === null) { + return ['No skill selected.']; + } + + $scope = (array) ($data['scope'] ?? []); + $invocation = (array) ($data['invocation'] ?? []); + + return [ + sprintf('Side: %s', strval($scope['side'] ?? '')), + sprintf('Number: %s', strval($scope['number'] ?? '')), + sprintf('Status: %s', strval($scope['status'] ?? '')), + sprintf('Targets: %s', ($scope['targetCount'] ?? null) === null ? 'Auto' : strval($scope['targetCount'])), + '', + sprintf('Occasion: %s', strval($data['occasion'] ?? '')), + sprintf('Repeat: %d', intval($invocation['repeat'] ?? 1)), + sprintf('AP Gain: %d', intval($invocation['apGain'] ?? 0)), + ]; + } + + /** + * A summary of the selected skill: its number, kind, cost, scope and effects. + * + * @return string[] + */ + private function getDatabaseSkillPreviewLines(): array + { + $data = $this->getSelectedSkillData(); + $record = $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()); + + if ($data === null || $record === null) { + return ['No skill selected.']; + } + + $scope = (array) ($data['scope'] ?? []); + $invocation = (array) ($data['invocation'] ?? []); + $effects = array_filter((array) ($data['effects'] ?? []), is_array(...)); + + return [ + sprintf('File: %s/%s.php', SkillCatalog::DIRECTORY, $record->recordId), + sprintf('Name: %s', strval($data['name'] ?? '')), + sprintf('Kind: %s', ucfirst(strval($data['kind'] ?? ''))), + sprintf('Occasion: %s', strval($data['occasion'] ?? '')), + sprintf('Cost: %d MP', intval($data['cost'] ?? 0)), + sprintf('Cooldown: %d', intval($data['cooldown'] ?? 0)), + '', + sprintf('Scope: %s / %s / %s', strval($scope['side'] ?? ''), strval($scope['number'] ?? ''), strval($scope['status'] ?? '')), + sprintf('Invoke: %s', strval($invocation['message'] ?? '')), + '', + 'Effects', + ...($effects === [] ? ['No effects configured.'] : array_values(array_map(self::describeSkillEffect(...), $effects))), + ]; } /** @@ -9184,11 +9255,6 @@ private function createDatabaseEntry(): void return; } - if ($this->isSkillsDatabaseSelected()) { - $this->createDatabaseSkill(); - return; - } - $this->createDatabaseRecord(); } @@ -9215,25 +9281,6 @@ private function createDatabaseActor(): void $this->beginDatabaseEdit(); } - /** - * Creates a new skill entry in the project database. - * - * @return void - */ - private function createDatabaseSkill(): void - { - if (! $this->workspace instanceof ProjectWorkspace) { - return; - } - - $this->databaseSelectedSkillIndex = $this->workspace->skillDatabase->addSkill(); - $this->databaseSelectedSettingIndex = 0; - $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; - $this->statusMessage = "Created a new skill."; - $this->renderDatabasePanes(["list", "settings", "cue", "frames", "preview"]); - $this->beginDatabaseEdit(); - } - /** * Saves the active Database category. * @@ -9250,10 +9297,6 @@ private function saveActiveDatabase(): void $this->backupBeforeSave(...$this->workspace->actorDatabase->getBackupPaths()); $this->workspace->actorDatabase->save(); $this->setStatus('Actor database saved.', StatusLevel::SUCCESS); - } elseif ($this->isSkillsDatabaseSelected()) { - $this->backupBeforeSave(...$this->workspace->skillDatabase->getBackupPaths()); - $this->workspace->skillDatabase->save(); - $this->setStatus('Skill database saved.', StatusLevel::SUCCESS); } elseif (($recordDatabase = $this->getSelectedRecordDatabase()) instanceof ProjectRecordDatabase) { if (! $recordDatabase->isEditable()) { $this->setStatus($this->describeRecordReadOnly($recordDatabase), StatusLevel::WARN); @@ -9301,10 +9344,6 @@ private function getDatabaseSettingsFields(): array : []; } - if ($this->isSkillsDatabaseSelected()) { - return $this->getDatabaseSkillSettingsFields(); - } - $recordDatabase = $this->getSelectedRecordDatabase(); if ($recordDatabase instanceof ProjectRecordDatabase) { @@ -9336,61 +9375,6 @@ private function getDatabaseSettingsFields(): array } - /** - * Returns the editable settings fields for the selected skill. - * - * @return array> - */ - private function getDatabaseSkillSettingsFields(): array - { - $skill = $this->getSelectedSkill(); - - if (! $skill instanceof ProjectSkill) { - return []; - } - - $scope = $skill->getScope(); - $invocation = $skill->getInvocation(); - - $fields = [ - ['label' => 'Name', 'value' => $skill->getName(), 'control' => new InputControl(InputControlType::TEXT, $skill->getName()), 'field' => 'name'], - ['label' => 'Description', 'value' => $skill->getDescription(), 'control' => new InputControl(InputControlType::TEXT, $skill->getDescription()), 'field' => 'description'], - ['label' => 'Icon', 'value' => $skill->getIcon(), 'control' => new InputControl(InputControlType::TEXT, $skill->getIcon()), 'field' => 'icon'], - ['label' => 'Cost', 'value' => (string) $skill->getCost(), 'control' => new InputControl(InputControlType::INTEGER, (string) $skill->getCost()), 'field' => 'cost'], - ['label' => 'Cooldown', 'value' => (string) $skill->getCooldown(), 'control' => new InputControl(InputControlType::INTEGER, (string) $skill->getCooldown()), 'field' => 'cooldown'], - ['label' => 'Occasion', 'value' => $skill->getOccasion(), 'options' => array_map(static fn(Occasion $occasion): string => $occasion->value, Occasion::cases()), 'field' => 'occasion'], - ['label' => 'Scope Side', 'value' => (string) ($scope['side'] ?? ItemScopeSide::ENEMY->value), 'options' => array_map(static fn(ItemScopeSide $side): string => $side->value, ItemScopeSide::cases()), 'field' => 'scopeSide'], - ['label' => 'Scope Number', 'value' => (string) ($scope['number'] ?? ItemScopeNumber::ONE->value), 'options' => array_map(static fn(ItemScopeNumber $number): string => $number->value, ItemScopeNumber::cases()), 'field' => 'scopeNumber'], - ['label' => 'Scope Status', 'value' => (string) ($scope['status'] ?? ItemScopeStatus::ALIVE->value), 'options' => array_map(static fn(ItemScopeStatus $status): string => $status->value, ItemScopeStatus::cases()), 'field' => 'scopeStatus'], - ['label' => 'Target Count', 'value' => (string) ($scope['targetCount'] ?? ''), 'control' => new InputControl(InputControlType::INTEGER, (string) ($scope['targetCount'] ?? '')), 'field' => 'scopeTargetCount'], - ['label' => 'Invoke Text', 'value' => (string) ($invocation['message'] ?? ''), 'control' => new InputControl(InputControlType::TEXT, (string) ($invocation['message'] ?? '')), 'field' => 'invocationMessage'], - ['label' => 'Invoke Speed', 'value' => (string) ($invocation['speed'] ?? 0), 'control' => new InputControl(InputControlType::INTEGER, (string) ($invocation['speed'] ?? 0)), 'field' => 'invocationSpeed'], - ['label' => 'Accuracy', 'value' => (string) ($invocation['accuracy'] ?? 0), 'control' => new InputControl(InputControlType::INTEGER, (string) ($invocation['accuracy'] ?? 0)), 'field' => 'invocationAccuracy'], - ['label' => 'Repeat', 'value' => (string) ($invocation['repeat'] ?? 1), 'control' => new InputControl(InputControlType::INTEGER, (string) ($invocation['repeat'] ?? 1)), 'field' => 'invocationRepeat'], - ['label' => 'AP Gain', 'value' => (string) ($invocation['apGain'] ?? 10), 'control' => new InputControl(InputControlType::INTEGER, (string) ($invocation['apGain'] ?? 10)), 'field' => 'invocationApGain'], - ['label' => 'Effect Type', 'value' => $skill->getEffectType() ?? MagicEffectType::DESTRUCTIVE->value, 'options' => array_map(static fn(MagicEffectType $effectType): string => $effectType->value, MagicEffectType::cases()), 'field' => 'effectType'], - [ - 'label' => 'Animation', - 'value' => $skill->animationId === null ? '(Legacy fallback)' : (string) $skill->animationId, - 'field' => 'animationId', - 'reference' => 'animation_ids', - 'allowsNone' => true, - 'noneLabel' => '(Legacy fallback)', - ], - ]; - $database = $this->workspace->skillDatabase; - $fields = array_values(array_filter($fields, fn(array $field): bool => - $database->supportsField($this->databaseSelectedSkillIndex, $field['field']))); - $reason = $database->getReadOnlyReason($this->databaseSelectedSkillIndex); - if ($reason !== null) { - foreach ($fields as &$field) { - $field['editable'] = false; - } - unset($field); - array_unshift($fields, ['label' => 'Read-only', 'value' => $reason, 'editable' => false]); - } - return $fields; - } /** * Returns the input control for a database settings field when editable. * @@ -10825,7 +10809,6 @@ private function applyDatabaseFieldValueRecorded(array $field, string $rawValue) $identity = [ 'category' => $this->databaseCategoryIndex, 'actor' => $this->databaseSelectedActorIndex, - 'skill' => $this->databaseSelectedSkillIndex, 'records' => $this->databaseSelectedRecordIndexes, ]; @@ -10895,12 +10878,10 @@ private function applyDatabaseFieldValueAt(array $identity, string $field, strin $liveSelection = [ $this->databaseCategoryIndex, $this->databaseSelectedActorIndex, - $this->databaseSelectedSkillIndex, $this->databaseSelectedRecordIndexes, ]; $this->databaseCategoryIndex = $identity['category']; $this->databaseSelectedActorIndex = $identity['actor']; - $this->databaseSelectedSkillIndex = $identity['skill']; $this->databaseSelectedRecordIndexes = $identity['records'] ?? $this->databaseSelectedRecordIndexes; try { @@ -10909,7 +10890,6 @@ private function applyDatabaseFieldValueAt(array $identity, string $field, strin [ $this->databaseCategoryIndex, $this->databaseSelectedActorIndex, - $this->databaseSelectedSkillIndex, $this->databaseSelectedRecordIndexes, ] = $liveSelection; } @@ -10928,14 +10908,6 @@ private function applyDatabaseFieldValue(string $field, string $rawValue): void return; } - if ($this->isSkillsDatabaseSelected()) { - $value = in_array($field, ["cost", "cooldown", "invocationSpeed", "invocationAccuracy", "invocationRepeat", "invocationApGain"], true) - ? max(0, intval($rawValue)) - : ($field === "scopeTargetCount" ? $rawValue : trim($rawValue)); - $this->workspace->skillDatabase->setField($this->databaseSelectedSkillIndex, $field, $value); - return; - } - $recordDatabase = $this->getSelectedRecordDatabase(); if ($recordDatabase instanceof ProjectRecordDatabase) { @@ -13143,10 +13115,6 @@ private function getDatabaseListLines(): array return $this->getDatabaseActorListLines(); } - if ($this->isSkillsDatabaseSelected()) { - return $this->getDatabaseSkillListLines(); - } - if ($this->getSelectedRecordDatabase() instanceof ProjectRecordDatabase) { return $this->getDatabaseRecordListLines(); } @@ -13203,10 +13171,14 @@ private function getDatabaseRecordListLines(): array $prefix = $index === $selectedIndex ? '> ' : ' '; $record = $database->getRecordByIndex($index); $dirty = $record?->isDirty() ? ' *' : ''; - // Entries identified by a number (classes, animations) list it - // before their name. + // Entries identified by a number (classes, animations), or kept in + // numbered files (skills), list it before their name. $identity = $database->schema->identityKey === null ? null : $record?->get($database->schema->identityKey); - $number = is_int($identity) ? sprintf('%04d ', $identity) : ''; + $number = match (true) { + is_int($identity) => sprintf('%04d ', $identity), + $database->schema->numberedFiles && preg_match('/\A(\d+)-/', (string) $record?->recordId, $match) === 1 => $match[1] . ' ', + default => '', + }; $lines[] = sprintf('%s%s%s%s', $prefix, $number, $label, $dirty); } @@ -13254,37 +13226,6 @@ private function getDatabaseActorListLines(): array return $lines === [] ? ['No matches.'] : $lines; } - /** - * Returns the skill list lines. - * - * @return string[] - */ - private function getDatabaseSkillListLines(): array - { - $skills = $this->workspace?->skillDatabase->getSkills() ?? []; - - if ($skills === []) { - return ["No skills yet.", "", "Shift+A to create one."]; - } - - $lines = []; - - foreach ($this->getVisibleDatabaseEntryIndexes() as $index) { - $skill = $skills[$index] ?? null; - - if (! $skill instanceof ProjectSkill) { - continue; - } - - $prefix = $index === $this->databaseSelectedSkillIndex ? "> " : " "; - $dirty = $skill->isDirty() ? " *" : ""; - $lines[] = sprintf("%s%04d %s%s", $prefix, $skill->id, $skill->getName(), $dirty); - } - - return $lines; - } - - /** * Returns the current Database settings lines. * @@ -13585,23 +13526,6 @@ private function getDatabaseQuestCueLines(): array return $quest->getObjectiveSummaryLines(); } - /** - * Returns the skill effect summary lines. - * - * @return string[] - */ - private function getDatabaseSkillCueLines(): array - { - $skill = $this->getSelectedSkill(); - - if (! $skill instanceof ProjectSkill) { - return ["No skill selected."]; - } - - return $skill->getEffectSummaryLines(); - } - - /** * Returns the execution-order lines for the battle-entry rules cue. * @@ -13752,34 +13676,6 @@ private function getDatabaseQuestFrameLines(): array ]; } - /** - * Returns the skill scope summary lines. - * - * @return string[] - */ - private function getDatabaseSkillFrameLines(): array - { - $skill = $this->getSelectedSkill(); - - if (! $skill instanceof ProjectSkill) { - return ["No skill selected."]; - } - - $scope = $skill->getScope(); - $invocation = $skill->getInvocation(); - - return [ - sprintf("Side: %s", (string) ($scope["side"] ?? "Enemy")), - sprintf("Number: %s", (string) ($scope["number"] ?? "One")), - sprintf("Status: %s", (string) ($scope["status"] ?? "Alive")), - sprintf("Targets: %s", ($scope["targetCount"] ?? null) === null ? "Auto" : (string) $scope["targetCount"]), - "", - sprintf("Occasion: %s", $skill->getOccasion()), - sprintf("Repeat: %d", (int) ($invocation["repeat"] ?? 1)), - sprintf("AP Gain: %d", (int) ($invocation["apGain"] ?? 10)), - ]; - } - /** * Returns system behavior notes. * @@ -13913,37 +13809,6 @@ private function getDatabaseQuestPreviewLines(): array ]; } - /** - * Returns the preview lines for the selected skill. - * - * @return string[] - */ - private function getDatabaseSkillPreviewLines(): array - { - $skill = $this->getSelectedSkill(); - - if (! $skill instanceof ProjectSkill) { - return ["No skill selected."]; - } - - $scope = $skill->getScope(); - $invocation = $skill->getInvocation(); - $lines = [ - sprintf("Skill ID: %04d", $skill->id), - sprintf("Name: %s", $skill->getName()), - sprintf("Type: %s", ucfirst($skill->getType())), - sprintf("Occasion: %s", $skill->getOccasion()), - sprintf("Cost: %d MP", $skill->getCost()), - sprintf("Cooldown: %d", $skill->getCooldown()), - "", - sprintf("Scope: %s / %s / %s", (string) ($scope["side"] ?? "Enemy"), (string) ($scope["number"] ?? "One"), (string) ($scope["status"] ?? "Alive")), - sprintf("Invoke: %s", (string) ($invocation["message"] ?? "")), - "", - "Effects", - ]; - - return array_merge($lines, $skill->getEffectSummaryLines()); - } /** * Returns the preview lines for the system database. * @@ -14050,7 +13915,6 @@ private function isDatabaseCategoryDirty(string $categoryKey): bool return match ($categoryKey) { self::DATABASE_CATEGORY_ACTORS => $this->workspace->actorDatabase->isDirty(), - self::DATABASE_CATEGORY_SKILLS => $this->workspace->skillDatabase->isDirty(), default => $this->workspace->getRecordDatabase($categoryKey)?->isDirty() ?? false, }; } diff --git a/src/ProjectSkill.php b/src/ProjectSkill.php deleted file mode 100644 index 0b76d18..0000000 --- a/src/ProjectSkill.php +++ /dev/null @@ -1,172 +0,0 @@ - isset($this->payload['animationId']) ? intval($this->payload['animationId']) : null; - } - - public function __construct( - public readonly int $id, - private array $payload, - bool $isDirty = false, - ) { - if (! $isDirty) { - $this->captureBaseline(); - } - } - - public static function fromSkill(Skill $skill, int $id): self - { - return new self($id, [ - "type" => match (true) { - $skill instanceof MagicSkill => "magic", - $skill instanceof SpecialSkill => "special", - default => "basic", - }, - "name" => $skill->name, - "description" => $skill->description, - "icon" => $skill->icon, - "cost" => $skill->cost, - "cooldown" => $skill->cooldown, - "occasion" => $skill->occasion->value, - "scope" => [ - "side" => $skill->scope->side->value, - "number" => $skill->scope->number->value, - "status" => $skill->scope->status->value, - "targetCount" => $skill->scope->targetCount, - ], - "invocation" => [ - "message" => $skill->invocation->message, - "speed" => $skill->invocation->speed, - "accuracy" => $skill->invocation->accuracy, - "repeat" => $skill->invocation->repeat, - "apGain" => $skill->invocation->apGain, - ], - "effects" => array_map( - static fn(SkillEffect $effect): array => [ - "class" => $effect::class, - "formula" => $effect->formula, - "element" => $effect->element, - "variance" => $effect->variance, - "isCriticalHit" => $effect->isCriticalHit, - ], - $skill->effects, - ), - "effectType" => $skill instanceof MagicSkill ? $skill->effectType?->value : null, - "animationId" => $skill->animationId, - ]); - } - - public static function createBlank(int $id, string $name = "New Skill"): self - { - return new self($id, [ - "type" => "special", - "name" => $name, - "description" => "", - "icon" => "", - "cost" => 0, - "cooldown" => 0, - "occasion" => Occasion::BATTLE_SCREEN->value, - "scope" => [ - "side" => "Enemy", - "number" => "One", - "status" => "Alive", - "targetCount" => null, - ], - "invocation" => [ - "message" => "$1 uses $2!", - "speed" => 0, - "accuracy" => 0, - "repeat" => 1, - "apGain" => 10, - ], - "effects" => [], - "effectType" => null, - ], true); - } - - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - return serialize($this->toArray()); - } - - public function getType(): string { return strval($this->payload["type"] ?? "special"); } - public function getName(): string { return strval($this->payload["name"] ?? "Skill"); } - public function getDescription(): string { return strval($this->payload["description"] ?? ""); } - public function getIcon(): string { return strval($this->payload["icon"] ?? ""); } - public function getCost(): int { return max(0, intval($this->payload["cost"] ?? 0)); } - public function getCooldown(): int { return max(0, intval($this->payload["cooldown"] ?? 0)); } - public function getOccasion(): string { return strval($this->payload["occasion"] ?? Occasion::BATTLE_SCREEN->value); } - public function getScope(): array { return is_array($this->payload["scope"] ?? null) ? $this->payload["scope"] : []; } - public function getInvocation(): array { return is_array($this->payload["invocation"] ?? null) ? $this->payload["invocation"] : []; } - public function getEffects(): array { return is_array($this->payload["effects"] ?? null) ? array_values($this->payload["effects"]) : []; } - public function getEffectType(): ?string { $value = $this->payload["effectType"] ?? null; return is_string($value) && $value !== "" ? $value : null; } - - public function setField(string $field, mixed $value): void - { - if ($field === 'animationId') { - $this->payload[$field] = $value === null || trim(strval($value)) === '' ? null : intval($value); - $this->touchState(); - return; - } - - if (in_array($field, ["name", "description", "icon", "type", "occasion", "effectType"], true)) { - $this->payload[$field] = is_string($value) ? trim($value) : strval($value); - $this->touchState(); - return; - } - - if (in_array($field, ["cost", "cooldown"], true)) { - $this->payload[$field] = max(0, intval($value)); - $this->touchState(); - return; - } - if (str_starts_with($field, "scope")) { - $key = lcfirst(substr($field, 5)); - $this->payload["scope"][$key] = $field === "scopeTargetCount" ? (trim(strval($value)) === "" ? null : max(0, intval($value))) : strval($value); - $this->touchState(); - return; - } - - if (str_starts_with($field, "invocation")) { - $key = lcfirst(substr($field, 10)); - $this->payload["invocation"][$key] = $key === "message" ? strval($value) : max(0, intval($value)); - $this->touchState(); - } - } - - public function getEffectSummaryLines(): array - { - $lines = []; - foreach ($this->getEffects() as $effect) { - $class = basename(str_replace(chr(92), "/", strval($effect["class"] ?? "Effect"))); - $lines[] = sprintf("%s: %s", $class, strval($effect["formula"] ?? "")); - } - return $lines === [] ? ["No effects configured."] : $lines; - } - - public function toArray(): array - { - return $this->payload; - } -} diff --git a/src/ProjectSkillDatabase.php b/src/ProjectSkillDatabase.php deleted file mode 100644 index 734591e..0000000 --- a/src/ProjectSkillDatabase.php +++ /dev/null @@ -1,464 +0,0 @@ - */ - private array $sourceOrder = []; - /** @var array */ - private array $authored = []; - - public function __construct( - public readonly string $path, - private array $skills = [], - bool $isDirty = false, - ) { - if (! $isDirty) { - $this->captureBaseline(); - } - } - - public static function fromProject(string $projectRoot): self - { - $path = rtrim($projectRoot, DIRECTORY_SEPARATOR) - . DIRECTORY_SEPARATOR . "assets" - . DIRECTORY_SEPARATOR . "Data" - . DIRECTORY_SEPARATOR . "skills.php"; - if (is_file($path) === false) { - return new self($path, []); - } - $payload = require $path; - if (is_array($payload) === false) { - throw new RuntimeException(sprintf("Unable to parse %s.", $path)); - } - $skills = []; - $classes = []; - foreach (array_values($payload) as $index => $skill) { - if ($skill instanceof \Ichiloto\Engine\Entities\Skills\Skill) { - $skills[] = ProjectSkill::fromSkill($skill, $index + 1); - $classes[] = $skill::class; - } - } - $database = new self($path, $skills); - $database->source = (string) file_get_contents($path); - $document = PhpSourceDocument::parse($database->source); - foreach ($skills as $index => $skill) { - $key = spl_object_id($skill); - $database->sourceOrder[] = $key; - $database->authored[$key] = [ - 'skill' => $skill, - 'payload' => $skill->toArray(), - 'source' => count($payload) === count($skills) ? $document->entrySource($index) : null, - 'block' => count($payload) === count($skills) ? $document->getEntryBlockSource($index) : null, - 'class' => $classes[$index], - ]; - } - return $database; - } - - public function getSkills(): array { return array_values($this->skills); } - - public function getSkillByIndex(int $index): ?ProjectSkill - { - return $this->skills[$index] ?? null; - } - - public function addSkill(string $name = "New Skill"): int - { - $nextId = 1; - foreach ($this->skills as $skill) { $nextId = max($nextId, $skill->id + 1); } - $this->skills[] = ProjectSkill::createBlank($nextId, $name); - $this->touchState(); - return count($this->skills) - 1; - } - - public function setField(int $index, string $field, mixed $value): void - { - $skill = $this->getSkillByIndex($index); - if (! $skill instanceof ProjectSkill) { return; } - $authored = $this->authored[spl_object_id($skill)] ?? null; - if ($authored !== null && $authored['source'] === null) { - throw $this->getSourceRefusal($skill); - } - if (! $this->canEditField($index, $field)) { - throw new RuntimeException(sprintf('Skill "%s" does not support editing %s.', $skill->getName(), $field)); - } - $skill->setField($field, $value); - $this->touchState(); - } - - public function canEditField(int $index, string $field): bool - { - return $this->getReadOnlyReason($index) === null && $this->supportsField($index, $field); - } - - public function getReadOnlyReason(int $index): ?string - { - $skill = $this->getSkillByIndex($index); - if ($skill === null) { return null; } - $authored = $this->authored[spl_object_id($skill)] ?? null; - return $authored !== null && $authored['source'] === null - ? $this->getSourceRefusal($skill)->getMessage() - : null; - } - - public function supportsField(int $index, string $field): bool - { - $skill = $this->getSkillByIndex($index); - if ($skill === null || in_array($field, ['type', 'effects'], true)) { return false; } - $parameter = str_starts_with($field, 'scope') ? 'scope' - : (str_starts_with($field, 'invocation') ? 'invocation' : $field); - return in_array($parameter, $this->getConstructorParameters($skill), true); - } - - /** @return list */ - private function getConstructorParameters(ProjectSkill $skill): array - { - $class = $this->authored[spl_object_id($skill)]['class'] ?? $this->getSkillClass($skill); - return array_map(static fn(\ReflectionParameter $parameter): string => $parameter->getName(), - (new ReflectionClass($class))->getConstructor()?->getParameters() ?? []); - } - - /** - * Removes the skill at the given index (rewritten to disk on save, so - * re-inserting at the same index is a complete undo). - * - * @param int $index The skill index. - * @return ProjectSkill|null The removed skill, or null when the index is unknown. - */ - public function removeSkill(int $index): ?ProjectSkill - { - $skills = array_values($this->skills); - $skill = $skills[$index] ?? null; - - if (! $skill instanceof ProjectSkill) { - return null; - } - - if ($this->getReadOnlyReason($index) !== null) { - throw $this->getSourceRefusal($skill); - } - - array_splice($skills, $index, 1); - $this->skills = $skills; - $this->touchState(); - - return $skill; - } - - /** - * Re-inserts a previously removed skill (the undo of removeSkill()). - * - * @param int $index The index to restore the skill at. - * @param ProjectSkill $skill The skill to restore. - * @return void - */ - public function insertSkill(int $index, ProjectSkill $skill): void - { - $skills = array_values($this->skills); - $index = max(0, min(count($skills), $index)); - array_splice($skills, $index, 0, [$skill]); - $this->skills = $skills; - $this->touchState(); - } - - public function save(): void - { - $directory = dirname($this->path); - if (is_dir($directory) === false && mkdir($directory, 0777, true) === false && is_dir($directory) === false) { - throw new RuntimeException(sprintf("Unable to create %s.", $directory)); - } - if (! $this->isDirty() && is_file($this->path)) { - return; - } - - if ($this->source !== null && file_get_contents($this->path) !== $this->source) { - throw new RuntimeException('Refusing to overwrite skills.php changed outside the editor. Reload before saving.'); - } - $source = $this->getUpdatedSource(); - try { - \PhpToken::tokenize($source, TOKEN_PARSE); - } catch (\ParseError $error) { - throw new RuntimeException('Refusing to write invalid skill source: ' . $error->getMessage(), previous: $error); - } - AtomicFile::write($this->path, $source); - $this->source = $source; - $document = PhpSourceDocument::parse($source); - $this->sourceOrder = []; - foreach ($this->getSkills() as $index => $skill) { - $key = spl_object_id($skill); - $this->sourceOrder[] = $key; - $this->authored[$key] = [ - 'skill' => $skill, - 'payload' => $skill->toArray(), - 'source' => $document->entrySource($index), - 'block' => $document->getEntryBlockSource($index), - 'class' => $this->authored[$key]['class'] ?? $this->getSkillClass($skill), - ]; - } - $this->captureBaseline(); - } - - /** - * @inheritDoc - */ - protected function dependencyVersion(): string - { - $versions = []; - - foreach ($this->skills as $skill) { - $versions[] = $skill->stateVersion(); - } - - return count($this->skills) . ':' . implode(',', $versions); - } - - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - return serialize(array_map(static fn(ProjectSkill $skill): array => $skill->toArray(), $this->getSkills())); - } - - private function getUpdatedSource(): string - { - if ($this->source === null) { - return $this->getNewSource(); - } - $document = PhpSourceDocument::parse($this->source); - $order = $this->sourceOrder; - if ($document->entryCount() !== count($order)) { - throw new RuntimeException('Refusing to flatten skills.php: its entries cannot be matched to the loaded skills.'); - } - $retained = array_map(spl_object_id(...), $this->getSkills()); - foreach (array_reverse(array_keys($order)) as $index) { - if (in_array($order[$index], $retained, true)) { continue; } - $removed = $this->authored[$order[$index]]; - if ($removed['source'] === null) { throw $this->getSourceRefusal($removed['skill']); } - $document = $document->withoutEntry($index); - array_splice($order, $index, 1); - } - foreach ($this->getSkills() as $index => $skill) { - $key = spl_object_id($skill); - $position = array_search($key, $order, true); - $authored = $this->authored[$key] ?? null; - if ($authored !== null && $authored['source'] === null) { - if ($skill->toArray() !== $authored['payload']) { throw $this->getSourceRefusal($skill); } - // Opaque expressions stay untouched. Move editable neighbours past them. - while ($position !== $index) { - $neighbour = $this->authored[$order[$index]]['skill']; - $destination = array_search(spl_object_id($neighbour), $retained, true); - if ($destination === false || $destination <= $index) { throw $this->getSourceRefusal($skill); } - $document = $this->moveSkillEntry($document, $order, $neighbour, $destination); - $position = array_search($key, $order, true); - } - continue; - } - if ($position === $index && $skill->toArray() === ($authored['payload'] ?? null)) { - continue; - } - $entrySource = $this->getUpdatedEntry($skill); - if ($position === $index) { - if ($document->entrySource($index) !== $entrySource) { - $old = $document->entrySource($index); - if ($old === null) { throw new RuntimeException('Refusing to replace an unsupported skill expression.'); } - // Patch the entry's values through the same constructor-span writer. - $document = $this->applySkillChanges($document, $index, $skill); - } - continue; - } - $document = $this->moveSkillEntry($document, $order, $skill, $index); - } - for ($index = count($order) - 1; $index >= count($this->skills); $index--) { - $document = $document->withoutEntry($index); - } - return $document->source; - } - - /** @param list $order */ - private function moveSkillEntry(PhpSourceDocument $document, array &$order, ProjectSkill $skill, int $index): PhpSourceDocument - { - $key = spl_object_id($skill); - $entrySource = $this->getUpdatedEntry($skill); - $position = array_search($key, $order, true); - if ($position !== false) { - $document = $document->withoutEntry($position); - array_splice($order, $position, 1); - } - $index = min($index, count($order)); - $before = $index < count($order) ? $index : null; - $block = $this->authored[$key]['block'] ?? null; - if ($block !== null) { - $preserved = PhpSourceDocument::parse("applySkillChanges($preserved, 0, $skill)->getEntryBlockSource(0); - $document = $document->getWithEntryBlockSource($block, $before); - } else { - $document = $document->getWithEntrySource($entrySource, $before); - } - array_splice($order, $index, 0, [$key]); - return $document; - } - - private function getUpdatedEntry(ProjectSkill $skill): string - { - $authored = $this->authored[spl_object_id($skill)] ?? null; - if ($authored === null) { return ltrim($this->exportSkill($skill)); } - if ($authored['source'] === null) { - throw $this->getSourceRefusal($skill); - } - $document = PhpSourceDocument::parse('applySkillChanges($document, 0, $skill)->entrySource(0) - ?? throw new RuntimeException('Cannot preserve the edited skill constructor.'); - } - - private function getSourceRefusal(ProjectSkill $skill): RuntimeException - { - return new RuntimeException(sprintf('Refusing to flatten skill "%s" (entry %d): its authored expression is read-only. Edit its source directly.', $skill->getName(), $skill->id)); - } - - private function applySkillChanges(PhpSourceDocument $document, int $index, ProjectSkill $skill): PhpSourceDocument - { - $authored = $this->authored[spl_object_id($skill)]; - $parameters = $this->getConstructorParameters($skill); - foreach ($skill->toArray() as $field => $value) { - if ($value === ($authored['payload'][$field] ?? null)) { continue; } - if (in_array($field, ['type', 'effects'], true)) { - throw new RuntimeException('Refusing to regenerate skill type or effects; edit those expressions in source.'); - } - if ($field === 'animationId' && $value === null) { - $document = $document->getWithoutConstructorArgument($index, $field, $parameters); - continue; - } - $literal = match ($field) { - 'scope' => $this->exportScope($skill), - 'invocation' => $this->exportInvocation($skill), - 'occasion' => $this->exportOccasion($skill->getOccasion()), - 'effectType' => $this->exportMagicEffectType($skill->getEffectType()), - default => var_export($value, true), - }; - $document = $document->getWithConstructorArgument($index, $field, $literal, $parameters); - } - return $document; - } - - /** @return class-string */ - private function getSkillClass(ProjectSkill $skill): string - { - return match ($skill->getType()) { - 'basic' => BasicSkill::class, - 'magic' => MagicSkill::class, - default => SpecialSkill::class, - }; - } - - private function getNewSource(): string - { - $definitions = array_map(fn(ProjectSkill $skill): string => $this->exportSkill($skill), $this->getSkills()); - return " $definition . ',', $definitions)) . "\n];\n"; - } - - private function exportSkill(ProjectSkill $skill): string - { - if ($skill->getEffects() !== []) { - throw new RuntimeException('Refusing to regenerate skill effects without their authored source.'); - } - $lines = [ - sprintf(" new \\%s(", $this->getSkillClass($skill)), - " " . var_export($skill->getName(), true) . ",", - " " . var_export($skill->getDescription(), true) . ",", - " " . var_export($skill->getIcon(), true) . ",", - " " . $skill->getCost() . ",", - " " . $skill->getCooldown() . ",", - " " . $this->exportScope($skill) . ",", - " " . $this->exportOccasion($skill->getOccasion()) . ",", - " " . $this->exportInvocation($skill) . ",", - " [", - ]; - $lines[] = " ],"; - if ($skill->getType() === "magic") { - $lines[] = " [],"; - $lines[] = " " . $this->exportMagicEffectType($skill->getEffectType()) . ","; - } - if ($skill->animationId !== null) { - $lines[] = ' animationId: ' . $skill->animationId . ','; - } - $lines[] = " )"; - return implode(PHP_EOL, $lines); - } - - private function exportScope(ProjectSkill $skill): string - { - $scope = $skill->getScope(); - $parts = [ - "\\" . ItemScopeSide::class . "::" . $this->enumCaseFromValue(ItemScopeSide::class, strval($scope["side"] ?? "Enemy"), "ENEMY"), - "\\" . ItemScopeNumber::class . "::" . $this->enumCaseFromValue(ItemScopeNumber::class, strval($scope["number"] ?? "One"), "ONE"), - "\\" . ItemScopeStatus::class . "::" . $this->enumCaseFromValue(ItemScopeStatus::class, strval($scope["status"] ?? "Alive"), "ALIVE"), - ]; - if (($scope["targetCount"] ?? null) !== null) { $parts[] = strval(max(0, intval($scope["targetCount"]))); } - return "new \\Ichiloto\\Engine\\Entities\\ItemScope(" . implode(", ", $parts) . ")"; - } - - private function exportOccasion(string $occasion): string - { - return "\\" . Occasion::class . "::" . $this->enumCaseFromValue(Occasion::class, $occasion, "BATTLE_SCREEN"); - } - - private function exportInvocation(ProjectSkill $skill): string - { - $invocation = $skill->getInvocation(); - return sprintf( - "new \\Ichiloto\\Engine\\Entities\\Skills\\SkillInvocation(%s, %d, %d, %d, %d)", - var_export(strval($invocation["message"] ?? ""), true), - max(0, intval($invocation["speed"] ?? 0)), - max(0, intval($invocation["accuracy"] ?? 0)), - max(1, intval($invocation["repeat"] ?? 1)), - max(0, intval($invocation["apGain"] ?? 10)), - ); - } - - private function exportMagicEffectType(?string $effectType): string - { - return $effectType === null - ? "null" - : "\\Ichiloto\\Engine\\Entities\\Magic\\MagicEffectType::" . $this->enumCaseFromValue(\Ichiloto\Engine\Entities\Magic\MagicEffectType::class, $effectType, "DESTRUCTIVE"); - } - - private function enumCaseFromValue(string $enumClass, string $value, string $fallback): string - { - foreach ($enumClass::cases() as $case) { - if ($case->value === $value) { return $case->name; } - } - return $fallback; - } - - /** - * Returns the files a save of this database would overwrite. - * - * @return string[] - */ - public function getBackupPaths(): array - { - return [$this->path]; - } -} diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index 6c2a65f..117320b 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -47,7 +47,6 @@ public function __construct( public array $maps, public array $mapIds, public ProjectActorDatabase $actorDatabase, - public ProjectSkillDatabase $skillDatabase, public array $recordDatabases = [], public ?CutsceneLibrary $cutscenes = null, public ?ProjectConfig $config = null, @@ -109,8 +108,8 @@ public function getRecordDatabase(string $categoryKey): ?ProjectRecordDatabase } /** - * Reads the project's skill catalogue as saved: every skill across the - * Engine's skill data files, identified and classified as the runtime + * Reads the project's skill catalogue as saved: every skill record, as + * the runtime reads them, identified and classified as the runtime * finds them. * * @return SkillCatalog @@ -121,26 +120,17 @@ public function loadSkillCatalog(): SkillCatalog } /** - * Returns every skill name a reference may use: the skills.php entries as - * currently edited, then the skills the catalogue's other files author. + * Returns every skill name a reference may use: the Skills records as + * currently edited, in the order menus list them. * * @return string[] */ public function getSkillNames(): array { - $names = array_map( - static fn(ProjectSkill $skill): string => $skill->getName(), - $this->skillDatabase->getSkills() - ); - $catalog = $this->loadSkillCatalog(); - - foreach (array_keys($catalog->getSkills()) as $name) { - if ($catalog->getSourceFile($name) !== basename($this->skillDatabase->path)) { - $names[] = $name; - } - } - - return array_values(array_unique($names)); + return array_values(array_unique(array_filter(array_map( + static fn(ProjectRecord $record): string => trim(strval($record->get('name') ?? '')), + $this->getRecordDatabase('skills')?->getRecords() ?? [], + ), static fn(string $name): bool => $name !== ''))); } /** @@ -213,7 +203,6 @@ private static function loadFromProject(string $projectRoot): self maps: $maps = self::discoverMaps($projectRoot), mapIds: array_map(static fn(ProjectMap $map): string => $map->mapId, $maps), actorDatabase: ProjectActorDatabase::fromProject($projectRoot), - skillDatabase: ProjectSkillDatabase::fromProject($projectRoot), recordDatabases: array_map( static fn(RecordSchema $schema): ProjectRecordDatabase => ProjectRecordDatabase::fromProject($projectRoot, $schema, $projectConfig), RecordSchemaCatalog::all(), @@ -321,7 +310,6 @@ public function hasUnsavedChanges(): bool } return $this->actorDatabase->isDirty() - || $this->skillDatabase->isDirty() || ($this->config?->isDirty() ?? false) || ($this->cutscenes?->hasUnsavedChanges() ?? false); } @@ -360,13 +348,12 @@ public function listUnsavedChanges(): array * Read-only record categories are left out: they hold no edits, and * asking them to save would raise instead of doing nothing. * - * @return array + * @return array */ public function listSaveableDatabases(): array { $databases = [ 'Actors' => $this->actorDatabase, - 'Skills' => $this->skillDatabase, ]; if ($this->config !== null) { @@ -444,7 +431,6 @@ private function withMaps(array $maps): self maps: $maps, mapIds: array_map(static fn(ProjectMap $workspaceMap): string => $workspaceMap->mapId, $maps), actorDatabase: $this->actorDatabase, - skillDatabase: $this->skillDatabase, recordDatabases: $this->recordDatabases, cutscenes: $this->cutscenes, config: $this->config, diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index f0d0f02..d47f3d6 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1221,11 +1221,10 @@ private static function describeCanvasImage(CanvasImage $image): array /** * A database category's records, as its list shows them, whether they * can be edited and have unsaved changes, and which record operations - * the category takes. A category whose rows only the terminal editor - * builds yet is refused. + * the category takes. * * @return array{category: string, editable: bool, readOnly: ?string, dirty: bool, canCreate: bool, canDuplicate: bool, canDelete: bool, canReorder: bool, records: list} - * @throws SessionRefusal When the category is unknown or not yet served. + * @throws SessionRefusal When the category is unknown. */ public function listDatabaseRecords(string $category): array { @@ -2130,7 +2129,7 @@ private function requireRecordDatabase(string $category): ProjectRecordDatabase } return $this->workspace->getRecordDatabase($category) - ?? throw new SessionRefusal(sprintf('The %s database is edited in the terminal editor for now.', $category)); + ?? throw new SessionRefusal(sprintf('The %s database has no records to edit here.', $category)); } /** @@ -2138,7 +2137,7 @@ private function requireRecordDatabase(string $category): ProjectRecordDatabase * actor service, with what this editor's actor panes show, and every * schema-driven category through the shared record rules. * - * @throws SessionRefusal When the category is unknown or not yet served. + * @throws SessionRefusal When the category is unknown. */ private function requireCategory(string $category): DatabaseCategory { diff --git a/src/Validation/AnimationReferenceValidator.php b/src/Validation/AnimationReferenceValidator.php index 74b5083..25643c1 100644 --- a/src/Validation/AnimationReferenceValidator.php +++ b/src/Validation/AnimationReferenceValidator.php @@ -7,7 +7,11 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Animations\ActionAnimationResolver; use Ichiloto\Engine\Entities\Enumerations\WeaponType; -use Ichiloto\Engine\Entities\Magic\MagicEffectType; +use Ichiloto\Engine\Entities\Skills\BasicSkill; +use Ichiloto\Engine\Entities\Skills\MagicSkill; +use Ichiloto\Engine\Entities\Skills\SkillCatalog; +use Ichiloto\Engine\Entities\Skills\SkillRecord; +use InvalidArgumentException; /** Checks optional presentation references without invalidating gameplay. */ final class AnimationReferenceValidator @@ -20,8 +24,15 @@ public function validate(ProjectWorkspace $workspace): array $names = array_map(static fn($animation): string => strval($animation->get('name')), $animations); $issues = []; - foreach ($workspace->skillDatabase->getSkills() as $skill) { - $where = 'assets/Data/skills.php: ' . $skill->getName(); + foreach ($workspace->getRecordDatabase('skills')?->getRecords() ?? [] as $record) { + try { + $skill = SkillRecord::readSkill((array) $record->toArray()); + } catch (InvalidArgumentException) { + // A record the game cannot read is the skill catalogue's finding. + continue; + } + + $where = sprintf('assets/Data/%s/%s.php: %s', SkillCatalog::DIRECTORY, $record->recordId, $skill->name); if ($skill->animationId !== null) { array_push($issues, ...$this->checkId($skill->animationId, $ids, $where)); continue; @@ -29,13 +40,12 @@ public function validate(ProjectWorkspace $workspace): array // A basic skill's effect follows the attacker's weapon role, and a // summon plays its cutscene; neither is chosen by an animation name. - if ($skill->getType() === 'basic') { + if ($skill instanceof BasicSkill) { continue; } - $magicEffectType = $skill->getType() === 'magic' - ? MagicEffectType::tryFrom($skill->getEffectType() ?? '') : null; - $candidates = ActionAnimationResolver::getSkillCandidateNames($skill->getName(), $magicEffectType); + $magicEffectType = $skill instanceof MagicSkill ? $skill->effectType : null; + $candidates = ActionAnimationResolver::getSkillCandidateNames($skill->name, $magicEffectType); if (array_intersect($candidates, $names) !== []) { $issues[] = Issue::warning( $where, diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 2079c8e..a72a848 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -1353,8 +1353,8 @@ protected function checkBattleEntryRules(ProjectWorkspace $workspace): array } /** - * Reports what the Engine's skill catalogue cannot identify: a skill the - * runtime would skip, or one whose name another skill already uses. + * Reports what the Engine's skill catalogue cannot identify: a record the + * runtime cannot read, or a skill whose name another skill already uses. * * @return Issue[] */ @@ -1362,9 +1362,9 @@ protected function checkSkillCatalog(ProjectWorkspace $workspace): array { return array_map( static fn(string $problem): Issue => Issue::error( - 'assets/Data skill catalogue', + 'assets/Data/Skills', $problem, - 'A skill is found by its name across skills.php, abilities.php and magic.php, so each name must be unique.', + 'Each skill is one record file; correct the file named, and give each skill a name no other skill uses.', ), $workspace->loadSkillCatalog()->getProblems(), ); @@ -1433,7 +1433,7 @@ protected function checkSummons(ProjectWorkspace $workspace): array $issues[] = Issue::error( $where, sprintf('It links to action "%s", which does not exist.', $linkedAction), - 'Choose a skill from assets/Data/skills.php, abilities.php or magic.php.', + 'Choose one of the skills under assets/Data/Skills.', ); } diff --git a/tests/Pest.php b/tests/Pest.php index dc2143d..3624c03 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -386,3 +386,30 @@ function plainTextOfFrame(string $frame, int $width, int $height): string require_once __DIR__ . '/Support/MapMetadataFixtures.php'; require_once __DIR__ . '/Support/BattleFormationFixtures.php'; + +/** + * Authors a project's skill catalogue as record files, one per skill and + * numbered in the given order, replacing any records already there: the + * fixture form of `assets/Data/Skills`. + * + * @param string $root The project root. + */ +function writeSkillRecords(string $root, \Ichiloto\Engine\Entities\Skills\Skill ...$skills): void +{ + $directory = $root . '/assets/Data/' . \Ichiloto\Engine\Entities\Skills\SkillCatalog::DIRECTORY; + + if (! is_dir($directory)) { + mkdir($directory, 0777, true); + } + + array_map(unlink(...), glob($directory . '/*.php') ?: []); + + foreach (array_values($skills) as $index => $skill) { + $slug = trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($skill->name)) ?? '', '-'); + $data = var_export(\Ichiloto\Engine\Entities\Skills\SkillRecord::writeSkill($skill), true); + file_put_contents( + sprintf('%s/%04d-%s.php', $directory, $index + 1, $slug), + " \\Ichiloto\\Engine\\Entities\\Skills\\Skill::class, 'data' => {$data}];\n", + ); + } +} diff --git a/tests/Support/CutsceneFixtures.php b/tests/Support/CutsceneFixtures.php index e6a0e71..34e5c57 100644 --- a/tests/Support/CutsceneFixtures.php +++ b/tests/Support/CutsceneFixtures.php @@ -146,34 +146,17 @@ function cutsceneProject(): string */ function writeFireballSkill(string $root): void { - file_put_contents($root . '/assets/Data/skills.php', <<<'PHP_SOURCE' -stats->magicAttack * 3', NULL, 0.2, false), - ], - ), -]; -PHP_SOURCE); + writeSkillRecords($root, new \Ichiloto\Engine\Entities\Skills\MagicSkill( + 'Fireball', + 'A burst of flame.', + 'FIR', + 4, + 0, + new \Ichiloto\Engine\Entities\ItemScope(), + \Ichiloto\Engine\Entities\Enumerations\Occasion::BATTLE_SCREEN, + new \Ichiloto\Engine\Entities\Skills\SkillInvocation('$1 casts Fireball!', 0, 0, 1, 10), + [new \Ichiloto\Engine\Entities\Effects\SkillEffects\HPDamageSkillEffect('$user->stats->magicAttack * 3', null, 0.2, false)], + )); } /** diff --git a/tests/Unit/ActorAttackSkillTest.php b/tests/Unit/ActorAttackSkillTest.php index c7b3efe..bec10ae 100644 --- a/tests/Unit/ActorAttackSkillTest.php +++ b/tests/Unit/ActorAttackSkillTest.php @@ -9,18 +9,12 @@ function attackSkillProject(): string { $root = makeTemporaryProject(); - file_put_contents($root . '/assets/Data/skills.php', <<<'PHP' - skillDatabase; - $database->addSkill('Pending skill'); - $database->setField(0, 'cost', 123); + $database = $workspace->getRecordDatabase('skills'); + $index = $database->addRecord(); + $database->setField((int) $index, 'cost', '123'); callEditorMethod($editor, 'openActorReferenceMigration'); expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() ->and(getEditorProperty($editor, 'statusMessage'))->toContain('pending edits') ->and($database->isDirty())->toBeTrue() - ->and(is_file($database->path))->toBeFalse(); + ->and(glob($root . '/assets/Data/Skills/*.php') ?: [])->toBe([]); }); it('explains malformed explicit ids without offering a migration or opening input', function (): void { diff --git a/tests/Unit/AnimationReferenceTest.php b/tests/Unit/AnimationReferenceTest.php index 6b48eec..161a318 100644 --- a/tests/Unit/AnimationReferenceTest.php +++ b/tests/Unit/AnimationReferenceTest.php @@ -5,8 +5,6 @@ use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Database\CutsceneSchemas; use Ichiloto\Editor\Database\ReferencePicker; -use Ichiloto\Editor\ProjectSkill; -use Ichiloto\Editor\ProjectSkillDatabase; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Validation\AnimationReferenceValidator; use Ichiloto\Editor\Validation\Severity; @@ -15,6 +13,7 @@ use Ichiloto\Engine\Entities\Skills\SpecialSkill; use Ichiloto\Engine\Entities\Magic\MagicEffectType; use Ichiloto\Engine\Cutscenes\Summons\SummonEffectTiming; +use Ichiloto\Engine\Entities\Skills\SkillCatalog; it('displays the engine default summon effect timing in the editor', function () { $timing = array_values(array_filter(CutsceneSchemas::summons()->fields, @@ -36,15 +35,14 @@ it('round trips explicit animation ids for every skill subtype and allows clearing them', function (string $class) { $root = makeTemporaryProject(); - $skill = ProjectSkill::fromSkill(new $class('Renamed', '', '', 0, 0, animationId: 1), 1); - $database = new ProjectSkillDatabase($root . '/assets/Data/skills.php', [$skill], true); - $database->save(); - $loaded = ProjectSkillDatabase::fromProject($root); - expect($loaded->getSkillByIndex(0)->animationId)->toBe(1); + writeSkillRecords($root, new $class('Renamed', '', '', 0, 0, animationId: 1)); + $path = $root . '/assets/Data/Skills/0001-renamed.php'; + $loaded = loadRecordDatabase($root, 'skills'); + expect($loaded->getRecordByIndex(0)?->get('animationId'))->toBe(1); $loaded->setField(0, 'animationId', ''); $loaded->save(); - expect(ProjectSkillDatabase::fromProject($root)->getSkillByIndex(0)->animationId)->toBeNull() - ->and(file_get_contents($root . '/assets/Data/skills.php'))->not->toContain('animationId:'); + expect(SkillCatalog::load($root . '/assets')->findSkill('Renamed')?->animationId)->toBeNull() + ->and(file_get_contents($path))->not->toContain('animationId'); })->with([BasicSkill::class, MagicSkill::class, SpecialSkill::class]); it('round trips item animation references through the existing typed resource picker schema', function () { @@ -62,12 +60,12 @@ it('reports deprecated name fallback and stale ids without rejecting gameplay', function () { $root = makeTemporaryProject(); - $database = new ProjectSkillDatabase($root . '/assets/Data/skills.php', [ - ProjectSkill::fromSkill(new SpecialSkill('Slash', '', '', 0, 0), 1), - ProjectSkill::fromSkill(new SpecialSkill('Renamed', '', '', 0, 0, animationId: 1), 2), - ProjectSkill::fromSkill(new SpecialSkill('Missing', '', '', 0, 0, animationId: 999), 3), - ], true); - $database->save(); + writeSkillRecords( + $root, + new SpecialSkill('Slash', '', '', 0, 0), + new SpecialSkill('Renamed', '', '', 0, 0, animationId: 1), + new SpecialSkill('Missing', '', '', 0, 0, animationId: 999), + ); $issues = new AnimationReferenceValidator()->validate(ProjectWorkspace::fromProject($root)); expect($issues)->toHaveCount(2) ->and($issues[0]->severity)->toBe(Severity::WARNING) @@ -79,16 +77,16 @@ it('uses the engine fallback rules for magic animation diagnostics', function () { $root = makeTemporaryProject(); file_put_contents($root . '/assets/Data/animations.php', " 2, 'name' => 'Healing Aura']];"); - $database = new ProjectSkillDatabase($root . '/assets/Data/skills.php', [ - ProjectSkill::fromSkill(new MagicSkill('Cure', '', '', 0, 0, effectType: MagicEffectType::RESTORATIVE), 1), - ProjectSkill::fromSkill(new MagicSkill('Flare', '', '', 0, 0, effectType: MagicEffectType::DESTRUCTIVE), 2), - ], true); - $database->save(); + writeSkillRecords( + $root, + new MagicSkill('Cure', '', '', 0, 0, effectType: MagicEffectType::RESTORATIVE), + new MagicSkill('Flare', '', '', 0, 0, effectType: MagicEffectType::DESTRUCTIVE), + ); // Only the skills' diagnostics; this project binds no roles, which is // reported separately. $issues = array_values(array_filter( new AnimationReferenceValidator()->validate(ProjectWorkspace::fromProject($root)), - static fn($issue): bool => str_starts_with($issue->where, 'assets/Data/skills.php'), + static fn($issue): bool => str_starts_with($issue->where, 'assets/Data/Skills/'), )); expect($issues)->toHaveCount(1) ->and($issues[0]->where)->toContain('Cure'); @@ -96,81 +94,56 @@ it('routes skill animation edits through the existing picker and undo transaction', function () { $root = makeTemporaryProject(); - $database = new ProjectSkillDatabase($root . '/assets/Data/skills.php', [ - ProjectSkill::fromSkill(new SpecialSkill('Skill', '', '', 0, 0), 1), - ], true); - $database->save(); + writeSkillRecords($root, new SpecialSkill('Skill', '', '', 0, 0)); $editor = deletionEditor($root); openDatabaseCategory($editor, 'skills'); - $fields = callEditorMethod($editor, 'getDatabaseSkillSettingsFields'); - $field = array_values(array_filter($fields, static fn(array $field): bool => $field['field'] === 'animationId'))[0]; + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); + $field = array_values(array_filter($fields, static fn(array $field): bool => ($field['field'] ?? null) === 'animationId'))[0]; expect($field['reference'])->toBe('animation_ids')->and($field['allowsNone'])->toBeTrue() ->and(isset($field['control']))->toBeFalse(); callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field, '1'); - $workspace = getEditorProperty($editor, 'workspace'); - expect($workspace->skillDatabase->getSkillByIndex(0)->animationId)->toBe(1); + $skills = getEditorProperty($editor, 'workspace')->getRecordDatabase('skills'); + $animation = static fn(): mixed => $skills->getRecordByIndex(0)?->get('animationId'); + expect($animation())->toBe(1); callEditorMethod($editor, 'performUndo'); - expect($workspace->skillDatabase->getSkillByIndex(0)->animationId)->toBeNull(); + expect($animation())->toBeNull(); callEditorMethod($editor, 'performRedo'); - expect($workspace->skillDatabase->getSkillByIndex(0)->animationId)->toBe(1); + expect($animation())->toBe(1); callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field, '(Legacy fallback)'); - expect($workspace->skillDatabase->getSkillByIndex(0)->animationId)->toBeNull(); + expect($animation())->toBeNull(); }); -it('offers only supported skill fields and leaves a no-op pick unchanged', function (string $class) { +it('offers a spell its effect type alone, and leaves a no-op pick unchanged', function (string $class) { $root = makeTemporaryProject(); - $database = new ProjectSkillDatabase($root . '/assets/Data/skills.php', [ - ProjectSkill::fromSkill(new $class('Skill', '', '', 0, 0), 1), - ], true); - $database->save(); + writeSkillRecords($root, new $class('Skill', '', '', 0, 0)); $editor = deletionEditor($root); openDatabaseCategory($editor, 'skills'); - $fields = callEditorMethod($editor, 'getDatabaseSkillSettingsFields'); - $byName = array_column($fields, null, 'field'); - expect($byName)->not->toHaveKey('type'); - $workspace = getEditorProperty($editor, 'workspace'); - $skill = $workspace->skillDatabase->getSkillByIndex(0); - $before = $skill->toArray(); + $byName = array_column(array_filter(callEditorMethod($editor, 'getDatabaseSettingsFields'), static fn(array $field): bool => isset($field['field'])), null, 'field'); + $skills = getEditorProperty($editor, 'workspace')->getRecordDatabase('skills'); + $before = $skills->getRecordByIndex(0)?->toArray(); + expect($byName)->toHaveKey('kind'); if ($class === MagicSkill::class) { expect($byName)->toHaveKey('effectType'); callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $byName['effectType'], $byName['effectType']['value']); } else { expect($byName)->not->toHaveKey('effectType'); - expect(fn() => $workspace->skillDatabase->setField(0, 'effectType', 'Destructive')) - ->toThrow(RuntimeException::class, 'does not support editing effectType'); } - expect($skill->toArray())->toBe($before)->and($workspace->skillDatabase->isDirty())->toBeFalse(); + expect($skills->getRecordByIndex(0)?->toArray())->toBe($before)->and($skills->isDirty())->toBeFalse(); callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $byName['animationId'], '1'); - $workspace->skillDatabase->save(); - expect((require $root . '/assets/Data/skills.php')[0]->animationId)->toBe(1); + $skills->save(); + expect(SkillCatalog::load($root . '/assets')->findSkill('Skill')?->animationId)->toBe(1); })->with([BasicSkill::class, MagicSkill::class, SpecialSkill::class]); -it('shows opaque skill fields read-only and refuses Enter and directional edits before opening controls', function () { +it('drops a spell\'s effect type when it becomes another kind of skill', function () { $root = makeTemporaryProject(); - $path = $root . '/assets/Data/skills.php'; - $source = <<<'PHP' -toContain('Computed', 'read-only'); - $values = array_column($fields, 'value', 'label'); - expect($values)->toMatchArray(['Name' => 'Computed', 'Description' => 'Authored description', 'Cost' => '4']); - foreach ($fields as $index => $field) { - expect($field['editable'])->toBeFalse(); - setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); - callEditorMethod($editor, 'dispatchInput', "\r"); - callEditorMethod($editor, 'dispatchInput', "\033[C"); - expect(getEditorProperty($editor, 'isDatabaseEditing'))->toBeFalse() - ->and(callEditorMethod($editor, 'isInspectorFieldInteractive', $field))->toBeFalse(); - } - expect(getEditorProperty($editor, 'workspace')->skillDatabase->isDirty())->toBeFalse() - ->and(file_get_contents($path))->toBe($source); + writeSkillRecords($root, new MagicSkill('Mend', '', '', 0, 0, effectType: MagicEffectType::BUFF)); + $skills = loadRecordDatabase($root, 'skills'); + + $skills->setField(0, 'kind', 'special'); + $skills->save(); + + expect($skills->getRecordByIndex(0)?->get('effectType'))->toBeNull() + ->and(SkillCatalog::load($root . '/assets')->findSkill('Mend'))->toBeInstanceOf(SpecialSkill::class); }); it('does not enter text editing for an established actor id', function () { diff --git a/tests/Unit/DatabaseDeletionTest.php b/tests/Unit/DatabaseDeletionTest.php index 815723f..909f28e 100644 --- a/tests/Unit/DatabaseDeletionTest.php +++ b/tests/Unit/DatabaseDeletionTest.php @@ -198,45 +198,3 @@ removeDirectoryRecursively($root); } }); - -it('refuses opaque skill deletion before prompting and still saves supported pending edits', function () { - $root = makeTemporaryProject(); - $path = $root . '/assets/Data/skills.php'; - $source = <<<'PHP' -skillDatabase; - $database->setField(1, 'animationId', 3); - $reason = $database->getReadOnlyReason(0); - - callEditorMethod($editor, 'dispatchInput', "\033[3~"); - - expect(getEditorProperty($editor, 'isDatabaseEntryDeleteConfirmationOpen'))->toBeFalse() - ->and(getEditorProperty($editor, 'pendingDatabaseDeletion'))->toBeNull() - ->and(getEditorProperty($editor, 'statusMessage'))->toBe($reason) - ->and($database->getSkills())->toHaveCount(2) - ->and($database->isDirty())->toBeTrue(); - expect(fn() => $database->removeSkill(0))->toThrow(RuntimeException::class, $reason); - - callEditorMethod($editor, 'dispatchInput', "\x13"); - - expect($database->isDirty())->toBeFalse() - ->and(file_get_contents($path))->toBe(str_replace( - "'Editable', '', '', 0, 0)", "'Editable', '', '', 0, 0, animationId: 3)", $source, - )); - } finally { - removeDirectoryRecursively($root); - } -}); diff --git a/tests/Unit/EnemyRecordFilesTest.php b/tests/Unit/EnemyRecordFilesTest.php index 5be91a9..7425bb0 100644 --- a/tests/Unit/EnemyRecordFilesTest.php +++ b/tests/Unit/EnemyRecordFilesTest.php @@ -21,8 +21,7 @@ function enemyRecordProject(array $records = []): string mkdir($root . '/assets/Graphics/Enemies', 0777, true); file_put_contents($root . '/assets/Graphics/Enemies/blob.txt', "(o)\n"); file_put_contents($root . '/assets/Graphics/Enemies/wisp.txt', "~*~\n"); - file_put_contents($root . '/assets/Data/skills.php', " $contents) { file_put_contents($root . '/assets/Data/Enemies/' . $file, $contents); diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index a71c423..c83c419 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -783,24 +783,17 @@ function writeValidatorActor(string $root, string $fileId, string $name, string ->and($issues[0]->message)->toBe('Its tiles2d crop table is no longer read.'); }); -/** Writes skills spread across all three of the Engine's skill files. */ -function writeSpreadSkillCatalog(string $root, string $extraMagic = ''): void +/** Writes a catalogue of every kind of skill, one record each. */ +function writeSpreadSkillCatalog(string $root, Ichiloto\Engine\Entities\Skills\Skill ...$extra): void { - $header = " new SpecialSkill('Ward', 'Guards.', '', 2, 0),\n" - . "];\n"); - file_put_contents($root . '/assets/Data/magic.php', $header . "return [\n" - . " 'Burn I' => new MagicSkill('Burn I', 'Burns.', '', 5, 0),\n" - . $extraMagic - . "];\n"); + writeSkillRecords( + $root, + new Ichiloto\Engine\Entities\Skills\BasicSkill('Attack', 'Strikes.', '', 0, 0), + new Ichiloto\Engine\Entities\Skills\MagicSkill('Cleanse', 'Lifts a poison.', '', 3, 0), + new Ichiloto\Engine\Entities\Skills\SpecialSkill('Ward', 'Guards.', '', 2, 0), + new Ichiloto\Engine\Entities\Skills\MagicSkill('Burn I', 'Burns.', '', 5, 0), + ...$extra, + ); } it('checks ability and spell alias targets against their own kind across every skill file', function () { @@ -846,9 +839,9 @@ function writeSpreadSkillCatalog(string $root, string $extraMagic = ''): void writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); - writeSpreadSkillCatalog($root, " 'Cleanse' => new MagicSkill('Cleanse', 'Again.', '', 3, 0),\n"); + writeSpreadSkillCatalog($root, new Ichiloto\Engine\Entities\Skills\MagicSkill('Cleanse', 'Again.', '', 3, 0)); - $issues = issuesMentioning(validateProject($root), '"Cleanse" is defined in both skills.php and magic.php'); + $issues = issuesMentioning(validateProject($root), '"Cleanse" is already defined by Skills/0002-cleanse.php'); expect($issues)->toHaveCount(1) ->and($issues[0]->severity)->toBe(Severity::ERROR); diff --git a/tests/Unit/Session/DatabaseSessionTest.php b/tests/Unit/Session/DatabaseSessionTest.php index eb8b214..8da1fdd 100644 --- a/tests/Unit/Session/DatabaseSessionTest.php +++ b/tests/Unit/Session/DatabaseSessionTest.php @@ -48,7 +48,7 @@ function databaseSessionRow(array $read, string $fieldId): array ->and(fn() => $session->readDatabaseRecord('common_events', 0, [9, 'then']))->toThrow(SessionRefusal::class, 'is no longer there') ->and(fn() => $session->readDatabaseRecord('common_events', 0, ['then' => 3]))->toThrow(SessionRefusal::class, 'A frame must be a list') ->and(fn() => $session->readDatabaseRecord('states', 9))->toThrow(SessionRefusal::class, 'states has no record 9.') - ->and(fn() => $session->readDatabaseRecord('skills', 0))->toThrow(SessionRefusal::class, 'edited in the terminal editor for now'); + ->and(fn() => $session->readDatabaseRecord('skills', 0))->toThrow(SessionRefusal::class, 'skills has no record 0.'); }); it('applies a row as one undo step, history naming the category, and refuses what the field cannot take', function () { diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index b13c6d4..038760b 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -220,7 +220,6 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a ->and(array_column($record['rows'], 'label'))->toContain('Name') ->and(array_find($record['rows'], static fn(array $row): bool => $row['label'] === 'Name')['value'])->toBe($items['records'][0]) ->and(fn() => $session->readDatabaseRecord('items', 999))->toThrow(SessionRefusal::class, 'items has no record 999.') - ->and(fn() => $session->listDatabaseRecords('skills'))->toThrow(SessionRefusal::class, 'edited in the terminal editor for now') ->and(fn() => $session->listDatabaseRecords('nope'))->toThrow(SessionRefusal::class, 'There is no database category nope.'); }); diff --git a/tests/Unit/SkillRecordFilesTest.php b/tests/Unit/SkillRecordFilesTest.php new file mode 100644 index 0000000..074feb3 --- /dev/null +++ b/tests/Unit/SkillRecordFilesTest.php @@ -0,0 +1,70 @@ +stats->attack * 3')]), + new MagicSkill('Ember', 'Burns.', '', 5, 0), + ); + + return $root; +} + +it('lists every skill record in file order, numbered, with its kind and effects as data', function () { + $skills = loadRecordDatabase(skillRecordsProject(), 'skills'); + + expect($skills->getEntryLabels())->toBe(['Attack', 'Lunge', 'Ember']) + ->and(array_map(static fn($record): string => $record->recordId, $skills->getRecords()))->toBe(['0001-attack', '0002-lunge', '0003-ember']) + ->and($skills->getRecordByIndex(1)?->get('kind'))->toBe('special') + ->and($skills->getRecordByIndex(1)?->get('effects'))->toBe([['type' => 'hp_damage', 'formula' => '$user->stats->attack * 3', 'variance' => 0.2]]); +}); + +it('creates and copies a skill under the next number, last in the list as reopening shows it', function () { + $root = skillRecordsProject(); + $skills = loadRecordDatabase($root, 'skills'); + $authoring = new RecordAuthoring(); + + $created = $authoring->createRecord($skills); + $copied = $authoring->duplicateRecord($skills, 0); + $skills->save(); + + expect([$created->index, $copied->index])->toBe([3, 4]) + ->and(array_map(basename(...), glob($root . '/assets/Data/Skills/*.php') ?: [])) + ->toBe(['0001-attack.php', '0002-lunge.php', '0003-ember.php', '0004-new-skill.php', '0005-attack-2.php']) + ->and(array_keys(SkillCatalog::load($root . '/assets')->getSkills()))->toBe(['Attack', 'Lunge', 'Ember', 'New Skill', 'Attack-2']) + ->and(loadRecordDatabase($root, 'skills')->getEntryLabels())->toBe(['Attack', 'Lunge', 'Ember', 'New Skill', 'Attack-2']); +}); + +it('authors effects by type, keeping only what each type reads', function () { + $root = skillRecordsProject(); + $skills = loadRecordDatabase($root, 'skills'); + $field = static fn(string $name): string => ProjectRecordDatabase::subFieldId('effect', 0, $name); + + $skills->setField(1, $field('element'), 'Fire'); + $skills->setField(1, $field('type'), 'add_state'); + $skills->setField(1, $field('stateId'), 'poison'); + $skills->save(); + + $lunge = SkillCatalog::load($root . '/assets')->findSkill('Lunge'); + + expect($skills->getRecordByIndex(1)?->get('effects'))->toBe([['type' => 'add_state', 'stateId' => 'poison']]) + ->and($lunge?->effects[0]?->stateId)->toBe('poison') + ->and(SkillCatalog::load($root . '/assets')->getProblems())->toBe([]); +}); diff --git a/tests/Unit/SkillSourcePreservationTest.php b/tests/Unit/SkillSourcePreservationTest.php deleted file mode 100644 index ee7573e..0000000 --- a/tests/Unit/SkillSourcePreservationTest.php +++ /dev/null @@ -1,229 +0,0 @@ -markTestSkipped('ICHILOTO_GAME_SRC is required for the production source fixture.'); } - $root = makeTemporaryProject(); - $path = $root . '/assets/Data/skills.php'; - $before = (string) file_get_contents($game . '/assets/Data/skills.php'); - file_put_contents($path, $before); - $original = require $path; - $database = ProjectSkillDatabase::fromProject($root); - $database->setField(2, 'animationId', 17); - $database->save(); - $after = (string) file_get_contents($path); - expect(str_replace(" animationId: 17,\n", '', $after))->toBe($before); - $saved = require $path; - expect($saved[2]->animationId)->toBe(17); - foreach ($original as $index => $skill) { - expect(serialize($saved[$index]->effects))->toBe(serialize($skill->effects)) - ->and(serialize($saved[$index]->requiredWeapons))->toBe(serialize($skill->requiredWeapons)); - } - $database->setField(2, 'animationId', 18); - $database->save(); - expect(file_get_contents($path))->toBe(str_replace('animationId: 17', 'animationId: 18', $after)); - $database->setField(2, 'animationId', null); - $database->save(); - expect((require $path)[2]->animationId)->toBeNull()->and(file_get_contents($path))->toBe($before); -}); - -it('preserves positional and aliased constructors with nested effects and trailing comments', function (): void { - $root = makeTemporaryProject(); - $path = $root . '/assets/Data/skills.php'; - $source = <<<'PHP' -setField(0, 'cost', 8); - $database->setField(0, 'animationId', 4); - $database->save(); - expect(file_get_contents($path))->toBe(str_replace(["'', 4, 0", 'animationId: 3'], ["'', 8, 0", 'animationId: 4'], $source)); - expect((require $path)[0]->effects[0]->stateId)->toBe('poison'); -}); - -it('preserves skill removal and saved undo without regenerating effects', function (): void { - $root = makeTemporaryProject(); - $path = $root . '/assets/Data/skills.php'; - $source = <<<'PHP' -removeSkill(0); - $database->save(); - expect(require $path)->toHaveCount(1); - expect(file_get_contents($path))->not->toContain('Cleanse documentation.') - ->and(file_get_contents($path))->toContain("// Other documentation.\n new SpecialSkill('Other', '', '', 0, 0), // Other tail."); - $database->insertSkill(0, $removed); - $database->save(); - expect(file_get_contents($path))->toBe($source); - expect((require $path)[0]->effects[0]->stateIds)->toBe(['poison']); - $moved = $database->removeSkill(1); - $database->insertSkill(0, $moved); - $database->save(); - expect(file_get_contents($path))->toContain("// Other documentation.\n new SpecialSkill('Other', '', '', 0, 0), // Other tail.\n // Cleanse documentation."); -}); - -it('adds a named animation after positional arguments without swallowing a trailing comment', function (): void { - $root = makeTemporaryProject(); - $path = $root . '/assets/Data/skills.php'; - file_put_contents($path, <<<'PHP' -setField(0, 'animationId', 42); - $database->save(); - expect((require $path)[0]->animationId)->toBe(42) - ->and(file_get_contents($path))->toContain('0, // Keep the cooldown comment.'); -}); - -it('refuses changing skill type rather than dropping its authored effects', function (): void { - $root = makeTemporaryProject(); - $path = $root . '/assets/Data/skills.php'; - $source = " $database->setField(0, 'type', 'magic'))->toThrow(RuntimeException::class, 'does not support editing type') - ->and(file_get_contents($path))->toBe($source)->and($database->isDirty())->toBeFalse(); -}); - -it('refuses unsafe source and externally changed skills without writing or clearing dirty state', function (): void { - $root = makeTemporaryProject(); - $path = $root . '/assets/Data/skills.php'; - $source = <<<'PHP' - $database->setField(0, 'animationId', 1))->toThrow(RuntimeException::class, 'skill "Computed" (entry 1)') - ->and(file_get_contents($path))->toBe($source) - ->and($database->isDirty())->toBeFalse(); - file_put_contents($path, $source . "\n// External edit\n"); - $database->addSkill(); - expect(fn() => $database->save())->toThrow(RuntimeException::class, 'changed outside') - ->and(file_get_contents($path))->toBe($source . "\n// External edit\n"); -}); - -it('keeps an unsupported skill untouched while saving and removing supported neighbours', function (): void { - $root = makeTemporaryProject(); - $path = $root . '/assets/Data/skills.php'; - $source = <<<'PHP' - $database->setField(1, 'animationId', 3))->toThrow(RuntimeException::class, '"Computed"'); - $database->setField(2, 'animationId', 4); - $database->save(); - expect(file_get_contents($path))->toBe(str_replace("'After', '', '', 0, 0)", "'After', '', '', 0, 0, animationId: 4)", $source)); - $database->removeSkill(0); - $database->save(); - expect((require $path)[0]->name)->toBe('Computed') - ->and((require $path)[1]->animationId)->toBe(4); -}); - -it('removes optional positional arguments without shifting the arguments that follow', function (): void { - $source = "getWithoutConstructorArgument(0, 'animationId', ['name', 'animationId', 'note']); - expect($document->source)->toBe(" PhpToken::tokenize($document->source, TOKEN_PARSE))->not->toThrow(ParseError::class); -}); - -it('moves supported skills across opaque neighbours without rewriting their expressions', function (): void { - $root = makeTemporaryProject(); - $path = $root . '/assets/Data/skills.php'; - $source = <<<'PHP' -removeSkill(0); - $database->insertSkill(2, $moved); - $database->save(); - expect(array_map(static fn($skill) => $skill->name, require $path))->toBe(['Computed', 'After', 'Before']) - ->and(file_get_contents($path))->toContain("// Opaque documentation.\n \$computed, // Opaque tail.") - ->and(file_get_contents($path))->toContain("// Before documentation.\n new SpecialSkill('Before', '', '', 0, 0), // Before tail."); - $database = ProjectSkillDatabase::fromProject($root); - $moved = $database->removeSkill(2); - $database->insertSkill(0, $moved); - $database->save(); - expect(file_get_contents($path))->toBe($source); - $moved = $database->removeSkill(2); - $database->insertSkill(0, $moved); - $database->save(); - expect(array_map(static fn($skill) => $skill->name, require $path))->toBe(['After', 'Before', 'Computed']); - $moved = $database->removeSkill(0); - $database->insertSkill(2, $moved); - $database->save(); - expect(file_get_contents($path))->toBe($source); -}); - -it('adds skills after a trailing comment without hiding the separator in that comment', function (): void { - $root = makeTemporaryProject(); - $path = $root . '/assets/Data/skills.php'; - file_put_contents($path, <<<'PHP' -addSkill('New'); - $database->save(); - expect(require $path)->toHaveCount(2) - ->and(file_get_contents($path))->toContain("0), // Keep this note.\n"); -}); - -it('clears a commented animation argument without leaving an orphan separator', function (): void { - $root = makeTemporaryProject(); - $path = $root . '/assets/Data/skills.php'; - file_put_contents($path, "setField(0, 'animationId', null); - $database->save(); - expect((require $path)[0]->animationId)->toBeNull() - ->and(file_get_contents($path))->not->toContain('animationId:') - ->and(file_get_contents($path))->toContain('/* authored note */'); -}); From aea440d8574f0e368e7f39f38d811688118da6b2 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 16:50:15 +0200 Subject: [PATCH 158/344] refactor(database): build the summaries beside a record once for both editors RecordPanes builds the panes the terminal shows beside a record (class curves, skill effects and scope, quest objectives and rewards, battle settings, battle-entry order, and each record's preview) from the record as it is now. The terminal's panes read it, and a session record read carries the same panes for the GUI. --- src/Database/RecordPanes.php | 358 ++++++++++++ src/Editor.php | 650 +-------------------- src/Session/EditorSession.php | 8 +- tests/Unit/BattleEntryRulesTest.php | 2 +- tests/Unit/Session/DatabaseSessionTest.php | 11 + 5 files changed, 391 insertions(+), 638 deletions(-) create mode 100644 src/Database/RecordPanes.php diff --git a/src/Database/RecordPanes.php b/src/Database/RecordPanes.php new file mode 100644 index 0000000..2ec6bee --- /dev/null +++ b/src/Database/RecordPanes.php @@ -0,0 +1,358 @@ +}> + */ + public static function describe(ProjectWorkspace $workspace, string $category, int $index): array + { + $data = static fn(): ?array => ($record = $workspace->getRecordDatabase($category)?->getRecordByIndex($index)) === null + ? null + : (array) $record->toArray(); + + return match ($category) { + 'actors' => self::describeActor($workspace->actorDatabase->getActorByIndex($index)), + 'classes' => self::describeClass(($row = $data()) === null ? null : ProjectClass::fromArray($row, $index + 1)), + 'skills' => self::describeSkill($data(), $workspace->getRecordDatabase('skills')?->getRecordByIndex($index)?->recordId), + 'quests' => self::describeQuest(($row = $data()) === null ? null : new ProjectQuest($row)), + 'system' => self::describeSystem($workspace), + 'battle_entry_rules' => ['cue' => ['title' => 'Execution Order', 'lines' => self::describeBattleEntryOrder($workspace, $index)]], + default => [], + }; + } + + /** @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> */ + private static function describeActor(?ProjectActor $actor): array + { + if ($actor === null) { + return self::panes('Collections', ['No actor selected.'], 'Stats', ['No actor selected.'], ['No actor selected.']); + } + + $abilities = $actor->getAbilities(); + $magic = $actor->getMagic(); + $images = $actor->getImages(); + $battleLines = $actor->getBattleSpriteLines(); + + return self::panes( + 'Collections', + [ + 'Abilities', + sprintf('Learned: %d', count($abilities['learned'] ?? [])), + sprintf('Learnables: %d', count($abilities['learnables'] ?? [])), + sprintf('Sort: %s', (string) ($abilities['sortOrder'] ?? 'A-Z')), + '', + 'Magic', + sprintf('Learned: %d', count($magic['learned'] ?? [])), + sprintf('Learnables: %d', count($magic['learnables'] ?? [])), + sprintf('Sort: %s', (string) ($magic['sortOrder'] ?? 'A-Z')), + ], + 'Stats', + [ + sprintf('HP %d/%d', $actor->getStat('currentHp'), $actor->getStat('totalHp')), + sprintf('MP %d/%d', $actor->getStat('currentMp'), $actor->getStat('totalMp')), + sprintf('AP %d/%d', $actor->getStat('currentAp'), $actor->getStat('totalAp')), + sprintf('ATK %d', $actor->getStat('attack')), + sprintf('DEF %d', $actor->getStat('defence')), + sprintf('MAT %d', $actor->getStat('magicAttack')), + sprintf('MDF %d', $actor->getStat('magicDefence')), + sprintf('SPD %d', $actor->getStat('speed')), + sprintf('GRC %d', $actor->getStat('grace')), + sprintf('EVA %d', $actor->getStat('evasion')), + sprintf('ACC %d', $actor->getStat('accuracy')), + sprintf('CRT %d', $actor->getStat('critical')), + ], + [ + sprintf('Actor ID: %s', $actor->id), + sprintf('Field sprites: %d', count($images['field'] ?? [])), + sprintf('Dialog portraits: %d', count($images['dialog'] ?? [])), + '', + 'Battle Sprite', + ...($battleLines !== [] ? $battleLines : ['(no battle sprite configured)']), + ], + ); + } + + /** @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> */ + private static function describeClass(?ProjectClass $class): array + { + if ($class === null) { + return self::panes('Experience Curve', ['No class selected.'], 'Stat Curves', ['No class selected.'], ['No class selected.']); + } + + $curve = $class->getExperienceCurve(); + $labels = [ + 'totalHp' => 'HP', 'totalMp' => 'MP', 'attack' => 'ATK', 'defence' => 'DEF', 'magicAttack' => 'MAT', + 'magicDefence' => 'MDF', 'speed' => 'SPD', 'grace' => 'GRC', 'evasion' => 'EVA', + ]; + $curves = []; + + foreach ($class->getParameterCurves() as $key => $parameter) { + $curves[] = sprintf('%-3s %d +%d / %d', $labels[$key] ?? strtoupper($key), $parameter['baseValue'], $parameter['extraGrowth'], $parameter['flatIncrement']); + } + + $experience = new ExperienceCurveGenerator( + baseValue: $curve['baseValue'], + extraValue: $curve['extraValue'], + accelerationA: $curve['accelerationA'], + accelerationB: $curve['accelerationB'], + ); + $generator = static function (string $key) use ($class): ParameterCurveGenerator { + $parameter = $class->getParameterCurve($key); + + return new ParameterCurveGenerator(1, $parameter['baseValue'], $parameter['extraGrowth'], $parameter['flatIncrement']); + }; + [$hp, $mp, $attack] = [$generator('totalHp'), $generator('totalMp'), $generator('attack')]; + $samples = []; + + foreach ([1, 10, 25, 50, 99] as $level) { + if ($level <= $class->getMaxLevel()) { + $samples[] = sprintf('Lv%02d HP%-4d MP%-3d ATK%-3d EXP%-6d', $level, $hp->getValue($level), $mp->getValue($level), $attack->getValue($level), $experience->getValue($level)); + } + } + + return self::panes( + 'Experience Curve', + [ + sprintf('Base: %d', $curve['baseValue']), + sprintf('Extra: %d', $curve['extraValue']), + sprintf('Accel A: %d', $curve['accelerationA']), + sprintf('Accel B: %d', $curve['accelerationB']), + '', + sprintf('Initial Lv: %d', $class->getInitialLevel()), + sprintf('Max Lv: %d', $class->getMaxLevel()), + sprintf('Traits: %d', count($class->getTraits())), + ], + 'Stat Curves', + $curves, + [sprintf('Class ID: %04d', $class->id), sprintf('Name: %s', $class->getName()), '', 'Curve Samples', ...$samples], + ); + } + + /** + * @param array|null $data The skill's record data. + * @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> + */ + private static function describeSkill(?array $data, ?string $file): array + { + if ($data === null) { + return self::panes('Effects', ['No skill selected.'], 'Scope', ['No skill selected.'], ['No skill selected.']); + } + + $scope = (array) ($data['scope'] ?? []); + $invocation = (array) ($data['invocation'] ?? []); + $effects = array_values(array_map(self::describeSkillEffect(...), array_filter((array) ($data['effects'] ?? []), is_array(...)))); + $effects = $effects === [] ? ['No effects configured.'] : $effects; + + return self::panes( + 'Effects', + $effects, + 'Scope', + [ + sprintf('Side: %s', strval($scope['side'] ?? '')), + sprintf('Number: %s', strval($scope['number'] ?? '')), + sprintf('Status: %s', strval($scope['status'] ?? '')), + sprintf('Targets: %s', ($scope['targetCount'] ?? null) === null ? 'Auto' : strval($scope['targetCount'])), + '', + sprintf('Occasion: %s', strval($data['occasion'] ?? '')), + sprintf('Repeat: %d', intval($invocation['repeat'] ?? 1)), + sprintf('AP Gain: %d', intval($invocation['apGain'] ?? 0)), + ], + [ + sprintf('File: %s/%s.php', SkillCatalog::DIRECTORY, strval($file)), + sprintf('Name: %s', strval($data['name'] ?? '')), + sprintf('Kind: %s', ucfirst(strval($data['kind'] ?? ''))), + sprintf('Occasion: %s', strval($data['occasion'] ?? '')), + sprintf('Cost: %d MP', intval($data['cost'] ?? 0)), + sprintf('Cooldown: %d', intval($data['cooldown'] ?? 0)), + '', + sprintf('Scope: %s / %s / %s', strval($scope['side'] ?? ''), strval($scope['number'] ?? ''), strval($scope['status'] ?? '')), + sprintf('Invoke: %s', strval($invocation['message'] ?? '')), + '', + 'Effects', + ...$effects, + ], + ); + } + + /** + * One effect of a skill record, as the Effects pane lists it. + * + * @param array $effect The effect's record data. + */ + private static function describeSkillEffect(array $effect): string + { + $parts = match (true) { + isset($effect['formula']) => [strval($effect['formula'])], + isset($effect['stateId']) => [sprintf('%s, %d%%', strval($effect['stateId']), intval($effect['chancePercent'] ?? 100))], + isset($effect['stateIds']) && is_array($effect['stateIds']) => [implode(', ', array_map('strval', $effect['stateIds']))], + isset($effect['stat']) => [sprintf('%s %+d%s', strval($effect['stat']), intval($effect['delta'] ?? 0), ($effect['affectsUser'] ?? false) === true ? ' on the user' : '')], + default => [], + }; + + foreach (['element', 'resolutionKind'] as $key) { + if (isset($effect[$key])) { + $parts[] = strval($effect[$key]); + } + } + + if (isset($effect['variance'])) { + $parts[] = sprintf('±%d%%', (int) round(floatval($effect['variance']) * 100)); + } + + return sprintf('%s: %s', ucfirst(str_replace('_', ' ', strval($effect['type'] ?? 'effect'))), implode(' · ', $parts)); + } + + /** @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> */ + private static function describeQuest(?ProjectQuest $quest): array + { + if ($quest === null) { + return self::panes('Objectives', ['No quest selected.'], 'Rewards', ['No quest selected.'], ['No quest selected.']); + } + + return self::panes( + 'Objectives', + $quest->getObjectiveSummaryLines(), + 'Rewards', + [ + sprintf('Gold: %d', $quest->getRewardGold()), + sprintf('EXP: %d', $quest->getRewardExperience()), + sprintf('Items: %s', $quest->getRewardItemsString() === '' ? '-' : $quest->getRewardItemsString()), + '', + 'Prereqs', + ...$quest->getPrerequisiteSummaryLines(), + ], + [ + sprintf('Quest ID: %s', $quest->getId()), + sprintf('Name: %s', $quest->getName()), + sprintf('Giver: %s', $quest->getGiver() === '' ? '-' : $quest->getGiver()), + sprintf('Objectives: %d', count($quest->getObjectives())), + sprintf('Prereqs: %d', count($quest->getPrerequisites())), + '', + 'Description', + $quest->getDescription() === '' ? '(none)' : $quest->getDescription(), + '', + 'Objectives', + ...$quest->getObjectiveSummaryLines(), + ], + ); + } + + /** @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> */ + private static function describeSystem(ProjectWorkspace $workspace): array + { + // The battle settings as the system record holds them, with the + // defaults the engine reads where it holds none. + $battle = $workspace->getSystemField('battle'); + $battle = is_array($battle) ? $battle : []; + $activeTime = is_array($battle['activeTime'] ?? null) ? $battle['activeTime'] : []; + $engine = strval($battle['engine'] ?? 'traditional'); + $mode = strval($activeTime['mode'] ?? 'wait'); + $fillRate = max(1, intval($activeTime['baseFillRate'] ?? 35)); + $speedFactor = max(0, intval($activeTime['speedFactorPercent'] ?? 35)); + $activeTimeOn = $engine === 'active_time'; + + return self::panes( + 'Battle Settings', + [ + sprintf('Engine: %s', $engine), + sprintf('ATB Mode: %s', $mode), + sprintf('Base Fill Rate: %d', $fillRate), + sprintf('Speed Factor: %d%%', $speedFactor), + ], + 'Notes', + $activeTimeOn + ? ['Active Time Battle is enabled.', 'Mode: wait', 'This first slice uses wait-mode flow', 'during command selection and resolution.'] + : ['Traditional turn-based battles.', 'ATB settings are stored but inactive.', 'Switch Battle Engine to active_time', 'to enable gauge-driven turns.'], + $activeTimeOn + ? [ + 'Battle Engine', 'Active Time Battle', '', + sprintf('Mode: %s', $mode), sprintf('Base Fill Rate: %d', $fillRate), sprintf('Speed Factor: %d%%', $speedFactor), '', + 'This engine fills battler gauges', 'continuously and resolves actions', 'as battlers become ready.', + ] + : [ + 'Battle Engine', 'Traditional Turn-Based', '', + 'Battlers act in a queued round order.', 'ATB settings are ignored until you', 'switch the project to active_time.', + ], + ); + } + + /** + * The order matching battle-entry rules run in: priority, then + * declaration order, the given rule marked. + * + * @return list + */ + private static function describeBattleEntryOrder(ProjectWorkspace $workspace, int $selected): array + { + $rules = []; + + foreach ($workspace->getRecordDatabase('battle_entry_rules')?->getRecords() ?? [] as $index => $record) { + $priority = $record->get('priority'); + $rules[] = [ + 'index' => $index, + 'priority' => is_int($priority) ? $priority : 0, + 'id' => trim(strval($record->get('id') ?? '')) ?: '(no id)', + ]; + } + + if ($rules === []) { + return ['No rules yet.', '', 'Battles begin unchanged.']; + } + + usort($rules, static fn(array $left, array $right): int => [$left['priority'], $left['index']] <=> [$right['priority'], $right['index']]); + $lines = ['Runs in this order:']; + + foreach ($rules as $position => $rule) { + $lines[] = sprintf( + '%s%2d. %s%s', + $rule['index'] === $selected ? '> ' : ' ', + $position + 1, + $rule['id'], + $rule['priority'] !== 0 ? sprintf(' (p %d)', $rule['priority']) : '', + ); + } + + return $lines; + } + + /** + * @param list $cue + * @param list $frames + * @param list $preview + * @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> + */ + private static function panes(string $cueTitle, array $cue, string $framesTitle, array $frames, array $preview): array + { + return [ + 'cue' => ['title' => $cueTitle, 'lines' => array_values($cue)], + 'frames' => ['title' => $framesTitle, 'lines' => array_values($frames)], + 'preview' => ['title' => 'Preview', 'lines' => array_values($preview)], + ]; + } +} diff --git a/src/Editor.php b/src/Editor.php index 80b21a4..426a7e9 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -29,6 +29,7 @@ use Ichiloto\Editor\Database\RecordAuthoring; use Ichiloto\Editor\Database\RecordChange; use Ichiloto\Editor\Database\RecordItem; +use Ichiloto\Editor\Database\RecordPanes; use Ichiloto\Editor\Database\RecordRefusal; use Ichiloto\Editor\Database\SharedFileTransaction; use Ichiloto\Editor\Database\ConditionCodec; @@ -105,8 +106,6 @@ use Ichiloto\Engine\Entities\Inventory\Items\Item; use Ichiloto\Engine\Entities\Inventory\Weapons\Weapon; use Ichiloto\Engine\Entities\Magic\MagicEffectType; -use Ichiloto\Engine\Entities\Roles\ExperienceCurveGenerator; -use Ichiloto\Engine\Entities\Roles\ParameterCurveGenerator; use Ichiloto\Engine\Entities\Skills\MagicSkill; use Ichiloto\Engine\Entities\Skills\Skill; use Ichiloto\Engine\Entities\Skills\SkillCatalog; @@ -9097,148 +9096,6 @@ private function getSelectedSkill(): ?Skill } } - /** - * One effect of a skill record, as the Effects pane lists it. - * - * @param array $effect The effect's record data. - */ - private static function describeSkillEffect(array $effect): string - { - $parts = match (true) { - isset($effect['formula']) => [strval($effect['formula'])], - isset($effect['stateId']) => [sprintf('%s, %d%%', strval($effect['stateId']), intval($effect['chancePercent'] ?? 100))], - isset($effect['stateIds']) && is_array($effect['stateIds']) => [implode(', ', array_map('strval', $effect['stateIds']))], - isset($effect['stat']) => [sprintf('%s %+d%s', strval($effect['stat']), intval($effect['delta'] ?? 0), ($effect['affectsUser'] ?? false) === true ? ' on the user' : '')], - default => [], - }; - - foreach (['element', 'resolutionKind'] as $key) { - if (isset($effect[$key])) { - $parts[] = strval($effect[$key]); - } - } - - if (isset($effect['variance'])) { - $parts[] = sprintf('±%d%%', (int) round(floatval($effect['variance']) * 100)); - } - - return sprintf('%s: %s', ucfirst(str_replace('_', ' ', strval($effect['type'] ?? 'effect'))), implode(' · ', $parts)); - } - - /** - * The selected skill's effects, one line each. - * - * @return string[] - */ - private function getDatabaseSkillCueLines(): array - { - $data = $this->getSelectedSkillData(); - - if ($data === null) { - return ['No skill selected.']; - } - - $effects = array_filter((array) ($data['effects'] ?? []), is_array(...)); - - return $effects === [] ? ['No effects configured.'] : array_values(array_map(self::describeSkillEffect(...), $effects)); - } - - /** - * Whom the selected skill reaches, when, and how often it rolls. - * - * @return string[] - */ - private function getDatabaseSkillFrameLines(): array - { - $data = $this->getSelectedSkillData(); - - if ($data === null) { - return ['No skill selected.']; - } - - $scope = (array) ($data['scope'] ?? []); - $invocation = (array) ($data['invocation'] ?? []); - - return [ - sprintf('Side: %s', strval($scope['side'] ?? '')), - sprintf('Number: %s', strval($scope['number'] ?? '')), - sprintf('Status: %s', strval($scope['status'] ?? '')), - sprintf('Targets: %s', ($scope['targetCount'] ?? null) === null ? 'Auto' : strval($scope['targetCount'])), - '', - sprintf('Occasion: %s', strval($data['occasion'] ?? '')), - sprintf('Repeat: %d', intval($invocation['repeat'] ?? 1)), - sprintf('AP Gain: %d', intval($invocation['apGain'] ?? 0)), - ]; - } - - /** - * A summary of the selected skill: its number, kind, cost, scope and effects. - * - * @return string[] - */ - private function getDatabaseSkillPreviewLines(): array - { - $data = $this->getSelectedSkillData(); - $record = $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()); - - if ($data === null || $record === null) { - return ['No skill selected.']; - } - - $scope = (array) ($data['scope'] ?? []); - $invocation = (array) ($data['invocation'] ?? []); - $effects = array_filter((array) ($data['effects'] ?? []), is_array(...)); - - return [ - sprintf('File: %s/%s.php', SkillCatalog::DIRECTORY, $record->recordId), - sprintf('Name: %s', strval($data['name'] ?? '')), - sprintf('Kind: %s', ucfirst(strval($data['kind'] ?? ''))), - sprintf('Occasion: %s', strval($data['occasion'] ?? '')), - sprintf('Cost: %d MP', intval($data['cost'] ?? 0)), - sprintf('Cooldown: %d', intval($data['cooldown'] ?? 0)), - '', - sprintf('Scope: %s / %s / %s', strval($scope['side'] ?? ''), strval($scope['number'] ?? ''), strval($scope['status'] ?? '')), - sprintf('Invoke: %s', strval($invocation['message'] ?? '')), - '', - 'Effects', - ...($effects === [] ? ['No effects configured.'] : array_values(array_map(self::describeSkillEffect(...), $effects))), - ]; - } - - /** - * Returns the selected quest from the project database. - * - * @return ProjectQuest|null - */ - private function getSelectedQuest(): ?ProjectQuest - { - if (! $this->isQuestsDatabaseSelected()) { - return null; - } - - // The terminal's journal panes read the quest as the shared record holds it, unsaved edits included. - $record = $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()); - - return $record === null ? null : new ProjectQuest((array) $record->toArray()); - } - - /** - * Returns the selected class from the project database. - * - * @return ProjectClass|null - */ - private function getSelectedClass(): ?ProjectClass - { - if (! $this->isClassesDatabaseSelected()) { - return null; - } - - // The terminal's curve panes read the class as the shared record holds it, unsaved edits included. - $record = $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()); - - return $record === null ? null : ProjectClass::fromArray((array) $record->toArray(), $this->getSelectedRecordIndex() + 1); - } - /** * Creates a new entry in the active Database category. * @@ -13038,7 +12895,7 @@ private function createDatabaseSettingsWindow(array $layout): EditorWindow private function createDatabaseCueWindow(array $layout): EditorWindow { return new EditorWindow( - title: $this->isActorsDatabaseSelected() ? "Collections" : ($this->isClassesDatabaseSelected() ? "Experience Curve" : ($this->isSkillsDatabaseSelected() ? "Effects" : ($this->isQuestsDatabaseSelected() ? "Objectives" : ($this->isSystemDatabaseSelected() ? "Battle Settings" : ($this->isBattleEntryRulesDatabaseSelected() ? "Execution Order" : "SE and Flash Timing"))))), + title: $this->getDatabasePanes()['cue']['title'] ?? 'SE and Flash Timing', help: $this->isQuestsDatabaseSelected() ? $this->fitHelp($layout['cueWidth'], 'Shift+O:Add Shift+X:Del', 'Shift+O/X:Add/Del', '?:Help') : '', @@ -13063,7 +12920,7 @@ private function createDatabaseCueWindow(array $layout): EditorWindow private function createDatabaseFramesWindow(array $layout): EditorWindow { return new EditorWindow( - title: $this->isActorsDatabaseSelected() ? "Stats" : ($this->isClassesDatabaseSelected() ? "Stat Curves" : ($this->isSkillsDatabaseSelected() ? "Scope" : ($this->isQuestsDatabaseSelected() ? "Rewards" : ($this->isSystemDatabaseSelected() ? "Notes" : "Frames")))), + title: $this->getDatabasePanes()['frames']['title'] ?? 'Frames', help: $this->isActorsDatabaseSelected() || $this->isClassesDatabaseSelected() || $this->isSkillsDatabaseSelected() || $this->isQuestsDatabaseSelected() || $this->isSystemDatabaseSelected() ? "" : "Up/Down:Frame", position: ["x" => $layout["innerX"] + $layout["categoryWidth"] + $layout["listWidth"] + ($layout["gutter"] * 2), "y" => $layout["innerY"] + $layout["topHeight"] + $layout["gutter"]], width: $layout["framesWidth"], @@ -13386,202 +13243,21 @@ private function buildReferencePickerRows(): array */ private function getDatabaseCueLines(): array { - if ($this->isActorsDatabaseSelected()) { - return $this->getDatabaseActorCollectionLines(); - } - - if ($this->isClassesDatabaseSelected()) { - return $this->getDatabaseClassExperienceLines(); - } - - if ($this->isSkillsDatabaseSelected()) { - return $this->getDatabaseSkillCueLines(); - } - - if ($this->isQuestsDatabaseSelected()) { - return $this->getDatabaseQuestCueLines(); - } - - if ($this->isSystemDatabaseSelected()) { - return $this->getDatabaseSystemCueLines(); - } - - if ($this->isBattleEntryRulesDatabaseSelected()) { - return $this->getDatabaseBattleEntryCueLines(); - } - - return $this->getSelectedDatabaseCategoryDefinition()->isImplemented ? ['-'] : ['No timing data yet.']; - } - - /** - * The project's battle settings as the system record holds them, with - * the defaults the engine reads where it holds none. - * - * @return array{engine: string, mode: string, baseFillRate: int, speedFactorPercent: int} - */ - private function getSystemBattleSettings(): array - { - $battle = $this->workspace?->getSystemField('battle'); - $battle = is_array($battle) ? $battle : []; - $activeTime = is_array($battle['activeTime'] ?? null) ? $battle['activeTime'] : []; - - return [ - 'engine' => strval($battle['engine'] ?? 'traditional'), - 'mode' => strval($activeTime['mode'] ?? 'wait'), - 'baseFillRate' => max(1, intval($activeTime['baseFillRate'] ?? 35)), - 'speedFactorPercent' => max(0, intval($activeTime['speedFactorPercent'] ?? 35)), - ]; - } - - /** - * Returns the system battle summary lines. - * - * @return string[] - */ - private function getDatabaseSystemCueLines(): array - { - if (! $this->workspace instanceof ProjectWorkspace) { - return ['No system settings loaded.']; - } - - $system = $this->getSystemBattleSettings(); - - return [ - sprintf('Engine: %s', $system['engine']), - sprintf('ATB Mode: %s', $system['mode']), - sprintf('Base Fill Rate: %d', $system['baseFillRate']), - sprintf('Speed Factor: %d%%', $system['speedFactorPercent']), - ]; - } - - /** - * Returns the class experience summary lines. - * - * @return string[] - */ - private function getDatabaseClassExperienceLines(): array - { - $class = $this->getSelectedClass(); - - if (! $class instanceof ProjectClass) { - return ['No class selected.']; - } - - $curve = $class->getExperienceCurve(); - - return [ - sprintf('Base: %d', $curve['baseValue']), - sprintf('Extra: %d', $curve['extraValue']), - sprintf('Accel A: %d', $curve['accelerationA']), - sprintf('Accel B: %d', $curve['accelerationB']), - '', - sprintf('Initial Lv: %d', $class->getInitialLevel()), - sprintf('Max Lv: %d', $class->getMaxLevel()), - sprintf('Traits: %d', count($class->getTraits())), - ]; + return $this->getDatabasePanes()['cue']['lines'] + ?? ($this->getSelectedDatabaseCategoryDefinition()->isImplemented ? ['-'] : ['No timing data yet.']); } /** - * Returns the actor collection summary lines. + * The summaries beside the selected record ({@see RecordPanes}), shared + * with the GUI. * - * @return string[] + * @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> */ - private function getDatabaseActorCollectionLines(): array + private function getDatabasePanes(): array { - $actor = $this->getSelectedActor(); - - if (! $actor instanceof ProjectActor) { - return ['No actor selected.']; - } - - $abilities = $actor->getAbilities(); - $magic = $actor->getMagic(); - - return [ - 'Abilities', - sprintf('Learned: %d', count($abilities['learned'] ?? [])), - sprintf('Learnables: %d', count($abilities['learnables'] ?? [])), - sprintf('Sort: %s', (string) ($abilities['sortOrder'] ?? 'A-Z')), - '', - 'Magic', - sprintf('Learned: %d', count($magic['learned'] ?? [])), - sprintf('Learnables: %d', count($magic['learnables'] ?? [])), - sprintf('Sort: %s', (string) ($magic['sortOrder'] ?? 'A-Z')), - ]; - } - - /** - * Returns the quest objective summary lines. - * - * @return string[] - */ - private function getDatabaseQuestCueLines(): array - { - $quest = $this->getSelectedQuest(); - - if (! $quest instanceof ProjectQuest) { - return ['No quest selected.']; - } - - return $quest->getObjectiveSummaryLines(); - } - - /** - * Returns the execution-order lines for the battle-entry rules cue. - * - * The runtime runs matching rules in priority then declaration order; - * this presents that deterministic order beside the settings pane, with - * the selected rule marked. - * - * @return string[] - */ - private function getDatabaseBattleEntryCueLines(): array - { - if (! $this->workspace instanceof ProjectWorkspace) { - return ['No project loaded.']; - } - - $database = $this->workspace->getRecordDatabase(self::DATABASE_CATEGORY_BATTLE_ENTRY_RULES); - - if (! $database instanceof ProjectRecordDatabase) { - return ['No rules loaded.']; - } - - $rules = []; - - foreach ($database->getRecords() as $index => $record) { - $priority = $record->get('priority'); - $rules[] = [ - 'index' => $index, - 'priority' => is_int($priority) ? $priority : 0, - 'id' => trim(strval($record->get('id') ?? '')) ?: '(no id)', - ]; - } - - if ($rules === []) { - return ['No rules yet.', '', 'Battles begin unchanged.']; - } - - usort( - $rules, - static fn(array $left, array $right): int => - [$left['priority'], $left['index']] <=> [$right['priority'], $right['index']], - ); - - $selectedIndex = $this->getSelectedRecordIndex(); - $lines = ['Runs in this order:']; - - foreach ($rules as $position => $rule) { - $lines[] = sprintf( - '%s%2d. %s%s', - $rule['index'] === $selectedIndex ? '> ' : ' ', - $position + 1, - $rule['id'], - $rule['priority'] !== 0 ? sprintf(' (p %d)', $rule['priority']) : '', - ); - } - - return $lines; + return $this->workspace instanceof ProjectWorkspace + ? RecordPanes::describe($this->workspace, $this->getSelectedDatabaseCategoryDefinition()->key, $this->getSelectedDatabaseEntryIndex()) + : []; } /** @@ -13591,148 +13267,8 @@ private function getDatabaseBattleEntryCueLines(): array */ private function getDatabaseFrameLines(): array { - if ($this->isActorsDatabaseSelected()) { - return $this->getDatabaseActorStatLines(); - } - - if ($this->isClassesDatabaseSelected()) { - return $this->getDatabaseClassCurveLines(); - } - - if ($this->isSystemDatabaseSelected()) { - return $this->getDatabaseSystemFrameLines(); - } - if ($this->isSkillsDatabaseSelected()) { - return $this->getDatabaseSkillFrameLines(); - } - - if ($this->isQuestsDatabaseSelected()) { - return $this->getDatabaseQuestFrameLines(); - } - - - return $this->getSelectedDatabaseCategoryDefinition()->isImplemented ? ['-'] : ['No entry frames.']; - } - - /** - * Returns the class curve summary lines. - * - * @return string[] - */ - private function getDatabaseClassCurveLines(): array - { - $class = $this->getSelectedClass(); - - if (! $class instanceof ProjectClass) { - return ['No class selected.']; - } - - $labelMap = [ - 'totalHp' => 'HP', - 'totalMp' => 'MP', - 'attack' => 'ATK', - 'defence' => 'DEF', - 'magicAttack' => 'MAT', - 'magicDefence' => 'MDF', - 'speed' => 'SPD', - 'grace' => 'GRC', - 'evasion' => 'EVA', - ]; - $lines = []; - - foreach ($class->getParameterCurves() as $key => $curve) { - $lines[] = sprintf( - '%-3s %d +%d / %d', - $labelMap[$key] ?? strtoupper($key), - $curve['baseValue'], - $curve['extraGrowth'], - $curve['flatIncrement'], - ); - } - - return $lines; - } - - /** - * Returns the quest reward and prerequisite summary lines. - * - * @return string[] - */ - private function getDatabaseQuestFrameLines(): array - { - $quest = $this->getSelectedQuest(); - - if (! $quest instanceof ProjectQuest) { - return ['No quest selected.']; - } - - return [ - sprintf('Gold: %d', $quest->getRewardGold()), - sprintf('EXP: %d', $quest->getRewardExperience()), - sprintf('Items: %s', $quest->getRewardItemsString() === '' ? '-' : $quest->getRewardItemsString()), - '', - 'Prereqs', - ...$quest->getPrerequisiteSummaryLines(), - ]; - } - - /** - * Returns system behavior notes. - * - * @return string[] - */ - private function getDatabaseSystemFrameLines(): array - { - if (! $this->workspace instanceof ProjectWorkspace) { - return ['No system settings loaded.']; - } - - $system = $this->getSystemBattleSettings(); - - if ($system['engine'] !== 'active_time') { - return [ - 'Traditional turn-based battles.', - 'ATB settings are stored but inactive.', - 'Switch Battle Engine to active_time', - 'to enable gauge-driven turns.', - ]; - } - - return [ - 'Active Time Battle is enabled.', - 'Mode: wait', - 'This first slice uses wait-mode flow', - 'during command selection and resolution.', - ]; - } - - /** - * Returns the actor stat summary lines. - * - * @return string[] - */ - private function getDatabaseActorStatLines(): array - { - $actor = $this->getSelectedActor(); - - if (! $actor instanceof ProjectActor) { - return ['No actor selected.']; - } - - return [ - sprintf('HP %d/%d', $actor->getStat('currentHp'), $actor->getStat('totalHp')), - sprintf('MP %d/%d', $actor->getStat('currentMp'), $actor->getStat('totalMp')), - sprintf('AP %d/%d', $actor->getStat('currentAp'), $actor->getStat('totalAp')), - sprintf('ATK %d', $actor->getStat('attack')), - sprintf('DEF %d', $actor->getStat('defence')), - sprintf('MAT %d', $actor->getStat('magicAttack')), - sprintf('MDF %d', $actor->getStat('magicDefence')), - sprintf('SPD %d', $actor->getStat('speed')), - sprintf('GRC %d', $actor->getStat('grace')), - sprintf('EVA %d', $actor->getStat('evasion')), - sprintf('ACC %d', $actor->getStat('accuracy')), - sprintf('CRT %d', $actor->getStat('critical')), - ]; + return $this->getDatabasePanes()['frames']['lines'] + ?? ($this->getSelectedDatabaseCategoryDefinition()->isImplemented ? ['-'] : ['No entry frames.']); } /** @@ -13742,163 +13278,7 @@ private function getDatabaseActorStatLines(): array */ private function getDatabasePreviewLines(): array { - if ($this->isActorsDatabaseSelected()) { - $actor = $this->getSelectedActor(); - - if (! $actor instanceof ProjectActor) { - return ["No actor selected."]; - } - - $images = $actor->getImages(); - $battleLines = $actor->getBattleSpriteLines(); - - return [ - sprintf("Actor ID: %s", $actor->id), - sprintf("Field sprites: %d", count($images["field"] ?? [])), - sprintf("Dialog portraits: %d", count($images["dialog"] ?? [])), - "", - "Battle Sprite", - ...($battleLines !== [] ? $battleLines : ["(no battle sprite configured)"]), - ]; - } - - if ($this->isClassesDatabaseSelected()) { - return $this->getDatabaseClassPreviewLines(); - } - - if ($this->isSkillsDatabaseSelected()) { - return $this->getDatabaseSkillPreviewLines(); - } - - if ($this->isQuestsDatabaseSelected()) { - return $this->getDatabaseQuestPreviewLines(); - } - - if ($this->isSystemDatabaseSelected()) { - return $this->getDatabaseSystemPreviewLines(); - } - - return []; - } - - /** - * Returns the preview lines for the selected quest. - * - * @return string[] - */ - private function getDatabaseQuestPreviewLines(): array - { - $quest = $this->getSelectedQuest(); - - if (! $quest instanceof ProjectQuest) { - return ['No quest selected.']; - } - - return [ - sprintf('Quest ID: %s', $quest->getId()), - sprintf('Name: %s', $quest->getName()), - sprintf('Giver: %s', $quest->getGiver() === '' ? '-' : $quest->getGiver()), - sprintf('Objectives: %d', count($quest->getObjectives())), - sprintf('Prereqs: %d', count($quest->getPrerequisites())), - '', - 'Description', - $quest->getDescription() === '' ? '(none)' : $quest->getDescription(), - '', - 'Objectives', - ...$quest->getObjectiveSummaryLines(), - ]; - } - - /** - * Returns the preview lines for the system database. - * - * @return string[] - */ - private function getDatabaseSystemPreviewLines(): array - { - if (! $this->workspace instanceof ProjectWorkspace) { - return ['No system settings loaded.']; - } - - $system = $this->getSystemBattleSettings(); - $engine = $system['engine']; - - if ($engine === 'active_time') { - return [ - 'Battle Engine', - 'Active Time Battle', - '', - sprintf('Mode: %s', $system['mode']), - sprintf('Base Fill Rate: %d', $system['baseFillRate']), - sprintf('Speed Factor: %d%%', $system['speedFactorPercent']), - '', - 'This engine fills battler gauges', - 'continuously and resolves actions', - 'as battlers become ready.', - ]; - } - - - return [ - 'Battle Engine', - 'Traditional Turn-Based', - '', - 'Battlers act in a queued round order.', - 'ATB settings are ignored until you', - 'switch the project to active_time.', - ]; - } - - /** - * Returns the preview lines for the selected class. - * - * @return string[] - */ - private function getDatabaseClassPreviewLines(): array - { - $class = $this->getSelectedClass(); - - if (! $class instanceof ProjectClass) { - return ['No class selected.']; - } - - $experienceCurve = $class->getExperienceCurve(); - $experienceGenerator = new ExperienceCurveGenerator( - baseValue: $experienceCurve['baseValue'], - extraValue: $experienceCurve['extraValue'], - accelerationA: $experienceCurve['accelerationA'], - accelerationB: $experienceCurve['accelerationB'], - ); - $hpCurve = $class->getParameterCurve('totalHp'); - $mpCurve = $class->getParameterCurve('totalMp'); - $attackCurve = $class->getParameterCurve('attack'); - $hpGenerator = new ParameterCurveGenerator(1, $hpCurve['baseValue'], $hpCurve['extraGrowth'], $hpCurve['flatIncrement']); - $mpGenerator = new ParameterCurveGenerator(1, $mpCurve['baseValue'], $mpCurve['extraGrowth'], $mpCurve['flatIncrement']); - $attackGenerator = new ParameterCurveGenerator(1, $attackCurve['baseValue'], $attackCurve['extraGrowth'], $attackCurve['flatIncrement']); - $sampleLevels = [1, 10, 25, 50, 99]; - $lines = [ - sprintf('Class ID: %04d', $class->id), - sprintf('Name: %s', $class->getName()), - '', - 'Curve Samples', - ]; - - foreach ($sampleLevels as $level) { - if ($level > $class->getMaxLevel()) { - continue; - } - - $lines[] = sprintf( - 'Lv%02d HP%-4d MP%-3d ATK%-3d EXP%-6d', - $level, - $hpGenerator->getValue($level), - $mpGenerator->getValue($level), - $attackGenerator->getValue($level), - $experienceGenerator->getValue($level), - ); - } - - return $lines; + return $this->getDatabasePanes()['preview']['lines'] ?? []; } /** diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index d47f3d6..8d8af5e 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -19,6 +19,7 @@ use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Database\DatabaseCategory; use Ichiloto\Editor\Database\RecordCategory; +use Ichiloto\Editor\Database\RecordPanes; use Ichiloto\Editor\Actors\ActorAuthoring; use Ichiloto\Editor\Actors\ActorCategory; use Ichiloto\Editor\Database\WorldWriteCodec; @@ -1255,10 +1256,12 @@ public function listDatabaseRecords(string $category): array * remove act on it. The heading of a list beside the record's own (a * quest's reward items) carries `listHeading`: adding to it adds that * list's first entry. `listNoun` names an entry of the list an add with - * no row goes to. + * no row goes to. At the record itself, `panes` are the summaries the + * terminal shows beside it ({@see RecordPanes}): a class's curves, a + * skill's effects, a quest's rewards, each a title and its lines. * * @param array $frame The frame; [] for the record itself. - * @return array{category: string, index: int, frame: list, frameLabel: ?string, editable: bool, readOnly: ?string, listNoun: ?string, rows: list>} + * @return array{category: string, index: int, frame: list, frameLabel: ?string, editable: bool, readOnly: ?string, listNoun: ?string, rows: list>, panes: list}>} * @throws SessionRefusal When the category, record or frame is unknown. */ public function readDatabaseRecord(string $category, int $index, array $frame = []): array @@ -1286,6 +1289,7 @@ public function readDatabaseRecord(string $category, int $index, array $frame = $field, isset($field['field']) ? 'record' : null, ), $fields), + 'panes' => $frame === [] ? array_values(RecordPanes::describe($this->workspace, $category, $index)) : [], ]; } diff --git a/tests/Unit/BattleEntryRulesTest.php b/tests/Unit/BattleEntryRulesTest.php index 4543a1f..ae74597 100644 --- a/tests/Unit/BattleEntryRulesTest.php +++ b/tests/Unit/BattleEntryRulesTest.php @@ -672,7 +672,7 @@ function editorBattleEntryRuleFixture(): array $editor = deletionEditor($root); openDatabaseCategory($editor, 'battle_entry_rules'); - $cue = callEditorMethod($editor, 'getDatabaseBattleEntryCueLines'); + $cue = callEditorMethod($editor, 'getDatabaseCueLines'); expect($cue[0])->toBe('Runs in this order:') ->and($cue[1])->toContain('1. first-declared-of-ties') diff --git a/tests/Unit/Session/DatabaseSessionTest.php b/tests/Unit/Session/DatabaseSessionTest.php index 8da1fdd..6842446 100644 --- a/tests/Unit/Session/DatabaseSessionTest.php +++ b/tests/Unit/Session/DatabaseSessionTest.php @@ -239,3 +239,14 @@ function databaseSessionRow(array $read, string $fieldId): array expect($session->listDatabaseRecords('animations')['records'])->toBe(['Hit Spark']) ->and($roles)->toMatchArray(['kind' => 'reference', 'reference' => 'animation_roles', 'multi' => true, 'value' => 'attack']); }); + +it('reads a record with the summaries the terminal shows beside it', function () { + $session = EditorSession::open(makeTemporaryProject()); + $class = $session->readDatabaseRecord('classes', 0); + $state = $session->readDatabaseRecord('states', 0); + + expect(array_column($class['panes'], 'title'))->toBe(['Experience Curve', 'Stat Curves', 'Preview']) + ->and($class['panes'][2]['lines'])->toContain('Curve Samples') + ->and($state['panes'])->toBe([]) + ->and($session->readDatabaseRecord('common_events', 0, [3, 'then'])['panes'])->toBe([]); +}); From 370e4c9f9125a94ed3aaf928d5a53fbe2fcf9160 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 16:51:21 +0200 Subject: [PATCH 159/344] docs(manual): state each Database category's coverage in the GUI editor --- docs/manual.md | 64 +++++++++++++++++++++++++++---------------------- docs/roadmap.md | 2 +- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 203f058..9bc0188 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1007,35 +1007,41 @@ changed on disk since it was read is refused rather than overwritten. A category is editable only when both answers are safe. Anything else is browsable, and the status line says exactly why. -| Category | Backing file | Status | -| --- | --- | --- | -| Actors | `assets/Data/Actors/*.php` | Editable | -| Classes | `assets/Data/classes.php` | Editable - levels, the experience curve, every stat curve, equipment types and skills learned | -| Skills | `assets/Data/Skills/*.php` | Editable - one data record per numbered file, attacks, abilities and spells alike, with their effects; `skills.php` loads them | -| Items | `assets/Data/items.php` | Editable - authored as `new Item(...)` calls, edited entry by entry | -| Weapons | `assets/Data/items.php` | Editable - authored as `new Weapon(...)` calls, edited entry by entry | -| Armors | `assets/Data/items.php` | Editable - authored as `new Armor(...)` calls, edited entry by entry | -| Enemies | `assets/Data/Enemies/*.php` | Editable - one data record per file, which `enemies.php` loads | -| Troops | `assets/Data/troops.php` | Editable | -| Battle Entry | `assets/Data/battle-entry-rules.php` | Editable | -| States | `assets/Data/states.php` | Editable | -| Animations | `assets/Data/animations.php` | Editable | -| Tilesets | - | Read-only - the engine has no tileset system | -| Common Events | `assets/Events/*.php` | Editable | -| Quests | `assets/Data/quests.php` | Editable - objectives (the target picked by type), reward items with quantities, prerequisites, optional; the id follows the name until something refers to it | -| Skits | `assets/Data/Skits/*.php` | Editable | -| Knowledge | `assets/Data/knowledge.php` | Editable | -| Knowledge Reports | `assets/Data/knowledge.php` | Editable | -| Knowledge Types | `assets/Data/knowledge.php` | Editable | -| Knowledge Enemies | `assets/Data/knowledge.php` | Editable | -| Permanent Growth | `assets/Data/permanent-growth.php` | Editable | -| Optimize Weights | `assets/Data/equipment-optimization.php` | Editable | -| Optimize Outcomes | `assets/Data/equipment-optimization.php` | Editable | -| Optimize Exclusions | `assets/Data/equipment-optimization.php` | Editable | -| System | `assets/Data/system.php` | Editable - title, starting gold, party, inventory and position, battle engine, openings and ATB settings | -| Configuration | `config.php` (`save`, `accessibility`, `ui`, `graphics`, `audio`, `inn`) | Editable - one row per setting, typed by what it holds; field zoom is kept from 1 to 8 | -| Types | `assets/Data/system.php` | Editable - the project's elements, one per row; an empty list means the Engine's defaults. Weapon, armor and equipment types are the Engine's own | -| Terms | `config.php` (`vocab`, `messages`) | Literal terms editable; comments and unrelated expressions preserved | +Both editors edit every category through the same service, so a value +changed in one reads the same in the other, and both save it the same +source-preserving way. The GUI shows a record's rows as the terminal does, +with the summaries the terminal keeps in its side panes (a class's curves, a +skill's effects, a quest's rewards) listed under them. + +| Category | Backing file | Terminal editor | GUI editor | +| --- | --- | --- | --- | +| Actors | `assets/Data/Actors/*.php` | Editable | Same; an identity freeze that needs other files repaired at once is made in the terminal | +| Classes | `assets/Data/classes.php` | Editable - levels, the experience curve, every stat curve, equipment types and skills learned | Same | +| Skills | `assets/Data/Skills/*.php` | Editable - one data record per numbered file, attacks, abilities and spells alike, with their effects; `skills.php` loads them | Same | +| Items | `assets/Data/items.php` | Editable - authored as `new Item(...)` calls, edited entry by entry | Same | +| Weapons | `assets/Data/items.php` | Editable - authored as `new Weapon(...)` calls, edited entry by entry | Same | +| Armors | `assets/Data/items.php` | Editable - authored as `new Armor(...)` calls, edited entry by entry | Same | +| Enemies | `assets/Data/Enemies/*.php` | Editable - one data record per file, which `enemies.php` loads | Same | +| Troops | `assets/Data/troops.php` | Editable | Same | +| Battle Entry | `assets/Data/battle-entry-rules.php` | Editable | Same | +| States | `assets/Data/states.php` | Editable | Same | +| Animations | `assets/Data/animations.php` | Editable | Same | +| Tilesets | - | Read-only - the engine has no tileset system | Same (read-only) | +| Common Events | `assets/Events/*.php` | Editable | Same | +| Quests | `assets/Data/quests.php` | Editable - objectives (the target picked by type), reward items with quantities, prerequisites, optional; the id follows the name until something refers to it | Same | +| Skits | `assets/Data/Skits/*.php` | Editable | Same | +| Knowledge | `assets/Data/knowledge.php` | Editable | Same | +| Knowledge Reports | `assets/Data/knowledge.php` | Editable | Same | +| Knowledge Types | `assets/Data/knowledge.php` | Editable | Same | +| Knowledge Enemies | `assets/Data/knowledge.php` | Editable | Same | +| Permanent Growth | `assets/Data/permanent-growth.php` | Editable | Same | +| Optimize Weights | `assets/Data/equipment-optimization.php` | Editable | Same | +| Optimize Outcomes | `assets/Data/equipment-optimization.php` | Editable | Same | +| Optimize Exclusions | `assets/Data/equipment-optimization.php` | Editable | Same | +| System | `assets/Data/system.php` | Editable - title, starting gold, party, inventory and position, battle engine, openings and ATB settings | Same | +| Configuration | `config.php` (`save`, `accessibility`, `ui`, `graphics`, `audio`, `inn`) | Editable - one row per setting, typed by what it holds; field zoom is kept from 1 to 8 | Same | +| Types | `assets/Data/system.php` | Editable - the project's elements, one per row; an empty list means the Engine's defaults. Weapon, armor and equipment types are the Engine's own | Same | +| Terms | `config.php` (`vocab`, `messages`) | Literal terms editable; comments and unrelated expressions preserved | Same | Why a category can still turn out read-only: a file the editor cannot evaluate, a value it could not write back out, or a comment inside returned diff --git a/docs/roadmap.md b/docs/roadmap.md index 5f6252d..5457bfd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -290,7 +290,7 @@ Order of work, each a complete slice before the next: shadows); the "no tileset system" note is stale. Types is done: it edits the elements `system.php` declares, which the element pickers now read (they read an unused enum under `assets/Data/Types` before). -5. The manual's category table gains a GUI column and states each category's +5. The manual's category table has a GUI column (done) and states each category's real coverage. Acceptance criteria: From ee4ab9a60975508119d9703cea8821542f3cc613 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 17:06:41 +0200 Subject: [PATCH 160/344] feat(database): edit tilesets in both editors Tilesets become a one-file-per-record category over assets/Data/Tilesets: name, the RPG Maker sheets, missing-art tile, above and table tiles, shadows, and pieces keyed by id, stamped (glyph rows, effect, tile rows per layer) or connected (a glyph per shape, a tile per layer for every shape or each). Validation reports every tileset the Engine would refuse. Lists may now be maps: RecordSubList owns how its entries are stored (readEntries/writeEntries), with keyField for keyed lists and valueField for keys whose values are not entries; a new keyed entry gets a free key and a key edit that would empty or repeat one is refused. Whole-number lists (CSV_INTEGERS), mixed sheet-and-tile lists (CSV_TOKENS) and per-shape tiles (SHAPE_TILES) are codecs. A changed variant now drops the old one's values by full path, and its nested list whenever the new variant defines another. The unused file-listing storage and always-read-only flag are removed. --- docs/manual.md | 15 +- docs/roadmap.md | 6 +- .../guide/tooling-loop/the-database.md | 2 +- src/Database/ProjectRecord.php | 22 ++ src/Database/ProjectRecordDatabase.php | 248 +++++++++++------- src/Database/RecordFieldCodec.php | 17 ++ src/Database/RecordSchema.php | 4 - src/Database/RecordSchemaCatalog.php | 94 ++++++- src/Database/RecordStorage.php | 9 - src/Database/RecordSubList.php | 85 ++++++ src/Database/ReferenceCatalog.php | 3 + src/Validation/ProjectValidator.php | 29 ++ src/Validation/RecordReferenceValidator.php | 2 +- tests/Unit/DatabaseDeletionTest.php | 2 +- tests/Unit/RecordCategoryEditorTest.php | 6 +- tests/Unit/RecordDatabaseTest.php | 12 - tests/Unit/TilesetRecordsTest.php | 131 +++++++++ 17 files changed, 542 insertions(+), 145 deletions(-) create mode 100644 tests/Unit/TilesetRecordsTest.php diff --git a/docs/manual.md b/docs/manual.md index 9bc0188..19a8032 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1026,7 +1026,7 @@ skill's effects, a quest's rewards) listed under them. | Battle Entry | `assets/Data/battle-entry-rules.php` | Editable | Same | | States | `assets/Data/states.php` | Editable | Same | | Animations | `assets/Data/animations.php` | Editable | Same | -| Tilesets | - | Read-only - the engine has no tileset system | Same (read-only) | +| Tilesets | `assets/Data/Tilesets/*.php` | Editable - name, RPG Maker sheets, missing-art tile, above and table tiles, shadow, and pieces by id with their glyphs and tiles | Same | | Common Events | `assets/Events/*.php` | Editable | Same | | Quests | `assets/Data/quests.php` | Editable - objectives (the target picked by type), reward items with quantities, prerequisites, optional; the id follows the name until something refers to it | Same | | Skits | `assets/Data/Skits/*.php` | Editable | Same | @@ -1124,6 +1124,19 @@ kind, drops the values only the old one read. Files are numbered skill takes the next number and goes last. `skills.php` is the barrel that loads the folder. +A tileset is one file under `assets/Data/Tilesets`, named by the id maps +give it, so renaming a tileset keeps its file. It names its RPG Maker sheets +(each picked from the project's PNGs), the tiles drawn above characters and +those drawn as tables (tile identities, comma-separated), the shadow its raised +tiles cast (casters, width and opacity, all three or none), the tile that marks +missing art, and its pieces, keyed by piece id. A stamped piece has glyph +rows, an optional effect, and tile rows on each tile layer; a connected piece +(`Connects: lines`) has a glyph for each shape and a tile on each layer, one +for every shape or one per shape (`horizontal: 5888, vertical: 5890, corner: +5892`). A new piece gets an id no other piece has, and an id another piece +already has is refused, since keys are what maps stamp by. Validation reports +a tileset the Engine would refuse, whether or not a map uses it yet. + ### Project-Owned Parameters A weapon or armor's **Special Property** parameters and a Permanent Growth diff --git a/docs/roadmap.md b/docs/roadmap.md index 5457bfd..3e7f260 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -286,8 +286,10 @@ Order of work, each a complete slice before the next: 3. Skills are one declarative record per numbered file under `assets/Data/Skills` (done; `abilities.php` and `magic.php` are gone). Items, weapons and armors follow. -4. Tilesets: an editor for `Data/Tilesets/.php` (sheets, pieces, tables, - shadows); the "no tileset system" note is stale. Types is done: it edits +4. Tilesets are done in both editors: sheets, missing art, above and table + tiles, shadows, and pieces keyed by id, stamped or connected, with their + tile layers. Tile identities are typed in both editors; picking them on a + sheet is GUI work for its visual authoring. Types is done: it edits the elements `system.php` declares, which the element pickers now read (they read an unused enum under `assets/Data/Types` before). 5. The manual's category table has a GUI column (done) and states each category's diff --git a/docs/website/guide/tooling-loop/the-database.md b/docs/website/guide/tooling-loop/the-database.md index e4d91ff..217b9dd 100644 --- a/docs/website/guide/tooling-loop/the-database.md +++ b/docs/website/guide/tooling-loop/the-database.md @@ -68,7 +68,7 @@ Enemies are one data file each under `assets/Data/Enemies`, and `enemies.php` lo Re-author one of those files as a plain array and the editor picks it up as editable automatically. Nothing in the editor needs to change. -`Types` holds your elements: the list in `system.php` that the game knows every element by, and that skills, equipment and affinities pick from. System and Types share `system.php`, and each saves only its own settings. `Tilesets` is empty because the engine has no tileset system: map tiles are painted directly on the canvas. +`Types` holds your elements: the list in `system.php` that the game knows every element by, and that skills, equipment and affinities pick from. System and Types share `system.php`, and each saves only its own settings. `Tilesets` edits each tileset's sheets, tile flags, shadow and the pieces maps are built from, one file per tileset. ## What saving preserves diff --git a/src/Database/ProjectRecord.php b/src/Database/ProjectRecord.php index 89d2855..c15be99 100644 --- a/src/Database/ProjectRecord.php +++ b/src/Database/ProjectRecord.php @@ -387,6 +387,28 @@ private static function withArrayValue(array $array, array $path, mixed $value): return $array; } + /** + * A list's entries as the editor edits them ({@see RecordSubList::readEntries()}). + * + * @return list> + */ + public function getEntries(RecordSubList $list): array + { + return array_values(array_filter($list->readEntries($this->get($list->key)), is_array(...))); + } + + /** + * Replaces a list's entries, stored as the list stores them + * ({@see RecordSubList::writeEntries()}); an empty list the file leaves + * out is removed. + * + * @param list> $entries + */ + public function setEntries(RecordSubList $list, array $entries): void + { + $this->set($list->key, $entries === [] && $list->removeWhenEmpty ? null : $list->writeEntries($entries)); + } + /** * Returns a nested sub-list (objectives, beats, members, commands). * diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 6f01012..e43d23e 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -280,7 +280,6 @@ public static function fromProject(string $projectRoot, RecordSchema $schema, ?P RecordStorage::LIST_FILE => self::loadListFile($path, $schema, $projectRoot), RecordStorage::DIRECTORY => self::loadDirectory($path, $schema, $projectRoot), RecordStorage::CONFIG_SUBTREE => self::loadConfigSubtree($path, $schema, $projectRoot, $config), - RecordStorage::FILE_LISTING => self::loadFileListing($path, $schema), RecordStorage::MAP_OWNED => throw new RuntimeException('Map-owned records are built over their owner, not loaded from a path.'), }; } @@ -412,7 +411,7 @@ protected function buildPersistedPayload(): string static fn(ProjectRecord $record): string => $record->recordId . '=' . PhpValueExporter::export($record->toArray()), $this->records, )) . '|' . implode(';', array_values($this->stagedDeletions)), - RecordStorage::CONFIG_SUBTREE, RecordStorage::FILE_LISTING, RecordStorage::MAP_OWNED => serialize([ + RecordStorage::CONFIG_SUBTREE, RecordStorage::MAP_OWNED => serialize([ array_map(static fn(ProjectRecord $record): array|object => $record->toArray(), $this->records), array_values($this->stagedDeletions), ]), @@ -488,13 +487,13 @@ public function getSettingsFields(int $index): array // count; its heading is where an entry is added to it. $fields[] = [ 'label' => $subList->heading, - 'value' => sprintf('%d', count($record->getSubList($subList->key, $subList->scalarKey))), + 'value' => sprintf('%d', count($record->getEntries($subList))), 'field' => $subList->prefix . 'List', 'listHeading' => true, ]; } - foreach ($record->getSubList($subList->key, $subList->scalarKey) as $entryIndex => $entry) { + foreach ($record->getEntries($subList) as $entryIndex => $entry) { $fields = [...$fields, ...$this->describeSubEntryFields($subList, $entryIndex, $entry, $isEditable, [])]; } } @@ -588,7 +587,7 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, return $fields; } - foreach (array_values((array) ($entry[$nestedList->key] ?? [])) as $nestedIndex => $nestedEntry) { + foreach ($nestedList->readEntries($entry[$nestedList->key] ?? []) as $nestedIndex => $nestedEntry) { if (! is_array($nestedEntry)) { continue; } @@ -732,6 +731,59 @@ public function setField(int $index, string $fieldId, string $rawValue): void } } + /** + * A new entry of a keyed list under a key no other entry holds: its + * blank's, or the list's noun, numbered when taken. + * + * @param list $entries The list's entries. + * @param array $entry The entry to add. + * @return array + */ + private static function withUniqueKey(RecordSubList $list, array $entries, array $entry): array + { + if ($list->keyField === null) { + return $entry; + } + + $taken = array_map(static fn(mixed $other): string => is_array($other) ? strval($other[$list->keyField] ?? '') : '', $entries); + $base = trim(strval($entry[$list->keyField] ?? '')) ?: Slug::of('new ' . $list->singular); + $key = $base; + + for ($number = 2; in_array($key, $taken, true); $number++) { + $key = $base . '-' . $number; + } + + $entry[$list->keyField] = $key; + + return $entry; + } + + /** + * Refuses a keyed list's key edit that would leave an entry without a key, + * or give it another entry's: either would lose an entry on save. + * + * @param list $entries The list's entries. + * @throws \InvalidArgumentException + */ + private static function assertKeyAvailable(RecordSubList $list, array $entries, int $at, RecordField $field, mixed $value): void + { + if ($list->keyField === null || $field->key !== $list->keyField) { + return; + } + + $key = trim(strval($value ?? '')); + + if ($key === '') { + throw new \InvalidArgumentException(sprintf('A %s needs %s.', $list->singular, strtolower($field->label))); + } + + foreach ($entries as $index => $other) { + if ($index !== $at && is_array($other) && strval($other[$list->keyField] ?? '') === $key) { + throw new \InvalidArgumentException(sprintf('Another %s already has %s "%s".', $list->singular, strtolower($field->label), $key)); + } + } + } + /** * Drops the values only a record's old shape offered, when an edit * changes which fields it offers: a spell turned into an ability keeps @@ -883,8 +935,7 @@ public function supportsRecordCreation(): bool { return $this->isEditable() && ! $this->holdsOneRecord() - && $this->schema->storage !== RecordStorage::CONFIG_SUBTREE - && $this->schema->storage !== RecordStorage::FILE_LISTING; + && $this->schema->storage !== RecordStorage::CONFIG_SUBTREE; } /** Whether the file is the category's one record (system.php), only ever edited. */ @@ -1215,10 +1266,10 @@ public function addSubItem(int $index, ?array $entry = null, ?int $at = null, ?s return null; } - $entries = $record->getSubList($subList->key, $subList->scalarKey); + $entries = $record->getEntries($subList); $position = $at === null ? count($entries) : max(0, min(count($entries), $at)); - array_splice($entries, $position, 0, [$entry ?? $subList->blank]); - $record->setSubList($subList->key, $entries, $subList->scalarKey, $subList->removeWhenEmpty); + array_splice($entries, $position, 0, [self::withUniqueKey($subList, $entries, $entry ?? $subList->blank)]); + $record->setEntries($subList, $entries); $this->touchState(); return $position; @@ -1240,14 +1291,14 @@ public function removeSubItem(int $index, int $entryIndex, ?string $listKey = nu return null; } - $entries = $record->getSubList($subList->key, $subList->scalarKey); + $entries = $record->getEntries($subList); if (! array_key_exists($entryIndex, $entries)) { return null; } [$removed] = array_splice($entries, $entryIndex, 1); - $record->setSubList($subList->key, $entries, $subList->scalarKey, $subList->removeWhenEmpty); + $record->setEntries($subList, $entries); $this->touchState(); return $removed; @@ -1270,10 +1321,10 @@ public function insertSubItem(int $index, int $entryIndex, array $entry, ?string return; } - $entries = $record->getSubList($subList->key, $subList->scalarKey); + $entries = $record->getEntries($subList); $entryIndex = max(0, min(count($entries), $entryIndex)); array_splice($entries, $entryIndex, 0, [$entry]); - $record->setSubList($subList->key, $entries, $subList->scalarKey, $subList->removeWhenEmpty); + $record->setEntries($subList, $entries); $this->touchState(); } @@ -1291,7 +1342,7 @@ public function countSubItems(int $index, ?string $listKey = null): int return 0; } - return count($this->getRecordByIndex($index)?->getSubList($subList->key, $subList->scalarKey) ?? []); + return count($this->getRecordByIndex($index)?->getEntries($subList) ?? []); } /** @@ -1305,7 +1356,7 @@ public function getSubItems(int $index, ?string $listKey = null): array { $subList = $this->schema->findInlineSubList($listKey); - return $subList === null ? [] : ($this->getRecordByIndex($index)?->getSubList($subList->key, $subList->scalarKey) ?? []); + return $subList === null ? [] : ($this->getRecordByIndex($index)?->getEntries($subList) ?? []); } /** @@ -1327,7 +1378,7 @@ public function nestedSubListContext(int $index, string $fieldId): ?array } $parentIndex = intval($parentMatch[1]); - $entry = $record->getSubList($subList->key, $subList->scalarKey)[$parentIndex] ?? null; + $entry = $record->getEntries($subList)[$parentIndex] ?? null; $nestedList = is_array($entry) ? $subList->nestedListFor($entry) : null; if ($nestedList === null) { @@ -1357,9 +1408,9 @@ public function addNestedSubItem(int $index, int $parentIndex, ?array $entry = n return null; } - $entries = array_values((array) ($context['parent'][$context['list']->key] ?? [])); + $entries = $context['list']->readEntries($context['parent'][$context['list']->key] ?? []); $position = $at === null ? count($entries) : max(0, min(count($entries), $at)); - array_splice($entries, $position, 0, [$entry ?? $context['list']->blank]); + array_splice($entries, $position, 0, [self::withUniqueKey($context['list'], $entries, $entry ?? $context['list']->blank)]); $this->writeNestedSubList($context, $entries); return $position; @@ -1374,7 +1425,7 @@ public function removeNestedSubItem(int $index, int $parentIndex, int $nestedInd return null; } - $entries = array_values((array) ($context['parent'][$context['list']->key] ?? [])); + $entries = $context['list']->readEntries($context['parent'][$context['list']->key] ?? []); if (! array_key_exists($nestedIndex, $entries) || ! is_array($entries[$nestedIndex])) { return null; @@ -1395,7 +1446,7 @@ public function insertNestedSubItem(int $index, int $parentIndex, int $nestedInd return; } - $entries = array_values((array) ($context['parent'][$context['list']->key] ?? [])); + $entries = $context['list']->readEntries($context['parent'][$context['list']->key] ?? []); $nestedIndex = max(0, min(count($entries), $nestedIndex)); array_splice($entries, $nestedIndex, 0, [$entry]); $this->writeNestedSubList($context, $entries); @@ -1443,7 +1494,6 @@ public function save(): void match ($this->schema->storage) { RecordStorage::DIRECTORY => $this->saveDirectory(), RecordStorage::CONFIG_SUBTREE => null, - RecordStorage::FILE_LISTING => null, RecordStorage::MAP_OWNED => $this->writeBack !== null ? ($this->writeBack)(array_map(static fn(ProjectRecord $record): array|object => $record->toArray(), $this->getRecords())) : null, @@ -1555,7 +1605,7 @@ private static function loadDirectory(string $path, RecordSchema $schema, ?strin $records[] = new ProjectRecord($payload, false, $filename, $stem, $file); } - return new self($schema, $path, $records, null, readOnlyReason: $schema->isAlwaysReadOnly ? $schema->readOnlyNote : null); + return new self($schema, $path, $records, null); } /** @@ -1569,44 +1619,7 @@ private static function loadConfigSubtree(string $path, RecordSchema $schema, ?s { $config ??= new ProjectConfig($projectRoot ?? dirname($path)); return new self($schema, $path, $config->getTermRecords($schema->configPath), - readOnlyReason: $schema->isAlwaysReadOnly ? $schema->readOnlyNote : $config->getReadOnlyReason(), config: $config); - } - - /** - * Lists the files in a directory without evaluating any of them. - * - * @param string $path The directory path. - * @param RecordSchema $schema The category schema. - * @return self - */ - private static function loadFileListing(string $path, RecordSchema $schema): self - { - $records = []; - $files = is_dir($path) ? (glob($path . DIRECTORY_SEPARATOR . '*.php') ?: []) : []; - sort($files); - - foreach ($files as $filename) { - $source = (string) file_get_contents($filename); - $kind = match (true) { - preg_match('/^\s*enum\s+/m', $source) === 1 => 'PHP enum', - preg_match('/^\s*(final\s+)?(readonly\s+)?class\s+/m', $source) === 1 => 'PHP class', - preg_match('/^\s*interface\s+/m', $source) === 1 => 'PHP interface', - default => 'data file', - }; - - $records[] = new ProjectRecord( - [ - 'file' => basename($filename), - 'kind' => $kind, - 'lines' => substr_count($source, "\n") + 1, - ], - false, - $filename, - basename($filename, '.php'), - ); - } - - return new self($schema, $path, $records, null, readOnlyReason: $schema->readOnlyNote); + readOnlyReason: $config->getReadOnlyReason(), config: $config); } /** @@ -1619,10 +1632,6 @@ private static function loadFileListing(string $path, RecordSchema $schema): sel */ private static function resolveReadOnlyReason(RecordSchema $schema, PhpDataFile $file, array $records): ?string { - if ($schema->isAlwaysReadOnly) { - return $schema->readOnlyNote; - } - if ($file->readOnlyReason !== null) { return $file->readOnlyReason; } @@ -2594,7 +2603,7 @@ private function saveDirectory(): void */ private function setSubField(ProjectRecord $record, RecordSubList $subList, int $entryIndex, string $token, string $rawValue): void { - $entries = $record->getSubList($subList->key, $subList->scalarKey); + $entries = $record->getEntries($subList); if (! array_key_exists($entryIndex, $entries)) { return; @@ -2606,10 +2615,12 @@ private function setSubField(ProjectRecord $record, RecordSubList $subList, int } $before = $entries[$entryIndex]; + $value = self::coerce($field, $rawValue); + self::assertKeyAvailable($subList, $entries, $entryIndex, $field, $value); $entries[$entryIndex] = self::writeNested( $entries[$entryIndex], explode('.', $field->key), - self::coerce($field, $rawValue), + $value, ); $entries[$entryIndex] = $subList->removeConflictingFields($entries[$entryIndex], $field->key); @@ -2618,7 +2629,7 @@ private function setSubField(ProjectRecord $record, RecordSubList $subList, int $entries[$entryIndex] = self::withoutStaleVariantFields($subList, $before, $entries[$entryIndex]); } - $record->setSubList($subList->key, $entries, $subList->scalarKey, $subList->removeWhenEmpty); + $record->setEntries($subList, $entries); $this->touchState(); return; @@ -2635,7 +2646,7 @@ private function setNestedSubField( string $fieldToken, string $rawValue, ): void { - $entries = $record->getSubList($subList->key, $subList->scalarKey); + $entries = $record->getEntries($subList); $entry = $entries[$entryIndex] ?? null; if (! is_array($entry)) { @@ -2646,7 +2657,7 @@ private function setNestedSubField( if ($written !== null) { $entries[$entryIndex] = $written; - $record->setSubList($subList->key, $entries, $subList->scalarKey, $subList->removeWhenEmpty); + $record->setEntries($subList, $entries); $this->touchState(); } } @@ -2683,7 +2694,7 @@ private static function withNestedFieldWritten( return null; } - $nestedEntries = array_values((array) ($entry[$nestedList->key] ?? [])); + $nestedEntries = $nestedList->readEntries($entry[$nestedList->key] ?? []); if (! is_array($nestedEntries[$nestedIndex] ?? null)) { return null; @@ -2694,12 +2705,14 @@ private static function withNestedFieldWritten( continue; } + $value = self::coerce($field, $rawValue); + self::assertKeyAvailable($nestedList, $nestedEntries, $nestedIndex, $field, $value); $nestedEntries[$nestedIndex] = self::writeNested( $nestedEntries[$nestedIndex], explode('.', $field->key), - self::coerce($field, $rawValue), + $value, ); - $entry[$nestedList->key] = $nestedEntries; + $entry[$nestedList->key] = $nestedList->writeEntries($nestedEntries); return $entry; } @@ -2846,7 +2859,7 @@ public function countFrameNestedItems(int $recordIndex, array $framePath, int $p $parent = $commands[$parentIndex] ?? null; $nestedList = is_array($parent) && $frameList !== null ? $frameList->nestedListFor($parent) : null; - return $nestedList === null ? 0 : count(array_values((array) ($parent[$nestedList->key] ?? []))); + return $nestedList === null ? 0 : count($nestedList->readEntries($parent[$nestedList->key] ?? [])); } /** @@ -2878,10 +2891,10 @@ public function addFrameNestedItem(int $recordIndex, array $framePath, int $pare return null; } - $nestedEntries = array_values((array) ($parent[$nestedList->key] ?? [])); + $nestedEntries = $nestedList->readEntries($parent[$nestedList->key] ?? []); $position = $at === null ? count($nestedEntries) : max(0, min(count($nestedEntries), $at)); - array_splice($nestedEntries, $position, 0, [$entry ?? $nestedList->blank]); - $commands[$parentIndex][$nestedList->key] = $nestedEntries; + array_splice($nestedEntries, $position, 0, [self::withUniqueKey($nestedList, $nestedEntries, $entry ?? $nestedList->blank)]); + $commands[$parentIndex][$nestedList->key] = $nestedList->writeEntries($nestedEntries); $this->writeFrameCommands($record, $rootList, $framePath, $commands); return $position; @@ -2913,7 +2926,7 @@ public function removeFrameNestedItem(int $recordIndex, array $framePath, int $p return null; } - $nestedEntries = array_values((array) ($parent[$nestedList->key] ?? [])); + $nestedEntries = $nestedList->readEntries($parent[$nestedList->key] ?? []); if (! is_array($nestedEntries[$nestedIndex] ?? null)) { return null; @@ -2928,7 +2941,7 @@ public function removeFrameNestedItem(int $recordIndex, array $framePath, int $p // not. unset($commands[$parentIndex][$nestedList->key]); } else { - $commands[$parentIndex][$nestedList->key] = $nestedEntries; + $commands[$parentIndex][$nestedList->key] = $nestedList->writeEntries($nestedEntries); } $this->writeFrameCommands($record, $rootList, $framePath, $commands); @@ -2950,7 +2963,7 @@ private function nestedListForParent(int $index, int $parentIndex): ?array return null; } - $entries = $record->getSubList($subList->key, $subList->scalarKey); + $entries = $record->getEntries($subList); $parent = $entries[$parentIndex] ?? null; $nestedList = is_array($parent) ? $subList->nestedListFor($parent) : null; @@ -2982,8 +2995,8 @@ private function writeNestedSubList(array $context, array $nestedEntries): void } $entries = $context['entries']; - $entries[$context['parentIndex']][$context['list']->key] = array_values($nestedEntries); - $context['record']->setSubList($subList->key, $entries, $subList->scalarKey, $subList->removeWhenEmpty); + $entries[$context['parentIndex']][$context['list']->key] = $context['list']->writeEntries($nestedEntries); + $context['record']->setEntries($subList, $entries); $this->touchState(); } @@ -3219,15 +3232,49 @@ private static function coerce(RecordField $field, string $rawValue): mixed return null; } - if ($field->codec === RecordFieldCodec::CSV_LIST) { + if (in_array($field->codec, [RecordFieldCodec::CSV_LIST, RecordFieldCodec::CSV_INTEGERS, RecordFieldCodec::CSV_TOKENS], true)) { $items = array_values(array_filter( array_map(trim(...), explode(',', $trimmed)), static fn(string $item): bool => $item !== '', )); + $isWhole = static fn(string $item): bool => preg_match('/\A-?\d+\z/', $item) === 1; + + if ($field->codec === RecordFieldCodec::CSV_INTEGERS) { + if (array_filter($items, static fn(string $item): bool => ! $isWhole($item)) !== []) { + throw new \InvalidArgumentException(sprintf('%s lists whole numbers separated by commas.', $field->label)); + } + + $items = array_map(intval(...), $items); + } elseif ($field->codec === RecordFieldCodec::CSV_TOKENS) { + $items = array_map(static fn(string $item): int|string => $isWhole($item) ? intval($item) : $item, $items); + } return $items === [] && $field->removeWhenEmpty ? null : $items; } + if ($field->codec === RecordFieldCodec::SHAPE_TILES) { + if ($trimmed === '') { + return $field->removeWhenEmpty ? null : ''; + } + + if (! str_contains($trimmed, ':')) { + return $trimmed; + } + + $shapes = []; + + foreach (explode(',', $trimmed) as $pair) { + [$shape, $tile] = array_map(trim(...), explode(':', $pair, 2)) + [1 => '']; + $shapes[$shape] = $tile; + } + + if (array_keys($shapes) !== ['horizontal', 'vertical', 'corner'] || in_array('', $shapes, true)) { + throw new \InvalidArgumentException(sprintf('%s is one tile, or one for each shape: horizontal: 5888, vertical: 5890, corner: 5892.', $field->label)); + } + + return $shapes; + } + if ($field->enumClass !== null && enum_exists($field->enumClass)) { // The stored value is the enum case, not its string: an object // rebuild hands it straight back to a typed constructor argument. @@ -3298,7 +3345,10 @@ private static function displayValue(RecordField $field, mixed $value): string RecordFieldCodec::AFFINITIES => ElementAffinityCodec::encodeAll(is_array($value) ? $value : []), RecordFieldCodec::WORLD_WRITES => WorldWriteCodec::encodeAll(is_array($value) ? $value : []), RecordFieldCodec::ACTOR_PREDICATES => BattleEntryPredicateCodec::encodeAll(is_array($value) ? $value : []), - RecordFieldCodec::CSV_LIST => implode(', ', array_map(strval(...), is_array($value) ? $value : [])), + RecordFieldCodec::CSV_LIST, RecordFieldCodec::CSV_INTEGERS, RecordFieldCodec::CSV_TOKENS => implode(', ', array_map(strval(...), is_array($value) ? $value : [])), + RecordFieldCodec::SHAPE_TILES => is_array($value) + ? implode(', ', array_map(static fn(string $shape): string => sprintf('%s: %s', $shape, ProjectRecord::stringify($value[$shape] ?? '')), array_keys($value))) + : ProjectRecord::stringify($value), RecordFieldCodec::KEY_VALUES => ParameterMapCodec::encode(is_array($value) ? $value : []), // A sprite authored as one string is one row; as a list, its rows. RecordFieldCodec::LINES => is_array($value) @@ -4085,7 +4135,10 @@ private function writeEntryFieldToken(RecordSubList $subList, array $entry, stri * when an entry changes variant: a wait turned into a transfer keeps * nothing of its seconds, and a finalizer command turned from a switch * write into a player move carries no name or value for the Engine's - * strict shapes to refuse. Unknown keys -- neither variant's -- stay. + * strict shapes to refuse. A value is dropped by its whole path, so two + * variants that keep different shapes under one key (a stamped piece's + * glyph rows, a connected piece's glyph per shape) never mix. Unknown + * keys -- neither variant's -- stay. * * @param array $before The entry before the change. * @param array $after The entry with the new variant. @@ -4093,27 +4146,24 @@ private function writeEntryFieldToken(RecordSubList $subList, array $entry, stri */ private static function withoutStaleVariantFields(RecordSubList $subList, array $before, array $after): array { - $roots = static function (array $fields): array { - $keys = []; - - foreach ($fields as $field) { - $keys[] = explode('.', $field->key)[0]; - } + if (strval($before[$subList->variantKey] ?? '') === strval($after[$subList->variantKey] ?? '')) { + // The same variant picked again changes nothing. + return $after; + } - return $keys; - }; - $oldRoots = $roots($subList->fieldsFor($before)); - $newRoots = $roots($subList->fieldsFor($after)); + $keys = static fn(array $fields): array => array_map(static fn(RecordField $field): string => $field->key, $fields); + $newKeys = $keys($subList->fieldsFor($after)); $oldNested = $subList->nestedListFor($before); $newNested = $subList->nestedListFor($after); - foreach ($oldRoots as $root) { - if ($root !== $subList->variantKey && ! in_array($root, $newRoots, true)) { - unset($after[$root]); + foreach ($keys($subList->fieldsFor($before)) as $key) { + if ($key !== $subList->variantKey && ! in_array($key, $newKeys, true)) { + $after = self::writeNested($after, explode('.', $key), null); } } - if ($oldNested !== null && ($newNested === null || $newNested->key !== $oldNested->key)) { + if ($oldNested !== null && $newNested !== $oldNested) { + // Another list, even under the same key, holds another shape. unset($after[$oldNested->key]); } diff --git a/src/Database/RecordFieldCodec.php b/src/Database/RecordFieldCodec.php index 656c366..d2b7f8a 100644 --- a/src/Database/RecordFieldCodec.php +++ b/src/Database/RecordFieldCodec.php @@ -22,6 +22,23 @@ enum RecordFieldCodec: string /** A list of plain strings, shown comma-separated. */ case CSV_LIST = 'csv_list'; + /** A list of whole numbers, shown comma-separated: a tileset's tile identities. */ + case CSV_INTEGERS = 'csv_integers'; + + /** + * A list shown comma-separated whose whole numbers are stored as numbers + * and anything else as text: a tileset's shadow casters, sheet names and + * tile identities alike. + */ + case CSV_TOKENS = 'csv_tokens'; + + /** + * A connected piece's tile on one layer: one tile entry for every shape + * (`5888`), or one per shape, shown as `horizontal: 5888, vertical: 5890, + * corner: 5892`. + */ + case SHAPE_TILES = 'shape_tiles'; + /** An element => multiplier map, edited a row at a time. */ case AFFINITIES = 'affinities'; diff --git a/src/Database/RecordSchema.php b/src/Database/RecordSchema.php index 8059091..9a557cc 100644 --- a/src/Database/RecordSchema.php +++ b/src/Database/RecordSchema.php @@ -31,8 +31,6 @@ * @param string|null $listPayloadKey When set, the file returns the sub-list bare (event scripts). * @param string[] $configPath The config.php subtree roots, for CONFIG_SUBTREE storage. * @param Closure(mixed): bool|null $recordFilter Selects which of a shared file's entries belong here. - * @param bool $isAlwaysReadOnly Whether the category never writes, regardless of the file probe. - * @param string $readOnlyNote An honest explanation shown when the category cannot be edited. */ /** * @param RecordProjection|null $projection How this category's records are @@ -73,8 +71,6 @@ public function __construct( public ?Closure $recordFilter = null, public ?Closure $makeBlank = null, public array $commandLists = [], - public bool $isAlwaysReadOnly = false, - public string $readOnlyNote = '', public ?RecordProjection $projection = null, public ?Closure $fieldsFor = null, public ?Closure $labelFor = null, diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index dc1d081..1db30af 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -37,6 +37,9 @@ use Ichiloto\Engine\Entities\Skills\SkillCatalog; use Ichiloto\Engine\Entities\Skills\SkillRecord; use Ichiloto\Engine\Entities\Skills\SkillResolutionScope; +use Ichiloto\Engine\Rendering\Tilesets\Tileset; +use Ichiloto\Engine\Rendering\Tilesets\TilesetPiece; +use Ichiloto\Engine\Rendering\Tilesets\TilesetSheet; use Ichiloto\Engine\Quests\QuestObjectiveType; use Ichiloto\Engine\Battle\Enumerations\BattleEngineType; use Ichiloto\Engine\Core\Enumerations\MovementHeading; @@ -1499,32 +1502,97 @@ private static function types(): RecordSchema } /** - * Tilesets — no engine system exists yet. - * - * Ichiloto maps store their glyphs directly in the map files and the - * canvas paints them; there is no tileset/terrain table to edit. The - * category stays visible and says so rather than pretending. + * Tilesets: one file per tileset under `assets/Data/Tilesets`, in the + * form the Engine's Tileset reads. A map names its tileset by the file's + * name, so the file keeps its name when the tileset is renamed. A + * tileset names its RPG Maker sheets, the tiles drawn above characters + * or as tables, the shadow its raised tiles cast, the tile that marks + * missing art, and the pieces maps are built from, keyed by piece id: + * a stamped piece's glyph rows with its tile rows on each tile layer, or + * a connected piece's glyph and tile for each shape of a line. * * @return RecordSchema */ private static function tilesets(): RecordSchema { + $layer = new RecordField('layer', 'Tile Layer'); + $tilesFor = static fn(?string $valueField, RecordField $value, array $blank): RecordSubList => new RecordSubList( + key: 'tiles', + prefix: 'tiles', + singular: 'tile layer', + fields: [$layer, $value], + blank: $blank, + keyField: 'layer', + valueField: $valueField, + ); + return new RecordSchema( key: 'tilesets', entryNoun: 'tileset', - storage: RecordStorage::FILE_LISTING, - relativePath: 'assets/Data/Tilesets', + storage: RecordStorage::DIRECTORY, + relativePath: 'assets/' . Tileset::DIRECTORY, fields: [ - new RecordField('file', 'File', isReadOnly: true), - new RecordField('kind', 'Kind', isReadOnly: true), + new RecordField('name', 'Name'), + ...array_map( + static fn(TilesetSheet $sheet): RecordField => new RecordField( + 'sheets.' . $sheet->value, + 'Sheet ' . $sheet->value, + reference: 'png_assets', + removeWhenEmpty: true, + allowsNone: true, + displayDefault: '(none)', + ), + TilesetSheet::cases(), + ), + new RecordField('missingArt', 'Missing Art Tile', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '(none)'), + new RecordField('above', 'Above Characters', codec: RecordFieldCodec::CSV_INTEGERS, removeWhenEmpty: true), + new RecordField('tables', 'Tables', codec: RecordFieldCodec::CSV_INTEGERS, removeWhenEmpty: true), + // A shadow is all three or none; validation names what is missing. + new RecordField('shadows.casters', 'Shadow Casters', codec: RecordFieldCodec::CSV_TOKENS, removeWhenEmpty: true), + new RecordField('shadows.width', 'Shadow Width', InputControlType::FLOAT, removeWhenEmpty: true), + new RecordField('shadows.opacity', 'Shadow Opacity', InputControlType::FLOAT, removeWhenEmpty: true), ], - labelKey: 'file', + labelKey: 'name', identityKey: null, - isAlwaysReadOnly: true, - readOnlyNote: 'the engine has no tileset system yet — map tiles are authored directly on the canvas', + blank: ['name' => 'New Tileset', 'sheets' => []], + subList: new RecordSubList( + key: 'pieces', + prefix: 'piece', + singular: 'piece', + fields: [ + new RecordField('id', 'Id'), + new RecordField('name', 'Name'), + new RecordField('layer', 'Glyph Layer'), + new RecordField( + 'connects', + 'Connects', + reference: 'piece_connections', + removeWhenEmpty: true, + allowsNone: true, + displayDefault: '(stamped whole)', + ), + ], + blank: ['id' => 'new-piece', 'name' => 'New piece', 'layer' => 'fixtures', 'glyphs' => ['#']], + variants: [ + '' => [ + new RecordField('glyphs', 'Glyph Rows', InputControlType::MULTILINE, codec: RecordFieldCodec::LINES), + new RecordField('effect', 'Effect', reference: 'effects', removeWhenEmpty: true, allowsNone: true, displayDefault: '(none)'), + ], + TilesetPiece::LINES => [ + new RecordField('glyphs.horizontal', 'Across Glyph'), + new RecordField('glyphs.vertical', 'Down Glyph'), + new RecordField('glyphs.corner', 'Corner Glyph'), + ], + ], + variantKey: 'connects', + nestedLists: [ + '' => $tilesFor('rows', new RecordField('rows', 'Tile Rows', InputControlType::MULTILINE, codec: RecordFieldCodec::LINES), ['layer' => 'tiles', 'rows' => ['0']]), + TilesetPiece::LINES => $tilesFor('tile', new RecordField('tile', 'Tile', codec: RecordFieldCodec::SHAPE_TILES), ['layer' => 'tiles', 'tile' => '0']), + ], + keyField: 'id', + ), ); } - /** * The shared read-only fields every inventory entry displays. * diff --git a/src/Database/RecordStorage.php b/src/Database/RecordStorage.php index 02fff02..4be7aed 100644 --- a/src/Database/RecordStorage.php +++ b/src/Database/RecordStorage.php @@ -18,15 +18,6 @@ enum RecordStorage: string /** A flattened subtree of the project's config.php (vocab, messages). */ case CONFIG_SUBTREE = 'config_subtree'; - /** - * A read-only inventory of the files in a directory, listed without being - * evaluated. Used where the "data" is really PHP source (the project's - * `Data/Types/*.php` enum declarations), which the editor must never - * `require` — doing so would declare classes into the editor's own - * process and can fatal on redeclaration. - */ - case FILE_LISTING = 'file_listing'; - /** * Records that live inside another asset's data (a map's `npcs`), read * from and written back through that asset rather than a file of their diff --git a/src/Database/RecordSubList.php b/src/Database/RecordSubList.php index 892b2e3..13d4a67 100644 --- a/src/Database/RecordSubList.php +++ b/src/Database/RecordSubList.php @@ -42,6 +42,10 @@ * as files that omit an empty list author it (a quest's reward items). * @param string $heading What a list beside the record's own is headed as ("Reward Items"): its * heading row names it, counts it, and is where an entry is added to it. + * @param string|null $keyField For a list stored as a map (a tileset's pieces, keyed by piece + * id): the field each entry carries its key as. Keys are unique and never empty. + * @param string|null $valueField For a keyed list whose values are not entries of their own (a + * piece's tiles: a layer name keyed to its rows): the field each entry carries its value as. */ public function __construct( public string $key, @@ -58,9 +62,90 @@ public function __construct( public string $heading = '', public ?string $scalarKey = null, public bool $removeWhenEmpty = false, + public ?string $keyField = null, + public ?string $valueField = null, ) { } + /** Where a keyed list's entry keeps a value the editor does not read, to write it back as it was. */ + public const string RAW_VALUE = '__value'; + + /** + * The entries a list holds, as the editor edits them. An entry authored + * as a bare value is its scalar field's value; in a list keyed by + * `keyField` each entry carries its key as that field. A value an + * unkeyed list holds that is neither stays as it is; a keyed list keeps + * it under its key, so writing the list back keeps it. + * + * @param mixed $stored The list as the file holds it. + * @return list + */ + public function readEntries(mixed $stored): array + { + if (! is_array($stored)) { + return []; + } + + $entries = []; + + foreach ($stored as $key => $value) { + $entry = match (true) { + $this->valueField !== null => [$this->valueField => $value], + is_array($value) => $value, + $this->scalarKey !== null && is_scalar($value) => [$this->scalarKey => $value], + default => null, + }; + + $entries[] = $this->keyField === null + ? $entry ?? $value + : [$this->keyField => (string) $key] + ($entry ?? [self::RAW_VALUE => $value]); + } + + return $entries; + } + + /** + * The form a list's entries are stored in: a map keyed by `keyField` + * where the list is one, and an entry holding nothing but its scalar + * field written bare. + * + * @param list $entries The entries, as the editor edits them. + * @return array + */ + public function writeEntries(array $entries): array + { + $stored = []; + + foreach (array_values($entries) as $entry) { + if (! is_array($entry)) { + $stored[] = $entry; + continue; + } + + $key = null; + + if ($this->keyField !== null) { + $key = (string) ($entry[$this->keyField] ?? ''); + unset($entry[$this->keyField]); + } + + $value = match (true) { + $this->valueField !== null => $entry[$this->valueField] ?? null, + array_key_exists(self::RAW_VALUE, $entry) => $entry[self::RAW_VALUE], + $this->scalarKey !== null && array_keys($entry) === [$this->scalarKey] => $entry[$this->scalarKey], + default => $entry, + }; + + if ($key === null) { + $stored[] = $value; + } else { + $stored[$key] = $value; + } + } + + return $stored; + } + /** @param array $entry @return array */ public function removeConflictingFields(array $entry, string $editedField): array { diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index f392802..177e822 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -44,6 +44,7 @@ final class ReferenceCatalog 'classes', 'attack_skills', 'resolution_kinds', + 'piece_connections', 'resolution_scopes', 'magic_effect_types', 'weapon_types', @@ -148,6 +149,8 @@ public function valuesFor(string $category): array // How a skill effect resolves, how often a skill rolls, and what // kind of spell it is: the Engine's own vocabularies. 'resolution_kinds' => array_map(static fn(ResolutionKind $kind): string => $kind->value, ResolutionKind::cases()), + // How a tileset piece joins the cells beside it. + 'piece_connections' => [\Ichiloto\Engine\Rendering\Tilesets\TilesetPiece::LINES], 'resolution_scopes' => array_map(static fn(SkillResolutionScope $scope): string => $scope->value, SkillResolutionScope::cases()), 'magic_effect_types' => array_map(static fn(MagicEffectType $type): string => $type->value, MagicEffectType::cases()), 'weapon_types' => array_map(static fn(WeaponType $type): string => $type->value, WeaponType::cases()), diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index a72a848..097d246 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -25,6 +25,7 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; +use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Ichiloto\Engine\Core\WorldConditionType; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicScriptValidator; use Ichiloto\Engine\Field\SkitSpeaker; @@ -142,6 +143,7 @@ public function validate(ProjectWorkspace $workspace): array ...$this->checkKnowledgeCatalog($workspace), ...$this->checkSpecialProperties($workspace), ...$this->checkElements($workspace), + ...$this->checkTilesets($workspace), ...$this->checkPermanentGrowth($workspace), ...$this->checkOptimizationPolicy($workspace), ...new SaveCompatibilityValidator()->validate($workspace), @@ -371,6 +373,33 @@ protected function checkActorDefinitions(ProjectWorkspace $workspace): array * @param ProjectWorkspace $workspace The project. * @return Issue[] The issues found. */ + /** + * Checks every tileset as the Engine reads it, unsaved edits included: + * one the Engine refuses leaves every map that names it without its + * pieces and graphics, used by a map yet or not. + * + * @param ProjectWorkspace $workspace The project. + * @return Issue[] The issues found. + */ + protected function checkTilesets(ProjectWorkspace $workspace): array + { + $issues = []; + + foreach ($workspace->getRecordDatabase('tilesets')?->getRecords() ?? [] as $record) { + try { + Tileset::fromArray($record->recordId, (array) $record->toArray()); + } catch (InvalidArgumentException $refused) { + $issues[] = Issue::error( + sprintf('assets/%s/%s.php', Tileset::DIRECTORY, $record->recordId), + $refused->getMessage(), + 'Correct it in Database > Tilesets.', + ); + } + } + + return $issues; + } + /** * Checks the project's elements as the game reads them at start: a name * twice, or an empty one, stops the game from starting at all. diff --git a/src/Validation/RecordReferenceValidator.php b/src/Validation/RecordReferenceValidator.php index 0d196dd..42aaad7 100644 --- a/src/Validation/RecordReferenceValidator.php +++ b/src/Validation/RecordReferenceValidator.php @@ -102,7 +102,7 @@ private function checkRecord(ProjectRecordDatabase $database, ProjectRecord $rec if (in_array($list->key, self::OWNED_ELSEWHERE[$schema->key] ?? [], true)) { continue; } - foreach ($record->getSubList($list->key, $list->scalarKey) as $position => $entry) { + foreach ($record->getEntries($list) as $position => $entry) { $entryWhere = sprintf('%s, %s %d', $where, $list->singular, $position + 1); foreach ($list->fieldsFor($entry) as $field) { $issue = $this->checkValue($field, self::readPath($entry, $field->key), $entryWhere, true); diff --git a/tests/Unit/DatabaseDeletionTest.php b/tests/Unit/DatabaseDeletionTest.php index 909f28e..8cfddde 100644 --- a/tests/Unit/DatabaseDeletionTest.php +++ b/tests/Unit/DatabaseDeletionTest.php @@ -110,7 +110,7 @@ try { $editor = deletionEditor($root); - // The engine has no tileset system, so the category is genuinely empty. + // The fixture has no tilesets, so the category is genuinely empty. openDatabaseCategory($editor, 'tilesets'); callEditorMethod($editor, 'dispatchInput', "\033[3~"); diff --git a/tests/Unit/RecordCategoryEditorTest.php b/tests/Unit/RecordCategoryEditorTest.php index 9020b02..31806ba 100644 --- a/tests/Unit/RecordCategoryEditorTest.php +++ b/tests/Unit/RecordCategoryEditorTest.php @@ -149,13 +149,15 @@ it('enrols editable record categories in Save All and leaves read-only ones out', function (): void { $root = makeTemporaryProject(); + // system.php, which System and Types share, written so the editor cannot rewrite it. + makeUnwritableCategory($root); try { $editor = deletionEditor($root); $saveable = getEditorProperty($editor, 'workspace')->listSaveableDatabases(); - expect(array_keys($saveable))->toContain('States', 'Troops', 'Skits', 'Common Events', 'Terms'); - expect(array_keys($saveable))->not->toContain('Items', 'Weapons', 'Armors', 'Enemies', 'Types', 'Tilesets'); + expect(array_keys($saveable))->toContain('States', 'Troops', 'Skits', 'Common Events', 'Terms', 'Tilesets'); + expect(array_keys($saveable))->not->toContain('Items', 'Weapons', 'Armors', 'Enemies', 'System', 'Types'); foreach (['States', 'Troops', 'Skits'] as $label) { expect($saveable[$label])->toBeInstanceOf(ProjectRecordDatabase::class); diff --git a/tests/Unit/RecordDatabaseTest.php b/tests/Unit/RecordDatabaseTest.php index 7fe0552..20d5fec 100644 --- a/tests/Unit/RecordDatabaseTest.php +++ b/tests/Unit/RecordDatabaseTest.php @@ -399,15 +399,3 @@ removeDirectoryRecursively($root); }); - -it('explains tilesets', function (): void { - $root = makeTemporaryProject(); - - $tilesets = loadRecordDatabase($root, 'tilesets'); - - expect($tilesets->isEditable())->toBeFalse(); - expect($tilesets->getReadOnlyReason())->toContain('no tileset system'); - expect($tilesets->getEntryLabels())->toBe([]); - - removeDirectoryRecursively($root); -}); diff --git a/tests/Unit/TilesetRecordsTest.php b/tests/Unit/TilesetRecordsTest.php new file mode 100644 index 0000000..82fa948 --- /dev/null +++ b/tests/Unit/TilesetRecordsTest.php @@ -0,0 +1,131 @@ + 'Inside', + 'sheets' => [ + 'A4' => 'Graphics/Tilesets/Inside/A4.png', + 'B' => 'Graphics/Tilesets/Inside/B.png', + ], + 'missingArt' => 255, + 'shadows' => ['casters' => ['A4'], 'width' => 0.5, 'opacity' => 0.5], + 'pieces' => [ + // A bed stands two cells long. + 'bed' => ['name' => 'Bed', 'layer' => 'fixtures', 'glyphs' => ['O', 'U'], 'tiles' => ['furniture' => ['1', '9']]], + 'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'connects' => 'lines', + 'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'], 'tiles' => ['walls' => '5888']], + ], + ]; + + PHP); + + return $root; +} + +function loadTileset(string $root): Tileset +{ + return Tileset::load($root . '/assets', 'inside'); +} + +it('reads a tileset\'s sheets, flags, shadow and pieces as rows, a piece\'s tiles by layer', function () { + $tilesets = loadRecordDatabase(tilesetProject(), 'tilesets'); + $fields = array_column(array_filter($tilesets->getFrameSettingsFields(0, []), static fn(array $field): bool => isset($field['field'])), 'value', 'field'); + + expect($tilesets->getEntryLabels())->toBe(['Inside']) + ->and($fields)->toMatchArray([ + 'sheets.A4' => 'Graphics/Tilesets/Inside/A4.png', + 'missingArt' => '255', + 'shadows.casters' => 'A4', + 'piece0Id' => 'bed', + 'piece0Tiles0Layer' => 'furniture', + 'piece1Id' => 'wall', + 'piece1Connects' => 'lines', + 'piece1Glyphscorner' => '+', + 'piece1Tiles0Tile' => '5888', + ]) + ->and($tilesets->getRecordByIndex(0)?->get('pieces')['bed']['tiles'])->toBe(['furniture' => ['1', '9']]); +}); + +it('edits pieces in their own source, keeping comments, keys and order', function () { + $root = tilesetProject(); + $path = $root . '/assets/Data/Tilesets/inside.php'; + $source = (string) file_get_contents($path); + $tilesets = loadRecordDatabase($root, 'tilesets'); + + $tilesets->setField(0, 'piece0Tiles0Rows', "2\n10"); + $tilesets->setField(0, 'piece1Tiles0Tile', 'horizontal: 5888, vertical: 5890, corner: 5892'); + $tilesets->setField(0, 'above', '4736, 4784'); + $tilesets->save(); + + $tileset = loadTileset($root); + + $saved = (string) file_get_contents($path); + + expect($saved)->toContain("// The rooms everything indoors is built from.", "// A bed stands two cells long.", "'tiles' => ['furniture' => ['2', '10']]]") + ->and(substr($saved, 0, (int) strpos($saved, " 'pieces'")))->toBe(substr($source, 0, (int) strpos($source, " 'pieces'"))) + ->and($tileset->pieces['bed']->tiles['furniture'])->toBe([['2'], ['10']]) + ->and($tileset->pieces['wall']->shapeTiles['walls']['vertical'])->toBe('5890') + ->and($tileset->above)->toBe([4736, 4784]); +}); + +it('adds pieces under ids of their own and refuses an id another piece has', function () { + $root = tilesetProject(); + $tilesets = loadRecordDatabase($root, 'tilesets'); + $authoring = new RecordAuthoring(); + + $authoring->addItem($tilesets, 0, [], 'piece1Name'); + $authoring->addItem($tilesets, 0, [], 'piece2Name'); + + expect(array_keys($tilesets->getRecordByIndex(0)?->get('pieces') ?? []))->toBe(['bed', 'wall', 'new-piece', 'new-piece-2']) + ->and(fn() => $authoring->applyField($tilesets, 0, [], 'piece2Id', 'bed', 'Id'))->toThrow(Ichiloto\Editor\Database\RecordRefusal::class, 'Another piece already has id "bed".') + ->and(fn() => $authoring->applyField($tilesets, 0, [], 'piece2Id', ' ', 'Id'))->toThrow(Ichiloto\Editor\Database\RecordRefusal::class, 'A piece needs id.'); + + $authoring->applyField($tilesets, 0, [], 'piece2Id', 'lamp', 'Id'); + $tilesets->save(); + + expect(array_keys(loadTileset($root)->pieces))->toBe(['bed', 'wall', 'lamp', 'new-piece-2']); +}); + +it('turns a stamped piece into a connected one without keeping its rows', function () { + $root = tilesetProject(); + $tilesets = loadRecordDatabase($root, 'tilesets'); + + $tilesets->setField(0, 'piece0Connects', 'lines'); + $bed = $tilesets->getRecordByIndex(0)?->get('pieces')['bed']; + + expect($bed)->toBe(['name' => 'Bed', 'layer' => 'fixtures', 'connects' => 'lines']); +}); + +it('reports a tileset the Engine would refuse, by its file', function () { + $root = tilesetProject(); + $tilesets = loadRecordDatabase($root, 'tilesets'); + $tilesets->setField(0, 'sheets.A4', ''); + $tilesets->setField(0, 'sheets.B', ''); + $tilesets->save(); + + $issues = array_filter( + new Ichiloto\Editor\Validation\ProjectValidator()->validate(Ichiloto\Editor\ProjectWorkspace::fromProject($root)), + static fn(Issue $issue): bool => str_contains($issue->where, 'Tilesets'), + ); + + expect(array_map(static fn(Issue $issue): string => $issue->where . ': ' . $issue->message, array_values($issues))) + ->toBe(['assets/Data/Tilesets/inside.php: Tileset inside needs at least one sheet.']); +}); From b55dfd22ea28df79cf595b3f6b5674ee6efb4e00 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 17:40:15 +0200 Subject: [PATCH 161/344] feat(database): edit items, weapons and armors as one record per file Items, weapons and armors are numbered record files under assets/Data/Items, Weapons and Armors, read through the Engine's ItemRecord, with item scope, occasion, animation and effects now authored in both editors. - InventoryCatalog::readDefinition builds a record as the game does, shared by Optimize and the equipment vocabulary, which now counts the Engine's defaults. - Validation reports each record the Engine's item catalogue refuses against its own file, replacing the whole-file inventory check. - A parameter line keeps hidden values where they were authored, so an unchanged line no longer marks a record dirty. - Tests use synthetic record fixtures; the manual, guides and roadmap describe the record folders. --- docs/guides/author-a-cutscene.md | 2 +- docs/manual.md | 73 +++--- docs/roadmap.md | 15 +- .../guide/tooling-loop/the-database.md | 2 +- src/Database/InventoryCatalog.php | 27 +- src/Database/ParameterMapCodec.php | 25 +- src/Database/ProjectRecordDatabase.php | 9 +- src/Database/RecordSchemaCatalog.php | 173 ++++++++----- src/Database/ReferenceCatalog.php | 10 +- src/EquipmentOptimizationPolicy.php | 7 +- src/ProjectWorkspace.php | 12 + src/Validation/ProjectValidator.php | 81 ++---- .../assets/Data/Items/0001-s-potion.php | 15 ++ .../assets/Data/Items/0002-antidote.php | 15 ++ .../assets/Data/Weapons/0001-wooden-sword.php | 19 ++ .../sample-project/assets/Data/items.php | 13 +- tests/Pest.php | 60 +++++ tests/Unit/DefinitionDiagnosticsTest.php | 47 ++-- tests/Unit/ElementalAuthoringTest.php | 12 +- tests/Unit/EquipmentOptimizationTest.php | 4 +- tests/Unit/EquipmentTypeIconTest.php | 9 +- tests/Unit/IdentityFailClosedTest.php | 88 +++---- tests/Unit/InventoryIdentityTest.php | 100 +++----- .../Unit/InventorySourcePreservationTest.php | 237 ++++++------------ tests/Unit/ObjectRecordCreationTest.php | 22 +- tests/Unit/ProjectOwnedDataTest.php | 25 +- tests/Unit/RecordDatabaseTest.php | 13 +- tests/Unit/RecordReorderDurabilityTest.php | 6 +- .../Unit/StructuralSourcePreservationTest.php | 223 +++------------- 29 files changed, 625 insertions(+), 719 deletions(-) create mode 100644 tests/Fixtures/sample-project/assets/Data/Items/0001-s-potion.php create mode 100644 tests/Fixtures/sample-project/assets/Data/Items/0002-antidote.php create mode 100644 tests/Fixtures/sample-project/assets/Data/Weapons/0001-wooden-sword.php diff --git a/docs/guides/author-a-cutscene.md b/docs/guides/author-a-cutscene.md index c16c346..794c4b1 100644 --- a/docs/guides/author-a-cutscene.md +++ b/docs/guides/author-a-cutscene.md @@ -55,7 +55,7 @@ The full vocabulary: | `set_switch` | Switch, Value | | | `set_variable` | Variable, Operation, Value | Operation is `set` or `add` | | `record_event` | Story Event | What conditions test with `event:` | -| `give_item` | Item, Quantity | Item must exist in `items.php` | +| `give_item` | Item, Quantity | Item must be an item, weapon or armor the project defines | | `give_gold` | Amount | Negative debits | | `recover_party` | none | Fully restores travelling members and clears battle-only states | | `play_sound` | Sound | | diff --git a/docs/manual.md b/docs/manual.md index 19a8032..71811d0 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1018,9 +1018,9 @@ skill's effects, a quest's rewards) listed under them. | Actors | `assets/Data/Actors/*.php` | Editable | Same; an identity freeze that needs other files repaired at once is made in the terminal | | Classes | `assets/Data/classes.php` | Editable - levels, the experience curve, every stat curve, equipment types and skills learned | Same | | Skills | `assets/Data/Skills/*.php` | Editable - one data record per numbered file, attacks, abilities and spells alike, with their effects; `skills.php` loads them | Same | -| Items | `assets/Data/items.php` | Editable - authored as `new Item(...)` calls, edited entry by entry | Same | -| Weapons | `assets/Data/items.php` | Editable - authored as `new Weapon(...)` calls, edited entry by entry | Same | -| Armors | `assets/Data/items.php` | Editable - authored as `new Armor(...)` calls, edited entry by entry | Same | +| Items | `assets/Data/Items/*.php` | Editable - one data record per numbered file, with scope, occasion, animation and effects; `items.php` loads Items, Weapons and Armors | Same | +| Weapons | `assets/Data/Weapons/*.php` | Editable - one data record per numbered file, with type, stats, element, affinities and equipment metadata | Same | +| Armors | `assets/Data/Armors/*.php` | Editable - one data record per numbered file, armor or accessory, with type, slot, stats and affinities | Same | | Enemies | `assets/Data/Enemies/*.php` | Editable - one data record per file, which `enemies.php` loads | Same | | Troops | `assets/Data/troops.php` | Editable | Same | | Battle Entry | `assets/Data/battle-entry-rules.php` | Editable | Same | @@ -1049,45 +1049,7 @@ data that holds objects or is not an array literal are each a reason, and the st still browse everything: an enemy shows its level, every stat, its sprite, its battle rewards, and its element affinities. -### How A File Of Constructor Calls Is Written - -`items.php` is not data, it is PHP code that *builds* data: `new Item(...)`, -`new Weapon(...)` and `new Armor(...)` calls with named arguments, -imports, comments, and enum expressions the author chose. Regenerating such -a file from loaded values would reorder arguments, spell out defaults nobody -wrote, and rewrite every entry to change one. So the editor does not -regenerate it. It edits the author's own source, entry by entry: - -- **A changed value** is patched where its argument sits. Every other byte of - the file - the other arguments, the other entries, the comments between - them - is the same afterwards. -- **A new entry** is written as a constructor call in the file's own - indentation, after the last entry. -- **A deleted entry** is cut whole, with its separator. -- **A deleted entry put back** by undo goes back exactly where it was when - the file has not been saved in between, and, when it has, is written back - ahead of the entry that follows it in the list - so the file reads in the - order the editor does. - -Every entry is found by the identity it declares - an item's stable id - -looked up in a fresh reading of the file at the moment of -writing, never by where it happened to sit when it was loaded. That is what -lets three categories share one file: Items, Weapons and Armors are three -views of `items.php`, and saving one of them, or all of them with `Ctrl+A`, -reads the file once, composes every dirty category's changes against that -one reading, and writes it once. - -Equipment is shown everywhere, in every renderer, by the one icon of its -type (one for all swords, one for all daggers), which the game's theme -binds. A weapon's or armor's own `icon` is legacy compatibility data: the -Weapons and Armors records show it as a read-only `Legacy Icon` row and keep -it exactly as written. A consumable item's `Icon` stays its own and -editable. - -Where identity cannot prove the address, nothing is written and the status -line says why: two entries in the file declaring one id, an entry declaring -none, or a save that would leave two entries declaring one id. Give each -entry a distinct id, reload, and save again. +### How Data Files Are Written Files that are data - Troops, States, Permanent Growth - are regenerated as data, keeping everything from ` InventoryItem::class, 'data' +=> [...]]` with a `kind` of `item`, `weapon`, `armor` or `accessory`, a +stable id, and only the values the record sets: the Engine's defaults fill +the rest. An item has its scope, occasion, battle animation and effects: a +list under the Effects heading, each a type (HP or MP recovery, resurrection, +HP damage, or a max HP or MP increase) with a value, a success rate and +whether the value is an amount or a percentage. Equipment has its type, +parameter changes, element and affinities, and its metadata (form, size, +material, slot, availability, acquisition policy, special property). An +armor that becomes an accessory drops its equipment type. Files are numbered +because shops and menus list definitions in file order, items first, then +weapons, then armors; a new or copied record takes the next number and goes +last. `items.php` is the barrel that loads all three folders. A record the +game cannot build, such as a sell rate outside 0 through 10000, is left out +of the game and reported by validation against its own file, in the Engine's +own words. + +Equipment is shown everywhere, in every renderer, by the one icon of its +type (one for all swords, one for all daggers), which the game's theme +binds. A weapon's or armor's own `icon` is legacy compatibility data: the +Weapons and Armors records show it as a read-only `Legacy Icon` row and keep +it exactly as written. A consumable item's `Icon` stays its own and +editable. + A tileset is one file under `assets/Data/Tilesets`, named by the id maps give it, so renaming a tileset keeps its file. It names its RPG Maker sheets (each picked from the project's PNGs), the tiles drawn above characters and @@ -2212,7 +2199,7 @@ renderers enter battle with a direct cut; the terminal intro is unaffected. An inventory id two definitions claim is reported once, naming every claimant with the category it was authored in, the aliases it brought, and -its entry in `items.php` - the game refuses the whole catalogue until one +its record file - the game refuses the whole catalogue until one of them is renamed, and until then nothing under any claimant is offered by a picker, resolved by a reference, or accepted as a save alias target. diff --git a/docs/roadmap.md b/docs/roadmap.md index 3e7f260..61adbbe 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -263,8 +263,8 @@ actors through `ActorCategory` over `ActorAuthoring`, the actor service the terminal editor uses too. Progress: Classes, Quests, System, a new Configuration category, Actors, -Types and Skills are done in both editors; the GUI session now serves every -category. +Types, Skills, Tilesets, Items, Weapons and Armors are done in both editors; +the GUI session now serves every category. Known gap: an actor authored without an id whose identity freeze also needs other files rewritten (the cross-file repair `SourceSetCommand` writes at once) is refused in the GUI with a pointer to the terminal's repair dialog; @@ -276,8 +276,8 @@ Order of work, each a complete slice before the next: 1. Classes, Quests, System, Actors and Skills move onto the shared record service. The terminal keeps its workflow and every field and preview it has now (experience and stat curves, actor stats); the GUI gains the category. -2. Data the record files hold but no editor reaches: item effects (after - items' per-record move). Skill effects and kind are done. Enemy drops are done, +2. Data the record files hold but no editor reaches: done. Item effects, + scope, occasion and animation, and skill effects and kind, are edited. Enemy drops are done, and every schema record's references, list entries included, are now validated. Enemy `position` and `battleAnimation` are not authored: troops own battle positions (`Troop` overwrites the enemy's), and no runtime code @@ -285,7 +285,12 @@ Order of work, each a complete slice before the next: The rest of `system.php` is done (System and Configuration). 3. Skills are one declarative record per numbered file under `assets/Data/Skills` (done; `abilities.php` and `magic.php` are gone). - Items, weapons and armors follow. + Items, weapons and armors are too, under `assets/Data/Items`, `Weapons` + and `Armors`, with `items.php` as their barrel (done). No catalogued + category stores `new Class(...)` entries any more, so the object-entry + write path (constructor-call entries in `ProjectRecordDatabase` and + `SharedFileTransaction`) serves no category; removing it, while keeping + the `PhpSourceDocument` reading `ActorReferenceSource` uses, is open. 4. Tilesets are done in both editors: sheets, missing art, above and table tiles, shadows, and pieces keyed by id, stamped or connected, with their tile layers. Tile identities are typed in both editors; picking them on a diff --git a/docs/website/guide/tooling-loop/the-database.md b/docs/website/guide/tooling-loop/the-database.md index 217b9dd..37a7eec 100644 --- a/docs/website/guide/tooling-loop/the-database.md +++ b/docs/website/guide/tooling-loop/the-database.md @@ -64,7 +64,7 @@ Editable today: Actors, Classes, Skills, Items, Weapons, Armors, Enemies, Troops Enemies are one data file each under `assets/Data/Enemies`, and `enemies.php` loads them. An enemy's action patterns name skills from your skill catalogue, so you pick the skill rather than rebuild it. -`items.php` is PHP that *builds* data with `new Item(...)`, `new Weapon(...)` and `new Armor(...)` calls. The editor never regenerates it: a change is patched into the argument it belongs to, so your imports, comments and the other entries stay exactly as you wrote them. +Items, weapons and armors are one data file each under `assets/Data/Items`, `Weapons` and `Armors`, numbered in the order shops list them, and `items.php` loads all three. An item's effects are a list you edit row by row; a change to one record touches only its own file, keeping your imports and comments. Re-author one of those files as a plain array and the editor picks it up as editable automatically. Nothing in the editor needs to change. diff --git a/src/Database/InventoryCatalog.php b/src/Database/InventoryCatalog.php index 15b3f05..6b84090 100644 --- a/src/Database/InventoryCatalog.php +++ b/src/Database/InventoryCatalog.php @@ -5,6 +5,9 @@ namespace Ichiloto\Editor\Database; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Entities\Inventory\InventoryItem; +use Ichiloto\Engine\Entities\Inventory\ItemRecord; +use InvalidArgumentException; use RuntimeException; /** @@ -34,9 +37,9 @@ final class InventoryCatalog /** * The record categories whose entries are inventory definitions. * - * The engine reads one `assets/Data/items.php` catalogue; the editor - * presents it as consumables, weapons and armors, so identity spans all - * three rather than living in any one of them. + * The engine reads one catalogue from the `Items`, `Weapons` and `Armors` + * record folders; the editor presents it as those three categories, so + * identity spans all three rather than living in any one of them. */ public const array CATEGORIES = ['items', 'weapons', 'armors']; @@ -420,6 +423,24 @@ public static function definitionId(mixed $authoredId, string $name): ?string return 'legacy.' . self::slug($name); } + /** + * Returns a definition as the game builds it from its record, unsaved + * edits included and the defaults the record leaves out filled in. A + * record the game cannot build is the validator's to report, so it reads + * as null here. + * + * @param ProjectRecord $record The inventory record. + * @return InventoryItem|null The definition. + */ + public static function readDefinition(ProjectRecord $record): ?InventoryItem + { + try { + return ItemRecord::readItem((array) $record->toArray()); + } catch (InvalidArgumentException) { + return null; + } + } + /** * Determines whether an authored id is one the engine will accept. * diff --git a/src/Database/ParameterMapCodec.php b/src/Database/ParameterMapCodec.php index d03a4d3..b94f950 100644 --- a/src/Database/ParameterMapCodec.php +++ b/src/Database/ParameterMapCodec.php @@ -133,7 +133,9 @@ public static function decode(string $line): array /** * Returns a map with authored scalars replaced and everything the line - * cannot carry kept exactly as it was. + * cannot carry kept exactly as it was, where it was: a hidden value stays + * after the parameter it followed, so a line read back unchanged leaves + * the map as stored. * * @param array $existing The parameters as stored. * @param array $authored The parameters read off the line. @@ -141,14 +143,29 @@ public static function decode(string $line): array */ public static function merge(array $existing, array $authored): array { - $merged = $authored; + $leading = []; + $hiddenAfter = []; + $anchor = null; foreach ($existing as $name => $value) { - if (! is_scalar($value) && ! array_key_exists($name, $authored)) { - $merged[$name] = $value; + if (array_key_exists($name, $authored)) { + $anchor = $name; + } elseif (is_scalar($value)) { + continue; + } elseif ($anchor === null) { + $leading[$name] = $value; + } else { + $hiddenAfter[$anchor][$name] = $value; } } + $merged = $leading; + + foreach ($authored as $name => $value) { + $merged[$name] = $value; + $merged += $hiddenAfter[$name] ?? []; + } + return $merged; } diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index e43d23e..3099e4c 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -150,7 +150,8 @@ private static function identityOf(mixed $entry, ?string $key): ?string * Describes where a record sits in its source, for a diagnostic that has * to tell two records with one identity apart. * - * Only for a category over one file, and only while the records still + * A record with a file of its own is that file. In a category over one + * file it is the record's place in it, and only while the records still * stand in the order the file was read in -- after a record is added or * removed the file's places no longer line up with the list, and a * guess would point at the wrong entry, so nothing is said. @@ -160,6 +161,12 @@ private static function identityOf(mixed $entry, ?string $key): ?string */ public function sourceContextOf(int $index): ?string { + if ($this->schema->storage === RecordStorage::DIRECTORY) { + $record = array_values($this->records)[$index] ?? null; + + return $record === null ? null : sprintf('%s/%s.php', $this->schema->relativePath, $record->recordId); + } + if ($this->schema->storage !== RecordStorage::LIST_FILE || ! $this->file instanceof PhpDataFile || ! is_array($this->file->payload)) { return null; } diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 1db30af..f9ec90a 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -47,15 +47,14 @@ use Ichiloto\Engine\Entities\Enumerations\ActionConditionType; use Ichiloto\Engine\Entities\Enumerations\ItemUserType; use Ichiloto\Engine\Progress\Knowledge\KnowledgeProgressService; -use Ichiloto\Engine\Entities\Inventory\Accessory; +use Ichiloto\Engine\Entities\Inventory\InventoryItem; +use Ichiloto\Engine\Entities\Inventory\ItemCatalog; +use Ichiloto\Engine\Entities\Inventory\ItemRecord; +use Ichiloto\Engine\Entities\Enumerations\ValueBasis; use Ichiloto\Engine\Entities\Inventory\EquipmentSlotType; -use Ichiloto\Engine\Entities\Inventory\Armor; -use Ichiloto\Engine\Entities\Inventory\Items\Item; -use Ichiloto\Engine\Entities\Inventory\Weapons\Weapon; use Ichiloto\Engine\Entities\Enumerations\ArmorType; use Ichiloto\Engine\Entities\Enumerations\WeaponType; use Ichiloto\Engine\Entities\Character; -use Ichiloto\Engine\Entities\ParameterChanges; /** * The schemas behind the Database categories added in Phase 6. @@ -362,49 +361,70 @@ private static function battleEntryRules(): RecordSchema } /** - * Consumables and key items — the `Item` entries of `assets/Data/items.php`. - * - * The file is authored as `new Item(...)` constructor calls, so the editor - * browses it and never rewrites it. + * Consumables and key items: one record per numbered file under + * `assets/Data/Items`, in the form the Engine's ItemRecord reads, with + * whom an item reaches, when, its effects and its animation. * * @return RecordSchema */ private static function items(): RecordSchema { + $enumValues = static fn(array $cases): array => array_map(static fn(\BackedEnum $case): string => strval($case->value), $cases); + return new RecordSchema( key: 'items', entryNoun: 'item', - storage: RecordStorage::LIST_FILE, - relativePath: 'assets/Data/items.php', + storage: RecordStorage::DIRECTORY, + relativePath: 'assets/Data/' . ItemCatalog::DIRECTORIES[0], fields: [ ...self::inventoryFields(), - // Only a plain item carries a stack limit: the engine's - // Equipment constructor does not take one. - new RecordField('maxQuantity', 'Max Quantity', InputControlType::INTEGER), + new RecordField('scope.side', 'Scope Side', options: $enumValues(ItemScopeSide::cases()), removeWhenEmpty: true, displayDefault: ItemScopeSide::NONE->value), + new RecordField('scope.number', 'Scope Number', options: $enumValues(ItemScopeNumber::cases()), removeWhenEmpty: true, displayDefault: ItemScopeNumber::ONE->value), + new RecordField('scope.status', 'Scope Status', options: $enumValues(ItemScopeStatus::cases()), removeWhenEmpty: true, displayDefault: ItemScopeStatus::ALIVE->value), + new RecordField('scope.randomNumber', 'Random Targets', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '1'), + new RecordField('occasion', 'Occasion', options: $enumValues(Occasion::cases()), removeWhenEmpty: true, displayDefault: Occasion::ALWAYS->value), new RecordField( 'animationId', 'Animation', InputControlType::INTEGER, reference: 'animation_ids', + removeWhenEmpty: true, allowsNone: true, displayDefault: '(None)', ), ], labelKey: 'name', identityKey: 'id', - recordFilter: static fn(mixed $entry): bool => $entry instanceof Item, - makeBlank: static fn(string $name): object => new Item( - $name, - 'What it does.', - '✨', - 0, - id: self::inventoryDefinitionId('item', $name), + subList: new RecordSubList( + key: 'effects', + prefix: 'effect', + singular: 'effect', + fields: [ + new RecordField('type', 'Type', options: array_keys(ItemRecord::EFFECTS)), + new RecordField('name', 'Name'), + new RecordField('description', 'Description'), + new RecordField('value', 'Value', InputControlType::INTEGER), + new RecordField('successRate', 'Success Rate', InputControlType::FLOAT), + new RecordField('valueBasis', 'Value Basis', options: $enumValues(ValueBasis::cases()), removeWhenEmpty: true, displayDefault: ValueBasis::ACTUAL->value), + ], + blank: ['type' => 'hp_recovery', 'name' => 'Recover HP', 'description' => 'Recovers HP', 'value' => 50, 'successRate' => 1.0], + removeWhenEmpty: true, ), + makeBlank: static fn(string $name): array => [ + 'kind' => 'item', + 'id' => self::inventoryDefinitionId('item', $name), + 'name' => $name, + 'description' => 'What it does.', + 'icon' => '✨', + 'price' => 0, + ], + recordClass: InventoryItem::class, + numberedFiles: true, ); } /** - * Weapons — the `Weapon` entries of `assets/Data/items.php`. Edited by rebuilding the entry. + * Weapons: one record per numbered file under `assets/Data/Weapons`. * * @return RecordSchema */ @@ -413,69 +433,80 @@ private static function weapons(): RecordSchema return new RecordSchema( key: 'weapons', entryNoun: 'weapon', - storage: RecordStorage::LIST_FILE, - relativePath: 'assets/Data/items.php', - fields: self::equipmentFields([ + storage: RecordStorage::DIRECTORY, + relativePath: 'assets/Data/' . ItemCatalog::DIRECTORIES[1], + fields: self::equipmentFields(EquipmentSlotType::WEAPON, [ new RecordField( 'equipmentType', 'Equipment Type', options: array_map(static fn(WeaponType $type): string => $type->value, WeaponType::cases()), - enumClass: WeaponType::class, + removeWhenEmpty: true, ), ...self::parameterChangeFields(), ]), labelKey: 'name', identityKey: 'id', - recordFilter: static fn(mixed $entry): bool => $entry instanceof Weapon, - makeBlank: static fn(string $name): object => new Weapon( - $name, - 'What it does.', - '🗡', - 0, - equipmentType: WeaponType::SWORD, - parameterChanges: new ParameterChanges(attack: 1), - id: self::inventoryDefinitionId('equipment', $name), - ), + makeBlank: static fn(string $name): array => [ + 'kind' => 'weapon', + 'id' => self::inventoryDefinitionId('equipment', $name), + 'name' => $name, + 'description' => 'What it does.', + 'icon' => '🗡', + 'price' => 0, + 'equipmentType' => WeaponType::SWORD->value, + 'parameterChanges' => ['attack' => 1], + ], + recordClass: InventoryItem::class, + numberedFiles: true, ); } /** - * Armors and accessories — the `Armor`/`Accessory` entries of - * `assets/Data/items.php`. Edited by rebuilding the entry. + * Armors and accessories: one record per numbered file under + * `assets/Data/Armors`. An accessory has no equipment type. * * @return RecordSchema */ private static function armors(): RecordSchema { + $kind = new RecordField('kind', 'Kind', options: ['armor', 'accessory']); + $type = new RecordField( + 'equipmentType', + 'Equipment Type', + options: array_map(static fn(ArmorType $type): string => $type->value, ArmorType::cases()), + removeWhenEmpty: true, + ); + $fields = static fn(bool $isAccessory): array => [ + $kind, + ...self::equipmentFields($isAccessory ? EquipmentSlotType::ACCESSORY : EquipmentSlotType::BODY, [ + ...($isAccessory ? [] : [$type]), + ...self::parameterChangeFields(), + ]), + ]; + return new RecordSchema( key: 'armors', entryNoun: 'armor', - storage: RecordStorage::LIST_FILE, - relativePath: 'assets/Data/items.php', - fields: self::equipmentFields([ - new RecordField( - 'equipmentType', - 'Equipment Type', - options: array_map(static fn(ArmorType $type): string => $type->value, ArmorType::cases()), - enumClass: ArmorType::class, - ), - ...self::parameterChangeFields(), - ]), + storage: RecordStorage::DIRECTORY, + relativePath: 'assets/Data/' . ItemCatalog::DIRECTORIES[2], + fields: $fields(false), labelKey: 'name', identityKey: 'id', - recordFilter: static fn(mixed $entry): bool => $entry instanceof Armor || $entry instanceof Accessory, - makeBlank: static fn(string $name): object => new Armor( - $name, - 'What it protects against.', - '🛡', - 0, - equipmentType: ArmorType::GENERAL_ARMOR, - parameterChanges: new ParameterChanges(defence: 1), - id: self::inventoryDefinitionId('equipment', $name), - ), + makeBlank: static fn(string $name): array => [ + 'kind' => 'armor', + 'id' => self::inventoryDefinitionId('equipment', $name), + 'name' => $name, + 'description' => 'What it protects against.', + 'icon' => '🛡', + 'price' => 0, + 'equipmentType' => ArmorType::GENERAL_ARMOR->value, + 'parameterChanges' => ['defence' => 1], + ], + fieldsFor: static fn(array $row): array => $fields(strval($row['kind'] ?? 'armor') === 'accessory'), + recordClass: InventoryItem::class, + numberedFiles: true, ); } - /** * Knowledge subjects — the `subjects` list of * `assets/Data/knowledge.php`. @@ -1623,16 +1654,18 @@ private static function inventoryFields(bool $isEquipment = false): array 'userType', 'Who May Use It', options: array_map(static fn(ItemUserType $type): string => $type->value, ItemUserType::cases()), - enumClass: ItemUserType::class, + removeWhenEmpty: true, + displayDefault: ItemUserType::ALL->value, ), - RecordField::boolean('isKeyItem', 'Key Item'), - RecordField::boolean('consumable', 'Consumable'), + // What a record leaves out reads as the Engine's default for its kind. + RecordField::boolean('isKeyItem', 'Key Item', displayDefault: 'false'), + RecordField::boolean('consumable', 'Consumable', displayDefault: $isEquipment ? 'false' : 'true'), // Trade new RecordField('price', 'Price', InputControlType::INTEGER), - RecordField::boolean('sellable', 'Sellable', removeWhenEmpty: false), - new RecordField('sellRateBasisPoints', 'Sell Rate (basis points)', InputControlType::INTEGER, step: 500), + RecordField::boolean('sellable', 'Sellable', removeWhenEmpty: false, displayDefault: 'true'), + new RecordField('sellRateBasisPoints', 'Sell Rate (basis points)', InputControlType::INTEGER, step: 500, displayDefault: '5000'), // Stock - new RecordField('quantity', 'Quantity', InputControlType::INTEGER), + new RecordField('quantity', 'Quantity', InputControlType::INTEGER, displayDefault: '1'), // Project-owned vocabularies: the engine reads these as plain // strings a project gives meaning to, so they are typed rather // than chosen from a list this code would have to invent. @@ -1691,19 +1724,21 @@ private static function parameterChangeFields(): array * enough, which is why a shield has to say so. Form, size and material * are project-owned words the engine only stores. * + * @param EquipmentSlotType $defaultSlot The slot this kind of equipment is equipped to unless it says otherwise. * @param RecordField[] $typeAndStats The type row and the stat rows this kind of equipment has. * @return RecordField[] */ - private static function equipmentFields(array $typeAndStats): array + private static function equipmentFields(EquipmentSlotType $defaultSlot, array $typeAndStats): array { return [ ...self::inventoryFields(isEquipment: true), - // Slot and kind + // Slot and kind; a record leaves out the slot its kind defaults to. new RecordField( 'semanticSlot', 'Slot', options: array_map(static fn(EquipmentSlotType $slot): string => $slot->value, EquipmentSlotType::cases()), - enumClass: EquipmentSlotType::class, + removeWhenEmpty: true, + displayDefault: $defaultSlot->value, ), ...$typeAndStats, // Shape diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 177e822..dbab859 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -553,9 +553,10 @@ private function audioValues(string $category): array * * An availability, an acquisition policy and a special property are all * project words -- the runtime imposes no list of them -- so what a - * picker can offer is what the project has already said somewhere. A - * special property is a shape rather than a string, and it is the `type` - * inside it that Optimize weighs. + * picker can offer is what the project has already said somewhere, + * including what a record says by leaving the engine's default in place. + * A special property is a shape rather than a string, and it is the + * `type` inside it that Optimize weighs. * * @param string $field The equipment field. * @return string[] The distinct values, sorted. @@ -572,7 +573,8 @@ private function equipmentVocabulary(string $field): array } foreach ($database->getRecords() as $record) { - $value = $record->get($field); + $definition = InventoryCatalog::readDefinition($record); + $value = $definition !== null && property_exists($definition, $field) ? $definition->{$field} : null; if ($field === 'specialProperty') { $value = is_array($value) ? ($value['type'] ?? null) : null; diff --git a/src/EquipmentOptimizationPolicy.php b/src/EquipmentOptimizationPolicy.php index 0323b62..ea91e01 100644 --- a/src/EquipmentOptimizationPolicy.php +++ b/src/EquipmentOptimizationPolicy.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor; +use Ichiloto\Editor\Database\InventoryCatalog; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Throwable; @@ -310,10 +311,10 @@ private static function candidates(ProjectWorkspace $workspace): array } foreach ($database->getRecords() as $record) { - $payload = $record->toArray(); + $equipment = InventoryCatalog::readDefinition($record); - if ($payload instanceof \Ichiloto\Engine\Entities\Inventory\Equipment) { - $candidates[] = $payload; + if ($equipment instanceof \Ichiloto\Engine\Entities\Inventory\Equipment) { + $candidates[] = $equipment; } } } diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index 117320b..23fbcab 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -19,6 +19,7 @@ use Ichiloto\Editor\Storage\FilesystemFileSetOperations; use Ichiloto\Engine\Core\ProjectFormat; use Ichiloto\Engine\Entities\Elements\ElementRegistry; +use Ichiloto\Engine\Entities\Inventory\ItemCatalog; use Ichiloto\Engine\Entities\Skills\SkillCatalog; use Ichiloto\Engine\Field\MapGraphics; use RecursiveDirectoryIterator; @@ -119,6 +120,17 @@ public function loadSkillCatalog(): SkillCatalog return SkillCatalog::load($this->projectRoot . DIRECTORY_SEPARATOR . 'assets'); } + /** + * Reads the project's inventory catalogue as saved: every item, weapon + * and armor record, as the runtime reads them. + * + * @return ItemCatalog + */ + public function loadItemCatalog(): ItemCatalog + { + return ItemCatalog::load($this->projectRoot . DIRECTORY_SEPARATOR . 'assets'); + } + /** * Returns every skill name a reference may use: the Skills records as * currently edited, in the order menus list them. diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 097d246..178e44d 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -110,21 +110,12 @@ public function validate(ProjectWorkspace $workspace): array $this->inventoryCatalog = InventoryCatalog::fromWorkspace($workspace); } catch (Throwable $throwable) { return [Issue::error( - 'assets/Data/items.php', + 'assets/Data/Items, Weapons and Armors', sprintf('The inventory could not be read: %s', $throwable->getMessage()), 'Until this is fixed the project has no items, weapons or armors at all.' )]; } - $unreadableInventory = $this->describeUnreadableInventory($workspace); - - if ($unreadableInventory !== null) { - // Every reference to an item would now fail for the same reason, and - // a page of "does not exist" would bury it. One accurate error beats - // the cascade it causes. - return [$unreadableInventory]; - } - $issues = [ ...$this->checkScriptCommandDeclarations($workspace), ...$this->checkMaps($workspace), @@ -132,6 +123,7 @@ public function validate(ProjectWorkspace $workspace): array ...$this->checkTroops($workspace), ...$this->checkBattleEntryRules($workspace), ...$this->checkSkillCatalog($workspace), + ...$this->checkItemCatalog($workspace), ...$this->checkSummons($workspace), ...$this->checkCutscenes($workspace), ...$this->checkReferences($workspace), @@ -160,51 +152,6 @@ public function validate(ProjectWorkspace $workspace): array return $issues; } - /** - * Returns the one error to report when a project has an inventory file - * the runtime cannot read, or null when it can. - * - * The engine's own constructors enforce a definition's bounds -- a sell - * rate outside 0 through 10000, a modifier outside -100 through 100, an - * affinity that is not a factor -- by refusing to build it, which takes - * the whole file with it. The catalogue is then empty, and every - * reference to an item in the project fails for a reason that has - * nothing to do with that reference. - * - * @param ProjectWorkspace $workspace The project. - * @return Issue|null The error, or null. - */ - protected function describeUnreadableInventory(ProjectWorkspace $workspace): ?Issue - { - $path = rtrim($workspace->projectRoot, '/') . '/assets/Data/items.php'; - - // Whether the file loaded at all, which a catalogue whose every - // definition is contested still did. - if (! is_file($path) || $this->inventoryCatalog->claimants() !== []) { - return null; - } - - try { - $payload = ProjectDirectoryContext::run($workspace->projectRoot, static fn(): mixed => require $path); - } catch (Throwable $throwable) { - return Issue::error( - 'assets/Data/items.php', - sprintf('The inventory could not be read: %s', $throwable->getMessage()), - 'Until this is fixed the project has no items, weapons or armors at all.' - ); - } - - if (is_array($payload) && $payload !== []) { - return Issue::error( - 'assets/Data/items.php', - 'The inventory holds entries the project could not read as definitions.', - 'Until this is fixed the project has no items, weapons or armors at all.' - ); - } - - return null; - } - /** * Checks that every inventory definition can be resolved to exactly one * thing, and that its policy numbers are ones the runtime accepts. @@ -1399,6 +1346,30 @@ protected function checkSkillCatalog(ProjectWorkspace $workspace): array ); } + /** + * Reports the inventory records the Engine's item catalogue cannot read. + * + * The engine's own constructors enforce a definition's bounds (a sell rate + * outside 0 through 10000, a modifier outside -100 through 100, an + * affinity that is not a factor) by refusing to build it. The game then + * leaves that one record out, so it is reported against its own file in + * the engine's own words; a reference to it is still a reference to the + * definition the author meant, and is not reported again. + * + * @return Issue[] + */ + protected function checkItemCatalog(ProjectWorkspace $workspace): array + { + return array_map( + static fn(string $problem): Issue => Issue::error( + 'assets/Data/' . strstr($problem, ':', true), + ltrim((string) strstr($problem, ':'), ': '), + 'Each item, weapon and armor is one record file; correct the file named in Database > Items, Weapons or Armors.', + ), + $workspace->loadItemCatalog()->getProblems(), + ); + } + /** * Checks authored summon gates, wielder policies, linked actions, and * actor starting assignments without imposing any game-specific names. diff --git a/tests/Fixtures/sample-project/assets/Data/Items/0001-s-potion.php b/tests/Fixtures/sample-project/assets/Data/Items/0001-s-potion.php new file mode 100644 index 0000000..6eca15e --- /dev/null +++ b/tests/Fixtures/sample-project/assets/Data/Items/0001-s-potion.php @@ -0,0 +1,15 @@ + InventoryItem::class, + 'data' => [ + 'kind' => 'item', + 'id' => 'legacy.s-potion', + 'name' => 'S-Potion', + 'description' => 'A potion that restores 50 HP.', + 'icon' => '🧪', + 'price' => 50, + ], +]; diff --git a/tests/Fixtures/sample-project/assets/Data/Items/0002-antidote.php b/tests/Fixtures/sample-project/assets/Data/Items/0002-antidote.php new file mode 100644 index 0000000..20d52f0 --- /dev/null +++ b/tests/Fixtures/sample-project/assets/Data/Items/0002-antidote.php @@ -0,0 +1,15 @@ + InventoryItem::class, + 'data' => [ + 'kind' => 'item', + 'id' => 'legacy.antidote', + 'name' => 'Antidote', + 'description' => 'Cures Poison.', + 'icon' => '🧪', + 'price' => 80, + ], +]; diff --git a/tests/Fixtures/sample-project/assets/Data/Weapons/0001-wooden-sword.php b/tests/Fixtures/sample-project/assets/Data/Weapons/0001-wooden-sword.php new file mode 100644 index 0000000..6b067d0 --- /dev/null +++ b/tests/Fixtures/sample-project/assets/Data/Weapons/0001-wooden-sword.php @@ -0,0 +1,19 @@ + InventoryItem::class, + 'data' => [ + 'kind' => 'weapon', + 'id' => 'legacy.wooden-sword', + 'name' => 'Wooden Sword', + 'description' => 'A wooden sword.', + 'icon' => '🗡️', + 'price' => 100, + 'equipmentType' => 'Sword', + 'parameterChanges' => [ + 'attack' => 1, + ], + ], +]; diff --git a/tests/Fixtures/sample-project/assets/Data/items.php b/tests/Fixtures/sample-project/assets/Data/items.php index 076a478..954efd0 100644 --- a/tests/Fixtures/sample-project/assets/Data/items.php +++ b/tests/Fixtures/sample-project/assets/Data/items.php @@ -1,12 +1,7 @@ Each definition's record file, relative to the project root, in the given order. + */ +function writeItemRecords(string $root, \Ichiloto\Engine\Entities\Inventory\InventoryItem ...$items): array +{ + $data = $root . '/assets/Data'; + + foreach (\Ichiloto\Engine\Entities\Inventory\ItemCatalog::DIRECTORIES as $folder) { + is_dir("$data/$folder") || mkdir("$data/$folder", 0777, true); + array_map(unlink(...), glob("$data/$folder/*.php") ?: []); + } + + $numbers = []; + $files = []; + + foreach ($items as $item) { + $folder = match (true) { + $item instanceof \Ichiloto\Engine\Entities\Inventory\Items\Item => 'Items', + $item instanceof \Ichiloto\Engine\Entities\Inventory\Weapons\Weapon => 'Weapons', + default => 'Armors', + }; + $numbers[$folder] = ($numbers[$folder] ?? 0) + 1; + $slug = trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($item->name)) ?? '', '-'); + $file = sprintf('assets/Data/%s/%04d-%s.php', $folder, $numbers[$folder], $slug); + $record = var_export(\Ichiloto\Engine\Entities\Inventory\ItemRecord::writeItem($item), true); + file_put_contents("$root/$file", " \\Ichiloto\\Engine\\Entities\\Inventory\\InventoryItem::class, 'data' => {$record}];\n"); + $files[] = $file; + } + + file_put_contents("$data/items.php", " + */ +function itemsFromSource(string $source): array +{ + $path = (string) tempnam(sys_get_temp_dir(), 'ichiloto-items-'); + + try { + file_put_contents($path, $source); + + return array_values((static fn(): mixed => require $path)()); + } finally { + unlink($path); + } +} diff --git a/tests/Unit/DefinitionDiagnosticsTest.php b/tests/Unit/DefinitionDiagnosticsTest.php index e853810..8c8b784 100644 --- a/tests/Unit/DefinitionDiagnosticsTest.php +++ b/tests/Unit/DefinitionDiagnosticsTest.php @@ -85,37 +85,22 @@ function writeActorFile(string $root, string $name, array $data): void ->toContain('variant awakened adjusts "luck"'); }); -it('leaves the runtime to enforce its own bounds, and says so when a project cannot open', function () { +it('leaves the runtime to enforce its own bounds, and reports the record it refuses', function () { $root = makeTemporaryProject('ichiloto-diag-'); + $before = diagnosticsFor($root, Severity::ERROR); // The engine's own constructor refuses a sell rate outside 0 through - // 10000. It does that by throwing, which takes the whole file with it, - // so the project cannot be opened at all rather than opening with a - // quietly clamped value. The editor does not re-check the bound; it - // surfaces the engine's own words. - file_put_contents($root . '/assets/Data/items.php', <<<'PHP' - toHaveCount(1) - ->and($errors[0])->toContain('The inventory could not be read') - ->and($errors[0])->toContain('sell rate must be between 0 and 10000'); + // 10000 rather than clamping it, and the game leaves that record out. + // The editor does not re-check the bound; it surfaces the engine's own + // words against the record's own file. + file_put_contents($root . '/assets/Data/Items/0099-odd-potion.php', " \Ichiloto\Engine\Entities\Inventory\InventoryItem::class, + 'data' => ['kind' => 'item', 'id' => 'item.odd', 'name' => 'Odd Potion', 'description' => 'Priced strangely.', 'icon' => 'i', 'price' => 10, 'sellRateBasisPoints' => 25000], + ], true) . ";\n"); + + // Reported once, and the rest of the inventory still reads. + $errors = array_values(array_diff(diagnosticsFor($root, Severity::ERROR), $before)); + + expect($errors)->toBe(['assets/Data/Items/0099-odd-potion.php: Item sell rate must be between 0 and 10000 basis points.']); }); it('reports a knowledge catalogue that points at what it does not declare', function () { @@ -178,7 +163,7 @@ function writeActorFile(string $root, string $name, array $data): void it('reports a name two definitions both claim', function () { $root = makeTemporaryProject('ichiloto-diag-'); - file_put_contents($root . '/assets/Data/items.php', <<<'PHP' + writeItemRecords($root, ...itemsFromSource(<<<'PHP' toContain('"elixir" is claimed by'); diff --git a/tests/Unit/ElementalAuthoringTest.php b/tests/Unit/ElementalAuthoringTest.php index 0bce765..2fbec37 100644 --- a/tests/Unit/ElementalAuthoringTest.php +++ b/tests/Unit/ElementalAuthoringTest.php @@ -4,6 +4,7 @@ use Ichiloto\Editor\Database\AffinityEditor; use Ichiloto\Editor\Database\ElementAffinityCodec; +use Ichiloto\Editor\Database\InventoryCatalog; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\RecordSchemaCatalog; use Ichiloto\Editor\Database\ReferenceCatalog; @@ -82,7 +83,7 @@ function writeProjectElements(string $root): void expect($editor->encoded())->toBe('Fire: -1'); }); -it('authors an armor ward end to end, into the engine object', function () { +it('authors an armor ward end to end, into the definition the game builds', function () { $root = makeTemporaryProject(); writeProjectElements($root); @@ -94,11 +95,11 @@ function writeProjectElements(string $root): void $reloaded = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('armors')); $labels = $reloaded->getEntryLabels(); - $armor = $reloaded->getRecords()[array_search('Flame Ward', $labels, true)]->toArray(); + $armor = InventoryCatalog::readDefinition($reloaded->getRecords()[array_search('Flame Ward', $labels, true)]); expect($armor)->toBeInstanceOf(Armor::class) ->and($armor->elementAffinities)->toBe(['Fire' => 0.5, 'Ice' => 2.0]) - // The engine's own lookup, on the object the editor wrote. + // The engine's own lookup, on the definition the editor wrote. ->and($armor->getElementMultiplier('Fire'))->toBe(0.5); }); @@ -110,13 +111,14 @@ function writeProjectElements(string $root): void $index = $database->addRecord(); $database->setField($index, 'element', 'Fire'); - expect($database->getRecords()[$index]->toArray()->element)->toBe('Fire'); + expect(InventoryCatalog::readDefinition($database->getRecords()[$index])?->element)->toBe('Fire'); // '(None)' is what the picker's clearing entry commits: an optional // reference must have a way back to nothing. $database->setField($index, 'element', '(None)'); - expect($database->getRecords()[$index]->toArray()->element)->toBeNull(); + expect($database->getRecords()[$index]->get('element'))->toBeNull() + ->and(InventoryCatalog::readDefinition($database->getRecords()[$index]))->not->toBeNull(); }); it('declares the element fields as picked, cycled, or row-built', function () { diff --git a/tests/Unit/EquipmentOptimizationTest.php b/tests/Unit/EquipmentOptimizationTest.php index 38fafc4..047f705 100644 --- a/tests/Unit/EquipmentOptimizationTest.php +++ b/tests/Unit/EquipmentOptimizationTest.php @@ -31,7 +31,7 @@ function writeOptimizePolicy(string $root, string $body): string */ function writeOptimizeInventory(string $root): void { - file_put_contents($root . '/assets/Data/items.php', <<<'PHP' + writeItemRecords($root, ...itemsFromSource(<<<'PHP' 'lifesteal', 'amount' => 10], ), ]; - PHP); + PHP)); } /** diff --git a/tests/Unit/EquipmentTypeIconTest.php b/tests/Unit/EquipmentTypeIconTest.php index 1e1c2fd..aa8cd71 100644 --- a/tests/Unit/EquipmentTypeIconTest.php +++ b/tests/Unit/EquipmentTypeIconTest.php @@ -27,7 +27,7 @@ function iconRow(ProjectRecordDatabase $database, int $index): ?array it('shows a weapon\'s own icon as read-only legacy data and keeps it through an edit', function () { $root = makeTemporaryProject(); $database = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('weapons')); - $before = $database->getRecords()[0]->toArray()->icon; + $before = $database->getRecords()[0]->get('icon'); $row = iconRow($database, 0); expect($row)->not->toBeNull() @@ -36,10 +36,11 @@ function iconRow(ProjectRecordDatabase $database, int $index): ?array $database->setField(0, 'description', 'Edited beside a legacy icon.'); $database->save(); - $saved = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('weapons'))->getRecords()[0]->toArray(); + $saved = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('weapons'))->getRecords()[0]; - expect($saved->description)->toBe('Edited beside a legacy icon.') - ->and($saved->icon)->toBe($before); + expect($before)->not->toBeNull() + ->and($saved->get('description'))->toBe('Edited beside a legacy icon.') + ->and($saved->get('icon'))->toBe($before); }); it('keeps a consumable\'s own icon editable', function () { diff --git a/tests/Unit/IdentityFailClosedTest.php b/tests/Unit/IdentityFailClosedTest.php index 99d9251..de55732 100644 --- a/tests/Unit/IdentityFailClosedTest.php +++ b/tests/Unit/IdentityFailClosedTest.php @@ -6,6 +6,11 @@ use Ichiloto\Editor\ProjectActorDatabase; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Validation\Severity; +use Ichiloto\Engine\Entities\Enumerations\WeaponType; +use Ichiloto\Engine\Entities\Inventory\Armor; +use Ichiloto\Engine\Entities\Inventory\EquipmentSlotType; +use Ichiloto\Engine\Entities\Inventory\Items\Item; +use Ichiloto\Engine\Entities\Inventory\Weapons\Weapon; /** * Identity, when a project has got it wrong. @@ -30,20 +35,13 @@ function contestedInventoryProject(): string { $root = makeTemporaryProject('ichiloto-identity-'); - file_put_contents($root . '/assets/Data/items.php', <<<'PHP' - new Item(id: 'gear.shared', name: 'Shared Item', description: 'x', icon: 'i', price: 1, aliases: ['Old Item Alias']), + 'weapons' => new Weapon(id: 'gear.shared', name: 'Shared Weapon', description: 'x', icon: '/', price: 1, equipmentType: WeaponType::SWORD, aliases: ['Old Weapon Alias']), + 'armors' => new Armor(id: 'gear.shared', name: 'Shared Armor', semanticSlot: EquipmentSlotType::BODY, description: 'x', icon: '[', price: 1, aliases: ['Old Armor Alias']), ]; - $sources = [ - 'items' => "new Item(id: 'gear.shared', name: 'Shared Item', description: 'x', icon: 'i', price: 1, aliases: ['Old Item Alias']),", - 'weapons' => "new Weapon(id: 'gear.shared', name: 'Shared Weapon', description: 'x', icon: '/', price: 1, equipmentType: WeaponType::SWORD, aliases: ['Old Weapon Alias']),", - 'armors' => "new Armor(id: 'gear.shared', name: 'Shared Armor', semanticSlot: EquipmentSlotType::BODY, description: 'x', icon: '[', price: 1, aliases: ['Old Armor Alias']),", - ]; - - foreach ($categories as $category) { - $entries[] = $sources[$category]; - } - - file_put_contents($root . '/assets/Data/items.php', sprintf(<<<'PHP' - $definitions[$category], $categories), + ); return $root; } +/** Where a claimant of the multi-claimant project is authored. */ +function multiClaimantSource(string $category): string +{ + return match ($category) { + 'items' => 'assets/Data/Items/0002-shared-item.php', + 'weapons' => 'assets/Data/Weapons/0001-shared-weapon.php', + default => 'assets/Data/Armors/0001-shared-armor.php', + }; +} + it('retains every claimant of a contested id with its category, aliases and source, and resolves none of them', function (array $categories) { $root = multiClaimantProject($categories); $workspace = ProjectWorkspace::fromProject($root); @@ -237,7 +225,7 @@ function multiClaimantProject(array $categories): string expect($claimant->name)->toBe('Shared ' . $noun) ->and($claimant->aliases)->toBe(['Old ' . $noun . ' Alias']) - ->and($claimant->source)->toBe(sprintf('assets/Data/items.php entry %d', $position + 2)); + ->and($claimant->source)->toBe(multiClaimantSource($claimant->category)); } // Nothing of any claimant resolves or is offered. @@ -300,7 +288,7 @@ function multiClaimantProject(array $categories): string foreach ($categories as $position => $category) { $noun = rtrim($category, 's'); - expect($message)->toContain(sprintf('Shared %s (%s; aliases: Old %s Alias; assets/Data/items.php entry %d)', ucfirst($noun), $noun, ucfirst($noun), $position + 2)); + expect($message)->toContain(sprintf('Shared %s (%s; aliases: Old %s Alias; %s)', ucfirst($noun), $noun, ucfirst($noun), multiClaimantSource($category))); } expect(implode("\n", $errors)) diff --git a/tests/Unit/InventoryIdentityTest.php b/tests/Unit/InventoryIdentityTest.php index 90dcd0d..1cb1e8c 100644 --- a/tests/Unit/InventoryIdentityTest.php +++ b/tests/Unit/InventoryIdentityTest.php @@ -8,38 +8,6 @@ use Ichiloto\Editor\Validation\SaveCompatibilityValidator; use Ichiloto\Editor\Validation\Severity; -/** - * Writes an items.php whose entries are the given PHP constructor snippets. - * - * @param string $root The project root. - * @param string[] $entries The constructor expressions. - * @return void - */ -function writeInventorySource(string $root, array $entries): void -{ - file_put_contents( - $root . '/assets/Data/items.php', - " ' ' . $e, $entries)) . ",\n];\n", - ); -} - -/** - * An Item constructor expression with named arguments. - * - * @param array $arguments The named arguments, already as PHP. - * @return string The expression. - */ -function itemExpression(array $arguments): string -{ - $parts = []; - - foreach ($arguments as $name => $value) { - $parts[] = sprintf('%s: %s', $name, $value); - } - - return 'new \Ichiloto\Engine\Entities\Inventory\Items\Item(' . implode(', ', $parts) . ')'; -} - /** * Returns the real game project root, with its own class autoloader * registered, or null when this checkout has no game beside it. @@ -130,16 +98,16 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('resolves a stable id, a display name, and a declared alias to one definition', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression([ - 'id' => "'item.s-potion'", - 'name' => "'S-Potion'", - 'description' => "'Restores 50 HP.'", - 'icon' => "'🧪'", - 'price' => '50', - 'aliases' => "['Old Potion Name']", - ]), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item( + id: 'item.s-potion', + name: 'S-Potion', + description: 'Restores 50 HP.', + icon: '🧪', + price: 50, + aliases: ['Old Potion Name'], + ), + ); $catalog = inventoryCatalog($root); @@ -186,10 +154,10 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('fails closed on an ambiguous reference instead of guessing a definition', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression(['id' => "'item.one'", 'name' => "'First'", 'description' => "''", 'icon' => "'a'", 'price' => '1', 'aliases' => "['Shared Name']"]), - itemExpression(['id' => "'item.two'", 'name' => "'Second'", 'description' => "''", 'icon' => "'b'", 'price' => '2', 'aliases' => "['shared name']"]), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.one', name: 'First', description: '', icon: 'a', price: 1, aliases: ['Shared Name']), + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.two', name: 'Second', description: '', icon: 'b', price: 2, aliases: ['shared name']), + ); $catalog = inventoryCatalog($root); @@ -209,9 +177,9 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('derives the engine legacy id for a definition authored without one', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression(['name' => "'Old Charm'", 'description' => "''", 'icon' => "'c'", 'price' => '3']), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item(name: 'Old Charm', description: '', icon: 'c', price: 3), + ); $catalog = inventoryCatalog($root); @@ -228,9 +196,9 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('accepts an alias target that is a stable definition id', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression(['id' => "'item.s-potion'", 'name' => "'S-Potion'", 'description' => "''", 'icon' => "'🧪'", 'price' => '50']), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.s-potion', name: 'S-Potion', description: '', icon: '🧪', price: 50), + ); writeCompatibilityManifest($root, [ 'contentVersion' => 0, 'migrations' => [], @@ -245,9 +213,9 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('still fails an alias target that names nothing in the catalogue', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression(['id' => "'item.s-potion'", 'name' => "'S-Potion'", 'description' => "''", 'icon' => "'🧪'", 'price' => '50']), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.s-potion', name: 'S-Potion', description: '', icon: '🧪', price: 50), + ); writeCompatibilityManifest($root, [ 'contentVersion' => 0, 'migrations' => [], @@ -304,9 +272,9 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('offers inventory references as stable ids labelled with their names', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression(['id' => "'item.s-potion'", 'name' => "'S-Potion'", 'description' => "''", 'icon' => "'🧪'", 'price' => '50']), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.s-potion', name: 'S-Potion', description: '', icon: '🧪', price: 50), + ); $catalog = new \Ichiloto\Editor\Database\ReferenceCatalog(ProjectWorkspace::fromProject($root)); @@ -352,11 +320,11 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('accepts an id, a name or an alias wherever content names an item, and refuses the rest', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression(['id' => "'item.s-potion'", 'name' => "'S-Potion'", 'description' => "''", 'icon' => "'🧪'", 'price' => '50', 'aliases' => "['Potion']"]), - itemExpression(['id' => "'item.twin'", 'name' => "'First Twin'", 'description' => "''", 'icon' => "'a'", 'price' => '1', 'aliases' => "['Shared']"]), - itemExpression(['id' => "'item.twin-two'", 'name' => "'Second Twin'", 'description' => "''", 'icon' => "'b'", 'price' => '1', 'aliases' => "['shared']"]), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.s-potion', name: 'S-Potion', description: '', icon: '🧪', price: 50, aliases: ['Potion']), + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.twin', name: 'First Twin', description: '', icon: 'a', price: 1, aliases: ['Shared']), + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.twin-two', name: 'Second Twin', description: '', icon: 'b', price: 1, aliases: ['shared']), + ); $mapPath = $root . '/assets/Maps/test-map/test-map.data.php'; $map = require $mapPath; @@ -392,9 +360,9 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('validates what a chest gives out', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression(['id' => "'item.s-potion'", 'name' => "'S-Potion'", 'description' => "''", 'icon' => "'🧪'", 'price' => '50']), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.s-potion', name: 'S-Potion', description: '', icon: '🧪', price: 50), + ); $mapPath = $root . '/assets/Maps/test-map/test-map.data.php'; $map = require $mapPath; $map['events']['E']['data'] = ['lootType' => 'item', 'loot' => 'item.absent']; diff --git a/tests/Unit/InventorySourcePreservationTest.php b/tests/Unit/InventorySourcePreservationTest.php index fc21c07..68e0a64 100644 --- a/tests/Unit/InventorySourcePreservationTest.php +++ b/tests/Unit/InventorySourcePreservationTest.php @@ -7,25 +7,52 @@ use Ichiloto\Editor\Database\RecordSchemaCatalog; /** - * Copies the real project's authored items.php into a throwaway project. - * - * The point of these tests is the actual authored source -- named arguments, - * imported or fully qualified classes, nested constructors, emoji icons -- not a - * simplified stand-in, so the file is copied rather than written. - * - * @return array{0: string, 1: string}|null The project root and items path. + * Editing an inventory record changes the value asked for in that record's + * own file and nothing else: not the record's other lines, not its comments + * or imports, and not any other file in the project. */ -function projectWithAuthoredInventory(): ?array -{ - $game = gameSourceRoot(); - if ($game === null) { - return null; - } +/** + * The authored weapon record these tests edit: an import, comments, an emoji + * icon and a nested map, written the way an author writes one. + */ +const AUTHORED_WEAPON_RECORD = <<<'PHP' + InventoryItem::class, + 'data' => [ + 'kind' => 'weapon', + 'id' => 'equipment.ember-blade', + 'name' => 'Ember Blade', + 'description' => 'Warm to the touch.', // Not hot. + 'icon' => '🗡️', + 'price' => 300, + 'equipmentType' => 'Sword', + /* Balanced for the first town. */ + 'parameterChanges' => [ + 'attack' => 6, + 'speed' => 1, + ], + 'element' => 'Fire', + ], +]; +PHP; +/** + * Returns a project whose weapons are the fixture's sword and the authored + * Ember Blade, and the blade's record file. + * + * @return array{0: string, 1: string} The project root and the record file. + */ +function projectWithAuthoredWeapon(): array +{ $root = makeTemporaryProject('ichiloto-source-'); - $path = $root . '/assets/Data/items.php'; - copy($game . '/assets/Data/items.php', $path); + $path = $root . '/assets/Data/Weapons/0002-ember-blade.php'; + file_put_contents($path, AUTHORED_WEAPON_RECORD); return [$root, $path]; } @@ -38,62 +65,51 @@ function inventoryDatabase(string $root, string $category): ProjectRecordDatabas return ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category)); } -it('changes one authored value and leaves every other byte alone', function () { - $project = projectWithAuthoredInventory(); +/** + * Returns the lines of an edited file that differ from the original. + * + * @return string[] The changed lines. + */ +function changedLines(string $before, string $after): array +{ + $beforeLines = explode("\n", $before); - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } + return array_values(array_filter( + explode("\n", $after), + static fn(string $line, int $index): bool => ($beforeLines[$index] ?? null) !== $line, + ARRAY_FILTER_USE_BOTH, + )); +} - [$root, $path] = $project; - $before = (string) file_get_contents($path); +it('changes one authored value and leaves every other byte alone', function () { + [$root, $path] = projectWithAuthoredWeapon(); + $database = inventoryDatabase($root, 'weapons'); + $index = array_search('Ember Blade', $database->getEntryLabels(), true); - $database = inventoryDatabase($root, 'items'); - $database->setField(0, 'price', '55'); + $database->setField($index, 'price', '450'); $database->save(); $after = (string) file_get_contents($path); - $beforeLines = explode("\n", $before); - $afterLines = explode("\n", $after); - $changed = []; - foreach ($afterLines as $index => $line) { - if (($beforeLines[$index] ?? null) !== $line) { - $changed[] = $line; - } - } + expect(substr_count($after, "\n"))->toBe(substr_count(AUTHORED_WEAPON_RECORD, "\n")) + ->and(changedLines(AUTHORED_WEAPON_RECORD, $after))->toBe([" 'price' => 450,"]) + ->and(inventoryDatabase($root, 'weapons')->getRecordByIndex($index)?->get('price'))->toBe(450); +}); - preg_match('/\bnew[ \t]+[^\s(]+[ \t]*\(/', $before, $authoredItemConstructor); - preg_match('/equipmentType:[^\r\n]+/', $before, $authoredEquipmentType); - expect($authoredItemConstructor)->not->toBe([]) - ->and($authoredEquipmentType)->not->toBe([]); - - // Exactly one line differs, and it is the one asked for. - expect($afterLines)->toHaveCount(count($beforeLines)) - ->and($changed)->toHaveCount(1) - ->and(trim($changed[0]))->toBe('price: 55,') - // The authored spelling of everything else survives. - ->and($after)->toContain($authoredItemConstructor[0]) - ->and($after)->toContain("id: 'item.s-potion',") - ->and($after)->toContain('use Ichiloto\Engine\Entities\Effects\HPRecoveryEffect;') - ->and($after)->toContain($authoredEquipmentType[0]) - // The file is still PHP, and still the same list of definitions. - ->and(PhpSourceDocument::parse($after)->entryCount())->toBe(PhpSourceDocument::parse($before)->entryCount()); - - $reloaded = inventoryDatabase($root, 'items'); - expect($reloaded->getRecordByIndex(0)?->get('price'))->toBe(55); -})->group('engine'); +it('edits a nested value in place, keeping the comments around it', function () { + [$root, $path] = projectWithAuthoredWeapon(); + $database = inventoryDatabase($root, 'weapons'); + $index = array_search('Ember Blade', $database->getEntryLabels(), true); -it('writes nothing at all when nothing changed', function () { - $project = projectWithAuthoredInventory(); + $database->setField($index, 'parameterChanges.attack', '9'); + $database->save(); - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } + expect((string) file_get_contents($path))->toBe(str_replace("'attack' => 6,", "'attack' => 9,", AUTHORED_WEAPON_RECORD)); +}); - [$root, $path] = $project; - $before = (string) file_get_contents($path); - $modifiedAt = filemtime($path); +it('writes nothing at all when nothing changed', function () { + [$root] = projectWithAuthoredWeapon(); + $before = sourceHashTree($root); foreach (['items', 'weapons', 'armors'] as $category) { $database = inventoryDatabase($root, $category); @@ -108,83 +124,21 @@ function inventoryDatabase(string $root, string $category): ProjectRecordDatabas $database->save(); } - expect((string) file_get_contents($path))->toBe($before) - ->and(filemtime($path))->toBe($modifiedAt); -})->group('engine'); - -it('edits a weapon inside its nested constructor without touching the others', function () { - $project = projectWithAuthoredInventory(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = (string) file_get_contents($path); - - $database = inventoryDatabase($root, 'weapons'); - $database->setField(0, 'parameterChanges.attack', '9'); - $database->save(); - - $after = (string) file_get_contents($path); - $changed = array_values(array_filter( - array_map( - static fn(string $line, int $index): ?string => ($line !== (explode("\n", $before)[$index] ?? null)) ? $line : null, - explode("\n", $after), - array_keys(explode("\n", $after)), - ), - static fn(?string $line): bool => $line !== null, - )); - - preg_match('/parameterChanges:[ \t]*new[ \t]+[^\s(]+[ \t]*\(/', $before, $authoredParameterConstructor); - expect($authoredParameterConstructor)->not->toBe([]); - - expect($changed)->toHaveCount(1) - ->and(trim($changed[0]))->toBe('attack: 9,') - // The nested constructor itself is untouched, not rebuilt. - ->and($after)->toContain($authoredParameterConstructor[0]); - - $reloaded = inventoryDatabase($root, 'weapons'); - expect($reloaded->getRecordByIndex(0)?->get('parameterChanges.attack'))->toBe(9); -})->group('engine'); - -it('leaves every unrelated file in the project byte-identical', function () { - $project = projectWithAuthoredInventory(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } + expect(sourceHashTree($root))->toBe($before); +}); - [$root, $path] = $project; +it('leaves every other file in the project byte-identical', function () { + [$root] = projectWithAuthoredWeapon(); $before = sourceHashTree($root); $database = inventoryDatabase($root, 'items'); - $database->setField(1, 'description', 'A potion that restores rather more HP.'); + $database->setField(1, 'description', 'Cures poison, and tastes of it.'); $database->save(); - $after = sourceHashTree($root); - - expect(array_keys(array_diff_assoc($after, $before)))->toBe(['assets/Data/items.php']); -})->group('engine'); - -it('keeps an authored file whose entries are not named-argument calls working as before', function () { - // The fixture project authors its items positionally, which cannot be - // patched by argument name. That file still saves through the ordinary - // writer rather than failing, and the record still changes. - $root = makeTemporaryProject('ichiloto-source-'); - $path = $root . '/assets/Data/items.php'; - - expect((string) file_get_contents($path))->toContain("new Item('S-Potion'"); - - $database = inventoryDatabase($root, 'items'); - $database->setField(0, 'price', '77'); - $database->save(); - - $reloaded = inventoryDatabase($root, 'items'); - - expect($reloaded->getRecordByIndex(0)?->get('price'))->toBe(77); + expect(array_keys(array_diff_assoc(sourceHashTree($root), $before)))->toBe(['assets/Data/Items/0002-antidote.php']); }); + // -- The document itself --------------------------------------------------- it('reads, replaces, adds and removes one argument at a time', function () { @@ -288,31 +242,6 @@ function inventoryDatabase(string $root, string $category): ProjectRecordDatabas ->and($emptied->argumentSource(0, 'name'))->toBe("'Two'"); }); -it('edits a constructor-call file with comments inside its data in place, keeping every comment', function () { - $root = makeTemporaryProject('ichiloto-source-'); - $path = $root . '/assets/Data/items.php'; - $source = <<<'PHP' -isEditable())->toBeTrue()->and($items->getReadOnlyReason())->toBeNull(); - - $items->setField(1, 'price', '95'); - $items->save(); - - expect((string) file_get_contents($path))->toBe(str_replace('price: 80', 'price: 95', $source)); -}); - it('refuses to regenerate a commented file it cannot edit in place, rather than drop the comments', function () { $root = makeTemporaryProject('ichiloto-source-'); $path = $root . '/assets/Data/states.php'; diff --git a/tests/Unit/ObjectRecordCreationTest.php b/tests/Unit/ObjectRecordCreationTest.php index ae6937e..bc448b6 100644 --- a/tests/Unit/ObjectRecordCreationTest.php +++ b/tests/Unit/ObjectRecordCreationTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Ichiloto\Editor\Database\InventoryCatalog; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\RecordSchemaCatalog; use Ichiloto\Engine\Entities\Enumerations\ArmorType; @@ -43,7 +44,7 @@ function objectCategoryOn(string $root, string $category): ProjectRecordDatabase expect($labels)->toContain('Iron Cuirass'); - $armor = $reloaded->getRecords()[array_search('Iron Cuirass', $labels, true)]->toArray(); + $armor = InventoryCatalog::readDefinition($reloaded->getRecords()[array_search('Iron Cuirass', $labels, true)]); expect($armor)->toBeInstanceOf(Armor::class) ->and($armor->equipmentType)->toBe(ArmorType::HEAVY_ARMOR) @@ -66,15 +67,19 @@ function objectCategoryOn(string $root, string $category): ProjectRecordDatabase expect($reloadedWeapons->getEntryLabels())->toContain('Bronze Axe') ->and($reloadedItems->getEntryLabels())->toContain('Bitterroot Tonic') - // Sharing items.php cuts both ways: creating in one category must - // not disturb the other's entries. + // Creating in one inventory category must not disturb another's + // entries. ->and($reloadedItems->getEntryLabels())->toContain('S-Potion'); - $weapon = $reloadedWeapons->getRecords()[ + $weapon = InventoryCatalog::readDefinition($reloadedWeapons->getRecords()[ array_search('Bronze Axe', $reloadedWeapons->getEntryLabels(), true) - ]->toArray(); + ]); + $item = InventoryCatalog::readDefinition($reloadedItems->getRecords()[ + array_search('Bitterroot Tonic', $reloadedItems->getEntryLabels(), true) + ]); - expect($weapon)->toBeInstanceOf(Weapon::class); + expect($weapon)->toBeInstanceOf(Weapon::class) + ->and($item)->toBeInstanceOf(Item::class); }); it('numbers a second new armor instead of colliding, in name and in identity', function () { @@ -109,14 +114,15 @@ function objectCategoryOn(string $root, string $category): ProjectRecordDatabase ->and($byLabel['Equipment Type']['options'] ?? null)->toContain('Small Shield'); }); -it('sets the equipment type as the enum case, whatever the case typed', function () { +it('sets the equipment type as the engine names it, whatever the case typed', function () { $root = makeTemporaryProject(); $database = objectCategoryOn($root, 'armors'); $index = $database->addRecord(); $database->setField($index, 'equipmentType', 'heavy armor'); - expect($database->getRecords()[$index]->toArray()->equipmentType)->toBe(ArmorType::HEAVY_ARMOR); + expect($database->getRecords()[$index]->get('equipmentType'))->toBe('Heavy Armor') + ->and(InventoryCatalog::readDefinition($database->getRecords()[$index])?->equipmentType)->toBe(ArmorType::HEAVY_ARMOR); }); it('refuses to create rather than create what the save would drop', function () { diff --git a/tests/Unit/ProjectOwnedDataTest.php b/tests/Unit/ProjectOwnedDataTest.php index 5ed8280..7c8e064 100644 --- a/tests/Unit/ProjectOwnedDataTest.php +++ b/tests/Unit/ProjectOwnedDataTest.php @@ -22,14 +22,12 @@ /** * Returns a project whose weapon carries a special property. * - * @return array{0: string, 1: string} The project root and items path. + * @return array{0: string, 1: string} The project root and the weapon's record file. */ function projectOwnedProject(): array { $root = makeTemporaryProject('ichiloto-owned-'); - $path = $root . '/assets/Data/items.php'; - - file_put_contents($path, <<<'PHP' + $path = $root . '/' . writeItemRecords($root, ...itemsFromSource(<<<'PHP' 10, 'tiers' => ['minor', 'major']], ['percent' => 20, 'capPerHit' => 40], - ))->toBe(['percent' => 20, 'capPerHit' => 40, 'tiers' => ['minor', 'major']]); + ))->toBe(['percent' => 20, 'tiers' => ['minor', 'major'], 'capPerHit' => 40]); + + // A hidden value stays where it was authored, so an unchanged line + // leaves the map exactly as stored. + $stored = ['label' => 'x', 'tiers' => ['a'], 'percent' => 10, 'nested' => ['b' => 1]]; + + expect(ParameterMapCodec::merge($stored, ['label' => 'x', 'percent' => 10]))->toBe($stored) + ->and(ParameterMapCodec::merge(['first' => [1], 'percent' => 10], ['percent' => 5]))->toBe(['first' => [1], 'percent' => 5]); }); it('authors special-property parameters without losing the nested ones', function () { @@ -153,7 +158,7 @@ function projectOwnedProject(): array $weapons->setField(0, 'specialProperty.parameters', 'percent=25, capPerHit=40, appliesTo=magical'); $weapons->save(); - $property = (static fn(): mixed => require $path)()[0]->specialProperty; + $property = (static fn(): mixed => require $path)()['data']['specialProperty']; expect($property['type'])->toBe('lifesteal') ->and($property['parameters']['percent'])->toBe(25) @@ -164,7 +169,7 @@ function projectOwnedProject(): array it('reports a special property the runtime carries but nothing can read', function () { $root = makeTemporaryProject('ichiloto-owned-'); - file_put_contents($root . '/assets/Data/items.php', <<<'PHP' + writeItemRecords($root, ...itemsFromSource(<<<'PHP' 'lifesteal', 'parameters' => 'quite a lot'], ), ]; - PHP); + PHP)); $issues = new \Ichiloto\Editor\Validation\ProjectValidator() ->validate(ProjectWorkspace::fromProject($root)); @@ -495,7 +500,7 @@ function sameParameters(array $expected, array $actual): bool $weapons->setField(0, 'specialProperty.parameters', 'percent=10, capPerHit=40, appliesTo=physical, weight=1.0, big=1.0E+20, tag="1e5", " odd key "=-0.0'); $weapons->save(); - $parameters = (static fn(): mixed => require $path)()[0]->specialProperty['parameters']; + $parameters = (static fn(): mixed => require $path)()['data']['specialProperty']['parameters']; expect($parameters['weight'])->toBe(1.0) ->and($parameters['big'])->toBe(1.0E+20) diff --git a/tests/Unit/RecordDatabaseTest.php b/tests/Unit/RecordDatabaseTest.php index 20d5fec..d2cafc6 100644 --- a/tests/Unit/RecordDatabaseTest.php +++ b/tests/Unit/RecordDatabaseTest.php @@ -286,15 +286,12 @@ removeDirectoryRecursively($root); }); -it('edits object-backed inventory categories by rebuilding the entry', function (): void { +it('edits inventory categories one record file each', function (): void { $root = makeTemporaryProject(); $items = loadRecordDatabase($root, 'items'); $weapons = loadRecordDatabase($root, 'weapons'); - // Entries authored as `new Item(...)` are rebuilt from the arguments they - // were built with, so they can be edited without rewriting the file into - // something else. expect($items->isEditable())->toBeTrue(); expect($items->getEntryLabels())->toBe(['S-Potion', 'Antidote']); expect($weapons->getEntryLabels())->toBe(['Wooden Sword']); @@ -305,17 +302,19 @@ removeDirectoryRecursively($root); }); -it('writes an edited object entry back as the constructor call it was', function (): void { +it('writes an edited inventory record back into its own file', function (): void { $root = makeTemporaryProject(); + $antidote = (string) file_get_contents($root . '/assets/Data/Items/0002-antidote.php'); $database = loadRecordDatabase($root, 'items'); $database->setField(0, 'price', '75'); $database->save(); - $written = (string) file_get_contents($root . '/assets/Data/items.php'); + $written = (string) file_get_contents($root . '/assets/Data/Items/0001-s-potion.php'); $reloaded = loadRecordDatabase($root, 'items'); - expect($written)->toContain('price: 75') + expect($written)->toContain("'price' => 75,") + ->and((string) file_get_contents($root . '/assets/Data/Items/0002-antidote.php'))->toBe($antidote) ->and($reloaded->getRecordByIndex(0)?->get('price'))->toBe(75) // Everything else survives the rewrite. ->and($reloaded->getEntryLabels())->toBe(['S-Potion', 'Antidote']); diff --git a/tests/Unit/RecordReorderDurabilityTest.php b/tests/Unit/RecordReorderDurabilityTest.php index d9716ed..c11e73f 100644 --- a/tests/Unit/RecordReorderDurabilityTest.php +++ b/tests/Unit/RecordReorderDurabilityTest.php @@ -219,10 +219,12 @@ static function (): void { expect($help)->toContain('[ / ] move the entry') ->and($help)->toContain('Shift+D duplicate the entry'); + // Numbered record files: a copy is numbered last, and the order is + // the files' own, so nothing moves. openDatabaseCategory($editor, 'items'); $help = implode("\n", callEditorMethod($editor, 'getHelpLines')); - expect($help)->not->toContain('move the entry up or down') - ->and($help)->not->toContain('Shift+D duplicate the entry'); + expect($help)->toContain('Shift+D duplicate the entry') + ->and($help)->not->toContain('move the entry'); openDatabaseCategory($editor, 'states'); $help = implode("\n", callEditorMethod($editor, 'getHelpLines')); diff --git a/tests/Unit/StructuralSourcePreservationTest.php b/tests/Unit/StructuralSourcePreservationTest.php index 50b9571..eb9fa0d 100644 --- a/tests/Unit/StructuralSourcePreservationTest.php +++ b/tests/Unit/StructuralSourcePreservationTest.php @@ -2,44 +2,18 @@ declare(strict_types=1); -use Ichiloto\Editor\Database\PhpSourceDocument; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\RecordSchemaCatalog; /** - * Adding and removing a record without rewriting the file around it. + * Adding and removing an inventory record without rewriting anything else. * - * Editing a value was already surgical, but changing the *shape* of the list - * fell back to the writer that rebuilds the whole returned expression. On a - * file of authored constructor calls that means every entry reformatted, - * arguments reordered, defaults nobody wrote written out, and any expression - * the exporter cannot spell silently replaced -- to add one record. - * - * The file used here is the real game's `items.php`: fully qualified class - * names, imports, nested constructors, enum cases, emoji icons, and items, - * weapons and armors interleaved in one list. + * Each item, weapon and armor is a record file of its own, numbered in the + * order menus list them. Changing the shape of a category writes or deletes + * that one file: every other record, in every inventory category, keeps its + * own bytes. */ -/** - * Copies the real authored inventory into a throwaway project. - * - * @return array{0: string, 1: string}|null The project root and items path. - */ -function structuralProject(): ?array -{ - $game = gameSourceRoot(); - - if ($game === null) { - return null; - } - - $root = makeTemporaryProject('ichiloto-structural-'); - $path = $root . '/assets/Data/items.php'; - copy($game . '/assets/Data/items.php', $path); - - return [$root, $path]; -} - /** * Opens one inventory category. */ @@ -48,186 +22,71 @@ function structuralDatabase(string $root, string $category): ProjectRecordDataba return ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category)); } -/** - * Returns the lines of the original that a change rewrote. - * - * @return string[] The rewritten lines. - */ -function rewrittenLines(string $before, string $after): array -{ - $beforeLines = explode("\n", $before); - $afterLines = explode("\n", $after); - $rewritten = []; - - foreach ($beforeLines as $index => $line) { - if (($afterLines[$index] ?? null) !== $line) { - $rewritten[] = $line; - } - } - - return $rewritten; -} - -it('adds a record without rewriting the entries already in the file', function () { - $project = structuralProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = (string) file_get_contents($path); - $entriesBefore = PhpSourceDocument::parse($before)->entryCount(); - - $weapons = structuralDatabase($root, 'weapons'); - $index = $weapons->addRecord(); - $label = $weapons->getEntryLabels()[$index] ?? ''; - $weapons->save(); - - $after = (string) file_get_contents($path); - - // The list grew by one, and every byte the file had up to its closing - // bracket is still there, in order: the new entry was inserted, not - // written by rebuilding the list around it. - $upToClose = rtrim(substr($before, 0, (int) strrpos($before, '];'))); - preg_match('/equipmentType:[^\r\n]+/', $before, $authoredEquipmentType); - preg_match('/parameterChanges:[ \t]*new[ \t]+[^\s(]+[ \t]*\(/', $before, $authoredParameterConstructor); - expect($authoredEquipmentType)->not->toBe([]) - ->and($authoredParameterConstructor)->not->toBe([]); - - expect(PhpSourceDocument::parse($after)->entryCount())->toBe($entriesBefore + 1) - ->and(str_starts_with($after, $upToClose))->toBeTrue() - ->and(rewrittenLines($before, $after))->toHaveCount(2) - // Everything the surgical writer exists to protect. - ->and($after)->toContain('use Ichiloto\Engine\Entities\Effects\HPRecoveryEffect;') - ->and($after)->toContain("id: 'item.s-potion',") - ->and($after)->toContain($authoredEquipmentType[0]) - ->and($after)->toContain($authoredParameterConstructor[0]); - - $reloaded = structuralDatabase($root, 'weapons'); - expect($reloaded->getEntryLabels())->toContain($label); -})->group('engine'); - -it('removes a record without rewriting its neighbours', function () { - $project = structuralProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = (string) file_get_contents($path); - $entriesBefore = PhpSourceDocument::parse($before)->entryCount(); +it('adds a record as a file of its own, numbered last, leaving every other file alone', function () { + $root = makeTemporaryProject('ichiloto-structural-'); + $before = sourceHashTree($root); $items = structuralDatabase($root, 'items'); - $labels = $items->getEntryLabels(); - $removed = $items->removeRecord(1); + $index = $items->addRecord(); + $label = $items->getEntryLabels()[$index] ?? ''; $items->save(); - $after = (string) file_get_contents($path); - - expect($removed?->get('name'))->toBe($labels[1]) - ->and(PhpSourceDocument::parse($after)->entryCount())->toBe($entriesBefore - 1) - // The entries either side of it kept their own bytes. - ->and($after)->toContain("id: 'item.s-potion',") - ->and($after)->toContain('use Ichiloto\Engine\Entities\Effects\HPRecoveryEffect;'); + $after = sourceHashTree($root); - $reloaded = structuralDatabase($root, 'items'); - expect($reloaded->getEntryLabels())->not->toContain($labels[1]) - ->and($reloaded->getEntryLabels())->toContain($labels[0]); -})->group('engine'); - -it('leaves the other categories of a shared file alone', function () { - $project = structuralProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } + expect(array_keys(array_diff_key($after, $before)))->toBe(['assets/Data/Items/0003-item-new-item.php']) + ->and(array_diff_assoc(array_intersect_key($after, $before), $before))->toBe([]) + ->and(structuralDatabase($root, 'items')->getEntryLabels())->toBe(['S-Potion', 'Antidote', $label]); +}); - [$root, $path] = $project; - $weaponsBefore = structuralDatabase($root, 'weapons')->getEntryLabels(); - $armorsBefore = structuralDatabase($root, 'armors')->getEntryLabels(); +it('removes a record by removing its file, leaving its neighbours alone', function () { + $root = makeTemporaryProject('ichiloto-structural-'); + $before = sourceHashTree($root); - // Items, weapons and armors are interleaved in one list, so removing an - // item must not disturb the weapons and armors around it. $items = structuralDatabase($root, 'items'); - $items->removeRecord(0); + $removed = $items->removeRecord(0); $items->save(); - expect(structuralDatabase($root, 'weapons')->getEntryLabels())->toBe($weaponsBefore) - ->and(structuralDatabase($root, 'armors')->getEntryLabels())->toBe($armorsBefore); -})->group('engine'); + $after = sourceHashTree($root); -it('puts a deleted record back exactly, bytes and all', function () { - $project = structuralProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } + expect($removed?->get('name'))->toBe('S-Potion') + ->and(array_keys(array_diff_key($before, $after)))->toBe(['assets/Data/Items/0001-s-potion.php']) + ->and(array_diff_assoc($after, $before))->toBe([]) + ->and(structuralDatabase($root, 'items')->getEntryLabels())->toBe(['Antidote']) + // The other inventory categories are untouched. + ->and(structuralDatabase($root, 'weapons')->getEntryLabels())->toBe(['Wooden Sword']); +}); - [$root, $path] = $project; - $before = (string) file_get_contents($path); +it('puts a deleted record back exactly, bytes and all', function () { + $root = makeTemporaryProject('ichiloto-structural-'); + $before = sourceHashTree($root); $items = structuralDatabase($root, 'items'); $removed = $items->removeRecord(1); - // Undone before the save ever happens: the file was never changed, and - // the record goes back where it came from. + // Undone before the save ever happens: nothing on disk changes, and the + // record goes back where it came from. $items->insertRecord(1, $removed); $items->save(); - expect((string) file_get_contents($path))->toBe($before); -})->group('engine'); + expect(sourceHashTree($root))->toBe($before); +}); it('survives add, save, delete, save, and reload in one sitting', function () { - $project = structuralProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = (string) file_get_contents($path); - $entriesBefore = PhpSourceDocument::parse($before)->entryCount(); + $root = makeTemporaryProject('ichiloto-structural-'); + $before = sourceHashTree($root); $armors = structuralDatabase($root, 'armors'); $index = $armors->addRecord(); $label = $armors->getEntryLabels()[$index] ?? ''; $armors->save(); - expect(PhpSourceDocument::parse((string) file_get_contents($path))->entryCount())->toBe($entriesBefore + 1); - $reloaded = structuralDatabase($root, 'armors'); - $at = array_search($label, $reloaded->getEntryLabels(), true); - $reloaded->removeRecord(intval($at)); - $reloaded->save(); + expect($reloaded->getEntryLabels())->toContain($label); - $after = (string) file_get_contents($path); + $reloaded->removeRecord(intval(array_search($label, $reloaded->getEntryLabels(), true))); + $reloaded->save(); - // Back to the count it started at, with the original entries still - // spelled the way the author spelled them. - expect(PhpSourceDocument::parse($after)->entryCount())->toBe($entriesBefore) - ->and($after)->toContain("id: 'item.s-potion',") + // Back to exactly the project it started as. + expect(sourceHashTree($root))->toBe($before) ->and(structuralDatabase($root, 'armors')->getEntryLabels())->not->toContain($label); -})->group('engine'); - -it('refuses a structural edit it cannot make in the source, rather than regenerating', function () { - // The fixture authors its entries positionally, so an argument cannot be - // patched by name -- but an entry can still be appended and removed by - // span, which is what makes the refusal specific rather than blanket. - $root = makeTemporaryProject('ichiloto-structural-'); - $path = $root . '/assets/Data/items.php'; - $before = (string) file_get_contents($path); - - expect($before)->toContain("new Item('S-Potion'"); - - $items = structuralDatabase($root, 'items'); - $items->addRecord(); - $items->save(); - - $after = (string) file_get_contents($path); - - expect(PhpSourceDocument::parse($after)->entryCount())->toBe(PhpSourceDocument::parse($before)->entryCount() + 1) - // The positional entries kept their own spelling. - ->and($after)->toContain("new Item('S-Potion'"); }); From 3fb9655eb2ca04959efeb2303ae925326f98f23e Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 17:58:01 +0200 Subject: [PATCH 162/344] feat(session): write an actor identity repair across files from the GUI An actor's identity freeze that also repairs other files was refused in the GUI with a pointer to the terminal. The actor category now reports the file set it needs (SourceSetRequired); database.apply asks first, listing every file, and on "write" the session writes it as one SourceSetCommand, the same undoable step the terminal records. Map revisions now count on across a workspace reload, so a revision given before the reload never names the reloaded map, and history reports every map and category as changed when a step reloads them. Both editors decide when a freeze needs the wider repair through ActorAuthoring::planIdentityRepair. --- docs/manual.md | 8 +- docs/roadmap.md | 10 +- src/Actors/ActorAuthoring.php | 41 +++++++ src/Actors/ActorCategory.php | 32 ++--- src/Database/DatabaseCategory.php | 1 + src/Editor.php | 4 +- src/History/SourceSetRequired.php | 38 ++++++ src/Session/EditorSession.php | 153 +++++++++++++++++++----- src/Session/SessionHost.php | 1 + tests/Unit/Session/ActorSessionTest.php | 61 +++++++++- 10 files changed, 281 insertions(+), 68 deletions(-) create mode 100644 src/History/SourceSetRequired.php diff --git a/docs/manual.md b/docs/manual.md index 71811d0..4c1d039 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -944,6 +944,12 @@ what a save resolves the actor by. A project that declares none is resolved by display name, so the row says so, and renaming such an actor strands every save that named it. +The row's action freezes the current name as the permanent id. When that +is the whole repair, it is one edit, written on Save. When other files name +actors by what the repair changes (skits, maps, other actors), both editors +first list every file it writes; confirming writes them all at once, not on +Save, and Undo restores them. Pending edits are saved or undone first. + **Attack Style** is the character's own weapon: the weapon type they fight with when no weapon is equipped, part of who they are and with no stats (`Unarmed`, the default, writes nothing). A battle's basic attack plays the @@ -1015,7 +1021,7 @@ skill's effects, a quest's rewards) listed under them. | Category | Backing file | Terminal editor | GUI editor | | --- | --- | --- | --- | -| Actors | `assets/Data/Actors/*.php` | Editable | Same; an identity freeze that needs other files repaired at once is made in the terminal | +| Actors | `assets/Data/Actors/*.php` | Editable | Same | | Classes | `assets/Data/classes.php` | Editable - levels, the experience curve, every stat curve, equipment types and skills learned | Same | | Skills | `assets/Data/Skills/*.php` | Editable - one data record per numbered file, attacks, abilities and spells alike, with their effects; `skills.php` loads them | Same | | Items | `assets/Data/Items/*.php` | Editable - one data record per numbered file, with scope, occasion, animation and effects; `items.php` loads Items, Weapons and Armors | Same | diff --git a/docs/roadmap.md b/docs/roadmap.md index 61adbbe..27d8c9c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -265,11 +265,11 @@ terminal editor uses too. Progress: Classes, Quests, System, a new Configuration category, Actors, Types, Skills, Tilesets, Items, Weapons and Armors are done in both editors; the GUI session now serves every category. -Known gap: an actor authored without an id whose identity freeze also needs -other files rewritten (the cross-file repair `SourceSetCommand` writes at -once) is refused in the GUI with a pointer to the terminal's repair dialog; -the single-file freeze works in both editors. The session would need its -workspace replacement to keep loaded maps for the repair's undo. +An identity freeze that also repairs other files is asked about and written +at once in both editors: a document reports the file set it needs +(`SourceSetRequired`) and the workspace's owner writes it as one +`SourceSetCommand`. The session's map revisions count on across the reload, +so a revision given before it never names the reloaded map. Order of work, each a complete slice before the next: diff --git a/src/Actors/ActorAuthoring.php b/src/Actors/ActorAuthoring.php index db831f8..243a100 100644 --- a/src/Actors/ActorAuthoring.php +++ b/src/Actors/ActorAuthoring.php @@ -10,6 +10,7 @@ use Ichiloto\Editor\Database\SummonAssignmentDiagnostics; use Ichiloto\Editor\EquipmentOptimizationPolicy; use Ichiloto\Editor\History\GenericCommand; +use Ichiloto\Editor\History\SourceSetRequired; use Ichiloto\Editor\Inspector\InputControl; use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Editor\PermanentGrowthCatalog; @@ -17,6 +18,7 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Entities\Enumerations\WeaponType; use RuntimeException; +use Throwable; /** * How an actor is authored, for the terminal editor and the GUI alike: the @@ -197,6 +199,45 @@ public function freezeIdentity(ProjectWorkspace $workspace, int $index): RecordC ), $index); } + /** + * Returns the project-wide repair an actor's identity freeze needs when + * freezing its name is not the whole of it: the other files that name + * actors by what the repair changes are rewritten with it, at once. + * Returns null when the freeze alone is the repair. + * + * @throws RecordRefusal When the repair cannot be planned. + */ + public function planIdentityRepair(ProjectWorkspace $workspace, ProjectActor $actor): ?ActorIdentityMigrationPlan + { + try { + $plan = ActorIdentityMigration::planProject($workspace->projectRoot); + } catch (Throwable $failure) { + throw new RecordRefusal(sprintf('The actor identity repair could not be planned: %s', $failure->getMessage()), previous: $failure); + } + + return $plan->getChangedPaths() === [$actor->path] ? null : $plan; + } + + /** + * Describes a project-wide identity repair as the file set it writes, + * for whoever owns the workspace to confirm and write as one step. + */ + public function describeIdentityRepair(ProjectWorkspace $workspace, ProjectActor $actor, ActorIdentityMigrationPlan $plan): SourceSetRequired + { + $paths = array_map( + static fn(string $path): string => ltrim(substr($path, strlen(rtrim($workspace->projectRoot, DIRECTORY_SEPARATOR))), DIRECTORY_SEPARATOR), + $plan->getChangedPaths(), + ); + + return new SourceSetRequired( + $plan->getSourceSet(), + 'Migrate actor identities and references', + 'this actor migration', + sprintf('Freeze "%s" as its permanent id, repairing actor references in %d files?', $actor->getName(), count($paths)), + $paths, + ); + } + /** * The rows that say which actor this is to a save. * diff --git a/src/Actors/ActorCategory.php b/src/Actors/ActorCategory.php index a4c5b7c..6f0bd3a 100644 --- a/src/Actors/ActorCategory.php +++ b/src/Actors/ActorCategory.php @@ -11,7 +11,6 @@ use Ichiloto\Editor\ProjectActor; use Ichiloto\Editor\ProjectWorkspace; use RuntimeException; -use Throwable; /** * The project's actors as a Database category, authored through the same @@ -100,7 +99,13 @@ public function applyField(int $index, array $frame, string $fieldId, string $va if ($fieldId === 'id' && ! array_key_exists('id', $actor->getData())) { // An actor authored without an id: the edit is the one-time // freeze of its name, as the terminal's Enter on the row is. - $this->refuseWiderRepair($actor); + // When other files name actors by what it changes, the freeze is + // that whole repair, written at once by the workspace's owner. + $repair = $this->authoring->planIdentityRepair($this->workspace, $actor); + + if ($repair !== null) { + throw $this->authoring->describeIdentityRepair($this->workspace, $actor, $repair); + } return $this->authoring->freezeIdentity($this->workspace, $index); } @@ -161,29 +166,6 @@ private function requireActor(int $index): ProjectActor ?? throw new RecordRefusal(sprintf('actors has no record %d.', $index)); } - /** - * Freezing one actor's name is the whole repair only when no other file - * needs it too. A repair across files rewrites them at once rather than - * on save, so it is made from the terminal editor's repair dialog. - * - * @throws RecordRefusal - */ - private function refuseWiderRepair(ProjectActor $actor): void - { - try { - $paths = ActorIdentityMigration::planProject($this->workspace->projectRoot)->getChangedPaths(); - } catch (Throwable $failure) { - throw new RecordRefusal(sprintf('The actor identity repair could not be planned: %s', $failure->getMessage()), previous: $failure); - } - - if ($paths !== [$actor->path]) { - throw new RecordRefusal(sprintf( - 'Freezing this id also needs %d other file(s) repaired at once. Repair actor identities and references from Database > Actors in the terminal editor.', - max(0, count($paths) - 1), - )); - } - } - /** * @param list $frame * @throws RecordRefusal diff --git a/src/Database/DatabaseCategory.php b/src/Database/DatabaseCategory.php index 0284ecd..f4fffcb 100644 --- a/src/Database/DatabaseCategory.php +++ b/src/Database/DatabaseCategory.php @@ -71,6 +71,7 @@ public function locateItem(int $index, array $frame, string $fieldId): ?RecordIt /** * @param list $frame * @throws RecordRefusal When the record is gone or read-only, or the value is refused. + * @throws \Ichiloto\Editor\History\SourceSetRequired When the edit is only whole as files written at once. */ public function applyField(int $index, array $frame, string $fieldId, string $value, string $label): RecordChange; diff --git a/src/Editor.php b/src/Editor.php index 426a7e9..8e6ca68 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -9328,8 +9328,8 @@ private function beginDatabaseEdit(): void private function openActorIdentityMigration(ProjectActor $actor): void { try { - $plan = ActorIdentityMigration::planProject($this->projectRoot); - if ($plan->getChangedPaths() !== [$actor->path]) { + $plan = $this->workspace instanceof ProjectWorkspace ? $this->actorAuthoring->planIdentityRepair($this->workspace, $actor) : null; + if ($plan !== null) { $this->openActorReferenceMigration($plan); return; } diff --git a/src/History/SourceSetRequired.php b/src/History/SourceSetRequired.php new file mode 100644 index 0000000..d5f1581 --- /dev/null +++ b/src/History/SourceSetRequired.php @@ -0,0 +1,38 @@ + $paths The files the set writes, relative to the project root. + */ + public function __construct( + public readonly SourceSetPlan $plan, + public readonly string $label, + public readonly string $subject, + public readonly string $question, + public readonly array $paths, + ) { + parent::__construct($question); + } +} diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 8d8af5e..c24e274 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -46,6 +46,8 @@ use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Maps\MapReferences; use Ichiloto\Editor\Maps\TilePalette; +use Ichiloto\Editor\History\SourceSetCommand; +use Ichiloto\Editor\History\SourceSetRequired; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Storage\WorkspaceSave; @@ -60,6 +62,7 @@ use Closure; use InvalidArgumentException; use RuntimeException; +use WeakReference; /** * One open project as every editor interface edits it: its documents, the @@ -84,6 +87,16 @@ final class EditorSession /** How actors are authored, with what this editor's actor panes show. */ private readonly ActorAuthoring $actorAuthoring; + + /** + * Where each map's revisions count from. A map read afresh, when a file + * set written at once reloads the project or its undo puts the earlier + * one back, counts on from every revision given for it before, so one + * revision never names two states of a map. + * + * @var array, base: int, last: int}> + */ + private array $mapRevisions = []; private function __construct( ProjectWorkspace $workspace, private readonly CommandHistory $history, @@ -129,12 +142,12 @@ public function describeProject(): array */ public function describeMaps(): array { - return array_map(static fn(ProjectMap $map): array => [ + return array_map(fn(ProjectMap $map): array => [ 'id' => $map->mapId, 'name' => $map->getDisplayName(), 'dirty' => $map->isDirty(), 'readOnly' => $map->getGridSourceIssue(), - 'revision' => $map->stateVersion(), + 'revision' => $this->getMapRevision($map), ], $this->workspace->maps); } @@ -280,7 +293,7 @@ public function readMap(string $mapId): array 'name' => $map->getDisplayName(), 'width' => $width, 'height' => $height, - 'revision' => $map->stateVersion(), + 'revision' => $this->getMapRevision($map), 'dirty' => $map->isDirty(), 'readOnly' => $map->getGridSourceIssue(), 'baseLayer' => $map->getBaseLayerId(), @@ -328,7 +341,7 @@ public function readWorld(string $mapId, bool $tileShadows = false): array return [ 'map' => $map->mapId, - 'revision' => $map->stateVersion(), + 'revision' => $this->getMapRevision($map), 'assetRoot' => $map->getAssetRoot(), 'operations' => $world->getOperations(true, $tileShadows), 'layerIds' => $layerIds, @@ -381,7 +394,7 @@ public function readTiles(string $mapId): array } } - return ['map' => $map->mapId, 'revision' => $map->stateVersion(), 'layers' => $layers]; + return ['map' => $map->mapId, 'revision' => $this->getMapRevision($map), 'layers' => $layers]; } /** @@ -422,7 +435,7 @@ public function stampTiles(string $mapId, int $revision, string $layerName, arra $this->history->record($applied['command']); } - return ['changed' => $applied['changed'], 'revision' => $map->stateVersion()]; + return ['changed' => $applied['changed'], 'revision' => $this->getMapRevision($map)]; } /** @@ -468,7 +481,7 @@ public function paint(string $mapId, int $revision, string $layerId, array $cell $this->history->record($applied['command']); } - return ['status' => 'applied', 'changed' => $applied['changed'], 'revision' => $map->stateVersion()]; + return ['status' => 'applied', 'changed' => $applied['changed'], 'revision' => $this->getMapRevision($map)]; } /** @@ -631,7 +644,7 @@ private function editLayers(string $mapId, int $revision, Closure $edit): array $this->history->record($result['command']); } - return ['status' => 'applied', 'revision' => $map->stateVersion(), 'changed' => $result['command'] !== null, + return ['status' => 'applied', 'revision' => $this->getMapRevision($map), 'changed' => $result['command'] !== null, 'layer' => $result['layer']]; } @@ -680,7 +693,7 @@ public function readInspector(string $mapId, ?string $marker = null): array return [ 'map' => $mapId, - 'revision' => $map->stateVersion(), + 'revision' => $this->getMapRevision($map), 'event' => $marker, 'rows' => array_map(self::describeRow(...), $this->collectInspectorFields($map, $marker)), ]; @@ -724,7 +737,7 @@ public function applyInspector(string $mapId, int $revision, array $key, string throw new SessionRefusal(sprintf('Answer clear or cancel, not %s.', $answer)); } if ($answer === 'cancel') { - return ['status' => 'applied', 'revision' => $map->stateVersion(), 'changed' => false]; + return ['status' => 'applied', 'revision' => $this->getMapRevision($map), 'changed' => false]; } } @@ -732,7 +745,7 @@ public function applyInspector(string $mapId, int $revision, array $key, string ? $inspector->changeMapKind($map, $value, $cleared > 0 && $answer === 'clear') : $inspector->apply($map, $field, $value)); - return ['status' => 'applied', 'revision' => $map->stateVersion(), 'changed' => $command !== null]; + return ['status' => 'applied', 'revision' => $this->getMapRevision($map), 'changed' => $command !== null]; } /** @@ -772,7 +785,7 @@ public function createEvent(string $mapId, int $revision, array $cells, string $ return $created['command']; }); - return ['marker' => $created['marker'], 'revision' => $map->stateVersion()]; + return ['marker' => $created['marker'], 'revision' => $this->getMapRevision($map)]; } /** @@ -787,7 +800,7 @@ public function deleteEvent(string $mapId, int $revision, string $marker): array $map = $this->requireCurrentMap($mapId, $revision); $this->runEdit(static fn(): Command => EventAuthoring::deleteEvent($map, $marker)); - return ['revision' => $map->stateVersion()]; + return ['revision' => $this->getMapRevision($map)]; } /** @@ -801,7 +814,7 @@ public function moveEvent(string $mapId, int $revision, string $marker, int $del $map = $this->requireCurrentMap($mapId, $revision); $command = $this->runEdit(static fn(): ?Command => EventAuthoring::moveEvent($map, $marker, $deltaX, $deltaY)); - return ['revision' => $map->stateVersion(), 'changed' => $command !== null]; + return ['revision' => $this->getMapRevision($map), 'changed' => $command !== null]; } /** @@ -815,7 +828,7 @@ public function setEventBounds(string $mapId, int $revision, string $marker, int $map = $this->requireCurrentMap($mapId, $revision); $command = $this->runEdit(static fn(): ?Command => EventAuthoring::setEventBounds($map, $marker, $x, $y, $width, $height)); - return ['revision' => $map->stateVersion(), 'changed' => $command !== null]; + return ['revision' => $this->getMapRevision($map), 'changed' => $command !== null]; } /** @@ -831,7 +844,7 @@ public function setEventDestination(string $mapId, int $revision, string $marker $destination = $this->requireMap($destinationMapId); $command = $this->runEdit(fn(): ?Command => $this->createMapInspector($map)->setTransferDestination($map, $marker, $destination, $x, $y)); - return ['revision' => $map->stateVersion(), 'changed' => $command !== null]; + return ['revision' => $this->getMapRevision($map), 'changed' => $command !== null]; } /** @@ -878,7 +891,7 @@ private function editInspectorList(string $mapId, int $revision, array $key, Clo return $result?->command; }); - return ['revision' => $map->stateVersion(), 'changed' => $result?->command !== null, 'message' => $result?->summary ?? 'Nothing changed.']; + return ['revision' => $this->getMapRevision($map), 'changed' => $result?->command !== null, 'message' => $result?->summary ?? 'Nothing changed.']; } /** @@ -1302,7 +1315,7 @@ public function readDatabaseRecord(string $category, int $index, array $frame = * @return array{changed: bool, records: list, note?: string} Whether it changed, the labels afterwards (a rename shows), and what else it did. * @throws SessionRefusal When the category or record is unknown or read-only, the row is gone or read-only, or the value is refused. */ - public function applyDatabaseRecord(string $category, int $index, array $key, string $value): array + public function applyDatabaseRecord(string $category, int $index, array $key, string $value, ?string $answer = null): array { $database = $this->requireCategory($category); $frame = self::requireFrame($key['frame'] ?? [], 'database.record'); @@ -1318,14 +1331,65 @@ public function applyDatabaseRecord(string $category, int $index, array $key, st throw new SessionRefusal(sprintf('%s cannot be edited here.', trim((string) ($field['label'] ?? 'That row')))); } - $change = $this->changeRecord(static fn(): RecordChange => $database->applyField( - $index, $frame, $fieldId, $value, (string) ($field['label'] ?? 'Database field'), - )); + try { + $change = $this->changeRecord(static fn(): RecordChange => $database->applyField( + $index, $frame, $fieldId, $value, (string) ($field['label'] ?? 'Database field'), + )); + } catch (SourceSetRequired $required) { + return $this->writeSourceSet($required, $answer, fn(): array => $this->requireCategory($category)->getRecordLabels()); + } return array_filter(['changed' => $change->command !== null, 'records' => $database->getRecordLabels(), 'note' => $change->note], static fn(mixed $value): bool => $value !== null); } + /** + * Asks before writing a file set an edit needs at once, then writes it + * as one undo step: the workspace is reloaded from the written files, so + * every map and category reads afresh. + * + * @param Closure(): list $records The category's labels afterwards. + * @return array{status: 'question', question: string, answers: list}|array{changed: bool, records: list, reloaded?: true} + * @throws SessionRefusal When an answer is not one offered, edits are pending, or the files cannot be written. + */ + private function writeSourceSet(SourceSetRequired $required, ?string $answer, Closure $records): array + { + if ($answer === null) { + return [ + 'status' => 'question', + 'question' => $required->question, + 'answers' => [ + ['key' => 'cancel', 'label' => 'Cancel', 'description' => 'Leave all files unchanged.'], + ['key' => 'write', 'label' => sprintf('Write %d repaired files now', count($required->paths)), + 'description' => sprintf('Writes %s now, not on Save. Undo restores the files.', implode(', ', $required->paths))], + ], + ]; + } + if (! in_array($answer, ['write', 'cancel'], true)) { + throw new SessionRefusal(sprintf('Answer write or cancel, not %s.', $answer)); + } + if ($answer === 'cancel') { + return ['changed' => false, 'records' => $records()]; + } + if ($this->workspace->hasUnsavedChanges()) { + throw new SessionRefusal(sprintf('Save or undo pending edits before %s. No files were changed.', $required->subject)); + } + + $command = new SourceSetCommand($required->label, $required->subject, $required->plan, $this->workspace, + fn(): ProjectWorkspace => $this->workspace, + function (ProjectWorkspace $workspace): void { $this->workspace = $workspace; }, + ); + + try { + $command->execute(); + } catch (RuntimeException $failure) { + throw new SessionRefusal($failure->getMessage(), previous: $failure); + } + $this->history->record($command); + + return ['changed' => true, 'records' => $records(), 'reloaded' => true]; + } + /** * Adds an item at a record row as one undo step: after the item the row * belongs to, or with `$child` at the end of what that item holds (a @@ -1654,7 +1718,7 @@ public function readNpc(string $mapId, int $index, array $frame = []): array return [ 'map' => $mapId, - 'revision' => $map->stateVersion(), + 'revision' => $this->getMapRevision($map), 'index' => $index, 'frame' => $frame, 'frameLabel' => $frame === [] ? null : $inspector->records()->describeFramePath($frame), @@ -1762,7 +1826,7 @@ private function applyNpcChange(ProjectMap $map, callable $change): array } return [ - 'revision' => $map->stateVersion(), + 'revision' => $this->getMapRevision($map), 'changed' => $applied->command !== null, 'index' => $applied->index, 'id' => $applied->npc?->getId(), @@ -1988,17 +2052,24 @@ public function saveAll(): array */ private function traverseHistory(callable $step): array { - $before = array_map(static fn(ProjectMap $map): int => $map->stateVersion(), $this->workspace->maps); + $workspace = $this->workspace; + $before = array_map($this->getMapRevision(...), $this->getMapsById()); $databases = $this->listDatabaseVersions(); $command = $step(); + // A step that put another workspace in place (a file set written at + // once, or its undo) changed whatever it reloaded: everything. + $reloaded = $this->workspace !== $workspace; $changed = $revisions = []; - foreach ($this->workspace->maps as $index => $map) { - if ($map->stateVersion() !== $before[$index]) { - $changed[] = $map->mapId; - $revisions[$map->mapId] = $map->stateVersion(); + foreach ($this->getMapsById() as $mapId => $map) { + $revision = $this->getMapRevision($map); + if ($reloaded || $revision !== ($before[$mapId] ?? null)) { + $changed[] = (string) $mapId; + $revisions[$mapId] = $revision; } } - $changedDatabases = array_keys(array_diff_assoc($this->listDatabaseVersions(), $databases)); + $changedDatabases = $reloaded + ? array_keys($this->listDatabaseVersions()) + : array_keys(array_diff_assoc($this->listDatabaseVersions(), $databases)); return ['label' => $command?->label, 'maps' => $changed, 'revisions' => $revisions, 'databases' => array_values(array_map('strval', $changedDatabases))]; } @@ -2165,10 +2236,32 @@ private function requireMap(string $mapId): ProjectMap } /** @throws SessionRefusal When the map changed since the revision the caller saw. */ + /** + * A map's revision as an interface is given it: its own state version, + * counted on from every revision given for an earlier reading of it. + */ + private function getMapRevision(ProjectMap $map): int + { + $known = $this->mapRevisions[$map->mapId] ?? null; + + if ($known === null || $known['map']->get() !== $map) { + $known = [ + 'map' => WeakReference::create($map), + 'base' => $known === null ? 0 : $known['last'] + 1 - $map->stateVersion(), + 'last' => 0, + ]; + } + + $known['last'] = $known['base'] + $map->stateVersion(); + $this->mapRevisions[$map->mapId] = $known; + + return $known['last']; + } + private function requireCurrentMap(string $mapId, int $revision): ProjectMap { $map = $this->requireMap($mapId); - if ($map->stateVersion() !== $revision) { + if ($this->getMapRevision($map) !== $revision) { throw new SessionRefusal(sprintf('%s changed since revision %d; reload it and edit again.', $mapId, $revision)); } diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 527f822..3875942 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -276,6 +276,7 @@ private function dispatch(string $method, array $params): array self::requireInt($params, 'index'), is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key database.record gave.'), is_scalar($params['value'] ?? null) ? (string) $params['value'] : throw new InvalidRequest('"value" must be a string, number or boolean.'), + self::readOptionalString($params, 'answer'), ), 'database.add' => $session->addDatabaseItem( self::requireString($params, 'category'), diff --git a/tests/Unit/Session/ActorSessionTest.php b/tests/Unit/Session/ActorSessionTest.php index 6590b9d..69f4611 100644 --- a/tests/Unit/Session/ActorSessionTest.php +++ b/tests/Unit/Session/ActorSessionTest.php @@ -134,17 +134,51 @@ function writeLegacyActor(string $root, string $name): string expect(file_get_contents($path))->toContain("'id' => 'Mira'"); }); -it('refuses an identity freeze that needs other files repaired at once, naming where that is done', function () { +it('asks before an identity freeze that repairs other files at once, then writes it as one undo step', function () { $root = makeTemporaryProject(); - writeLegacyActor($root, 'Mira'); + $mira = writeLegacyActor($root, 'Mira'); writeLegacyActor($root, 'Tobin'); + $before = sourceHashTree($root); $session = EditorSession::open($root); + [$map] = $session->describeMaps(); $index = array_search('Mira', $session->listDatabaseRecords('actors')['records'], true); $id = actorSessionRow($session->readDatabaseRecord('actors', $index), 'id'); + $question = $session->applyDatabaseRecord('actors', $index, $id['key'], ''); + + expect($question['status'])->toBe('question') + ->and($question['question'])->toContain('Freeze "Mira"') + ->and(array_column($question['answers'], 'key'))->toBe(['cancel', 'write']) + ->and($question['answers'][1]['description'])->toContain('assets/Data/Actors/Mira.php', 'Undo restores the files') + ->and($session->applyDatabaseRecord('actors', $index, $id['key'], '', 'cancel')['changed'])->toBeFalse() + ->and(fn() => $session->applyDatabaseRecord('actors', $index, $id['key'], '', 'maybe'))->toThrow(SessionRefusal::class, 'Answer write or cancel') + ->and(sourceHashTree($root))->toBe($before); + + // Pending edits are saved or undone first: the set is written at once. + $description = actorSessionRow($session->readDatabaseRecord('actors', 0), 'description')['key']; + $session->applyDatabaseRecord('actors', 0, $description, 'Pending.'); + expect(fn() => $session->applyDatabaseRecord('actors', $index, $id['key'], '', 'write')) + ->toThrow(SessionRefusal::class, 'Save or undo pending edits'); + $session->undo(); - expect(fn() => $session->applyDatabaseRecord('actors', $index, $id['key'], '')) - ->toThrow(SessionRefusal::class, 'other file(s) repaired at once') - ->and($session->listDatabaseRecords('actors')['dirty'])->toBeFalse(); + $written = $session->applyDatabaseRecord('actors', $index, $id['key'], '', 'write'); + [$reread] = $session->describeMaps(); + + expect($written)->toMatchArray(['changed' => true, 'reloaded' => true]) + ->and((string) file_get_contents($mira))->toContain("'id' => 'Mira'") + ->and(sourceHashTree($root))->not->toBe($before) + ->and(actorSessionRow($session->readDatabaseRecord('actors', $index), 'id'))->toMatchArray(['kind' => 'info', 'value' => 'Mira']) + ->and($session->listDatabaseRecords('actors')['dirty'])->toBeFalse() + // The project was read again; a revision given before names nothing now. + ->and($reread['revision'])->toBeGreaterThan($map['revision']) + ->and(fn() => $session->deleteEvent($map['id'], $map['revision'], 'A'))->toThrow(SessionRefusal::class, 'changed since revision'); + + $undone = $session->undo(); + + expect($undone['label'])->toBe('Migrate actor identities and references') + ->and($undone['databases'])->toContain('actors') + ->and($undone['revisions'][$map['id']])->toBeGreaterThan($reread['revision']) + ->and(sourceHashTree($root))->toBe($before) + ->and(actorSessionRow($session->readDatabaseRecord('actors', $index), 'id')['kind'])->toBe('action'); }); it('serves actors over the line protocol', function () { @@ -162,3 +196,20 @@ function writeLegacyActor(string $root, string $name): string ->and($applied['changed'])->toBeTrue() ->and($undone['databases'])->toBe(['actors']); }); + +it('asks and answers a file set written at once over the line protocol', function () { + $root = makeTemporaryProject(); + writeLegacyActor($root, 'Mira'); + writeLegacyActor($root, 'Tobin'); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(int $id, string $method, array $params = []): array => + $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params])); + $request(1, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + $index = array_search('Mira', $request(2, 'database.records', ['category' => 'actors'])['result']['records'], true); + $key = actorSessionRow($request(3, 'database.record', ['category' => 'actors', 'index' => $index, 'frame' => []])['result'], 'id')['key']; + $apply = ['category' => 'actors', 'index' => $index, 'key' => $key, 'value' => '']; + + expect($request(4, 'database.apply', $apply)['result']['status'])->toBe('question') + ->and($request(5, 'database.apply', [...$apply, 'answer' => 'write'])['result'])->toMatchArray(['changed' => true, 'reloaded' => true]) + ->and($request(6, 'history.undo')['result']['label'])->toBe('Migrate actor identities and references'); +}); From 39dc2d988412fcc51c232893cafc97aed916b366 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 18:27:58 +0200 Subject: [PATCH 163/344] fix(database): save every category back to the bytes it read after an undo A new test opens every Database category through the session, creates a record where one is empty, then edits, saves, undoes and saves again, requiring the original bytes. It found four defects, fixed where they are owned: - The source writer appended a key the file lacked at the end of its map; it now goes ahead of the key that follows it in the record. - Removing entries from an array written on one line left their separators behind; runs are now cut with them, and an append after a cut run joins the last surviving value. - A projected file (knowledge, Optimize) re-added a category's key at the end; the fold now puts it back where the file first held it. - Creating an enemy in a project without enemy sprites said only that enemies cannot be created; requireNewRecord carries the reason. Game-gated tests that still used the removed skill database now read skills as records. Two Game-gated tests of the constructor-call shared inventory file are removed with that layout; shared array lists keep their synthetic coverage. --- docs/roadmap.md | 6 +- src/Cutscenes/Source/ArraySourceWriter.php | 119 +++- .../Source/PhpArraySourceDocument.php | 98 +++- src/Database/ProjectRecordDatabase.php | 113 +++- src/Database/RecordAuthoring.php | 11 +- src/Database/RecordSchema.php | 4 +- src/Database/RecordSchemaCatalog.php | 8 +- .../Unit/Cutscenes/ArraySourceWriterTest.php | 42 ++ .../LastLegendSummonVerificationTest.php | 2 +- tests/Unit/DurableSourceIdentityTest.php | 552 ------------------ tests/Unit/SaveIntegrityTest.php | 1 - .../Session/DatabaseCoverageSessionTest.php | 136 +++++ tests/Unit/SharedInventoryTransactionTest.php | 310 ---------- 13 files changed, 505 insertions(+), 897 deletions(-) delete mode 100644 tests/Unit/DurableSourceIdentityTest.php create mode 100644 tests/Unit/Session/DatabaseCoverageSessionTest.php delete mode 100644 tests/Unit/SharedInventoryTransactionTest.php diff --git a/docs/roadmap.md b/docs/roadmap.md index 27d8c9c..9cc9752 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -289,8 +289,10 @@ Order of work, each a complete slice before the next: and `Armors`, with `items.php` as their barrel (done). No catalogued category stores `new Class(...)` entries any more, so the object-entry write path (constructor-call entries in `ProjectRecordDatabase` and - `SharedFileTransaction`) serves no category; removing it, while keeping - the `PhpSourceDocument` reading `ActorReferenceSource` uses, is open. + `SharedFileTransaction`, with their identity addressing and refusals) + serves no category; removing it, while keeping the `PhpSourceDocument` + reading `ActorReferenceSource` uses, is open. Shared array lists keep + their synthetic coverage in `SharedListFoldTest`. 4. Tilesets are done in both editors: sheets, missing art, above and table tiles, shadows, and pieces keyed by id, stamped or connected, with their tile layers. Tile identities are typed in both editors; picking them on a diff --git a/src/Cutscenes/Source/ArraySourceWriter.php b/src/Cutscenes/Source/ArraySourceWriter.php index d19ebaa..406a037 100644 --- a/src/Cutscenes/Source/ArraySourceWriter.php +++ b/src/Cutscenes/Source/ArraySourceWriter.php @@ -12,7 +12,9 @@ * * The values an author edits are arrays; the file is source. This walks both * together: a scalar that changed is rewritten where its literal sits, a - * keyed entry that appeared is added on lines of its own, one that vanished + * keyed entry that appeared is added on lines of its own, ahead of the entry + * that follows it in the new value so the file reads in that order (at the + * end when nothing the file holds follows it), one that vanished * is cut with its heading, and a list is aligned so that an entry moved * keeps its bytes and an entry edited keeps everything but the changed * field. A value written as a variable is retargeted rather than expanded: @@ -38,6 +40,31 @@ final class ArraySourceWriter */ private array $appends = []; + /** + * Keyed entries queued ahead of an entry the file already holds, per + * entry: several inserted before one entry become one edit, in order. + * + * @var array}> + */ + private array $inserts = []; + + /** + * Entries removed from arrays whose entries share their lines, per + * parent node, cut together at flush time so the separators that joined + * them go with them. + * + * @var array}> + */ + private array $sharedLineRemovals = []; + + /** + * Arrays whose last surviving entry lost its comma with the entries + * after it, by node. + * + * @var array + */ + private array $separatorCuts = []; + /** * Entry indexes this rewrite removes (or moves away), per parent node, * so an append knows which entry will actually precede it. @@ -188,8 +215,12 @@ private function diffArray(SourceNode $node, ?array $old, array $new, array $pat return; } - // Keyed: recurse into keys both have, add the new ones, cut the gone. - foreach ($new as $key => $value) { + // Keyed: recurse into keys both have, add the new ones where the new + // value orders them, cut the gone. + $keys = array_keys($new); + + foreach ($keys as $position => $key) { + $value = $new[$key]; $entry = $node->entryFor($key); if ($entry !== null && array_key_exists($key, $old)) { @@ -198,13 +229,22 @@ private function diffArray(SourceNode $node, ?array $old, array $new, array $pat continue; } - $this->queueAppend($node, $key, $this->literalFor($value, [...$path, $key])); + $literal = $this->literalFor($value, [...$path, $key]); + $anchor = $this->findFollowingEntry($node, $old, array_slice($keys, $position + 1)); + + if ($anchor === null) { + $this->queueAppend($node, $key, $literal); + + continue; + } + + $this->inserts[spl_object_id($anchor)] ??= ['node' => $node, 'anchor' => $anchor, 'entries' => []]; + $this->inserts[spl_object_id($anchor)]['entries'][] = ['key' => $key, 'literal' => $literal]; } foreach ($node->entries as $index => $entry) { if ($entry->key !== null && ! array_key_exists($entry->key, $new)) { - $this->edits[] = $this->document->removeEntryEdit([...$path, $entry->key]); - $this->removedEntries[spl_object_id($node)][$index] = true; + $this->queueRemoval($node, $index, [...$path, $entry->key]); } } } @@ -381,8 +421,7 @@ private function emitListMatches(SourceNode $node, array $old, array $new, array foreach ($old as $i => $entry) { if (! isset($matchedOld[$i])) { - $this->edits[] = $this->document->removeEntryEdit([...$path, $i]); - $this->removedEntries[spl_object_id($node)][$i] = true; + $this->queueRemoval($node, $i, [...$path, $i]); } } @@ -421,6 +460,49 @@ private function emitListMatches(SourceNode $node, array $old, array $new, array } } + /** + * Returns the entry a new key goes ahead of: the first of the keys after + * it in the new value that the file holds and keeps. Null puts the new + * key at the end. + * + * @param array $old + * @param list $following + */ + private function findFollowingEntry(SourceNode $node, array $old, array $following): ?SourceEntry + { + foreach ($following as $key) { + $entry = $node->entryFor($key); + + if ($entry !== null && array_key_exists($key, $old)) { + return $entry; + } + } + + return null; + } + + /** + * Removes an entry: at once, with its lines and heading, from an array + * written a line per entry; at flush time, with the separators that + * joined it, from one whose entries share their lines. + * + * @param array $path The entry's path. + */ + private function queueRemoval(SourceNode $node, int $index, array $path): void + { + $id = spl_object_id($node); + $this->removedEntries[$id][$index] = true; + + if (! $this->document->sharesLines($node)) { + $this->edits[] = $this->document->removeEntryEdit($path); + + return; + } + + $this->sharedLineRemovals[$id] ??= ['node' => $node, 'indexes' => []]; + $this->sharedLineRemovals[$id]['indexes'][] = $index; + } + /** * Queues an entry for the end of an array. Appends flush together, one * composable edit per parent, so they never collide with each other or @@ -450,6 +532,25 @@ private function queueAppend(SourceNode $node, int|string|null $key, string $lit */ private function flushAppends(): void { + foreach ($this->sharedLineRemovals as $id => $removal) { + $indexes = $removal['indexes']; + sort($indexes); + $planned = $this->document->removeSharedLineEntriesEdits($removal['node'], $indexes); + array_push($this->edits, ...$planned['edits']); + + if ($planned['separatorCut']) { + $this->separatorCuts[$id] = true; + } + } + + $this->sharedLineRemovals = []; + + foreach ($this->inserts as $insert) { + $this->edits[] = $this->document->insertEntriesBeforeEdit($insert['node'], $insert['anchor'], $insert['entries']); + } + + $this->inserts = []; + foreach ($this->appends as $id => $append) { $node = $append['node']; $removed = $this->removedEntries[$id] ?? []; @@ -464,7 +565,7 @@ private function flushAppends(): void if ($append['inline'] !== []) { // After the last entry this rewrite keeps; right at the body // start when it keeps none. - $this->edits[] = $this->document->planInlineAppend($node, $append['inline'], $lastSurviving); + $this->edits[] = $this->document->planInlineAppend($node, $append['inline'], $lastSurviving, isset($this->separatorCuts[$id])); } if ($append['lines'] === []) { diff --git a/src/Cutscenes/Source/PhpArraySourceDocument.php b/src/Cutscenes/Source/PhpArraySourceDocument.php index ca4f812..b4a87c3 100644 --- a/src/Cutscenes/Source/PhpArraySourceDocument.php +++ b/src/Cutscenes/Source/PhpArraySourceDocument.php @@ -499,6 +499,95 @@ public function renameKeyEdit(array $path, string $newKey): array throw new SourcePreservationRefusal('No literal key at ' . self::describePath($path)); } + /** + * Plans inserting entries, in order, ahead of an entry the array holds: + * on lines of their own above its block, or ahead of it on its line when + * it shares one. + * + * @param list $entries + * @return array{0: int, 1: int, 2: string} The edit. + */ + public function insertEntriesBeforeEdit(SourceNode $array, SourceEntry $anchor, array $entries): array + { + if ($this->lineIndentBefore($anchor->start) === null) { + $inline = array_map(fn(array $entry): string => $this->renderInlineEntry($entry['key'], $entry['literal'], $anchor->start), $entries); + + return [$anchor->start, $anchor->start, implode(', ', $inline) . ', ']; + } + + [$blockStart] = $this->blockSpan($anchor); + $lines = array_map(fn(array $entry): string => $this->renderEntryLine($array, $entry['key'], $entry['literal']), $entries); + + return [$blockStart, $blockStart, implode('', $lines)]; + } + + /** + * Returns whether every entry of an array shares its line with what + * precedes it, as `['type' => 'text', 'name' => '']` does. + */ + public function sharesLines(SourceNode $array): bool + { + if ($array->entries === []) { + return false; + } + + foreach ($array->entries as $entry) { + if ($this->lineIndentBefore($entry->start) !== null) { + return false; + } + } + + return true; + } + + /** + * Plans removing entries of an array whose entries share their lines, + * each run of removed entries with the separators that joined it, so the + * survivors read as if written without them: a run cut ahead of the + * entry after it, a run at the end cut after the entry before it, and an + * array left empty written `[]`. + * + * @param list $removed The indexes of the entries removed, ascending. + * @return array{edits: list, separatorCut: bool} The edits, and + * whether the last surviving entry's comma went with a run after it. + */ + public function removeSharedLineEntriesEdits(SourceNode $array, array $removed): array + { + $entries = $array->entries; + $count = count($entries); + $isRemoved = array_fill_keys($removed, true); + + if (count($isRemoved) === $count && $array->bodyStart !== null && $array->bodyEnd !== null) { + return ['edits' => [[$array->bodyStart, $array->bodyEnd, '']], 'separatorCut' => false]; + } + + $edits = []; + $separatorCut = false; + + for ($i = 0; $i < $count; $i++) { + if (! isset($isRemoved[$i])) { + continue; + } + + $j = $i; + + while (isset($isRemoved[$j + 1])) { + $j++; + } + + if ($j + 1 < $count) { + $edits[] = [$entries[$i]->start, $entries[$j + 1]->start, '']; + } else { + $edits[] = [$entries[$i - 1]->end, $entries[$j]->end, '']; + $separatorCut = true; + } + + $i = $j; + } + + return ['edits' => $edits, 'separatorCut' => $separatorCut]; + } + /** * Plans inserting an entry into an array: ahead of the entry now at a * position, or after the last one. @@ -612,14 +701,21 @@ private static function renderEntry(int|string|null $key, string $value): string * @param array $entries The entries, each `key => value` or a value. * @param SourceEntry|null $lastSurviving The entry that will precede the * appended ones, or null when none survives and they start the body. + * @param bool $separatorCut Whether the entries after it are cut with its + * comma ({@see removeSharedLineEntriesEdits}): the appended ones then + * join right after its value, ahead of any trailing comma left behind. * @return array{0: int, 1: int, 2: string} The edit. */ - public function planInlineAppend(SourceNode $array, array $entries, ?SourceEntry $lastSurviving): array + public function planInlineAppend(SourceNode $array, array $entries, ?SourceEntry $lastSurviving, bool $separatorCut = false): array { if ($array->bodyStart === null) { throw new RuntimeException('Only an array node takes appended entries.'); } + if ($lastSurviving !== null && $separatorCut) { + return [$lastSurviving->end, $lastSurviving->end, ', ' . implode(', ', $entries)]; + } + $last = $array->entries === [] ? null : $array->entries[count($array->entries) - 1]; $trailingComma = $last !== null && $last->separatorEnd !== $last->end; $text = implode(', ', $entries) . ($trailingComma ? ',' : ''); diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 3099e4c..a17df53 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -64,6 +64,8 @@ private function __construct( private ?ProjectConfig $config = null, private ?\Closure $identityReferences = null, ) { + $this->learnKeyOrder(); + if ($schema->storage === RecordStorage::LIST_FILE && $schema->projection === null) { // What the file declares for each record -- the identity it is // addressed by and the values it was authored with -- so a save @@ -79,6 +81,15 @@ private function __construct( } } + /** + * The top-level keys of a projected file in the order the file first + * held each: a key this category's fold adds back (its records gone and + * returned, say by an undo after a save) goes back where it was. + * + * @var list + */ + private array $authoredKeyOrder = []; + /** * @var array}> * What the file held for each record when it was last read or adopted, @@ -965,13 +976,31 @@ public function supportsRecordDeletion(): bool /** * Appends a blank record. * - * @return int|null The new record index, or null when the category is read-only. + * @return int|null The new record index, or null when no record can be made here. */ public function addRecord(): ?int { - if (! $this->supportsRecordCreation()) { + try { + return $this->requireNewRecord(); + } catch (RecordRefusal) { return null; } + } + + /** + * Appends a blank record, or says why none can be made: the category + * takes no new records, or the project lacks what a new one needs. + * + * @return int The new record index. + * @throws RecordRefusal With the reason an author reads. + */ + public function requireNewRecord(): int + { + $cannot = sprintf('%s entries cannot be created from the editor.', ucfirst($this->schema->entryNoun)); + + if (! $this->supportsRecordCreation()) { + throw new RecordRefusal($cannot); + } $payload = $this->schema->blank; $identityKey = $this->schema->identityKey; @@ -997,13 +1026,16 @@ public function addRecord(): ?int $projectRoot, fn(string $root): mixed => ($this->schema->makeBlank)($name, $root), ); - } catch (Throwable) { - return null; + } catch (RecordRefusal $refusal) { + // The factory's own reason, such as what the project lacks. + throw $refusal; + } catch (Throwable $failure) { + throw new RecordRefusal(sprintf('A new %s could not be made: %s', $this->schema->entryNoun, $failure->getMessage()), previous: $failure); } // A data record's blank is its data; a constructor list's, the object. if ($this->schema->recordClass !== null ? ! is_array($payload) : ! is_object($payload)) { - return null; + throw new RecordRefusal($cannot); } } @@ -1019,7 +1051,7 @@ public function addRecord(): ?int // Never append what the save merge would drop: a blank that is not // a member of its own category vanishes on save, after the editor // said it was created. - return null; + throw new RecordRefusal($cannot); } $sourcePath = null; @@ -1728,6 +1760,31 @@ private function rereadBackingFile(): void } $this->file = PhpDataFile::load($this->file->path, $this->projectRoot); + $this->learnKeyOrder(); + } + + /** + * Learns where a projected file holds its top-level keys: a key seen for + * the first time goes ahead of the next key the file holds after it that + * is already known, and a known key keeps the place it was first seen in. + */ + private function learnKeyOrder(): void + { + if ($this->schema->projection === null || ! is_array($this->file?->payload)) { + return; + } + + $keys = array_keys($this->file->payload); + + foreach ($keys as $position => $key) { + if (in_array($key, $this->authoredKeyOrder, true)) { + continue; + } + + $anchor = array_find(array_slice($keys, $position + 1), fn(int|string $next): bool => in_array($next, $this->authoredKeyOrder, true)); + $at = $anchor === null ? count($this->authoredKeyOrder) : (int) array_search($anchor, $this->authoredKeyOrder, true); + array_splice($this->authoredKeyOrder, $at, 0, [$key]); + } } /** @@ -1768,6 +1825,46 @@ public function projectRoot(): ?string return $this->projectRoot; } + /** + * Puts each top-level key a fold added back where the file had it when + * first read: ahead of the next key it preceded then that the payload + * still holds. Keys the payload already held keep their order, and a key + * the file never had stays where the fold put it. + * + * @param array $before The payload before the fold. + * @param array $folded The payload after it. + * @return array + */ + private function placeReturningKeys(array $before, array $folded): array + { + $added = array_diff_key($folded, $before); + + if ($added === [] || $this->authoredKeyOrder === []) { + return $folded; + } + + $placed = array_diff_key($folded, $added); + + foreach ($added as $key => $value) { + $position = array_search($key, $this->authoredKeyOrder, true); + $anchor = $position === false ? null : array_find( + array_slice($this->authoredKeyOrder, $position + 1), + static fn(int|string $next): bool => array_key_exists($next, $placed), + ); + + if ($anchor === null) { + $placed[$key] = $value; + + continue; + } + + $at = (int) array_search($anchor, array_keys($placed), true); + $placed = array_slice($placed, 0, $at, true) + [$key => $value] + array_slice($placed, $at, null, true); + } + + return $placed; + } + /** * Folds this category's records into a payload without writing. * @@ -1791,10 +1888,10 @@ public function projectRoot(): ?string public function foldInto(array $whole): array { if ($this->schema->projection !== null) { - return $this->schema->projection->write($whole, array_map( + return $this->placeReturningKeys($whole, $this->schema->projection->write($whole, array_map( static fn(ProjectRecord $record): array => (array) $record->toArray(), $this->getRecords(), - )); + ))); } $records = $this->getRecords(); diff --git a/src/Database/RecordAuthoring.php b/src/Database/RecordAuthoring.php index 0f9a51b..c23a20e 100644 --- a/src/Database/RecordAuthoring.php +++ b/src/Database/RecordAuthoring.php @@ -344,17 +344,14 @@ public function removeOption(ProjectRecordDatabase $database, int $index, array * Creates a blank record at the end of the category. Its index is the * change's. * - * @throws RecordRefusal When the category is read-only or takes no new records. + * @throws RecordRefusal When the category is read-only, takes no new records, or the project lacks what one needs. */ public function createRecord(ProjectRecordDatabase $database): RecordChange { $this->requireEditable($database); - $index = $database->addRecord(); - $record = $index === null ? null : $database->getRecordByIndex($index); - - if ($index === null || $record === null) { - throw new RecordRefusal(sprintf('%s entries cannot be created from the editor.', ucfirst($database->schema->entryNoun))); - } + $index = $database->requireNewRecord(); + $record = $database->getRecordByIndex($index) + ?? throw new RecordRefusal(sprintf('%s entries cannot be created from the editor.', ucfirst($database->schema->entryNoun))); return new RecordChange(new GenericCommand( sprintf('%s create', ucfirst($database->schema->entryNoun)), diff --git a/src/Database/RecordSchema.php b/src/Database/RecordSchema.php index 9a557cc..1ec695c 100644 --- a/src/Database/RecordSchema.php +++ b/src/Database/RecordSchema.php @@ -31,8 +31,8 @@ * @param string|null $listPayloadKey When set, the file returns the sub-list bare (event scripts). * @param string[] $configPath The config.php subtree roots, for CONFIG_SUBTREE storage. * @param Closure(mixed): bool|null $recordFilter Selects which of a shared file's entries belong here. - */ - /** + * @param Closure(string, string): mixed|null $makeBlank Makes a new record from its name and the project root; + * it throws a RecordRefusal saying what the project lacks when it cannot. * @param RecordProjection|null $projection How this category's records are * read out of, and folded back into, a file that is not simply a list of * them -- one list inside a catalogue that holds several, or the nested diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index f9ec90a..a7f2f58 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -1192,9 +1192,9 @@ private static function enemies(): RecordSchema ], // A new enemy needs a real sprite to load, so it starts on the // project's first one; a project with no enemy sprites cannot - // author an enemy yet, and creation refuses rather than writing a - // record the engine would reject. - makeBlank: static function (string $name, string $projectRoot): ?array { + // author an enemy yet, and creation refuses, saying so, rather + // than writing a record the engine would reject. + makeBlank: static function (string $name, string $projectRoot): array { $spriteDirectory = rtrim($projectRoot, DIRECTORY_SEPARATOR) . '/assets/Graphics/Enemies'; $sprites = is_dir($spriteDirectory) ? array_values(array_filter( @@ -1206,7 +1206,7 @@ private static function enemies(): RecordSchema : []; if ($sprites === []) { - return null; + throw new RecordRefusal('A new enemy starts on the project\'s first enemy sprite. Add one under assets/Graphics/Enemies first.'); } return [ diff --git a/tests/Unit/Cutscenes/ArraySourceWriterTest.php b/tests/Unit/Cutscenes/ArraySourceWriterTest.php index 4324766..05d45c1 100644 --- a/tests/Unit/Cutscenes/ArraySourceWriterTest.php +++ b/tests/Unit/Cutscenes/ArraySourceWriterTest.php @@ -379,3 +379,45 @@ function rewriteAuthored(callable $change): string expect($seen)->toBeGreaterThan(0); })->group('engine'); + +it('puts a keyed entry back where the new value orders it, so a removal and its undo leave the bytes', function () { + $source = " 'armor',\n // The armor's type.\n 'equipmentType' => 'Shield',\n 'price' => 10,\n 'inline' => ['type' => 'text', 'name' => '', 'text' => 'A note.'],\n];\n"; + $full = evaluateSource($source); + $without = $full; + unset($without['equipmentType'], $without['inline']['name']); + + $removed = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $full, $without); + $restored = ArraySourceWriter::rewrite($removed, $without, $full); + + expect($removed->source)->toBe(" 'armor',\n 'price' => 10,\n 'inline' => ['type' => 'text', 'text' => 'A note.'],\n];\n") + ->and(evaluateSource($restored->source))->toBe($full) + // The comment went with its entry, so only that is not back. + ->and($restored->source)->toBe(str_replace(" // The armor's type.\n", '', $source)); +}); + +it('cuts the entries of an array written on one line with the separators that joined them', function (array $remove, string $expected, bool $restoresBytes) { + $source = " 'text', 'name' => '', 'text' => 'A note.']];\n"; + $old = evaluateSource($source); + $new = $old; + + foreach ($remove as $key) { + unset($new[0][$key]); + } + + $rewritten = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new); + + expect($rewritten->source)->toBe("and(evaluateSource($rewritten->source))->toBe($new); + + // Put back, the line reads as it did; an array left empty has no line + // to follow, so it opens onto lines of its own. + $restored = ArraySourceWriter::rewrite($rewritten, $new, $old)->source; + + expect(evaluateSource($restored))->toBe($old) + ->and($restored === $source)->toBe($restoresBytes); +})->with([ + 'the first' => [['type'], "['name' => '', 'text' => 'A note.']", true], + 'one between' => [['name'], "['type' => 'text', 'text' => 'A note.']", true], + 'the last two' => [['name', 'text'], "['type' => 'text']", true], + 'every one' => [['type', 'name', 'text'], '[]', false], +]); diff --git a/tests/Unit/Cutscenes/LastLegendSummonVerificationTest.php b/tests/Unit/Cutscenes/LastLegendSummonVerificationTest.php index e2cacd8..c6952fa 100644 --- a/tests/Unit/Cutscenes/LastLegendSummonVerificationTest.php +++ b/tests/Unit/Cutscenes/LastLegendSummonVerificationTest.php @@ -109,7 +109,7 @@ $workspace = getEditorProperty($editor, 'workspace'); $actor = $workspace->actorDatabase->getActors()[0]; $actorName = $actor->getName(); - $skill = $workspace->skillDatabase->getSkills()[0]->getName(); + $skill = $workspace->getSkillNames()[0]; // 1.–2. Create, name, link a real battle action. setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_LIST); diff --git a/tests/Unit/DurableSourceIdentityTest.php b/tests/Unit/DurableSourceIdentityTest.php deleted file mode 100644 index 9ca8dc9..0000000 --- a/tests/Unit/DurableSourceIdentityTest.php +++ /dev/null @@ -1,552 +0,0 @@ - - */ -function identityCategories(string $root): array -{ - $open = []; - - foreach (['items', 'weapons', 'armors'] as $category) { - $open[$category] = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category)); - } - - return $open; -} - -/** - * Returns the stable ids each inventory category holds, read fresh from disk. - * - * @return array - */ -function identityIds(string $root): array -{ - $ids = []; - - foreach (['items', 'weapons', 'armors'] as $category) { - $ids[$category] = array_map( - static fn($record): string => strval($record->get('id')), - ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category))->getRecords(), - ); - } - - return $ids; -} - -/** - * Returns the ids of a category as an open database currently lists them. - * - * @return string[] - */ -function listedIds(ProjectRecordDatabase $database): array -{ - return array_map(static fn($record): string => strval($record->get('id')), $database->getRecords()); -} - -/** - * Returns the price the file now declares for one stable id, read fresh. - */ -function priceOnDisk(string $root, string $category, string $id): mixed -{ - foreach (ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category))->getRecords() as $record) { - if ($record->get('id') === $id) { - return $record->get('price'); - } - } - - return null; -} - -/** - * Returns the authored bytes of the entry declaring one stable id. - * - * @return string|null The entry's source, or null when the file lacks it. - */ -function entryBytesFor(string $path, string $id): ?string -{ - $document = PhpSourceDocument::parse((string) file_get_contents($path)); - - for ($index = 0; $index < $document->entryCount(); $index++) { - $source = $document->entrySource($index); - - if ($source !== null && preg_match("/\\bid:\\s*'" . preg_quote($id, '/') . "'/", $source) === 1) { - return $source; - } - } - - return null; -} - -/** - * Returns the index of a record with one stable id in an open database. - */ -function indexOfId(ProjectRecordDatabase $database, string $id): int -{ - $index = array_search($id, listedIds($database), true); - - if ($index === false) { - throw new RuntimeException("{$id} is not listed."); - } - - return $index; -} - -/** - * The review's exact sequence, in one category, with a sibling left dirty - * throughout: delete the middle of A, B, C; save; undo; save; edit the - * restored B; save; reload. - */ -function runRestoreSequence(string $category, string $sibling): array -{ - $project = identityProject(); - - if ($project === null) { - return []; - } - - [$root, $path] = $project; - $open = identityCategories($root); - $before = identityIds($root); - [$a, $b, $c] = $before[$category]; - $bytesBefore = [ - 'a' => entryBytesFor($path, $a), - 'c' => entryBytesFor($path, $c), - ]; - - // The sibling is dirty for the whole sequence and never saved during it. - $open[$sibling]->setField(0, 'price', '999'); - - $removed = $open[$category]->removeRecord(1); - $open[$category]->save(); - $afterDelete = identityIds($root)[$category]; - - $open[$category]->insertRecord(1, $removed); - $open[$category]->save(); - $afterUndo = identityIds($root)[$category]; - - $open[$category]->setField(1, 'price', '4242'); - $open[$category]->save(); - - return [ - 'root' => $root, - 'path' => $path, - 'open' => $open, - 'ids' => [$a, $b, $c], - 'before' => $before, - 'afterDelete' => $afterDelete, - 'afterUndo' => $afterUndo, - 'bytesBefore' => $bytesBefore, - 'restored' => $removed, - ]; -} - -it('finds a restored record by its id and edits it, not its neighbour', function (string $category, string $sibling) { - $run = runRestoreSequence($category, $sibling); - - if ($run === []) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$a, $b, $c] = $run['ids']; - $before = $run['before']; - $path = $run['path']; - $root = $run['root']; - - // The delete removed exactly B; the undo put exactly B back, between A - // and C in the file as well as in the list; and the edit landed on B. - expect($run['afterDelete'])->toBe(array_values(array_diff($before[$category], [$b]))) - ->and($run['afterUndo'])->toBe($before[$category]) - ->and(priceOnDisk($root, $category, $b))->toBe(4242) - ->and(entryBytesFor($path, $a))->toBe($run['bytesBefore']['a']) - ->and(entryBytesFor($path, $c))->toBe($run['bytesBefore']['c']); - - // The reload lists every category exactly as it did, with only B changed. - $reloaded = identityIds($root); - - foreach (['items', 'weapons', 'armors'] as $each) { - expect($reloaded[$each])->toBe($before[$each], "{$each} changed."); - } - - // The sibling is still dirty with its own edit and nothing of it was - // written. - expect($run['open'][$sibling]->isDirty())->toBeTrue() - ->and(priceOnDisk($root, $sibling, $before[$sibling][0]))->not->toBe(999); -})->with([ - ['items', 'weapons'], - ['weapons', 'armors'], - ['armors', 'items'], -])->group('engine'); - -it('redoes the delete after the restored save, and undoes it again, by id', function (string $category) { - $run = runRestoreSequence($category, $category === 'items' ? 'armors' : 'items'); - - if ($run === []) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$a, $b, $c] = $run['ids']; - $root = $run['root']; - $path = $run['path']; - $database = $run['open'][$category]; - - // Redo: the delete of B again, then save. Only B leaves. - $database->removeRecord(indexOfId($database, $b)); - $database->save(); - - expect(identityIds($root)[$category])->toBe(array_values(array_diff($run['before'][$category], [$b]))) - ->and(entryBytesFor($path, $a))->toBe($run['bytesBefore']['a']) - ->and(entryBytesFor($path, $c))->toBe($run['bytesBefore']['c']); - - // Undo once more: B returns between A and C, still carrying its edit. - $database->insertRecord(1, $run['restored']); - $database->save(); - - expect(identityIds($root)[$category])->toBe($run['before'][$category]) - ->and(priceOnDisk($root, $category, $b))->toBe(4242) - ->and(entryBytesFor($path, $a))->toBe($run['bytesBefore']['a']) - ->and(entryBytesFor($path, $c))->toBe($run['bytesBefore']['c']) - ->and(ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category))->isDirty())->toBeFalse(); -})->with(['items', 'weapons', 'armors'])->group('engine'); - -it('composes the restore sequence in one category with structural edits in a sibling', function (string $category, string $sibling) { - $project = identityProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $open = identityCategories($root); - $before = identityIds($root); - [$a, $b, $c] = $before[$category]; - $siblingIds = $before[$sibling]; - - // The sibling removes its first record and adds one, and saves in the - // middle of the category's sequence. - $removedSibling = strval($open[$sibling]->removeRecord(0)?->get('id')); - $addedIndex = $open[$sibling]->addRecord(); - $addedSibling = strval($open[$sibling]->getRecordByIndex($addedIndex)?->get('id')); - - $removed = $open[$category]->removeRecord(1); - $open[$category]->save(); - $open[$sibling]->save(); - $open[$category]->insertRecord(1, $removed); - $open[$category]->save(); - $open[$category]->setField(1, 'price', '4242'); - $open[$category]->save(); - - $after = identityIds($root); - - expect($after[$category])->toBe($before[$category]) - ->and($after[$sibling])->toBe([...array_values(array_diff($siblingIds, [$removedSibling])), $addedSibling]) - ->and(priceOnDisk($root, $category, $b))->toBe(4242) - ->and(priceOnDisk($root, $category, $a))->toBe(priceOnDisk($root, $category, $a)) - ->and(entryBytesFor($path, $c))->not->toBeNull(); - - // The third category was never touched. - $third = array_values(array_diff(['items', 'weapons', 'armors'], [$category, $sibling]))[0]; - expect($after[$third])->toBe($before[$third]); -})->with([ - ['items', 'weapons'], - ['items', 'armors'], - ['weapons', 'armors'], - ['weapons', 'items'], - ['armors', 'items'], - ['armors', 'weapons'], -])->group('engine'); - -it('places every kind of change by id in one Save All: edit, add, delete, and restore across siblings', function () { - $project = identityProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = identityIds($root); - - $editor = createEditorForTesting($root); - setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); - setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]); - - /** @var ProjectWorkspace $workspace */ - $workspace = getEditorProperty($editor, 'workspace'); - $items = $workspace->getRecordDatabase('items'); - $weapons = $workspace->getRecordDatabase('weapons'); - $armors = $workspace->getRecordDatabase('armors'); - - // Round one: delete the middle item and the first weapon; save all. - $middleItem = $before['items'][1]; - $firstWeapon = $before['weapons'][0]; - $removedItem = $items->removeRecord(1); - $weapons->removeRecord(0); - callEditorMethod($editor, 'saveAllAssets'); - - expect(identityIds($root)['items'])->toBe(array_values(array_diff($before['items'], [$middleItem]))) - ->and(identityIds($root)['weapons'])->toBe(array_values(array_diff($before['weapons'], [$firstWeapon]))); - - // Round two, all at once: restore the item, add an armor, edit a weapon. - $items->insertRecord(1, $removedItem); - $addedIndex = $armors->addRecord(); - $addedArmor = strval($armors->getRecordByIndex($addedIndex)?->get('id')); - $editedWeapon = $before['weapons'][1]; - $weapons->setField(indexOfId($weapons, $editedWeapon), 'price', '5150'); - callEditorMethod($editor, 'saveAllAssets'); - - $after = identityIds($root); - - expect($after['items'])->toBe($before['items']) - ->and($after['weapons'])->toBe(array_values(array_diff($before['weapons'], [$firstWeapon]))) - ->and($after['armors'])->toBe([...$before['armors'], $addedArmor]) - ->and(priceOnDisk($root, 'weapons', $editedWeapon))->toBe(5150) - ->and($workspace->hasUnsavedChanges())->toBeFalse(); -})->group('engine'); - -it('refuses to write two entries sharing one id, and changes nothing', function () { - $project = identityProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = (string) file_get_contents($path); - $open = identityCategories($root); - $items = $open['items']; - $ids = listedIds($items); - - // Two records now declare one id: the second is a copy of the first, - // added as the runtime would refuse to load it. - $index = $items->addRecord(); - $duplicate = new ReflectionProperty($items->getRecordByIndex($index), 'payload'); - $duplicate->setValue($items->getRecordByIndex($index), $items->getRecordByIndex(0)?->toArray()); - - expect(static fn() => $items->save())->toThrow(SourceIdentityConflict::class, $ids[0]); - - // No byte written, still dirty, and the records still list what they - // listed -- the file will be addressable again once the copy is gone. - expect((string) file_get_contents($path))->toBe($before) - ->and($items->isDirty())->toBeTrue() - ->and(listedIds($items))->toBe([...$ids, $ids[0]]); - - $items->removeRecord($index); - $items->save(); - - expect((string) file_get_contents($path))->toBe($before); -})->group('engine'); - -it('refuses to address an entry the file holds twice, and leaves both alone', function () { - $project = identityProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $source = (string) file_get_contents($path); - $document = PhpSourceDocument::parse($source); - - // The file, as authored by hand, holds the second item twice. - $second = $document->entrySource(1); - $doubled = substr_replace($source, $second . ",\n " . $second, strpos($source, (string) $second), strlen((string) $second)); - file_put_contents($path, $doubled); - $doubledBytes = (string) file_get_contents($path); - - $items = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('items')); - $ids = listedIds($items); - $twice = $ids[1]; - - expect($ids[2])->toBe($twice); - - // Editing one of the two: which entry? Refused, with the id named. - $items->setField(1, 'price', '4242'); - expect(static fn() => $items->save())->toThrow(SourceIdentityConflict::class, $twice); - expect((string) file_get_contents($path))->toBe($doubledBytes); - - // Deleting one of the two: which entry? Refused too. - $items->setField(1, 'price', strval($items->getRecordByIndex(2)?->get('price'))); - $items->removeRecord(1); - expect(static fn() => $items->save())->toThrow(SourceIdentityConflict::class, $twice); - expect((string) file_get_contents($path))->toBe($doubledBytes); - - // A record the file holds once is still edited on its own. - $fresh = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('items')); - $fresh->setField(0, 'price', '4242'); - $fresh->save(); - - expect(priceOnDisk($root, 'items', $ids[0]))->toBe(4242) - ->and(substr_count((string) file_get_contents($path), "id: '{$twice}'"))->toBe(2); -})->group('engine'); - -it('refuses to address entries that declare no identity rather than guessing', function () { - $project = identityProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = (string) file_get_contents($path); - $items = RecordSchemaCatalog::forKey('items'); - - // The same category, addressed by a key its entries never declare: no - // entry can be told from another, so no entry may be written to. - $unaddressable = new \Ichiloto\Editor\Database\RecordSchema( - key: $items->key, - entryNoun: $items->entryNoun, - storage: $items->storage, - relativePath: $items->relativePath, - fields: $items->fields, - labelKey: $items->labelKey, - identityKey: 'serial', - recordFilter: $items->recordFilter, - makeBlank: $items->makeBlank, - ); - $database = ProjectRecordDatabase::fromProject($root, $unaddressable); - $database->setField(0, 'price', '4242'); - - expect(static fn() => $database->save())->toThrow(SourceIdentityConflict::class, 'serial'); - expect((string) file_get_contents($path))->toBe($before) - ->and($database->isDirty())->toBeTrue(); -})->group('engine'); - -it('changes no byte and no identity when the grouped write fails', function () { - $project = identityProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = (string) file_get_contents($path); - $open = identityCategories($root); - $ids = identityIds($root); - - $removedItem = $open['items']->removeRecord(1); - $open['weapons']->setField(0, 'price', '4242'); - $open['armors']->addRecord(); - - chmod($root . '/assets/Data', 0o555); - set_error_handler(static fn(): bool => true, E_WARNING); - - try { - expect(static fn() => SharedFileTransaction::commit(array_values($open)))->toThrow(RuntimeException::class); - } finally { - restore_error_handler(); - chmod($root . '/assets/Data', 0o755); - } - - // Nothing written, everything still dirty. - expect((string) file_get_contents($path))->toBe($before) - ->and($open['items']->isDirty())->toBeTrue() - ->and($open['weapons']->isDirty())->toBeTrue() - ->and($open['armors']->isDirty())->toBeTrue(); - - // And the identities the categories address by are exactly as they - // were: putting the item back and saving now finds its entry still in - // the file and writes only what changed since. - $open['items']->insertRecord(1, $removedItem); - SharedFileTransaction::commit(array_values($open)); - - $after = identityIds($root); - - expect($after['items'])->toBe($ids['items']) - ->and(priceOnDisk($root, 'weapons', $ids['weapons'][0]))->toBe(4242) - ->and(count($after['armors']))->toBe(count($ids['armors']) + 1) - ->and(entryBytesFor($path, $ids['items'][1]))->toBe(entryBytesFor($before === '' ? $path : $path, $ids['items'][1])); -})->group('engine'); - -it('writes nothing, and takes one backup per file, when a grouped save has nothing to do', function () { - $project = identityProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $open = identityCategories($root); - - // Removed and put back before any save: the file never changed and the - // record is recognised again by its id, so there is nothing to write. - $removed = $open['items']->removeRecord(1); - $open['items']->insertRecord(1, $removed); - $open['weapons']->setField(0, 'price', strval($open['weapons']->getRecordByIndex(0)?->get('price'))); - - $before = (string) file_get_contents($path); - $mtime = filemtime($path); - touch($path, $mtime - 100); - clearstatcache(); - $stamped = filemtime($path); - - expect(SharedFileTransaction::commit(array_values($open)))->toBeFalse(); - clearstatcache(); - - expect((string) file_get_contents($path))->toBe($before) - ->and(filemtime($path))->toBe($stamped); - - // Through Save All, one file, one backup, however many categories. - $config = json_decode((string) file_get_contents($root . '/ichiloto.json'), true); - $config['editor'] = ['backups' => ['enabled' => true, 'retain' => 5]]; - file_put_contents($root . '/ichiloto.json', json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - $editor = createEditorForTesting($root); - setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); - setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]); - - /** @var ProjectWorkspace $workspace */ - $workspace = getEditorProperty($editor, 'workspace'); - $workspace->getRecordDatabase('items')?->setField(0, 'price', '4242'); - $workspace->getRecordDatabase('weapons')?->setField(0, 'price', '4243'); - $workspace->getRecordDatabase('armors')?->removeRecord(0); - callEditorMethod($editor, 'saveAllAssets'); - - $backups = glob($root . '/' . \Ichiloto\Editor\Backup\BackupSettings::DEFAULT_DIRECTORY . '/assets/Data/items.php.*.bak') ?: []; - - expect($backups)->toHaveCount(1) - ->and((string) file_get_contents($backups[0]))->toBe($before) - ->and(priceOnDisk($root, 'items', identityIds($root)['items'][0]))->toBe(4242) - ->and(priceOnDisk($root, 'weapons', identityIds($root)['weapons'][0]))->toBe(4243); -})->group('engine'); diff --git a/tests/Unit/SaveIntegrityTest.php b/tests/Unit/SaveIntegrityTest.php index fb99779..41f145f 100644 --- a/tests/Unit/SaveIntegrityTest.php +++ b/tests/Unit/SaveIntegrityTest.php @@ -319,7 +319,6 @@ function hashTree(string $directory): array } $workspace->actorDatabase->save(); - $workspace->skillDatabase->save(); foreach ($workspace->recordDatabases as $database) { if ($database->isEditable()) { diff --git a/tests/Unit/Session/DatabaseCoverageSessionTest.php b/tests/Unit/Session/DatabaseCoverageSessionTest.php new file mode 100644 index 0000000..2e8e386 --- /dev/null +++ b/tests/Unit/Session/DatabaseCoverageSessionTest.php @@ -0,0 +1,136 @@ +readDatabaseRecord($category, $index); + // References are read in the context of a map. + $map = $session->describeMaps()[0]['id']; + + foreach ($read['rows'] as $row) { + $value = match ($row['kind']) { + 'reference' => array_values(array_diff( + array_column($session->listReferences($map, $row['reference']), 'value'), + [$row['value']], + ))[0] ?? null, + 'text' => ($row['value'] ?? '') === '' ? null : $row['value'] . ' Revised', + 'integer' => is_numeric($row['raw'] ?? null) ? (string) ((int) $row['raw'] + 1) : null, + 'float' => is_numeric($row['raw'] ?? null) ? (string) ((float) $row['raw'] + 0.5) : null, + 'boolean' => ($row['raw'] ?? $row['value']) === 'true' ? 'false' : 'true', + 'options' => array_values(array_diff($row['options'] ?? [], [$row['value']]))[0] ?? null, + default => null, + }; + + if ($value === null || ! isset($row['key'])) { + continue; + } + + try { + if (($session->applyDatabaseRecord($category, $index, $row['key'], $value)['changed'] ?? false) === true) { + return true; + } + } catch (SessionRefusal) { + continue; + } + } + + // A list's heading offers its first entry, as the GUI's "+ element" does. + $heading = array_find($read['rows'], static fn(array $row): bool => ($row['listHeading'] ?? false) === true && isset($row['key'])); + + if ($heading !== null) { + return $session->addDatabaseItem($category, $index, $heading['key'], child: true)['changed']; + } + + return ($read['listNoun'] ?? null) !== null + && $session->addDatabaseItem($category, $index, ['frame' => []])['changed']; +} + +it('opens every category, and creates, edits, saves and undoes in each writable one back to the bytes it read', function () { + $root = makeTemporaryProject(); + // A new enemy starts on the project's first enemy sprite. + mkdir($root . '/assets/Graphics/Enemies', 0o777, true); + file_put_contents($root . '/assets/Graphics/Enemies/blob.txt', "(oo)\n"); + $session = EditorSession::open($root); + $writable = []; + + foreach ($session->describeProject()['databases'] as $category) { + $key = $category['key']; + $list = $session->listDatabaseRecords($key); + + foreach (array_keys($list['records']) as $index) { + expect($session->readDatabaseRecord($key, $index)['rows'])->toBeArray(); + } + + if (! $list['editable']) { + continue; + } + + if ($list['records'] === [] && $list['canCreate']) { + $created = $session->createDatabaseRecord($key); + + expect($session->saveDatabase($key)['saved'])->toBeTrue() + ->and(EditorSession::open($root)->listDatabaseRecords($key)['records'])->toBe($created['records']); + } + + $writable[] = $key; + } + + $before = sourceHashTree($root); + $roundTripped = []; + + foreach ($writable as $key) { + $read = $session->readDatabaseRecord($key, 0)['rows']; + + if (! editRecordOnce($session, $key, 0)) { + continue; + } + + expect($session->saveDatabase($key)['saved'])->toBeTrue() + ->and(sourceHashTree($root))->not->toBe($before); + + $session->undo(); + $session->saveDatabase($key); + + $after = sourceHashTree($root); + + // Every file it read holds its bytes again. A category over defaults + // alone (a project with no system.php) has its file now, written by + // the save before the undo and holding what it read. + expect(array_intersect_key($after, $before))->toBe($before, sprintf('%s did not save back to the bytes it read.', $key)) + ->and(count($after) - count($before))->toBeLessThanOrEqual(1) + ->and(EditorSession::open($root)->readDatabaseRecord($key, 0)['rows'])->toBe($read); + $before = $after; + $roundTripped[] = $key; + } + + // Every category the session can write took an edit and gave it back. + expect($roundTripped)->toBe($writable); +}); + +it('says what a new record needs when the project lacks it', function () { + $session = EditorSession::open(makeTemporaryProject()); + + expect(fn() => $session->createDatabaseRecord('enemies')) + ->toThrow(SessionRefusal::class, 'A new enemy starts on the project\'s first enemy sprite. Add one under assets/Graphics/Enemies first.'); +}); diff --git a/tests/Unit/SharedInventoryTransactionTest.php b/tests/Unit/SharedInventoryTransactionTest.php deleted file mode 100644 index 40e5c11..0000000 --- a/tests/Unit/SharedInventoryTransactionTest.php +++ /dev/null @@ -1,310 +0,0 @@ - - */ -function inventoryIds(string $root): array -{ - $ids = []; - - foreach (['items', 'weapons', 'armors'] as $category) { - $ids[$category] = array_map( - static fn($record): string => strval($record->get('id')), - ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category))->getRecords(), - ); - } - - return $ids; -} - -/** - * Opens all three inventory categories at once, as the editor holds them. - * - * @return array - */ -function inventoryCategories(string $root): array -{ - $open = []; - - foreach (['items', 'weapons', 'armors'] as $category) { - $open[$category] = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category)); - } - - return $open; -} - -it('preserves both structural edits in either save order', function (array $pair, bool $reversed) { - $project = inventoryTransactionProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root] = $project; - $before = inventoryIds($root); - $open = inventoryCategories($root); - $removed = []; - - foreach ($pair as $category) { - $removed[$category] = strval($open[$category]->removeRecord(0)?->get('id')); - } - - $order = $reversed ? array_reverse($pair) : $pair; - - foreach ($order as $category) { - $open[$category]->save(); - } - - $after = inventoryIds($root); - - foreach (['items', 'weapons', 'armors'] as $category) { - $expected = $before[$category]; - - if (isset($removed[$category])) { - $expected = array_values(array_diff($expected, [$removed[$category]])); - } - - // Exactly what was asked for, and nothing else, in every category. - expect($after[$category])->toBe( - $expected, - sprintf('%s is wrong after removing %s.', $category, implode(' and ', $pair)), - ); - } -})->with([ - [['items', 'weapons']], - [['items', 'armors']], - [['weapons', 'armors']], -])->with([false, true])->group('engine'); - -it('preserves a removal from all three categories at once', function () { - $project = inventoryTransactionProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = inventoryIds($root); - $source = (string) file_get_contents($path); - $open = inventoryCategories($root); - $removed = []; - - foreach ($open as $category => $database) { - $removed[$category] = strval($database->removeRecord(0)?->get('id')); - } - - foreach ($open as $database) { - $database->save(); - } - - $after = inventoryIds($root); - - foreach ($after as $category => $ids) { - expect($ids)->toBe(array_values(array_diff($before[$category], [$removed[$category]]))); - } - - // Three entries gone, and everything else spelled as the author spelled it. - preg_match('/parameterChanges:[ \t]*new[ \t]+[^\s(]+[ \t]*\(/', $source, $authoredParameterConstructor); - expect($authoredParameterConstructor)->not->toBe([]); - expect(PhpSourceDocument::parse((string) file_get_contents($path))->entryCount()) - ->toBe(PhpSourceDocument::parse($source)->entryCount() - 3) - ->and((string) file_get_contents($path))->toContain('use Ichiloto\Engine\Entities\Effects\HPRecoveryEffect;') - ->and((string) file_get_contents($path))->toContain($authoredParameterConstructor[0]); -})->group('engine'); - -it('composes a field edit in one category with a structural edit in another', function () { - $project = inventoryTransactionProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = inventoryIds($root); - $open = inventoryCategories($root); - - $open['items']->setField(0, 'price', '4321'); - $removedWeapon = strval($open['weapons']->removeRecord(0)?->get('id')); - $addedArmor = null; - $index = $open['armors']->addRecord(); - $addedArmor = strval($open['armors']->getRecordByIndex($index)?->get('id')); - - foreach (['weapons', 'armors', 'items'] as $category) { - $open[$category]->save(); - } - - $after = inventoryIds($root); - $reloadedItems = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('items')); - - expect($reloadedItems->getRecordByIndex(0)?->get('price'))->toBe(4321) - ->and($after['items'])->toBe($before['items']) - ->and($after['weapons'])->toBe(array_values(array_diff($before['weapons'], [$removedWeapon]))) - ->and($after['armors'])->toBe([...$before['armors'], $addedArmor]) - ->and((string) file_get_contents($path))->toContain('price: 4321,'); -})->group('engine'); - -it('writes one file once through Save All, with every category folded in', function () { - $project = inventoryTransactionProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = inventoryIds($root); - - $editor = createEditorForTesting($root); - setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); - setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]); - - /** @var ProjectWorkspace $workspace */ - $workspace = getEditorProperty($editor, 'workspace'); - $removedItem = strval($workspace->getRecordDatabase('items')?->removeRecord(0)?->get('id')); - $removedWeapon = strval($workspace->getRecordDatabase('weapons')?->removeRecord(0)?->get('id')); - $workspace->getRecordDatabase('armors')?->setField(0, 'price', '999'); - - callEditorMethod($editor, 'saveAllAssets'); - - $after = inventoryIds($root); - - expect($after['items'])->toBe(array_values(array_diff($before['items'], [$removedItem]))) - ->and($after['weapons'])->toBe(array_values(array_diff($before['weapons'], [$removedWeapon]))) - ->and($after['armors'])->toBe($before['armors']) - ->and((string) file_get_contents($path))->toContain('price: 999,') - ->and($workspace->hasUnsavedChanges())->toBeFalse(); -})->group('engine'); - -it('leaves every byte and every category alone when the write is refused', function () { - $project = inventoryTransactionProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = (string) file_get_contents($path); - $open = inventoryCategories($root); - $open['items']->removeRecord(0); - $open['weapons']->removeRecord(0); - - chmod($root . '/assets/Data', 0o555); - set_error_handler(static fn(): bool => true, E_WARNING); - - try { - expect(static fn() => SharedFileTransaction::commit(array_values($open))) - ->toThrow(RuntimeException::class); - - expect((string) file_get_contents($path))->toBe($before) - ->and($open['items']->isDirty())->toBeTrue() - ->and($open['weapons']->isDirty())->toBeTrue(); - } finally { - restore_error_handler(); - chmod($root . '/assets/Data', 0o755); - } -})->group('engine'); - -it('undoes and redoes exactly across an intermediate sibling save', function () { - $project = inventoryTransactionProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = inventoryIds($root); - - $editor = createEditorForTesting($root); - setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); - setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]); - callEditorMethod($editor, 'openDatabaseAtCategory', 'items'); - - $fields = array_column(callEditorMethod($editor, 'getDatabaseSettingsFields'), null, 'field'); - callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $fields['price'], '777'); - callEditorMethod($editor, 'saveAllAssets'); - - /** @var ProjectWorkspace $workspace */ - $workspace = getEditorProperty($editor, 'workspace'); - $removedWeapon = strval($workspace->getRecordDatabase('weapons')?->removeRecord(0)?->get('id')); - callEditorMethod($editor, 'saveAllAssets'); - - callEditorMethod($editor, 'performUndo'); - callEditorMethod($editor, 'saveAllAssets'); - - $undone = inventoryIds($root); - - // The price edit is undone; the sibling's removal, saved in between, - // stays removed. - expect(ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('items'))->getRecordByIndex(0)?->get('price')) - ->not->toBe(777) - ->and($undone['weapons'])->toBe(array_values(array_diff($before['weapons'], [$removedWeapon]))); - - callEditorMethod($editor, 'performRedo'); - callEditorMethod($editor, 'saveAllAssets'); - - expect(ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('items'))->getRecordByIndex(0)?->get('price')) - ->toBe(777) - ->and(inventoryIds($root)['weapons'])->toBe($undone['weapons']); -})->group('engine'); - -it('writes nothing when a grouped save has nothing to do', function () { - $project = inventoryTransactionProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = sourceHashTree($root); - $modifiedAt = filemtime($path); - $open = inventoryCategories($root); - - expect(SharedFileTransaction::commit(array_values($open)))->toBeFalse() - ->and(sourceHashTree($root))->toBe($before) - ->and(filemtime($path))->toBe($modifiedAt); -})->group('engine'); From f5a95089f009c915ab724f7ff846f10e5b3af9b8 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 20:22:34 +0200 Subject: [PATCH 164/344] feat(maps): author an encounter's battle arena, for the map and per troop A troop's encounter entry may be its weight or a map holding its weight and the battleArena its graphical battle takes place in, outranking the map's own battleArena. MapEncounters reads both, keeps an entry written as a map a map with every key it carries through renames and weight edits, and gains the map's arena as an owned key. The GUI editor's map inspector offers Arena rows for the map and each troop, picked from the battle presentation's arenas (battle_arenas). The terminal inspector keeps arenas exactly as written without offering them. Validation reports an arena, in an encounter or a start_battle command, that the presentation does not declare, or that a project without one names. --- docs/manual.md | 23 ++-- src/Database/ReferenceCatalog.php | 28 +++++ src/Field/MapEncounters.php | 131 +++++++++++++++++++--- src/Inspector/MapInspector.php | 74 ++++++++++++- src/Session/EditorSession.php | 4 +- src/Validation/ProjectValidator.php | 76 +++++++++++++ tests/Unit/EncounterArenaTest.php | 150 ++++++++++++++++++++++++++ tests/Unit/MapRuntimeMetadataTest.php | 6 +- 8 files changed, 467 insertions(+), 25 deletions(-) create mode 100644 tests/Unit/EncounterArenaTest.php diff --git a/docs/manual.md b/docs/manual.md index 4c1d039..7d56cb2 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -886,12 +886,23 @@ rows beneath it edit the engine's own `encounters` block: `;` glyphs) or `any` (every step, with danger tiles counting double). Its default, `encounter`, is likewise shown in parentheses and never written by browsing. - -A key inside the block the editor does not own - a field a later engine will -read - is preserved untouched, and keeps the block alive even when the last -troop is removed. A block shaped in a way the editor cannot hold exactly -(troops keyed by number, a weight that is an array) is shown read-only with -the shape named, and no edit anywhere rewrites it. +- In the GUI editor, `Arena` picks the battle presentation's arena a graphical + fight here takes place in, `(default arena)` leaving it to the + presentation's default, and each troop row has an `Arena` of its own that + outranks the map's, `(map arena)` leaving it to the map. A troop with an + arena is written `'Loch Ness' => ['weight' => 1, 'battleArena' => + 'arena.secret-lake']`. Arenas are graphical presentation only: they change + no weight, no fight and nothing the terminal shows, so the terminal editor + keeps them exactly as written without offering them. + +A key inside the block or a troop's entry the editor does not own - a field a +later engine will read - is preserved untouched, and keeps the block alive +even when the last troop is removed; an entry written as a map stays one. A +block shaped in a way the editor cannot hold exactly (troops keyed by number, +a weight that is an array, an arena that is not a key) is shown read-only +with the shape named, and no edit anywhere rewrites it. Validation reports an +arena, in an encounter or a `start_battle` command, that the battle +presentation does not declare, or that a project without one names. In Event mode, painting or selecting a marker with no definition opens the Event Type picker. **Story Script** creates the engine's diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index dbab859..128b2f2 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -12,6 +12,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; +use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Ichiloto\Engine\Entities\Enumerations\ArmorType; use Ichiloto\Engine\Entities\Enumerations\Occasion; @@ -90,6 +91,7 @@ final class ReferenceCatalog 'effects', 'map_regions', 'animation_roles', + 'battle_arenas', ]; /** @@ -110,6 +112,9 @@ final class ReferenceCatalog */ private ?InventoryCatalog $inventoryCatalog = null; + /** @var array|null The battle presentation's arenas, read once. */ + private ?array $arenaNames = null; + public function __construct( private readonly ProjectWorkspace $workspace, private readonly ?ProjectMap $currentMap = null, @@ -216,6 +221,9 @@ public function valuesFor(string $category): array 'event_markers' => $this->currentMap?->getEventMarkers() ?? [], // A map's kind is one of the project's tilesets, by file stem. 'tilesets' => array_keys($this->loadTilesetNames()), + // The scenes a graphical battle can take place in, by the key a + // map's encounters or a start_battle command name one with. + 'battle_arenas' => array_map(strval(...), array_keys($this->loadArenaNames())), // A region is the display name the game shows for where the party // is; choosing from the names the maps already use keeps one // region spelled one way. @@ -415,6 +423,10 @@ public function labelsFor(string $category): array return $this->loadTilesetNames(); } + if ($category === 'battle_arenas') { + return $this->loadArenaNames(); + } + if ($category === 'animation_ids') { $labels = []; foreach ($this->animationRecords() as $animation) { @@ -748,6 +760,22 @@ private function loadTilesetNames(): array return $names; } + /** + * Returns the arenas the project's battle presentation declares, key => + * display name, in authored order: none for a project without one, or + * whose presentation cannot be read, which validation reports. + * + * @return array + */ + private function loadArenaNames(): array + { + try { + return $this->arenaNames ??= BattlePresentationCatalog::load(rtrim($this->workspace->projectRoot, '/') . '/assets')?->getArenaChoices() ?? []; + } catch (\Throwable) { + return $this->arenaNames = []; + } + } + /** Returns asset-root-relative PNG choices without following paths outside the asset root. */ public static function getPngAssets(string $projectRoot): array { diff --git a/src/Field/MapEncounters.php b/src/Field/MapEncounters.php index d95e4fb..054f256 100644 --- a/src/Field/MapEncounters.php +++ b/src/Field/MapEncounters.php @@ -14,12 +14,22 @@ * * ```php * 'encounters' => [ - * 'troops' => ['Rat + Bat' => 5, 'Bat x 2' => 4], + * 'troops' => [ + * 'Rat + Bat' => 5, + * 'Loch Ness' => ['weight' => 1, 'battleArena' => 'arena.secret-lake'], + * ], * 'rate' => 22, * 'tiles' => 'encounter', + * 'battleArena' => 'arena.cryptic-ruins', * ], * ``` * + * A troop's entry is its weight, or a map holding its weight and the arena a + * graphical battle against it takes place in, which outranks the map's own + * `battleArena`; with neither, the battle presentation's default arena + * applies. Arenas are graphical presentation only: they change no weight, + * no fight and nothing the terminal shows. + * * The engine reads it loosely -- a missing `rate` means fifteen steps, a * missing `tiles` means danger tiles only, a troop with a non-positive or * unreadable weight is dropped, and a block that names no readable troop @@ -34,9 +44,10 @@ * same troop: PHP would keep the last and discard the other without a word, * so the editor refuses the edit instead. * - * Anything else inside the block -- a key a later engine will read -- is kept - * exactly as it was found. A shape this model cannot hold is refused by name - * rather than rewritten. + * Anything else inside the block or an entry -- a key a later engine will + * read -- is kept exactly as it was found, and an entry authored as a map + * stays one. A shape this model cannot hold is refused by name rather than + * rewritten. * * @package Ichiloto\Editor\Field */ @@ -54,12 +65,17 @@ final class MapEncounters /** The tile modes the engine understands. */ public const array TILE_MODES = ['encounter', 'any']; + /** The key naming a graphical battle arena, on the block and on an entry. */ + public const string ARENA_KEY = 'battleArena'; + /** The keys this model owns; everything else in the block is the author's. */ - private const array OWNED_KEYS = ['troops', 'rate', 'tiles']; + private const array OWNED_KEYS = ['troops', 'rate', 'tiles', self::ARENA_KEY]; /** * @param array|null $block The authored block, or null when the map declares none. - * @param array $rows The troop rows, in authored order. + * @param array}> $rows + * The troop rows, in authored order: each one's weight and arena, and + * the entry as authored when it was written as a map. * @param string|null $unsupported Why this block cannot be edited, or null. */ private function __construct( @@ -96,20 +112,34 @@ public static function of(mixed $block): self return new self($block, [], sprintf('the encounters troops are %s, not troop weights', get_debug_type($troops))); } + $mapArena = $block[self::ARENA_KEY] ?? null; + + if ($mapArena !== null && ! is_string($mapArena)) { + return new self($block, [], sprintf('the map\'s battleArena is %s, not an arena key', get_debug_type($mapArena))); + } + $rows = []; - foreach ($troops as $name => $weight) { + foreach ($troops as $name => $entry) { if (! is_string($name)) { // The engine skips a troop it cannot name, and the editor // cannot show a numeric key as a troop reference. return new self($block, [], sprintf('a troop is keyed by %s (%s) rather than by name', get_debug_type($name), var_export($name, true))); } + $structured = is_array($entry); + $weight = $structured ? ($entry['weight'] ?? null) : $entry; + $arena = $structured ? ($entry[self::ARENA_KEY] ?? null) : null; + if (! is_scalar($weight) && $weight !== null) { return new self($block, [], sprintf('the troop "%s" has a weight that is %s', $name, get_debug_type($weight))); } - $rows[] = ['name' => $name, 'weight' => $weight]; + if ($arena !== null && ! is_string($arena)) { + return new self($block, [], sprintf('the troop "%s" has a battleArena that is %s, not an arena key', $name, get_debug_type($arena))); + } + + $rows[] = ['name' => $name, 'weight' => $weight, 'arena' => $arena, 'entry' => $structured ? $entry : null]; } return new self($block, $rows, null); @@ -142,13 +172,24 @@ public function unsupportedReason(): ?string /** * The troop rows, in authored order. * - * @return array + * @return array}> */ public function rows(): array { return $this->rows; } + /** + * The arena the map's encounters take place in, or null when the battle + * presentation's default applies. + */ + public function mapArena(): ?string + { + $arena = $this->block[self::ARENA_KEY] ?? null; + + return is_string($arena) ? $arena : null; + } + /** * The authored rate, or null when the block leaves it to the engine. */ @@ -273,6 +314,35 @@ public function withTroopAt(int $index, string $name): ?array return $this->rebuilt($rows); } + /** + * The block with a different arena for the map's encounters; null leaves + * it to the battle presentation's default. + * + * @return array|null + */ + public function withMapArena(?string $arena): ?array + { + return $this->rebuilt($this->rows, [self::ARENA_KEY => $arena]); + } + + /** + * The block with one row's own arena changed; null leaves it to the map's. + * + * @return array|null + */ + public function withArenaAt(int $index, ?string $arena): ?array + { + $rows = $this->rows; + + if (! array_key_exists($index, $rows)) { + return $this->block; + } + + $rows[$index]['arena'] = $arena; + + return $this->rebuilt($rows); + } + /** * The block with one row's weight changed. * @@ -302,7 +372,7 @@ public function withTroopAdded(string $name, ?int $after = null, int $weight = 1 $this->assertFree($name, null); $rows = $this->rows; $position = $after === null ? count($rows) : min(count($rows), max(0, $after + 1)); - array_splice($rows, $position, 0, [['name' => $name, 'weight' => max(1, $weight)]]); + array_splice($rows, $position, 0, [['name' => $name, 'weight' => max(1, $weight), 'arena' => null, 'entry' => null]]); return $this->rebuilt($rows); } @@ -328,10 +398,12 @@ public function withTroopRemovedAt(int $index): ?array /** * Rebuilds the authored block around new rows, keeping every key this - * model does not own, and the order they were authored in. + * model does not own, and the order they were authored in. An entry + * authored as a map stays one, its other keys kept; a weight alone is + * written as a weight, and becomes a map only when it gains an arena. * - * @param array $rows - * @param array $changes Owned values to set. + * @param array}> $rows + * @param array $changes Owned values to set; null removes one. * @return array|null The block to write, or null to remove it. */ private function rebuilt(array $rows, array $changes = []): ?array @@ -350,18 +422,49 @@ private function rebuilt(array $rows, array $changes = []): ?array $troops = []; foreach ($rows as $row) { - $troops[$row['name']] = $row['weight'] ?? 1; + $troops[$row['name']] = self::entryFor($row); } $block['troops'] = $troops; foreach ($changes as $key => $value) { + if ($value === null) { + unset($block[$key]); + + continue; + } + $block[$key] = $value; } return $block; } + /** + * Writes one row as its entry: as authored, with its weight and arena. + * + * @param array{name: string, weight: mixed, arena: ?string, entry: ?array} $row + */ + private static function entryFor(array $row): mixed + { + $weight = $row['weight'] ?? 1; + + if ($row['entry'] === null && $row['arena'] === null) { + return $weight; + } + + $entry = $row['entry'] ?? []; + $entry['weight'] = $weight; + + if ($row['arena'] === null) { + unset($entry[self::ARENA_KEY]); + } else { + $entry[self::ARENA_KEY] = $row['arena']; + } + + return $entry; + } + /** * Refuses a troop another row already names. * diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index d788fe9..d8d4154 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -35,11 +35,18 @@ * cursor, a selection or a pane: the caller names the map and the event, * applies an edit through {@see apply()}, records the command it returns, * and shows an {@see InspectorRefusal}'s reason. + * + * An inspector for a graphical editor also offers what only a graphical + * renderer shows (the arena an encounter's battle takes place in). The + * terminal editor's inspector does not: those values are kept exactly as + * authored, never offered there. */ final readonly class MapInspector { public const string MAP_BGM_NONE = '(None)'; public const string MAP_KIND_NONE = 'Not set'; + public const string MAP_ARENA_NONE = '(default arena)'; + public const string ENCOUNTER_ARENA_NONE = '(map arena)'; /** A chest event's presentations, as an author picks one. */ public const array CHEST_TYPE_CHOICES = [ @@ -61,7 +68,11 @@ ['label' => 'Accessory', 'value' => LootType::ACCESSORY->value, 'description' => 'Rewards an accessory identifier.'], ]; - public function __construct(private ReferenceCatalog $references) + /** + * @param ReferenceCatalog $references The project's references as the map sees them. + * @param bool $graphical Whether the editor shows graphical presentation, and so offers it. + */ + public function __construct(private ReferenceCatalog $references, private bool $graphical = false) { } @@ -796,6 +807,26 @@ private static function describeListPath(array $path): string return mb_strtolower(rtrim($leaf, 's')); } + /** + * Reads a picked arena: one the battle presentation declares, or none. + * + * @throws InspectorRefusal When it names an arena the presentation does not declare. + */ + private function requireArena(string $value, string $none): ?string + { + $value = trim($value); + + if ($value === '' || $value === $none) { + return null; + } + + if (! in_array($value, $this->references->valuesFor('battle_arenas'), true)) { + throw new InspectorRefusal(sprintf('"%s" is not an arena the battle presentation declares.', $value)); + } + + return $value; + } + /** @throws InspectorRefusal When the map's encounters cannot be edited here. */ private function requireEditableEncounters(ProjectMap $map): MapEncounters { @@ -835,6 +866,8 @@ private function applyMapEncounterValue(ProjectMap $map, array $field, mixed $va 'tiles' => $encounters->withTiles((string) $value), 'weight' => $encounters->withWeightAt($index, (int) $value), 'troop' => $encounters->withTroopAt($index, (string) $value), + 'arena' => $encounters->withArenaAt($index, $this->requireArena((string) $value, self::ENCOUNTER_ARENA_NONE)), + 'mapArena' => $encounters->withMapArena($this->requireArena((string) $value, self::MAP_ARENA_NONE)), default => null, }; @@ -935,6 +968,19 @@ private function mapRuntimeFields(ProjectMap $map): array 'index' => $index, 'encounterList' => ['index' => $index], ]; + + if ($this->graphical) { + $fields[] = [ + 'label' => ' Arena', + 'value' => $this->describeArena($row['arena'], self::ENCOUNTER_ARENA_NONE), + 'reference' => 'battle_arenas', + 'noneLabel' => self::ENCOUNTER_ARENA_NONE, + 'target' => 'map-encounters', + 'field' => 'arena', + 'index' => $index, + 'encounterList' => ['index' => $index], + ]; + } } if ($rows === []) { @@ -958,9 +1004,35 @@ private function mapRuntimeFields(ProjectMap $map): array 'field' => 'tiles', ]; + if ($this->graphical) { + $fields[] = [ + 'label' => ' Arena', + 'value' => $this->describeArena($encounters->mapArena(), self::MAP_ARENA_NONE), + 'reference' => 'battle_arenas', + 'noneLabel' => self::MAP_ARENA_NONE, + 'target' => 'map-encounters', + 'field' => 'mapArena', + ]; + } + return $fields; } + /** + * How an arena reads on a row: its name, what applies when none is set, + * or a key the battle presentation does not declare, kept and said so. + */ + private function describeArena(?string $arena, string $none): string + { + if ($arena === null) { + return $none; + } + + $name = $this->references->labelsFor('battle_arenas')[$arena] ?? null; + + return $name === null ? $arena . ' · not an arena the battle presentation declares' : sprintf('%s (%s)', $name, $arena); + } + /** * Builds the ordered Music Variants rows: the map's conditional music, * evaluated by the engine in declaration order, first match wins. diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index c24e274..e701802 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -2093,7 +2093,9 @@ private function collectInspectorFields(ProjectMap $map, ?string $marker): array private function createMapInspector(ProjectMap $map): MapInspector { - return new MapInspector(new ReferenceCatalog($this->workspace, $map)); + // A session serves graphical editors, which also author what only a + // graphical renderer shows. + return new MapInspector(new ReferenceCatalog($this->workspace, $map), graphical: true); } /** diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 178e44d..6dac65b 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -25,6 +25,7 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; +use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Ichiloto\Engine\Core\WorldConditionType; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicScriptValidator; @@ -88,6 +89,18 @@ class ProjectValidator /** The project's effect timelines, read once per validation. */ protected ?EffectTimelineLibrary $effectLibrary = null; + /** + * The arena keys the project's battle presentation declares, read once per + * validation; null when it declares no battle presentation, or one that + * cannot be read ({@see $arenaCatalogProblem}). + * + * @var list|null + */ + protected ?array $arenaKeys = null; + + /** Why the battle presentation cannot be read, or null. */ + protected ?string $arenaCatalogProblem = null; + protected const string TRANSFER_TRIGGER = 'TransferPlayerTrigger'; protected const string SCRIPT_TRIGGER = 'ScriptEventTrigger'; @@ -101,6 +114,7 @@ public function validate(ProjectWorkspace $workspace): array { $this->projectRootForKnowledge = $workspace->projectRoot; $this->effectLibrary = new EffectTimelineLibrary($workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); + $this->readArenaKeys($workspace); $this->knowledgeCatalogData = null; $this->commonEventScripts = $this->eventScriptsById($workspace); $this->cinematicCommonEventIds = []; @@ -1883,9 +1897,67 @@ protected function checkEncounters(ProjectMap $map, array $troops): array } } + $issues = [...$issues, ...$this->checkBattleArena($encounters->mapArena(), $map->mapId, 'The encounters')]; + + foreach ($rows as $row) { + $issues = [...$issues, ...$this->checkBattleArena($row['arena'], $map->mapId, sprintf('The encounter with "%s"', $row['name']))]; + } + return [...$issues, ...$this->checkEncounterRate($map, $encounters), ...$this->checkEncounterTiles($map, $encounters), ...$this->checkEncounterTroopDuplicates($map)]; } + /** + * Reads the arena keys a battle may name, once per validation. + */ + protected function readArenaKeys(ProjectWorkspace $workspace): void + { + $this->arenaKeys = null; + $this->arenaCatalogProblem = null; + + try { + $catalog = BattlePresentationCatalog::load($workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); + $this->arenaKeys = $catalog === null ? null : array_map(strval(...), array_keys($catalog->getArenaChoices())); + } catch (Throwable $problem) { + $this->arenaCatalogProblem = $problem->getMessage(); + } + } + + /** + * Checks an arena a battle names: graphical presentation only, so it is + * checked against what the battle presentation declares, and a battle + * that names none takes the presentation's default. + * + * @return Issue[] The issues found. + */ + protected function checkBattleArena(mixed $arena, string $where, string $subject): array + { + if ($arena === null) { + return []; + } + + if (! is_string($arena) || trim($arena) === '') { + return [Issue::error($where, sprintf('%s names a battleArena that is not an arena key.', $subject), + 'Name one of the battle presentation\'s arenas, or remove it to use the default arena.')]; + } + + if ($this->arenaCatalogProblem !== null) { + return [Issue::error($where, sprintf('%s names the arena "%s", but the battle presentation cannot be read: %s', $subject, $arena, $this->arenaCatalogProblem), + sprintf('Correct assets/%s.', BattlePresentationCatalog::FILE))]; + } + + if ($this->arenaKeys === null) { + return [Issue::error($where, sprintf('%s names the arena "%s", but the project declares no battle presentation to draw it in.', $subject, $arena), + sprintf('Remove the battleArena, or declare arenas in assets/%s.', BattlePresentationCatalog::FILE))]; + } + + if (! in_array($arena, $this->arenaKeys, true)) { + return [Issue::error($where, sprintf('%s names the arena "%s", which the battle presentation does not declare.', $subject, $arena), + 'Choose one of its arenas, or remove the battleArena to use the default arena.')]; + } + + return []; + } + /** * Checks the average number of steps between fights. * @@ -3670,6 +3742,10 @@ protected function checkBattleContinuation(array $command, string $where): array $issues[] = Issue::error($where, 'A start_battle command names no troop.', 'Choose a configured troop.'); } + if (array_key_exists(MapEncounters::ARENA_KEY, $command)) { + $issues = [...$issues, ...$this->checkBattleArena($command[MapEncounters::ARENA_KEY] ?? '', $where, 'A start_battle command')]; + } + if ( array_key_exists('resultVariable', $command) && (! is_string($command['resultVariable']) || trim($command['resultVariable']) === '') diff --git a/tests/Unit/EncounterArenaTest.php b/tests/Unit/EncounterArenaTest.php new file mode 100644 index 0000000..374a427 --- /dev/null +++ b/tests/Unit/EncounterArenaTest.php @@ -0,0 +1,150 @@ + str_replace(" 'triggers' => [],", <<<'PHP' + 'triggers' => [], + 'encounters' => [ + 'troops' => [ + // The lake's own fight. + 'Pair' => ['weight' => 2, 'battleArena' => 'arena.road', 'note' => 'kept'], + ], + 'rate' => 20, + ], +PHP, $source)); + + return $root; +} + +/** The inspector row whose key names a field (and, for a troop row, its index). */ +function encounterRow(array $read, string $field): array +{ + return array_find($read['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $field) + ?? throw new RuntimeException("No inspector row {$field}."); +} + +it('reads structured entries and keeps what they carry through every edit', function () { + $encounters = MapEncounters::of([ + 'troops' => [ + 'Rats' => 5, + 'Loch Ness' => ['weight' => 1, 'battleArena' => 'arena.lake', 'note' => 'kept'], + ], + 'battleArena' => 'arena.ruins', + ]); + + expect($encounters->isSupported())->toBeTrue() + ->and($encounters->mapArena())->toBe('arena.ruins') + ->and(array_column($encounters->rows(), 'arena'))->toBe([null, 'arena.lake']) + ->and(array_column($encounters->rows(), 'weight'))->toBe([5, 1]) + // A rename and a weight keep the entry a map, with its other keys, in place. + ->and($encounters->withTroopAt(1, 'Nessie')['troops']) + ->toBe(['Rats' => 5, 'Nessie' => ['weight' => 1, 'battleArena' => 'arena.lake', 'note' => 'kept']]) + ->and($encounters->withWeightAt(1, 3)['troops']['Loch Ness'])->toBe(['weight' => 3, 'battleArena' => 'arena.lake', 'note' => 'kept']) + // A weight alone gains a map only with an arena; an arena cleared keeps the rest. + ->and($encounters->withArenaAt(0, 'arena.cave')['troops']['Rats'])->toBe(['weight' => 5, 'battleArena' => 'arena.cave']) + ->and($encounters->withArenaAt(1, null)['troops']['Loch Ness'])->toBe(['weight' => 1, 'note' => 'kept']) + ->and($encounters->withMapArena(null))->not->toHaveKey('battleArena') + ->and($encounters->withMapArena('arena.cave')['battleArena'])->toBe('arena.cave') + // The last row takes the map's arena with it, as it takes the rate. + ->and(MapEncounters::of(['troops' => ['Rats' => 5], 'battleArena' => 'arena.ruins'])->withTroopRemovedAt(0))->toBeNull(); +}); + +it('refuses an arena it cannot hold, saying where', function (array $block, string $reason) { + $encounters = MapEncounters::of($block); + + expect($encounters->isSupported())->toBeFalse() + ->and($encounters->unsupportedReason())->toContain($reason); +})->with([ + 'an entry\'s arena' => [['troops' => ['Rats' => ['weight' => 5, 'battleArena' => 7]]], 'the troop "Rats" has a battleArena that is int'], + 'the map\'s arena' => [['troops' => ['Rats' => 5], 'battleArena' => ['x']], 'the map\'s battleArena is array'], + 'an entry\'s weight' => [['troops' => ['Rats' => ['weight' => [5]]]], 'the troop "Rats" has a weight that is array'], +]); + +it('offers the arenas in a graphical editor, writing only what changed', function () { + $root = encounterArenaProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $session = EditorSession::open($root); + $read = $session->readInspector('test-map'); + + expect(encounterRow($read, 'arena'))->toMatchArray(['kind' => 'reference', 'reference' => 'battle_arenas', 'noneLabel' => '(map arena)', 'value' => 'Road (arena.road)']) + ->and(encounterRow($read, 'mapArena'))->toMatchArray(['kind' => 'reference', 'noneLabel' => '(default arena)', 'value' => '(default arena)']) + ->and($session->listReferences('test-map', 'battle_arenas'))->toBe([['value' => 'arena.road', 'label' => 'Road'], ['value' => 'arena.cave', 'label' => 'Cave']]); + + // A weight edit changes the weight and nothing else of the entry. + $before = (string) file_get_contents($path); + $session->applyInspector('test-map', $read['revision'], encounterRow($read, 'weight')['key'], '4'); + $session->saveMap('test-map'); + + expect((string) file_get_contents($path))->toBe(str_replace("['weight' => 2,", "['weight' => 4,", $before)); + + // The map's arena, then the entry's cleared to the map's. + $read = $session->readInspector('test-map'); + $session->applyInspector('test-map', $read['revision'], encounterRow($read, 'mapArena')['key'], 'arena.cave'); + $read = $session->readInspector('test-map'); + $session->applyInspector('test-map', $read['revision'], encounterRow($read, 'arena')['key'], ''); + $session->saveMap('test-map'); + $saved = (require $path)['encounters']; + + expect($saved['battleArena'])->toBe('arena.cave') + ->and($saved['troops'])->toBe(['Pair' => ['weight' => 4, 'note' => 'kept']]) + ->and((string) file_get_contents($path))->toContain('// The lake\'s own fight.'); + + // An arena the battle presentation does not declare is refused. + $read = $session->readInspector('test-map'); + expect(fn() => $session->applyInspector('test-map', $read['revision'], encounterRow($read, 'arena')['key'], 'arena.moon')) + ->toThrow(SessionRefusal::class, 'arena.moon'); +}); + +it('keeps arenas in the terminal editor without offering them', function () { + $root = encounterArenaProject(); + $workspace = ProjectWorkspace::fromProject($root); + $map = $workspace->maps[0]; + $terminal = new MapInspector(new ReferenceCatalog($workspace, $map)); + + $fields = $terminal->getMapFields($map); + + expect(array_filter($fields, static fn(array $field): bool => in_array($field['field'] ?? null, ['arena', 'mapArena'], true)))->toBe([]); + + $weight = array_find($fields, static fn(array $field): bool => ($field['field'] ?? null) === 'weight'); + $terminal->apply($map, $weight, '6'); + + expect($map->getMapDataField(['encounters', 'troops', 'Pair']))->toBe(['weight' => 6, 'battleArena' => 'arena.road', 'note' => 'kept']); +}); + +it('reports an arena a battle names that the battle presentation does not declare', function () { + $root = encounterArenaProject(); + editTestMapData($root, static fn(string $source): string => str_replace("'rate' => 20,", "'rate' => 20,\n 'battleArena' => 'arena.moon',", $source)); + $errors = static fn(string $root): string => implode("\n", array_map( + static fn($issue): string => $issue->where . ': ' . $issue->message, + array_filter(new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), static fn($issue): bool => $issue->severity === Severity::ERROR), + )); + + expect($errors($root))->toContain('test-map: The encounters names the arena "arena.moon", which the battle presentation does not declare.') + ->not->toContain('"arena.road"'); + + // Without a battle presentation, no arena can be drawn. + unlink($root . '/assets/Data/Presentation/battle.php'); + + expect($errors($root))->toContain('The encounter with "Pair" names the arena "arena.road", but the project declares no battle presentation to draw it in.'); +}); diff --git a/tests/Unit/MapRuntimeMetadataTest.php b/tests/Unit/MapRuntimeMetadataTest.php index 0988d98..25091de 100644 --- a/tests/Unit/MapRuntimeMetadataTest.php +++ b/tests/Unit/MapRuntimeMetadataTest.php @@ -266,7 +266,7 @@ 'a block that is not an array' => ['every few steps', 'is string, not an array'], 'troops that are not troop weights' => [['troops' => 'Bat x 2'], 'troops are string'], 'a troop keyed by number' => [['troops' => [5 => 'Bat x 2']], 'keyed by int'], - 'a weight that is an array' => [['troops' => ['Bat x 2' => ['weight' => 3]]], 'weight that is array'], + 'a weight that is an array' => [['troops' => ['Bat x 2' => ['weight' => [3]]]], 'weight that is array'], ]); // -- Validation -------------------------------------------------------------- @@ -451,8 +451,8 @@ $authored = MapEncounters::of(['troops' => ['Bat x 2' => 3, 'Lone Rat' => 1], 'rate' => 22, 'tiles' => 'any']); expect($authored->rows())->toBe([ - ['name' => 'Bat x 2', 'weight' => 3], - ['name' => 'Lone Rat', 'weight' => 1], + ['name' => 'Bat x 2', 'weight' => 3, 'arena' => null, 'entry' => null], + ['name' => 'Lone Rat', 'weight' => 1, 'arena' => null, 'entry' => null], ]) ->and($authored->rate())->toBe(22) ->and($authored->authoredRate())->toBe(22) From 4dafbeacda25c885e3f581b6098a5aadbe4c8b16 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 20:23:53 +0200 Subject: [PATCH 165/344] fix(maps): refuse a null battleArena rather than dropping it on the next edit --- src/Field/MapEncounters.php | 6 ++++-- tests/Unit/EncounterArenaTest.php | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Field/MapEncounters.php b/src/Field/MapEncounters.php index 054f256..92f1ff6 100644 --- a/src/Field/MapEncounters.php +++ b/src/Field/MapEncounters.php @@ -112,9 +112,11 @@ public static function of(mixed $block): self return new self($block, [], sprintf('the encounters troops are %s, not troop weights', get_debug_type($troops))); } + // An arena is a key or absent: a null written for one is no arena + // the engine can draw, and the editor will not quietly drop it. $mapArena = $block[self::ARENA_KEY] ?? null; - if ($mapArena !== null && ! is_string($mapArena)) { + if (array_key_exists(self::ARENA_KEY, $block) && ! is_string($mapArena)) { return new self($block, [], sprintf('the map\'s battleArena is %s, not an arena key', get_debug_type($mapArena))); } @@ -135,7 +137,7 @@ public static function of(mixed $block): self return new self($block, [], sprintf('the troop "%s" has a weight that is %s', $name, get_debug_type($weight))); } - if ($arena !== null && ! is_string($arena)) { + if ($structured && array_key_exists(self::ARENA_KEY, $entry) && ! is_string($arena)) { return new self($block, [], sprintf('the troop "%s" has a battleArena that is %s, not an arena key', $name, get_debug_type($arena))); } diff --git a/tests/Unit/EncounterArenaTest.php b/tests/Unit/EncounterArenaTest.php index 374a427..0bdd215 100644 --- a/tests/Unit/EncounterArenaTest.php +++ b/tests/Unit/EncounterArenaTest.php @@ -79,6 +79,8 @@ function encounterRow(array $read, string $field): array 'an entry\'s arena' => [['troops' => ['Rats' => ['weight' => 5, 'battleArena' => 7]]], 'the troop "Rats" has a battleArena that is int'], 'the map\'s arena' => [['troops' => ['Rats' => 5], 'battleArena' => ['x']], 'the map\'s battleArena is array'], 'an entry\'s weight' => [['troops' => ['Rats' => ['weight' => [5]]]], 'the troop "Rats" has a weight that is array'], + 'an entry\'s null arena' => [['troops' => ['Rats' => ['weight' => 5, 'battleArena' => null]]], 'the troop "Rats" has a battleArena that is null'], + 'the map\'s null arena' => [['troops' => ['Rats' => 5], 'battleArena' => null], 'the map\'s battleArena is null'], ]); it('offers the arenas in a graphical editor, writing only what changed', function () { From 7ec0894779985141b4760cae476b0a53adcce151 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 21:52:06 +0200 Subject: [PATCH 166/344] feat(session): create projects and search one, for the GUI's File and Edit menus The session host answers project.create without an open project, by running the console's own `ichiloto new` (ProjectCreator), so the console remains the one owner of what a new project holds. The console lookup the playtest used becomes the shared ConsoleBinary. project.search finds maps by id or name, events by marker, type or any value they hold, and NPCs by id or name, unsaved edits included; one character matches an event marker exactly. --- src/Console/ConsoleBinary.php | 126 ++++++++++++++++++ src/Console/ProjectCreator.php | 80 +++++++++++ src/Playtest/PlaytestLauncher.php | 98 +------------- src/Session/EditorSession.php | 64 +++++++++ src/Session/SessionHost.php | 19 +++ tests/Unit/PlaytestTest.php | 2 +- tests/Unit/Session/ProjectMenuSessionTest.php | 84 ++++++++++++ 7 files changed, 377 insertions(+), 96 deletions(-) create mode 100644 src/Console/ConsoleBinary.php create mode 100644 src/Console/ProjectCreator.php create mode 100644 tests/Unit/Session/ProjectMenuSessionTest.php diff --git a/src/Console/ConsoleBinary.php b/src/Console/ConsoleBinary.php new file mode 100644 index 0000000..063d1cf --- /dev/null +++ b/src/Console/ConsoleBinary.php @@ -0,0 +1,126 @@ +console->path, 'new', $title, '--directory', $directory, '--no-interaction', + $install ? '--install' : '--no-install']; + if ($hero !== null && trim($hero) !== '') { + $command = [...$command, '--hero', trim($hero)]; + } + if ($battleEngine !== null) { + $command = [...$command, '--battle-engine', $battleEngine]; + } + + $process = proc_open($command, [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (! is_resource($process)) { + throw new RuntimeException('The console could not be started to create the project.'); + } + $output = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $status = proc_close($process); + + if ($status !== 0 || ! is_file($directory . DIRECTORY_SEPARATOR . 'ichiloto.json')) { + $message = trim(strip_tags((string) $output)); + throw new RuntimeException($message === '' ? 'The console could not create the project.' : $message); + } + + return realpath($directory) ?: $directory; + } +} diff --git a/src/Playtest/PlaytestLauncher.php b/src/Playtest/PlaytestLauncher.php index 182d487..37e7a87 100644 --- a/src/Playtest/PlaytestLauncher.php +++ b/src/Playtest/PlaytestLauncher.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor\Playtest; +use Ichiloto\Editor\Console\ConsoleBinary; use RuntimeException; /** @@ -23,11 +24,7 @@ public function __construct(private readonly string $consoleBinary) } /** - * Locates the console entry point. - * - * A project may install the console in its own vendor directory, a global - * install puts it on PATH, and this package may itself be installed under - * another Composer vendor tree. + * Locates the console entry point ({@see ConsoleBinary::discover()}). * * @param string|null $override An explicit path (`ICHILOTO_CONSOLE_BIN`). * @param string|null $projectRoot The project being edited, if known. @@ -35,96 +32,7 @@ public function __construct(private readonly string $consoleBinary) */ public static function discover(?string $override = null, ?string $projectRoot = null): self { - $candidates = self::candidates($override, $projectRoot); - - foreach ($candidates as $candidate) { - if (is_file($candidate)) { - return new self($candidate); - } - } - - throw new RuntimeException(sprintf( - "Could not find the ichiloto console binary — set ICHILOTO_CONSOLE_BIN to its path.\nLooked in:\n %s", - implode("\n ", $candidates), - )); - } - - /** - * Returns where the console binary might be, best guess first. - * - * @param string|null $override An explicit path. - * @param string|null $projectRoot The project being edited, if known. - * @return string[] The paths to try. - */ - private static function candidates(?string $override, ?string $projectRoot): array - { - return array_values(array_unique(array_filter([ - $override, - (string) getenv('ICHILOTO_CONSOLE_BIN') ?: null, - // A project that requires ichiloto/console. - is_string($projectRoot) && $projectRoot !== '' - ? rtrim($projectRoot, DIRECTORY_SEPARATOR) . '/vendor/bin/ichiloto' - : null, - // This package installed under a vendor tree, the project's or - // the console's own. - ...self::vendorBinariesAbove(), - // Installed globally. - self::binaryOnPath(), - ]))); - } - - /** - * Returns every `vendor/bin/ichiloto` in a directory above this package. - * - * @return string[] The paths. - */ - private static function vendorBinariesAbove(): array - { - $paths = []; - $directory = dirname(__DIR__, 2); - - // Deep enough to climb out of `vendor/ichiloto/editor` and any - // directory nesting a project keeps above it. - for ($depth = 0; $depth < 8; $depth++) { - $paths[] = $directory . '/vendor/bin/ichiloto'; - $parent = dirname($directory); - - if ($parent === $directory) { - break; - } - - $directory = $parent; - } - - return $paths; - } - - /** - * Returns the console binary found on PATH, if it is there. - * - * @return string|null The path, or null when PATH has no ichiloto. - */ - private static function binaryOnPath(): ?string - { - $path = (string) getenv('PATH'); - - if ($path === '') { - return null; - } - - foreach (explode(PATH_SEPARATOR, $path) as $directory) { - if ($directory === '') { - continue; - } - - $candidate = rtrim($directory, DIRECTORY_SEPARATOR) . '/ichiloto'; - - if (is_file($candidate)) { - return $candidate; - } - } - - return null; + return new self(ConsoleBinary::discover($override, $projectRoot)->path); } /** diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index e701802..3788e9d 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -135,6 +135,70 @@ public function describeProject(): array ]; } + /** + * Finds what the project names a text by: maps by id or name, events by + * marker, type or any value they hold (a chest's loot, a destination, a + * script), and NPCs by id or name, unsaved edits included. Case is + * ignored; a query of one character is an event marker, matched exactly, + * rather than every text that contains it. + * + * @return list + */ + public function searchProject(string $query, int $limit = 100): array + { + $query = trim($query); + if ($query === '') { + return []; + } + $contains = static fn(string $text): bool => mb_stripos($text, $query) !== false; + $marker = mb_strlen($query) === 1; + $results = []; + + foreach ($this->getMapsById() as $mapId => $map) { + $mapId = (string) $mapId; + $name = $map->getDisplayName(); + if (! $marker && ($contains($mapId) || $contains($name))) { + $results[] = ['kind' => 'map', 'map' => $mapId, 'label' => $name, 'detail' => $mapId]; + } + + foreach (array_unique([...$map->getEventMarkers(), ...$map->getPlacedEventMarkers()]) as $eventMarker) { + $definition = $map->getEventDefinition($eventMarker); + $type = EventTypeCatalog::describeClass(is_string($definition['class'] ?? null) ? $definition['class'] : null); + $values = []; + // What the event holds, not the class that plays it. + $data = is_array($definition['data'] ?? null) ? $definition['data'] : []; + array_walk_recursive($data, static function (mixed $value) use (&$values): void { + if (is_scalar($value)) { + $values[] = (string) $value; + } + }); + $found = $marker + ? $eventMarker === $query + : $contains($type) || array_find($values, $contains) !== null; + if (! $found) { + continue; + } + $cell = $map->getEventArea($eventMarker)?->cells[0] ?? null; + $results[] = ['kind' => 'event', 'map' => $mapId, 'label' => sprintf('%s %s', $eventMarker, $type), + 'detail' => sprintf('%s · %s', $name, $marker ? 'marker' : (array_find($values, $contains) ?? $type)), + 'marker' => $eventMarker, ...($cell === null ? [] : ['x' => (int) $cell[0], 'y' => (int) $cell[1]])]; + } + + foreach ($map->getNpcs()->all() as $index => $npc) { + if (! $marker && $contains($npc->getId()) || $contains($npc->getName())) { + $results[] = ['kind' => 'npc', 'map' => $mapId, 'label' => $npc->getName(), 'detail' => sprintf('%s · %s', $name, $npc->getId()), + 'index' => (int) $index, 'x' => $npc->getX(), 'y' => $npc->getY()]; + } + } + + if (count($results) >= $limit) { + return array_slice($results, 0, $limit); + } + } + + return $results; + } + /** * Every map, by stable id, with what a list shows about it. * diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 3875942..43d8b0f 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -4,7 +4,10 @@ namespace Ichiloto\Editor\Session; +use Ichiloto\Editor\Console\ConsoleBinary; +use Ichiloto\Editor\Console\ProjectCreator; use JsonException; +use RuntimeException; use Throwable; /** @@ -94,9 +97,25 @@ private function dispatch(string $method, array $params): array return ['protocol' => self::PROTOCOL, 'project' => $this->session->describeProject()]; } + // Creating a project needs none open: an editor with no project offers it. + if ($method === 'project.create') { + try { + return ['root' => new ProjectCreator(ConsoleBinary::discover())->createProject( + self::requireString($params, 'title'), + self::requireString($params, 'directory'), + self::readOptionalString($params, 'hero'), + self::readOptionalString($params, 'battleEngine'), + ($params['install'] ?? false) === true, + )]; + } catch (RuntimeException $refused) { + throw new SessionRefusal($refused->getMessage(), previous: $refused); + } + } + $session = $this->session ?? throw new InvalidRequest('Say hello with a project first.'); return match ($method) { + 'project.search' => $session->searchProject(self::requireString($params, 'query')), 'maps.list' => $session->describeMaps(), 'maps.kinds' => $session->listMapKinds(), 'map.create' => $session->createMap( diff --git a/tests/Unit/PlaytestTest.php b/tests/Unit/PlaytestTest.php index a253236..a74d4d9 100644 --- a/tests/Unit/PlaytestTest.php +++ b/tests/Unit/PlaytestTest.php @@ -185,7 +185,7 @@ function checksumTree(string $directory): array }); it('discovers consoles from installed package locations', function (): void { - $candidates = new ReflectionMethod(PlaytestLauncher::class, 'candidates') + $candidates = new ReflectionMethod(\Ichiloto\Editor\Console\ConsoleBinary::class, 'candidates') ->invoke(null, null, '/srv/my-game'); expect($candidates)->toContain('/srv/my-game/vendor/bin/ichiloto') diff --git a/tests/Unit/Session/ProjectMenuSessionTest.php b/tests/Unit/Session/ProjectMenuSessionTest.php new file mode 100644 index 0000000..b1a23bf --- /dev/null +++ b/tests/Unit/Session/ProjectMenuSessionTest.php @@ -0,0 +1,84 @@ +createProject('Lake Legend', $root . '/lake-legend', 'Mira', 'active_time'); + + expect($created)->toBe(realpath($root . '/lake-legend')) + ->and(json_decode((string) file_get_contents($root . '/arguments.json'), true)) + ->toBe(['new', 'Lake Legend', '--directory', $root . '/lake-legend', '--no-interaction', '--no-install', '--hero', 'Mira', '--battle-engine', 'active_time']) + ->and(fn() => $creator->createProject('Again', $root . '/lake-legend'))->toThrow(RuntimeException::class, 'already exists') + ->and(fn() => $creator->createProject('', $root . '/other'))->toThrow(RuntimeException::class, 'title') + ->and(fn() => $creator->createProject('Rel', 'relative/path'))->toThrow(RuntimeException::class, 'Choose where') + ->and(fn() => $creator->createProject('Gone', $root . '/missing/game'))->toThrow(RuntimeException::class, 'does not exist') + ->and(fn() => $creator->createProject('Odd', $root . '/odd', battleEngine: 'turbo'))->toThrow(RuntimeException::class, 'traditional or active_time') + ->and(fn() => new ProjectCreator(fakeConsole($root, true))->createProject('Shut', $root . '/shut'))->toThrow(RuntimeException::class, 'The guild is closed.'); + + removeDirectoryRecursively($root); +}); + +it('finds maps, events and NPCs across the project, by what they are called and hold', function () { + $root = makeTemporaryProject(); + $session = EditorSession::open($root); + [$map] = $session->describeMaps(); + $session->createNpc($map['id'], $map['revision'], 2, 2, 'Lantern Keeper'); + + expect(array_column($session->searchProject('test'), 'kind'))->toContain('map') + // A chest found by the item it gives, with where it is. + ->and($session->searchProject('s-potion'))->toBe([[ + 'kind' => 'event', 'map' => 'test-map', 'label' => 'E Chest', 'detail' => 'Test Map · S-Potion', 'marker' => 'E', + ...array_intersect_key($session->searchProject('E')[0], ['x' => true, 'y' => true]), + ]]) + // One character is a marker, not every text holding it. + ->and(array_column($session->searchProject('E'), 'marker'))->toBe(['E']) + ->and($session->searchProject('lantern'))->toHaveCount(1) + ->and($session->searchProject('lantern')[0])->toMatchArray(['kind' => 'npc', 'map' => 'test-map', 'label' => 'Lantern Keeper', 'index' => 0, 'x' => 2, 'y' => 2]) + ->and($session->searchProject(' '))->toBe([]); +}); + +it('serves project creation without an open project, and search with one', function () { + $root = makeTemporaryProject(); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(int $id, string $method, array $params = []): array => + $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params])); + + // No project is open, and a refused creation says why. + expect($request(1, 'project.create', ['title' => 'Lake', 'directory' => 'relative'])['error'])->toMatchArray(['kind' => 'refusal']) + ->and($request(2, 'project.search', ['query' => 'test'])['error']['message'])->toContain('Say hello'); + + $request(3, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + + expect($request(4, 'project.search', ['query' => 'test'])['result'][0])->toMatchArray(['kind' => 'map', 'map' => 'test-map']); +}); From e8aa29c8203c286a7b518ece21d00b6c7d158db9 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 22:15:11 +0200 Subject: [PATCH 167/344] feat(session): build elemental affinities a row at a time, and preview any enemy whole A graphical editor could only show an enemy's or armour's affinities as a line it could not edit. The session now describes an affinities row as its entries, offers the game's elements and the named effects, and writes the line from rows, refusing an element the game does not know, one listed twice, or a multiplier that is not a number. Any multiplier is allowed, so Fire 2.5 or Thunder 1.5 can be set, not only the named effects. An enemy's battle preview stood it opposite the lead party member, whose ground line sits high on the canvas, so a creature taller than that line (Loch Ness) put its art above the canvas and the preview failed. It now stands opposite the first party member, lead first, beside whom its art fits. --- src/Session/EditorSession.php | 114 ++++++++++++++++-- src/Session/SessionHost.php | 7 +- tests/Support/BattleFormationFixtures.php | 17 +++ tests/Unit/Session/AffinitySessionTest.php | 47 ++++++++ .../Unit/Session/EnemyPreviewSessionTest.php | 37 +++--- 5 files changed, 196 insertions(+), 26 deletions(-) create mode 100644 tests/Unit/Session/AffinitySessionTest.php diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 3788e9d..fa0b36e 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -12,6 +12,7 @@ use Ichiloto\Editor\Database\ConditionCodec; use Ichiloto\Editor\Database\ConditionEditor; use Ichiloto\Editor\Database\DatabaseCatalog; +use Ichiloto\Editor\Database\ElementAffinityCodec; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\RecordItem; use Ichiloto\Editor\Database\RecordChange; @@ -1089,6 +1090,68 @@ public function encodeWorldState(string $codec, array $entries, ?array $writeTyp throw new SessionRefusal(sprintf('There is no %s codec; use conditions or writes.', $codec)); } + /** + * What an elemental affinity row is built from: the elements the game + * knows, and the named effects with their multipliers, which the + * terminal cycles and a graphical editor offers beside a typed one. + * + * @return array{elements: list, effects: list} + * @throws SessionRefusal When the project's element list cannot be read. + */ + public function describeAffinityVocabulary(): array + { + try { + $elements = $this->workspace->getElementIdentities(); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + $effects = []; + foreach (ElementAffinityCodec::EFFECTS as $label => $multiplier) { + $effects[] = ['label' => $label, 'multiplier' => $multiplier]; + } + + return ['elements' => $elements, 'effects' => $effects]; + } + + /** + * Writes elemental affinities built a row at a time as the one line an + * affinity row is set to, refusing an element the game does not know, + * one named twice, or a multiplier that is not a number. + * + * @param list $entries Each `{element, multiplier}`. + * @return array{line: string, descriptions: list} + * @throws SessionRefusal When an entry cannot be stored as the Engine reads it. + */ + public function encodeAffinities(array $entries): array + { + $elements = $this->describeAffinityVocabulary()['elements']; + $affinities = []; + foreach (array_values($entries) as $number => $entry) { + $element = is_array($entry) && is_string($entry['element'] ?? null) ? trim($entry['element']) : ''; + $multiplier = is_array($entry) ? ($entry['multiplier'] ?? null) : null; + if (! in_array($element, $elements, true)) { + throw new SessionRefusal(sprintf('Entry %d needs an element the game knows: %s.', $number + 1, implode(', ', $elements))); + } + if (array_key_exists($element, $affinities)) { + throw new SessionRefusal(sprintf('%s is listed twice; keep one multiplier for it.', $element)); + } + if (! is_int($multiplier) && ! is_float($multiplier) || ! is_finite((float) $multiplier)) { + throw new SessionRefusal(sprintf('%s needs a multiplier: 2 weak, 0.5 resist, 0 null, -1 absorb, or any number between.', $element)); + } + $affinities[$element] = (float) $multiplier; + } + $line = ElementAffinityCodec::encodeAll($affinities); + + return [ + 'line' => $line, + 'descriptions' => array_map( + static fn(string $element, float $multiplier): string => sprintf('%s: %s', $element, ElementAffinityCodec::describe($multiplier)), + array_keys($affinities), + array_values($affinities), + ), + ]; + } + public function listReferences(string $mapId, string $category): array { if (! ReferenceCatalog::knows($category)) { @@ -1150,10 +1213,13 @@ public function readTroopFormation(int $index, ?string $arena = null): array * way the game reads it, and its battle art composed by the Engine's * BattleFormationLayout at battle scale beside the starting party. Battle * layouts define where the party stands, not enemies (troops place those), - * so the enemy stands opposite the lead party member, its slot mirrored - * across the canvas: a size comparison, not a battle position. Both read - * the record as it is now, unsaved edits included. A project without a - * graphical battle still gets the sprite, with the reason there is no art. + * so the enemy stands opposite a party member, its slot mirrored across + * the canvas: a size comparison, not a battle position. It stands opposite + * the lead when its art fits on the canvas there, else opposite the next + * member it fits beside, so a creature taller than the lead's ground line + * is still shown whole. Both read the record as it is now, unsaved edits + * included. A project without a graphical battle still gets the sprite, + * with the reason there is no art. * * @return array * @throws SessionRefusal When the enemy is unknown. @@ -1167,9 +1233,23 @@ public function readEnemyPreview(int $index, ?string $arena = null): array try { $catalog = $this->requireBattleLayoutCatalog('preview enemies at battle scale on'); - $lead = $catalog->ui->partySlots[0] ?? throw new SessionRefusal('The battle layout has no party slot to stand an enemy beside.'); - $slot = new BattlerSlot($catalog->ui->width - $lead->x, $lead->y, $lead->width, $lead->height); - [$formation, $view] = $this->composeFormation($catalog, $arena, [['enemyId' => $name, 'slot' => $slot]]); + if ($catalog->ui->partySlots === []) { + throw new SessionRefusal('The battle layout has no party slot to stand an enemy beside.'); + } + $composed = $firstRefusal = null; + foreach ($catalog->ui->partySlots as $party) { + $slot = new BattlerSlot($catalog->ui->width - $party->x, $party->y, $party->width, $party->height); + try { + $composed = $this->composeFormation($catalog, $arena, [['enemyId' => $name, 'slot' => $slot]]); + break; + } catch (SessionRefusal $refusal) { + $firstRefusal ??= $refusal; + } + } + if ($composed === null) { + throw $firstRefusal; + } + [$formation, $view] = $composed; $preview['formation'] = [...$view, 'members' => [[ 'enemy' => $name, 'placement' => null, @@ -2180,6 +2260,7 @@ private static function describeRow(array $field): array is_string($field['action'] ?? null) => 'action', ($field['mapConditions'] ?? false) === true, ($field['conditions'] ?? false) === true => 'conditions', ($field['worldWrites'] ?? false) === true => 'writes', + ($field['affinities'] ?? false) === true => 'affinities', ($field['destination'] ?? false) === true => 'destination', is_string($field['reference'] ?? null) => 'reference', $choices !== null => 'options', @@ -2206,7 +2287,7 @@ private static function describeRow(array $field): array 'value' => (string) ($field['value'] ?? ''), 'raw' => match ($kind) { 'conditions' => is_string($field['encoded'] ?? null) ? $field['encoded'] : (string) ($field['value'] ?? ''), - 'writes' => (string) ($field['value'] ?? ''), + 'writes', 'affinities' => (string) ($field['value'] ?? ''), 'text', 'integer', 'float', 'boolean' => $control?->rawValue, default => null, }, @@ -2229,6 +2310,8 @@ private static function describeRow(array $field): array 'entries' => match ($kind) { 'conditions' => ConditionCodec::decodeAll(is_string($field['encoded'] ?? null) ? $field['encoded'] : (string) ($field['value'] ?? '')), 'writes' => WorldWriteCodec::decodeAll((string) ($field['value'] ?? '')), + // Affinities are built a row at a time: an element and its multiplier. + 'affinities' => self::describeAffinityEntries((string) ($field['value'] ?? '')), default => null, }, 'writeTypes' => $kind === 'writes' && is_array($field['writeTypes'] ?? null) ? array_values($field['writeTypes']) : null, @@ -2238,6 +2321,21 @@ private static function describeRow(array $field): array ], static fn(mixed $value): bool => $value !== null); } + /** + * An affinity line as the rows it is built from. + * + * @return list + */ + private static function describeAffinityEntries(string $line): array + { + $entries = []; + foreach (ElementAffinityCodec::decodeAll($line) as $element => $multiplier) { + $entries[] = ['element' => $element, 'multiplier' => $multiplier]; + } + + return $entries; + } + /** * What names a row for an edit: its target and the field, path, marker * and index it edits. Null for a row no edit applies through. diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 43d8b0f..01b7073 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -323,7 +323,12 @@ private function dispatch(string $method, array $params): array self::requireString($params, 'codec'), is_array($params['entries'] ?? null) ? $params['entries'] : throw new InvalidRequest('"entries" must be a list.'), is_array($params['writeTypes'] ?? null) ? array_values(array_filter($params['writeTypes'], is_string(...))) : null, - ), 'npc.create' => $session->createNpc( + ), + 'affinities.vocabulary' => $session->describeAffinityVocabulary(), + 'affinities.encode' => $session->encodeAffinities( + is_array($params['entries'] ?? null) ? $params['entries'] : throw new InvalidRequest('"entries" must be a list.'), + ), + 'npc.create' => $session->createNpc( self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireInt($params, 'x'), diff --git a/tests/Support/BattleFormationFixtures.php b/tests/Support/BattleFormationFixtures.php index ed5c8ed..9d8629e 100644 --- a/tests/Support/BattleFormationFixtures.php +++ b/tests/Support/BattleFormationFixtures.php @@ -45,3 +45,20 @@ function troopFormationProject(): string return $root; } + +/** The troop formation project with one enemy record and two terminal sprites. */ +function enemyPreviewProject(): string +{ + $root = troopFormationProject(); + @mkdir($root . '/assets/Data/Enemies', 0o777, true); + @mkdir($root . '/assets/Graphics/Enemies', 0o777, true); + file_put_contents($root . '/assets/Graphics/Enemies/bat.txt', "/\\o/\\\n ' '\n"); + file_put_contents($root . '/assets/Graphics/Enemies/wisp.txt', "~*~\n"); + file_put_contents($root . '/assets/secret.txt', "not a sprite\n"); + file_put_contents($root . '/assets/Data/Enemies/regular-bat.php', " Enemy::class, 'data' => " + . var_export(['name' => 'Regular Bat', 'level' => 2, 'imagePath' => 'bat', 'rewards' => ['experience' => 1, 'gold' => 1], 'stats' => [ + 'maxHp' => 10, 'maxMp' => 0, 'attack' => 3, 'defence' => 2, 'magicAttack' => 1, 'magicDefence' => 1, 'speed' => 2, 'grace' => 1, 'evasion' => 1, + ]], true) . "];\n"); + + return $root; +} diff --git a/tests/Unit/Session/AffinitySessionTest.php b/tests/Unit/Session/AffinitySessionTest.php new file mode 100644 index 0000000..62d0d1d --- /dev/null +++ b/tests/Unit/Session/AffinitySessionTest.php @@ -0,0 +1,47 @@ +readDatabaseRecord('enemies', 0)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'elementAffinities'); +} + +it('offers an affinity row as entries, built from the game\'s elements and the named effects', function () { + $session = EditorSession::open(enemyPreviewProject()); + $vocabulary = $session->describeAffinityVocabulary(); + + expect(readAffinityRow($session))->toMatchArray(['kind' => 'affinities', 'entries' => []]) + ->and($vocabulary['elements'])->toContain('Fire', 'Water') + ->and($vocabulary['effects'])->toBe([ + ['label' => 'Weak', 'multiplier' => 2.0], + ['label' => 'Resist', 'multiplier' => 0.5], + ['label' => 'Null', 'multiplier' => 0.0], + ['label' => 'Absorb', 'multiplier' => -1.0], + ]); +}); + +it('writes any multiplier, named or between, and the row reads it back as entries', function () { + $session = EditorSession::open(enemyPreviewProject()); + $encoded = $session->encodeAffinities([['element' => 'Water', 'multiplier' => -1], ['element' => 'Fire', 'multiplier' => 2.5]]); + + expect($encoded)->toBe(['line' => 'Water: -1; Fire: 2.5', 'descriptions' => ['Water: Absorb ×-1', 'Fire: ×2.5']]); + + $session->applyDatabaseRecord('enemies', 0, readAffinityRow($session)['key'], $encoded['line']); + + expect(readAffinityRow($session)['entries'])->toBe([['element' => 'Water', 'multiplier' => -1.0], ['element' => 'Fire', 'multiplier' => 2.5]]); +}); + +it('refuses an element the game does not know, one listed twice, or a multiplier that is not a number', function () { + $session = EditorSession::open(enemyPreviewProject()); + + expect(fn() => $session->encodeAffinities([['element' => 'Plasma', 'multiplier' => 2]]))->toThrow(Ichiloto\Editor\Session\SessionRefusal::class, 'an element the game knows') + ->and(fn() => $session->encodeAffinities([['element' => 'Fire', 'multiplier' => 2], ['element' => 'Fire', 'multiplier' => 0.5]]))->toThrow(Ichiloto\Editor\Session\SessionRefusal::class, 'listed twice') + ->and(fn() => $session->encodeAffinities([['element' => 'Fire', 'multiplier' => 'hot']]))->toThrow(Ichiloto\Editor\Session\SessionRefusal::class, 'needs a multiplier') + ->and($session->encodeAffinities([])['line'])->toBe(''); +}); diff --git a/tests/Unit/Session/EnemyPreviewSessionTest.php b/tests/Unit/Session/EnemyPreviewSessionTest.php index e80e88b..95cdd8c 100644 --- a/tests/Unit/Session/EnemyPreviewSessionTest.php +++ b/tests/Unit/Session/EnemyPreviewSessionTest.php @@ -4,23 +4,6 @@ use Ichiloto\Editor\Session\EditorSession; -/** The troop formation project with one enemy record and two terminal sprites. */ -function enemyPreviewProject(): string -{ - $root = troopFormationProject(); - @mkdir($root . '/assets/Data/Enemies', 0o777, true); - @mkdir($root . '/assets/Graphics/Enemies', 0o777, true); - file_put_contents($root . '/assets/Graphics/Enemies/bat.txt', "/\\o/\\\n ' '\n"); - file_put_contents($root . '/assets/Graphics/Enemies/wisp.txt', "~*~\n"); - file_put_contents($root . '/assets/secret.txt', "not a sprite\n"); - file_put_contents($root . '/assets/Data/Enemies/regular-bat.php', " Enemy::class, 'data' => " - . var_export(['name' => 'Regular Bat', 'level' => 2, 'imagePath' => 'bat', 'rewards' => ['experience' => 1, 'gold' => 1], 'stats' => [ - 'maxHp' => 10, 'maxMp' => 0, 'attack' => 3, 'defence' => 2, 'magicAttack' => 1, 'magicDefence' => 1, 'speed' => 2, 'grace' => 1, 'evasion' => 1, - ]], true) . "];\n"); - - return $root; -} - /** Sets the open enemy's sprite through its row, as an author would, without saving. */ function chooseEnemySprite(EditorSession $session, string $sprite): void { @@ -65,3 +48,23 @@ function chooseEnemySprite(EditorSession $session, string $sprite): void ->and($preview['formation'])->toBeNull() ->and($preview['formationIssue'])->toBe('This project has no graphical battle layout to preview enemies at battle scale on.'); }); + +it('stands an enemy too tall for the lead party member\'s ground line opposite the next member it fits beside', function () { + $root = enemyPreviewProject(); + writeTilesetTestPng($root . '/assets/Graphics/Actors/Kaelion.png', 10, 10); + writeTilesetTestPng($root . '/assets/Graphics/Enemies/Bat.png', 4, 10); + $battle = (string) file_get_contents($root . '/assets/Data/Presentation/battle.php'); + $battle = str_replace(['use Ichiloto\Engine\Battle\Presentation\BattlerSlot;', " actors: [],\n enemies: [],"], [ + "use Ichiloto\\Engine\\Battle\\Presentation\\BattlerSlot;\nuse Ichiloto\\Engine\\Battle\\Presentation\\{BattlerArtwork, BattleScale, BattlerScale};", + // At three times the party's height the bat stands 450 tall: above the canvas from the lead's ground line at 420, whole from the next at 560. + " actors: ['Kaelion' => new BattlerArtwork('Graphics/Actors/Kaelion.png', 10, 10, 5, 10)],\n" + . " enemies: ['Regular Bat' => new BattlerArtwork('Graphics/Enemies/Bat.png', 4, 10, 2, 10)],\n" + . " scale: new BattleScale('Kaelion', 150, actors: ['Kaelion' => new BattlerScale(1, 1)], enemies: ['Regular Bat' => new BattlerScale(3, 1)]),", + ], $battle); + file_put_contents($root . '/assets/Data/Presentation/battle.php', $battle); + + $preview = EditorSession::open($root)->readEnemyPreview(0); + + expect($preview['formationIssue'])->toBeNull() + ->and($preview['formation']['members'][0]['battler']['ground'])->toBe(['x' => 170.0, 'y' => 560.0]); +}); From 8dcada75d622fee43cfe8e1963f8ef7dd9cccc00 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 23:52:27 +0200 Subject: [PATCH 168/344] feat(database): set actors' and enemies' battle art as data, from their own pages Battle art lived only in a project's battle presentation code, which the editor does not rewrite, so the GUI could show an enemy's art but never set it. The Engine now reads battler bindings as data (battlers.php). Three categories edit that file through the shared record service: Actor Battle Art and Enemy Battle Art, one record per battler with its image, ground point, poses keyed by role and body profile, and Battle Scale, the reference. They are embedded in the actor's and enemy's pages rather than listed, so the terminal's Database is unchanged. - A projection presents the identity-keyed map as records. - A record can be created for a given identity, refused if it has one; these categories only make records that way. - A schema may name what its file must hold for the runtime to read it. The shared-file save checks the file as it would be written and refuses before writing. Battle art is checked by the Engine's own loader, which also refuses a battler the presentation code binds too. - The battle preview binds the art as it is now, unsaved edits included. - battlers.describe says where a battler's art is set: data, code, or not yet. - Saving a page saves what it embeds, first. Needs the Engine's BattlePresentationCatalog::loadCode, requested from the Engine coordinator; tests pass against an Engine with it. --- docs/manual.md | 28 +++++ src/Actors/ActorCategory.php | 6 +- src/Database/DatabaseCatalog.php | 24 ++++ src/Database/DatabaseCategory.php | 7 +- src/Database/DatabaseCategoryDefinition.php | 3 + src/Database/ProjectRecordDatabase.php | 21 +++- .../Projections/BattlerBindingProjection.php | 112 +++++++++++++++++ src/Database/RecordAuthoring.php | 10 +- src/Database/RecordCategory.php | 4 +- src/Database/RecordSchema.php | 8 ++ src/Database/RecordSchemaCatalog.php | 115 +++++++++++++++++ src/Database/SharedFileTransaction.php | 31 +++++ src/Session/EditorSession.php | 108 ++++++++++++++-- src/Session/SessionHost.php | 3 +- tests/Unit/Session/BattlerArtSessionTest.php | 116 ++++++++++++++++++ 15 files changed, 574 insertions(+), 22 deletions(-) create mode 100644 src/Database/Projections/BattlerBindingProjection.php create mode 100644 tests/Unit/Session/BattlerArtSessionTest.php diff --git a/docs/manual.md b/docs/manual.md index 7d56cb2..8748b72 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1059,6 +1059,7 @@ skill's effects, a quest's rewards) listed under them. | Configuration | `config.php` (`save`, `accessibility`, `ui`, `graphics`, `audio`, `inn`) | Editable - one row per setting, typed by what it holds; field zoom is kept from 1 to 8 | Same | | Types | `assets/Data/system.php` | Editable - the project's elements, one per row; an empty list means the Engine's defaults. Weapon, armor and equipment types are the Engine's own | Same | | Terms | `config.php` (`vocab`, `messages`) | Literal terms editable; comments and unrelated expressions preserved | Same | +| Actor Battle Art, Enemy Battle Art, Battle Scale | `assets/Data/Presentation/battlers.php` | Not shown: graphical battle art | Editable from the actor's or enemy's own page (see Battle Art) | Why a category can still turn out read-only: a file the editor cannot evaluate, a value it could not write back out, or a comment inside returned @@ -1229,6 +1230,33 @@ battle-entry rules match against. An omitted classification is the engine's `ordinary` default; the picker shows that without writing the key into older data. +### Battle Art + +The art an actor or enemy fights with in a graphical battle is bound to it as +data in `assets/Data/Presentation/battlers.php`, the Engine's battler +bindings file. Per actor (by definition id) or enemy (by name) it holds: + +- Image: base artwork, picked from the project's PNGs. +- Ground point: where the battler stands on its image, from 0 to 1 across and + down. It defaults to bottom centre. +- Poses: each role the battle shows (idle, attack, guard, knockout and the + rest), a still or a sheet of frames with columns, rows, frames, frames per + second, looping and a rest frame. +- Body profile: its size against the scale reference, and the share of its + image that size measures. + +The file can also name the scale reference: the actor every battler's size is +measured against, and that actor's height. Image sizes are always read from +the files, so replacing an image needs no edit here. + +This is graphical presentation, so it is set in the GUI editor, on the actor's +or enemy's own page, and the terminal editor does not list it. Each battler +has one owner. Art the project's `battle.php` registers in code stays there: +the editor says so and does not rewrite program code. A battler's art is set +here once it moves to `battlers.php`. A save that the battle could not read is +refused before anything is written, and so is one that binds a battler +`battle.php` also binds. + ### Battle Entry Rules the engine applies as a battle begins, in diff --git a/src/Actors/ActorCategory.php b/src/Actors/ActorCategory.php index 6f0bd3a..6865487 100644 --- a/src/Actors/ActorCategory.php +++ b/src/Actors/ActorCategory.php @@ -123,8 +123,12 @@ public function removeItem(int $index, array $frame, string $fieldId): RecordCha throw new RecordRefusal('An actor has no list to remove from.'); } - public function createRecord(): RecordChange + public function createRecord(?string $identity = null): RecordChange { + if ($identity !== null) { + throw new RecordRefusal('A new actor is named once it is made, not for another record.'); + } + return $this->authoring->createActor($this->workspace); } diff --git a/src/Database/DatabaseCatalog.php b/src/Database/DatabaseCatalog.php index 688f33c..5b82f77 100644 --- a/src/Database/DatabaseCatalog.php +++ b/src/Database/DatabaseCatalog.php @@ -47,6 +47,30 @@ public static function all(): array ]; } + /** + * Returns the categories edited from another category's records rather + * than listed on their own: battle art is set on the actor's or enemy's + * own page, in the graphical editor, and never shown in the terminal's. + * + * @return list + */ + public static function getEmbedded(): array + { + return [ + new DatabaseCategoryDefinition('battler_actors', 'Actor Battle Art', 'The art an actor fights with in a graphical battle.', true, ['actors']), + new DatabaseCategoryDefinition('battler_enemies', 'Enemy Battle Art', 'The art an enemy fights with in a graphical battle.', true, ['enemies']), + new DatabaseCategoryDefinition('battle_scale', 'Battle Scale', 'The actor every battler\'s size is measured against.', true, ['actors', 'enemies']), + ]; + } + + /** + * Returns whether a key names a category, listed or embedded. + */ + public static function knows(string $key): bool + { + return array_any([...self::all(), ...self::getEmbedded()], static fn(DatabaseCategoryDefinition $category): bool => $category->key === $key); + } + /** * Returns the category index for the requested key. * diff --git a/src/Database/DatabaseCategory.php b/src/Database/DatabaseCategory.php index f4fffcb..0bf295a 100644 --- a/src/Database/DatabaseCategory.php +++ b/src/Database/DatabaseCategory.php @@ -87,8 +87,11 @@ public function addItem(int $index, array $frame, ?string $fieldId, bool $child) */ public function removeItem(int $index, array $frame, string $fieldId): RecordChange; - /** @throws RecordRefusal When the category takes no new records. */ - public function createRecord(): RecordChange; + /** + * @param string|null $identity The identity a record is made for, when it belongs to something that already has one. + * @throws RecordRefusal When the category takes no new records, or none for that identity. + */ + public function createRecord(?string $identity = null): RecordChange; /** @throws RecordRefusal When the record cannot be duplicated. */ public function duplicateRecord(int $index): RecordChange; diff --git a/src/Database/DatabaseCategoryDefinition.php b/src/Database/DatabaseCategoryDefinition.php index e5f2e82..2234f00 100644 --- a/src/Database/DatabaseCategoryDefinition.php +++ b/src/Database/DatabaseCategoryDefinition.php @@ -14,12 +14,15 @@ * @param string $label User-facing category label. * @param string $description Short category purpose summary. * @param bool $isImplemented Whether the editor is currently implemented. + * @param list $hosts For a category edited from other categories' records + * rather than listed on its own: those categories' keys. */ public function __construct( public string $key, public string $label, public string $description, public bool $isImplemented = false, + public array $hosts = [], ) { } } diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index a17df53..64bfa23 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -991,16 +991,22 @@ public function addRecord(): ?int * Appends a blank record, or says why none can be made: the category * takes no new records, or the project lacks what a new one needs. * + * @param string|null $identity The identity the record is for, when it is + * made for something that already has one (an enemy's battle art); a + * fresh unique one when null. * @return int The new record index. - * @throws RecordRefusal With the reason an author reads. + * @throws RecordRefusal With the reason an author reads, including an identity that already has a record. */ - public function requireNewRecord(): int + public function requireNewRecord(?string $identity = null): int { $cannot = sprintf('%s entries cannot be created from the editor.', ucfirst($this->schema->entryNoun)); if (! $this->supportsRecordCreation()) { throw new RecordRefusal($cannot); } + if ($this->schema->identityGiven && $identity === null) { + throw new RecordRefusal(sprintf('%s is made for what it belongs to, from that record\'s own page.', ucfirst($this->schema->entryNoun))); + } $payload = $this->schema->blank; $identityKey = $this->schema->identityKey; @@ -1039,7 +1045,16 @@ public function requireNewRecord(): int } } - if (is_array($payload) && $identityKey !== null && array_key_exists($identityKey, $payload)) { + if ($identity !== null) { + if ($identityKey === null || ! is_array($payload)) { + throw new RecordRefusal(sprintf('%s entries are not made for a given name.', ucfirst($this->schema->entryNoun))); + } + if (array_any($this->getRecords(), static fn(ProjectRecord $record): bool => $record->getDisplayValue($identityKey) === $identity)) { + throw new RecordRefusal(sprintf('%s already has %s.', $identity, $this->schema->entryNoun)); + } + $payload[$identityKey] = $identity; + $recordId = $identity; + } elseif (is_array($payload) && $identityKey !== null && array_key_exists($identityKey, $payload)) { $payload[$identityKey] = $this->makeUniqueIdentity($payload[$identityKey]); $recordId = strval($payload[$identityKey]); } diff --git a/src/Database/Projections/BattlerBindingProjection.php b/src/Database/Projections/BattlerBindingProjection.php new file mode 100644 index 0000000..b4e3157 --- /dev/null +++ b/src/Database/Projections/BattlerBindingProjection.php @@ -0,0 +1,112 @@ +side] ?? null) ? $whole[$this->side] : [] as $identity => $entry) { + if (is_array($entry)) { + $rows[] = [self::IDENTITY => strval($identity), ...$entry]; + } + } + + return $rows; + } + + /** + * @inheritDoc + */ + public function write(array $whole, array $rows): array + { + $entries = []; + + foreach ($rows as $row) { + $identity = trim(strval($row[self::IDENTITY] ?? '')); + if ($identity === '') { + continue; + } + unset($row[self::IDENTITY]); + $entries[$identity] = $row; + } + + if ($entries === []) { + unset($whole[$this->side]); + + return $whole; + } + + $whole[$this->side] = $entries; + + return $whole; + } + + /** @inheritDoc */ + public function preservationIssue(mixed $whole): ?string + { + if (! is_array($whole)) { + return sprintf('returns %s, not an array', get_debug_type($whole)); + } + if (! array_key_exists($this->side, $whole)) { + return null; + } + $entries = $whole[$this->side]; + if (! is_array($entries) || ($entries !== [] && array_is_list($entries))) { + return sprintf('field "%s" is not a map of battler identities', $this->side); + } + foreach ($entries as $identity => $entry) { + if (! is_array($entry)) { + return sprintf('field "%s" entry "%s" is %s, not a binding', $this->side, strval($identity), get_debug_type($entry)); + } + if (array_key_exists(self::IDENTITY, $entry)) { + return sprintf('field "%s" entry "%s" holds "%s", which the editor uses for the identity', $this->side, strval($identity), self::IDENTITY); + } + } + + return null; + } + + /** + * @inheritDoc + * + * The map is written in row order, which it keeps. + */ + public function ordersRecords(): bool + { + return true; + } +} diff --git a/src/Database/RecordAuthoring.php b/src/Database/RecordAuthoring.php index c23a20e..2d0cc57 100644 --- a/src/Database/RecordAuthoring.php +++ b/src/Database/RecordAuthoring.php @@ -341,15 +341,15 @@ public function removeOption(ProjectRecordDatabase $database, int $index, array } /** - * Creates a blank record at the end of the category. Its index is the - * change's. + * Creates a blank record at the end of the category, for an identity + * when one is given. Its index is the change's. * - * @throws RecordRefusal When the category is read-only, takes no new records, or the project lacks what one needs. + * @throws RecordRefusal When the category is read-only, takes no new records, the project lacks what one needs, or the identity has one. */ - public function createRecord(ProjectRecordDatabase $database): RecordChange + public function createRecord(ProjectRecordDatabase $database, ?string $identity = null): RecordChange { $this->requireEditable($database); - $index = $database->requireNewRecord(); + $index = $database->requireNewRecord($identity); $record = $database->getRecordByIndex($index) ?? throw new RecordRefusal(sprintf('%s entries cannot be created from the editor.', ucfirst($database->schema->entryNoun))); diff --git a/src/Database/RecordCategory.php b/src/Database/RecordCategory.php index 5ae21af..727eeee 100644 --- a/src/Database/RecordCategory.php +++ b/src/Database/RecordCategory.php @@ -103,9 +103,9 @@ public function removeItem(int $index, array $frame, string $fieldId): RecordCha return $this->authoring->removeItem($this->database, $index, $frame, $fieldId); } - public function createRecord(): RecordChange + public function createRecord(?string $identity = null): RecordChange { - return $this->authoring->createRecord($this->database); + return $this->authoring->createRecord($this->database, $identity); } public function duplicateRecord(int $index): RecordChange diff --git a/src/Database/RecordSchema.php b/src/Database/RecordSchema.php index 1ec695c..e56717b 100644 --- a/src/Database/RecordSchema.php +++ b/src/Database/RecordSchema.php @@ -55,6 +55,12 @@ * @param bool $numberedFiles For one-file-per-record categories listed in file * order (skills): a file is named `0001-.php`, a new or copied record * takes the next number, and so goes last, as reopening lists it. + * @param Closure(array, string): ?string|null $saveCheck Why the runtime could + * not read the file as a save would write it, given that whole payload and the project root, or + * null when it can. Edits on the way there are free; the save is refused before a byte is written. + * @param bool $identityGiven Whether a record only ever belongs to something another category + * names (an enemy's battle art), so one is made for that name or not at all, never under an + * invented one the file could not key. */ public function __construct( public string $key, @@ -78,6 +84,8 @@ public function __construct( public array $subLists = [], public bool $identityFollowsLabel = false, public bool $numberedFiles = false, + public ?Closure $saveCheck = null, + public bool $identityGiven = false, ) { if ($projection instanceof WholeFileProjection) { // A field outside the keys the category owns would read blank and diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index a7f2f58..d6d09ed 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -8,6 +8,7 @@ use LogicException; use Ichiloto\Editor\ActorStatPreview; +use Ichiloto\Editor\Database\Projections\BattlerBindingProjection; use Ichiloto\Editor\Database\Projections\KeyedListProjection; use Ichiloto\Editor\Database\Projections\KnowledgeEnemyMappingProjection; use Ichiloto\Editor\Database\Projections\KnowledgeRecordTypeProjection; @@ -42,6 +43,9 @@ use Ichiloto\Engine\Rendering\Tilesets\TilesetSheet; use Ichiloto\Engine\Quests\QuestObjectiveType; use Ichiloto\Engine\Battle\Enumerations\BattleEngineType; +use Ichiloto\Engine\Battle\Presentation\BattlePoseRole; +use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; +use Ichiloto\Engine\Battle\Presentation\BattlerBindings; use Ichiloto\Engine\Core\Enumerations\MovementHeading; use Ichiloto\Editor\Database\Projections\WholeFileProjection; use Ichiloto\Engine\Entities\Enumerations\ActionConditionType; @@ -120,6 +124,9 @@ public static function all(): array self::configuration(), self::types(), self::tilesets(), + self::battlerArt('actors'), + self::battlerArt('enemies'), + self::battleScaleReference(), ]; $keyed = []; @@ -1624,6 +1631,114 @@ private static function tilesets(): RecordSchema ), ); } + /** Where battle art is bound to battlers as data, the Engine's {@see BattlerBindings::FILE}. */ + public const string BATTLERS_PATH = 'assets/' . BattlerBindings::FILE; + + /** + * One side of the battler bindings: which art an actor or enemy fights + * with in a graphical battle, edited from that actor's or enemy's own + * page. Each record is one identity (an actor's definition id, an + * enemy's name, as the battle catalog keys them) with its base artwork, + * its pose roles and its body profile against the scale reference. Image + * sizes are the files', never stored. The terminal battle never reads it. + * + * @param 'actors'|'enemies' $side + */ + private static function battlerArt(string $side): RecordSchema + { + $actors = $side === 'actors'; + + return new RecordSchema( + key: $actors ? 'battler_actors' : 'battler_enemies', + entryNoun: $actors ? 'actor battle art' : 'enemy battle art', + storage: RecordStorage::LIST_FILE, + relativePath: self::BATTLERS_PATH, + fields: [ + // Set when the art is made for its battler, and never moved to another. + new RecordField(BattlerBindingProjection::IDENTITY, $actors ? 'Actor' : 'Enemy', isReadOnly: true), + RecordField::reference('artwork.image', 'Image', 'png_assets', allowsNone: true), + new RecordField('artwork.pivot', 'Ground Point', codec: RecordFieldCodec::NORMALIZED_POINT, removeWhenEmpty: true, displayDefault: '0.5, 1 (bottom centre)'), + new RecordField('scale.relativeSize', 'Size', InputControlType::FLOAT, removeWhenEmpty: true), + new RecordField('scale.sourceSpan', 'Body Span', InputControlType::FLOAT, removeWhenEmpty: true), + new RecordField('scale.horizontal', 'Measured Across', InputControlType::BOOLEAN, removeWhenEmpty: true, displayDefault: 'false'), + ], + labelKey: BattlerBindingProjection::IDENTITY, + identityKey: BattlerBindingProjection::IDENTITY, + blank: [BattlerBindingProjection::IDENTITY => ''], + projection: new BattlerBindingProjection($side), + subLists: [ + // Each role the battle shows the battler in, a still or a + // sheet of frames, keyed by role as the file keys it. + new RecordSubList( + key: 'poses', + prefix: 'pose', + singular: 'pose', + fields: [ + new RecordField('role', 'Role', options: array_map(static fn(BattlePoseRole $role): string => $role->value, BattlePoseRole::cases())), + RecordField::reference('image', 'Image', 'png_assets'), + new RecordField('pivot', 'Ground Point', codec: RecordFieldCodec::NORMALIZED_POINT, removeWhenEmpty: true, displayDefault: '0.5, 1 (bottom centre)'), + new RecordField('columns', 'Columns', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '1'), + new RecordField('rows', 'Rows', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '1'), + new RecordField('frames', 'Frames', codec: RecordFieldCodec::CSV_INTEGERS, removeWhenEmpty: true, displayDefault: '0'), + new RecordField('fps', 'Frames per Second', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '8'), + new RecordField('loop', 'Loops', InputControlType::BOOLEAN, removeWhenEmpty: true, displayDefault: 'true'), + new RecordField('restFrame', 'Rest Frame', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + new RecordField('scaleSpan', 'Body Span', InputControlType::FLOAT, removeWhenEmpty: true), + ], + blank: ['role' => BattlePoseRole::IDLE->value, 'image' => ''], + heading: 'Poses', + removeWhenEmpty: true, + keyField: 'role', + ), + ], + saveCheck: self::checkBattlerBindings(...), + identityGiven: true, + ); + } + + /** + * The battle scale's reference: the actor every battler's size is + * measured against, and how tall that actor stands in arena units. + */ + private static function battleScaleReference(): RecordSchema + { + return new RecordSchema( + key: 'battle_scale', + entryNoun: 'battle scale', + storage: RecordStorage::LIST_FILE, + relativePath: self::BATTLERS_PATH, + fields: [ + RecordField::reference('reference.actor', 'Reference Actor', 'actor_ids', allowsNone: true), + new RecordField('reference.height', 'Reference Height', InputControlType::FLOAT, removeWhenEmpty: true), + ], + labelKey: 'reference.actor', + identityKey: null, + projection: new WholeFileProjection(['reference']), + saveCheck: self::checkBattlerBindings(...), + ); + } + + /** + * Why the battle could not read battler bindings as they would be + * saved, or null when it can: the Engine reads them, and binds them + * beside the battlers the project's battle presentation code registers, + * refusing an identity both own. + * + * @param array $whole The bindings file as it would be written. + */ + public static function checkBattlerBindings(array $whole, string $projectRoot): ?string + { + $assets = rtrim($projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'assets'; + try { + $bindings = BattlerBindings::getFromArray($whole, $assets); + BattlePresentationCatalog::loadCode($assets)?->bindBattlers($bindings); + } catch (\InvalidArgumentException|\RuntimeException $error) { + return sprintf('The battle could not read %s as it would be saved: %s', BattlerBindings::FILE, $error->getMessage()); + } + + return null; + } + /** * The shared read-only fields every inventory entry displays. * diff --git a/src/Database/SharedFileTransaction.php b/src/Database/SharedFileTransaction.php index 1734468..55062eb 100644 --- a/src/Database/SharedFileTransaction.php +++ b/src/Database/SharedFileTransaction.php @@ -111,6 +111,10 @@ private static function compose(array $databases, bool $write): bool $path = $first->backingFilePath(); $file = PhpDataFile::load($path, $first->projectRoot()); + if ($write) { + self::checkSave($databases, $dirty, $file); + } + // One snapshot of the source, and every category's wants resolved // against it before a byte is written. if (self::commitToSource($dirty, $file, $write)) { @@ -150,6 +154,33 @@ private static function compose(array $databases, bool $write): bool return true; } + /** + * Refuses a save the runtime could not read: the file as every dirty + * category would leave it is put to each sharing category's own check. + * + * @param array $databases The categories sharing the file. + * @param ProjectRecordDatabase[] $dirty The dirty ones. + * @throws RecordRefusal With the first check's reason. + */ + private static function checkSave(array $databases, array $dirty, PhpDataFile $file): void + { + $checks = array_filter($databases, static fn(object $database): bool => $database instanceof ProjectRecordDatabase + && $database->schema->saveCheck !== null); + if ($checks === []) { + return; + } + $payload = is_array($file->payload) ? $file->payload : []; + foreach ($dirty as $database) { + $payload = $database->foldInto($payload); + } + foreach ($checks as $database) { + $reason = ($database->schema->saveCheck)($payload, (string) $database->projectRoot()); + if ($reason !== null) { + throw new RecordRefusal($reason); + } + } + } + /** * Writes the file by editing the author's own source, when every dirty * category can say what it wants in those terms. diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index fa0b36e..bddcdf9 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -56,6 +56,9 @@ use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; use Ichiloto\Engine\Rendering\Presentation\PresentationWorld; use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; +use Ichiloto\Engine\Battle\Presentation\BattlerBindings; +use Ichiloto\Editor\Database\PhpDataFile; +use Ichiloto\Editor\Database\RecordSchemaCatalog; use Ichiloto\Engine\Battle\Presentation\BattleFormationBattler; use Ichiloto\Engine\Battle\Presentation\BattleFormationLayout; use Ichiloto\Engine\Battle\Presentation\BattlerSlot; @@ -1286,11 +1289,21 @@ private function readEnemySprite(mixed $imagePath): array )))]; } - /** The project's battle presentation, when it lays battles out on a graphical canvas. */ + /** + * The project's battle presentation, when it lays battles out on a + * graphical canvas: its presentation code, with the battle art bound as + * data as it is now, unsaved edits included, so a preview shows the art + * being set. + */ private function requireBattleLayoutCatalog(string $purpose): BattlePresentationCatalog { + $assets = $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; try { - $catalog = BattlePresentationCatalog::load($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); + $catalog = BattlePresentationCatalog::loadCode($assets); + $bindings = $catalog === null ? null : $this->readProposedBattlerBindings(); + if ($catalog !== null && $bindings !== null) { + $catalog = $catalog->bindBattlers(BattlerBindings::getFromArray($bindings, $assets)); + } } catch (\Throwable $error) { throw new SessionRefusal('The battle presentation cannot be read: ' . $error->getMessage(), previous: $error); } @@ -1301,6 +1314,74 @@ private function requireBattleLayoutCatalog(string $purpose): BattlePresentation return $catalog; } + /** + * The battler bindings file as a save would write it now: the file as + * saved, with every battle art category's unsaved edits folded in. Null + * when the project binds no battle art as data. + * + * @return array|null + */ + private function readProposedBattlerBindings(): ?array + { + $root = $this->workspace->projectRoot; + $file = PhpDataFile::load($root . DIRECTORY_SEPARATOR . RecordSchemaCatalog::BATTLERS_PATH, $root); + $payload = is_array($file->payload) ? $file->payload : null; + foreach (DatabaseCatalog::getEmbedded() as $category) { + $database = $this->workspace->getRecordDatabase($category->key); + if ($database !== null && $database->isDirty()) { + $payload = $database->foldInto($payload ?? []); + } + } + + return $payload; + } + + /** + * Where an actor's or enemy's battle art is set: its record in the + * battle art category when it has one, and otherwise whether the + * project's presentation code binds it, which the editor leaves to the + * code rather than rewriting program-shaped source. + * + * @param 'actors'|'enemies' $side + * @return array{category: string, index: ?int, owner: 'data'|'code'|null, note?: string} + * @throws SessionRefusal When the side is unknown or the presentation cannot be read. + */ + public function describeBattlerArt(string $side, string $identity): array + { + $category = match ($side) { + 'actors' => 'battler_actors', + 'enemies' => 'battler_enemies', + default => throw new SessionRefusal(sprintf('Battle art is bound to actors or enemies, not %s.', $side)), + }; + $database = $this->requireRecordDatabase($category); + foreach ($database->getRecords() as $index => $record) { + if ($record->getDisplayValue('identity') === $identity) { + return ['category' => $category, 'index' => $index, 'owner' => 'data']; + } + } + try { + $code = BattlePresentationCatalog::loadCode($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); + } catch (\Throwable $error) { + throw new SessionRefusal('The battle presentation cannot be read: ' . $error->getMessage(), previous: $error); + } + $party = $side === 'actors'; + $owned = $code !== null && in_array($identity, [ + ...array_keys($party ? $code->actors : $code->enemies), + ...array_keys($party ? $code->actorPoses : $code->enemyPoses), + ...array_keys(($party ? $code->scale?->actors : $code->scale?->enemies) ?? []), + ], true); + + $described = ['category' => $category, 'index' => null, 'owner' => $owned ? 'code' : null]; + if ($code === null) { + $described['note'] = 'This project has no graphical battle to set art for.'; + } elseif ($owned) { + $described['note'] = sprintf('%s binds this art in code, which the editor does not rewrite. Once it moves to %s, it is set here.', + BattlePresentationCatalog::FILE, BattlerBindings::FILE); + } + + return $described; + } + /** * Composes enemies with the starting party over an arena, and describes * what every formation view shares: the canvas, the arenas with the one @@ -1585,15 +1666,17 @@ public function removeDatabaseItem(string $category, int $index, array $key): ar } /** - * Creates a blank record at the end of a category, as one undo step. + * Creates a blank record at the end of a category, as one undo step: + * for an identity when one is given (an enemy's battle art, made for + * that enemy), refused when that identity already has one. * * @return array{index: int, records: list} The new record's index, and the labels afterwards. * @throws SessionRefusal When the category is unknown, read-only or takes no new records. */ - public function createDatabaseRecord(string $category): array + public function createDatabaseRecord(string $category, ?string $identity = null): array { $database = $this->requireCategory($category); - $change = $this->changeRecord(static fn(): RecordChange => $database->createRecord()); + $change = $this->changeRecord(static fn(): RecordChange => $database->createRecord($identity)); return ['index' => (int) $change->index, 'records' => $database->getRecordLabels()]; } @@ -1662,8 +1745,17 @@ public function saveDatabase(string $category): array if (! $database->isEditable()) { throw new SessionRefusal(sprintf('Read-only: %s.', $database->getReadOnlyReason() ?? 'this category cannot be written')); } + // What the page edits beside the record (an enemy's battle art) is + // saved with it, first, so a save it refuses leaves the page unsaved. + $embedded = ['saved' => false, 'backupFailures' => []]; + foreach (DatabaseCatalog::getEmbedded() as $definition) { + if (in_array($category, $definition->hosts, true) && $this->workspace->getRecordDatabase($definition->key)?->isDirty()) { + $saved = $this->saveDatabase($definition->key); + $embedded = ['saved' => true, 'backupFailures' => [...$embedded['backupFailures'], ...$saved['backupFailures']]]; + } + } if (! $database->isDirty()) { - return ['saved' => false, 'warnings' => [], 'backupFailures' => []]; + return ['saved' => $embedded['saved'], 'warnings' => [], 'backupFailures' => $embedded['backupFailures']]; } $paths = $database->getBackupPaths(); $failures = $this->backups->isEnabled() && $paths !== [] ? $this->backups->backup(...$paths)['failed'] : []; @@ -1674,7 +1766,7 @@ public function saveDatabase(string $category): array throw new SessionRefusal($refusal->getMessage(), previous: $refusal); } - return ['saved' => true, 'warnings' => [], 'backupFailures' => array_values($failures)]; + return ['saved' => true, 'warnings' => [], 'backupFailures' => [...$embedded['backupFailures'], ...array_values($failures)]]; } /** @@ -2363,7 +2455,7 @@ private static function describeKey(array $field): ?array /** @throws SessionRefusal */ private function requireRecordDatabase(string $category): ProjectRecordDatabase { - if (! array_any(DatabaseCatalog::all(), static fn($definition): bool => $definition->key === $category)) { + if (! DatabaseCatalog::knows($category)) { throw new SessionRefusal(sprintf('There is no database category %s.', $category)); } diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 01b7073..b655b80 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -308,7 +308,7 @@ private function dispatch(string $method, array $params): array self::requireInt($params, 'index'), is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key database.record gave.'), ), - 'database.create' => $session->createDatabaseRecord(self::requireString($params, 'category')), + 'database.create' => $session->createDatabaseRecord(self::requireString($params, 'category'), self::readOptionalString($params, 'identity')), 'database.duplicate' => $session->duplicateDatabaseRecord(self::requireString($params, 'category'), self::requireInt($params, 'index')), 'database.delete' => $session->deleteDatabaseRecord(self::requireString($params, 'category'), self::requireInt($params, 'index')), 'database.move' => $session->moveDatabaseRecord( @@ -324,6 +324,7 @@ private function dispatch(string $method, array $params): array is_array($params['entries'] ?? null) ? $params['entries'] : throw new InvalidRequest('"entries" must be a list.'), is_array($params['writeTypes'] ?? null) ? array_values(array_filter($params['writeTypes'], is_string(...))) : null, ), + 'battlers.describe' => $session->describeBattlerArt(self::requireString($params, 'side'), self::requireString($params, 'identity')), 'affinities.vocabulary' => $session->describeAffinityVocabulary(), 'affinities.encode' => $session->encodeAffinities( is_array($params['entries'] ?? null) ? $params['entries'] : throw new InvalidRequest('"entries" must be a list.'), diff --git a/tests/Unit/Session/BattlerArtSessionTest.php b/tests/Unit/Session/BattlerArtSessionTest.php new file mode 100644 index 0000000..59829de --- /dev/null +++ b/tests/Unit/Session/BattlerArtSessionTest.php @@ -0,0 +1,116 @@ +readDatabaseRecord('battler_enemies', $index)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $field) + ?? throw new RuntimeException("No row {$field}."); +} + +function setBattlerRow(EditorSession $session, int $index, string $field, string $value): void +{ + $session->applyDatabaseRecord('battler_enemies', $index, readBattlerRow($session, $index, $field)['key'], $value); +} + +it('binds an enemy\'s battle art as data, previews it unsaved, and saves a file the battle reads', function () { + $root = battlerArtProject(); + $session = EditorSession::open($root); + + expect($session->describeBattlerArt('enemies', 'Regular Bat'))->toBe(['category' => 'battler_enemies', 'index' => null, 'owner' => null]); + + $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index']; + setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png'); + setBattlerRow($session, $index, 'artwork.pivot', '0.25, 1'); + + // The preview shows the art as set, before it is saved. + expect($session->readEnemyPreview(0)['formation']['members'][0]['battler']['image']['asset'] ?? null)->toBe('Graphics/Enemies/Bat.png') + ->and(file_exists($root . '/assets/' . BattlerBindings::FILE))->toBeFalse(); + + $session->saveDatabase('battler_enemies'); + + expect(BattlerBindings::load($root . '/assets')->enemies['Regular Bat'])->toEqual(new BattlerArtwork('Graphics/Enemies/Bat.png', 40, 80, 10, 80)) + ->and($session->describeBattlerArt('enemies', 'Regular Bat'))->toBe(['category' => 'battler_enemies', 'index' => 0, 'owner' => 'data']); +}); + +it('keeps poses as the role-keyed map the battle reads', function () { + $root = battlerArtProject(); + $session = EditorSession::open($root); + $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index']; + setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png'); + $heading = array_find($session->readDatabaseRecord('battler_enemies', $index)['rows'], static fn(array $row): bool => ($row['listHeading'] ?? false) === true); + $session->addDatabaseItem('battler_enemies', $index, $heading['key'], true); + setBattlerRow($session, $index, 'pose0Role', 'attack'); + setBattlerRow($session, $index, 'pose0Image', 'Graphics/Enemies/BatAttack.png'); + $session->saveDatabase('battler_enemies'); + + $written = require $root . '/assets/' . BattlerBindings::FILE; + + expect($written['enemies']['Regular Bat']['poses'])->toBe(['attack' => ['image' => 'Graphics/Enemies/BatAttack.png']]) + ->and(BattlerBindings::load($root . '/assets')->enemyPoses['Regular Bat']->roles)->toHaveKey('attack'); +}); + +it('saves an enemy\'s battle art with the enemy, as its page edits both', function () { + $root = battlerArtProject(); + $session = EditorSession::open($root); + $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index']; + setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png'); + + expect($session->saveDatabase('enemies')['saved'])->toBeTrue() + ->and(BattlerBindings::load($root . '/assets')->enemies)->toHaveKey('Regular Bat') + ->and($session->listUnsavedChanges())->toBe([]); +}); + +it('refuses to save art the battle could not read, writing nothing', function () { + $root = battlerArtProject(); + $session = EditorSession::open($root); + $session->createDatabaseRecord('battler_enemies', 'Regular Bat'); + + expect(fn() => $session->saveDatabase('battler_enemies'))->toThrow(SessionRefusal::class, 'a battler needs artwork, poses or both') + ->and(file_exists($root . '/assets/' . BattlerBindings::FILE))->toBeFalse(); +}); + +it('leaves art the presentation code binds to the code, and refuses a second owner for it', function () { + $root = battlerArtProject(); + $battle = (string) file_get_contents($root . '/assets/Data/Presentation/battle.php'); + file_put_contents($root . '/assets/Data/Presentation/battle.php', str_replace(' enemies: [],', + " enemies: ['Regular Bat' => new Ichiloto\\Engine\\Battle\\Presentation\\BattlerArtwork('Graphics/Enemies/Bat.png', 40, 80, 20, 80)],", $battle)); + $session = EditorSession::open($root); + + expect($session->describeBattlerArt('enemies', 'Regular Bat'))->toMatchArray(['index' => null, 'owner' => 'code']) + ->and($session->describeBattlerArt('enemies', 'Regular Bat')['note'])->toContain('binds this art in code'); + + $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index']; + setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png'); + + expect(fn() => $session->saveDatabase('battler_enemies'))->toThrow(SessionRefusal::class, 'Regular Bat is bound both in'); +}); + +it('makes one record per battler, and keeps battle art out of the terminal\'s category list', function () { + $session = EditorSession::open(battlerArtProject()); + $session->createDatabaseRecord('battler_enemies', 'Regular Bat'); + + expect(fn() => $session->createDatabaseRecord('battler_enemies', 'Regular Bat'))->toThrow(SessionRefusal::class, 'Regular Bat already has enemy battle art.') + ->and(fn() => $session->createDatabaseRecord('battler_enemies'))->toThrow(SessionRefusal::class, 'Enemy battle art is made for what it belongs to') + ->and(array_column(array_map(static fn($category): array => (array) $category, DatabaseCatalog::all()), 'key'))->not->toContain('battler_enemies', 'battler_actors', 'battle_scale') + ->and(fn() => $session->describeBattlerArt('troops', 'Pair'))->toThrow(SessionRefusal::class, 'actors or enemies'); +}); From 2db50cb15fe191e4b1ead1c2fcd63d4d05459588 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 00:03:03 +0200 Subject: [PATCH 169/344] fix(actors): rename actor identities in battle art bound as data An actor's battle art can now be bound in battlers.php, keyed by actor id under actors and naming the scale reference actor, but identity repair never read that file, so a rename would strand the actor's art. The inventory now lists it: actor keys and the reference follow the rename; enemies and image paths are left as they are. --- src/Actors/ActorReferenceInventory.php | 9 +++++++- tests/Unit/ActorIdentityMigrationTest.php | 26 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/Actors/ActorReferenceInventory.php b/src/Actors/ActorReferenceInventory.php index 9f945e0..8919c90 100644 --- a/src/Actors/ActorReferenceInventory.php +++ b/src/Actors/ActorReferenceInventory.php @@ -40,6 +40,12 @@ public static function getReferences(mixed $payload, string $relativePath): arra $references[] = ['path' => ['scale', 'actors', $id], 'reference' => $id, 'kind' => 'key']; } } + // Battler bindings (battlers.php) key actors under `actors`, read + // above, and name the scale reference actor; enemies are enemy identities. + $reference = $payload['reference'] ?? null; + if (is_array($reference) && array_key_exists('actor', $reference)) { + $references[] = ['path' => ['reference', 'actor'], 'reference' => $reference['actor'], 'kind' => 'value']; + } // Speaker labels bind to an actor or to a non-actor artwork resource. $resources = (array) ($payload['resources'] ?? []); foreach ((array) ($payload['speakers'] ?? []) as $label => $identity) { @@ -88,7 +94,8 @@ public static function getSourcePaths(string $root): array { $paths = []; foreach (['assets/Data/system.php', 'assets/Data/battle-entry-rules.php', 'assets/Data/troops.php', - 'assets/Data/Presentation/battle.php', 'assets/Data/Presentation/dialogue.php', 'assets/Data/Presentation/menus.php'] as $relative) { + 'assets/Data/Presentation/battle.php', 'assets/Data/Presentation/battlers.php', 'assets/Data/Presentation/dialogue.php', + 'assets/Data/Presentation/menus.php'] as $relative) { if (is_file($root . '/' . $relative)) { $paths[] = $root . '/' . $relative; } } foreach (['assets/Data/Skits', 'assets/Events', 'assets/Maps'] as $directory) { diff --git a/tests/Unit/ActorIdentityMigrationTest.php b/tests/Unit/ActorIdentityMigrationTest.php index 4d05852..5a896b2 100644 --- a/tests/Unit/ActorIdentityMigrationTest.php +++ b/tests/Unit/ActorIdentityMigrationTest.php @@ -352,3 +352,29 @@ function createLegacyActorProject(): array ->and(ActorIdentityMigration::planProject($root)->getChangedPaths())->toBe([]) ->and(new \Ichiloto\Editor\Validation\ActorReferenceValidator()->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)))->toBe([]); }); + +it('repairs battle art bound as data: actor keys and the scale reference, never enemies or image paths', function () { + [$root, $actorPath] = createLegacyActorProject(); + file_put_contents($actorPath, " ['id' => 'hero', 'name' => 'Kaelion']];"); + mkdir($root . '/assets/Data/Presentation'); + $battlers = <<<'SOURCE' + ['actor' => 'Kaelion', 'height' => 150], + 'actors' => [ + 'Kaelion' => ['artwork' => ['image' => 'Graphics/Kaelion/Idle.png'], 'scale' => ['relativeSize' => 1, 'sourceSpan' => 0.7]], + ], + 'enemies' => [ + 'Kaelion' => ['artwork' => ['image' => 'Graphics/Kaelion/Shade.png']], + ], +]; +SOURCE; + file_put_contents($root . '/assets/Data/Presentation/battlers.php', $battlers); + ActorIdentityMigration::migrateProject($root); + + expect(file_get_contents($root . '/assets/Data/Presentation/battlers.php'))->toBe(str_replace( + ["'actor' => 'Kaelion'", " 'Kaelion' => ['artwork' => ['image' => 'Graphics/Kaelion/Idle.png']"], + ["'actor' => 'hero'", " 'hero' => ['artwork' => ['image' => 'Graphics/Kaelion/Idle.png']"], $battlers)) + ->and(ActorIdentityMigration::planProject($root)->getChangedPaths())->toBe([]); +}); From 356f4b81914bfe015c39015185b97635a25a0d0d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 02:00:47 +0200 Subject: [PATCH 170/344] feat(session): preview an actor at battle scale for its battle art An actor's battle art could be set as data but had nothing to show it on. actors.preview composes the actor in the lead party slot, the rest of the starting party beside it, through the Engine's formation layout with the art as it is now, unsaved edits included. An actor without a stable id has no art to bind and says so. --- src/Session/EditorSession.php | 39 +++++++++++++++++++- src/Session/SessionHost.php | 2 + tests/Unit/Session/BattlerArtSessionTest.php | 19 ++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index bddcdf9..ad4aae9 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1265,6 +1265,37 @@ public function readEnemyPreview(int $index, ?string $arena = null): array return $preview; } + /** + * An actor as Database > Actors shows it in battle: its art composed by + * the Engine's BattleFormationLayout at battle scale in the lead party + * slot, the rest of the starting party beside it for comparison. It reads + * the art as it is now, unsaved edits included. An actor without a + * stable id has no battle art to bind, and says so. + * + * @return array{name: string, identity: ?string, formation: ?array, formationIssue: ?string} + * @throws SessionRefusal When the actor is unknown. + */ + public function readActorPreview(int $index, ?string $arena = null): array + { + $actor = array_values($this->workspace->actorDatabase->getActors())[$index] + ?? throw new SessionRefusal(sprintf('actors has no record %d.', $index)); + $identity = $actor->hasDefinitionId() ? $actor->getDefinitionId() : null; + $preview = ['name' => $actor->getName(), 'identity' => $identity, 'formation' => null, 'formationIssue' => null]; + if ($identity === null) { + $preview['formationIssue'] = 'This actor has no stable id yet, which its battle art is bound to. Repair actor identities first.'; + + return $preview; + } + try { + [, $view] = $this->composeFormation($this->requireBattleLayoutCatalog('preview actors at battle scale on'), $arena, [], $identity); + $preview['formation'] = [...$view, 'members' => []]; + } catch (SessionRefusal $refusal) { + $preview['formationIssue'] = $refusal->getMessage(); + } + + return $preview; + } + /** * An enemy's terminal sprite rows, read by the game's own rule * (`Graphics/Enemies/.txt`), never from outside that folder. @@ -1390,7 +1421,7 @@ public function describeBattlerArt(string $side, string $identity): array * @param list $enemies * @return array{0: BattleFormationLayout, 1: array} */ - private function composeFormation(BattlePresentationCatalog $catalog, ?string $arena, array $enemies): array + private function composeFormation(BattlePresentationCatalog $catalog, ?string $arena, array $enemies, ?string $lead = null): array { $assetRoot = $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; $names = []; @@ -1398,7 +1429,11 @@ private function composeFormation(BattlePresentationCatalog $catalog, ?string $a $names[$actor->getDefinitionId()] = $actor->getName(); } $starting = $this->workspace->getSystemField('startingParty'); - $partyIds = array_values(array_slice(array_filter(is_array($starting) ? $starting : [], is_string(...)), 0, count($catalog->ui->partySlots))); + $partyIds = array_values(array_filter(is_array($starting) ? $starting : [], is_string(...))); + if ($lead !== null) { + $partyIds = [$lead, ...array_filter($partyIds, static fn(string $id): bool => $id !== $lead)]; + } + $partyIds = array_values(array_slice($partyIds, 0, count($catalog->ui->partySlots))); try { $formation = BattleFormationLayout::compose($catalog, $arena, $enemies, $partyIds, $assetRoot); diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index b655b80..7e2b04b 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -283,6 +283,8 @@ private function dispatch(string $method, array $params): array 'database.records' => $session->listDatabaseRecords(self::requireString($params, 'category')), 'troops.formation' => $session->readTroopFormation(self::requireInt($params, 'index'), is_string($params['arena'] ?? null) ? $params['arena'] : null), + 'actors.preview' => $session->readActorPreview(self::requireInt($params, 'index'), + is_string($params['arena'] ?? null) ? $params['arena'] : null), 'enemies.preview' => $session->readEnemyPreview(self::requireInt($params, 'index'), is_string($params['arena'] ?? null) ? $params['arena'] : null), 'database.record' => $session->readDatabaseRecord( diff --git a/tests/Unit/Session/BattlerArtSessionTest.php b/tests/Unit/Session/BattlerArtSessionTest.php index 59829de..d2953ca 100644 --- a/tests/Unit/Session/BattlerArtSessionTest.php +++ b/tests/Unit/Session/BattlerArtSessionTest.php @@ -114,3 +114,22 @@ function setBattlerRow(EditorSession $session, int $index, string $field, string ->and(array_column(array_map(static fn($category): array => (array) $category, DatabaseCatalog::all()), 'key'))->not->toContain('battler_enemies', 'battler_actors', 'battle_scale') ->and(fn() => $session->describeBattlerArt('troops', 'Pair'))->toThrow(SessionRefusal::class, 'actors or enemies'); }); + +it('previews an actor in the lead party slot with its art as set, unsaved edits included', function () { + $root = battlerArtProject(); + writeTilesetTestPng($root . '/assets/Graphics/Actors/Kaelion.png', 50, 100); + $session = EditorSession::open($root); + $actor = array_search('Kaelion', $session->listDatabaseRecords('actors')['records'], true); + $identity = $session->readActorPreview($actor)['identity']; + + $art = $session->createDatabaseRecord('battler_actors', $identity)['index']; + $image = array_find($session->readDatabaseRecord('battler_actors', $art)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'artwork.image'); + $session->applyDatabaseRecord('battler_actors', $art, $image['key'], 'Graphics/Actors/Kaelion.png'); + $preview = $session->readActorPreview($actor); + + expect($preview['formationIssue'])->toBeNull() + ->and($preview['formation']['party'][0])->toMatchArray(['name' => 'Kaelion', 'ground' => ['x' => 1050.0, 'y' => 420.0]]) + ->and($preview['formation']['party'][0]['image']['asset'] ?? null)->toBe('Graphics/Actors/Kaelion.png') + ->and($preview['formation']['members'])->toBe([]) + ->and($session->describeBattlerArt('actors', $identity))->toMatchArray(['index' => $art, 'owner' => 'data']); +}); From de212d8c4345b689ee831c117acdaf7ed7f7c84c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 02:10:01 +0200 Subject: [PATCH 171/344] feat(animations): convert a legacy cell-frame animation to a timeline A legacy animation record could only be played by the compatibility reader; nothing in the editor turned it into a timeline. The conversion uses the Engine's LegacyAnimationMigration, at the timing the author chooses for its consumer: battle-phase cadence or a fixed rate, the ticks each original frame lasts, the rest frame for reduced motion, and whether its flash cues become flash tracks. Nothing supplies a rate. It is one reversible source set: the new Animations//.timeline.php, and, when a battle is to play it, the record's caster or target effect naming it, inserted into the file's own source. The record's legacy frames and cues stay as written. The author is asked first, shown each file as it would be written, and the write is one undo step. Before any write it refuses: - a record with nothing to convert; - a name already taken, or a binding the record already has; - timing its consumer cannot compile; - a field timeline paced by battle phases; - pending edits; - an animations file it cannot edit in place. animations.conversion lists the record's frames, cues and bindings, and the skills, items, roles and field scripts that play it now. A field script keeps playing the record until its own command names the timeline. - Source set plans can now create a file, and remove it with its folder on undo. - The effect validator's field_animation traversal is shared as visitFieldAnimations. --- src/Animations/LegacyAnimationConversion.php | 240 ++++++++++++++++++ src/Session/EditorSession.php | 63 +++++ src/Session/SessionHost.php | 12 + src/Storage/SourceSetPlan.php | 29 ++- src/Validation/EffectValidator.php | 28 +- .../AnimationConversionSessionTest.php | 111 ++++++++ 6 files changed, 475 insertions(+), 8 deletions(-) create mode 100644 src/Animations/LegacyAnimationConversion.php create mode 100644 tests/Unit/Session/AnimationConversionSessionTest.php diff --git a/src/Animations/LegacyAnimationConversion.php b/src/Animations/LegacyAnimationConversion.php new file mode 100644 index 0000000..03245aa --- /dev/null +++ b/src/Animations/LegacyAnimationConversion.php @@ -0,0 +1,240 @@ +/.timeline.php`, + * made by the Engine's {@see LegacyAnimationMigration}, and, when a battle + * should play it, the record's caster or target effect naming it. + * + * Timing belongs to the consumer, so nothing here supplies a rate: the + * author chooses battle-phase cadence (a battle paces it) or a fixed rate, + * the ticks each original frame lasts, and the rest frame shown with + * reduced motion. The record's legacy frames and cues stay as written; the + * compatibility reader still plays them wherever nothing names the + * timeline. Field scripts that play the record keep doing so until their + * own command is pointed at the timeline; a field consumer's timing may + * differ from a battle's, and one record may need several timelines. + * + * Planning writes nothing. It refuses, before any write, a record with + * nothing to convert, a timeline name already taken, a binding the record + * already has, a timing the Engine cannot compile for its consumer, and an + * animations file whose source the editor cannot edit in place. + */ +final class LegacyAnimationConversion +{ + /** Where the record says a battle plays a timeline. */ + public const array BINDINGS = ['sourceEffect', 'targetEffect']; + + private const string ANIMATIONS = 'assets/Data/animations.php'; + + /** + * What converting the record would touch: its legacy content, its + * current bindings, and every consumer that plays it now. + * + * @return array{id: int, name: string, frames: int, maxFrames: int, position: string, cues: int, flashes: int, + * sourceEffect: ?string, targetEffect: ?string, battle: list, field: list} + * @throws InvalidArgumentException When there is no such record, or it has nothing to convert. + */ + public static function describe(ProjectWorkspace $workspace, int $id): array + { + $entry = self::requireEntry(self::readEntries($workspace->projectRoot), $id)[1]; + $animation = Animation::fromArray($entry); + $cues = array_filter(array_map(static fn(int $frame): mixed => $animation->getCue($frame), range(1, $animation->maxFrames))); + + return [ + 'id' => $id, + 'name' => $animation->name, + // The frames authored; the rest of the frame count are blank holds. + 'frames' => count(array_filter((array) ($entry['frames'] ?? []), is_array(...))), + 'maxFrames' => $animation->maxFrames, + 'position' => $animation->position->value, + 'cues' => count($cues), + 'flashes' => count(array_filter($cues, static fn($cue): bool => $cue->flashColor !== null && $cue->flashDurationFrames > 0)), + 'sourceEffect' => is_string($entry['sourceEffect'] ?? null) ? $entry['sourceEffect'] : null, + 'targetEffect' => is_string($entry['targetEffect'] ?? null) ? $entry['targetEffect'] : null, + 'battle' => self::findBattleConsumers($workspace, $id, $animation), + 'field' => self::findFieldConsumers($workspace, $id, $animation->name), + ]; + } + + /** + * Plans the conversion without writing. + * + * @param EffectCadence $cadence Battle-phase cadence (paced by a battle) or a fixed rate. + * @param int|null $fps The fixed rate; required for fixed cadence, refused for battle-phase. + * @param int $ticksPerFrame How many timeline frames each original frame lasts. + * @param int $restFrame The original frame (zero-based, flash tails included) shown with reduced motion. + * @param bool $includeFlash Whether the record's flash cues become flash tracks. + * @param string|null $binding `sourceEffect` or `targetEffect` when a battle should play it, or null. + * @throws InvalidArgumentException With the reason, when the conversion cannot be made as asked. + */ + public static function plan(ProjectWorkspace $workspace, int $id, string $timelineId, EffectCadence $cadence, ?int $fps, + int $ticksPerFrame, int $restFrame, bool $includeFlash, ?string $binding): SourceSetPlan + { + $root = $workspace->projectRoot; + $entries = self::readEntries($root); + [$index, $entry] = self::requireEntry($entries, $id); + if ($binding !== null && ! in_array($binding, self::BINDINGS, true)) { + throw new InvalidArgumentException(sprintf('A battle plays a timeline as the record\'s %s, not %s.', implode(' or ', self::BINDINGS), $binding)); + } + if ($binding !== null && is_string($entry[$binding] ?? null) && $entry[$binding] !== '') { + throw new InvalidArgumentException(sprintf('%s already plays %s as its %s; convert it for another consumer or clear that first.', + strval($entry['name'] ?? $id), $entry[$binding], $binding)); + } + if ($binding === null && $cadence === EffectCadence::BATTLE_PHASE) { + throw new InvalidArgumentException('Only a battle paces a timeline by its phases; bind it to the record for battle, or give it a fixed rate.'); + } + EffectTimelineLibrary::assertId($timelineId); + $folder = $root . '/assets/' . EffectTimelineLibrary::DIRECTORY . '/' . $timelineId; + $timelinePath = $folder . '/' . $timelineId . '.timeline.php'; + if (file_exists($folder)) { + throw new InvalidArgumentException(sprintf('A timeline named %s already exists; choose a name of its own.', $timelineId)); + } + + $data = LegacyAnimationMigration::getTimelineData($timelineId, Animation::fromArray($entry), $cadence, $ticksPerFrame, $restFrame, $fps, $includeFlash); + if ($binding === null) { + // A field consumer compiles it outside battle, where battle-only keys are refused. + new EffectTimelineLibrary('')->compile($timelineId, $data); + } + $timelineSource = " PhpDataFile::getComparableValue(PhpDataFile::evaluateIsolated($staged, $root)); + + // The record changes only when a battle is to play the timeline. + $record = $binding === null ? [] : [$path => $source]; + + return new SourceSetPlan( + $root, + [$path => $source], + [...$record, $timelinePath => null], + [...($binding === null ? [] : [$path => $proposed]), $timelinePath => $timelineSource], + [...($binding === null ? [] : [$path => PhpDataFile::getComparableValue($entries)]), $timelinePath => null], + [...($binding === null ? [] : [$path => PhpDataFile::getComparableValue($after)]), $timelinePath => PhpDataFile::getComparableValue($data)], + static fn(): array => is_file($path) ? [$path] : [], + $readBack, + 'animation conversion', + 'The animations file', + ); + } + + /** @return list The animations file's records, as the runtime reads them. */ + private static function readEntries(string $root): array + { + $file = PhpDataFile::load($root . '/' . self::ANIMATIONS, $root); + if (! is_array($file->payload) || ! array_is_list($file->payload)) { + throw new InvalidArgumentException(sprintf('%s is not a list of animation records the editor can read.', self::ANIMATIONS)); + } + + return $file->payload; + } + + /** + * @param list $entries + * @return array{0: int, 1: array} The record's position and data. + */ + private static function requireEntry(array $entries, int $id): array + { + foreach ($entries as $index => $entry) { + if (is_array($entry) && ($entry['id'] ?? null) === $id) { + if (array_filter((array) ($entry['frames'] ?? [])) === [] && array_filter((array) ($entry['cues'] ?? [])) === []) { + throw new InvalidArgumentException(sprintf('%s has no legacy frames or cues to convert.', strval($entry['name'] ?? $id))); + } + + return [$index, $entry]; + } + } + + throw new InvalidArgumentException(sprintf('There is no animation %d.', $id)); + } + + /** The animations file with the record naming the timeline, edited in place. */ + private static function bindInSource(string $source, int $index, string $binding, string $timelineId): string + { + try { + $document = PhpArraySourceDocument::parse($source); + } catch (SourceUnreadable $unreadable) { + throw new InvalidArgumentException(sprintf('%s cannot be edited in place: %s', self::ANIMATIONS, $unreadable->getMessage()), previous: $unreadable); + } + $record = $document->nodeAt([$index]); + if ($record === null || $record->kind !== SourceNode::ARRAY || $record->hasOpaqueKey) { + throw new InvalidArgumentException(sprintf('Animation record %d is not a literal array the editor can edit in place.', $index + 1)); + } + + try { + return $document->withEdits([$document->insertEntryEdit([$index], count($record->entries), $binding, var_export($timelineId, true))])->source; + } catch (RuntimeException $failure) { + throw new InvalidArgumentException(sprintf('%s cannot take the binding in place: %s', self::ANIMATIONS, $failure->getMessage()), previous: $failure); + } + } + + /** @return list The skills and items that play the record in battle, and the roles it holds. */ + private static function findBattleConsumers(ProjectWorkspace $workspace, int $id, Animation $animation): array + { + $consumers = []; + foreach ($workspace->getRecordDatabase('skills')?->getRecords() ?? [] as $record) { + try { + $skill = SkillRecord::readSkill((array) $record->toArray()); + } catch (InvalidArgumentException) { + continue; + } + if ($skill->animationId === $id) { + $consumers[] = sprintf('skill %s', $skill->name); + } + } + foreach ($workspace->getRecordDatabase('items')?->getRecords() ?? [] as $item) { + if ($item->get('animationId') === $id) { + $consumers[] = sprintf('item %s', strval($item->get('name'))); + } + } + foreach ($animation->roles as $role) { + $consumers[] = sprintf('every action with role %s', $role); + } + + return $consumers; + } + + /** @return list The scripts whose field animations play the record, by id or by name. */ + private static function findFieldConsumers(ProjectWorkspace $workspace, int $id, string $name): array + { + $uses = []; + EffectValidator::visitFieldAnimations($workspace, $workspace->cutscenes?->assets(CutsceneType::CINEMATIC) ?? [], + static function (array $command, string $where) use (&$uses, $id, $name): void { + // A legacy reference names the record by id or by name. + if (in_array($command['animation'] ?? $command['id'] ?? null, [$id, $name], true)) { + $uses[] = $where; + } + }); + + return array_values(array_unique($uses)); + } +} diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index ad4aae9..f38bcdb 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -11,7 +11,9 @@ use Ichiloto\Editor\Canvas\PieceRole; use Ichiloto\Editor\Database\ConditionCodec; use Ichiloto\Editor\Database\ConditionEditor; +use Ichiloto\Editor\Animations\LegacyAnimationConversion; use Ichiloto\Editor\Database\DatabaseCatalog; +use Ichiloto\Engine\Animations\Timelines\EffectCadence; use Ichiloto\Editor\Database\ElementAffinityCodec; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\RecordItem; @@ -1367,6 +1369,67 @@ private function readProposedBattlerBindings(): ?array return $payload; } + /** + * What converting a legacy cell-frame animation to a timeline would + * touch: its frames and cues, its current battle bindings, and the + * battle and field consumers that play it now. + * + * @return array As {@see LegacyAnimationConversion::describe()}. + * @throws SessionRefusal When the record is unknown or has nothing to convert. + */ + public function describeAnimationConversion(int $index): array + { + try { + return LegacyAnimationConversion::describe($this->workspace, $this->requireAnimationId($index)); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + } + + /** + * Converts a legacy cell-frame animation to a timeline with the timing + * its consumer needs, asked first and written as one undo step: the new + * timeline and, when a battle is to play it, the record naming it. The + * question carries each file as it would be written. Nothing supplies a + * rate; the author's cadence, ticks and rest frame are the timeline's. + * + * @param 'battle_phase'|'fixed' $cadence + * @param 'sourceEffect'|'targetEffect'|null $binding + * @return array The question with `preview` (path to source), or the write's result. + * @throws SessionRefusal When the conversion cannot be made as asked, edits are pending, or the files cannot be written. + */ + public function convertAnimation(int $index, string $timelineId, string $cadence, ?int $fps, int $ticksPerFrame, + int $restFrame, bool $includeFlash, ?string $binding, ?string $answer = null): array + { + $id = $this->requireAnimationId($index); + try { + $plan = LegacyAnimationConversion::plan($this->workspace, $id, $timelineId, EffectCadence::parse($cadence), + $fps, $ticksPerFrame, $restFrame, $includeFlash, $binding); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + $root = rtrim($this->workspace->projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $relative = static fn(string $path): string => str_starts_with($path, $root) ? substr($path, strlen($root)) : $path; + $paths = array_map($relative, $plan->getChangedPaths()); + $name = strval($this->requireRecordDatabase('animations')->getRecordByIndex($index)?->get('name')); + $required = new SourceSetRequired($plan, sprintf('Convert %s to timeline %s', $name, $timelineId), 'this animation conversion', + sprintf('Convert %s to the timeline %s, writing %s?', $name, $timelineId, implode(' and ', $paths)), $paths); + $result = $this->writeSourceSet($required, $answer, fn(): array => $this->requireCategory('animations')->getRecordLabels()); + if ($answer === null) { + $result['preview'] = array_combine($paths, array_values($plan->getProposedSources())); + } + + return $result; + } + + /** @throws SessionRefusal When the animations category has no record there. */ + private function requireAnimationId(int $index): int + { + $id = $this->requireRecordDatabase('animations')->getRecordByIndex($index)?->get('id'); + + return is_int($id) ? $id : throw new SessionRefusal(sprintf('animations has no record %d with an id.', $index)); + } + /** * Where an actor's or enemy's battle art is set: its record in the * battle art category when it has one, and otherwise whether the diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 7e2b04b..f6518dc 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -327,6 +327,18 @@ private function dispatch(string $method, array $params): array is_array($params['writeTypes'] ?? null) ? array_values(array_filter($params['writeTypes'], is_string(...))) : null, ), 'battlers.describe' => $session->describeBattlerArt(self::requireString($params, 'side'), self::requireString($params, 'identity')), + 'animations.conversion' => $session->describeAnimationConversion(self::requireInt($params, 'index')), + 'animations.convert' => $session->convertAnimation( + self::requireInt($params, 'index'), + self::requireString($params, 'timeline'), + self::requireString($params, 'cadence'), + is_int($params['fps'] ?? null) ? $params['fps'] : null, + self::requireInt($params, 'ticksPerFrame'), + self::requireInt($params, 'restFrame'), + is_bool($params['includeFlash'] ?? true) ? ($params['includeFlash'] ?? true) : throw new InvalidRequest('"includeFlash" must be a boolean.'), + self::readOptionalString($params, 'binding'), + self::readOptionalString($params, 'answer'), + ), 'affinities.vocabulary' => $session->describeAffinityVocabulary(), 'affinities.encode' => $session->encodeAffinities( is_array($params['entries'] ?? null) ? $params['entries'] : throw new InvalidRequest('"entries" must be a list.'), diff --git a/src/Storage/SourceSetPlan.php b/src/Storage/SourceSetPlan.php index 918e04e..920fead 100644 --- a/src/Storage/SourceSetPlan.php +++ b/src/Storage/SourceSetPlan.php @@ -25,7 +25,8 @@ final class SourceSetPlan /** * @param string $root The project root. * @param array $watched Every source the plan read, by path. - * @param array $originals The bytes of each changed file. + * @param array $originals The bytes of each changed file, null for a + * file the plan creates: it must not exist until applied, and reverting removes it again. * @param array $proposals The planned bytes of each changed file. * @param array $beforeValues What each changed file reads back as now. * @param array $afterValues What each changed file must read back as once written. @@ -50,7 +51,7 @@ public function __construct( /** @return list */ public function getChangedPaths(): array { return array_keys($this->proposals); } - /** @return array */ + /** @return array Null for a file the plan creates. */ public function getOriginalSources(): array { return $this->originals; } /** @return array */ @@ -71,15 +72,29 @@ public function revert(): void if ($this->applied) { $this->installSources($this->originals, $this->beforeValues); $this->applied = false; + // A folder only the plan's created file lived in goes with it. + foreach (array_keys(array_filter($this->originals, static fn(?string $source): bool => $source === null)) as $path) { + $folder = dirname($path); + if (is_dir($folder) && (scandir($folder) ?: []) === ['.', '..']) { + rmdir($folder); + } + } } } + /** @param array $sources Null removes a file. */ private function installSources(array $sources, array $expectedValues): void { $this->assertSourcesUnchanged(); if ($sources === []) { return; } $transaction = new FileSetTransaction($this->root . '/assets'); - foreach ($sources as $path => $source) { $transaction->write($path, $source); } + foreach ($sources as $path => $source) { + if ($source === null) { + $transaction->remove($path); + } else { + $transaction->write($path, $source); + } + } try { $staged = $transaction->stage(); $this->assertSourcesUnchanged(); @@ -98,6 +113,14 @@ private function installSources(array $sources, array $expectedValues): void private function assertSourcesUnchanged(): void { + foreach ($this->originals as $path => $source) { + if ($source === null && is_file($path) !== $this->applied) { + throw new RuntimeException("{$path} " . ($this->applied ? 'was removed' : 'appeared') . " outside the {$this->subject}; reload and plan again."); + } + if ($source === null && $this->applied && file_get_contents($path) !== $this->proposals[$path]) { + throw new RuntimeException("{$path} changed outside the {$this->subject}; reload and plan again."); + } + } foreach ($this->watched as $path => $source) { $expected = $this->applied ? ($this->proposals[$path] ?? $source) : $source; if (! is_file($path) || file_get_contents($path) !== $expected) { diff --git a/src/Validation/EffectValidator.php b/src/Validation/EffectValidator.php index 719a165..fd421d9 100644 --- a/src/Validation/EffectValidator.php +++ b/src/Validation/EffectValidator.php @@ -169,13 +169,33 @@ public static function findUses(ProjectWorkspace $workspace, array &$issues = [] public static function findScriptUses(ProjectWorkspace $workspace, iterable $cinematics): array { $uses = []; - $collect = static function (mixed $value, string $where) use (&$uses, &$collect): void { + self::visitFieldAnimations($workspace, $cinematics, static function (array $command, string $where) use (&$uses): void { + if (is_string($command['effect'] ?? null) && $command['effect'] !== '') { + $uses[$command['effect']][] = $where; + } + }); + + return array_map(static fn(array $places): array => array_values(array_unique($places)), $uses); + } + + /** + * Visits every `field_animation` command a script plays: map events, + * event scripts and cinematics, at any depth of branches, lanes and + * choices, since the command is the same wherever it is nested. What + * cannot be read is not visited; validation reports it. + * + * @param iterable $cinematics The project's cinematics, as the editor holds them. + * @param \Closure(array, string): void $visit Given the command and where it is. + */ + public static function visitFieldAnimations(ProjectWorkspace $workspace, iterable $cinematics, \Closure $visit): void + { + $collect = static function (mixed $value, string $where) use (&$collect, $visit): void { if (! is_array($value)) { return; } - if (($value['type'] ?? null) === 'field_animation' && is_string($value['effect'] ?? null) && $value['effect'] !== '') { - $uses[$value['effect']][] = $where; + if (($value['type'] ?? null) === 'field_animation') { + $visit($value, $where); } foreach ($value as $nested) { @@ -198,7 +218,5 @@ public static function findScriptUses(ProjectWorkspace $workspace, iterable $cin foreach ($cinematics as $cinematic) { $collect([$cinematic->data(), $cinematic->partner()], sprintf('cinematic %s', $cinematic->id)); } - - return array_map(static fn(array $places): array => array_values(array_unique($places)), $uses); } } diff --git a/tests/Unit/Session/AnimationConversionSessionTest.php b/tests/Unit/Session/AnimationConversionSessionTest.php new file mode 100644 index 0000000..70c0021 --- /dev/null +++ b/tests/Unit/Session/AnimationConversionSessionTest.php @@ -0,0 +1,111 @@ + 1, + 'name' => 'Old Spark', + 'position' => 'center', + 'maxFrames' => 3, + 'frames' => [ + ['index' => 1, 'cells' => [['x' => 0, 'y' => 0, 'symbol' => '*', 'color' => 'yellow']]], + ['index' => 2, 'cells' => [['x' => 1, 'y' => 0, 'symbol' => '+']]], + ], + 'cues' => [['frame' => 1, 'soundEffect' => 'spark', 'flashColor' => 'white', 'flashDurationFrames' => 2]], + ], + ['id' => 2, 'name' => 'Already Timed', 'targetEffect' => 'shared-burst'], +]; +PHP; + +/** A project whose animations hold one legacy record and one already on a timeline. */ +function legacyAnimationProject(): string +{ + $root = makeTemporaryProject(); + file_put_contents($root . '/assets/Data/animations.php', LEGACY_ANIMATIONS); + + return $root; +} + +function legacyAnimationIndex(EditorSession $session, string $name): int +{ + return array_search($name, $session->listDatabaseRecords('animations')['records'], true); +} + +it('describes what a conversion touches: the legacy frames and cues, its bindings and who plays it', function () { + $session = EditorSession::open(legacyAnimationProject()); + + expect($session->describeAnimationConversion(legacyAnimationIndex($session, 'Old Spark')))->toMatchArray([ + 'id' => 1, 'name' => 'Old Spark', 'frames' => 2, 'maxFrames' => 3, 'position' => 'center', 'cues' => 1, 'flashes' => 1, + 'sourceEffect' => null, 'targetEffect' => null, 'field' => [], + ])->and(fn() => $session->describeAnimationConversion(legacyAnimationIndex($session, 'Already Timed'))) + ->toThrow(SessionRefusal::class, 'has no legacy frames or cues to convert'); +}); + +it('asks first, then writes the timeline and the battle binding as one undo step, keeping the legacy frames', function () { + $root = legacyAnimationProject(); + $session = EditorSession::open($root); + $index = legacyAnimationIndex($session, 'Old Spark'); + $convert = static fn(?string $answer) => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'targetEffect', $answer); + $timeline = $root . '/assets/Animations/old-spark/old-spark.timeline.php'; + + $asked = $convert(null); + + expect($asked['status'])->toBe('question') + ->and(array_keys($asked['preview']))->toBe(['assets/Data/animations.php', 'assets/Animations/old-spark/old-spark.timeline.php']) + ->and($asked['preview']['assets/Data/animations.php'])->toContain("// A spark in the old cell frames.", "'targetEffect' => 'old-spark'") + ->and(file_exists($timeline))->toBeFalse(); + + expect($convert('write'))->toMatchArray(['changed' => true, 'reloaded' => true]) + ->and(file_get_contents($root . '/assets/Data/animations.php'))->toBe($asked['preview']['assets/Data/animations.php']) + ->and(file_get_contents($root . '/assets/Data/animations.php'))->toContain("'frames' => [", "'cues' => [") + ->and(new EffectTimelineLibrary($root . '/assets')->load('old-spark', forBattle: true))->not->toBeNull(); + + $session->undo(); + + expect(file_get_contents($root . '/assets/Data/animations.php'))->toBe(LEGACY_ANIMATIONS) + ->and(file_exists($timeline))->toBeFalse() + ->and(is_dir(dirname($timeline)))->toBeFalse(); +}); + +it('converts for a field consumer at an explicit fixed rate, leaving the record as it is', function () { + $root = legacyAnimationProject(); + $session = EditorSession::open($root); + + $session->convertAnimation(legacyAnimationIndex($session, 'Old Spark'), 'field-old-spark', 'fixed', 25, 3, 0, false, null, 'write'); + $written = require $root . '/assets/Animations/field-old-spark/field-old-spark.timeline.php'; + + expect(file_get_contents($root . '/assets/Data/animations.php'))->toBe(LEGACY_ANIMATIONS) + ->and($written)->toMatchArray(['fps' => 25, 'lengthFrames' => 9, 'restFrame' => 0]) + ->and(new EffectTimelineLibrary($root . '/assets')->load('field-old-spark'))->not->toBeNull(); +}); + +it('refuses, before writing, timing its consumer cannot use, a taken name, and pending edits', function () { + $root = legacyAnimationProject(); + $session = EditorSession::open($root); + $index = legacyAnimationIndex($session, 'Old Spark'); + mkdir($root . '/assets/Animations/taken', 0o777, true); + + expect(fn() => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, null))->toThrow(SessionRefusal::class, 'Only a battle paces') + ->and(fn() => $session->convertAnimation($index, 'old-spark', 'fixed', null, 1, 0, true, null))->toThrow(SessionRefusal::class, 'explicit consumer cadence') + ->and(fn() => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 9, true, 'targetEffect'))->toThrow(SessionRefusal::class, 'rest frame') + ->and(fn() => $session->convertAnimation($index, 'taken', 'battle_phase', null, 1, 0, true, 'targetEffect'))->toThrow(SessionRefusal::class, 'already exists') + ->and(fn() => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'sideEffect'))->toThrow(SessionRefusal::class, 'sourceEffect or targetEffect'); + + $name = array_find($session->readDatabaseRecord('animations', $index)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'name'); + $session->applyDatabaseRecord('animations', $index, $name['key'], 'Renamed Spark'); + + expect(fn() => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'targetEffect', 'write')) + ->toThrow(SessionRefusal::class, 'Save or undo pending edits') + ->and(file_exists($root . '/assets/Animations/old-spark'))->toBeFalse(); +}); From cb25b7949969f75d403483705b7a80de0d8f7546 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 02:21:35 +0200 Subject: [PATCH 172/344] fix(session): write only the file set the author was shown, and group one gesture's edits A file set written at once (an actor identity repair, an animation conversion) was asked about, then planned again from the request when the answer came, and that fresh plan was written. With other choices, or files changed in between, the bytes written were not the ones shown. A source set plan now has a fingerprint of every source it read and every byte it would write. The question carries it, and writing requires it back: a plan that does not match is refused with nothing written, and the author is asked again. The write answer now reads "Write N files now", not "repaired". database.applyMany sets several of a record's rows as one undo step, through each field's own rules. If one value is refused, the others are taken back. A click setting the ground point of a base image and the idle pose showing it is then one action. --- src/History/CommandGroup.php | 43 ++++++++++ src/Session/EditorSession.php | 80 ++++++++++++++++--- src/Session/SessionHost.php | 8 ++ src/Storage/SourceSetPlan.php | 10 +++ tests/Unit/Session/ActorSessionTest.php | 15 +++- .../AnimationConversionSessionTest.php | 30 ++++++- tests/Unit/Session/BattlerArtSessionTest.php | 31 +++++++ 7 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 src/History/CommandGroup.php diff --git a/src/History/CommandGroup.php b/src/History/CommandGroup.php new file mode 100644 index 0000000..27ada07 --- /dev/null +++ b/src/History/CommandGroup.php @@ -0,0 +1,43 @@ + $commands The commands, in the order they were made. + */ + public function __construct( + public readonly string $label, + private readonly array $commands, + ) { + } + + /** + * @inheritDoc + */ + public function execute(): void + { + foreach ($this->commands as $command) { + $command->execute(); + } + } + + /** + * @inheritDoc + */ + public function undo(): void + { + foreach (array_reverse($this->commands) as $command) { + $command->undo(); + } + } +} diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index f38bcdb..5d7e643 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -50,6 +50,7 @@ use Ichiloto\Editor\Maps\MapReferences; use Ichiloto\Editor\Maps\TilePalette; use Ichiloto\Editor\History\SourceSetCommand; +use Ichiloto\Editor\History\CommandGroup; use Ichiloto\Editor\History\SourceSetRequired; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; @@ -1399,7 +1400,7 @@ public function describeAnimationConversion(int $index): array * @throws SessionRefusal When the conversion cannot be made as asked, edits are pending, or the files cannot be written. */ public function convertAnimation(int $index, string $timelineId, string $cadence, ?int $fps, int $ticksPerFrame, - int $restFrame, bool $includeFlash, ?string $binding, ?string $answer = null): array + int $restFrame, bool $includeFlash, ?string $binding, ?string $answer = null, ?string $confirm = null): array { $id = $this->requireAnimationId($index); try { @@ -1414,7 +1415,7 @@ public function convertAnimation(int $index, string $timelineId, string $cadence $name = strval($this->requireRecordDatabase('animations')->getRecordByIndex($index)?->get('name')); $required = new SourceSetRequired($plan, sprintf('Convert %s to timeline %s', $name, $timelineId), 'this animation conversion', sprintf('Convert %s to the timeline %s, writing %s?', $name, $timelineId, implode(' and ', $paths)), $paths); - $result = $this->writeSourceSet($required, $answer, fn(): array => $this->requireCategory('animations')->getRecordLabels()); + $result = $this->writeSourceSet($required, $answer, fn(): array => $this->requireCategory('animations')->getRecordLabels(), $confirm); if ($answer === null) { $result['preview'] = array_combine($paths, array_values($plan->getProposedSources())); } @@ -1638,7 +1639,7 @@ public function readDatabaseRecord(string $category, int $index, array $frame = * @return array{changed: bool, records: list, note?: string} Whether it changed, the labels afterwards (a rename shows), and what else it did. * @throws SessionRefusal When the category or record is unknown or read-only, the row is gone or read-only, or the value is refused. */ - public function applyDatabaseRecord(string $category, int $index, array $key, string $value, ?string $answer = null): array + public function applyDatabaseRecord(string $category, int $index, array $key, string $value, ?string $answer = null, ?string $confirm = null): array { $database = $this->requireCategory($category); $frame = self::requireFrame($key['frame'] ?? [], 'database.record'); @@ -1659,23 +1660,77 @@ public function applyDatabaseRecord(string $category, int $index, array $key, st $index, $frame, $fieldId, $value, (string) ($field['label'] ?? 'Database field'), )); } catch (SourceSetRequired $required) { - return $this->writeSourceSet($required, $answer, fn(): array => $this->requireCategory($category)->getRecordLabels()); + return $this->writeSourceSet($required, $answer, fn(): array => $this->requireCategory($category)->getRecordLabels(), $confirm); } return array_filter(['changed' => $change->command !== null, 'records' => $database->getRecordLabels(), 'note' => $change->note], static fn(mixed $value): bool => $value !== null); } + /** + * Sets several of a record's rows as one action, undone and redone + * together: what one gesture changes, such as a click that sets the + * ground point of two images showing the same stance. Each value goes + * through the record's own field rules, as one row's edit does; if any is + * refused, those already made are taken back and nothing is recorded. + * An edit that needs a file set written at once is not made this way. + * + * @param list, value: string}> $changes Each row's key, as `database.record` gave it, and its value. + * @return array{changed: bool, records: list} + * @throws SessionRefusal When a row is gone or read-only, or a value is refused; nothing is changed. + */ + public function applyDatabaseRecordValues(string $category, int $index, array $changes, string $label): array + { + $database = $this->requireCategory($category); + $made = []; + try { + foreach ($changes as $change) { + $key = is_array($change) && is_array($change['key'] ?? null) ? $change['key'] : throw new SessionRefusal('Each change needs the row key database.record gave.'); + $frame = self::requireFrame($key['frame'] ?? [], 'database.record'); + $fieldId = $key['field'] ?? null; + $field = is_string($fieldId) ? array_find($this->collectRecordFields($database, $index, $frame), + static fn(array $candidate): bool => ($candidate['field'] ?? null) === $fieldId) : null; + if (! is_string($fieldId) || $field === null || ($field['editable'] ?? true) === false) { + throw new SessionRefusal('That row is no longer in the record, or cannot be edited; read it again.'); + } + try { + $applied = $database->applyField($index, $frame, $fieldId, strval($change['value'] ?? ''), (string) ($field['label'] ?? $label)); + } catch (RecordRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } catch (SourceSetRequired $required) { + throw new SessionRefusal(sprintf('%s writes several files at once; set it on its own.', trim((string) ($field['label'] ?? 'That row'))), previous: $required); + } + if ($applied->command !== null) { + $made[] = $applied->command; + } + } + } catch (SessionRefusal $refusal) { + foreach (array_reverse($made) as $command) { + $command->undo(); + } + throw $refusal; + } + if ($made !== []) { + $this->history->record(new CommandGroup($label, $made)); + } + + return ['changed' => $made !== [], 'records' => $database->getRecordLabels()]; + } + /** * Asks before writing a file set an edit needs at once, then writes it * as one undo step: the workspace is reloaded from the written files, so - * every map and category reads afresh. + * every map and category reads afresh. The question carries the plan's + * fingerprint, and writing requires it back: a plan made again from + * other choices or changed files is refused, so what is written is + * exactly what the author was shown. * * @param Closure(): list $records The category's labels afterwards. - * @return array{status: 'question', question: string, answers: list}|array{changed: bool, records: list, reloaded?: true} - * @throws SessionRefusal When an answer is not one offered, edits are pending, or the files cannot be written. + * @param string|null $confirm The fingerprint the question gave, when answering write. + * @return array{status: 'question', question: string, answers: list, confirm: string}|array{changed: bool, records: list, reloaded?: true} + * @throws SessionRefusal When an answer is not one offered, the plan is not the one shown, edits are pending, or the files cannot be written. */ - private function writeSourceSet(SourceSetRequired $required, ?string $answer, Closure $records): array + private function writeSourceSet(SourceSetRequired $required, ?string $answer, Closure $records, ?string $confirm = null): array { if ($answer === null) { return [ @@ -1683,9 +1738,11 @@ private function writeSourceSet(SourceSetRequired $required, ?string $answer, Cl 'question' => $required->question, 'answers' => [ ['key' => 'cancel', 'label' => 'Cancel', 'description' => 'Leave all files unchanged.'], - ['key' => 'write', 'label' => sprintf('Write %d repaired files now', count($required->paths)), + ['key' => 'write', 'label' => sprintf('Write %d files now', count($required->paths)), 'description' => sprintf('Writes %s now, not on Save. Undo restores the files.', implode(', ', $required->paths))], ], + // Answering write names this, so what is written is what was asked about. + 'confirm' => $required->plan->getFingerprint(), ]; } if (! in_array($answer, ['write', 'cancel'], true)) { @@ -1694,6 +1751,11 @@ private function writeSourceSet(SourceSetRequired $required, ?string $answer, Cl if ($answer === 'cancel') { return ['changed' => false, 'records' => $records()]; } + // The plan made now must be the one the author was shown: the same + // choices over the same files. Anything else is asked again. + if ($confirm === null || ! hash_equals($required->plan->getFingerprint(), $confirm)) { + throw new SessionRefusal(sprintf('The files or choices changed since %s was shown, so nothing was written. Review it again.', $required->subject)); + } if ($this->workspace->hasUnsavedChanges()) { throw new SessionRefusal(sprintf('Save or undo pending edits before %s. No files were changed.', $required->subject)); } diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index f6518dc..84079b5 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -298,6 +298,13 @@ private function dispatch(string $method, array $params): array is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key database.record gave.'), is_scalar($params['value'] ?? null) ? (string) $params['value'] : throw new InvalidRequest('"value" must be a string, number or boolean.'), self::readOptionalString($params, 'answer'), + self::readOptionalString($params, 'confirm'), + ), + 'database.applyMany' => $session->applyDatabaseRecordValues( + self::requireString($params, 'category'), + self::requireInt($params, 'index'), + is_array($params['changes'] ?? null) ? array_values($params['changes']) : throw new InvalidRequest('"changes" must be a list of {key, value}.'), + self::requireString($params, 'label'), ), 'database.add' => $session->addDatabaseItem( self::requireString($params, 'category'), @@ -338,6 +345,7 @@ private function dispatch(string $method, array $params): array is_bool($params['includeFlash'] ?? true) ? ($params['includeFlash'] ?? true) : throw new InvalidRequest('"includeFlash" must be a boolean.'), self::readOptionalString($params, 'binding'), self::readOptionalString($params, 'answer'), + self::readOptionalString($params, 'confirm'), ), 'affinities.vocabulary' => $session->describeAffinityVocabulary(), 'affinities.encode' => $session->encodeAffinities( diff --git a/src/Storage/SourceSetPlan.php b/src/Storage/SourceSetPlan.php index 920fead..3fb2710 100644 --- a/src/Storage/SourceSetPlan.php +++ b/src/Storage/SourceSetPlan.php @@ -57,6 +57,16 @@ public function getOriginalSources(): array { return $this->originals; } /** @return array */ public function getProposedSources(): array { return $this->proposals; } + /** + * What the plan is exactly: every source it read and every byte it would + * write. An author who reviewed a plan confirms this; a plan made again + * from changed choices or changed files has another. + */ + public function getFingerprint(): string + { + return hash('sha256', serialize([$this->root, $this->watched, $this->originals, $this->proposals])); + } + /** @return list Every changed path. */ public function apply(): array { diff --git a/tests/Unit/Session/ActorSessionTest.php b/tests/Unit/Session/ActorSessionTest.php index 69f4611..90f3b2f 100644 --- a/tests/Unit/Session/ActorSessionTest.php +++ b/tests/Unit/Session/ActorSessionTest.php @@ -156,11 +156,16 @@ function writeLegacyActor(string $root, string $name): string // Pending edits are saved or undone first: the set is written at once. $description = actorSessionRow($session->readDatabaseRecord('actors', 0), 'description')['key']; $session->applyDatabaseRecord('actors', 0, $description, 'Pending.'); - expect(fn() => $session->applyDatabaseRecord('actors', $index, $id['key'], '', 'write')) + expect(fn() => $session->applyDatabaseRecord('actors', $index, $id['key'], '', 'write', $question['confirm'])) ->toThrow(SessionRefusal::class, 'Save or undo pending edits'); $session->undo(); - $written = $session->applyDatabaseRecord('actors', $index, $id['key'], '', 'write'); + // Writing names the plan that was shown; without it, or with another, nothing is written. + expect(fn() => $session->applyDatabaseRecord('actors', $index, $id['key'], '', 'write'))->toThrow(SessionRefusal::class, 'Review it again') + ->and(fn() => $session->applyDatabaseRecord('actors', $index, $id['key'], '', 'write', str_repeat('0', 64)))->toThrow(SessionRefusal::class, 'Review it again') + ->and(sourceHashTree($root))->toBe($before); + + $written = $session->applyDatabaseRecord('actors', $index, $id['key'], '', 'write', $question['confirm']); [$reread] = $session->describeMaps(); expect($written)->toMatchArray(['changed' => true, 'reloaded' => true]) @@ -209,7 +214,9 @@ function writeLegacyActor(string $root, string $name): string $key = actorSessionRow($request(3, 'database.record', ['category' => 'actors', 'index' => $index, 'frame' => []])['result'], 'id')['key']; $apply = ['category' => 'actors', 'index' => $index, 'key' => $key, 'value' => '']; - expect($request(4, 'database.apply', $apply)['result']['status'])->toBe('question') - ->and($request(5, 'database.apply', [...$apply, 'answer' => 'write'])['result'])->toMatchArray(['changed' => true, 'reloaded' => true]) + $asked = $request(4, 'database.apply', $apply)['result']; + + expect($asked['status'])->toBe('question') + ->and($request(5, 'database.apply', [...$apply, 'answer' => 'write', 'confirm' => $asked['confirm']])['result'])->toMatchArray(['changed' => true, 'reloaded' => true]) ->and($request(6, 'history.undo')['result']['label'])->toBe('Migrate actor identities and references'); }); diff --git a/tests/Unit/Session/AnimationConversionSessionTest.php b/tests/Unit/Session/AnimationConversionSessionTest.php index 70c0021..c8f2732 100644 --- a/tests/Unit/Session/AnimationConversionSessionTest.php +++ b/tests/Unit/Session/AnimationConversionSessionTest.php @@ -56,7 +56,7 @@ function legacyAnimationIndex(EditorSession $session, string $name): int $root = legacyAnimationProject(); $session = EditorSession::open($root); $index = legacyAnimationIndex($session, 'Old Spark'); - $convert = static fn(?string $answer) => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'targetEffect', $answer); + $convert = static fn(?string $answer, ?string $confirm = null) => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'targetEffect', $answer, $confirm); $timeline = $root . '/assets/Animations/old-spark/old-spark.timeline.php'; $asked = $convert(null); @@ -66,7 +66,7 @@ function legacyAnimationIndex(EditorSession $session, string $name): int ->and($asked['preview']['assets/Data/animations.php'])->toContain("// A spark in the old cell frames.", "'targetEffect' => 'old-spark'") ->and(file_exists($timeline))->toBeFalse(); - expect($convert('write'))->toMatchArray(['changed' => true, 'reloaded' => true]) + expect($convert('write', $asked['confirm']))->toMatchArray(['changed' => true, 'reloaded' => true]) ->and(file_get_contents($root . '/assets/Data/animations.php'))->toBe($asked['preview']['assets/Data/animations.php']) ->and(file_get_contents($root . '/assets/Data/animations.php'))->toContain("'frames' => [", "'cues' => [") ->and(new EffectTimelineLibrary($root . '/assets')->load('old-spark', forBattle: true))->not->toBeNull(); @@ -82,7 +82,9 @@ function legacyAnimationIndex(EditorSession $session, string $name): int $root = legacyAnimationProject(); $session = EditorSession::open($root); - $session->convertAnimation(legacyAnimationIndex($session, 'Old Spark'), 'field-old-spark', 'fixed', 25, 3, 0, false, null, 'write'); + $index = legacyAnimationIndex($session, 'Old Spark'); + $asked = $session->convertAnimation($index, 'field-old-spark', 'fixed', 25, 3, 0, false, null); + $session->convertAnimation($index, 'field-old-spark', 'fixed', 25, 3, 0, false, null, 'write', $asked['confirm']); $written = require $root . '/assets/Animations/field-old-spark/field-old-spark.timeline.php'; expect(file_get_contents($root . '/assets/Data/animations.php'))->toBe(LEGACY_ANIMATIONS) @@ -105,7 +107,27 @@ function legacyAnimationIndex(EditorSession $session, string $name): int $name = array_find($session->readDatabaseRecord('animations', $index)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'name'); $session->applyDatabaseRecord('animations', $index, $name['key'], 'Renamed Spark'); - expect(fn() => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'targetEffect', 'write')) + $asked = $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'targetEffect'); + expect(fn() => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'targetEffect', 'write', $asked['confirm'])) ->toThrow(SessionRefusal::class, 'Save or undo pending edits') ->and(file_exists($root . '/assets/Animations/old-spark'))->toBeFalse(); }); + +it('writes only the plan that was previewed: other choices, or files changed since, are asked again', function () { + $root = legacyAnimationProject(); + $session = EditorSession::open($root); + $index = legacyAnimationIndex($session, 'Old Spark'); + $asked = $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'targetEffect'); + + // The author changed a choice after the preview. + expect(fn() => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 2, 0, true, 'targetEffect', 'write', $asked['confirm'])) + ->toThrow(SessionRefusal::class, 'Review it again'); + + // The animations file changed on disk after the preview. + file_put_contents($root . '/assets/Data/animations.php', str_replace('// A spark in the old cell frames.', '// Edited elsewhere.', LEGACY_ANIMATIONS)); + + expect(fn() => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'targetEffect', 'write', $asked['confirm'])) + ->toThrow(SessionRefusal::class, 'Review it again') + ->and(file_exists($root . '/assets/Animations/old-spark'))->toBeFalse() + ->and(file_get_contents($root . '/assets/Data/animations.php'))->toContain('// Edited elsewhere.'); +}); diff --git a/tests/Unit/Session/BattlerArtSessionTest.php b/tests/Unit/Session/BattlerArtSessionTest.php index d2953ca..1b86f1b 100644 --- a/tests/Unit/Session/BattlerArtSessionTest.php +++ b/tests/Unit/Session/BattlerArtSessionTest.php @@ -133,3 +133,34 @@ function setBattlerRow(EditorSession $session, int $index, string $field, string ->and($preview['formation']['members'])->toBe([]) ->and($session->describeBattlerArt('actors', $identity))->toMatchArray(['index' => $art, 'owner' => 'data']); }); + +it('sets the ground point of a base image and the idle pose showing it as one undo step', function () { + $root = battlerArtProject(); + $session = EditorSession::open($root); + $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index']; + setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png'); + $heading = array_find($session->readDatabaseRecord('battler_enemies', $index)['rows'], static fn(array $row): bool => ($row['listHeading'] ?? false) === true); + $session->addDatabaseItem('battler_enemies', $index, $heading['key'], true); + setBattlerRow($session, $index, 'pose0Image', 'Graphics/Enemies/Bat.png'); + $both = [ + ['key' => readBattlerRow($session, $index, 'pose0Pivot')['key'], 'value' => '0.25, 0.9'], + ['key' => readBattlerRow($session, $index, 'artwork.pivot')['key'], 'value' => '0.25, 0.9'], + ]; + + expect($session->applyDatabaseRecordValues('battler_enemies', $index, $both, 'Ground point')['changed'])->toBeTrue() + ->and(readBattlerRow($session, $index, 'pose0Pivot')['value'])->toBe('0.25, 0.9') + ->and(readBattlerRow($session, $index, 'artwork.pivot')['value'])->toBe('0.25, 0.9'); + + expect($session->undo()['label'])->toBe('Ground point') + ->and(readBattlerRow($session, $index, 'pose0Pivot')['value'])->toBe('') + ->and(readBattlerRow($session, $index, 'artwork.pivot')['value'])->toBe(''); + + $session->redo(); + expect(readBattlerRow($session, $index, 'artwork.pivot')['value'])->toBe('0.25, 0.9'); + + // One refused value takes back the rest. + $refused = [['key' => readBattlerRow($session, $index, 'pose0Pivot')['key'], 'value' => '0.5, 0.5'], + ['key' => readBattlerRow($session, $index, 'artwork.pivot')['key'], 'value' => 'off the image']]; + expect(fn() => $session->applyDatabaseRecordValues('battler_enemies', $index, $refused, 'Ground point'))->toThrow(SessionRefusal::class) + ->and(readBattlerRow($session, $index, 'pose0Pivot')['value'])->toBe('0.25, 0.9'); +}); From 0561f0147bb2ad9a385fd130be2e08c9c364e390 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 02:49:51 +0200 Subject: [PATCH 173/344] feat(editor): convert a legacy animation to a timeline from the terminal Shift+T on a legacy cell-frame animation record opens the conversion in the settings pane, through the same LegacyAnimationConversion service the GUI uses. It lists what the record holds and who plays it, then asks for: - the consumer: a battle paced by its phases, a battle at a fixed rate, or a field script; - the binding a battle uses; - the name, rate, ticks per original frame and rest frame, none filled in; - the flash policy, preset to the consumer's. Preview plans the conversion and holds that plan, showing each file as it would be written. Write applies exactly that plan as one SourceSetCommand undo step. The plan refuses files changed since. A changed choice forgets the preview, and pending edits are refused. The form's state lives in AnimationConversionEditor, which does not draw. The manual and the Database help describe it. No graphical category is added to the terminal. --- docs/manual.md | 25 ++ src/Animations/AnimationConversionEditor.php | 267 ++++++++++++++++++ src/Editor.php | 152 ++++++++++ .../Unit/TerminalAnimationConversionTest.php | 140 +++++++++ 4 files changed, 584 insertions(+) create mode 100644 src/Animations/AnimationConversionEditor.php create mode 100644 tests/Unit/TerminalAnimationConversionTest.php diff --git a/docs/manual.md b/docs/manual.md index 8748b72..7e065d6 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1214,6 +1214,31 @@ written, shown read-only as **Legacy Frames** and **Legacy Cues** beside its **Legacy Position**; the Engine still plays them through its importer. New animation is authored as effect timelines. +Such a record can be converted to a timeline: **Shift+T** in the terminal, +or **Convert to timeline** on its page in the GUI editor. Both use the same +conversion, which lists what plays the record now (skills, items, roles and +field scripts) and asks for the timing its consumer needs. Nothing is filled +in for it: + +- Who plays the timeline: + - a battle paced by its phases; + - a battle at a fixed rate; + - a field script at a fixed rate. +- For a battle, whether the record names it as its **Target Effect** or its + **Caster Effect**. +- The timeline's name, its frames per second (for a fixed rate), the ticks + each original frame lasts, and the rest frame shown with reduced motion. +- Whether the record's flash cues become flash tracks. Battles have always + played them; field animations never did. + +Preview shows every file as it would be written: the new +`assets/Animations//.timeline.php`, and for a battle the record +naming it. Write applies exactly that preview as one undo step. A changed +choice, or a file changed since, needs a fresh preview, and pending edits are +saved or undone first. The record's legacy frames and cues stay as written, +and field scripts keep playing the record until their own command names the +timeline. + ### Troops Encounter groups. Each troop has a `Name`, an optional `Escape Policy`, and a diff --git a/src/Animations/AnimationConversionEditor.php b/src/Animations/AnimationConversionEditor.php new file mode 100644 index 0000000..c3ec5ef --- /dev/null +++ b/src/Animations/AnimationConversionEditor.php @@ -0,0 +1,267 @@ + 'Battle, paced by its phases', + 'battle_fixed' => 'Battle, at a fixed rate', + 'field' => 'Field script, at a fixed rate', + ]; + + /** The record keys a battle plays a timeline through. */ + public const array BINDINGS = ['targetEffect' => 'Target effect', 'sourceEffect' => 'Caster effect']; + + /** Rows typed into, by name. */ + private const array TYPED = ['timeline', 'fps', 'ticks', 'rest']; + + private bool $isOpen = false; + private int $recordIndex = 0; + private int $animationId = 0; + private string $name = ''; + /** @var array What converting touches, as the service describes it. */ + private array $facts = []; + private ?string $consumer = null; + private string $binding = 'targetEffect'; + /** @var array The typed rows' text. */ + private array $typed = []; + private bool $includeFlash = true; + private int $selectedIndex = 0; + private ?SourceSetPlan $plan = null; + private ?string $error = null; + + /** + * Opens the form on a legacy animation record. + * + * @param array $facts What converting touches ({@see LegacyAnimationConversion::describe()}). + */ + public function open(int $recordIndex, array $facts): void + { + $this->isOpen = true; + $this->recordIndex = $recordIndex; + $this->animationId = intval($facts['id'] ?? 0); + $this->name = strval($facts['name'] ?? ''); + $this->facts = $facts; + $this->consumer = null; + $this->binding = 'targetEffect'; + // The name is the record's, in lowercase words; the timing is the author's. + $this->typed = ['timeline' => trim(strtolower(preg_replace('/[^A-Za-z0-9]+/', '-', $this->name) ?? ''), '-'), 'fps' => '', 'ticks' => '', 'rest' => '']; + $this->includeFlash = true; + $this->selectedIndex = 0; + $this->plan = null; + $this->error = null; + } + + public function close(): void + { + $this->isOpen = false; + $this->plan = null; + $this->facts = []; + } + + public function isOpen(): bool + { + return $this->isOpen; + } + + public function getRecordIndex(): int + { + return $this->recordIndex; + } + + public function getName(): string + { + return $this->name; + } + + /** @return list The rows shown now, in order; the binding and rate rows only where the consumer has them. */ + public function getRows(): array + { + $battle = $this->consumer !== null && $this->consumer !== 'field'; + + return array_values(array_filter([ + 'consumer', + $battle ? 'binding' : null, + 'timeline', + $this->consumer !== null && $this->consumer !== 'battle_phase' ? 'fps' : null, + 'ticks', + 'rest', + 'flash', + 'preview', + $this->plan !== null ? 'write' : null, + ])); + } + + public function getSelectedRow(): string + { + $rows = $this->getRows(); + + return $rows[min($this->selectedIndex, count($rows) - 1)]; + } + + public function move(int $delta): void + { + $count = count($this->getRows()); + $this->selectedIndex = max(0, min($count - 1, $this->selectedIndex + $delta)); + } + + /** Steps the selected choice row: the consumer, the binding or the flash policy. */ + public function cycle(int $step): void + { + switch ($this->getSelectedRow()) { + case 'consumer': + $keys = array_keys(self::CONSUMERS); + $at = $this->consumer === null ? ($step > 0 ? -1 : 0) : (int) array_search($this->consumer, $keys, true); + $this->consumer = $keys[(($at + $step) % count($keys) + count($keys)) % count($keys)]; + // Battles have always played flash cues; field animations never did. + $this->includeFlash = $this->consumer !== 'field'; + break; + case 'binding': + $this->binding = $this->binding === 'targetEffect' ? 'sourceEffect' : 'targetEffect'; + break; + case 'flash': + $this->includeFlash = ! $this->includeFlash; + break; + default: + return; + } + $this->forgetPreview(); + } + + /** Types into the selected text row; a row of choices takes no typing. */ + public function type(string $text): void + { + $row = $this->getSelectedRow(); + if (! in_array($row, self::TYPED, true)) { + return; + } + $this->typed[$row] .= $text; + $this->forgetPreview(); + } + + public function backspace(): void + { + $row = $this->getSelectedRow(); + if (in_array($row, self::TYPED, true) && $this->typed[$row] !== '') { + $this->typed[$row] = mb_substr($this->typed[$row], 0, -1); + $this->forgetPreview(); + } + } + + /** + * Plans the conversion for the choices shown and holds that plan. + * + * @return bool Whether a plan is held; otherwise the error says why. + */ + public function preview(ProjectWorkspace $workspace): bool + { + $this->plan = null; + try { + if ($this->consumer === null) { + throw new InvalidArgumentException('Choose who plays the timeline: a battle or a field script.'); + } + $whole = static fn(string $text, string $what): int => preg_match('/\A\d+\z/', trim($text)) === 1 + ? intval(trim($text)) : throw new InvalidArgumentException(sprintf('%s needs a whole number.', $what)); + $this->plan = LegacyAnimationConversion::plan( + $workspace, + $this->animationId, + trim($this->typed['timeline']), + $this->consumer === 'battle_phase' ? EffectCadence::BATTLE_PHASE : EffectCadence::FIXED, + $this->consumer === 'battle_phase' ? null : $whole($this->typed['fps'], 'Frames per second'), + $whole($this->typed['ticks'], 'Ticks per original frame'), + $whole($this->typed['rest'], 'Rest frame'), + $this->includeFlash, + $this->consumer === 'field' ? null : $this->binding, + ); + $this->error = null; + } catch (InvalidArgumentException $refusal) { + $this->error = $refusal->getMessage(); + } + + return $this->plan !== null; + } + + /** The plan previewed for the choices shown, which writing applies exactly. */ + public function getPlan(): ?SourceSetPlan + { + return $this->plan; + } + + public function setError(?string $error): void + { + $this->error = $error; + } + + /** @return list The form's lines, without layout. */ + public function describeRows(string $projectRoot): array + { + $facts = $this->facts; + $plays = static fn(mixed $list): string => is_array($list) && $list !== [] ? implode(', ', array_map(strval(...), $list)) : 'nothing'; + $lines = [ + sprintf('%d authored frames over %d (%s anchored), %d cues, %d with a flash.', + intval($facts['frames'] ?? 0), intval($facts['maxFrames'] ?? 0), strval($facts['position'] ?? ''), + intval($facts['cues'] ?? 0), intval($facts['flashes'] ?? 0)), + sprintf('Battle plays it through: %s.', $plays($facts['battle'] ?? [])), + sprintf('Field scripts that play it: %s. They keep playing the record until their command names the timeline.', $plays($facts['field'] ?? [])), + '', + ]; + $selected = $this->getSelectedRow(); + foreach ($this->getRows() as $row) { + $value = match ($row) { + 'consumer' => sprintf('Played by: %s', $this->consumer === null ? '(choose with Left/Right)' : self::CONSUMERS[$this->consumer]), + 'binding' => sprintf('Binds as: %s', self::BINDINGS[$this->binding]), + 'timeline' => sprintf('Timeline name: %s', $this->typed['timeline']), + 'fps' => sprintf('Frames per second: %s', $this->typed['fps']), + 'ticks' => sprintf('Ticks per original frame: %s', $this->typed['ticks']), + 'rest' => sprintf('Rest frame (from 0): %s', $this->typed['rest']), + 'flash' => sprintf('Flash cues: %s', $this->includeFlash ? 'become flash tracks' : 'left out'), + 'preview' => '[ Preview the files ]', + 'write' => sprintf('[ Write %d files now ]', count($this->plan?->getChangedPaths() ?? [])), + }; + $lines[] = ($row === $selected ? '> ' : ' ') . $value; + } + if ($this->error !== null) { + $lines = [...$lines, '', ' ' . $this->error]; + } + if ($this->plan !== null) { + $root = rtrim($projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + foreach ($this->plan->getProposedSources() as $path => $source) { + $lines[] = ''; + $lines[] = ' ' . (str_starts_with($path, $root) ? substr($path, strlen($root)) : $path); + foreach (explode("\n", rtrim($source, "\n")) as $line) { + $lines[] = ' ' . $line; + } + } + } + + return $lines; + } + + private function forgetPreview(): void + { + $this->plan = null; + $this->error = null; + } +} diff --git a/src/Editor.php b/src/Editor.php index 8e6ca68..b314127 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -34,6 +34,8 @@ use Ichiloto\Editor\Database\SharedFileTransaction; use Ichiloto\Editor\Database\ConditionCodec; use Ichiloto\Editor\Database\QuestReferences; +use Ichiloto\Editor\Animations\AnimationConversionEditor; +use Ichiloto\Editor\Animations\LegacyAnimationConversion; use Ichiloto\Editor\Database\AffinityEditor; use Ichiloto\Editor\Database\ConditionEditor; use Ichiloto\Editor\Field\NpcInspector; @@ -496,6 +498,7 @@ final class Editor private readonly ReferencePicker $referencePicker; private readonly ConditionEditor $conditionEditor; private readonly AffinityEditor $affinityEditor; + private readonly AnimationConversionEditor $animationConversion; private readonly WorldWriteEditor $worldWriteEditor; private readonly BattleEntryPredicateEditor $battleEntryPredicateEditor; private const string WORLD_WRITE_NAME_FIELD = '__world_write_name'; @@ -736,6 +739,7 @@ public function __construct(private readonly string $projectRoot) $this->referencePicker = new ReferencePicker(); $this->conditionEditor = new ConditionEditor(); $this->affinityEditor = new AffinityEditor(); + $this->animationConversion = new AnimationConversionEditor(); $this->worldWriteEditor = new WorldWriteEditor(); $this->battleEntryPredicateEditor = new BattleEntryPredicateEditor(); $this->modals = new ModalStack(); @@ -1834,6 +1838,12 @@ private function handleNpcInspectorInput(string $input, string $normalizedInput) return; } + if ($this->animationConversion->isOpen()) { + $this->handleAnimationConversionInput($input); + + return; + } + if ($this->worldWriteEditor->isOpen()) { $this->handleWorldWriteEditorInput($input); @@ -2847,6 +2857,11 @@ private function handleDatabaseInput(string $input, string $normalizedInput): vo return; } + if ($this->animationConversion->isOpen()) { + $this->handleAnimationConversionInput($input); + return; + } + if ($this->worldWriteEditor->isOpen()) { $this->handleWorldWriteEditorInput($input); return; @@ -2973,6 +2988,12 @@ private function handleDatabaseInput(string $input, string $normalizedInput): vo return; } + // Shift+T converts a legacy cell-frame animation to a timeline. + if ($this->getSelectedDatabaseCategoryDefinition()->key === 'animations' && $this->isShiftLetterShortcut($input, 'T')) { + $this->openAnimationConversion(); + return; + } + // Shift+O/Shift+X are the one sub-list idiom: quest objectives, skit // beats, troop members, and event-script commands all use them. if (($this->getSelectedRecordDatabase()?->schema->getInlineSubLists() ?? []) !== [] && $this->isShiftLetterShortcut($input, 'O')) { @@ -5319,6 +5340,10 @@ private function getHelpLines(): array $lines[] = ' [ / ] move the entry up or down; this'; $lines[] = ' category stores its order'; } + if ($this->getSelectedDatabaseCategoryDefinition()->key === 'animations') { + $lines[] = ' Shift+T convert a legacy cell-frame record'; + $lines[] = ' to a timeline, previewed first'; + } $lines[] = ' Shift+O / Shift+X add or remove an objective, beat,'; $lines[] = ' troop member or script command'; $lines[] = ' Del the same, on the settings field'; @@ -9897,6 +9922,122 @@ private function buildBattleEntryPredicateEditorRows(): array * @param array $field The settings-pane field descriptor. * @return void */ + /** + * Opens the timeline conversion on the selected animation record, when + * it still holds legacy cell frames or cues. + */ + private function openAnimationConversion(): void + { + if (! $this->workspace instanceof ProjectWorkspace) { + return; + } + $index = $this->getSelectedRecordIndex(); + $id = $this->getSelectedRecordDatabase()?->getRecordByIndex($index)?->get('id'); + try { + $facts = LegacyAnimationConversion::describe($this->workspace, is_int($id) ? $id : -1); + } catch (InvalidArgumentException $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderDatabasePanes(['settings']); + + return; + } + $this->animationConversion->open($index, $facts); + $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; + $this->statusMessage = sprintf('Converting %s to a timeline.', $this->animationConversion->getName()); + $this->renderDatabasePanes(['settings']); + } + + /** + * Handles input while a conversion is being set up: rows move with + * Up/Down, choices step with Left/Right, text rows take typing, Enter on + * Preview plans it and on Write applies exactly that plan as one undo + * step, and Escape leaves everything unchanged. + */ + private function handleAnimationConversionInput(string $input): void + { + $conversion = $this->animationConversion; + if ($input === "\033" || $input === "\x1b") { + $conversion->close(); + $this->statusMessage = 'Conversion cancelled; nothing was written.'; + $this->renderDatabasePanes(['settings']); + + return; + } + match (true) { + str_contains($input, "\033[A") => $conversion->move(-1), + str_contains($input, "\033[B") => $conversion->move(1), + str_contains($input, "\033[D") => $conversion->cycle(-1), + str_contains($input, "\033[C") => $conversion->cycle(1), + $input === "\x7f" || $input === "\x08" => $conversion->backspace(), + $input === "\n" || $input === "\r" => $this->submitAnimationConversion(), + mb_strlen($input) === 1 && ctype_print($input) => $conversion->type($input), + default => null, + }; + if ($conversion->isOpen()) { + $this->renderDatabasePanes(['settings']); + } + } + + /** Enter in the conversion: preview it, write the previewed plan, or move to the next row. */ + private function submitAnimationConversion(): void + { + $conversion = $this->animationConversion; + if (! $this->workspace instanceof ProjectWorkspace) { + return; + } + if ($conversion->getSelectedRow() === 'preview') { + if ($conversion->preview($this->workspace)) { + $conversion->move(1); + $this->statusMessage = 'Previewed. Write applies exactly these files.'; + } + + return; + } + if ($conversion->getSelectedRow() !== 'write') { + $conversion->move(1); + + return; + } + $plan = $conversion->getPlan(); + if ($plan === null) { + return; + } + if ($this->workspace->hasUnsavedChanges()) { + $conversion->setError('Save or undo pending edits before converting. No files were changed.'); + + return; + } + try { + $command = new SourceSetCommand(sprintf('Convert %s to a timeline', $conversion->getName()), 'this animation conversion', $plan, $this->workspace, + fn(): ?ProjectWorkspace => $this->workspace, + function (ProjectWorkspace $workspace): void { $this->workspace = $workspace; }, + ); + $command->execute(); + } catch (Throwable $failure) { + // Files changed since the preview, or the write failed: nothing is written; preview again. + $conversion->setError($failure->getMessage()); + + return; + } + $this->recordCommand($command); + $name = $conversion->getName(); + $conversion->close(); + $this->setStatus(sprintf('%s converted. Ctrl+Z restores the files.', $name)); + $this->requestFullRender(); + } + + /** @return list The conversion's rows, in the settings pane. */ + private function buildAnimationConversionRows(): array + { + $width = $this->recordPaneMetrics()['width']; + $lines = [sprintf('Convert %s to a timeline', $this->animationConversion->getName()), '']; + foreach ($this->animationConversion->describeRows($this->projectRoot) as $line) { + $lines = [...$lines, ...($line === '' ? [''] : SettingsPaneLayout::wrapProse($line, $width))]; + } + + return $lines; + } + private function openAffinityEditor(array $field): void { if (! $this->workspace instanceof ProjectWorkspace) { @@ -12848,6 +12989,13 @@ private function createDatabaseSettingsWindow(array $layout): EditorWindow 'a/d:Add/Del n/x:Edit ?:Help', '?:Help', ), + $this->animationConversion->isOpen() => $this->fitHelp( + $layout['settingsWidth'], + 'Up/Down:Row Left/Right:Choose Type:Text Enter:Preview/Write Esc:Cancel', + 'Left/Right:Choose Enter:Preview/Write Esc:Cancel', + 'Enter:Preview/Write Esc:Cancel', + '?:Help', + ), $this->affinityEditor->isOpen() => $this->fitHelp( $layout['settingsWidth'], 'a:Add d:Delete n:Element x/X:Effect Enter:Done Esc:Cancel', @@ -13120,6 +13268,10 @@ private function getDatabaseSettingsLines(): array return $this->buildAffinityEditorRows(); } + if ($this->animationConversion->isOpen()) { + return $this->buildAnimationConversionRows(); + } + if ($this->worldWriteEditor->isOpen()) { return $this->buildWorldWriteEditorRows(); } diff --git a/tests/Unit/TerminalAnimationConversionTest.php b/tests/Unit/TerminalAnimationConversionTest.php new file mode 100644 index 0000000..287a919 --- /dev/null +++ b/tests/Unit/TerminalAnimationConversionTest.php @@ -0,0 +1,140 @@ + 1, + 'name' => 'Old Spark', + 'position' => 'center', + 'maxFrames' => 3, + 'frames' => [ + ['index' => 1, 'cells' => [['x' => 0, 'y' => 0, 'symbol' => '*', 'color' => 'yellow']]], + ['index' => 2, 'cells' => [['x' => 1, 'y' => 0, 'symbol' => '+']]], + ], + 'cues' => [['frame' => 1, 'soundEffect' => 'spark', 'flashColor' => 'white', 'flashDurationFrames' => 2]], + ], +]; +PHP; + +/** The terminal editor on the Animations database, the legacy record selected. */ +function openLegacyAnimationEditor(): array +{ + $root = makeTemporaryProject(); + file_put_contents($root . '/assets/Data/animations.php', TERMINAL_LEGACY_ANIMATIONS); + $editor = createEditorForTesting($root); + setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); + setEditorProperty($editor, 'lastTerminalSize', ['width' => 160, 'height' => 48]); + callEditorMethod($editor, 'openDatabaseAtCategory', 'animations'); + + return [$editor, $root]; +} + +/** The conversion pane as one line of prose, whatever its wrapping. */ +function readConversionPane(Editor $editor): string +{ + return preg_replace('/\s+/', ' ', implode(' ', callEditorMethod($editor, 'buildAnimationConversionRows'))); +} + +/** The conversion form the editor holds. */ +function terminalConversion(Editor $editor): AnimationConversionEditor +{ + return getEditorProperty($editor, 'animationConversion'); +} + +it('converts from the terminal: chosen timing, previewed files, one undo step restoring them', function () { + [$editor, $root] = openLegacyAnimationEditor(); + $timeline = $root . '/assets/Animations/old-spark/old-spark.timeline.php'; + + pressKeys($editor, 'T'); + $conversion = terminalConversion($editor); + + expect($conversion->isOpen())->toBeTrue() + ->and($conversion->getRows())->toBe(['consumer', 'timeline', 'ticks', 'rest', 'flash', 'preview']); + + // A battle paced by its phases, bound as the target effect; then the ticks and rest frame, typed. + pressKeys($editor, "\033[C", "\033[B", "\033[B", "\033[B"); + typeText($editor, '1'); + pressKeys($editor, "\033[B"); + typeText($editor, '0'); + pressKeys($editor, "\033[B", "\033[B", "\r"); + + expect($conversion->getPlan())->not->toBeNull() + ->and($conversion->getSelectedRow())->toBe('write') + ->and(implode("\n", callEditorMethod($editor, 'buildAnimationConversionRows')))->toContain("'targetEffect' => 'old-spark'", 'assets/Animations/old-spark/old-spark.timeline.php') + ->and(file_exists($timeline))->toBeFalse(); + + pressKeys($editor, "\r"); + + expect($conversion->isOpen())->toBeFalse() + ->and(file_get_contents($root . '/assets/Data/animations.php'))->toContain("'targetEffect' => 'old-spark'", "'frames' => [", '// A spark in the old cell frames.') + ->and(new EffectTimelineLibrary($root . '/assets')->load('old-spark', forBattle: true))->not->toBeNull(); + + callEditorMethod($editor, 'performUndo'); + + expect(file_get_contents($root . '/assets/Data/animations.php'))->toBe(TERMINAL_LEGACY_ANIMATIONS) + ->and(file_exists($timeline))->toBeFalse(); +}); + +it('forgets a preview when a choice changes, so Write only follows a preview of what is shown', function () { + [$editor] = openLegacyAnimationEditor(); + pressKeys($editor, 'T', "\033[C", "\033[B", "\033[B", "\033[B"); + typeText($editor, '1'); + pressKeys($editor, "\033[B"); + typeText($editor, '0'); + pressKeys($editor, "\033[B", "\033[B", "\r"); + $conversion = terminalConversion($editor); + + expect($conversion->getRows())->toContain('write'); + + // Back up to the ticks and change them. + pressKeys($editor, "\033[A", "\033[A", "\033[A", "\033[A"); + typeText($editor, '2'); + + expect($conversion->getPlan())->toBeNull() + ->and($conversion->getRows())->not->toContain('write'); +}); + +it('refuses to write a preview the files have moved on from, writing nothing', function () { + [$editor, $root] = openLegacyAnimationEditor(); + pressKeys($editor, 'T', "\033[C", "\033[B", "\033[B", "\033[B"); + typeText($editor, '1'); + pressKeys($editor, "\033[B"); + typeText($editor, '0'); + pressKeys($editor, "\033[B", "\033[B", "\r"); + + file_put_contents($root . '/assets/Data/animations.php', str_replace('// A spark in the old cell frames.', '// Edited elsewhere.', TERMINAL_LEGACY_ANIMATIONS)); + pressKeys($editor, "\r"); + + expect(terminalConversion($editor)->isOpen())->toBeTrue() + ->and(readConversionPane($editor))->toContain('changed outside the animation conversion') + ->and(file_exists($root . '/assets/Animations/old-spark'))->toBeFalse() + ->and(file_get_contents($root . '/assets/Data/animations.php'))->toContain('// Edited elsewhere.'); +}); + +it('says why a record cannot be converted, and asks for timing rather than assuming it', function () { + [$editor, $root] = openLegacyAnimationEditor(); + // Down to Preview with no consumer chosen. + pressKeys($editor, 'T', "\033[B", "\033[B", "\033[B", "\033[B", "\033[B", "\r"); + + expect(readConversionPane($editor))->toContain('Choose who plays the timeline'); + + pressKeys($editor, "\033"); + file_put_contents($root . '/assets/Data/animations.php', " 1, 'name' => 'Already Timed', 'targetEffect' => 'burst']];\n"); + setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); + pressKeys($editor, 'T'); + + expect(terminalConversion($editor)->isOpen())->toBeFalse() + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('has no legacy frames or cues to convert'); +}); From b06ef532cb4a93f37973d61e3c80d5350754c374 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 03:11:32 +0200 Subject: [PATCH 174/344] fix(editor): keep the terminal conversion's controls on screen, and review every file before writing The conversion pane showed its first lines only. At real sizes the facts filled it, Preview and Write were off screen, and the proposed source could not be read past the pane's height. The form is now two modes, each a list of lines the cursor walks, drawn through the shared ScrollWindow: - Choices: the controls, each wrapped to the pane and stepped over whole, then the record's facts a line at a time. The pane keeps the whole selected control in view. - Review: Preview opens it on every file the held plan would write, a line at a time. Enter writes exactly that plan; Esc goes back to the choices, on Review and write. Refusals also reach the status line. Choice invalidation, the stale-file refusal, the exact-plan write and the single undo step are unchanged. The tests read the pane as the window fits it, at 80x24, 120x40 and 160x48, with long facts and a thirty-frame source. --- docs/manual.md | 6 +- src/Animations/AnimationConversionEditor.php | 254 ++++++++++++++---- src/Editor.php | 84 ++++-- .../Unit/TerminalAnimationConversionTest.php | 199 +++++++++----- 4 files changed, 389 insertions(+), 154 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 7e065d6..4db3b7f 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1233,7 +1233,11 @@ in for it: Preview shows every file as it would be written: the new `assets/Animations//.timeline.php`, and for a battle the record -naming it. Write applies exactly that preview as one undo step. A changed +naming it. In the terminal it opens a review of those files: Up/Down read +them a line at a time, Enter writes them, and Esc goes back to the choices. +The pane follows the cursor, so the selected choice and every reviewed line +are shown at any terminal size. Write applies exactly that preview as one +undo step. A changed choice, or a file changed since, needs a fresh preview, and pending edits are saved or undone first. The record's legacy frames and cues stay as written, and field scripts keep playing the record until their own command names the diff --git a/src/Animations/AnimationConversionEditor.php b/src/Animations/AnimationConversionEditor.php index c3ec5ef..10043b5 100644 --- a/src/Animations/AnimationConversionEditor.php +++ b/src/Animations/AnimationConversionEditor.php @@ -6,19 +6,25 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Storage\SourceSetPlan; +use Ichiloto\Editor\UI\SettingsPaneLayout; use Ichiloto\Engine\Animations\Timelines\EffectCadence; use InvalidArgumentException; /** * The terminal's form for converting a legacy cell-frame animation to a - * timeline, a row at a time: who plays it, the binding a battle uses, its - * name, rate, ticks per original frame, rest frame and flash policy. Nothing - * is filled in for the timing, which belongs to the consumer. + * timeline. It has two modes, each a list of lines a cursor walks, so the + * pane's shared scrolling keeps the cursor in view at any size: + * + * - Choices: who plays it, the binding a battle uses, its name, rate, ticks + * per original frame, rest frame and flash policy, then Preview. The + * record's facts follow, so walking on past the controls reads them. + * Nothing is filled in for the timing, which belongs to the consumer. + * - Review: every file the previewed plan would write, line by line. Enter + * writes exactly that plan; Escape goes back to the choices. * * Previewing plans the conversion with {@see LegacyAnimationConversion} and - * holds that exact plan; writing applies it, so what is written is what was - * shown, and the plan itself refuses files changed since. Any change to a - * choice forgets the preview. + * holds that exact plan. The plan itself refuses files changed since, and + * any changed choice forgets it. * * Nothing here draws, so the behaviour can be exercised without a terminal. */ @@ -34,10 +40,17 @@ final class AnimationConversionEditor /** The record keys a battle plays a timeline through. */ public const array BINDINGS = ['targetEffect' => 'Target effect', 'sourceEffect' => 'Caster effect']; + /** Choosing the conversion's settings. */ + public const string MODE_CHOICES = 'choices'; + + /** Reading the previewed files before writing them. */ + public const string MODE_REVIEW = 'review'; + /** Rows typed into, by name. */ private const array TYPED = ['timeline', 'fps', 'ticks', 'rest']; private bool $isOpen = false; + private string $mode = self::MODE_CHOICES; private int $recordIndex = 0; private int $animationId = 0; private string $name = ''; @@ -48,7 +61,12 @@ final class AnimationConversionEditor /** @var array The typed rows' text. */ private array $typed = []; private bool $includeFlash = true; - private int $selectedIndex = 0; + /** The cursor's line in the mode's lines. */ + private int $cursor = 0; + /** @var list The control each line of the choices belongs to, as last laid out; null for facts. */ + private array $lineControls = []; + /** A control the cursor goes to once the choices are next laid out. */ + private ?string $cursorControl = null; private ?SourceSetPlan $plan = null; private ?string $error = null; @@ -60,6 +78,7 @@ final class AnimationConversionEditor public function open(int $recordIndex, array $facts): void { $this->isOpen = true; + $this->mode = self::MODE_CHOICES; $this->recordIndex = $recordIndex; $this->animationId = intval($facts['id'] ?? 0); $this->name = strval($facts['name'] ?? ''); @@ -69,7 +88,7 @@ public function open(int $recordIndex, array $facts): void // The name is the record's, in lowercase words; the timing is the author's. $this->typed = ['timeline' => trim(strtolower(preg_replace('/[^A-Za-z0-9]+/', '-', $this->name) ?? ''), '-'), 'fps' => '', 'ticks' => '', 'rest' => '']; $this->includeFlash = true; - $this->selectedIndex = 0; + $this->cursor = 0; $this->plan = null; $this->error = null; } @@ -86,6 +105,11 @@ public function isOpen(): bool return $this->isOpen; } + public function getMode(): string + { + return $this->mode; + } + public function getRecordIndex(): int { return $this->recordIndex; @@ -96,8 +120,8 @@ public function getName(): string return $this->name; } - /** @return list The rows shown now, in order; the binding and rate rows only where the consumer has them. */ - public function getRows(): array + /** @return list The controls shown now, in order; the binding and rate only where the consumer has them. */ + public function getControls(): array { $battle = $this->consumer !== null && $this->consumer !== 'field'; @@ -110,27 +134,95 @@ public function getRows(): array 'rest', 'flash', 'preview', - $this->plan !== null ? 'write' : null, + $this->plan !== null ? 'review' : null, ])); } - public function getSelectedRow(): string + /** + * The control under the cursor, on any of its wrapped lines, or null on + * a line of facts, a message or a reviewed file. Before the choices are + * first laid out, each control is one line. + */ + public function getSelectedControl(): ?string { - $rows = $this->getRows(); + if ($this->mode !== self::MODE_CHOICES) { + return null; + } + + return $this->lineControls === [] ? ($this->getControls()[$this->cursor] ?? null) : ($this->lineControls[$this->cursor] ?? null); + } - return $rows[min($this->selectedIndex, count($rows) - 1)]; + public function getCursor(): int + { + return $this->cursor; } - public function move(int $delta): void + /** + * The last line a scrolling pane must show so the whole selected + * control is on screen: the end of its wrapped lines, or the cursor's + * own line elsewhere. A control taller than the pane shows from its start. + */ + public function getCursorEnd(int $visibleRows): int { - $count = count($this->getRows()); - $this->selectedIndex = max(0, min($count - 1, $this->selectedIndex + $delta)); + $control = $this->getSelectedControl(); + $end = $this->cursor; + while ($control !== null && ($this->lineControls[$end + 1] ?? null) === $control) { + $end++; + } + + return min($end, $this->cursor + max(1, $visibleRows) - 1); } - /** Steps the selected choice row: the consumer, the binding or the flash policy. */ + /** + * The mode's lines at a width, with the cursor's line marked: the + * choices with the facts after them, or every reviewed file. Each line is + * one the cursor can rest on, so following the cursor shows them all. + * + * @return list + */ + public function getLines(int $width, string $projectRoot): array + { + $lines = $this->mode === self::MODE_REVIEW ? $this->getReviewLines($width, $projectRoot) : $this->getChoiceLines($width); + $this->cursor = max(0, min($this->cursor, count($lines) - 1)); + foreach ($lines as $index => $line) { + $lines[$index] = ($index === $this->cursor ? '> ' : ' ') . $line; + } + + return $lines; + } + + /** + * Moves the cursor a step, within as many lines as the mode has at that + * width. Among the choices a step is a whole control, however many + * lines it wraps to, landing on its first line; through facts and a + * review it is a line. + */ + public function move(int $delta, int $lineCount): void + { + $last = max(0, $lineCount - 1); + $step = $delta <=> 0; + foreach (range(1, abs($delta)) as $ignored) { + $from = $this->cursor; + $control = $this->mode === self::MODE_CHOICES ? ($this->lineControls[$from] ?? null) : null; + $at = max(0, min($last, $from + $step)); + // Off the rest of this control's lines when going down; onto the first line of the one above when going up. + while ($step > 0 && $control !== null && $at < $last && ($this->lineControls[$at] ?? null) === $control) { + $at++; + } + if ($step < 0 && $this->mode === self::MODE_CHOICES && ($this->lineControls[$at] ?? null) !== null) { + while ($at > 0 && ($this->lineControls[$at - 1] ?? null) === $this->lineControls[$at]) { + $at--; + } + } + // A last control taller than the rest stays where it is rather than landing mid-way. + $this->cursor = $step > 0 && $control !== null && ($this->lineControls[$at] ?? null) === $control ? $from : $at; + } + } + + /** Steps the selected choice: the consumer, the binding or the flash policy. */ public function cycle(int $step): void { - switch ($this->getSelectedRow()) { + switch ($this->getSelectedControl()) { case 'consumer': $keys = array_keys(self::CONSUMERS); $at = $this->consumer === null ? ($step > 0 ? -1 : 0) : (int) array_search($this->consumer, $keys, true); @@ -150,28 +242,29 @@ public function cycle(int $step): void $this->forgetPreview(); } - /** Types into the selected text row; a row of choices takes no typing. */ + /** Types into the selected text control; any other line takes no typing. */ public function type(string $text): void { - $row = $this->getSelectedRow(); - if (! in_array($row, self::TYPED, true)) { + $control = $this->getSelectedControl(); + if (! in_array($control, self::TYPED, true)) { return; } - $this->typed[$row] .= $text; + $this->typed[$control] .= $text; $this->forgetPreview(); } public function backspace(): void { - $row = $this->getSelectedRow(); - if (in_array($row, self::TYPED, true) && $this->typed[$row] !== '') { - $this->typed[$row] = mb_substr($this->typed[$row], 0, -1); + $control = $this->getSelectedControl(); + if (in_array($control, self::TYPED, true) && $this->typed[$control] !== '') { + $this->typed[$control] = mb_substr($this->typed[$control], 0, -1); $this->forgetPreview(); } } /** - * Plans the conversion for the choices shown and holds that plan. + * Plans the conversion for the choices shown, holds that plan, and opens + * it for review from its first line. * * @return bool Whether a plan is held; otherwise the error says why. */ @@ -199,10 +292,29 @@ public function preview(ProjectWorkspace $workspace): bool } catch (InvalidArgumentException $refusal) { $this->error = $refusal->getMessage(); } + if ($this->plan !== null) { + $this->review(); + } return $this->plan !== null; } + /** Opens the held plan for review from its first line. */ + public function review(): void + { + if ($this->plan !== null) { + $this->mode = self::MODE_REVIEW; + $this->cursor = 0; + } + } + + /** Back from review to the choices, the cursor on the review control's first line once laid out. */ + public function returnToChoices(): void + { + $this->mode = self::MODE_CHOICES; + $this->cursorControl = 'review'; + } + /** The plan previewed for the choices shown, which writing applies exactly. */ public function getPlan(): ?SourceSetPlan { @@ -214,22 +326,26 @@ public function setError(?string $error): void $this->error = $error; } - /** @return list The form's lines, without layout. */ - public function describeRows(string $projectRoot): array + /** Why the last preview or write could not be made, if it could not. */ + public function getError(): ?string { - $facts = $this->facts; - $plays = static fn(mixed $list): string => is_array($list) && $list !== [] ? implode(', ', array_map(strval(...), $list)) : 'nothing'; - $lines = [ - sprintf('%d authored frames over %d (%s anchored), %d cues, %d with a flash.', - intval($facts['frames'] ?? 0), intval($facts['maxFrames'] ?? 0), strval($facts['position'] ?? ''), - intval($facts['cues'] ?? 0), intval($facts['flashes'] ?? 0)), - sprintf('Battle plays it through: %s.', $plays($facts['battle'] ?? [])), - sprintf('Field scripts that play it: %s. They keep playing the record until their command names the timeline.', $plays($facts['field'] ?? [])), - '', - ]; - $selected = $this->getSelectedRow(); - foreach ($this->getRows() as $row) { - $value = match ($row) { + return $this->error; + } + + /** + * The controls, a message if any, then the record's facts, every one + * wrapped to the pane so none is cut short; each line remembers the + * control it belongs to. + * + * @return list + */ + private function getChoiceLines(int $width): array + { + $width = max(8, $width - 2); + $lines = []; + $this->lineControls = []; + foreach ($this->getControls() as $control) { + $label = match ($control) { 'consumer' => sprintf('Played by: %s', $this->consumer === null ? '(choose with Left/Right)' : self::CONSUMERS[$this->consumer]), 'binding' => sprintf('Binds as: %s', self::BINDINGS[$this->binding]), 'timeline' => sprintf('Timeline name: %s', $this->typed['timeline']), @@ -238,21 +354,50 @@ public function describeRows(string $projectRoot): array 'rest' => sprintf('Rest frame (from 0): %s', $this->typed['rest']), 'flash' => sprintf('Flash cues: %s', $this->includeFlash ? 'become flash tracks' : 'left out'), 'preview' => '[ Preview the files ]', - 'write' => sprintf('[ Write %d files now ]', count($this->plan?->getChangedPaths() ?? [])), + 'review' => sprintf('[ Review and write %d files ]', count($this->plan?->getChangedPaths() ?? [])), }; - $lines[] = ($row === $selected ? '> ' : ' ') . $value; + foreach (SettingsPaneLayout::wrapProse($label, $width) as $line) { + $lines[] = $line; + $this->lineControls[] = $control; + } } - if ($this->error !== null) { - $lines = [...$lines, '', ' ' . $this->error]; + $facts = $this->facts; + $plays = static fn(mixed $list): string => is_array($list) && $list !== [] ? implode(', ', array_map(strval(...), $list)) : 'nothing'; + $prose = [ + ...($this->error === null ? [] : ['', $this->error]), + '', + sprintf('%d authored frames over %d (%s anchored), %d cues, %d with a flash.', + intval($facts['frames'] ?? 0), intval($facts['maxFrames'] ?? 0), strval($facts['position'] ?? ''), + intval($facts['cues'] ?? 0), intval($facts['flashes'] ?? 0)), + sprintf('Battle plays it through: %s.', $plays($facts['battle'] ?? [])), + sprintf('Field scripts that play it: %s. They keep playing the record until their command names the timeline.', $plays($facts['field'] ?? [])), + ]; + foreach ($prose as $paragraph) { + $lines = [...$lines, ...($paragraph === '' ? [''] : SettingsPaneLayout::wrapProse($paragraph, $width))]; } - if ($this->plan !== null) { - $root = rtrim($projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; - foreach ($this->plan->getProposedSources() as $path => $source) { - $lines[] = ''; - $lines[] = ' ' . (str_starts_with($path, $root) ? substr($path, strlen($root)) : $path); - foreach (explode("\n", rtrim($source, "\n")) as $line) { - $lines[] = ' ' . $line; - } + $this->lineControls = array_pad($this->lineControls, count($lines), null); + if ($this->cursorControl !== null) { + $at = array_search($this->cursorControl, $this->lineControls, true); + $this->cursor = is_int($at) ? $at : 0; + $this->cursorControl = null; + } + + return $lines; + } + + /** @return list Every file the held plan would write, path then source, wrapped. */ + private function getReviewLines(int $width, string $projectRoot): array + { + $width = max(8, $width - 2); + $root = rtrim($projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $sources = $this->plan?->getProposedSources() ?? []; + $lines = SettingsPaneLayout::wrapProse(sprintf('Review the %d files Enter writes; Esc returns to the choices.', count($sources)), $width); + foreach ($sources as $path => $source) { + $lines[] = ''; + $lines = [...$lines, ...SettingsPaneLayout::wrapProse(str_starts_with($path, $root) ? substr($path, strlen($root)) : $path, $width)]; + foreach (explode("\n", rtrim($source, "\n")) as $line) { + // Code keeps its indentation; a line wider than the pane continues below it. + $lines = [...$lines, ...($line === '' ? [''] : SettingsPaneLayout::wrapProse(' ' . $line, $width))]; } } @@ -263,5 +408,6 @@ private function forgetPreview(): void { $this->plan = null; $this->error = null; + $this->mode = self::MODE_CHOICES; } } diff --git a/src/Editor.php b/src/Editor.php index b314127..2bf48e2 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -9948,24 +9948,32 @@ private function openAnimationConversion(): void } /** - * Handles input while a conversion is being set up: rows move with - * Up/Down, choices step with Left/Right, text rows take typing, Enter on - * Preview plans it and on Write applies exactly that plan as one undo - * step, and Escape leaves everything unchanged. + * Handles input while a conversion is being set up or reviewed. Up/Down + * walk the mode's lines, the pane following the cursor; choices step + * with Left/Right and text takes typing; Enter on Preview plans it and + * opens the review, and in review writes exactly that plan as one undo + * step. Escape goes back from review to the choices, and from the + * choices leaves everything unchanged. */ private function handleAnimationConversionInput(string $input): void { $conversion = $this->animationConversion; + $review = $conversion->getMode() === AnimationConversionEditor::MODE_REVIEW; if ($input === "\033" || $input === "\x1b") { - $conversion->close(); - $this->statusMessage = 'Conversion cancelled; nothing was written.'; + if ($review) { + $conversion->returnToChoices(); + } else { + $conversion->close(); + $this->statusMessage = 'Conversion cancelled; nothing was written.'; + } $this->renderDatabasePanes(['settings']); return; } + $lineCount = count($conversion->getLines($this->recordPaneMetrics()['width'], $this->projectRoot)); match (true) { - str_contains($input, "\033[A") => $conversion->move(-1), - str_contains($input, "\033[B") => $conversion->move(1), + str_contains($input, "\033[A") => $conversion->move(-1, $lineCount), + str_contains($input, "\033[B") => $conversion->move(1, $lineCount), str_contains($input, "\033[D") => $conversion->cycle(-1), str_contains($input, "\033[C") => $conversion->cycle(1), $input === "\x7f" || $input === "\x08" => $conversion->backspace(), @@ -9978,26 +9986,28 @@ private function handleAnimationConversionInput(string $input): void } } - /** Enter in the conversion: preview it, write the previewed plan, or move to the next row. */ + /** Enter in the conversion: preview it, open its review, write the reviewed plan, or move on a line. */ private function submitAnimationConversion(): void { $conversion = $this->animationConversion; if (! $this->workspace instanceof ProjectWorkspace) { return; } - if ($conversion->getSelectedRow() === 'preview') { - if ($conversion->preview($this->workspace)) { - $conversion->move(1); - $this->statusMessage = 'Previewed. Write applies exactly these files.'; + if ($conversion->getMode() === AnimationConversionEditor::MODE_CHOICES) { + $control = $conversion->getSelectedControl(); + if ($control === 'preview') { + // The status line carries the outcome, so a refusal is read wherever the pane has scrolled. + $conversion->preview($this->workspace) + ? $this->setStatus('Review the files; Enter writes exactly these.') + : $this->setStatus((string) $conversion->getError(), StatusLevel::WARN); + } elseif ($control === 'review') { + $conversion->review(); + } elseif ($control !== null) { + $conversion->move(1, count($conversion->getLines($this->recordPaneMetrics()['width'], $this->projectRoot))); } return; } - if ($conversion->getSelectedRow() !== 'write') { - $conversion->move(1); - - return; - } $plan = $conversion->getPlan(); if ($plan === null) { return; @@ -10016,6 +10026,8 @@ function (ProjectWorkspace $workspace): void { $this->workspace = $workspace; }, } catch (Throwable $failure) { // Files changed since the preview, or the write failed: nothing is written; preview again. $conversion->setError($failure->getMessage()); + $conversion->returnToChoices(); + $this->setStatus($failure->getMessage(), StatusLevel::WARN); return; } @@ -10026,16 +10038,26 @@ function (ProjectWorkspace $workspace): void { $this->workspace = $workspace; }, $this->requestFullRender(); } - /** @return list The conversion's rows, in the settings pane. */ + /** + * The conversion in the settings pane: its title, then the mode's lines + * in whatever height the pane has, following the cursor through the + * shared scroll window so the selected line is always shown. + * + * @return list + */ private function buildAnimationConversionRows(): array { - $width = $this->recordPaneMetrics()['width']; - $lines = [sprintf('Convert %s to a timeline', $this->animationConversion->getName()), '']; - foreach ($this->animationConversion->describeRows($this->projectRoot) as $line) { - $lines = [...$lines, ...($line === '' ? [''] : SettingsPaneLayout::wrapProse($line, $width))]; - } + $metrics = $this->recordPaneMetrics(); + $conversion = $this->animationConversion; + $title = $conversion->getMode() === AnimationConversionEditor::MODE_REVIEW + ? sprintf('Review: convert %s', $conversion->getName()) + : sprintf('Convert %s to a timeline', $conversion->getName()); + $lines = $conversion->getLines($metrics['width'], $this->projectRoot); + // Two header lines, then the lines in whatever height the pane has. + $visibleRows = max(1, $metrics['rows'] - 2); - return $lines; + // The whole selected control stays on screen, not only its first line. + return [$title, '', ...ScrollWindow::slice($lines, $conversion->getCursorEnd($visibleRows), $visibleRows)]; } private function openAffinityEditor(array $field): void @@ -12989,11 +13011,17 @@ private function createDatabaseSettingsWindow(array $layout): EditorWindow 'a/d:Add/Del n/x:Edit ?:Help', '?:Help', ), + $this->animationConversion->isOpen() && $this->animationConversion->getMode() === AnimationConversionEditor::MODE_REVIEW => $this->fitHelp( + $layout['settingsWidth'], + 'Up/Down:Read Enter:Write these files Esc:Back to choices', + 'Up/Down:Read Enter:Write Esc:Back', + 'Enter:Write Esc:Back', + ), $this->animationConversion->isOpen() => $this->fitHelp( $layout['settingsWidth'], - 'Up/Down:Row Left/Right:Choose Type:Text Enter:Preview/Write Esc:Cancel', - 'Left/Right:Choose Enter:Preview/Write Esc:Cancel', - 'Enter:Preview/Write Esc:Cancel', + 'Up/Down:Row Left/Right:Choose Type:Text Enter:Preview Esc:Cancel', + 'Left/Right:Choose Enter:Preview Esc:Cancel', + 'Enter:Preview Esc:Cancel', '?:Help', ), $this->affinityEditor->isOpen() => $this->fitHelp( diff --git a/tests/Unit/TerminalAnimationConversionTest.php b/tests/Unit/TerminalAnimationConversionTest.php index 287a919..71ac538 100644 --- a/tests/Unit/TerminalAnimationConversionTest.php +++ b/tests/Unit/TerminalAnimationConversionTest.php @@ -3,134 +3,191 @@ declare(strict_types=1); use Ichiloto\Editor\Animations\AnimationConversionEditor; +use Ichiloto\Engine\Animations\ActionAnimationResolver; use Ichiloto\Editor\Editor; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; -/** Converting a legacy animation to a timeline from the terminal's Database. Synthetic fixtures only. */ +/** + * Converting a legacy animation to a timeline from the terminal's Database, + * read from the pane as the window fits it at real terminal sizes. + * Synthetic fixtures only. + */ -const TERMINAL_LEGACY_ANIMATIONS = <<<'PHP' - %d, 'cells' => [['x' => %d, 'y' => 0, 'symbol' => '*']]],", $frame, $frame % 5); + } + // Every role the Engine supports: a long list of battle consumers to wrap. + $roles = implode(', ', array_map(static fn(string $role): string => var_export($role, true), ActionAnimationResolver::getSupportedRoles())); + + return " 1,\n 'name' => 'Old Spark',\n" + . " 'position' => 'center',\n 'maxFrames' => 30,\n 'roles' => [{$roles}],\n 'frames' => [\n" + . implode("\n", $frames) . "\n ],\n" + . " 'cues' => [['frame' => 1, 'soundEffect' => 'spark', 'flashColor' => 'white', 'flashDurationFrames' => 2]],\n ],\n];\n"; +} -return [ - // A spark in the old cell frames. - [ - 'id' => 1, - 'name' => 'Old Spark', - 'position' => 'center', - 'maxFrames' => 3, - 'frames' => [ - ['index' => 1, 'cells' => [['x' => 0, 'y' => 0, 'symbol' => '*', 'color' => 'yellow']]], - ['index' => 2, 'cells' => [['x' => 1, 'y' => 0, 'symbol' => '+']]], - ], - 'cues' => [['frame' => 1, 'soundEffect' => 'spark', 'flashColor' => 'white', 'flashDurationFrames' => 2]], - ], -]; -PHP; - -/** The terminal editor on the Animations database, the legacy record selected. */ -function openLegacyAnimationEditor(): array +/** The terminal editor at a size, on the Animations database with the legacy record selected. */ +function openLegacyAnimationEditor(int $width = 160, int $height = 48): array { $root = makeTemporaryProject(); - file_put_contents($root . '/assets/Data/animations.php', TERMINAL_LEGACY_ANIMATIONS); + file_put_contents($root . '/assets/Data/animations.php', longLegacyAnimations()); $editor = createEditorForTesting($root); setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); - setEditorProperty($editor, 'lastTerminalSize', ['width' => 160, 'height' => 48]); + setEditorProperty($editor, 'lastTerminalSize', ['width' => $width, 'height' => $height]); callEditorMethod($editor, 'openDatabaseAtCategory', 'animations'); return [$editor, $root]; } -/** The conversion pane as one line of prose, whatever its wrapping. */ -function readConversionPane(Editor $editor): string -{ - return preg_replace('/\s+/', ' ', implode(' ', callEditorMethod($editor, 'buildAnimationConversionRows'))); -} - -/** The conversion form the editor holds. */ function terminalConversion(Editor $editor): AnimationConversionEditor { return getEditorProperty($editor, 'animationConversion'); } -it('converts from the terminal: chosen timing, previewed files, one undo step restoring them', function () { - [$editor, $root] = openLegacyAnimationEditor(); - $timeline = $root . '/assets/Animations/old-spark/old-spark.timeline.php'; +/** @return list The settings pane as the window draws it: fitted to its width and height. */ +function visibleConversionPane(Editor $editor): array +{ + $metrics = callEditorMethod($editor, 'recordPaneMetrics'); - pressKeys($editor, 'T'); - $conversion = terminalConversion($editor); + return callEditorMethod($editor, 'fitLines', callEditorMethod($editor, 'getDatabaseSettingsLines'), $metrics['width'], $metrics['rows']); +} + +/** The line the cursor is on, as drawn; it must be on screen. */ +function visibleCursorLine(Editor $editor): string +{ + return array_values(array_filter(visibleConversionPane($editor), static fn(string $line): bool => str_starts_with($line, '> ')))[0] + ?? throw new RuntimeException("The cursor is off screen:\n" . implode("\n", visibleConversionPane($editor))); +} - expect($conversion->isOpen())->toBeTrue() - ->and($conversion->getRows())->toBe(['consumer', 'timeline', 'ticks', 'rest', 'flash', 'preview']); +/** The visible pane as one line of prose, cursor markers dropped, whatever its wrapping. */ +function readVisibleConversionPane(Editor $editor): string +{ + return preg_replace('/\s+/', ' ', implode(' ', array_map(static fn(string $line): string => substr($line, 2), visibleConversionPane($editor)))); +} - // A battle paced by its phases, bound as the target effect; then the ticks and rest frame, typed. - pressKeys($editor, "\033[C", "\033[B", "\033[B", "\033[B"); +/** Battle phase cadence, target binding, ticks 1 and rest 0, typed through the pane. */ +function chooseBattleTiming(Editor $editor): void +{ + pressKeys($editor, 'T', "\033[C", "\033[B", "\033[B", "\033[B"); typeText($editor, '1'); pressKeys($editor, "\033[B"); typeText($editor, '0'); - pressKeys($editor, "\033[B", "\033[B", "\r"); + pressKeys($editor, "\033[B", "\033[B"); +} + +it('keeps the selected control on screen and every fact reachable at real terminal sizes', function (int $width, int $height) { + [$editor] = openLegacyAnimationEditor($width, $height); + pressKeys($editor, 'T'); + $conversion = terminalConversion($editor); + $seen = []; + + expect(visibleCursorLine($editor))->toContain('Played by'); + + // Walk every step, controls then the facts after them, reading what the pane shows at each. + $lines = count($conversion->getLines(callEditorMethod($editor, 'recordPaneMetrics')['width'], getEditorProperty($editor, 'projectRoot'))); + foreach (range(1, $lines) as $step) { + visibleCursorLine($editor); + $seen[] = readVisibleConversionPane($editor); + pressKeys($editor, "\033[B"); + } + $read = implode(' ', $seen); - expect($conversion->getPlan())->not->toBeNull() - ->and($conversion->getSelectedRow())->toBe('write') - ->and(implode("\n", callEditorMethod($editor, 'buildAnimationConversionRows')))->toContain("'targetEffect' => 'old-spark'", 'assets/Animations/old-spark/old-spark.timeline.php') + expect($read)->toContain('Preview the files', 'role ' . array_last(ActionAnimationResolver::getSupportedRoles()), 'until their command names the timeline'); +})->with([[80, 24], [120, 40], [160, 48]]); + +it('reviews every proposed line before writing exactly that plan, as one undo step', function (int $width, int $height) { + [$editor, $root] = openLegacyAnimationEditor($width, $height); + $timeline = $root . '/assets/Animations/old-spark/old-spark.timeline.php'; + chooseBattleTiming($editor); + $conversion = terminalConversion($editor); + + expect($conversion->getSelectedControl())->toBe('preview') + ->and(visibleCursorLine($editor))->toContain('[ Preview') + ->and(readVisibleConversionPane($editor))->toContain('[ Preview the files ]'); + + pressKeys($editor, "\r"); + + expect($conversion->getMode())->toBe(AnimationConversionEditor::MODE_REVIEW) ->and(file_exists($timeline))->toBeFalse(); + // Read the whole review, a line at a time, as the pane shows it. + $seen = []; + $lines = count($conversion->getLines(callEditorMethod($editor, 'recordPaneMetrics')['width'], $root)); + foreach (range(1, $lines) as $step) { + $seen[] = substr(visibleCursorLine($editor), 2); + pressKeys($editor, "\033[B"); + } + $read = preg_replace('/\s+/', '', implode('', $seen)); + + // Every proposed file was on screen in full, in order: nothing is written unread. + foreach ($conversion->getPlan()->getProposedSources() as $source) { + expect(str_contains($read, preg_replace('/\s+/', '', $source)))->toBeTrue(); + } + pressKeys($editor, "\r"); expect($conversion->isOpen())->toBeFalse() - ->and(file_get_contents($root . '/assets/Data/animations.php'))->toContain("'targetEffect' => 'old-spark'", "'frames' => [", '// A spark in the old cell frames.') + ->and(file_get_contents($root . '/assets/Data/animations.php'))->toContain("'targetEffect' => 'old-spark'", '// A spark in the old cell frames.') ->and(new EffectTimelineLibrary($root . '/assets')->load('old-spark', forBattle: true))->not->toBeNull(); callEditorMethod($editor, 'performUndo'); - expect(file_get_contents($root . '/assets/Data/animations.php'))->toBe(TERMINAL_LEGACY_ANIMATIONS) + expect(file_get_contents($root . '/assets/Data/animations.php'))->toBe(longLegacyAnimations()) ->and(file_exists($timeline))->toBeFalse(); -}); +})->with([[80, 24], [120, 40], [160, 48]]); -it('forgets a preview when a choice changes, so Write only follows a preview of what is shown', function () { - [$editor] = openLegacyAnimationEditor(); - pressKeys($editor, 'T', "\033[C", "\033[B", "\033[B", "\033[B"); - typeText($editor, '1'); - pressKeys($editor, "\033[B"); - typeText($editor, '0'); - pressKeys($editor, "\033[B", "\033[B", "\r"); +it('goes back from review to the choices on Escape, and forgets the plan when a choice changes', function () { + [$editor] = openLegacyAnimationEditor(80, 24); + chooseBattleTiming($editor); + pressKeys($editor, "\r", "\033"); $conversion = terminalConversion($editor); - expect($conversion->getRows())->toContain('write'); + expect($conversion->getMode())->toBe(AnimationConversionEditor::MODE_CHOICES) + ->and(visibleCursorLine($editor))->toContain('[ Review') + ->and($conversion->getSelectedControl())->toBe('review') + ->and(readVisibleConversionPane($editor))->toContain('[ Review and write 2 files ]'); - // Back up to the ticks and change them. + // Up to the ticks, and change them. pressKeys($editor, "\033[A", "\033[A", "\033[A", "\033[A"); typeText($editor, '2'); expect($conversion->getPlan())->toBeNull() - ->and($conversion->getRows())->not->toContain('write'); + ->and($conversion->getControls())->not->toContain('review'); }); -it('refuses to write a preview the files have moved on from, writing nothing', function () { - [$editor, $root] = openLegacyAnimationEditor(); - pressKeys($editor, 'T', "\033[C", "\033[B", "\033[B", "\033[B"); - typeText($editor, '1'); - pressKeys($editor, "\033[B"); - typeText($editor, '0'); - pressKeys($editor, "\033[B", "\033[B", "\r"); +it('refuses to write a review the files have moved on from, writing nothing and saying why', function () { + [$editor, $root] = openLegacyAnimationEditor(80, 24); + chooseBattleTiming($editor); + pressKeys($editor, "\r"); - file_put_contents($root . '/assets/Data/animations.php', str_replace('// A spark in the old cell frames.', '// Edited elsewhere.', TERMINAL_LEGACY_ANIMATIONS)); + file_put_contents($root . '/assets/Data/animations.php', str_replace('// A spark in the old cell frames.', '// Edited elsewhere.', longLegacyAnimations())); pressKeys($editor, "\r"); - expect(terminalConversion($editor)->isOpen())->toBeTrue() - ->and(readConversionPane($editor))->toContain('changed outside the animation conversion') + expect(terminalConversion($editor)->getMode())->toBe(AnimationConversionEditor::MODE_CHOICES) + ->and(terminalConversion($editor)->getError())->toContain('changed outside the animation conversion') ->and(file_exists($root . '/assets/Animations/old-spark'))->toBeFalse() ->and(file_get_contents($root . '/assets/Data/animations.php'))->toContain('// Edited elsewhere.'); }); -it('says why a record cannot be converted, and asks for timing rather than assuming it', function () { - [$editor, $root] = openLegacyAnimationEditor(); +it('asks for who plays the timeline rather than assuming it', function () { + [$editor] = openLegacyAnimationEditor(160, 48); // Down to Preview with no consumer chosen. - pressKeys($editor, 'T', "\033[B", "\033[B", "\033[B", "\033[B", "\033[B", "\r"); + pressKeys($editor, 'T', "\033[B", "\033[B", "\033[B", "\033[B", "\033[B"); + + expect(visibleCursorLine($editor))->toContain('Preview the files'); - expect(readConversionPane($editor))->toContain('Choose who plays the timeline'); + pressKeys($editor, "\r"); + + expect(terminalConversion($editor)->getError())->toContain('Choose who plays the timeline') + ->and(terminalConversion($editor)->getMode())->toBe(AnimationConversionEditor::MODE_CHOICES); +}); - pressKeys($editor, "\033"); +it('says why a record cannot be converted', function () { + [$editor, $root] = openLegacyAnimationEditor(80, 24); file_put_contents($root . '/assets/Data/animations.php', " 1, 'name' => 'Already Timed', 'targetEffect' => 'burst']];\n"); setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); pressKeys($editor, 'T'); From e6591e12887016f7ac97455a5017a85a134adc6f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 03:29:26 +0200 Subject: [PATCH 175/344] fix(ui): wrap text indented deeper than the pane without losing it SettingsPaneLayout::wrapProse kept a line's whole indent on every wrapped line, so text indented past a narrow pane's width came out wider than the pane and was truncated to spaces: deeply nested source in the conversion review was invisible. The indent is now shortened where it must be, so the text keeps at least half the width, every line fits, and nothing is lost. --- src/UI/SettingsPaneLayout.php | 6 ++++-- tests/Unit/SettingsPaneLayoutTest.php | 9 +++++++++ tests/Unit/TerminalAnimationConversionTest.php | 5 ++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/UI/SettingsPaneLayout.php b/src/UI/SettingsPaneLayout.php index 48cb37b..0764f33 100644 --- a/src/UI/SettingsPaneLayout.php +++ b/src/UI/SettingsPaneLayout.php @@ -268,7 +268,9 @@ public static function wrap(string $text, int $firstWidth, int $restWidth): arra /** * Wraps a plain line of prose to a width, keeping its leading indent on - * every continuation line. + * every continuation line. Every line fits the width and no text is + * lost: an indent deeper than the pane allows is shortened, so the text + * keeps at least half the width (deeply nested source in a narrow pane). * * @param string $line The line, possibly indented. * @param int $contentWidth The pane's writable width. @@ -277,13 +279,13 @@ public static function wrap(string $text, int $firstWidth, int $restWidth): arra public static function wrapProse(string $line, int $contentWidth): array { $contentWidth = max(1, $contentWidth); - $indent = strlen($line) - strlen(ltrim($line, ' ')); $text = ltrim($line, ' '); if ($text === '' || mb_strwidth($line) <= $contentWidth) { return [$line]; } + $indent = min(strlen($line) - strlen($text), intdiv($contentWidth, 2)); $width = max(1, $contentWidth - $indent); $pad = str_repeat(' ', $indent); diff --git a/tests/Unit/SettingsPaneLayoutTest.php b/tests/Unit/SettingsPaneLayoutTest.php index 3db528e..1d3fbe6 100644 --- a/tests/Unit/SettingsPaneLayoutTest.php +++ b/tests/Unit/SettingsPaneLayoutTest.php @@ -110,3 +110,12 @@ function paneRow(string $label, string $value, bool $editable = true, bool $sele ->toBe([' None. Nothing changes when', ' this completes.']) ->and(SettingsPaneLayout::wrapProse(' a to add a write.', 30))->toBe([' a to add a write.']); }); + +it('keeps text deeper than the pane is wide, shortening its indent instead of losing it', function () { + $lines = \Ichiloto\Editor\UI\SettingsPaneLayout::wrapProse(str_repeat(' ', 24) . '// preserved nested source', 16); + + expect(array_filter($lines, static fn(string $line): bool => mb_strwidth($line) > 16))->toBe([]) + // A word longer than the room left is split, never dropped. + ->and(preg_replace('/\s+/', '', implode('', $lines)))->toBe('//preservednestedsource') + ->and($lines[0])->toStartWith(str_repeat(' ', 8) . '//'); +}); diff --git a/tests/Unit/TerminalAnimationConversionTest.php b/tests/Unit/TerminalAnimationConversionTest.php index 71ac538..6bf531b 100644 --- a/tests/Unit/TerminalAnimationConversionTest.php +++ b/tests/Unit/TerminalAnimationConversionTest.php @@ -24,7 +24,7 @@ function longLegacyAnimations(): string // Every role the Engine supports: a long list of battle consumers to wrap. $roles = implode(', ', array_map(static fn(string $role): string => var_export($role, true), ActionAnimationResolver::getSupportedRoles())); - return " 1,\n 'name' => 'Old Spark',\n" + return " 1,\n 'name' => 'Old Spark',\n" . " 'position' => 'center',\n 'maxFrames' => 30,\n 'roles' => [{$roles}],\n 'frames' => [\n" . implode("\n", $frames) . "\n ],\n" . " 'cues' => [['frame' => 1, 'soundEffect' => 'spark', 'flashColor' => 'white', 'flashDurationFrames' => 2]],\n ],\n];\n"; @@ -123,6 +123,9 @@ function chooseBattleTiming(Editor $editor): void } $read = preg_replace('/\s+/', '', implode('', $seen)); + // Deeply indented source stays readable however narrow the pane. + expect($read)->toContain('//preservednestedsource'); + // Every proposed file was on screen in full, in order: nothing is written unread. foreach ($conversion->getPlan()->getProposedSources() as $source) { expect(str_contains($read, preg_replace('/\s+/', '', $source)))->toBeTrue(); From 572c4305064ba279c886dd6e24ecb825a02da80d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 05:04:52 +0200 Subject: [PATCH 176/344] feat(database): grant counter attacks from actors, enemies, states and abilities The Engine reads an optional counterAttack => ['skill' => name] on actors, enemies, states and non-magic skills, validated by its CounterAttackRule. The Database can now author it, in both editors through the shared record and actor services: - Counter Attack on enemies, states, basic and special skills (not spells), and actors. - It is off unless chosen. Choosing none removes the grant, leaving no empty counterAttack, as the Engine reads an omitted one. - The picker (counter_skills) offers only the skills CounterAttackRule::resolveSkill accepts. - CounterAttackValidator reports a grant the Engine would refuse, with the Engine's own reason. No game's counters are enabled. The manual describes it. --- docs/manual.md | 12 ++ src/Actors/ActorAuthoring.php | 16 ++- src/Database/RecordSchemaCatalog.php | 19 ++- src/Database/ReferenceCatalog.php | 27 ++++ src/ProjectActor.php | 28 +++++ src/Validation/CounterAttackValidator.php | 52 ++++++++ src/Validation/ProjectValidator.php | 1 + .../Unit/Session/CounterAttackSessionTest.php | 116 ++++++++++++++++++ 8 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 src/Validation/CounterAttackValidator.php create mode 100644 tests/Unit/Session/CounterAttackSessionTest.php diff --git a/docs/manual.md b/docs/manual.md index 4db3b7f..91f5586 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1259,6 +1259,18 @@ battle-entry rules match against. An omitted classification is the engine's `ordinary` default; the picker shows that without writing the key into older data. +### Counter Attacks + +An actor, an enemy, a state or a non-magic skill can grant a **Counter +Attack**: the skill its bearer answers with when a physical hit lands on +it, after the attacker has returned, never chaining into another counter. +It is off unless chosen. The picker offers only skills the Engine accepts +for a counter (a battle-usable basic or special skill aimed at one living +opponent), and choosing none removes the grant, as the Engine reads one +left out. A state grants it while active, and a skill once learned; spells +do not grant one. Validation reports a grant the Engine would refuse, with +the Engine's own reason. + ### Battle Art The art an actor or enemy fights with in a graphical battle is bound to it as diff --git a/src/Actors/ActorAuthoring.php b/src/Actors/ActorAuthoring.php index 243a100..2156cf6 100644 --- a/src/Actors/ActorAuthoring.php +++ b/src/Actors/ActorAuthoring.php @@ -61,6 +61,9 @@ final class ActorAuthoring /** What the Attack Skill row reads when the actor uses the Engine's own attack. */ public const string BUILT_IN_ATTACK = '(Built-in attack)'; + /** What an actor without a counter attack reads as. */ + public const string NO_COUNTER = '(no counter)'; + /** @var array Which variant each actor's rows are editing. */ private array $variantSelections = []; @@ -578,6 +581,17 @@ public function describeFields(ProjectWorkspace $workspace, ProjectActor $actor) 'noneLabel' => self::BUILT_IN_ATTACK, 'field' => 'attackSkill', ], + [ + // The skill this actor responds with when a physical hit + // lands on it; off unless chosen, picked from the skills the + // Engine's counter rule accepts. + 'label' => 'Counter Attack', + 'value' => $actor->getCounterAttackSkill() === '' ? self::NO_COUNTER : $actor->getCounterAttackSkill(), + 'reference' => 'counter_skills', + 'allowsNone' => true, + 'noneLabel' => self::NO_COUNTER, + 'field' => 'counterAttack', + ], ...$this->actorSummonFields($workspace, $actor), [ 'label' => 'Level', @@ -695,7 +709,7 @@ private function getActorClassOptions(ProjectWorkspace $workspace): array */ private static function coerceActorFieldValue(string $field, string $rawValue): string|int { - if (in_array($field, ['name', 'description', 'class', 'attackStyle', 'attackSkill', 'id', 'defaultNaturalVariantId', 'summons'], true)) { + if (in_array($field, ['name', 'description', 'class', 'attackStyle', 'attackSkill', 'counterAttack', 'id', 'defaultNaturalVariantId', 'summons'], true)) { return trim($rawValue); } diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index d6d09ed..9c85805 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -175,6 +175,8 @@ private static function states(): RecordSchema new RecordField('disposition', 'Disposition', options: array_map(static fn(StateDisposition $disposition): string => $disposition->value, StateDisposition::cases()), removeWhenEmpty: true, displayDefault: StateDisposition::HARMFUL->value), + // A bearer of this state counters while it is active. + self::counterAttackField(), ], labelKey: 'name', identityKey: 'id', @@ -1158,6 +1160,7 @@ private static function enemies(): RecordSchema new RecordField('elementAffinities', 'Element Affinities', removeWhenEmpty: true, codec: RecordFieldCodec::AFFINITIES), new RecordField('stateResistances', 'State Resistances', removeWhenEmpty: true, codec: RecordFieldCodec::KEY_VALUES), RecordField::reference('knowledgeSubjectId', 'Knowledge Subject', 'knowledge_subjects', allowsNone: true), + self::counterAttackField(), ], labelKey: 'name', identityKey: 'name', @@ -1334,7 +1337,8 @@ private static function skills(): RecordSchema variants: $variants, variantKey: 'type', ), - fieldsFor: static fn(array $row): array => strval($row['kind'] ?? '') === 'magic' ? [...$fields, $effectType] : $fields, + // A spell has an effect type; a learned non-magic ability may grant a counter attack instead. + fieldsFor: static fn(array $row): array => strval($row['kind'] ?? '') === 'magic' ? [...$fields, $effectType] : [...$fields, self::counterAttackField()], recordClass: Skill::class, numberedFiles: true, ); @@ -1631,6 +1635,19 @@ private static function tilesets(): RecordSchema ), ); } + /** + * A counter attack: the skill a battler responds with when a physical + * hit lands on it, after the attacker returns, never chaining. It is off + * unless chosen, and choosing none removes it, as the Engine reads an + * omitted counterAttack. The picker offers only skills the Engine's + * CounterAttackRule accepts. + */ + private static function counterAttackField(): RecordField + { + return new RecordField('counterAttack.skill', 'Counter Attack', reference: 'counter_skills', removeWhenEmpty: true, + allowsNone: true, displayDefault: '(no counter)'); + } + /** Where battle art is bound to battlers as data, the Engine's {@see BattlerBindings::FILE}. */ public const string BATTLERS_PATH = 'assets/' . BattlerBindings::FILE; diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 128b2f2..6609b49 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -12,6 +12,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; +use Ichiloto\Engine\Battle\CounterAttackRule; use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Ichiloto\Engine\Entities\Enumerations\ArmorType; @@ -44,6 +45,7 @@ final class ReferenceCatalog 'actor_ids', 'classes', 'attack_skills', + 'counter_skills', 'resolution_kinds', 'piece_connections', 'resolution_scopes', @@ -150,6 +152,8 @@ public function valuesFor(string $category): array // What an actor's Attack command may use: a basic skill from the // catalogue that can be used in battle. 'attack_skills' => $this->attackSkillNames(), + // What a counter attack may respond with, by the Engine's own rule. + 'counter_skills' => $this->counterSkillNames(), // A class restricts what its members equip by the engine's type names. // How a skill effect resolves, how often a skill rolls, and what // kind of spell it is: the Engine's own vocabularies. @@ -246,6 +250,29 @@ private function attackSkillNames(): array )); } + /** + * The skills a counter attack may respond with: those the Engine's + * CounterAttackRule accepts (a battle-usable basic or special skill + * targeting one living opponent, without summon or required weapons). + * + * @return list + */ + private function counterSkillNames(): array + { + $catalog = $this->workspace->loadSkillCatalog(); + $names = []; + foreach (array_keys($catalog->getSkills()) as $name) { + try { + new CounterAttackRule((string) $name)->resolveSkill($catalog); + $names[] = (string) $name; + } catch (\InvalidArgumentException) { + continue; + } + } + + return $names; + } + /** @return ProjectRecord[] The project's animations, in file order. */ private function animationRecords(): array { diff --git a/src/ProjectActor.php b/src/ProjectActor.php index 52c3207..f5d076e 100644 --- a/src/ProjectActor.php +++ b/src/ProjectActor.php @@ -206,6 +206,18 @@ public function getAttackSkill(): string return is_string($skill) ? $skill : ''; } + /** + * Returns the skill the actor counters with, from `data.counterAttack.skill`. + * + * @return string The skill's name, or an empty string when the actor does not counter. + */ + public function getCounterAttackSkill(): string + { + $skill = $this->getData()['counterAttack']['skill'] ?? ''; + + return is_string($skill) ? $skill : ''; + } + /** * Returns the Engine weapon type an attack style names, read by the * Engine's own rule, or null when it names none. @@ -624,6 +636,22 @@ public function setField(string $field, mixed $value): void return; } + if ($field === 'counterAttack') { + // No counter is the absence of the grant, so it removes the key, + // as the Engine reads an omitted counterAttack; any other value + // names the skill it responds with. + $skill = trim((string) $value); + + if ($skill === '') { + unset($this->payload['data']['counterAttack']); + } else { + $this->payload['data']['counterAttack'] = ['skill' => $skill]; + } + + $this->touchState(); + return; + } + if ($field === 'summons') { // A list of stable summon ids, each at most once, in the order // chosen; an empty list removes the key so the actor reads as diff --git a/src/Validation/CounterAttackValidator.php b/src/Validation/CounterAttackValidator.php new file mode 100644 index 0000000..68fd773 --- /dev/null +++ b/src/Validation/CounterAttackValidator.php @@ -0,0 +1,52 @@ +loadSkillCatalog(); + $issues = []; + + foreach ($workspace->actorDatabase->getActors() as $actor) { + array_push($issues, ...$this->check($actor->getData()['counterAttack'] ?? null, $catalog, + sprintf('assets/Data/Actors: %s', $actor->getName()))); + } + foreach (['enemies' => 'assets/Data/Enemies', 'states' => 'assets/Data/states.php', 'skills' => 'assets/Data/Skills'] as $category => $where) { + foreach ($workspace->getRecordDatabase($category)?->getRecords() ?? [] as $record) { + array_push($issues, ...$this->check($record->get('counterAttack'), $catalog, + sprintf('%s: %s', $where, strval($record->get('name'))))); + } + } + + return $issues; + } + + /** @return Issue[] */ + private function check(mixed $grant, SkillCatalog $catalog, string $where): array + { + try { + CounterAttackRule::fromArray($grant)?->resolveSkill($catalog); + } catch (InvalidArgumentException $refused) { + return [Issue::error($where, $refused->getMessage(), 'Pick its Counter Attack from the list, which offers only skills a counter may use, or choose no counter.')]; + } + + return []; + } +} diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 6dac65b..be003bd 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -142,6 +142,7 @@ public function validate(ProjectWorkspace $workspace): array ...$this->checkCutscenes($workspace), ...$this->checkReferences($workspace), ...new AnimationReferenceValidator()->validate($workspace), + ...new CounterAttackValidator()->validate($workspace), ...$this->checkDefinitionIdentities($workspace), ...$this->checkActorDefinitions($workspace), ...new ActorReferenceValidator()->validate($workspace), diff --git a/tests/Unit/Session/CounterAttackSessionTest.php b/tests/Unit/Session/CounterAttackSessionTest.php new file mode 100644 index 0000000..7e2f568 --- /dev/null +++ b/tests/Unit/Session/CounterAttackSessionTest.php @@ -0,0 +1,116 @@ +readDatabaseRecord($category, $index)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $field); +} + +it('offers only the skills the Engine accepts for a counter', function () { + $root = counterAttackProject(); + $session = EditorSession::open($root); + [$map] = $session->describeMaps(); + $offered = array_column($session->listReferences($map['id'], 'counter_skills'), 'value'); + $catalog = SkillCatalog::load($root . '/assets'); + + expect($offered)->toBe(['Riposte']) + ->and(new CounterAttackRule('Riposte')->resolveSkill($catalog)->name)->toBe('Riposte'); +}); + +it('grants an enemy a counter attack and takes it away, leaving no empty grant behind', function () { + $root = counterAttackProject(); + $session = EditorSession::open($root); + $file = $root . '/assets/Data/Enemies/regular-bat.php'; + $before = (string) file_get_contents($file); + + expect(counterRow($session, 'enemies', 0, 'counterAttack.skill'))->toMatchArray(['kind' => 'reference', 'reference' => 'counter_skills']); + + $session->applyDatabaseRecord('enemies', 0, counterRow($session, 'enemies', 0, 'counterAttack.skill')['key'], 'Riposte'); + $session->saveDatabase('enemies'); + $granted = require $file; + + expect($granted['data']['counterAttack'])->toBe(['skill' => 'Riposte']) + ->and(CounterAttackRule::fromArray($granted['data']['counterAttack'])->skill)->toBe('Riposte'); + + $session->applyDatabaseRecord('enemies', 0, counterRow($session, 'enemies', 0, 'counterAttack.skill')['key'], ''); + $session->saveDatabase('enemies'); + + expect((require $file)['data'])->not->toHaveKey('counterAttack') + ->and(file_get_contents($file))->toBe($before); +}); + +it('lets states and non-magic skills grant one, and never spells', function () { + $root = counterAttackProject(); + file_put_contents($root . '/assets/Data/states.php', " 'parry-stance', 'name' => 'Parry Stance', 'icon' => '', 'description' => '']];\n"); + $session = EditorSession::open($root); + $skills = $session->listDatabaseRecords('skills')['records']; + + expect(counterRow($session, 'states', 0, 'counterAttack.skill'))->not->toBeNull() + ->and(counterRow($session, 'skills', array_search('Riposte', $skills, true), 'counterAttack.skill'))->not->toBeNull() + ->and(counterRow($session, 'skills', array_search('Flare', $skills, true), 'counterAttack.skill'))->toBeNull(); + + $session->applyDatabaseRecord('states', 0, counterRow($session, 'states', 0, 'counterAttack.skill')['key'], 'Riposte'); + $session->saveDatabase('states'); + + expect((require $root . '/assets/Data/states.php')[0]['counterAttack'])->toBe(['skill' => 'Riposte']); +}); + +it('grants an actor a counter attack through the actor service', function () { + $root = counterAttackProject(); + $session = EditorSession::open($root); + $actor = array_search('Kaelion', $session->listDatabaseRecords('actors')['records'], true); + $row = counterRow($session, 'actors', $actor, 'counterAttack'); + + expect($row)->toMatchArray(['kind' => 'reference', 'reference' => 'counter_skills', 'value' => '(no counter)']); + + $session->applyDatabaseRecord('actors', $actor, $row['key'], 'Riposte'); + $session->saveDatabase('actors'); + $workspace = ProjectWorkspace::fromProject($root); + $saved = array_find($workspace->actorDatabase->getActors(), static fn($candidate): bool => $candidate->getName() === 'Kaelion'); + + expect($saved->getData()['counterAttack'])->toBe(['skill' => 'Riposte']) + ->and(counterRow(EditorSession::open($root), 'actors', $actor, 'counterAttack')['value'])->toBe('Riposte'); + + $session->applyDatabaseRecord('actors', $actor, counterRow($session, 'actors', $actor, 'counterAttack')['key'], ''); + $session->saveDatabase('actors'); + $cleared = array_find(ProjectWorkspace::fromProject($root)->actorDatabase->getActors(), static fn($candidate): bool => $candidate->getName() === 'Kaelion'); + + expect($cleared->getData())->not->toHaveKey('counterAttack'); +}); + +it('reports a grant the Engine would refuse, with the Engine\'s reason', function () { + $root = counterAttackProject(); + $file = $root . '/assets/Data/Enemies/regular-bat.php'; + file_put_contents($file, str_replace("'level' => 2,", "'level' => 2,\n 'counterAttack' => ['skill' => 'Flare'],", (string) file_get_contents($file))); + + $issues = new CounterAttackValidator()->validate(ProjectWorkspace::fromProject($root)); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->where)->toContain('Regular Bat') + ->and($issues[0]->message)->toContain('battle-usable basic or special skill'); +}); From 21633e3856ccff861e37e1f623737d2eb392c044 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 05:35:13 +0200 Subject: [PATCH 177/344] test(database): a skill linked to a summon is never offered or accepted as a counter With the Engine's skill catalogue now classifying the project's own summons, an authored summon's linked skill is left out of the counter picker and a grant naming it is reported, by the Engine's rule. --- .../Unit/Session/CounterAttackSessionTest.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/Unit/Session/CounterAttackSessionTest.php b/tests/Unit/Session/CounterAttackSessionTest.php index 7e2f568..d55d9fd 100644 --- a/tests/Unit/Session/CounterAttackSessionTest.php +++ b/tests/Unit/Session/CounterAttackSessionTest.php @@ -114,3 +114,31 @@ function counterRow(EditorSession $session, string $category, int $index, string ->and($issues[0]->where)->toContain('Regular Bat') ->and($issues[0]->message)->toContain('battle-usable basic or special skill'); }); + +it('never offers or accepts a skill the project links to a summon', function () { + $root = counterAttackProject(); + // A special skill that would otherwise qualify, linked to an authored summon. + writeSkillRecords($root, + new BasicSkill('Riposte', '', '', 0, 0), + new \Ichiloto\Engine\Entities\Skills\SpecialSkill('Ember Call', '', '', 0, 0), + ); + $summon = $root . '/assets/Cutscenes/Summons/ember'; + mkdir($summon, 0777, true); + file_put_contents($summon . '/ember.data.php', " 'ember', 'name' => 'Ember', 'linkedActionId' => 'Ember Call', + 'availability' => ['conditions' => []], + 'wielders' => ['mode' => 'characters', 'characters' => ['Kaelion'], 'tenancy' => 'exclusive'], + ], true) . ";\n"); + file_put_contents($summon . '/ember.timeline.php', " 12, 'lengthFrames' => 1, 'tracks' => [], 'cues' => []];\n"); + $file = $root . '/assets/Data/Enemies/regular-bat.php'; + file_put_contents($file, str_replace("'level' => 2,", "'level' => 2,\n 'counterAttack' => ['skill' => 'Ember Call'],", (string) file_get_contents($file))); + + $session = EditorSession::open($root); + [$map] = $session->describeMaps(); + $issues = new CounterAttackValidator()->validate(ProjectWorkspace::fromProject($root)); + + expect(array_column($session->listReferences($map['id'], 'counter_skills'), 'value'))->toBe(['Riposte']) + ->and($issues)->toHaveCount(1) + ->and($issues[0]->where)->toContain('Regular Bat') + ->and($issues[0]->message)->toContain('without summon'); +}); From aa4c9a84fbb3810628867a2eee44017de8190417 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 07:02:30 +0200 Subject: [PATCH 178/344] feat(database): report the Engine's formation clearance in previews and validation Troop formations, enemy previews and actor previews now carry the Engine's shared clearance diagnostics for every battler: art outside the layout's battler or enemy area, overlapping another battler, or leaving its target cursor no room. They are reported, never moved. An enemy preview stands opposite the first party member it clears beside, else the first it fits beside, as before. TroopFormationValidator checks the starting party in its slots and every placed troop through BattleFormationLayout::compose and getClearanceDiagnostics, over the default arena, as warnings; a formation the Engine cannot compose is an error. The battle art test helpers move to the shared formation fixtures so other tests can use them. --- src/Session/EditorSession.php | 44 ++++++--- src/Validation/ProjectValidator.php | 1 + src/Validation/TroopFormationValidator.php | 99 +++++++++++++++++++ tests/Support/BattleFormationFixtures.php | 24 +++++ tests/Unit/Session/BattlerArtSessionTest.php | 22 ----- .../Session/FormationClearanceSessionTest.php | 70 +++++++++++++ 6 files changed, 222 insertions(+), 38 deletions(-) create mode 100644 src/Validation/TroopFormationValidator.php create mode 100644 tests/Unit/Session/FormationClearanceSessionTest.php diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 5d7e643..957dda6 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1206,9 +1206,9 @@ public function readTroopFormation(int $index, ?string $arena = null): array $members[$memberIndex]['issue'] = $error->getMessage(); } } - [$formation, $view] = $this->composeFormation($catalog, $arena, $placed); + [$formation, $view, $clearance] = $this->composeFormation($catalog, $arena, $placed); foreach ($formation->enemies as $position => $battler) { - $members[$placedMembers[$position]]['battler'] = self::describeFormationBattler($battler); + $members[$placedMembers[$position]]['battler'] = self::describeFormationBattler($battler, $clearance['enemies'][$position]); } return [...$view, 'members' => array_values($members)]; @@ -1221,9 +1221,10 @@ public function readTroopFormation(int $index, ?string $arena = null): array * layouts define where the party stands, not enemies (troops place those), * so the enemy stands opposite a party member, its slot mirrored across * the canvas: a size comparison, not a battle position. It stands opposite - * the lead when its art fits on the canvas there, else opposite the next - * member it fits beside, so a creature taller than the lead's ground line - * is still shown whole. Both read the record as it is now, unsaved edits + * the first member it stands clear beside by the Engine's formation + * clearance, else the first it fits on the canvas beside, so a creature + * taller than the lead's ground line is still shown whole, with what it + * does not clear. Both read the record as it is now, unsaved edits * included. A project without a graphical battle still gets the sprite, * with the reason there is no art. * @@ -1246,20 +1247,25 @@ public function readEnemyPreview(int $index, ?string $arena = null): array foreach ($catalog->ui->partySlots as $party) { $slot = new BattlerSlot($catalog->ui->width - $party->x, $party->y, $party->width, $party->height); try { - $composed = $this->composeFormation($catalog, $arena, [['enemyId' => $name, 'slot' => $slot]]); - break; + $candidate = $this->composeFormation($catalog, $arena, [['enemyId' => $name, 'slot' => $slot]]); } catch (SessionRefusal $refusal) { $firstRefusal ??= $refusal; + continue; + } + $composed ??= $candidate; + if ($candidate[2]['enemies'][0] === []) { + $composed = $candidate; + break; } } if ($composed === null) { throw $firstRefusal; } - [$formation, $view] = $composed; + [$formation, $view, $clearance] = $composed; $preview['formation'] = [...$view, 'members' => [[ 'enemy' => $name, 'placement' => null, - 'battler' => self::describeFormationBattler($formation->enemies[0]), + 'battler' => self::describeFormationBattler($formation->enemies[0], $clearance['enemies'][0]), ]]]; } catch (SessionRefusal $refusal) { $preview['formationIssue'] = $refusal->getMessage(); @@ -1480,10 +1486,12 @@ public function describeBattlerArt(string $side, string $identity): array /** * Composes enemies with the starting party over an arena, and describes * what every formation view shares: the canvas, the arenas with the one - * previewed and its backgrounds, and the party in its slots. + * previewed and its backgrounds, and the party in its slots. Also returns + * the Engine's clearance diagnostics for party and enemies, by position, + * which include each battler's own. * * @param list $enemies - * @return array{0: BattleFormationLayout, 1: array} + * @return array{0: BattleFormationLayout, 1: array, 2: array{party: list>, enemies: list>}} */ private function composeFormation(BattlePresentationCatalog $catalog, ?string $arena, array $enemies, ?string $lead = null): array { @@ -1504,9 +1512,10 @@ private function composeFormation(BattlePresentationCatalog $catalog, ?string $a } catch (\InvalidArgumentException|\RuntimeException $error) { throw new SessionRefusal('The formation cannot be composed: ' . $error->getMessage(), previous: $error); } + $clearance = $formation->getClearanceDiagnostics($assetRoot); $party = []; foreach ($formation->party as $position => $battler) { - $party[] = ['name' => $names[$partyIds[$position]] ?? $partyIds[$position], ...self::describeFormationBattler($battler)]; + $party[] = ['name' => $names[$partyIds[$position]] ?? $partyIds[$position], ...self::describeFormationBattler($battler, $clearance['party'][$position])]; } $arenas = []; foreach ($formation->arenaChoices as $id => $name) { @@ -1521,11 +1530,14 @@ private function composeFormation(BattlePresentationCatalog $catalog, ?string $a 'arena' => $chosen === false ? null : (string) $chosen, 'backgrounds' => array_map(self::describeCanvasImage(...), $formation->backgrounds), 'party' => $party, - ]]; + ], $clearance]; } - /** @return array Where a battler stands and is drawn, its art and its body at battle scale. */ - private static function describeFormationBattler(BattleFormationBattler $battler): array + /** + * @param list $diagnostics What the Engine reports about it in this formation. + * @return array Where a battler stands and is drawn, its art, its body at battle scale and what it does not clear. + */ + private static function describeFormationBattler(BattleFormationBattler $battler, array $diagnostics): array { $bounds = $battler->bounds; @@ -1535,7 +1547,7 @@ private static function describeFormationBattler(BattleFormationBattler $battler 'image' => $battler->image === null ? null : self::describeCanvasImage($battler->image), 'bodySpan' => $battler->bodySpan, 'horizontal' => $battler->horizontal, - 'diagnostics' => array_values(array_map(strval(...), $battler->diagnostics)), + 'diagnostics' => array_values(array_map(strval(...), $diagnostics)), ]; } diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index be003bd..7b5bd95 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -143,6 +143,7 @@ public function validate(ProjectWorkspace $workspace): array ...$this->checkReferences($workspace), ...new AnimationReferenceValidator()->validate($workspace), ...new CounterAttackValidator()->validate($workspace), + ...new TroopFormationValidator()->validate($workspace), ...$this->checkDefinitionIdentities($workspace), ...$this->checkActorDefinitions($workspace), ...new ActorReferenceValidator()->validate($workspace), diff --git a/src/Validation/TroopFormationValidator.php b/src/Validation/TroopFormationValidator.php new file mode 100644 index 0000000..ee90abb --- /dev/null +++ b/src/Validation/TroopFormationValidator.php @@ -0,0 +1,99 @@ +projectRoot . DIRECTORY_SEPARATOR . 'assets'; + $layoutFile = 'assets/' . BattlePresentationCatalog::FILE; + try { + $catalog = BattlePresentationCatalog::load($assets); + } catch (Throwable $problem) { + return [Issue::error($layoutFile, sprintf('The battle presentation cannot be read, so no formation can be checked: %s', $problem->getMessage()), + sprintf('Correct %s.', $layoutFile))]; + } + if ($catalog?->ui === null) { + return []; + } + + $names = []; + foreach ($workspace->actorDatabase->getActors() as $actor) { + $names[$actor->getDefinitionId()] = $actor->getName(); + } + $starting = $workspace->getSystemField('startingParty'); + $party = array_slice(array_values(array_filter(is_array($starting) ? $starting : [], is_string(...))), 0, count($catalog->ui->partySlots)); + + try { + $alone = BattleFormationLayout::compose($catalog, null, [], $party, $assets)->getClearanceDiagnostics($assets); + } catch (InvalidArgumentException|RuntimeException $problem) { + return [Issue::error($layoutFile, sprintf('The starting party cannot be stood in its battle slots: %s', $problem->getMessage()), + 'Correct the party slots in the battle layout, or the actors\' battle art.')]; + } + $issues = []; + foreach ($alone['party'] as $position => $diagnostics) { + foreach ($diagnostics as $diagnostic) { + $issues[] = Issue::warning(sprintf('%s: party slot %d', $layoutFile, $position + 1), + sprintf('%s: %s', $names[$party[$position]] ?? $party[$position], $diagnostic), + 'Move the party slot in the battle layout, or adjust the actor\'s battle art size or ground point.'); + } + } + + foreach ($workspace->getRecordDatabase('troops')?->getRecords() ?? [] as $record) { + $troop = strval($record->get('name')); + $where = sprintf('assets/Data/troops.php: %s', $troop); + $placed = $members = []; + foreach ($record->getSubList('enemies') as $index => $entry) { + if (!is_array($entry) || !is_array($entry['graphicalPlacement'] ?? null)) { + continue; + } + try { + // A malformed placement is the troop schema's to report. + $placed[] = ['enemyId' => strval($entry['enemy'] ?? ''), 'slot' => BattlerSlot::fromArray($entry['graphicalPlacement'])]; + $members[] = $index; + } catch (InvalidArgumentException) { + } + } + if ($placed === []) { + continue; + } + try { + $clearance = BattleFormationLayout::compose($catalog, null, $placed, $party, $assets)->getClearanceDiagnostics($assets); + } catch (InvalidArgumentException|RuntimeException $problem) { + $issues[] = Issue::error($where, sprintf('Its formation cannot be composed: %s', $problem->getMessage()), + 'Move its members on the troop\'s formation canvas until each stands on the battle canvas.'); + continue; + } + foreach ($clearance['enemies'] as $position => $diagnostics) { + foreach ($diagnostics as $diagnostic) { + $issues[] = Issue::warning($where, + sprintf('%s (member %d): %s', $placed[$position]['enemyId'], $members[$position] + 1, $diagnostic), + 'Move it on the troop\'s formation canvas, or adjust its battle art size or ground point.'); + } + } + } + + return $issues; + } +} diff --git a/tests/Support/BattleFormationFixtures.php b/tests/Support/BattleFormationFixtures.php index 9d8629e..cf221b1 100644 --- a/tests/Support/BattleFormationFixtures.php +++ b/tests/Support/BattleFormationFixtures.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use Ichiloto\Editor\Session\EditorSession; + /** A project whose battle has two arenas, two party slots and a troop of two. */ function troopFormationProject(): string { @@ -62,3 +64,25 @@ function enemyPreviewProject(): string return $root; } + +/** The enemy preview project with battle art for its bat on disk. */ +function battlerArtProject(): string +{ + $root = enemyPreviewProject(); + writeTilesetTestPng($root . '/assets/Graphics/Enemies/Bat.png', 40, 80); + writeTilesetTestPng($root . '/assets/Graphics/Enemies/BatAttack.png', 160, 80); + + return $root; +} + +/** A battle art record's row, by its field. */ +function readBattlerRow(EditorSession $session, int $index, string $field): array +{ + return array_find($session->readDatabaseRecord('battler_enemies', $index)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $field) + ?? throw new RuntimeException("No row {$field}."); +} + +function setBattlerRow(EditorSession $session, int $index, string $field, string $value): void +{ + $session->applyDatabaseRecord('battler_enemies', $index, readBattlerRow($session, $index, $field)['key'], $value); +} diff --git a/tests/Unit/Session/BattlerArtSessionTest.php b/tests/Unit/Session/BattlerArtSessionTest.php index 1b86f1b..96e1d5a 100644 --- a/tests/Unit/Session/BattlerArtSessionTest.php +++ b/tests/Unit/Session/BattlerArtSessionTest.php @@ -10,28 +10,6 @@ /** Setting an actor's or enemy's battle art as data, from its own page. Synthetic fixtures only. */ -/** The enemy preview project with battle art for its bat on disk. */ -function battlerArtProject(): string -{ - $root = enemyPreviewProject(); - writeTilesetTestPng($root . '/assets/Graphics/Enemies/Bat.png', 40, 80); - writeTilesetTestPng($root . '/assets/Graphics/Enemies/BatAttack.png', 160, 80); - - return $root; -} - -/** A battle art record's row, by its field. */ -function readBattlerRow(EditorSession $session, int $index, string $field): array -{ - return array_find($session->readDatabaseRecord('battler_enemies', $index)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $field) - ?? throw new RuntimeException("No row {$field}."); -} - -function setBattlerRow(EditorSession $session, int $index, string $field, string $value): void -{ - $session->applyDatabaseRecord('battler_enemies', $index, readBattlerRow($session, $index, $field)['key'], $value); -} - it('binds an enemy\'s battle art as data, previews it unsaved, and saves a file the battle reads', function () { $root = battlerArtProject(); $session = EditorSession::open($root); diff --git a/tests/Unit/Session/FormationClearanceSessionTest.php b/tests/Unit/Session/FormationClearanceSessionTest.php new file mode 100644 index 0000000..4de9ad8 --- /dev/null +++ b/tests/Unit/Session/FormationClearanceSessionTest.php @@ -0,0 +1,70 @@ + 300,", "'y' => {$y},", (string) file_get_contents($troops))); + $session = EditorSession::open($root); + $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index']; + setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png'); + setBattlerRow($session, $index, 'artwork.pivot', '0.5, 1'); + $session->saveDatabase('battler_enemies'); + + return $root; +} + +/** @return list The validator's issues about troops. */ +function readTroopClearanceIssues(string $root): array +{ + return array_values(array_filter(new TroopFormationValidator()->validate(ProjectWorkspace::fromProject($root)), + static fn(Issue $issue): bool => str_starts_with($issue->where, 'assets/Data/troops.php'))); +} + +it('reports nothing for a troop member standing clear of the UI and the party', function () { + $root = formationClearanceProject(300); + $member = EditorSession::open($root)->readTroopFormation(0)['members'][0]['battler']; + + expect($member['diagnostics'])->toBe([]) + ->and(readTroopClearanceIssues($root))->toBe([]); +}); + +it('reports a troop member whose art reaches above the battler area, and leaves it where it stands', function () { + $root = formationClearanceProject(200); + $troops = (string) file_get_contents($root . '/assets/Data/troops.php'); + $member = EditorSession::open($root)->readTroopFormation(0)['members'][0]; + $issues = readTroopClearanceIssues($root); + + expect($member['placement'])->toMatchArray(['x' => 260, 'y' => 200]) + ->and($member['battler']['ground'])->toEqual(['x' => 260, 'y' => 200]) + ->and(implode(' ', $member['battler']['diagnostics']))->toContain('outside the authored battler area') + ->and($issues)->not->toBe([]) + ->and($issues[0]->where)->toBe('assets/Data/troops.php: Pair') + ->and($issues[0]->message)->toContain('Regular Bat (member 1)') + ->and(file_get_contents($root . '/assets/Data/troops.php'))->toBe($troops); +}); + +it('shows an enemy preview beside a party member it stands clear of, with what it does not clear', function () { + $root = formationClearanceProject(300); + $battler = EditorSession::open($root)->readEnemyPreview(0)['formation']['members'][0]['battler']; + + expect($battler['diagnostics'])->toBe([]) + ->and($battler['image']['asset'])->toBe('Graphics/Enemies/Bat.png'); +}); From 36db60708901fbe15d40d63acb109ab63749542c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 07:29:31 +0200 Subject: [PATCH 179/344] feat(database): keep a troop member's slot display scale through placement edits Battle Placement is the Engine's battler slot, so its codec is now BATTLER_SLOT (was RECT, used only by this field): x, y, width and height, plus the optional displayScale the Engine accepts, shown as a fifth number when authored. Values are checked by BattlerSlot::fromArray, so the Editor refuses exactly what a battle would. Before this, editing a placement dropped an authored displayScale. --- src/Database/ProjectRecordDatabase.php | 17 +++++++++++------ src/Database/RecordFieldCodec.php | 11 +++++++---- src/Database/RecordSchemaCatalog.php | 2 +- .../Unit/Session/TroopFormationSessionTest.php | 16 ++++++++++++++++ 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 64bfa23..afad338 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -16,6 +16,7 @@ use Ichiloto\Editor\Inspector\InputControl; use Ichiloto\Editor\Inspector\InputControlType; use RuntimeException; +use Ichiloto\Engine\Battle\Presentation\BattlerSlot; /** * A schema-driven Database category. @@ -3281,21 +3282,24 @@ private static function coerce(RecordField $field, string $rawValue): mixed return [intval($parts[0]), intval($parts[1])]; } - if ($field->codec === RecordFieldCodec::RECT) { + if ($field->codec === RecordFieldCodec::BATTLER_SLOT) { if ($trimmed === '' && $field->removeWhenEmpty) { return null; } $parts = array_map(trim(...), explode(',', $trimmed)); - if (count($parts) !== 4 || array_filter($parts, static fn(string $part): bool => ! is_numeric($part)) !== []) { - throw new \InvalidArgumentException(sprintf('%s must be four numbers: x, y, width and height.', $field->label)); + if (! in_array(count($parts), [4, 5], true) || array_filter($parts, static fn(string $part): bool => ! is_numeric($part)) !== []) { + throw new \InvalidArgumentException(sprintf('%s must be four numbers, x, y, width and height, and optionally a display scale.', $field->label)); } // Whole numbers stay integers, as an author writes them. $numbers = array_map(static fn(string $part): int|float => floor((float) $part) === (float) $part ? (int) $part : (float) $part, $parts); + $slot = array_combine(array_slice(['x', 'y', 'width', 'height', 'displayScale'], 0, count($numbers)), $numbers); + // The Engine's own slot rules decide what a battle accepts. + BattlerSlot::fromArray($slot, $field->label); - return array_combine(['x', 'y', 'width', 'height'], $numbers); + return $slot; } if ($field->codec === RecordFieldCodec::NORMALIZED_POINT) { @@ -3476,8 +3480,9 @@ private static function displayValue(RecordField $field, mixed $value): string RecordFieldCodec::POINT => is_array($value) ? implode(', ', array_map(strval(...), array_values($value))) : ProjectRecord::stringify($value), - RecordFieldCodec::RECT => is_array($value) - ? implode(', ', array_map(static fn(string $key): string => ProjectRecord::stringify($value[$key] ?? ''), ['x', 'y', 'width', 'height'])) + RecordFieldCodec::BATTLER_SLOT => is_array($value) + ? implode(', ', array_map(static fn(string $key): string => ProjectRecord::stringify($value[$key] ?? ''), + array_key_exists('displayScale', $value) ? ['x', 'y', 'width', 'height', 'displayScale'] : ['x', 'y', 'width', 'height'])) : ProjectRecord::stringify($value), RecordFieldCodec::NORMALIZED_POINT => is_array($value) ? sprintf('%s, %s', ProjectRecord::stringify($value['x'] ?? ''), ProjectRecord::stringify($value['y'] ?? '')) diff --git a/src/Database/RecordFieldCodec.php b/src/Database/RecordFieldCodec.php index d2b7f8a..737c827 100644 --- a/src/Database/RecordFieldCodec.php +++ b/src/Database/RecordFieldCodec.php @@ -76,9 +76,12 @@ enum RecordFieldCodec: string case NORMALIZED_POINT = 'normalized_point'; /** - * A rectangle stored as `['x' => x, 'y' => y, 'width' => w, 'height' => h]`, - * shown as `x, y, width, height`: a battler's graphical placement. Whole - * or not at all, so one edit moves it as one step. + * A battler slot, the Engine's BattlerSlot, stored as `['x' => x, + * 'y' => y, 'width' => w, 'height' => h]` with an optional + * `'displayScale' => s`, shown as `x, y, width, height` or + * `x, y, width, height, s`: a battler's graphical placement. Whole or + * not at all, so one edit moves it as one step, and its display scale + * is kept. */ - case RECT = 'rect'; + case BATTLER_SLOT = 'battler_slot'; } diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 9c85805..4199c77 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -283,7 +283,7 @@ private static function troops(): RecordSchema new RecordField('position.1', 'Y', InputControlType::INTEGER), // The graphical battle's own placement: the point its feet stand on // and its contain limits, never the terminal position above. - new RecordField('graphicalPlacement', 'Battle Placement', codec: RecordFieldCodec::RECT, removeWhenEmpty: true), + new RecordField('graphicalPlacement', 'Battle Placement', codec: RecordFieldCodec::BATTLER_SLOT, removeWhenEmpty: true), ], blank: ['enemy' => 'Regular Bat', 'position' => [15, 7]], ), diff --git a/tests/Unit/Session/TroopFormationSessionTest.php b/tests/Unit/Session/TroopFormationSessionTest.php index 46587b5..3c6f497 100644 --- a/tests/Unit/Session/TroopFormationSessionTest.php +++ b/tests/Unit/Session/TroopFormationSessionTest.php @@ -47,3 +47,19 @@ ->and($session->undo()['label'] ?? null)->not->toBeNull() ->and($session->readTroopFormation(0)['members'][0]['placement']['x'])->toBe(260); }); + +it('keeps a member placement\'s display scale through an edit, and refuses one the battle would not accept', function () { + $root = troopFormationProject(); + $troops = $root . '/assets/Data/troops.php'; + file_put_contents($troops, str_replace("'height' => 190]]", "'height' => 190, 'displayScale' => 1.1]]", (string) file_get_contents($troops))); + $session = EditorSession::open($root); + $placement = array_find($session->readDatabaseRecord('troops', 0)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'member0GraphicalPlacement'); + + expect($placement['value'])->toBe('260, 300, 275, 190, 1.1'); + $session->applyDatabaseRecord('troops', 0, $placement['key'], '410, 330, 275, 190, 1.1'); + expect($session->readTroopFormation(0)['members'][0]['placement'])->toBe(['x' => 410, 'y' => 330, 'width' => 275, 'height' => 190, 'displayScale' => 1.1]) + ->and(fn() => $session->applyDatabaseRecord('troops', 0, $placement['key'], '410, 330, 275, 190, 0'))->toThrow(SessionRefusal::class); + + $session->saveDatabase('troops'); + expect((require $troops)[0]['enemies'][0]['graphicalPlacement'])->toBe(['x' => 410, 'y' => 330, 'width' => 275, 'height' => 190, 'displayScale' => 1.1]); +}); From 326e1117015900456bdc817a03472999acdb2732 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 11:43:30 +0200 Subject: [PATCH 180/344] fix(validation): check summon aliases against the summons the project defines Summon definitions are one record per directory, but the save compatibility validator read their data files as lists, so it never found a summon and reported every summon alias target as undefined. It now lists summons through the Engine's SummonCutsceneLibrary, which owns loading them. --- src/Validation/SaveCompatibilityValidator.php | 27 ++++++------- tests/Unit/ProjectValidatorTest.php | 39 +++++++++++++++++++ 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/Validation/SaveCompatibilityValidator.php b/src/Validation/SaveCompatibilityValidator.php index 3b356c9..de30361 100644 --- a/src/Validation/SaveCompatibilityValidator.php +++ b/src/Validation/SaveCompatibilityValidator.php @@ -8,12 +8,12 @@ use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Cutscenes\Summons\SummonCutsceneDefinition; +use Ichiloto\Engine\Cutscenes\Summons\SummonCutsceneLibrary; use Ichiloto\Engine\Entities\Skills\SkillCatalog; use Ichiloto\Engine\Exceptions\InvalidSaveCompatibilityManifestException; use Ichiloto\Engine\IO\SaveCompatibility\ContentReferenceCategory; use Ichiloto\Engine\IO\SaveCompatibility\SaveCompatibilityManifest; -use RecursiveDirectoryIterator; -use RecursiveIteratorIterator; use Throwable; /** Validates the project-owned save compatibility manifest. */ @@ -450,7 +450,12 @@ private function phpListIdentities(string $path): array return $ids; } - /** @return string[] */ + /** + * The summons the game defines, as the Engine's summon library loads them: + * one definition per directory, each a single record. + * + * @return string[] + */ private function summonIds(ProjectWorkspace $workspace): array { $root = $workspace->projectRoot . '/assets/Cutscenes/Summons'; @@ -459,18 +464,10 @@ private function summonIds(ProjectWorkspace $workspace): array return []; } - $ids = []; - $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root)); - - foreach ($iterator as $file) { - if (! $file->isFile() || ! str_ends_with($file->getFilename(), '.data.php')) { - continue; - } - - $ids = [...$ids, ...$this->phpListIdentities($file->getPathname())]; - } - - return array_values(array_unique($ids)); + return array_values(array_unique(array_map( + static fn(SummonCutsceneDefinition $definition): string => $definition->id, + new SummonCutsceneLibrary($root)->load(), + ))); } private function isOneShotEventIdentity(string $identity): bool diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index c83c419..f56548e 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -1066,3 +1066,42 @@ function effectIssueLines(string $root): array 'enemy Sewer Rat, drop 2: Its item names the item "Moon Tonic", which the project does not define.', ]); }); + +it('checks a summon alias against the summons the project defines', function () { + $root = makeTemporaryProject(); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeConsistentEnemies($root); + writeSpreadSkillCatalog($root); + // One summon, defined as the Engine loads it: a directory holding its data and timeline records. + $summon = $root . '/assets/Cutscenes/Summons/ember'; + mkdir($summon, 0777, true); + file_put_contents($summon . '/ember.data.php', " 'ember', 'name' => 'Ember', 'linkedActionId' => 'Ember Call', + 'availability' => ['conditions' => []], + 'wielders' => ['mode' => 'characters', 'characters' => ['Kaelion'], 'tenancy' => 'exclusive'], + ], true) . ";\n"); + file_put_contents($summon . '/ember.timeline.php', " 12, 'lengthFrames' => 1, 'tracks' => [], 'cues' => []];\n"); + writeSaveCompatibilityManifest($root, <<<'PHP' + 0, + 'migrations' => [], + 'aliases' => [ + 'summons' => [ + ['from' => 'spark', 'to' => 'ember'], + ['from' => 'ash', 'to' => 'cinder'], + ], + ], + 'tombstones' => [], + ]; + PHP); + + $missing = array_map( + static fn(Issue $issue): string => $issue->message, + issuesMentioning(validateProject($root), 'is not defined in the current'), + ); + + expect($missing)->toBe(['Alias target "cinder" is not defined in the current summons catalog.']); +}); From 2671fc1ae4dede486049af36e1d60af98168e1b2 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 16:59:28 +0200 Subject: [PATCH 181/344] feat(session): choose the battle test troop and party, and play it The project's battle test lives in system data as the Engine's ProjectBattleTest (troop, party, arena), as RPG Maker keeps its Battle Test. System gains a Battle Test field (BATTLE_TEST codec, read and written the Engine's way) so it is edited through the record service with undo and source-preserving saves. - battleTest.describe: the troop, arena and party (the starting party when none is set, said so), each member with the Engine's BattleTestChoices (slots, equipment, commands, skills, magic, summons), and the Engine's problems. - battleTest.apply: the whole entry, one undo step; refused unless the Engine reads it. - battleTest.start/status/stop: ichiloto battle in its own window from a playtest overlay whose system data carries the battle test as it stands, refusing without a troop, with setup problems, or while a database or cutscene a battle reads is unsaved. The playtest overlay and launcher are generalised for it. - Troop, enemy and actor previews stand the battle test's party when it sets one, else the starting party, and name which. - BattleTestValidator warns of an unreadable entry, a missing troop or arena, and party problems. --- src/Database/ProjectRecordDatabase.php | 21 ++ src/Database/RecordFieldCodec.php | 8 + src/Database/RecordSchemaCatalog.php | 5 +- src/Playtest/PlaytestLauncher.php | 27 +- src/Playtest/PlaytestOverlay.php | 88 ++++-- src/Session/EditorSession.php | 274 ++++++++++++++++++- src/Session/SessionHost.php | 6 + src/Validation/BattleTestValidator.php | 81 ++++++ src/Validation/ProjectValidator.php | 1 + tests/Unit/Session/BattleTestSessionTest.php | 115 ++++++++ 10 files changed, 597 insertions(+), 29 deletions(-) create mode 100644 src/Validation/BattleTestValidator.php create mode 100644 tests/Unit/Session/BattleTestSessionTest.php diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index afad338..48d9ca9 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -17,6 +17,7 @@ use Ichiloto\Editor\Inspector\InputControlType; use RuntimeException; use Ichiloto\Engine\Battle\Presentation\BattlerSlot; +use Ichiloto\Engine\Scenes\Arena\ProjectBattleTest; /** * A schema-driven Database category. @@ -3282,6 +3283,23 @@ private static function coerce(RecordField $field, string $rawValue): mixed return [intval($parts[0]), intval($parts[1])]; } + if ($field->codec === RecordFieldCodec::BATTLE_TEST) { + if ($trimmed === '' && $field->removeWhenEmpty) { + return null; + } + + $decoded = json_decode($trimmed, true); + + if (! is_array($decoded)) { + throw new \InvalidArgumentException(sprintf('%s must be a battle test, written as a JSON object.', $field->label)); + } + + // The Engine's own reading: a shape it would refuse is refused here, before anything is written. + $test = ProjectBattleTest::fromArray($decoded)->toArray(); + + return $test === [] && $field->removeWhenEmpty ? null : $test; + } + if ($field->codec === RecordFieldCodec::BATTLER_SLOT) { if ($trimmed === '' && $field->removeWhenEmpty) { return null; @@ -3480,6 +3498,9 @@ private static function displayValue(RecordField $field, mixed $value): string RecordFieldCodec::POINT => is_array($value) ? implode(', ', array_map(strval(...), array_values($value))) : ProjectRecord::stringify($value), + RecordFieldCodec::BATTLE_TEST => is_array($value) + ? (string) json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + : ProjectRecord::stringify($value), RecordFieldCodec::BATTLER_SLOT => is_array($value) ? implode(', ', array_map(static fn(string $key): string => ProjectRecord::stringify($value[$key] ?? ''), array_key_exists('displayScale', $value) ? ['x', 'y', 'width', 'height', 'displayScale'] : ['x', 'y', 'width', 'height'])) diff --git a/src/Database/RecordFieldCodec.php b/src/Database/RecordFieldCodec.php index 737c827..584f1bb 100644 --- a/src/Database/RecordFieldCodec.php +++ b/src/Database/RecordFieldCodec.php @@ -84,4 +84,12 @@ enum RecordFieldCodec: string * is kept. */ case BATTLER_SLOT = 'battler_slot'; + + /** + * A project's battle test, the Engine's ProjectBattleTest: the troop to + * preselect, the party (each member's actor, level, equipment and + * loadout) and the arena. Stored as the Engine writes it and shown as + * one line of JSON; a value the Engine would not read is refused. + */ + case BATTLE_TEST = 'battle_test'; } diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 4199c77..e16334f 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -21,6 +21,7 @@ use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Editor\PermanentGrowthCatalog; use Ichiloto\Engine\Entities\States\StateDisposition; +use Ichiloto\Engine\Scenes\Arena\ProjectBattleTest; use Ichiloto\Engine\Animations\AnimationTargetPosition; use Ichiloto\Editor\Events\ProjectScriptCommands; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandDefinition; @@ -927,11 +928,13 @@ private static function system(): RecordSchema new RecordField('battle.activeTime.speedFactorPercent', 'ATB Speed Factor %', InputControlType::INTEGER), new RecordField('battle.activeTime.openingVariance', 'ATB Opening Variance', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField('battle.activeTime.openingSpeedFactorPercent', 'ATB Opening Speed %', InputControlType::INTEGER, removeWhenEmpty: true), + // Test settings, as RPG Maker keeps its Battle Test: normal play never reads them. + new RecordField(ProjectBattleTest::SYSTEM_KEY, 'Battle Test', removeWhenEmpty: true, codec: RecordFieldCodec::BATTLE_TEST), ], labelKey: 'title', identityKey: null, // The keys System owns; its elements are the Types category's. - projection: new WholeFileProjection(['title', 'currency', 'startingPositions', 'startingParty', 'startingInventory', 'battle']), + projection: new WholeFileProjection(['title', 'currency', 'startingPositions', 'startingParty', 'startingInventory', 'battle', ProjectBattleTest::SYSTEM_KEY]), subLists: [ new RecordSubList( key: 'startingParty', diff --git a/src/Playtest/PlaytestLauncher.php b/src/Playtest/PlaytestLauncher.php index 37e7a87..e0420f5 100644 --- a/src/Playtest/PlaytestLauncher.php +++ b/src/Playtest/PlaytestLauncher.php @@ -61,6 +61,31 @@ public function buildCommand(PlaytestOverlay $overlay): string * @throws RuntimeException When the process cannot start. */ public function start(PlaytestOverlay $overlay, string $rendererId, string $logPath): PlaytestRun + { + return $this->spawn($overlay, ['play', '--no-tmux', '--no-interaction', '--renderer=' . $rendererId, '-d', $overlay->root], $logPath); + } + + /** + * Starts a battle test in the background with a renderer of its own, as + * {@see start()} starts a playtest: `ichiloto battle` against a battle + * test overlay, fighting the named troop with the party and arena its + * system data's battle test sets. + * + * @param string $troop The troop to fight, by name. + * @throws RuntimeException When the process cannot start. + */ + public function startBattle(PlaytestOverlay $overlay, string $rendererId, string $logPath, string $troop): PlaytestRun + { + return $this->spawn($overlay, ['battle', '--no-interaction', '--renderer=' . $rendererId, '-d', $overlay->root, '--troop', $troop], $logPath); + } + + /** + * Runs a console command against an overlay, reading nothing and writing + * its output to a log. + * + * @param list $arguments The command and its options. + */ + private function spawn(PlaytestOverlay $overlay, array $arguments, string $logPath): PlaytestRun { $log = @fopen($logPath, 'ab'); if (! is_resource($log)) { @@ -70,7 +95,7 @@ public function start(PlaytestOverlay $overlay, string $rendererId, string $logP try { $pipes = []; $process = @proc_open( - [PHP_BINARY, $this->consoleBinary, 'play', '--no-tmux', '--no-interaction', '--renderer=' . $rendererId, '-d', $overlay->root], + [PHP_BINARY, $this->consoleBinary, ...$arguments], [0 => ['file', '/dev/null', 'r'], 1 => $log, 2 => $log], $pipes, $overlay->root, diff --git a/src/Playtest/PlaytestOverlay.php b/src/Playtest/PlaytestOverlay.php index 92d1cd2..d7f72b4 100644 --- a/src/Playtest/PlaytestOverlay.php +++ b/src/Playtest/PlaytestOverlay.php @@ -8,6 +8,7 @@ use Ichiloto\Editor\Database\PhpValueExporter; use Ichiloto\Editor\Events\EventMarkers; use Ichiloto\Engine\Field\MapGridSource; +use Ichiloto\Engine\Scenes\Arena\ProjectBattleTest; use RuntimeException; use Throwable; @@ -37,15 +38,15 @@ final class PlaytestOverlay /** * @param string $root The overlay project root. - * @param string $mapId The map the playtest starts on. - * @param int $spawnX The spawn column. - * @param int $spawnY The spawn row. + * @param string|null $mapId The map the playtest starts on; null for a battle test, which starts on none. + * @param int|null $spawnX The spawn column. + * @param int|null $spawnY The spawn row. */ private function __construct( public readonly string $root, - public readonly string $mapId, - public readonly int $spawnX, - public readonly int $spawnY, + public readonly ?string $mapId = null, + public readonly ?int $spawnX = null, + public readonly ?int $spawnY = null, ) { } @@ -59,6 +60,48 @@ private function __construct( * @return self */ public static function create(string $projectRoot, string $mapId, int $spawnX, int $spawnY): self + { + $root = self::build($projectRoot, static function (array $system) use ($mapId, $spawnX, $spawnY): array { + $player = $system['startingPositions']['player'] ?? []; + $player = is_array($player) ? $player : []; + $player['destinationMap'] = $mapId; + $player['spawnPoint'] = ['x' => $spawnX, 'y' => $spawnY]; + $player['spawnSprite'] = $player['spawnSprite'] ?? ['^']; + $system['startingPositions']['player'] = $player; + + return $system; + }, 'a playtest spawn'); + + return new self($root, $mapId, $spawnX, $spawnY); + } + + /** + * Builds an overlay for a battle test: the author's project with its + * system data's battle test replaced by the one given (unsaved edits + * included), or removed when it is empty, so `ichiloto battle` reads the + * party and arena the author set without the project being written. + * + * @param array $battleTest The battle test, as the Engine's ProjectBattleTest writes it. + */ + public static function createForBattle(string $projectRoot, array $battleTest): self + { + return new self(self::build($projectRoot, static function (array $system) use ($battleTest): array { + unset($system[ProjectBattleTest::SYSTEM_KEY]); + + return $battleTest === [] ? $system : [...$system, ProjectBattleTest::SYSTEM_KEY => $battleTest]; + }, 'a battle test')); + } + + /** + * Fills a fresh overlay directory: links to the project throughout, a + * fresh `.data` holding only the player settings, and a system.php the + * given change has rewritten. + * + * @param callable(array): array $rewriteSystem + * @param string $purpose What the rewrite is for, as an error names it. + * @return string The overlay root. + */ + private static function build(string $projectRoot, callable $rewriteSystem, string $purpose): string { $projectRoot = rtrim($projectRoot, DIRECTORY_SEPARATOR); $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('ichiloto-playtest-', true); @@ -94,9 +137,8 @@ public static function create(string $projectRoot, string $mapId, int $spawnX, i self::writeSystemOverride( $dataSource . DIRECTORY_SEPARATOR . 'system.php', $dataTarget . DIRECTORY_SEPARATOR . 'system.php', - $mapId, - $spawnX, - $spawnY, + $rewriteSystem, + $purpose, ); } catch (\Throwable $throwable) { // An overlay that could not be finished is not left behind: what @@ -107,7 +149,7 @@ public static function create(string $projectRoot, string $mapId, int $spawnX, i throw $throwable; } - return new self($root, $mapId, $spawnX, $spawnY); + return $root; } /** @@ -147,6 +189,10 @@ private function installCinematicLaunch(string $projectRoot, string $cinematicId { $mapsSource = $projectRoot . '/assets/Maps'; $mapsTarget = $this->root . '/assets/Maps'; + if ($this->mapId === null || $this->spawnX === null || $this->spawnY === null) { + throw new RuntimeException('A cinematic playtest starts on a map cell.'); + } + $segments = explode('/', trim(str_replace('\\', '/', $this->mapId), '/')); $leaf = $segments[array_key_last($segments)]; $mapSourceDirectory = $mapsSource . '/' . implode('/', $segments); @@ -306,24 +352,23 @@ private static function mirrorDirectory(string $source, string $target, array $s } /** - * Writes a copy of system.php with the playtest spawn substituted in. + * Writes a copy of system.php with one change made: a playtest's spawn, + * or a battle test's setup. * * The rest of the author's system data — party, battle settings, title * screen — is preserved exactly, so the playtest is the real game. * * @param string $sourcePath The project's system.php. * @param string $targetPath The overlay's system.php. - * @param string $mapId The map to start on. - * @param int $spawnX The spawn column. - * @param int $spawnY The spawn row. + * @param callable(array): array $rewriteSystem The change. + * @param string $purpose What the change is for, as an error names it. * @return void */ private static function writeSystemOverride( string $sourcePath, string $targetPath, - string $mapId, - int $spawnX, - int $spawnY, + callable $rewriteSystem, + string $purpose, ): void { if (! is_file($sourcePath)) { throw new RuntimeException("The project has no assets/Data/system.php to base a playtest on."); @@ -341,16 +386,11 @@ private static function writeSystemOverride( if (! PhpValueExporter::isExportable($payload)) { throw new RuntimeException( - 'system.php contains PHP objects, so a playtest spawn cannot be written without rewriting them.', + "system.php contains PHP objects, so {$purpose} cannot be written without rewriting them.", ); } - $player = $payload['startingPositions']['player'] ?? []; - $player = is_array($player) ? $player : []; - $player['destinationMap'] = $mapId; - $player['spawnPoint'] = ['x' => $spawnX, 'y' => $spawnY]; - $player['spawnSprite'] = $player['spawnSprite'] ?? ['^']; - $payload['startingPositions']['player'] = $player; + $payload = $rewriteSystem($payload); PhpDataFile::writeTransactionally( $targetPath, diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 957dda6..5f3630e 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -65,6 +65,14 @@ use Ichiloto\Engine\Battle\Presentation\BattleFormationBattler; use Ichiloto\Engine\Battle\Presentation\BattleFormationLayout; use Ichiloto\Engine\Battle\Presentation\BattlerSlot; +use Ichiloto\Editor\Database\EngineDataBootstrap; +use Ichiloto\Engine\Cutscenes\Summons\SummonCutsceneLibrary; +use Ichiloto\Engine\Scenes\Arena\BattleTestChoices; +use Ichiloto\Engine\Scenes\Arena\BattleTestLoadoutCatalog; +use Ichiloto\Engine\Scenes\Arena\BattleTestSetup; +use Ichiloto\Engine\Scenes\Arena\ProjectBattleTest; +use Ichiloto\Engine\Util\Config\ConfigStore; +use Ichiloto\Engine\Util\Stores\ItemStore; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasImage; use Closure; use InvalidArgumentException; @@ -91,6 +99,9 @@ final class EditorSession /** The playtest running in the background, or the last one, to report how it ended. */ private ?PlaytestRun $playtest = null; + /** The battle test running in its own window, and the troop it fights. */ + private ?PlaytestRun $battleTestRun = null; + private ?string $battleTestTroop = null; /** How actors are authored, with what this editor's actor panes show. */ private readonly ActorAuthoring $actorAuthoring; @@ -1500,8 +1511,7 @@ private function composeFormation(BattlePresentationCatalog $catalog, ?string $a foreach ($this->workspace->actorDatabase->getActors() as $actor) { $names[$actor->getDefinitionId()] = $actor->getName(); } - $starting = $this->workspace->getSystemField('startingParty'); - $partyIds = array_values(array_filter(is_array($starting) ? $starting : [], is_string(...))); + [$partyIds, $partySource] = $this->readPreviewParty(); if ($lead !== null) { $partyIds = [$lead, ...array_filter($partyIds, static fn(string $id): bool => $id !== $lead)]; } @@ -1530,9 +1540,31 @@ private function composeFormation(BattlePresentationCatalog $catalog, ?string $a 'arena' => $chosen === false ? null : (string) $chosen, 'backgrounds' => array_map(self::describeCanvasImage(...), $formation->backgrounds), 'party' => $party, + 'partySource' => $partySource, ], $clearance]; } + /** + * The party a battle preview stands beside its enemies: the battle + * test's when it sets one, as the battle test will fight with it, else + * the starting party; and which it is. A battle test that cannot be read + * is named, not quietly replaced. + * + * @return array{0: list, 1: string} + */ + private function readPreviewParty(): array + { + $starting = array_values(array_filter($this->readStartingPartyReferences(), is_string(...))); + try { + $setup = ProjectBattleTest::fromArray($this->readBattleTestEntry())->setup; + } catch (InvalidArgumentException) { + return [$starting, 'starting party (the battle test cannot be read)']; + } + + return $setup === null ? [$starting, 'starting party'] + : [array_map(static fn($member): string => $member->actorId, $setup->members), 'battle test']; + } + /** * @param list $diagnostics What the Engine reports about it in this formation. * @return array Where a battler stands and is drawn, its art, its body at battle scale and what it does not clear. @@ -2408,10 +2440,246 @@ public function stopPlaytest(): array return $this->describePlaytest(); } - /** Ends what the session started that would outlive it: a running playtest. */ + /** + * The project's battle test as the Battle Test dialog edits it, as RPG + * Maker's Troops > Battle Test does: the troop to preselect, the party + * and the arena, kept in system data as the Engine's ProjectBattleTest. + * With no party set the starting party stands in, as it will in the + * battle, and says so. Each member comes with what it may be given (the + * Engine's BattleTestChoices, the same as the in-game arena offers) and + * the setup with the Engine's own problems. Reads the record as it is + * now, unsaved edits included. + * + * @return array + * @throws SessionRefusal When the project has no system settings. + */ + public function describeBattleTest(): array + { + $entry = $this->readBattleTestEntry(); + $view = [ + 'battleTest' => $entry, + 'troops' => array_values(array_map(strval(...), $this->listDatabaseRecords('troops')['records'])), + 'arenas' => $this->readArenaChoices(), + 'actors' => array_map(static fn($actor): array => ['id' => $actor->getDefinitionId(), 'name' => $actor->getName()], + array_values(array_filter($this->workspace->actorDatabase->getActors(), static fn($actor): bool => $actor->hasDefinitionId()))), + 'maxMembers' => BattleTestSetup::MAX_MEMBERS, + 'troop' => null, 'arena' => null, 'source' => 'starting party', 'members' => [], 'problems' => [], 'issue' => null, + ]; + try { + $test = ProjectBattleTest::fromArray($entry); + } catch (InvalidArgumentException $invalid) { + return [...$view, 'issue' => 'The battle test cannot be read: ' . $invalid->getMessage()]; + } + + return ProjectDirectoryContext::run($this->workspace->projectRoot, function () use ($view, $test): array { + $actors = $this->workspace->actorDatabase->createActorStore(); + $items = $this->loadItemStore(); + $skills = $this->workspace->loadSkillCatalog(); + $summons = new SummonCutsceneLibrary($this->workspace->projectRoot . '/assets/Cutscenes/Summons'); + $view = [...$view, 'troop' => $test->troop, 'source' => $test->setup === null ? 'starting party' : 'battle test']; + try { + $setup = $test->createSetup($actors, $this->readStartingPartyReferences()); + } catch (InvalidArgumentException $invalid) { + return [...$view, 'arena' => $test->arena, 'issue' => $invalid->getMessage()]; + } + $choices = new BattleTestChoices($actors, $items, new BattleTestLoadoutCatalog($skills, $summons)); + $names = array_column($view['actors'], 'name', 'id'); + $members = []; + foreach ($setup->members as $index => $member) { + $described = ['actor' => $member->actorId, 'name' => $names[$member->actorId] ?? $member->actorId, 'level' => $member->level, + 'commands' => $member->commands === null ? null : array_column($member->commands, 'value'), + 'skills' => $member->skills, 'summons' => $member->summons, 'maxLevel' => null, 'equipment' => [], 'choices' => null]; + if ($actors->get($member->actorId) !== null) { + $described['maxLevel'] = $choices->getMaxLevel($member->actorId); + $described['equipment'] = array_map(static fn(string $slot): array => ['slot' => $slot, 'item' => $member->equipment[$slot] ?? null, + 'choices' => $choices->getEquipmentChoices($member->actorId, $slot)], $choices->getSlotNames($member->actorId)); + $described['choices'] = array_combine(['commands', 'skills', 'magic', 'summons'], array_map( + static fn(string $field): array => $choices->getLoadoutChoices($setup, $index, $field), ['commands', 'skills', 'magic', 'summons'])); + } + $members[] = $described; + } + + return [...$view, 'arena' => $setup->arena, 'members' => $members, + 'problems' => $setup->getProblems($actors, $items, $skills, $summons)]; + }); + } + + /** + * Sets the project's battle test, as the Battle Test dialog applies it: + * the whole entry written to system data through the record service in + * one undo step, read the Engine's way first so a shape it would not + * read is refused before anything changes. An empty entry removes it. + * + * @param array $battleTest As the Engine's ProjectBattleTest writes it. + * @return array The battle test, as {@see describeBattleTest()}. + * @throws SessionRefusal When the entry is not a battle test. + */ + public function applyBattleTest(array $battleTest): array + { + try { + $entry = ProjectBattleTest::fromArray($battleTest)->toArray(); + } catch (InvalidArgumentException $invalid) { + throw new SessionRefusal('That is not a battle test: ' . $invalid->getMessage(), previous: $invalid); + } + $row = array_find($this->readDatabaseRecord('system', 0)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === ProjectBattleTest::SYSTEM_KEY) + ?? throw new SessionRefusal('System settings have no battle test.'); + $this->applyDatabaseRecord('system', 0, $row['key'], + $entry === [] ? '' : (string) json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + + return $this->describeBattleTest(); + } + + /** + * Plays the battle test in the background in its own graphical window, + * as `ichiloto battle` does: the named troop, or the battle test's own, + * fought by the battle test's party in its arena. It runs from an + * overlay of the project carrying the battle test as it is now, unsaved + * edits included, and writes nothing into the project; anything else a + * battle reads from disk must be saved first. + * + * @param int|null $troopIndex The troop to fight; null for the battle test's troop. + * @return array The battle test run, as {@see describeBattleTestRun()}. + * @throws SessionRefusal When no troop is chosen, the setup has problems, something is unsaved, one is running, or it cannot start. + */ + public function startBattleTest(?int $troopIndex = null): array + { + if ($this->battleTestRun?->isRunning() === true) { + throw new SessionRefusal('A battle test is already running; stop it or close its window first.'); + } + $troops = $this->requireRecordDatabase('troops'); + $troop = $troopIndex === null + ? (ProjectBattleTest::fromArray($this->readBattleTestEntry())->troop ?? null) + : trim(strval(($troops->getRecordByIndex($troopIndex) ?? throw new SessionRefusal(sprintf('troops has no record %d.', $troopIndex)))->get('name'))); + if ($troop === null || $troop === '') { + throw new SessionRefusal('Choose a troop to fight in the battle test.'); + } + $unsaved = $this->listBattleUnsavedChanges(); + if ($unsaved !== []) { + throw new SessionRefusal(sprintf('Save %s first; the battle reads them on disk.', implode(', ', $unsaved))); + } + $described = $this->describeBattleTest(); + if ($described['issue'] !== null || $described['problems'] !== []) { + throw new SessionRefusal("The battle test party cannot be set up:\n" . implode("\n", array_filter([$described['issue'], ...$described['problems']]))); + } + $overlay = null; + try { + $overlay = PlaytestOverlay::createForBattle($this->workspace->projectRoot, $this->readBattleTestEntry()); + $launcher = PlaytestLauncher::discover(projectRoot: $this->workspace->projectRoot); + $log = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('ichiloto-battle-test-', true) . '.log'; + $this->battleTestRun = $launcher->startBattle($overlay, self::PLAYTEST_RENDERER, $log, $troop); + $this->battleTestTroop = $troop; + } catch (RuntimeException $error) { + $overlay?->destroy(); + throw new SessionRefusal(sprintf('The battle test could not start: %s', $error->getMessage()), previous: $error); + } + + return $this->describeBattleTestRun(); + } + + /** + * Whether a battle test is running, the troop it fights, and how the + * last one ended: its exit code and, when it failed, the end of its output. + * + * @return array{running: bool, troop: ?string, stopped: bool, exitCode: ?int, log: ?string} + */ + public function describeBattleTestRun(): array + { + $run = $this->battleTestRun; + $running = $run?->isRunning() ?? false; + $exitCode = $running ? null : $run?->getExitCode(); + + return [ + 'running' => $running, + 'troop' => $run === null ? null : $this->battleTestTroop, + 'stopped' => $run?->wasStopped() ?? false, + 'exitCode' => $exitCode, + 'log' => $run !== null && ! $running && ! $run->wasStopped() && $exitCode !== 0 ? $run->readLogTail() : null, + ]; + } + + /** + * Ends a running battle test, window and all. + * + * @return array The run, as {@see describeBattleTestRun()}. + */ + public function stopBattleTest(): array + { + $this->battleTestRun?->stop(); + + return $this->describeBattleTestRun(); + } + + /** The system data's battle test as it stands, unsaved edits included; empty when there is none. */ + private function readBattleTestEntry(): array + { + $system = $this->requireRecordDatabase('system')->getRecordByIndex(0) + ?? throw new SessionRefusal('The project has no system settings.'); + $entry = $system->get(ProjectBattleTest::SYSTEM_KEY); + + return is_array($entry) ? $entry : []; + } + + /** @return list The starting party as system data holds it now, unsaved edits included. */ + private function readStartingPartyReferences(): array + { + $starting = $this->workspace->getSystemField('startingParty'); + + return is_array($starting) ? array_values($starting) : []; + } + + /** @return list The battle presentation's arenas; none without one. */ + private function readArenaChoices(): array + { + try { + $catalog = BattlePresentationCatalog::loadCode($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); + } catch (\Throwable) { + return []; + } + $choices = $catalog?->getArenaChoices() ?? []; + + return array_map(static fn(string|int $id, string $name): array => ['id' => (string) $id, 'name' => $name], array_keys($choices), $choices); + } + + /** The Engine's item store for this project, as authored data reads it. */ + private function loadItemStore(): ItemStore + { + EngineDataBootstrap::ensure($this->workspace->projectRoot); + + $items = ConfigStore::has(ItemStore::class) ? ConfigStore::get(ItemStore::class) : null; + + return $items instanceof ItemStore ? $items + : throw new SessionRefusal('The project\'s items cannot be read, so a battle test party cannot be set up.'); + } + + /** + * What a battle reads from disk that has unsaved edits: every database + * but System (whose battle test the overlay carries) and every + * cutscene, since summons play them. Maps are not part of a battle. + * + * @return list + */ + private function listBattleUnsavedChanges(): array + { + $system = $this->workspace->getRecordDatabase('system'); + $unsaved = []; + foreach ($this->workspace->listSaveableDatabases() as $label => $database) { + if ($database !== $system && $database->isDirty()) { + $unsaved[] = $label; + } + } + foreach ($this->workspace->cutscenes?->dirtyAssets() ?? [] as $asset) { + $unsaved[] = $asset->type->noun() . ' ' . $asset->id; + } + + return $unsaved; + } + + /** Ends what the session started that would outlive it: a running playtest or battle test. */ public function close(): void { $this->playtest?->stop(); + $this->battleTestRun?->stop(); } /** Whether any map or database has changes not yet saved. */ diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 84079b5..59c03e6 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -399,6 +399,12 @@ private function dispatch(string $method, array $params): array 'playtest.start' => $session->startPlaytest(self::requireString($params, 'map'), self::requireInt($params, 'x'), self::requireInt($params, 'y')), 'playtest.status' => $session->describePlaytest(), 'playtest.stop' => $session->stopPlaytest(), + 'battleTest.describe' => $session->describeBattleTest(), + 'battleTest.apply' => $session->applyBattleTest(is_array($params['battleTest'] ?? null) ? $params['battleTest'] + : throw new InvalidRequest('"battleTest" must be a battle test object.')), + 'battleTest.start' => $session->startBattleTest(array_key_exists('troop', $params) ? self::requireInt($params, 'troop') : null), + 'battleTest.status' => $session->describeBattleTestRun(), + 'battleTest.stop' => $session->stopBattleTest(), default => throw new InvalidRequest(sprintf('Unknown method "%s".', $method)), }; } diff --git a/src/Validation/BattleTestValidator.php b/src/Validation/BattleTestValidator.php new file mode 100644 index 0000000..237aad4 --- /dev/null +++ b/src/Validation/BattleTestValidator.php @@ -0,0 +1,81 @@ +getRecordDatabase('system')?->getRecordByIndex(0); + $entry = $system?->get(ProjectBattleTest::SYSTEM_KEY); + if ($entry === null) { + return []; + } + try { + $test = ProjectBattleTest::fromArray(is_array($entry) ? $entry : throw new InvalidArgumentException('it is not an entry.')); + } catch (InvalidArgumentException $invalid) { + return [Issue::warning(self::WHERE, 'The battle test cannot be read: ' . $invalid->getMessage(), + 'Set it again from the Battle Test dialog, or remove it to use the starting party.')]; + } + + $issues = []; + $troops = array_map(static fn($record): string => trim(strval($record->get('name'))), + $workspace->getRecordDatabase('troops')?->getRecords() ?? []); + if ($test->troop !== null && !in_array(strtolower($test->troop), array_map(strtolower(...), $troops), true)) { + $issues[] = Issue::warning(self::WHERE, sprintf('The battle test troop %s is not among the troops.', $test->troop), + 'Choose one of the project\'s troops.'); + } + $arena = $test->setup?->arena ?? $test->arena; + if ($arena !== null) { + try { + $arenas = BattlePresentationCatalog::loadCode($workspace->projectRoot . '/assets')?->getArenaChoices() ?? []; + } catch (Throwable) { + $arenas = null; + } + if ($arenas !== null && !array_key_exists($arena, $arenas)) { + $issues[] = Issue::warning(self::WHERE, sprintf('The battle test arena %s is not one the battle presentation declares.', $arena), + 'Choose one of its arenas, or none for its default.'); + } + } + if ($test->setup === null) { + return $issues; + } + + return [...$issues, ...ProjectDirectoryContext::run($workspace->projectRoot, static function () use ($workspace, $test): array { + EngineDataBootstrap::ensure($workspace->projectRoot); + $items = ConfigStore::has(ItemStore::class) ? ConfigStore::get(ItemStore::class) : null; + if (!$items instanceof ItemStore) { + return []; + } + $problems = $test->setup->getProblems($workspace->actorDatabase->createActorStore(), $items, + $workspace->loadSkillCatalog(), new SummonCutsceneLibrary($workspace->projectRoot . '/assets/Cutscenes/Summons')); + + return array_map(static fn(string $problem): Issue => Issue::warning(self::WHERE, $problem, + 'Fix the member in the Battle Test dialog; the battle test will not start until it can be set up.'), $problems); + })]; + } +} diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 7b5bd95..ab360ce 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -144,6 +144,7 @@ public function validate(ProjectWorkspace $workspace): array ...new AnimationReferenceValidator()->validate($workspace), ...new CounterAttackValidator()->validate($workspace), ...new TroopFormationValidator()->validate($workspace), + ...new BattleTestValidator()->validate($workspace), ...$this->checkDefinitionIdentities($workspace), ...$this->checkActorDefinitions($workspace), ...new ActorReferenceValidator()->validate($workspace), diff --git a/tests/Unit/Session/BattleTestSessionTest.php b/tests/Unit/Session/BattleTestSessionTest.php new file mode 100644 index 0000000..bc63564 --- /dev/null +++ b/tests/Unit/Session/BattleTestSessionTest.php @@ -0,0 +1,115 @@ + 'Ichiloto\\Engine\\Entities\\Character', 'data' => [ + 'id' => 'Kaelion', 'name' => 'Kaelion', 'level' => 1, 'currentExp' => 0, 'stats' => [ + 'currentHp' => 40, 'currentMp' => 10, 'currentAp' => 3, 'totalHp' => 100, 'totalMp' => 20, 'totalAp' => 3, + 'attack' => 8, 'defence' => 7, 'magicAttack' => 6, 'magicDefence' => 5, 'speed' => 4, 'grace' => 3, 'evasion' => 2, + ], + ]], true) . ";\n"); + + return $root; +} + +it('stands the starting party in for a battle test that sets none, saying so, with what each member may wear', function () { + $root = battleTestProject(); + $test = EditorSession::open($root)->describeBattleTest(); + + expect($test['source'])->toBe('starting party') + ->and($test['troops'])->toBe(['Pair']) + ->and(array_column($test['members'], 'actor'))->toBe(['Kaelion']) + ->and($test['members'][0]['maxLevel'])->toBeInt() + ->and(array_column($test['members'][0]['equipment'], 'slot'))->toContain('Weapon') + ->and($test['members'][0]['equipment'][0]['choices'][0])->toBe(['id' => null, 'name' => '(None)']) + ->and($test['members'][0]['choices']['commands'][0]['id'])->toBeNull() + ->and($test['problems'])->toBe([]) + ->and($test['issue'])->toBeNull(); +}); + +it('sets the battle test in system data as one undo step, and the previews stand its party', function () { + $root = battleTestProject(); + $file = $root . '/assets/Data/system.php'; + $before = (string) file_get_contents($file); + $session = EditorSession::open($root); + + $test = $session->applyBattleTest(['troop' => 'Pair', 'members' => [['actor' => 'Kaelion', 'level' => 3]]]); + expect($test['source'])->toBe('battle test') + ->and($test['troop'])->toBe('Pair') + ->and($test['members'][0]['level'])->toBe(3) + ->and($session->readTroopFormation(0)['partySource'])->toBe('battle test'); + + $session->saveDatabase('system'); + expect((require $file)[0][ProjectBattleTest::SYSTEM_KEY] ?? (require $file)[ProjectBattleTest::SYSTEM_KEY] ?? null) + ->toBe(['troop' => 'Pair', 'members' => [['actor' => 'Kaelion', 'level' => 3]]]); + + $session->undo(); + $session->saveDatabase('system'); + expect(file_get_contents($file))->toBe($before) + ->and($session->readTroopFormation(0)['partySource'])->toBe('starting party'); +}); + +it('refuses what the Engine would not read, changing nothing', function () { + $root = battleTestProject(); + $session = EditorSession::open($root); + + expect(fn() => $session->applyBattleTest(['members' => [['actor' => 'Kaelion']]]))->toThrow(SessionRefusal::class, 'whole-number level') + ->and(fn() => $session->applyBattleTest(['party' => []]))->toThrow(SessionRefusal::class, 'has no party') + ->and($session->listUnsavedChanges())->toBe([]); +}); + +it('names the problems a battle test party has, and the validator warns of them and of a missing troop', function () { + $root = battleTestProject(); + $session = EditorSession::open($root); + $test = $session->applyBattleTest(['troop' => 'Nobody', 'members' => [['actor' => 'Kaelion', 'level' => 999]]]); + $session->saveDatabase('system'); + $issues = array_map(static fn($issue): string => $issue->message, new BattleTestValidator()->validate(ProjectWorkspace::fromProject($root))); + + expect(implode(' ', $test['problems']))->toContain('level 999 is beyond its highest') + ->and(implode(' ', $issues))->toContain('The battle test troop Nobody is not among the troops.') + ->and(implode(' ', $issues))->toContain('level 999 is beyond its highest') + ->and(fn() => $session->startBattleTest())->toThrow(SessionRefusal::class); +}); + +it('refuses to start without a troop, or while a database a battle reads is unsaved', function () { + $root = battleTestProject(); + $session = EditorSession::open($root); + + expect(fn() => $session->startBattleTest())->toThrow(SessionRefusal::class, 'Choose a troop'); + + $placement = array_find($session->readDatabaseRecord('troops', 0)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'member0GraphicalPlacement'); + $session->applyDatabaseRecord('troops', 0, $placement['key'], '300, 300, 275, 190'); + expect(fn() => $session->startBattleTest(0))->toThrow(SessionRefusal::class, 'first; the battle reads them on disk'); +}); + +it('plays a battle test from an overlay carrying the battle test as it stands, never writing the project', function () { + $root = battleTestProject(); + $system = $root . '/assets/Data/system.php'; + $before = (string) file_get_contents($system); + $entry = ['troop' => 'Pair', 'members' => [['actor' => 'Kaelion', 'level' => 3]]]; + + $overlay = PlaytestOverlay::createForBattle($root, $entry); + $copied = require $overlay->root . '/assets/Data/system.php'; + $emptied = PlaytestOverlay::createForBattle($root, []); + $none = require $emptied->root . '/assets/Data/system.php'; + $overlay->destroy(); + $emptied->destroy(); + + expect($copied[ProjectBattleTest::SYSTEM_KEY])->toBe($entry) + ->and($copied['startingParty'])->toBe(['Kaelion']) + ->and($none)->not->toHaveKey(ProjectBattleTest::SYSTEM_KEY) + ->and(file_get_contents($system))->toBe($before); +}); From f8245bcf0f16fc6bceab66122585e29492b6d35a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 17:00:39 +0200 Subject: [PATCH 182/344] feat(session): describe a battle test draft without writing it --- src/Session/EditorSession.php | 8 +++++--- src/Session/SessionHost.php | 6 +++++- tests/Unit/Session/BattleTestSessionTest.php | 11 +++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 5f3630e..6478d32 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -2448,14 +2448,16 @@ public function stopPlaytest(): array * battle, and says so. Each member comes with what it may be given (the * Engine's BattleTestChoices, the same as the in-game arena offers) and * the setup with the Engine's own problems. Reads the record as it is - * now, unsaved edits included. + * now, unsaved edits included, or describes a draft the dialog is + * editing without writing it. * + * @param array|null $draft An entry to describe instead of the record's. * @return array * @throws SessionRefusal When the project has no system settings. */ - public function describeBattleTest(): array + public function describeBattleTest(?array $draft = null): array { - $entry = $this->readBattleTestEntry(); + $entry = $draft ?? $this->readBattleTestEntry(); $view = [ 'battleTest' => $entry, 'troops' => array_values(array_map(strval(...), $this->listDatabaseRecords('troops')['records'])), diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 59c03e6..c3036c1 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -399,7 +399,11 @@ private function dispatch(string $method, array $params): array 'playtest.start' => $session->startPlaytest(self::requireString($params, 'map'), self::requireInt($params, 'x'), self::requireInt($params, 'y')), 'playtest.status' => $session->describePlaytest(), 'playtest.stop' => $session->stopPlaytest(), - 'battleTest.describe' => $session->describeBattleTest(), + 'battleTest.describe' => $session->describeBattleTest(match (true) { + ! array_key_exists('battleTest', $params) => null, + is_array($params['battleTest']) => $params['battleTest'], + default => throw new InvalidRequest('"battleTest" must be a battle test object.'), + }), 'battleTest.apply' => $session->applyBattleTest(is_array($params['battleTest'] ?? null) ? $params['battleTest'] : throw new InvalidRequest('"battleTest" must be a battle test object.')), 'battleTest.start' => $session->startBattleTest(array_key_exists('troop', $params) ? self::requireInt($params, 'troop') : null), diff --git a/tests/Unit/Session/BattleTestSessionTest.php b/tests/Unit/Session/BattleTestSessionTest.php index bc63564..d23fe38 100644 --- a/tests/Unit/Session/BattleTestSessionTest.php +++ b/tests/Unit/Session/BattleTestSessionTest.php @@ -113,3 +113,14 @@ function battleTestProject(): string ->and($none)->not->toHaveKey(ProjectBattleTest::SYSTEM_KEY) ->and(file_get_contents($system))->toBe($before); }); + +it('describes a draft the dialog is editing without writing it', function () { + $root = battleTestProject(); + $session = EditorSession::open($root); + $draft = $session->describeBattleTest(['members' => [['actor' => 'Kaelion', 'level' => 5]]]); + + expect($draft['source'])->toBe('battle test') + ->and($draft['members'][0]['level'])->toBe(5) + ->and($session->describeBattleTest()['source'])->toBe('starting party') + ->and($session->listUnsavedChanges())->toBe([]); +}); From 79a472dc667c8a1de6db75ee00a763d13e8f044f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 17:41:22 +0200 Subject: [PATCH 183/344] fix(workspace): name each saveable database by its own category, so battle art no longer hides the actors from Save All listSaveableDatabases labelled every record category through DatabaseCatalog::indexOf(), which answers the first listed category, Actors, for the embedded battle art categories. Each of them replaced the Actors database in the list, so unsaved actor edits and unsaved battle art were left out of the unsaved changes, and Save All did not write the actors. Categories are now named by DatabaseCatalog::findByKey(), listed or embedded, and an unknown record category is refused. --- src/Database/DatabaseCatalog.php | 16 ++++++++++++++-- src/ProjectWorkspace.php | 8 +++++++- tests/Unit/Session/ActorSessionTest.php | 15 +++++++++++++++ tests/Unit/Session/BattlerArtSessionTest.php | 14 ++++++++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/Database/DatabaseCatalog.php b/src/Database/DatabaseCatalog.php index 5b82f77..9ed04dd 100644 --- a/src/Database/DatabaseCatalog.php +++ b/src/Database/DatabaseCatalog.php @@ -68,11 +68,23 @@ public static function getEmbedded(): array */ public static function knows(string $key): bool { - return array_any([...self::all(), ...self::getEmbedded()], static fn(DatabaseCategoryDefinition $category): bool => $category->key === $key); + return self::findByKey($key) !== null; } /** - * Returns the category index for the requested key. + * Returns the category a key names, listed or embedded; null when none + * does. Unlike {@see indexOf()}, which places a listed category in the + * Database list, this never answers with another category. + */ + public static function findByKey(string $key): ?DatabaseCategoryDefinition + { + return array_find([...self::all(), ...self::getEmbedded()], static fn(DatabaseCategoryDefinition $category): bool => $category->key === $key); + } + + /** + * Returns the Database list position of a listed category; the first + * category for any other key, embedded ones included. To name a + * category by its key, use {@see findByKey()}. * * @param string $key Stable category key. * @return int diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index 23fbcab..4196fea 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -24,6 +24,7 @@ use Ichiloto\Engine\Field\MapGraphics; use RecursiveDirectoryIterator; use InvalidArgumentException; +use LogicException; use RecursiveIteratorIterator; use RuntimeException; use Throwable; @@ -374,7 +375,12 @@ public function listSaveableDatabases(): array foreach ($this->recordDatabases as $categoryKey => $recordDatabase) { if ($recordDatabase->isEditable()) { - $databases[DatabaseCatalog::at(DatabaseCatalog::indexOf($categoryKey))->label] = $recordDatabase; + // By its own label: an embedded category (battle art) has no + // Database list position, and naming it by one replaced the + // Actors database here, hiding it from Save All. + $category = DatabaseCatalog::findByKey($categoryKey) + ?? throw new LogicException(sprintf('The record category %s is not in the Database catalog.', $categoryKey)); + $databases[$category->label] = $recordDatabase; } } diff --git a/tests/Unit/Session/ActorSessionTest.php b/tests/Unit/Session/ActorSessionTest.php index 90f3b2f..2bfb0c7 100644 --- a/tests/Unit/Session/ActorSessionTest.php +++ b/tests/Unit/Session/ActorSessionTest.php @@ -220,3 +220,18 @@ function writeLegacyActor(string $root, string $name): string ->and($request(5, 'database.apply', [...$apply, 'answer' => 'write', 'confirm' => $asked['confirm']])['result'])->toMatchArray(['changed' => true, 'reloaded' => true]) ->and($request(6, 'history.undo')['result']['label'])->toBe('Migrate actor identities and references'); }); + +it('names an unsaved actor edit among the unsaved changes, and Save All writes it, beside the battle art categories', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + $session = EditorSession::open($root); + $name = actorSessionRow($session->readDatabaseRecord('actors', 0), 'name'); + $session->applyDatabaseRecord('actors', 0, $name['key'], 'Kaelion the Bold'); + + expect($session->listUnsavedChanges())->toBe(['Actors database']); + + $session->saveAll(); + + expect(file_get_contents($path))->toContain('Kaelion the Bold') + ->and($session->listUnsavedChanges())->toBe([]); +}); diff --git a/tests/Unit/Session/BattlerArtSessionTest.php b/tests/Unit/Session/BattlerArtSessionTest.php index 96e1d5a..38ca832 100644 --- a/tests/Unit/Session/BattlerArtSessionTest.php +++ b/tests/Unit/Session/BattlerArtSessionTest.php @@ -142,3 +142,17 @@ expect(fn() => $session->applyDatabaseRecordValues('battler_enemies', $index, $refused, 'Ground point'))->toThrow(SessionRefusal::class) ->and(readBattlerRow($session, $index, 'pose0Pivot')['value'])->toBe('0.25, 0.9'); }); + +it('names unsaved battle art among the unsaved changes, as the status bar counts them', function () { + $root = battlerArtProject(); + $session = EditorSession::open($root); + $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index']; + setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png'); + setBattlerRow($session, $index, 'artwork.pivot', '0.25, 1'); + + expect($session->listUnsavedChanges())->not->toBe([]); + + $session->saveDatabase('battler_enemies'); + + expect($session->listUnsavedChanges())->toBe([]); +}); From 662fe7958c6fca9370dea3a67f675a2d9a5abbab Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 02:59:18 +0200 Subject: [PATCH 184/344] fix(canvas): keep a tile that stands for a glyph with its glyph and collision Tile edits wrote tiles alone, so moving a piece's tiles in the GUI editor left its glyph and collision behind. Every tile a tileset piece draws now stands for that piece's glyph (TileGlyphBinding). Stamping, erasing or covering such a tile writes or removes its glyph through the same planner a glyph edit uses, with the rest of its tiles and the piece's tileless glyph cells, as one undo step; wall cells take their neighbours' shapes; an erase and a stamp in one call move a piece. A tile several pieces draw is asked about (tiles.stamp choices), and a glyph that would fall off the map is refused. Tiles no piece draws stay free. The TUI piece stamp and wall drawing also go through the planner, so a glyph they cover takes its tiles. --- docs/manual.md | 13 + src/Canvas/CanvasEditor.php | 272 ++++++++++++++++-- src/Canvas/GlyphTilePlanner.php | 46 ++- src/Canvas/PieceCanvas.php | 57 ++-- src/Canvas/TileBinding.php | 41 +++ src/Canvas/TileGlyphBinding.php | 103 +++++++ src/Session/EditorSession.php | 38 ++- src/Session/SessionHost.php | 23 +- .../Session/TilesCarryGlyphsSessionTest.php | 151 ++++++++++ tests/Unit/TilesetPiecesTest.php | 25 ++ 10 files changed, 700 insertions(+), 69 deletions(-) create mode 100644 src/Canvas/TileBinding.php create mode 100644 src/Canvas/TileGlyphBinding.php create mode 100644 tests/Unit/Session/TilesCarryGlyphsSessionTest.php diff --git a/docs/manual.md b/docs/manual.md index 91f5586..026bd25 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -411,6 +411,19 @@ intact: typed again, and the eyedropper picks up the piece a glyph draws. Tiles no piece accounts for, such as a floor under a wall, stay where they are. +- Glyphs follow the tiles that stand for them, the other way round. Any tile + a tileset piece draws stands for that piece's glyph on its gameplay layer, + such as a bed's tiles for `O` and `U` on fixtures or a wall's tile for `-`, + `|` and `+` on buildings. Placing such a tile in the GUI editor writes its + glyph, and so its collision, where the piece puts it, with the rest of that + glyph's tiles and the piece's glyph cells that draw no tile (the lower half + of a window); erasing or covering one removes the glyph it stood for, with + its other tiles; a wall's cells take the shapes their neighbours give them. + Glyphs, collision and tiles change in one undo step, and picked-up tiles + move in one step too. A tile two pieces draw is asked about. A tile whose + glyph would fall off the map is refused. Tiles no piece draws, such as a + rug or a picture, stand for nothing in the terminal and are placed freely. + - Each tile layer moves with one gameplay layer: the one the map data names (`'tileLayers' => ['floor' => ['movesWith' => 'buildings']]`), or else the one whose tileset pieces write it, when only one does. Copying, cutting and diff --git a/src/Canvas/CanvasEditor.php b/src/Canvas/CanvasEditor.php index 68e4cab..f3181f3 100644 --- a/src/Canvas/CanvasEditor.php +++ b/src/Canvas/CanvasEditor.php @@ -36,12 +36,13 @@ final class CanvasEditor * @param array $choices The role key chosen for a glyph, or null for no tiles. * @param bool $repaint Whether a glyph painted over itself may take another role. * @param list $excludedLayers Tile layers the edit sets itself. + * @param array $assigned The role key a written cell's glyph plays, by `x,y`, when the edit knows it. * @return array{tiles: array>, unresolved: array>}|null * The tiles and the glyphs still waiting on a choice, or null when the layer has no pieces to follow. * @throws MapSourceRefusal When a tile layer the pieces draw on cannot be read. */ public static function plan(ProjectMap $map, string $layerId, array $writes, array $choices = [], bool $repaint = false, - array $excludedLayers = []): ?array + array $excludedLayers = [], array $assigned = []): ?array { $planner = self::loadGlyphTilePlanner($map, $layerId, $excludedLayers); if ($planner === null) { @@ -61,6 +62,7 @@ public static function plan(ProjectMap $map, string $layerId, array $writes, arr self::createTileReader($map), $choices, $repaint, + $assigned, ); } @@ -158,46 +160,274 @@ public static function resolvePaintStyle(string $symbol, ?string $colorDirective /** * Sets one tile in each cell of a tile layer, `0` erasing it, as one undo - * step, creating the layer when the map does not have it yet. Tiles are - * independent of glyphs: nothing here changes a glyph or collision. An - * autotile is placed by its kind; the Engine shapes it from its - * neighbours. + * step, by {@see stampTiles()}: a tile that stands for a glyph brings or + * takes that glyph with it. * * @param list $cells The cells, as [x, y]. - * @return array{command: ?Command, changed: int} No command when nothing changed. + * @param array $choices The role key chosen for a tile that could stand for several glyphs, by tile entry. + * @return array{command: ?Command, changed: int, glyphs: int, unresolved: array>} * @throws MapSourceRefusal When the tile is not a tile identity, a cell is outside the map or the layer cannot be written; nothing is changed. */ - public static function setTiles(ProjectMap $map, string $layerName, array $cells, int $tile, string $label): array + public static function setTiles(ProjectMap $map, string $layerName, array $cells, int $tile, string $label, array $choices = []): array { - return self::stampTiles($map, $layerName, array_map(static fn(array $cell): array => [$cell[0], $cell[1], $tile], $cells), $label); + return self::stampTiles($map, $layerName, array_map(static fn(array $cell): array => [$cell[0], $cell[1], $tile], $cells), $label, $choices); } /** * Sets each listed cell of a tile layer to its own tile, `0` erasing, as * one undo step: a block of tiles chosen together and stamped, as RPG * Maker places a multi-tile selection. The layer is created when the map - * does not have it yet. Glyphs and collision never change. + * does not have it yet. An autotile is placed by its kind; the Engine + * shapes it from its neighbours. + * + * A tile that stands for a glyph ({@see TileGlyphBinding}) never moves + * without it. Placing one writes its glyph where its role puts it, with + * the rest of that role's tiles and the piece's glyph cells that draw no + * tile; erasing or covering one removes the glyph it stood for, with its + * other tiles. A wall's glyphs take the shapes their neighbours give them. + * Glyphs, their collision and every tile change as one undo step. A tile + * that stands for nothing, such as a rug, is written as given. An erase + * and a stamp in one call move a piece. * * @param list $cells Each cell as [x, y, tile]. - * @return array{command: ?Command, changed: int} No command when nothing changed. - * @throws MapSourceRefusal When a tile is not a tile identity, a cell is outside the map or the layer cannot be written; nothing is changed. + * @param array $choices The role key chosen for a tile that could stand for several glyphs, by tile entry. + * @return array{command: ?Command, changed: int, glyphs: int, unresolved: array>} + * No command when nothing changed, or when a tile still waits on a choice ({@see PieceRole} by tile entry). + * @throws MapSourceRefusal When a tile is not a tile identity, a cell is outside the map, a tile's glyph would fall + * outside its layer or the layer cannot be written; nothing is changed. */ - public static function stampTiles(ProjectMap $map, string $layerName, array $cells, string $label): array + public static function stampTiles(ProjectMap $map, string $layerName, array $cells, string $label, array $choices = []): array { + $stamp = []; + foreach ($cells as [$x, $y, $tile]) { + $stamp["{$x},{$y}"] = (string) $tile; + } + $binding = TileGlyphBinding::fromMap($map); + $tileAt = self::createTileReader($map); + $glyphPlan = self::planStampGlyphs($map, $binding, $layerName, $stamp, $tileAt, $choices); + if ($glyphPlan['unresolved'] !== []) { + return ['command' => null, 'changed' => 0, 'glyphs' => 0, 'unresolved' => $glyphPlan['unresolved']]; + } + + // The tiles the glyphs' roles take and bring, then the stamp's own + // tiles, except where an arriving role draws: its tiles stay whole. + $tiles = []; + foreach ($glyphPlan['writes'] as $layerId => $writes) { + $plan = self::plan($map, $layerId, $writes, assigned: $glyphPlan['assigned'][$layerId] ?? []); + foreach ($plan['tiles'] ?? [] as $tileLayer => $entries) { + foreach ($entries as $entry) { + $tiles[$tileLayer]["{$entry['x']},{$entry['y']}"] = $entry['entry']; + } + } + } + foreach ($stamp as $position => $entry) { + $planned = $tiles[$layerName][$position] ?? null; + if ($planned === null || $planned === (string) TileId::EMPTY) { + $tiles[$layerName][$position] = $entry; + } + } + $before = $map->getTileLayerSources(); - $entries = array_map(static fn(array $cell): array => ['x' => $cell[0], 'y' => $cell[1], 'entry' => (string) $cell[2]], $cells); - $previous = $map->readTileEntries([$layerName], 0, 0, $map->getWidth(), $map->getHeight())[$layerName] ?? []; - $map->writeTileCells([$layerName => $entries]); + $previous = []; + foreach (array_keys($tiles) as $tileLayer) { + $previous[$tileLayer] = $map->readTileEntries([$tileLayer], 0, 0, $map->getWidth(), $map->getHeight())[$tileLayer] ?? []; + } + $map->writeTileCells(array_map(static fn(array $entries): array => array_map(static function (string $entry, string $position): array { + [$x, $y] = array_map(intval(...), explode(',', $position)); + + return ['x' => $x, 'y' => $y, 'entry' => $entry]; + }, $entries, array_map(strval(...), array_keys($entries))), $tiles)); $after = $map->getTileLayerSources(); - if ($after === $before) { - return ['command' => null, 'changed' => 0]; + $strokes = []; + $glyphs = 0; + foreach ($glyphPlan['writes'] as $layerId => $writes) { + [$stroke, $changed] = self::writeCells($map, $layerId, $writes, $label); + $glyphs += $changed; + if ($stroke->hasChanges()) { + $strokes[] = $stroke; + } + } + if ($strokes === [] && $after === $before) { + return ['command' => null, 'changed' => 0, 'glyphs' => 0, 'unresolved' => []]; + } + $changed = 0; + foreach ($tiles as $tileLayer => $entries) { + foreach ($entries as $position => $entry) { + [$x, $y] = array_map(intval(...), explode(',', (string) $position)); + $changed += ($previous[$tileLayer][$y][$x] ?? (string) TileId::EMPTY) !== $entry ? 1 : 0; + } } - $changed = count(array_filter($cells, static fn(array $cell): bool - => ($previous[$cell[1]][$cell[0]] ?? (string) TileId::EMPTY) !== (string) $cell[2])); return ['command' => new GenericCommand($label, - static fn() => $map->restoreTileLayerSources($after), - static fn() => $map->restoreTileLayerSources($before)), 'changed' => $changed]; + static function () use ($strokes, $map, $after): void { + foreach ($strokes as $stroke) { + $stroke->execute(); + } + $map->restoreTileLayerSources($after); + }, + static function () use ($strokes, $map, $before): void { + foreach (array_reverse($strokes) as $stroke) { + $stroke->undo(); + } + $map->restoreTileLayerSources($before); + }), 'changed' => $changed, 'glyphs' => $glyphs, 'unresolved' => []]; + } + + /** + * The glyphs a tile stamp writes so no tile that stands for a glyph is + * left without it: the glyph a covered or erased tile stood for leaves, + * and the glyph a placed tile stands for arrives, each with the piece's + * glyph cells that draw no tile. A wall joins or leaves its connected + * piece and every wall cell beside it takes its new shape. + * + * @param array $stamp The stamp's tile entries, by `x,y`. + * @param Closure(string, int, int): string $tileAt Tile entries before the stamp. + * @param array $choices The role key chosen for an ambiguous tile, by entry. + * @return array{writes: array>, assigned: array>, + * unresolved: array>} Glyph writes and the role each written cell plays, by gameplay layer id. + * @throws MapSourceRefusal When a tile's glyph would fall outside its layer. + */ + private static function planStampGlyphs(ProjectMap $map, TileGlyphBinding $binding, string $tileLayer, array $stamp, + Closure $tileAt, array $choices): array + { + $glyphAt = static fn(string $layerId, int $x, int $y): ?string + => $map->hasLayerCell($layerId, $x, $y) ? $map->getLayerSymbol($layerId, $x, $y) : null; + $symbols = $assigned = $unresolved = []; + $connected = []; + + $leave = static function (TileBinding $bound, int $x, int $y) use (&$symbols, &$assigned, &$connected, $glyphAt): void { + if ($bound->isConnected) { + $connected[$bound->layerId][$bound->piece->id]['erased']["{$x},{$y}"] = ['x' => $x, 'y' => $y]; + return; + } + $symbols[$bound->layerId]["{$x},{$y}"] = ' '; + unset($assigned[$bound->layerId]["{$x},{$y}"]); + foreach ($bound->companions as $companion) { + [$cx, $cy] = [$x + $companion['dx'], $y + $companion['dy']]; + if ($glyphAt($bound->layerId, $cx, $cy) === $companion['glyph']) { + $symbols[$bound->layerId]["{$cx},{$cy}"] = ' '; + } + } + }; + $arrive = static function (TileBinding $bound, int $x, int $y) use (&$symbols, &$assigned, &$connected, $glyphAt): void { + if ($glyphAt($bound->layerId, $x, $y) === null) { + throw new MapSourceRefusal(sprintf('That tile stands for %s, whose glyph at (%d, %d) is outside the map. Nothing was changed.', + $bound->role->label, $x, $y)); + } + if ($bound->isConnected) { + $connected[$bound->layerId][$bound->piece->id]['drawn']["{$x},{$y}"] = ['x' => $x, 'y' => $y]; + return; + } + $symbols[$bound->layerId]["{$x},{$y}"] = $bound->glyph; + $assigned[$bound->layerId]["{$x},{$y}"] = $bound->role->key; + foreach ($bound->companions as $companion) { + [$cx, $cy] = [$x + $companion['dx'], $y + $companion['dy']]; + if ($glyphAt($bound->layerId, $cx, $cy) !== null) { + $symbols[$bound->layerId]["{$cx},{$cy}"] = $companion['glyph']; + $assigned[$bound->layerId]["{$cx},{$cy}"] = $companion['key']; + } + } + }; + + // Leaving first: the glyph each covered or erased tile stood for. + foreach ($stamp as $position => $entry) { + [$x, $y] = array_map(intval(...), explode(',', (string) $position)); + $old = $tileAt($tileLayer, $x, $y); + if ($old === $entry) { + continue; + } + foreach ($binding->findBindings($tileLayer, $old) as $bound) { + [$gx, $gy] = [$x - $bound->dx, $y - $bound->dy]; + if ($glyphAt($bound->layerId, $gx, $gy) === $bound->glyph + && $binding->getPlanner($bound->layerId)?->findPlayedRole($bound->glyph, $gx, $gy, $tileAt)?->key === $bound->role->key) { + $leave($bound, $gx, $gy); + break; + } + } + } + // Then arriving: the glyph each placed tile stands for. + foreach ($stamp as $position => $entry) { + [$x, $y] = array_map(intval(...), explode(',', (string) $position)); + $bound = self::chooseBinding($binding->findBindings($tileLayer, $entry), $tileLayer, $x, $y, $stamp, $glyphAt, $choices[$entry] ?? null); + if ($bound instanceof TileBinding) { + $arrive($bound, $x - $bound->dx, $y - $bound->dy); + } elseif ($bound !== null) { + $unresolved[$entry] = $bound; + } + } + // Connected pieces: every drawn, erased and neighbouring wall cell takes its shape. + foreach ($connected as $layerId => $pieces) { + foreach ($pieces as $pieceId => $cells) { + $piece = $map->loadTileset()?->pieces[$pieceId] ?? null; + if ($piece === null) { + continue; + } + $isMember = static fn(int $x, int $y): bool => in_array($glyphAt($layerId, $x, $y), $piece->shapes, true); + foreach (ConnectedPieceShaper::reshapeCells($piece, array_values($cells['drawn'] ?? []), array_values($cells['erased'] ?? []), $isMember) as $cell) { + if ($glyphAt($layerId, $cell['x'], $cell['y']) !== null) { + $symbols[$layerId]["{$cell['x']},{$cell['y']}"] = $cell['shape'] === null ? ' ' : $piece->shapes[$cell['shape']]; + } + } + } + } + + $writes = []; + foreach ($symbols as $layerId => $cells) { + foreach ($cells as $position => $symbol) { + [$x, $y] = array_map(intval(...), explode(',', (string) $position)); + $writes[$layerId][] = ['x' => $x, 'y' => $y, 'symbol' => $symbol]; + } + } + + return ['writes' => $writes, 'assigned' => $assigned, 'unresolved' => $unresolved]; + } + + /** + * The glyph a placed tile stands for: its only binding; the one the rest + * of the stamp proves, by holding more of that role's tiles; the author's + * choice; or, still undecided, the roles to choose among. The cells of + * one connected piece count as one binding, since their neighbours decide + * the shape. Null for a tile that stands for nothing. + * + * @param list $bindings + * @param array $stamp + * @param Closure(string, int, int): ?string $glyphAt + * @return TileBinding|list|null + */ + private static function chooseBinding(array $bindings, string $tileLayer, int $x, int $y, array $stamp, Closure $glyphAt, + ?string $choice): TileBinding|array|null + { + $distinct = []; + foreach ($bindings as $bound) { + $distinct[$bound->isConnected ? "{$bound->layerId}:{$bound->piece->id}" : "{$bound->layerId}:{$bound->role->key}"] ??= $bound; + } + if (count($distinct) < 2) { + return array_values($distinct)[0] ?? null; + } + if ($choice !== null) { + foreach ($distinct as $bound) { + if ($bound->role->key === $choice) { + return $bound; + } + } + } + $scored = []; + foreach ($distinct as $bound) { + [$gx, $gy] = [$x - $bound->dx, $y - $bound->dy]; + $score = 0; + foreach ($bound->role->tiles[$tileLayer] ?? [] as $cell) { + $score += ($stamp[($gx + $cell['dx']) . ',' . ($gy + $cell['dy'])] ?? null) === $cell['entry'] ? 2 : 0; + } + $score += $glyphAt($bound->layerId, $gx, $gy) === $bound->glyph ? 1 : 0; + $scored[$score][] = $bound; + } + krsort($scored); + $best = reset($scored); + if (count($best) === 1) { + return $best[0]; + } + + return array_values(array_map(static fn(TileBinding $bound): PieceRole => $bound->role, $distinct)); } /** diff --git a/src/Canvas/GlyphTilePlanner.php b/src/Canvas/GlyphTilePlanner.php index c054fd5..d255185 100644 --- a/src/Canvas/GlyphTilePlanner.php +++ b/src/Canvas/GlyphTilePlanner.php @@ -19,8 +19,11 @@ */ final readonly class GlyphTilePlanner { - /** @param array> $roles Roles by glyph, distinct by the tiles they draw. */ - private function __construct(private array $roles) {} + /** + * @param array> $roles Roles by glyph, distinct by the tiles they draw. + * @param array $twins The kept role for each key of a role that draws the same tiles with the same glyph. + */ + private function __construct(private array $roles, private array $twins = []) {} /** * @param iterable $pieces The map's tileset pieces. @@ -29,19 +32,28 @@ private function __construct(private array $roles) {} */ public static function fromPieces(iterable $pieces, string $layerName, array $excludedLayers = []): self { - $roles = []; + $roles = $twins = []; foreach ($pieces as $piece) { if ($piece->layer !== $layerName) { continue; } foreach (PieceRole::readPiece($piece, $excludedLayers) as $glyph => $pieceRoles) { foreach ($pieceRoles as $role) { - $roles[(string) $glyph][self::getSignature($role)] ??= $role; + $kept = $roles[(string) $glyph][self::getSignature($role)] ??= $role; + if ($kept !== $role) { + $twins[$role->key] = $kept; + } } } } - return new self(array_map(array_values(...), $roles)); + return new self(array_map(array_values(...), $roles), $twins); + } + + /** @return array> Every role the layer's glyphs can play, keyed by glyph. */ + public function listRoles(): array + { + return $this->roles; } /** @return list The roles a glyph can play, one for each set of tiles. */ @@ -50,11 +62,15 @@ public function getRoles(string $glyph): array return $this->roles[$glyph] ?? []; } - /** The role with this key that a glyph can play, or null when it has none. */ + /** + * The role with this key that a glyph can play, or null when it has none. + * A role kept for another that draws the same tiles answers for its key. + */ public function findRole(string $glyph, string $key): ?PieceRole { + $twin = $this->twins[$key] ?? null; foreach ($this->getRoles($glyph) as $role) { - if ($role->key === $key) { + if ($role->key === $key || $role === $twin) { return $role; } } @@ -102,12 +118,17 @@ public function findPlayedRole(string $glyph, int $x, int $y, Closure $tileAt): * @param Closure(int, int): ?string $glyphAt The layer's glyph at a cell before the changes, or null off the map. * @param Closure(string, int, int): string $tileAt The entry at a cell of a tile layer before the changes. * @param array $choices The role key the author chose for a glyph, or null for no tiles. + * @param array $assigned The role key a cell's glyph plays, by `x,y`, when the edit already knows + * it, such as a tile edit that placed that role's tile. It outranks proof and choice, and plans the cell even + * when its glyph stays the same, so a role whose tiles had gone missing gets them back. * @return array{tiles: array>, unresolved: array>} * The tile cells to write, keyed by tile layer name, and the roles of each glyph that still needs a choice. */ - public function plan(array $changes, Closure $glyphAt, Closure $tileAt, array $choices = [], bool $repaint = false): array + public function plan(array $changes, Closure $glyphAt, Closure $tileAt, array $choices = [], bool $repaint = false, + array $assigned = []): array { - $changes = array_values(array_filter($changes, static fn(array $change): bool => $repaint || $change['old'] !== $change['new'])); + $changes = array_values(array_filter($changes, static fn(array $change): bool => $repaint || $change['old'] !== $change['new'] + || isset($assigned["{$change['x']},{$change['y']}"]))); $changed = []; foreach ($changes as $change) { $changed["{$change['x']},{$change['y']}"] = $change['new']; @@ -117,7 +138,12 @@ public function plan(array $changes, Closure $glyphAt, Closure $tileAt, array $c $unresolved = []; foreach ($changes as $index => $change) { - $resolved[$index] = $change['new'] === ' ' ? null : $this->resolveRole($change, $glyphAt, $tileAt, $changed, $choices, $unresolved); + $key = $assigned["{$change['x']},{$change['y']}"] ?? null; + $resolved[$index] = match (true) { + $change['new'] === ' ' => null, + $key !== null => $this->findRole($change['new'], $key), + default => $this->resolveRole($change, $glyphAt, $tileAt, $changed, $choices, $unresolved), + }; } // Every leaving role takes its tiles before any arriving role draws, // so a glyph moved within one edit keeps the tiles it brings. diff --git a/src/Canvas/PieceCanvas.php b/src/Canvas/PieceCanvas.php index 0f20d69..e028b40 100644 --- a/src/Canvas/PieceCanvas.php +++ b/src/Canvas/PieceCanvas.php @@ -270,13 +270,29 @@ private function reloadPiecePlacement(): ?array * Stamps the piece at the cursor as one undo step: its glyphs on the * gameplay layer it names, in the brush colour, and its tiles on the tile * layers it names, creating any the map does not have yet. Space glyphs - * and `0` tiles leave their cells as they are. A stamp that cannot be - * made whole changes nothing. + * and `0` tiles leave their cells as they are. A glyph the stamp covers + * takes its own tiles with it, as any glyph edit does + * ({@see GlyphTilePlanner}). A stamp that cannot be made whole changes + * nothing. */ private function stampPiece(ProjectMap $map, TilesetPiece $piece): void { $x = $this->cursorX; $y = $this->cursorY; + $writes = $assigned = []; + foreach ($piece->glyphs as $row => $symbols) { + foreach ($symbols as $column => $symbol) { + if ($symbol !== ' ') { + $writes[] = ['x' => $x + $column, 'y' => $y + $row, 'symbol' => $symbol, 'color' => $this->selectedPaintColor]; + } + } + } + // Each glyph cell plays its own cell of this piece, never a lookalike. + foreach (PieceRole::readPiece($piece) as $roles) { + foreach ($roles as $role) { + $assigned[($x + $role->cell[1]) . ',' . ($y + $role->cell[0])] = $role->key; + } + } try { $layer = $this->findPieceLayer($map, $piece); foreach ($piece->glyphs as $row => $symbols) { @@ -289,21 +305,16 @@ private function stampPiece(ProjectMap $map, TilesetPiece $piece): void } $this->finalizeActiveStroke(); $tilesBefore = $map->getTileLayerSources(); - $map->writeTileEntries($piece->tiles, $x, $y); + // A piece with glyphs draws through its roles; one without, such as a rug, stands for nothing and is laid as authored. + $writes === [] + ? $map->writeTileEntries($piece->tiles, $x, $y) + : $map->writeTileCells(CanvasEditor::plan($map, $layer, $writes, assigned: $assigned)['tiles'] ?? []); $tilesAfter = $map->getTileLayerSources(); } catch (MapSourceRefusal $refusal) { $this->setStatus($refusal->getMessage(), StatusLevel::WARN); $this->renderCanvasArea(); return; } - $writes = []; - foreach ($piece->glyphs as $row => $symbols) { - foreach ($symbols as $column => $symbol) { - if ($symbol !== ' ') { - $writes[] = ['x' => $x + $column, 'y' => $y + $row, 'symbol' => $symbol, 'color' => $this->selectedPaintColor]; - } - } - } [$stroke] = CanvasEditor::writeCells($map, $layer, $writes, 'Piece stamp'); if (! $stroke->hasChanges() && $tilesAfter === $tilesBefore) { $this->setStatus(sprintf('%s is already at (%d, %d).', $piece->name, $x, $y)); @@ -405,29 +416,23 @@ private function applyConnectedPiece(ProjectMap $map, TilesetPiece $piece, array } } $cells = ConnectedPieceShaper::reshapeCells($piece, $drawn, $erased, $this->resolveConnectedMemberLookup($map, $layer, $piece)); - $tiles = []; - foreach ($piece->shapeTiles as $tileLayer => $entries) { - foreach ($cells as $cell) { - $tiles[$tileLayer][] = ['x' => $cell['x'], 'y' => $cell['y'], - 'entry' => $cell['shape'] === null ? (string) TileId::EMPTY : $entries[$cell['shape']]]; - } - } + $drawnKeys = array_flip(array_map(static fn(array $cell): string => "{$cell['x']},{$cell['y']}", $drawn)); + $writes = array_map(fn(array $cell): array => [ + 'x' => $cell['x'], + 'y' => $cell['y'], + 'symbol' => $cell['shape'] === null ? ' ' : $piece->shapes[$cell['shape']], + 'color' => isset($drawnKeys["{$cell['x']},{$cell['y']}"]) ? $this->selectedPaintColor : null, + ], $cells); + // Each shape draws its tiles, and a glyph the wall covers takes its own ({@see GlyphTilePlanner}). $this->finalizeActiveStroke(); $tilesBefore = $map->getTileLayerSources(); - $map->writeTileCells($tiles); + $map->writeTileCells(CanvasEditor::plan($map, $layer, $writes)['tiles'] ?? []); $tilesAfter = $map->getTileLayerSources(); } catch (MapSourceRefusal $refusal) { $this->setStatus($refusal->getMessage(), StatusLevel::WARN); $this->renderCanvasArea(); return null; } - $drawnKeys = array_flip(array_map(static fn(array $cell): string => "{$cell['x']},{$cell['y']}", $drawn)); - $writes = array_map(fn(array $cell): array => [ - 'x' => $cell['x'], - 'y' => $cell['y'], - 'symbol' => $cell['shape'] === null ? ' ' : $piece->shapes[$cell['shape']], - 'color' => isset($drawnKeys["{$cell['x']},{$cell['y']}"]) ? $this->selectedPaintColor : null, - ], $cells); [$stroke] = CanvasEditor::writeCells($map, $layer, $writes, $label); if (! $stroke->hasChanges() && $tilesAfter === $tilesBefore) { return false; diff --git a/src/Canvas/TileBinding.php b/src/Canvas/TileBinding.php new file mode 100644 index 0000000..d83ae02 --- /dev/null +++ b/src/Canvas/TileBinding.php @@ -0,0 +1,41 @@ + $companions The piece's glyph cells that draw no + * tile, relative to this role's glyph cell; they come and go with it, as the lower half of a window does. + */ + public function __construct( + public readonly string $layerId, + public readonly string $glyph, + public readonly PieceRole $role, + public readonly int $dx, + public readonly int $dy, + public readonly TilesetPiece $piece, + public readonly array $companions = [], + ) {} + + /** Whether the glyph belongs to a connected piece, whose shape its neighbours decide. */ + public bool $isConnected { + get => $this->piece->connects !== null; + } +} diff --git a/src/Canvas/TileGlyphBinding.php b/src/Canvas/TileGlyphBinding.php new file mode 100644 index 0000000..aa1f053 --- /dev/null +++ b/src/Canvas/TileGlyphBinding.php @@ -0,0 +1,103 @@ +>> $bindings By tile layer name, then tile entry. + * @param array $planners Each gameplay layer's planner, by layer id. + */ + private function __construct(private array $bindings, private array $planners) {} + + /** The bindings of a map's tileset pieces, over the gameplay layers the map has. */ + public static function fromMap(ProjectMap $map): self + { + try { + $pieces = $map->loadTileset()?->pieces ?? []; + } catch (InvalidArgumentException | RuntimeException) { + $pieces = []; + } + $bindings = $planners = []; + foreach ($map->getLayers() as $layer) { + if ($pieces === [] || $layer['id'] === MapLayers::EVENT || $layer['decoration']) { + continue; + } + $planner = GlyphTilePlanner::fromPieces($pieces, $layer['name']); + $planners[$layer['id']] = $planner; + foreach ($planner->listRoles() as $glyph => $roles) { + foreach ($roles as $role) { + $piece = $pieces[$role->pieceId]; + $companions = self::findCompanions($piece, $role); + foreach ($role->tiles as $tileLayer => $cells) { + foreach ($cells as $cell) { + $bindings[$tileLayer][$cell['entry']][] = new TileBinding($layer['id'], (string) $glyph, $role, + $cell['dx'], $cell['dy'], $piece, $companions); + } + } + } + } + } + + return new self($bindings, $planners); + } + + /** @return list The glyphs a tile on a tile layer can stand for; none for a free tile. */ + public function findBindings(string $tileLayer, string $entry): array + { + return $this->bindings[$tileLayer][$entry] ?? []; + } + + /** Whether a tile on a tile layer stands for a glyph. */ + public function isBound(string $tileLayer, string $entry): bool + { + return $this->findBindings($tileLayer, $entry) !== []; + } + + /** The planner that keeps a gameplay layer's tiles with its glyphs, or null when the layer has no pieces. */ + public function getPlanner(string $layerId): ?GlyphTilePlanner + { + return $this->planners[$layerId] ?? null; + } + + /** + * An item piece's glyph cells that draw no tile, relative to a role's + * glyph cell. A connected piece has none: each of its cells is its own. + * + * @return list + */ + private static function findCompanions(TilesetPiece $piece, PieceRole $role): array + { + if ($role->cell === null) { + return []; + } + $companions = []; + foreach (PieceRole::readPiece($piece) as $glyph => $roles) { + foreach ($roles as $other) { + if ($other->tiles === [] && $other->cell !== null && $other->key !== $role->key) { + $companions[] = ['dx' => $other->cell[1] - $role->cell[1], 'dy' => $other->cell[0] - $role->cell[0], + 'glyph' => (string) $glyph, 'key' => $other->key]; + } + } + } + + return $companions; + } +} diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 6478d32..e01ecf8 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -480,44 +480,60 @@ public function readTiles(string $mapId): array } /** - * Sets one tile in cells of a tile layer, `0` erasing, as one undo step - * ({@see CanvasEditor::setTiles()}). A layer the map does not have yet is - * created. Tiles never change glyphs or collision. + * Sets one tile in cells of a tile layer, `0` erasing, as one undo step, + * by {@see stampTiles()}. * * @param list $cells The cells, as [x, y]. - * @return array{changed: int, revision: int} + * @param array $choices The role key chosen for a tile that could stand for several glyphs, by tile entry. + * @return array{status: 'applied', changed: int, glyphs: int, revision: int}|array{status: 'question', tile: string, roles: list} * @throws SessionRefusal When the map is unknown, stale or read-only, or refuses the tile. */ - public function paintTiles(string $mapId, int $revision, string $layerName, array $cells, int $tile, string $label = 'Place tiles'): array + public function paintTiles(string $mapId, int $revision, string $layerName, array $cells, int $tile, string $label = 'Place tiles', + array $choices = []): array { - return $this->stampTiles($mapId, $revision, $layerName, array_map(static fn(array $cell): array => [$cell[0], $cell[1], $tile], $cells), $label); + return $this->stampTiles($mapId, $revision, $layerName, array_map(static fn(array $cell): array => [$cell[0], $cell[1], $tile], $cells), + $label, $choices); } /** * Sets each cell of a tile layer to its own tile as one undo step * ({@see CanvasEditor::stampTiles()}): a block chosen in the palette or - * picked from the map, stamped where the author drags. + * picked from the map, stamped where the author drags, or an erase and a + * stamp together that move it. A layer the map does not have yet is + * created. A tile that stands for a glyph brings or takes that glyph and + * its collision with it; a tile that could stand for several is asked + * about, and nothing changes until the edit is made again with the answer. * * @param list $cells Each cell as [x, y, tile]. - * @return array{changed: int, revision: int} + * @param array $choices The role key chosen for a tile that could stand for several glyphs, by tile entry. + * @return array{status: 'applied', changed: int, glyphs: int, revision: int}|array{status: 'question', tile: string, roles: list} * @throws SessionRefusal When the map is unknown, stale or read-only, or refuses a tile. */ - public function stampTiles(string $mapId, int $revision, string $layerName, array $cells, string $label = 'Place tiles'): array + public function stampTiles(string $mapId, int $revision, string $layerName, array $cells, string $label = 'Place tiles', + array $choices = []): array { $map = $this->requireCurrentMap($mapId, $revision); if ($map->getGridSourceIssue() !== null) { throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); } try { - $applied = CanvasEditor::stampTiles($map, $layerName, $cells, $label); + $applied = CanvasEditor::stampTiles($map, $layerName, $cells, $label, $choices); } catch (MapSourceRefusal $refusal) { throw new SessionRefusal($refusal->getMessage(), previous: $refusal); } + if ($applied['unresolved'] !== []) { + $tile = (string) array_key_first($applied['unresolved']); + + return ['status' => 'question', 'tile' => $tile, 'roles' => array_map( + static fn(PieceRole $role): array => ['key' => $role->key, 'label' => $role->label], + $applied['unresolved'][$tile], + )]; + } if ($applied['command'] !== null) { $this->history->record($applied['command']); } - return ['changed' => $applied['changed'], 'revision' => $this->getMapRevision($map)]; + return ['status' => 'applied', 'changed' => $applied['changed'], 'glyphs' => $applied['glyphs'], 'revision' => $this->getMapRevision($map)]; } /** diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index c3036c1..7d62feb 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -136,13 +136,16 @@ private function dispatch(string $method, array $params): array self::requireString($params, 'layer'), self::requireTileCells($params), is_string($params['label'] ?? null) ? $params['label'] : 'Place tiles', - ), 'tiles.paint' => $session->paintTiles( + self::readTileChoices($params), + ), + 'tiles.paint' => $session->paintTiles( self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireString($params, 'layer'), self::requireCells($params), self::requireInt($params, 'tile'), is_string($params['label'] ?? null) ? $params['label'] : 'Place tiles', + self::readTileChoices($params), ), 'map.paint' => $session->paint( self::requireString($params, 'map'), @@ -494,4 +497,22 @@ private static function readChoices(array $params): array return $choices; } + + /** + * The role key the author chose for each tile that could stand for + * several glyphs, by tile entry. A tile that stands for a glyph always + * takes one, so there is no answer for none. + * + * @param array $params + * @return array + */ + private static function readTileChoices(array $params): array + { + $choices = $params['choices'] ?? []; + if (! is_array($choices) || ! array_all($choices, static fn(mixed $choice): bool => is_string($choice))) { + throw new InvalidRequest('"choices" must map tiles to a role key.'); + } + + return array_combine(array_map(strval(...), array_keys($choices)), $choices); + } } diff --git a/tests/Unit/Session/TilesCarryGlyphsSessionTest.php b/tests/Unit/Session/TilesCarryGlyphsSessionTest.php new file mode 100644 index 0000000..5e294e6 --- /dev/null +++ b/tests/Unit/Session/TilesCarryGlyphsSessionTest.php @@ -0,0 +1,151 @@ + ['name' => 'Chest', 'layer' => 'buildings', 'glyphs' => ['m'], 'tiles' => ['furniture' => ['60']]], + // The sofa's back rises into the row above its seat. + 'sofa' => ['name' => 'Sofa', 'layer' => 'buildings', 'glyphs' => [' ', '##'], 'tiles' => ['furniture' => ['70 71', '72 73']]], + // A window draws one tile over two glyph cells. + 'window' => ['name' => 'Window', 'layer' => 'buildings', 'glyphs' => ['X', 'X'], 'tiles' => ['windows' => ['384', '0']]], + // Two pieces drawing the same tile. + 'stool' => ['name' => 'Stool', 'layer' => 'buildings', 'glyphs' => ['o'], 'tiles' => ['furniture' => ['80']]], + 'pot' => ['name' => 'Pot', 'layer' => 'buildings', 'glyphs' => ['p'], 'tiles' => ['furniture' => ['80']]], + 'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'connects' => 'lines', + 'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'], 'tiles' => ['walls' => '5888']], + // A rug has no glyph: its tiles stand for nothing in the terminal. + 'rug' => ['name' => 'Rug', 'layer' => 'buildings', 'glyphs' => [' '], 'tiles' => ['rug' => ['90']]], + ]); + + return EditorSession::open($root); +} + +/** @return list The buildings layer, row by row. */ +function readSessionBuildingRows(EditorSession $session): array +{ + $layer = array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] === 'map:4'); + + return array_map(static fn(array $row): string => implode('', $row), $layer['rows']); +} + +/** @return list> A tile layer's tiles, row by row; none where the map has no such layer. */ +function readSessionTiles(EditorSession $session, string $layer): array +{ + $found = array_find($session->readTiles('test-map')['layers'], static fn(array $candidate): bool => $candidate['name'] === $layer); + + return $found['rows'] ?? []; +} + +function stampSessionTiles(EditorSession $session, string $layer, array $cells, array $choices = []): array +{ + return $session->stampTiles('test-map', $session->readMap('test-map')['revision'], $layer, $cells, 'Place tiles', $choices); +} + +it('writes the glyph a placed tile stands for and removes it with the tile, each one undo step', function () { + $session = tilesCarryGlyphsSession(); + + expect(stampSessionTiles($session, 'furniture', [[0, 0, 60]]))->toMatchArray(['status' => 'applied', 'changed' => 1, 'glyphs' => 1]) + ->and(readSessionBuildingRows($session)[0])->toBe('m/ '); + + stampSessionTiles($session, 'furniture', [[0, 0, 0]]); + expect(readSessionBuildingRows($session)[0])->toBe(' / ') + ->and(readSessionTiles($session, 'furniture')[0][0])->toBe(0); + + $session->undo(); + expect(readSessionBuildingRows($session)[0])->toBe('m/ ') + ->and(readSessionTiles($session, 'furniture')[0][0])->toBe(60); + $session->undo(); + expect(readSessionBuildingRows($session)[0])->toBe(' / '); +}); + +it('moves a piece with its glyphs and every tile of it when one stamp erases it and places it again', function () { + $session = tilesCarryGlyphsSession(); + stampSessionTiles($session, 'furniture', [[0, 1, 72], [1, 1, 73]]); + // The seat's glyphs arrive with the back in the row above. + expect(readSessionBuildingRows($session))->toBe([' / ', '##x ']) + ->and(readSessionTiles($session, 'furniture'))->toBe([[70, 71, 0, 0], [72, 73, 0, 0]]); + + stampSessionTiles($session, 'furniture', [[0, 1, 0], [1, 1, 0], [2, 1, 72], [3, 1, 73]]); + expect(readSessionBuildingRows($session))->toBe([' / ', ' ##']) + ->and(readSessionTiles($session, 'furniture'))->toBe([[0, 0, 70, 71], [0, 0, 72, 73]]); + + // The whole move is one step. + $session->undo(); + expect(readSessionBuildingRows($session))->toBe([' / ', '##x ']) + ->and(readSessionTiles($session, 'furniture'))->toBe([[70, 71, 0, 0], [72, 73, 0, 0]]); +}); + +it('takes the glyph a covered tile stood for, with the rest of its tiles', function () { + $session = tilesCarryGlyphsSession(); + stampSessionTiles($session, 'furniture', [[0, 1, 72], [1, 1, 73]]); + + // Covering the sofa's back removes the seat glyph it belongs to and that seat's other tiles. + stampSessionTiles($session, 'furniture', [[0, 0, 60]]); + expect(readSessionBuildingRows($session))->toBe(['m/ ', ' #x ']) + ->and(readSessionTiles($session, 'furniture'))->toBe([[60, 71, 0, 0], [0, 73, 0, 0]]); +}); + +it('brings a piece\'s glyph cells that draw no tile with the one that does', function () { + $session = tilesCarryGlyphsSession(); + + stampSessionTiles($session, 'windows', [[3, 0, 384]]); + expect(readSessionBuildingRows($session))->toBe([' / X', ' xxX']); + + stampSessionTiles($session, 'windows', [[3, 0, 0]]); + expect(readSessionBuildingRows($session))->toBe([' / ', ' xx ']); +}); + +it('joins and leaves a wall, each wall cell taking the shape its neighbours give it', function () { + $session = tilesCarryGlyphsSession(); + + stampSessionTiles($session, 'walls', [[2, 0, 5888], [3, 0, 5888]]); + expect(readSessionBuildingRows($session)[0])->toBe(' /--'); + + stampSessionTiles($session, 'walls', [[3, 0, 0]]); + expect(readSessionBuildingRows($session)[0][3])->toBe(' ') + ->and(readSessionTiles($session, 'walls')[0])->toBe([0, 0, 5888, 0]); +}); + +it('leaves glyphs alone for a tile that stands for nothing', function () { + $session = tilesCarryGlyphsSession(); + + expect(stampSessionTiles($session, 'rug', [[0, 0, 90], [3, 1, 90]]))->toMatchArray(['changed' => 2, 'glyphs' => 0]) + ->and(readSessionBuildingRows($session))->toBe([' / ', ' xx ']); +}); + +it('asks which glyph a tile shared by several pieces stands for, changing nothing until answered', function () { + $session = tilesCarryGlyphsSession(); + + $asked = stampSessionTiles($session, 'furniture', [[0, 0, 80]]); + expect($asked['status'])->toBe('question') + ->and($asked['tile'])->toBe('80') + ->and(array_column($asked['roles'], 'label'))->toBe(['Stool', 'Pot']) + ->and(readSessionBuildingRows($session)[0])->toBe(' / ') + ->and($session->listUnsavedChanges())->toBe([]); + + stampSessionTiles($session, 'furniture', [[0, 0, 80]], ['80' => $asked['roles'][1]['key']]); + expect(readSessionBuildingRows($session)[0])->toBe('p/ '); +}); + +it('refuses a tile whose glyph would fall outside the map, changing nothing', function () { + $session = tilesCarryGlyphsSession(); + + // The sofa's back on the last row would need its seat below the map. + expect(fn() => stampSessionTiles($session, 'furniture', [[0, 1, 70]]))->toThrow(SessionRefusal::class, 'outside the map') + ->and(readSessionBuildingRows($session))->toBe([' / ', ' xx ']) + ->and($session->listUnsavedChanges())->toBe([]); +}); diff --git a/tests/Unit/TilesetPiecesTest.php b/tests/Unit/TilesetPiecesTest.php index 9563eab..f5104ed 100644 --- a/tests/Unit/TilesetPiecesTest.php +++ b/tests/Unit/TilesetPiecesTest.php @@ -731,3 +731,28 @@ function readTileEntries(string $source): array { return Ichiloto\Editor\Maps\TileLayerSource::readLayer($source, 'layer.tiles.php')->getEntries(); } + +it('takes a covered glyph\'s tiles with it when a wall or a piece is drawn over it', function () { + [$editor, $map] = createWallCanvasEditor(); + $tilesOf = static function (string $layer) use ($map): array { + $found = array_filter($map->getTileLayerSources(), static fn(string $path): bool => str_ends_with($path, ".{$layer}.tiles.php"), ARRAY_FILTER_USE_KEY); + + return $found === [] ? [] : readTileEntries((string) reset($found)); + }; + choosePieceByKeys($editor); + callEditorMethod($editor, 'dispatchInput', "\n"); + callEditorMethod($editor, 'dispatchInput', "\033"); + expect(readBuildingRows($map)[0])->toBe('= ') + ->and($tilesOf('decor')[0])->toBe(['5', '5', '0', '0', '0', '0']) + ->and($tilesOf('furniture')[0][0])->toBe('7'); + + // The wall covers the bed's top cell, which takes its own tiles and the + // blank cell's beside it; the bed's lower cells keep theirs. + chooseWallByKeys($editor); + drawWallThrough($editor, [0, 0], [2, 0]); + expect(readBuildingRows($map)[0])->toBe('--- ') + ->and($tilesOf('decor'))->toBe([['0', '0', '0', '0', '0', '0'], ['6', '0', '0', '0', '0', '0'], + ...array_fill(0, 3, array_fill(0, 6, '0'))]) + ->and($tilesOf('furniture')[0][0])->toBe('0') + ->and($tilesOf('furniture')[1][1])->toBe('8'); +}); From 0f1ebe328b39b22457ec28857ad3a682cb038f03 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 03:28:13 +0200 Subject: [PATCH 185/344] test: remove the parity fixtures' temporary projects after each run The battle entry parity actors directory now registers with the suite's temporary project cleanup, and the map BGM parity runner subprocess removes its project on shutdown, so neither leaves directories in the system temp folder. --- tests/Support/map-bgm-parity-runner.php | 10 ++++++++++ tests/Unit/BattleEntryParityTest.php | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/Support/map-bgm-parity-runner.php b/tests/Support/map-bgm-parity-runner.php index 4177f57..432acd1 100644 --- a/tests/Support/map-bgm-parity-runner.php +++ b/tests/Support/map-bgm-parity-runner.php @@ -48,6 +48,16 @@ // a real game. $projectRoot = sys_get_temp_dir() . '/ichiloto-bgm-parity-' . bin2hex(random_bytes(4)); mkdir($projectRoot . '/assets/Data', 0755, true); +// This subprocess has no suite to clean up after it: it removes its project +// itself when it ends, normally or not. +register_shutdown_function(static function () use ($projectRoot): void { + chdir(sys_get_temp_dir()); + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($projectRoot, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST) as $entry) { + $entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($projectRoot); +}); file_put_contents($projectRoot . '/assets/Data/items.php', <<<'PHP' [$name, $id]) { From 469a51258bb0a4014b7ba1e32a4bb9786196fe09 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 03:43:26 +0200 Subject: [PATCH 186/344] feat(session): edit summons and other cutscenes through the record RPCs Each cutscene type is a record category (cutscenes/summon, cutscenes/effect, cutscenes/cinematic) listed in the session hello and resolved by database.records, database.record, database.apply, database.add and database.remove. Changes go through CutsceneLibrary::changeAsset, which takes the records into the asset at once and undoes them by the asset's edit state; the TUI's cutscene edits now use the same method. Saving writes the type's changed assets with their paired files. --- src/Cutscenes/CutsceneLibrary.php | 51 +++++ src/Cutscenes/CutsceneRecordCategory.php | 188 ++++++++++++++++++ src/Cutscenes/CutsceneType.php | 15 ++ src/Cutscenes/Editing/CutscenesWorkspace.php | 35 ++-- src/Session/EditorSession.php | 25 +++ .../Session/CutsceneRecordSessionTest.php | 44 ++++ 6 files changed, 337 insertions(+), 21 deletions(-) create mode 100644 src/Cutscenes/CutsceneRecordCategory.php create mode 100644 tests/Unit/Session/CutsceneRecordSessionTest.php diff --git a/src/Cutscenes/CutsceneLibrary.php b/src/Cutscenes/CutsceneLibrary.php index 9b80d5b..ef03d60 100644 --- a/src/Cutscenes/CutsceneLibrary.php +++ b/src/Cutscenes/CutsceneLibrary.php @@ -6,6 +6,8 @@ use Closure; use Ichiloto\Editor\Database\ProjectRecordDatabase; +use Ichiloto\Editor\History\Command; +use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\Database\RecordSchema; use Ichiloto\Editor\Database\RecordSchemaCatalog; use RuntimeException; @@ -275,6 +277,55 @@ public function refreshRecords(CutsceneType $type): void $this->databases[$type->value] = $this->buildRecords($type); } + /** + * Changes one asset through its type's records, the one way every + * interface does: the change runs against the records, is taken into + * the asset at once, and comes back as the command that undoes and redoes + * it by restoring the asset's whole edit state, pinned to that asset + * whatever is selected when the history fires. Null when nothing changed. + * + * @template T + * @param int $index The asset's place among its type's records. + * @param callable(ProjectRecordDatabase, int): T $change The change, against the records and the record index. + * @param (Closure(CutsceneAsset): void)|null $restored Told when an undo or redo puts the asset back, for an interface to follow. + * @return array{command: ?Command, result: T} + * @throws RuntimeException When the asset is missing or read-only; nothing is changed. + * @throws Throwable Whatever the change throws; the records are rebuilt from the untouched asset. + */ + public function changeAsset(CutsceneType $type, int $index, string $label, callable $change, ?Closure $restored = null): array + { + $id = $this->ids($type)[$index] ?? throw new RuntimeException(sprintf('There is no %s %d.', $type->noun(), $index)); + $asset = $this->find($type, $id) ?? throw new RuntimeException(sprintf('There is no %s %s.', $type->noun(), $id)); + if (! $asset->isEditable()) { + throw new RuntimeException(sprintf('%s is read-only: %s.', ucfirst($type->noun()), $asset->readOnlyReason())); + } + $records = $this->records($type); + $before = $asset->captureEditState(); + try { + $result = $change($records, $index); + $records->save(); + } finally { + $this->refreshRecords($type); + } + $after = $asset->captureEditState(); + if ($after === $before) { + return ['command' => null, 'result' => $result]; + } + $restore = function (array $state) use ($type, $id, $restored): void { + $asset = $this->find($type, $id); + if ($asset === null) { + return; + } + $asset->restoreEditState($state); + $this->refreshRecords($type); + if ($restored !== null) { + $restored($asset); + } + }; + + return ['command' => new GenericCommand($label, static fn() => $restore($after), static fn() => $restore($before)), 'result' => $result]; + } + private function buildRecords(CutsceneType $type): ProjectRecordDatabase { $schema = $this->schemaFor($type); diff --git a/src/Cutscenes/CutsceneRecordCategory.php b/src/Cutscenes/CutsceneRecordCategory.php new file mode 100644 index 0000000..3a55ff0 --- /dev/null +++ b/src/Cutscenes/CutsceneRecordCategory.php @@ -0,0 +1,188 @@ +key = $type->getRecordCategory(); + } + + public function isEditable(): bool + { + return true; + } + + public function getReadOnlyReason(): ?string + { + return null; + } + + public function isDirty(): bool + { + return array_any($this->library->assets($this->type), static fn(CutsceneAsset $asset): bool => $asset->isDirty()); + } + + public function supportsRecordCreation(): bool + { + return false; + } + + public function supportsRecordDuplication(): bool + { + return false; + } + + public function supportsRecordDeletion(): bool + { + return false; + } + + public function supportsDurableReorder(): bool + { + return false; + } + + public function getRecordLabels(): array + { + return $this->read()->getRecordLabels(); + } + + public function getRecordRows(int $index, array $frame): array + { + return $this->read()->getRecordRows($index, $frame); + } + + public function describeFrame(array $frame): ?string + { + return $this->read()->describeFrame($frame); + } + + public function getListNoun(array $frame): ?string + { + return $this->read()->getListNoun($frame); + } + + public function locateItem(int $index, array $frame, string $fieldId): ?RecordItem + { + return $this->read()->locateItem($index, $frame, $fieldId); + } + + public function applyField(int $index, array $frame, string $fieldId, string $value, string $label): RecordChange + { + return $this->change($index, $label, static fn(RecordCategory $records, int $at): RecordChange + => $records->applyField($at, $frame, $fieldId, $value, $label)); + } + + public function addItem(int $index, array $frame, ?string $fieldId, bool $child): RecordChange + { + return $this->change($index, 'Add', static fn(RecordCategory $records, int $at): RecordChange + => $records->addItem($at, $frame, $fieldId, $child)); + } + + public function removeItem(int $index, array $frame, string $fieldId): RecordChange + { + return $this->change($index, 'Remove', static fn(RecordCategory $records, int $at): RecordChange + => $records->removeItem($at, $frame, $fieldId)); + } + + public function createRecord(?string $identity = null): RecordChange + { + throw $this->refuseRecordOperation('created'); + } + + public function duplicateRecord(int $index): RecordChange + { + throw $this->refuseRecordOperation('duplicated'); + } + + public function deleteRecord(int $index): RecordChange + { + throw $this->refuseRecordOperation('deleted'); + } + + public function moveRecord(int $index, int $step): RecordChange + { + throw $this->refuseRecordOperation('reordered'); + } + + public function getBackupPaths(): array + { + $paths = []; + foreach ($this->library->assets($this->type) as $asset) { + if ($asset->isDirty()) { + array_push($paths, ...array_filter([$asset->dataPath(), $asset->partnerPath()], is_file(...))); + } + } + + return $paths; + } + + public function save(): void + { + foreach ($this->library->assets($this->type) as $asset) { + if ($asset->isDirty()) { + $this->library->save($this->type, $asset->id); + } + } + } + + /** The type's records, read through the shared record rules. */ + private function read(): RecordCategory + { + return new RecordCategory($this->library->records($this->type)); + } + + /** + * Runs one record change through the library, which takes it into the + * asset and gives the step that undoes it. + * + * @param callable(RecordCategory, int): RecordChange $change + * @throws RecordRefusal When the asset is missing or read-only, or the change is refused; nothing is changed. + */ + private function change(int $index, string $label, callable $change): RecordChange + { + try { + $applied = $this->library->changeAsset($this->type, $index, $label, + static fn(ProjectRecordDatabase $records, int $at): RecordChange => $change(new RecordCategory($records), $at)); + } catch (RecordRefusal $refusal) { + throw $refusal; + } catch (RuntimeException $failure) { + throw new RecordRefusal($failure->getMessage(), previous: $failure); + } + /** @var RecordChange $made */ + $made = $applied['result']; + + return new RecordChange($applied['command'], $made->index, $made->removed, $made->note); + } + + private function refuseRecordOperation(string $done): Throwable + { + return new RecordRefusal(sprintf('A %s is %s from the Cutscenes workspace, which names its folder first.', $this->type->noun(), $done)); + } +} diff --git a/src/Cutscenes/CutsceneType.php b/src/Cutscenes/CutsceneType.php index 2419450..6af87cb 100644 --- a/src/Cutscenes/CutsceneType.php +++ b/src/Cutscenes/CutsceneType.php @@ -60,6 +60,21 @@ public function partnerNoun(): string }; } + /** + * The record category an interface edits this type's assets through, as + * it edits a Database category: `cutscenes/summon` for summons. + */ + public function getRecordCategory(): string + { + return 'cutscenes/' . $this->value; + } + + /** The type a record category key names, or null for a key that names no cutscene type. */ + public static function findByRecordCategory(string $category): ?self + { + return str_starts_with($category, 'cutscenes/') ? self::tryFrom(substr($category, strlen('cutscenes/'))) : null; + } + /** * Returns the singular noun used in status messages. */ diff --git a/src/Cutscenes/Editing/CutscenesWorkspace.php b/src/Cutscenes/Editing/CutscenesWorkspace.php index 1f23206..249522f 100644 --- a/src/Cutscenes/Editing/CutscenesWorkspace.php +++ b/src/Cutscenes/Editing/CutscenesWorkspace.php @@ -1093,9 +1093,8 @@ private function mutateSelectedCutscene(string $label, callable $mutation): bool { $library = $this->cutsceneLibrary(); $asset = $this->selectedCutscene(); - $records = $this->cutsceneRecords(); - if ($library === null || $asset === null || $records === null) { + if ($library === null || $asset === null) { return false; } @@ -1106,33 +1105,22 @@ private function mutateSelectedCutscene(string $label, callable $mutation): bool return false; } - $type = $asset->type; - $id = $asset->id; - $before = $this->snapshotCutscene($asset); - try { - $mutation($records, $this->getSelectedRecordIndex()); - $records->save(); + // The library owns the change: records, the asset and the undo step that restores it. + $command = $library->changeAsset($asset->type, $this->getSelectedRecordIndex(), $label, $mutation, + fn(CutsceneAsset $restored) => $this->followRestoredCutscene($restored))['command']; } catch (Throwable $throwable) { - $library->refreshRecords($type); $this->setErrorStatus($throwable, $label); $this->renderCutscenesArea(); return false; } - $library->refreshRecords($type); - $after = $this->snapshotCutscene($asset); - - if ($after === $before) { + if ($command === null) { return false; } - $this->recordCommand(new GenericCommand( - $label, - fn() => $this->restoreCutsceneSnapshot($type, $id, $after), - fn() => $this->restoreCutsceneSnapshot($type, $id, $before), - )); + $this->recordCommand($command); return true; } @@ -1219,14 +1207,19 @@ private function restoreCutsceneSnapshot(CutsceneType $type, string $id, array $ $asset->restoreEditState($snapshot); $library->refreshRecords($type); + $this->followRestoredCutscene($asset); + } + /** Puts the screen onto an asset an undo or redo just restored, when the workspace is open. */ + private function followRestoredCutscene(CutsceneAsset $asset): void + { if ($this->isCutscenesOpen) { - if ($this->cutsceneType !== $type) { - $this->switchCutsceneType($type); + if ($this->cutsceneType !== $asset->type) { + $this->switchCutsceneType($asset->type); } $this->clampCutsceneSelection(); - $this->selectCutsceneById($id); + $this->selectCutsceneById($asset->id); $this->clampDatabaseSettingSelection(); $this->renderCutscenesArea(); } diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index e01ecf8..543a229 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -7,6 +7,8 @@ use Ichiloto\Editor\Backup\BackupSettings; use Ichiloto\Editor\Backup\BackupWriter; use Ichiloto\Editor\Canvas\CanvasEditor; +use Ichiloto\Editor\Cutscenes\CutsceneRecordCategory; +use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\Canvas\PieceRole; use Ichiloto\Editor\Database\ConditionCodec; @@ -150,6 +152,12 @@ public function describeProject(): array 'description' => $category->description, 'implemented' => $category->isImplemented, ], DatabaseCatalog::all()), + // Cutscene types, edited through the same record RPCs under their record category keys. + 'cutscenes' => array_map(static fn(CutsceneType $type): array => [ + 'key' => $type->getRecordCategory(), + 'label' => $type->label(), + 'description' => $type->describeCategory(), + ], CutsceneType::cases()), ]; } @@ -2771,9 +2779,15 @@ private function traverseHistory(callable $step): array /** @return array Each category the session edits, by key, with its content version. */ private function listDatabaseVersions(): array { + $cutscenes = []; + foreach ($this->workspace->cutscenes === null ? [] : CutsceneType::cases() as $type) { + $cutscenes[$type->getRecordCategory()] = $this->workspace->cutscenes->records($type)->getContentVersion(); + } + return [ 'actors' => $this->workspace->actorDatabase->getContentVersion(), ...array_map(static fn(ProjectRecordDatabase $database): string => $database->getContentVersion(), $this->workspace->recordDatabases), + ...$cutscenes, ]; } @@ -2913,6 +2927,12 @@ private static function describeKey(array $field): ?array /** @throws SessionRefusal */ private function requireRecordDatabase(string $category): ProjectRecordDatabase { + // A cutscene type's assets are records too, edited through the library's own record category. + $cutscene = CutsceneType::findByRecordCategory($category); + if ($cutscene !== null) { + return $this->workspace->cutscenes?->records($cutscene) + ?? throw new SessionRefusal(sprintf('This project has no %s assets to edit.', $cutscene->noun())); + } if (! DatabaseCatalog::knows($category)) { throw new SessionRefusal(sprintf('There is no database category %s.', $category)); } @@ -2933,6 +2953,11 @@ private function requireCategory(string $category): DatabaseCategory if ($category === 'actors') { return new ActorCategory($this->workspace, $this->actorAuthoring); } + $cutscene = CutsceneType::findByRecordCategory($category); + if ($cutscene !== null) { + return new CutsceneRecordCategory($this->workspace->cutscenes + ?? throw new SessionRefusal(sprintf('This project has no %s assets to edit.', $cutscene->noun())), $cutscene); + } return new RecordCategory($this->requireRecordDatabase($category)); } diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php new file mode 100644 index 0000000..5e16f5f --- /dev/null +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -0,0 +1,44 @@ +describeProject(); + + expect(array_column($hello['cutscenes'], 'key'))->toBe(['cutscenes/cinematic', 'cutscenes/summon', 'cutscenes/effect']) + ->and(array_column($hello['cutscenes'], 'label'))->toBe(['Cinematic', 'Summon', 'Effect']); +}); + +it('reads a summon\'s fields and its tracks, keyframes and cues as frames of its record', function () { + $session = EditorSession::open(cutsceneProject()); + $records = $session->listDatabaseRecords('cutscenes/summon'); + $record = $session->readDatabaseRecord('cutscenes/summon', 0); + + expect($records['records'])->toHaveCount(1) + ->and(array_column($record['rows'], 'label'))->toContain('Name'); +}); + +it('edits a summon field as one undo step and saves it to its data file', function () { + $root = cutsceneProject(); + $session = EditorSession::open($root); + $name = array_find($session->readDatabaseRecord('cutscenes/summon', 0)['rows'], static fn(array $row): bool => trim($row['label']) === 'Name'); + $file = $root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.data.php'; + $before = (string) file_get_contents($file); + + expect($session->applyDatabaseRecord('cutscenes/summon', 0, $name['key'], 'Lantern Wraith')['changed'])->toBeTrue() + ->and($session->listUnsavedChanges())->not->toBe([]); + $session->undo(); + expect(array_find($session->readDatabaseRecord('cutscenes/summon', 0)['rows'], static fn(array $row): bool => trim($row['label']) === 'Name')['value']) + ->toBe($name['value']); + $session->redo(); + $session->saveAll(); + + expect((string) file_get_contents($file))->toContain("'Lantern Wraith'") + ->and($session->listUnsavedChanges())->toBe([]) + ->and(fn() => $session->readDatabaseRecord('cutscenes/nothing', 0))->toThrow(SessionRefusal::class); +}); From cc0422615fdf510273f8de184a21cf17031fca2c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 03:45:33 +0200 Subject: [PATCH 187/344] feat(session): group the hello's categories as Database and Cutscenes Each category the hello lists names its group, so an interface lists cutscene types under their own heading; a summon category saves through database.save like any other. --- src/Session/EditorSession.php | 3 +++ tests/Unit/Session/CutsceneRecordSessionTest.php | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 543a229..e83bc3c 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -151,12 +151,15 @@ public function describeProject(): array 'label' => $category->label, 'description' => $category->description, 'implemented' => $category->isImplemented, + 'group' => 'Database', ], DatabaseCatalog::all()), // Cutscene types, edited through the same record RPCs under their record category keys. 'cutscenes' => array_map(static fn(CutsceneType $type): array => [ 'key' => $type->getRecordCategory(), 'label' => $type->label(), 'description' => $type->describeCategory(), + 'implemented' => true, + 'group' => 'Cutscenes', ], CutsceneType::cases()), ]; } diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php index 5e16f5f..3df1d90 100644 --- a/tests/Unit/Session/CutsceneRecordSessionTest.php +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -11,7 +11,9 @@ $hello = EditorSession::open(cutsceneProject())->describeProject(); expect(array_column($hello['cutscenes'], 'key'))->toBe(['cutscenes/cinematic', 'cutscenes/summon', 'cutscenes/effect']) - ->and(array_column($hello['cutscenes'], 'label'))->toBe(['Cinematic', 'Summon', 'Effect']); + ->and(array_column($hello['cutscenes'], 'label'))->toBe(['Cinematic', 'Summon', 'Effect']) + ->and(array_unique(array_column($hello['cutscenes'], 'group')))->toBe(['Cutscenes']) + ->and(array_unique(array_column($hello['databases'], 'group')))->toBe(['Database']); }); it('reads a summon\'s fields and its tracks, keyframes and cues as frames of its record', function () { @@ -42,3 +44,15 @@ ->and($session->listUnsavedChanges())->toBe([]) ->and(fn() => $session->readDatabaseRecord('cutscenes/nothing', 0))->toThrow(SessionRefusal::class); }); + +it('saves a summon category as the Database window saves any category', function () { + $root = cutsceneProject(); + $session = EditorSession::open($root); + $name = array_find($session->readDatabaseRecord('cutscenes/summon', 0)['rows'], static fn(array $row): bool => trim($row['label']) === 'Name'); + $session->applyDatabaseRecord('cutscenes/summon', 0, $name['key'], 'Lantern Wraith'); + + expect($session->listDatabaseRecords('cutscenes/summon'))->toMatchArray(['dirty' => true, 'canCreate' => false]) + ->and($session->saveDatabase('cutscenes/summon')['saved'])->toBeTrue() + ->and((string) file_get_contents($root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.data.php'))->toContain("'Lantern Wraith'") + ->and($session->listUnsavedChanges())->toBe([]); +}); From 1ba8a1532329f4ff7259a65cd21e86d6fe2ac91c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 03:48:05 +0200 Subject: [PATCH 188/344] feat(session): read reference choices in the context they are edited in references.list takes the map only when one gives the context, and the record being edited as {category, index}: a project's own references (images, skills) need no open map, and a summon's own cues are read from the summon record named. --- src/Session/EditorSession.php | 21 +++++++++++++++++-- src/Session/SessionHost.php | 6 +++++- .../Session/CutsceneRecordSessionTest.php | 13 ++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index e83bc3c..655a98f 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1196,12 +1196,29 @@ public function encodeAffinities(array $entries): array ]; } - public function listReferences(string $mapId, string $category): array + /** + * The choices a reference row offers, read in the context it is edited + * in: the project's own references need no map, a map's own (its events, + * regions) are read from the map named, and a cutscene's own (a summon's + * cues) from the cutscene record named, as `{category, index}`. + * + * @param array{category?: mixed, index?: mixed}|null $record The record being edited, when it gives the context. + * @return list + * @throws SessionRefusal When the category, map or record is unknown. + */ + public function listReferences(?string $mapId, string $category, ?array $record = null): array { if (! ReferenceCatalog::knows($category)) { throw new SessionRefusal(sprintf('There is no reference category %s.', $category)); } - $catalog = new ReferenceCatalog($this->workspace, $this->requireMap($mapId)); + $cutscene = null; + $type = is_string($record['category'] ?? null) ? CutsceneType::findByRecordCategory($record['category']) : null; + if ($type !== null) { + $id = $this->workspace->cutscenes?->ids($type)[(int) ($record['index'] ?? -1)] ?? null; + $cutscene = ($id === null ? null : $this->workspace->cutscenes?->find($type, $id)) + ?? throw new SessionRefusal(sprintf('There is no %s %s.', $type->noun(), strval($record['index'] ?? ''))); + } + $catalog = new ReferenceCatalog($this->workspace, $mapId === null ? null : $this->requireMap($mapId), $cutscene); $labels = $catalog->labelsFor($category); return array_map(static fn(mixed $value): array => [ diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 7d62feb..c8a33da 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -329,7 +329,11 @@ private function dispatch(string $method, array $params): array in_array($params['direction'] ?? null, ['up', 'down'], true) ? $params['direction'] : throw new InvalidRequest('"direction" must be "up" or "down".'), ), 'database.save' => $session->saveDatabase(self::requireString($params, 'category')), - 'references.list' => $session->listReferences(self::requireString($params, 'map'), self::requireString($params, 'category')), + 'references.list' => $session->listReferences( + is_string($params['map'] ?? null) ? $params['map'] : null, + self::requireString($params, 'category'), + is_array($params['record'] ?? null) ? $params['record'] : null, + ), 'conditions.grammar' => $session->describeWorldStateGrammar(), 'conditions.encode' => $session->encodeWorldState( self::requireString($params, 'codec'), diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php index 3df1d90..54e8e1d 100644 --- a/tests/Unit/Session/CutsceneRecordSessionTest.php +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -56,3 +56,16 @@ ->and((string) file_get_contents($root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.data.php'))->toContain("'Lantern Wraith'") ->and($session->listUnsavedChanges())->toBe([]); }); + +it('lists a project reference without a map, and a summon\'s own cues from the summon being edited', function () { + $root = cutsceneProject(); + mkdir($root . '/assets/Graphics/Effects', 0o777, true); + writeTilesetTestPng($root . '/assets/Graphics/Effects/Spark.png', 8, 8); + $session = EditorSession::open($root); + $cues = $session->listReferences(null, 'summon_cues', ['category' => 'cutscenes/summon', 'index' => 0]); + + expect(array_column($session->listReferences(null, 'png_assets'), 'value'))->toContain('Graphics/Effects/Spark.png') + ->and($cues)->not->toBe([]) + ->and(fn() => $session->listReferences(null, 'summon_cues', ['category' => 'cutscenes/summon', 'index' => 9])) + ->toThrow(SessionRefusal::class); +}); From f153b717656591cabc6f45f509443ee3f49dee5a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 03:50:41 +0200 Subject: [PATCH 189/344] feat(session): describe a summon or effect timeline and preview its frames cutscenes.timeline lays out a summon's or effect's fps, length, tracks with their keyframes' frames and durations, and cues, each with the record row key that edits it through database.apply. cutscenes.preview draws one frame as the terminal presentation does, unsaved edits included, with the cues on it. --- src/Session/EditorSession.php | 86 +++++++++++++++++++ src/Session/SessionHost.php | 3 + .../Session/CutsceneRecordSessionTest.php | 26 ++++++ 3 files changed, 115 insertions(+) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 655a98f..32bba99 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -7,14 +7,17 @@ use Ichiloto\Editor\Backup\BackupSettings; use Ichiloto\Editor\Backup\BackupWriter; use Ichiloto\Editor\Canvas\CanvasEditor; +use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Cutscenes\CutsceneRecordCategory; use Ichiloto\Editor\Cutscenes\CutsceneType; +use Ichiloto\Editor\Cutscenes\Preview\TimelinePreviewSession; use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\Canvas\PieceRole; use Ichiloto\Editor\Database\ConditionCodec; use Ichiloto\Editor\Database\ConditionEditor; use Ichiloto\Editor\Animations\LegacyAnimationConversion; use Ichiloto\Editor\Database\DatabaseCatalog; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Animations\Timelines\EffectCadence; use Ichiloto\Editor\Database\ElementAffinityCodec; use Ichiloto\Editor\Database\ProjectRecordDatabase; @@ -79,6 +82,7 @@ use Closure; use InvalidArgumentException; use RuntimeException; +use Throwable; use WeakReference; /** @@ -1196,6 +1200,88 @@ public function encodeAffinities(array $entries): array ]; } + /** + * A summon's or effect's timeline as a timeline editor lays it out: its + * frames per second and length, each track with its keyframes' frames and + * durations, and the cues, each value with the record row key that edits + * it through `database.apply`, as the record grid edits it. A cinematic is + * a command tree, not a timeline. + * + * @return array{fps: int, lengthFrames: int, tracks: list>, cues: list>} + * @throws SessionRefusal When the record is not a summon or effect the project has. + */ + public function describeCutsceneTimeline(string $category, int $index): array + { + $asset = $this->requireTimelineCutscene($category, $index); + $tracks = $cues = []; + foreach ($this->readDatabaseRecord($category, $index, ['tracks'])['rows'] as $row) { + $field = (string) ($row['key']['field'] ?? ''); + if (preg_match('/^track(\d+)(Id|Type|Presentation)$/', $field, $match) === 1) { + $tracks[(int) $match[1]][strtolower($match[2])] = (string) $row['value']; + } elseif (preg_match('/^track(\d+)Keyframe(\d+)(Frame|Duration)$/', $field, $match) === 1) { + $tracks[(int) $match[1]]['keyframes'][(int) $match[2]][strtolower($match[3])] = (int) $row['value']; + $tracks[(int) $match[1]]['keyframes'][(int) $match[2]][strtolower($match[3]) . 'Key'] = $row['key']; + } + } + foreach ($this->readDatabaseRecord($category, $index, ['cues'])['rows'] as $row) { + if (preg_match('/^cue(\d+)(Id|Frame|Type)$/', (string) ($row['key']['field'] ?? ''), $match) === 1) { + $value = $match[2] === 'Frame' ? (int) $row['value'] : (string) $row['value']; + $cues[(int) $match[1]][strtolower($match[2])] = $value; + if ($match[2] === 'Frame') { + $cues[(int) $match[1]]['frameKey'] = $row['key']; + } + } + } + $payload = $asset->payload(); + + return [ + 'fps' => (int) ($payload['fps'] ?? 0), + 'lengthFrames' => (int) ($payload['lengthFrames'] ?? 0), + 'tracks' => array_values(array_map(static fn(array $track): array => [...$track, + 'keyframes' => array_values($track['keyframes'] ?? [])], $tracks)), + 'cues' => array_values($cues), + ]; + } + + /** + * One frame of a summon or effect as its terminal presentation draws it, + * from the record as edited, unsaved changes included, with the cues on + * that frame: what a timeline editor shows at its playhead. + * + * @return array{frame: int, totalFrames: int, fps: int, lines: list, cues: list>} + * @throws SessionRefusal When the record is not a summon or effect, or its timeline does not compile. + */ + public function readCutscenePreview(string $category, int $index, int $frame, int $width, int $height): array + { + $asset = $this->requireTimelineCutscene($category, $index); + try { + $compiled = $asset->type === CutsceneType::EFFECT + ? $asset->compiledEffect(EffectPresentation::TERMINAL, false) + : $asset->compiledSummon(); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s does not compile: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + $preview = new TimelinePreviewSession($compiled, $asset->type === CutsceneType::EFFECT ? null : false); + $frame = max(0, min($frame, $preview->totalFrames() - 1)); + + return ['frame' => $frame, 'totalFrames' => $preview->totalFrames(), 'fps' => $preview->fps(), + 'lines' => array_values($preview->frame(max(1, $width), max(1, $height), $frame)), + 'cues' => array_values($preview->cuesAt($frame))]; + } + + /** A summon or effect record's asset; a cinematic is a command tree, not a timeline. */ + private function requireTimelineCutscene(string $category, int $index): CutsceneAsset + { + $type = CutsceneType::findByRecordCategory($category); + if ($type === null || $type === CutsceneType::CINEMATIC) { + throw new SessionRefusal(sprintf('%s is not a summon or effect category.', $category)); + } + $id = $this->workspace->cutscenes?->ids($type)[$index] ?? null; + + return ($id === null ? null : $this->workspace->cutscenes?->find($type, $id)) + ?? throw new SessionRefusal(sprintf('There is no %s %d.', $type->noun(), $index)); + } + /** * The choices a reference row offers, read in the context it is edited * in: the project's own references need no map, a map's own (its events, diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index c8a33da..59dcc0c 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -329,6 +329,9 @@ private function dispatch(string $method, array $params): array in_array($params['direction'] ?? null, ['up', 'down'], true) ? $params['direction'] : throw new InvalidRequest('"direction" must be "up" or "down".'), ), 'database.save' => $session->saveDatabase(self::requireString($params, 'category')), + 'cutscenes.timeline' => $session->describeCutsceneTimeline(self::requireString($params, 'category'), self::requireInt($params, 'index')), + 'cutscenes.preview' => $session->readCutscenePreview(self::requireString($params, 'category'), self::requireInt($params, 'index'), + self::requireInt($params, 'frame'), self::requireInt($params, 'width'), self::requireInt($params, 'height')), 'references.list' => $session->listReferences( is_string($params['map'] ?? null) ? $params['map'] : null, self::requireString($params, 'category'), diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php index 54e8e1d..6d44259 100644 --- a/tests/Unit/Session/CutsceneRecordSessionTest.php +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -69,3 +69,29 @@ ->and(fn() => $session->listReferences(null, 'summon_cues', ['category' => 'cutscenes/summon', 'index' => 9])) ->toThrow(SessionRefusal::class); }); + +it('describes a summon timeline with the row keys that edit it, and moves a keyframe as one undo step', function () { + $session = EditorSession::open(cutsceneProject()); + $timeline = $session->describeCutsceneTimeline('cutscenes/summon', 0); + $keyframe = $timeline['tracks'][0]['keyframes'][0]; + + expect($timeline['fps'])->toBeGreaterThan(0) + ->and($timeline['lengthFrames'])->toBeGreaterThan(0) + ->and($timeline['tracks'])->not->toBe([]) + ->and($keyframe)->toHaveKeys(['frame', 'duration', 'frameKey', 'durationKey']); + + $session->applyDatabaseRecord('cutscenes/summon', 0, $keyframe['frameKey'], (string) ($keyframe['frame'] + 1)); + expect($session->describeCutsceneTimeline('cutscenes/summon', 0)['tracks'][0]['keyframes'][0]['frame'])->toBe($keyframe['frame'] + 1); + $session->undo(); + expect($session->describeCutsceneTimeline('cutscenes/summon', 0)['tracks'][0]['keyframes'][0]['frame'])->toBe($keyframe['frame']) + ->and(fn() => $session->describeCutsceneTimeline('cutscenes/cinematic', 0))->toThrow(SessionRefusal::class); +}); + +it('previews a summon frame as its terminal presentation draws it, clamped to the timeline', function () { + $session = EditorSession::open(cutsceneProject()); + $preview = $session->readCutscenePreview('cutscenes/summon', 0, 9999, 40, 10); + + expect($preview['frame'])->toBe($preview['totalFrames'] - 1) + ->and($preview['lines'])->toHaveCount(10) + ->and(mb_strlen($preview['lines'][0]))->toBe(40); +}); From d47e900b71e8a18e4151e61a7117e4e138fcc0c2 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 04:15:23 +0200 Subject: [PATCH 190/344] feat(session): describe image track art and switch an effect's sequence cutscenes.timeline describes each image track's art (its image, sheet columns and rows, the image's own size read from the file, its attachment with its choices and its pivot, with the keys that edit them) and each image keyframe's sheet cell, and names the sequence an effect with separate ones shows. cutscenes.presentation switches that sequence, as the TUI's switch does, writing nothing. --- src/Session/EditorSession.php | 65 +++++++++++++++++-- src/Session/SessionHost.php | 2 + .../Session/CutsceneRecordSessionTest.php | 18 +++++ 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 32bba99..314625e 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1218,9 +1218,22 @@ public function describeCutsceneTimeline(string $category, int $index): array $field = (string) ($row['key']['field'] ?? ''); if (preg_match('/^track(\d+)(Id|Type|Presentation)$/', $field, $match) === 1) { $tracks[(int) $match[1]][strtolower($match[2])] = (string) $row['value']; - } elseif (preg_match('/^track(\d+)Keyframe(\d+)(Frame|Duration)$/', $field, $match) === 1) { - $tracks[(int) $match[1]]['keyframes'][(int) $match[2]][strtolower($match[3])] = (int) $row['value']; - $tracks[(int) $match[1]]['keyframes'][(int) $match[2]][strtolower($match[3]) . 'Key'] = $row['key']; + } elseif (preg_match('/^track(\d+)(Asset|Sheetcolumns|Sheetrows|Attachment|Pivot)$/', $field, $match) === 1) { + // An image track's art, and the keys that edit where it sits on its anchor. + $art = ['Asset' => 'asset', 'Sheetcolumns' => 'columns', 'Sheetrows' => 'rows', 'Attachment' => 'attachment', 'Pivot' => 'pivot'][$match[2]]; + $tracks[(int) $match[1]]['art'][$art] = in_array($art, ['columns', 'rows'], true) ? max(1, (int) $row['value']) : (string) ($row['raw'] ?? $row['value']); + if (in_array($art, ['attachment', 'pivot'], true)) { + $tracks[(int) $match[1]]['art'][$art . 'Key'] = $row['key']; + } + if ($art === 'attachment') { + $tracks[(int) $match[1]]['art']['attachmentOptions'] = array_values(array_filter($row['options'] ?? [], static fn(mixed $option): bool => $option !== '')); + } + } elseif (preg_match('/^track(\d+)Keyframe(\d+)(Frame|Duration|SourceFrame)$/', $field, $match) === 1) { + $name = lcfirst($match[3]); + $tracks[(int) $match[1]]['keyframes'][(int) $match[2]][$name] = (int) $row['value']; + if ($name !== 'sourceFrame') { + $tracks[(int) $match[1]]['keyframes'][(int) $match[2]][$name . 'Key'] = $row['key']; + } } } foreach ($this->readDatabaseRecord($category, $index, ['cues'])['rows'] as $row) { @@ -1235,10 +1248,13 @@ public function describeCutsceneTimeline(string $category, int $index): array $payload = $asset->payload(); return [ + // An effect with separate terminal and graphical sequences names the one shown; others name none. + 'presentation' => $asset->getPresentationView()?->value, 'fps' => (int) ($payload['fps'] ?? 0), 'lengthFrames' => (int) ($payload['lengthFrames'] ?? 0), - 'tracks' => array_values(array_map(static fn(array $track): array => [...$track, - 'keyframes' => array_values($track['keyframes'] ?? [])], $tracks)), + 'tracks' => array_values(array_map(fn(array $track): array => [...$track, + 'keyframes' => array_values($track['keyframes'] ?? []), + ...(isset($track['art']) ? ['art' => $this->measureTrackArt($track['art'])] : [])], $tracks)), 'cues' => array_values($cues), ]; } @@ -1269,6 +1285,45 @@ public function readCutscenePreview(string $category, int $index, int $frame, in 'cues' => array_values($preview->cuesAt($frame))]; } + /** + * An image track's art with the image's own size, read from the file the + * track names inside the asset root; none for a file that is missing or + * not an image, which the track's own validation reports. + * + * @param array $art + * @return array + */ + private function measureTrackArt(array $art): array + { + $root = realpath($this->workspace->projectRoot . '/assets'); + $path = $root === false || ($art['asset'] ?? '') === '' ? false : realpath($root . '/' . $art['asset']); + $size = $path !== false && str_starts_with($path, $root . DIRECTORY_SEPARATOR) ? @getimagesize($path) : false; + + return [...$art, 'width' => $size === false ? null : $size[0], 'height' => $size === false ? null : $size[1]]; + } + + /** + * Chooses which of an effect's sequences its record shows and edits, + * terminal or graphical, as the TUI's sequence switch does. Nothing is + * written; the other sequence stays as it is. + * + * @return array{presentation: string} + * @throws SessionRefusal When the record is not an effect with separate sequences, or the sequence is unknown. + */ + public function selectCutscenePresentation(string $category, int $index, string $presentation): array + { + $asset = $this->requireTimelineCutscene($category, $index); + $chosen = EffectPresentation::tryFrom($presentation) + ?? throw new SessionRefusal(sprintf('An effect sequence is terminal or graphical, not %s.', $presentation)); + if (! $asset->hasPresentations()) { + throw new SessionRefusal(sprintf('%s has one sequence for every renderer.', $asset->id)); + } + $asset->selectPresentation($chosen); + $this->workspace->cutscenes?->refreshRecords($asset->type); + + return ['presentation' => $chosen->value]; + } + /** A summon or effect record's asset; a cinematic is a command tree, not a timeline. */ private function requireTimelineCutscene(string $category, int $index): CutsceneAsset { diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 59dcc0c..990c93c 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -330,6 +330,8 @@ private function dispatch(string $method, array $params): array ), 'database.save' => $session->saveDatabase(self::requireString($params, 'category')), 'cutscenes.timeline' => $session->describeCutsceneTimeline(self::requireString($params, 'category'), self::requireInt($params, 'index')), + 'cutscenes.presentation' => $session->selectCutscenePresentation(self::requireString($params, 'category'), + self::requireInt($params, 'index'), self::requireString($params, 'presentation')), 'cutscenes.preview' => $session->readCutscenePreview(self::requireString($params, 'category'), self::requireInt($params, 'index'), self::requireInt($params, 'frame'), self::requireInt($params, 'width'), self::requireInt($params, 'height')), 'references.list' => $session->listReferences( diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php index 6d44259..f13d221 100644 --- a/tests/Unit/Session/CutsceneRecordSessionTest.php +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -95,3 +95,21 @@ ->and($preview['lines'])->toHaveCount(10) ->and(mb_strlen($preview['lines'][0]))->toBe(40); }); + +it('shows an effect\'s graphical sequence with its image track\'s art, and sets the pivot as one undo step', function () { + $session = EditorSession::open(effectProject()); + $index = array_search('dusk-slash', $session->listDatabaseRecords('cutscenes/effect')['records'], true); + + expect($session->describeCutsceneTimeline('cutscenes/effect', $index)['presentation'])->toBe('terminal') + ->and($session->selectCutscenePresentation('cutscenes/effect', $index, 'graphical'))->toBe(['presentation' => 'graphical']); + $art = $session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']; + expect($art)->toMatchArray(['asset' => 'Graphics/Effects/dusk-slash.png', 'columns' => 2, 'rows' => 1, 'width' => 32, 'height' => 16]) + ->and($art['attachmentOptions'])->toBe(['center', 'head', 'ground']); + + $session->applyDatabaseRecord('cutscenes/effect', $index, $art['pivotKey'], '0.25, 1'); + expect($session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']['pivot'])->toBe('0.25, 1'); + $session->undo(); + expect($session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']['pivot'])->toBe($art['pivot']) + ->and(fn() => $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical'))->toThrow(SessionRefusal::class) + ->and(fn() => $session->selectCutscenePresentation('cutscenes/effect', $index, 'holographic'))->toThrow(SessionRefusal::class); +}); From 5c6afca7f0b6844efb931c8cb5f059b26661d11e Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 04:22:11 +0200 Subject: [PATCH 191/344] feat(session): create, duplicate and delete summons and other cutscenes through the record RPCs CutsceneLibrary::createAsset and duplicateAsset make an asset under a free, safe id and return the step that undoes and redoes it; the TUI's create and duplicate now use them. Cutscene record categories offer database.create (with the new id as its identity), database.duplicate and database.delete, each one undo step; folders follow on save. --- src/Cutscenes/CutsceneLibrary.php | 58 ++++++++++++++++++- src/Cutscenes/CutsceneRecordCategory.php | 42 +++++++++++--- src/Cutscenes/Editing/CutscenesWorkspace.php | 29 +++------- .../Session/CutsceneRecordSessionTest.php | 2 +- 4 files changed, 99 insertions(+), 32 deletions(-) diff --git a/src/Cutscenes/CutsceneLibrary.php b/src/Cutscenes/CutsceneLibrary.php index ef03d60..18e755b 100644 --- a/src/Cutscenes/CutsceneLibrary.php +++ b/src/Cutscenes/CutsceneLibrary.php @@ -311,6 +311,62 @@ public function changeAsset(CutsceneType $type, int $index, string $label, calla if ($after === $before) { return ['command' => null, 'result' => $result]; } + return ['command' => $this->createRestoringCommand($label, $type, $id, $before, $after, $restored), 'result' => $result]; + } + + /** + * Creates a new asset of a type from its schema's blank, under the + * preferred id made free and safe (or the blank's own), and returns it + * with the command that undoes and redoes its creation. It reaches disk + * on save. + * + * @param (Closure(CutsceneAsset): void)|null $restored Told when an undo or redo puts the asset back. + * @return array{asset: CutsceneAsset, command: Command} + */ + public function createAsset(CutsceneType $type, ?string $preferredId = null, ?Closure $restored = null): array + { + $blank = $this->records($type)->schema->blank; + $id = $this->freeId($type, $preferredId ?? strval($blank['id'] ?? ('new-' . $type->noun()))); + $created = CutsceneAsset::create($type, $id, $this->rootFor($type), [...$blank, 'id' => $id], $this->projectRoot); + $this->adopt($created); + + return ['asset' => $created, 'command' => $this->createPresenceCommand(sprintf('Create %s', $type->noun()), $created, $restored)]; + } + + /** + * Duplicates an asset under the preferred id made free and safe (or the + * original's with `-copy`), and returns the copy with the command that + * undoes and redoes the duplication. It reaches disk on save. + * + * @param (Closure(CutsceneAsset): void)|null $restored Told when an undo or redo puts the copy back. + * @return array{asset: CutsceneAsset, command: Command} + * @throws RuntimeException When the asset is missing. + */ + public function duplicateAsset(CutsceneType $type, string $id, ?string $preferredId = null, ?Closure $restored = null): array + { + $copy = $this->duplicate($type, $id, $this->freeId($type, $preferredId ?? $id . '-copy')); + + return ['asset' => $copy, 'command' => $this->createPresenceCommand(sprintf('Duplicate %s', $type->noun()), $copy, $restored)]; + } + + /** The command that takes a just-made asset away on undo and brings it back on redo. */ + private function createPresenceCommand(string $label, CutsceneAsset $asset, ?Closure $restored): Command + { + $state = $asset->captureEditState(); + + return $this->createRestoringCommand($label, $asset->type, $asset->id, [...$state, 'deleted' => true], [...$state, 'deleted' => false], $restored); + } + + /** + * The command that puts an asset into one edit state on redo and another + * on undo, pinned to the asset whatever is selected when it fires. + * + * @param array $before + * @param array $after + * @param (Closure(CutsceneAsset): void)|null $restored + */ + private function createRestoringCommand(string $label, CutsceneType $type, string $id, array $before, array $after, ?Closure $restored): Command + { $restore = function (array $state) use ($type, $id, $restored): void { $asset = $this->find($type, $id); if ($asset === null) { @@ -323,7 +379,7 @@ public function changeAsset(CutsceneType $type, int $index, string $label, calla } }; - return ['command' => new GenericCommand($label, static fn() => $restore($after), static fn() => $restore($before)), 'result' => $result]; + return new GenericCommand($label, static fn() => $restore($after), static fn() => $restore($before)); } private function buildRecords(CutsceneType $type): ProjectRecordDatabase diff --git a/src/Cutscenes/CutsceneRecordCategory.php b/src/Cutscenes/CutsceneRecordCategory.php index 3a55ff0..086c84c 100644 --- a/src/Cutscenes/CutsceneRecordCategory.php +++ b/src/Cutscenes/CutsceneRecordCategory.php @@ -19,9 +19,11 @@ * record rows and frames as any other category. Reads go through the shared * record rules; every change goes through {@see CutsceneLibrary::changeAsset()}, * which takes it into the asset at once and undoes it by the asset's own edit - * state. Saving writes the type's changed assets with their paired files. - * Assets are created, duplicated and deleted by the library's own flows, - * which name their folders first; this category does not offer them. + * state. An asset is created from the schema's blank or duplicated under the + * id the interface names (made free and safe), and deleted, each one undo + * step through the library; the folder follows on save. Saving writes the + * type's changed assets with their paired files. The records' order is the + * folders', so they are not reordered. */ final readonly class CutsceneRecordCategory implements DatabaseCategory { @@ -50,17 +52,17 @@ public function isDirty(): bool public function supportsRecordCreation(): bool { - return false; + return true; } public function supportsRecordDuplication(): bool { - return false; + return true; } public function supportsRecordDeletion(): bool { - return false; + return true; } public function supportsDurableReorder(): bool @@ -111,19 +113,33 @@ public function removeItem(int $index, array $frame, string $fieldId): RecordCha => $records->removeItem($at, $frame, $fieldId)); } + /** @param string|null $identity The new asset's id, made free and safe; the schema's blank id when none is given. */ public function createRecord(?string $identity = null): RecordChange { - throw $this->refuseRecordOperation('created'); + ['asset' => $asset, 'command' => $command] = $this->library->createAsset($this->type, $identity); + + return new RecordChange($command, $this->findIndex($asset->id)); } public function duplicateRecord(int $index): RecordChange { - throw $this->refuseRecordOperation('duplicated'); + $id = $this->library->ids($this->type)[$index] ?? throw new RecordRefusal(sprintf('There is no %s %d.', $this->type->noun(), $index)); + try { + ['asset' => $copy, 'command' => $command] = $this->library->duplicateAsset($this->type, $id); + } catch (RuntimeException $failure) { + throw new RecordRefusal($failure->getMessage(), previous: $failure); + } + + return new RecordChange($command, $this->findIndex($copy->id)); } public function deleteRecord(int $index): RecordChange { - throw $this->refuseRecordOperation('deleted'); + $label = sprintf('Delete %s', $this->type->noun()); + $deleted = $this->change($index, $label, static fn(RecordCategory $records, int $at): RecordChange => $records->deleteRecord($at)); + $count = count($this->library->ids($this->type)); + + return new RecordChange($deleted->command, $count === 0 ? null : min($index, $count - 1)); } public function moveRecord(int $index, int $step): RecordChange @@ -152,6 +168,14 @@ public function save(): void } } + /** Where an asset sits among its type's records. */ + private function findIndex(string $id): ?int + { + $index = array_search($id, $this->library->ids($this->type), true); + + return is_int($index) ? $index : null; + } + /** The type's records, read through the shared record rules. */ private function read(): RecordCategory { diff --git a/src/Cutscenes/Editing/CutscenesWorkspace.php b/src/Cutscenes/Editing/CutscenesWorkspace.php index 249522f..2f5e83f 100644 --- a/src/Cutscenes/Editing/CutscenesWorkspace.php +++ b/src/Cutscenes/Editing/CutscenesWorkspace.php @@ -1516,13 +1516,10 @@ private function createCutscene(): void } $type = $this->cutsceneType; - $id = $library->freeId($type, strval($records->schema->blank['id'] ?? ('new-' . $type->noun()))); - $payload = $records->schema->blank; - $payload['id'] = $id; try { - $created = CutsceneAsset::create($type, $id, $library->rootFor($type), $payload, $this->workspace?->projectRoot); - $library->adopt($created); + ['asset' => $created, 'command' => $command] = $library->createAsset($type, + restored: fn(CutsceneAsset $restored) => $this->followRestoredCutscene($restored)); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, 'Cutscene creation'); $this->renderCutscenesArea(); @@ -1530,12 +1527,8 @@ private function createCutscene(): void return; } - $state = $created->captureEditState(); - $this->recordCommand(new GenericCommand( - sprintf('Create %s', $type->noun()), - fn() => $this->restoreCutsceneSnapshot($type, $id, [...$state, 'deleted' => false]), - fn() => $this->restoreCutsceneSnapshot($type, $id, [...$state, 'deleted' => true]), - )); + $id = $created->id; + $this->recordCommand($command); $this->leaveCutsceneEditingState(); $this->cutsceneFilter->clear(); @@ -1559,11 +1552,9 @@ private function duplicateSelectedCutscene(): void return; } - $type = $asset->type; - $newId = $library->freeId($type, $asset->id . '-copy'); - try { - $copy = $library->duplicate($type, $asset->id, $newId); + ['asset' => $copy, 'command' => $command] = $library->duplicateAsset($asset->type, $asset->id, + restored: fn(CutsceneAsset $restored) => $this->followRestoredCutscene($restored)); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, 'Cutscene duplication'); $this->renderCutscenesArea(); @@ -1571,12 +1562,8 @@ private function duplicateSelectedCutscene(): void return; } - $state = $copy->captureEditState(); - $this->recordCommand(new GenericCommand( - sprintf('Duplicate %s', $type->noun()), - fn() => $this->restoreCutsceneSnapshot($type, $newId, [...$state, 'deleted' => false]), - fn() => $this->restoreCutsceneSnapshot($type, $newId, [...$state, 'deleted' => true]), - )); + $newId = $copy->id; + $this->recordCommand($command); $this->leaveCutsceneEditingState(); $this->selectCutsceneById($newId); diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php index f13d221..726708d 100644 --- a/tests/Unit/Session/CutsceneRecordSessionTest.php +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -51,7 +51,7 @@ $name = array_find($session->readDatabaseRecord('cutscenes/summon', 0)['rows'], static fn(array $row): bool => trim($row['label']) === 'Name'); $session->applyDatabaseRecord('cutscenes/summon', 0, $name['key'], 'Lantern Wraith'); - expect($session->listDatabaseRecords('cutscenes/summon'))->toMatchArray(['dirty' => true, 'canCreate' => false]) + expect($session->listDatabaseRecords('cutscenes/summon'))->toMatchArray(['dirty' => true]) ->and($session->saveDatabase('cutscenes/summon')['saved'])->toBeTrue() ->and((string) file_get_contents($root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.data.php'))->toContain("'Lantern Wraith'") ->and($session->listUnsavedChanges())->toBe([]); From d196ff598d501efc96937ea14841f98fcc9112b0 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 04:26:01 +0200 Subject: [PATCH 192/344] test(session): cover creating, duplicating and deleting summons through the record RPCs --- .../Session/CutsceneRecordSessionTest.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php index 726708d..0e13549 100644 --- a/tests/Unit/Session/CutsceneRecordSessionTest.php +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -113,3 +113,29 @@ ->and(fn() => $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical'))->toThrow(SessionRefusal::class) ->and(fn() => $session->selectCutscenePresentation('cutscenes/effect', $index, 'holographic'))->toThrow(SessionRefusal::class); }); + +it('creates, duplicates and deletes summons as one undo step each, reaching disk on save', function () { + $root = cutsceneProject(); + $session = EditorSession::open($root); + $summons = static fn(): array => $session->listDatabaseRecords('cutscenes/summon'); + + expect($summons())->toMatchArray(['canCreate' => true, 'canDuplicate' => true, 'canDelete' => true]); + $created = $session->createDatabaseRecord('cutscenes/summon', 'Ember Djinn'); + $id = array_find($session->readDatabaseRecord('cutscenes/summon', $created['index'])['rows'], static fn(array $row): bool => trim($row['label']) === 'Id')['value']; + expect($id)->toBe('ember-djinn') + ->and($summons()['records'])->toHaveCount(2); + + $session->undo(); + expect($summons()['records'])->toHaveCount(1); + $session->redo(); + $copy = $session->duplicateDatabaseRecord('cutscenes/summon', 0); + expect($summons()['records'])->toHaveCount(3); + $session->saveAll(); + expect(is_file($root . '/assets/Cutscenes/Summons/ember-djinn/ember-djinn.data.php'))->toBeTrue() + ->and(is_dir($root . '/assets/Cutscenes/Summons/lantern-wisp-copy'))->toBeTrue(); + + $session->deleteDatabaseRecord('cutscenes/summon', $copy['index']); + expect($summons()['records'])->toHaveCount(2); + $session->saveAll(); + expect(is_dir($root . '/assets/Cutscenes/Summons/lantern-wisp-copy'))->toBeFalse(); +}); From 26f0795df058c5354c7f6a5c7473d8791c501c36 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 04:30:13 +0200 Subject: [PATCH 193/344] feat(schema): summon image tracks share the effect image track schema A summon track may be an image track with the effect schema's image fields (CutsceneSchemas::imageTrackFields, now shared by effects and summons), an anchor that defaults to the target, and the effect image keyframes; glyph and text tracks keep the summon's screen positions, and a track whose type is left out is a glyph track. A summon may set its rest frame. Waits on the Engine compiling summon image tracks through the shared effect schema. --- src/Database/CutsceneSchemas.php | 68 +++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index 6f0144c..6a154a9 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -159,6 +159,7 @@ public static function summons(): RecordSchema new RecordField('formatVersion', 'Timeline Format', InputControlType::INTEGER), new RecordField('fps', 'FPS', InputControlType::INTEGER), new RecordField('lengthFrames', 'Length (frames)', InputControlType::INTEGER), + new RecordField('restFrame', 'Rest Frame', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField('editor', 'Editor Metadata', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), ], labelKey: 'name', @@ -514,23 +515,7 @@ public static function effectTrackList(): RecordSubList new RecordField('facing', 'Facing', options: ['', 'west', 'east'], removeWhenEmpty: true, displayDefault: '(undirected)'), ], blank: ['id' => 'track', 'type' => 'glyph', 'keyframes' => [['frame' => 0, 'duration' => 1, 'content' => '*', 'position' => ['x' => 0, 'y' => 0]]]], - variants: [ - 'image' => [ - RecordField::reference('asset', 'Image', 'png_assets'), - new RecordField('sheet.columns', 'Sheet Columns', InputControlType::INTEGER, displayDefault: '1'), - new RecordField('sheet.rows', 'Sheet Rows', InputControlType::INTEGER, displayDefault: '1'), - new RecordField('cells.width', 'Cell Width', InputControlType::INTEGER, displayDefault: '1'), - new RecordField('cells.height', 'Cell Height', InputControlType::INTEGER, displayDefault: '1'), - new RecordField('depth', 'Depth', options: ['front', 'behind'], removeWhenEmpty: true, displayDefault: 'front'), - // Battle only: the battler point the image is placed on (the empty - // choice removes it: its centre), and the point of the sheet cell that - // sits there, x and y from 0 to 1 (removed: the cell's middle). - new RecordField('attachment', 'Attachment', - options: ['', ...array_map(static fn(EffectImageAttachment $attachment): string => $attachment->value, EffectImageAttachment::cases())], - removeWhenEmpty: true, displayDefault: '(center)'), - new RecordField('pivot', 'Pivot', codec: RecordFieldCodec::NORMALIZED_POINT, removeWhenEmpty: true, displayDefault: '0.5, 0.5'), - ], - ], + variants: ['image' => self::imageTrackFields()], variantKey: 'type', nestedLists: [ 'image' => self::effectImageKeyframeList(), @@ -615,6 +600,38 @@ public static function effectCueList(): RecordSubList * Returns the track list of a summon timeline, each track owning its * keyframes as a nested list. */ + /** + * An image track's own fields, the same on an effect and a summon: the + * sheet it draws from, its depth, and (in battle) the battler point it is + * placed on and the point of the sheet cell that sits there. + * + * @return list + */ + public static function imageTrackFields(): array + { + return [ + RecordField::reference('asset', 'Image', 'png_assets'), + new RecordField('sheet.columns', 'Sheet Columns', InputControlType::INTEGER, displayDefault: '1'), + new RecordField('sheet.rows', 'Sheet Rows', InputControlType::INTEGER, displayDefault: '1'), + new RecordField('cells.width', 'Cell Width', InputControlType::INTEGER, displayDefault: '1'), + new RecordField('cells.height', 'Cell Height', InputControlType::INTEGER, displayDefault: '1'), + new RecordField('depth', 'Depth', options: ['front', 'behind'], removeWhenEmpty: true, displayDefault: 'front'), + // Battle only: the battler point the image is placed on (the empty + // choice removes it: its centre), and the point of the sheet cell that + // sits there, x and y from 0 to 1 (removed: the cell's middle). + new RecordField('attachment', 'Attachment', + options: ['', ...array_map(static fn(EffectImageAttachment $attachment): string => $attachment->value, EffectImageAttachment::cases())], + removeWhenEmpty: true, displayDefault: '(center)'), + new RecordField('pivot', 'Pivot', codec: RecordFieldCodec::NORMALIZED_POINT, removeWhenEmpty: true, displayDefault: '0.5, 0.5'), + ]; + } + + /** + * A summon's tracks. Glyph and text tracks keep the summon's own screen + * positions; an image track is an effect image track, placed on its + * anchor (the target, by default) with the effect schema's fields and + * keyframes, so a summon's art is authored as any effect's is. + */ public static function trackList(): RecordSubList { return new RecordSubList( @@ -623,14 +640,27 @@ public static function trackList(): RecordSubList singular: 'track', fields: [ new RecordField('id', 'Id'), - new RecordField('type', 'Type', options: ['glyph', 'text', 'flash', 'shake'], removeWhenEmpty: true, displayDefault: 'glyph'), + new RecordField('type', 'Type', options: ['glyph', 'text', 'image', 'flash', 'shake'], removeWhenEmpty: true, displayDefault: 'glyph'), // Which renderers draw the track: every one, or only the terminal or the graphical. new RecordField('presentation', 'Presentation', options: ['all', ...array_map(static fn(EffectPresentation $presentation): string => $presentation->value, EffectPresentation::cases())], removeWhenEmpty: true, displayDefault: 'all'), ], blank: ['type' => 'glyph', 'id' => 'track', 'keyframes' => []], - nestedLists: ['*' => self::keyframeList()], + variants: ['image' => [ + new RecordField('anchor', 'Anchor', options: ['target', 'caster', 'screen'], removeWhenEmpty: true, displayDefault: 'target'), + ...self::imageTrackFields(), + ]], + variantKey: 'type', + nestedLists: [ + 'image' => self::effectImageKeyframeList(), + // A track whose type is left out is a glyph track. + '' => self::keyframeList(), + 'glyph' => self::keyframeList(), + 'text' => self::keyframeList(), + 'flash' => self::keyframeList(), + 'shake' => self::keyframeList(), + ], ); } From 67afae7f93bbef4d9576eccd2469eea4d8fbd658 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 04:49:22 +0200 Subject: [PATCH 194/344] feat(cutscenes): compile summons per renderer, with their image tracks' facing Summon image tracks may set a facing, as effect image tracks do. A summon compiles for one renderer through the Engine's summon compiler with the project's asset root: the editors' previews compile the terminal presentation, which never needs graphical art, while the save check and validation compile both, so a missing or unusable summon image is refused there and named. --- src/Cutscenes/CutsceneAsset.php | 12 ++++++--- src/Cutscenes/CutsceneHydration.php | 10 ++++--- src/Database/CutsceneSchemas.php | 2 ++ src/Validation/CutsceneValidation.php | 13 +++++++--- .../Session/CutsceneRecordSessionTest.php | 26 +++++++++++++++++++ 5 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/Cutscenes/CutsceneAsset.php b/src/Cutscenes/CutsceneAsset.php index 1f33a49..050f61d 100644 --- a/src/Cutscenes/CutsceneAsset.php +++ b/src/Cutscenes/CutsceneAsset.php @@ -663,7 +663,10 @@ public function hydrate(?array $data = null, ?array $partner = null): void return; } - CutsceneHydration::compileSummon($data, $partner, $this->projectRoot); + // Both renderers play a summon; each must accept it as it stands. + foreach (EffectPresentation::cases() as $presentation) { + CutsceneHydration::compileSummon($data, $partner, $this->projectRoot, $presentation); + } } /** @@ -696,17 +699,18 @@ public function cinematicDefinition(): CinematicDefinition } /** - * Compiles the summon as it stands in memory, unsaved edits included. + * Compiles the summon as it stands in memory, unsaved edits included, as + * one renderer plays it: the terminal by default, as the editors preview it. * * @throws RuntimeException When the Engine refuses it, or for a cinematic. */ - public function compiledSummon(): SummonCompiledCutscene + public function compiledSummon(EffectPresentation $presentation = EffectPresentation::TERMINAL): SummonCompiledCutscene { if ($this->type !== CutsceneType::SUMMON) { throw new RuntimeException(sprintf('%s is a cinematic, not a summon.', $this->id)); } - return CutsceneHydration::compileSummon($this->data, $this->partner, $this->projectRoot); + return CutsceneHydration::compileSummon($this->data, $this->partner, $this->projectRoot, $presentation); } /** diff --git a/src/Cutscenes/CutsceneHydration.php b/src/Cutscenes/CutsceneHydration.php index a3e7e9a..1862ffb 100644 --- a/src/Cutscenes/CutsceneHydration.php +++ b/src/Cutscenes/CutsceneHydration.php @@ -59,17 +59,21 @@ public static function summon(array $data, array $timeline, ?string $projectRoot } /** - * Compiles a summon through the engine's own compiler. + * Compiles a summon through the engine's own compiler, as one renderer + * plays it: the terminal never needs the graphical art, and the + * graphical presentation checks its images against the project's assets. * * @param array $data The data file's array. * @param array $timeline The timeline file's array. */ - public static function compileSummon(array $data, array $timeline, ?string $projectRoot = null): SummonCompiledCutscene + public static function compileSummon(array $data, array $timeline, ?string $projectRoot = null, + EffectPresentation $presentation = EffectPresentation::TERMINAL): SummonCompiledCutscene { return self::inProject( $projectRoot, - static fn(): SummonCompiledCutscene => new SummonCutsceneCompiler()->compile( + static fn(): SummonCompiledCutscene => new SummonCutsceneCompiler(assetRoot: self::getAssetRoot($projectRoot))->compile( SummonCutsceneDefinition::fromArrays($data, $timeline), + $presentation, ), ); } diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index 6a154a9..f9db41d 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -649,6 +649,8 @@ public static function trackList(): RecordSubList blank: ['type' => 'glyph', 'id' => 'track', 'keyframes' => []], variants: ['image' => [ new RecordField('anchor', 'Anchor', options: ['target', 'caster', 'screen'], removeWhenEmpty: true, displayDefault: 'target'), + // Battle only: the way the art is drawn; the battle mirrors it for a summon cast the other way. + new RecordField('facing', 'Facing', options: ['', 'west', 'east'], removeWhenEmpty: true, displayDefault: '(undirected)'), ...self::imageTrackFields(), ]], variantKey: 'type', diff --git a/src/Validation/CutsceneValidation.php b/src/Validation/CutsceneValidation.php index ac72597..61276b7 100644 --- a/src/Validation/CutsceneValidation.php +++ b/src/Validation/CutsceneValidation.php @@ -9,6 +9,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneLibrary; use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicScriptValidator; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; @@ -125,10 +126,14 @@ protected function checkCutsceneAsset(ProjectWorkspace $workspace, CutsceneAsset return [...$issues, ...$this->checkCinematicReferences($workspace, $asset, $definition->commands, $definition->finalizer)]; } - try { - $asset->compiledSummon(); - } catch (Throwable $throwable) { - return [...$issues, Issue::error($where, $throwable->getMessage(), 'The Engine refuses this summon as it stands; the message names what it needs.')]; + // The terminal and the graphical presentation each compile the summon as they play it. + foreach (EffectPresentation::cases() as $presentation) { + try { + $asset->compiledSummon($presentation); + } catch (Throwable $throwable) { + return [...$issues, Issue::error($where, sprintf('%s presentation: %s', ucfirst($presentation->value), $throwable->getMessage()), + 'The Engine refuses this summon as it stands; the message names what it needs.')]; + } } return $issues; diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php index 0e13549..446aa14 100644 --- a/tests/Unit/Session/CutsceneRecordSessionTest.php +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -139,3 +139,29 @@ $session->saveAll(); expect(is_dir($root . '/assets/Cutscenes/Summons/lantern-wisp-copy'))->toBeFalse(); }); + +it('authors a summon image track with the effect image fields, compiled per renderer', function () { + $root = cutsceneProject(); + $file = $root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.timeline.php'; + // A summon with art rests on an authored frame, as reduced motion draws it. + file_put_contents($file, str_replace([" 'lengthFrames' => 24,", " ['type' => 'text', 'id' => 'name',"], [" 'lengthFrames' => 24,\n 'restFrame' => 12,", + " ['type' => 'image', 'id' => 'wisp-art', 'asset' => 'Graphics/Summons/Wisp.png', 'sheet' => ['columns' => 2, 'rows' => 1],\n" + . " 'anchor' => 'target', 'attachment' => 'ground', 'pivot' => ['x' => 0.5, 'y' => 1],\n" + . " 'keyframes' => [['frame' => 0, 'sourceFrame' => 0], ['frame' => 12, 'sourceFrame' => 1]]],\n" + . " ['type' => 'text', 'id' => 'name',"], (string) file_get_contents($file))); + $session = EditorSession::open($root); + $track = array_find($session->describeCutsceneTimeline('cutscenes/summon', 0)['tracks'], static fn(array $track): bool => $track['id'] === 'wisp-art'); + $asset = Ichiloto\Editor\Cutscenes\CutsceneLibrary::fromProject($root)->find(Ichiloto\Editor\Cutscenes\CutsceneType::SUMMON, 'lantern-wisp'); + + // The record shows the image track's own rows, and its keyframes' sheet cells. + expect($track['type'])->toBe('image') + ->and($track['art'])->toMatchArray(['asset' => 'Graphics/Summons/Wisp.png', 'columns' => 2, 'pivot' => '0.5, 1', 'width' => null]) + ->and(array_column($track['keyframes'], 'sourceFrame'))->toBe([0, 1]) + // The terminal never needs the art; the graphical presentation refuses an image that is not there. + ->and($asset->compiledSummon()->fps)->toBe(12) + ->and(fn() => $asset->compiledSummon(Ichiloto\Engine\Animations\Timelines\EffectPresentation::GRAPHICAL))->toThrow(RuntimeException::class, 'Graphics/Summons/Wisp.png'); + + mkdir($root . '/assets/Graphics/Summons', 0o777, true); + writeTilesetTestPng($root . '/assets/Graphics/Summons/Wisp.png', 16, 8); + expect($asset->compiledSummon(Ichiloto\Engine\Animations\Timelines\EffectPresentation::GRAPHICAL)->fps)->toBe(12); +}); From b204727c11029cfeffa0bfe4a3b75009696e57cb Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 05:32:51 +0200 Subject: [PATCH 195/344] fix(database): keep zero where a field's absence means something else A whole-number field removed when empty dropped zero even where its absence reads as another value, so a 0% state chance became 100% and a rest frame of 0 became the last frame. Zero is now unset only where absence reads as zero, and writing back a field's shown default clears it. --- src/Database/ProjectRecordDatabase.php | 10 ++++-- tests/Unit/IntegerFieldAbsenceTest.php | 46 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/IntegerFieldAbsenceTest.php diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 48d9ca9..0de33e0 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -3447,7 +3447,9 @@ private static function coerce(RecordField $field, string $rawValue): mixed return self::coerceScalar($field, $trimmed); } - if ($trimmed === '' && $field->removeWhenEmpty) { + // Clearing the field, or writing back what its absence reads as, is its absence. + if ($field->removeWhenEmpty && ($trimmed === '' + || ($field->displayDefault !== null && mb_strtolower($trimmed) === mb_strtolower($field->displayDefault)))) { return null; } @@ -3531,7 +3533,9 @@ private static function coerceScalar(RecordField $field, string $value): mixed /** * Casts an integer field, dropping the key when the value is zero and the - * schema treats zero as "unset". + * schema treats zero as "unset": its absence reads as zero, or as nothing + * it names. Where absence means something else (a 100% chance, the last + * frame), zero is a value of its own and is kept. * * @param RecordField $field The field schema. * @param string $value The trimmed value. @@ -3541,7 +3545,7 @@ private static function coerceInteger(RecordField $field, string $value): ?int { $number = intval($value); - return $number === 0 && $field->removeWhenEmpty ? null : $number; + return $number === 0 && $field->removeWhenEmpty && ($field->displayDefault ?? '0') === '0' ? null : $number; } /** diff --git a/tests/Unit/IntegerFieldAbsenceTest.php b/tests/Unit/IntegerFieldAbsenceTest.php new file mode 100644 index 0000000..aae4a45 --- /dev/null +++ b/tests/Unit/IntegerFieldAbsenceTest.php @@ -0,0 +1,46 @@ + 'one']], static function (): void { + })); +} + +function readIntegerAbsence(RecordCategory $records): array +{ + return array_column($records->getRecordRows(0, []), 'value', 'label'); +} + +it('keeps zero where absence means something else, and clears a field given its shown default', function () { + $records = integerAbsenceRecords(); + foreach (['count' => '0', 'chance' => '0', 'rest' => '0'] as $field => $value) { + $records->applyField(0, [], $field, $value, 'Set'); + } + + expect(readIntegerAbsence($records))->toMatchArray(['Count' => '', 'Chance %' => '0', 'Rest Frame' => '0']); + + $records->applyField(0, [], 'chance', '100', 'Set'); + $records->applyField(0, [], 'rest', 'last frame', 'Set'); + expect(readIntegerAbsence($records))->toMatchArray(['Chance %' => '100', 'Rest Frame' => 'Last frame']); + + $records->applyField(0, [], 'rest', '', 'Set'); + expect(readIntegerAbsence($records)['Rest Frame'])->toBe('Last frame'); +}); From e2c3815ea817cf71b505f1d1cc1db932676d53e3 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 05:32:51 +0200 Subject: [PATCH 196/344] feat(cutscenes): preview a summon in battle through the Engine's command preview cutscenes.battlePreview casts the summon as it stands, unsaved edits included, by the first battle test party member its wielders allow, at the targets its linked action takes in the battle test troop, over the battle test arena with the project's battle pacing. Each frame is the Engine's own: phase, graphical canvas, terminal arena lines and cues, plus the command's phases. The preview is kept until the summon or its battle changes, so seeking is cheap. A summon's Rest Frame reads as the last frame when unset. --- src/Cutscenes/CutsceneAsset.php | 15 +++ src/Cutscenes/Preview/SummonBattlePreview.php | 100 ++++++++++++++++ src/Database/CutsceneSchemas.php | 2 +- src/Session/EditorSession.php | 113 ++++++++++++++++++ src/Session/SessionHost.php | 2 + tests/Support/BattleFormationFixtures.php | 29 ++++- tests/Unit/Session/BattleTestSessionTest.php | 14 --- .../SummonBattlePreviewSessionTest.php | 83 +++++++++++++ 8 files changed, 339 insertions(+), 19 deletions(-) create mode 100644 src/Cutscenes/Preview/SummonBattlePreview.php create mode 100644 tests/Unit/Session/SummonBattlePreviewSessionTest.php diff --git a/src/Cutscenes/CutsceneAsset.php b/src/Cutscenes/CutsceneAsset.php index 050f61d..ecc56cb 100644 --- a/src/Cutscenes/CutsceneAsset.php +++ b/src/Cutscenes/CutsceneAsset.php @@ -18,6 +18,7 @@ use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicDefinition; use Ichiloto\Engine\Cutscenes\Summons\SummonCompiledCutscene; +use Ichiloto\Engine\Cutscenes\Summons\SummonCutsceneDefinition; use RuntimeException; use Throwable; @@ -698,6 +699,20 @@ public function cinematicDefinition(): CinematicDefinition return CutsceneHydration::cinematic($this->data, $this->partner, $this->projectRoot); } + /** + * Hydrates the summon as it stands in memory, unsaved edits included. + * + * @throws RuntimeException When the Engine refuses it, or for a cinematic. + */ + public function summonDefinition(): SummonCutsceneDefinition + { + if ($this->type !== CutsceneType::SUMMON) { + throw new RuntimeException(sprintf('%s is a cinematic, not a summon.', $this->id)); + } + + return CutsceneHydration::summon($this->data, $this->partner, $this->projectRoot); + } + /** * Compiles the summon as it stands in memory, unsaved edits included, as * one renderer plays it: the terminal by default, as the editors preview it. diff --git a/src/Cutscenes/Preview/SummonBattlePreview.php b/src/Cutscenes/Preview/SummonBattlePreview.php new file mode 100644 index 0000000..032849e --- /dev/null +++ b/src/Cutscenes/Preview/SummonBattlePreview.php @@ -0,0 +1,100 @@ + $this->preview->totalFrames; } + + /** + * @param string $caster The member who casts it. + * @param list $targets Whom it is cast at. + * @param array $phases The command's phases, in command frames. + */ + private function __construct( + private readonly BattleCommandPreview $preview, + public readonly string $caster, + public readonly array $targets, + public readonly array $phases, + ) { + } + + /** + * @param SummonCompiledCutscene $graphical The summon compiled for the graphical battle. + * @param SummonCompiledCutscene $terminal The same summon compiled for the terminal arena. + * @throws InvalidArgumentException When no member may cast it, its action is unknown or it has no one to target. + */ + public static function create( + SummonCutsceneDefinition $definition, + SummonCompiledCutscene $graphical, + SummonCompiledCutscene $terminal, + BattleConfig $battle, + SkillCatalog $skills, + BattlePacing $pacing, + BattlePresentationCatalog $catalog, + string $assetRoot, + ): self { + $battlers = $battle->partyRoster->battlers; + $policy = $definition->wielders; + $caster = array_find($battlers, static fn($battler): bool => $battler instanceof Character + && ($policy === null || ($policy->isValid() && $policy->allowsCharacter($battler)))) + ?? throw new InvalidArgumentException(sprintf('No one in the battle test party may call %s; add a member who may.', $definition->name)); + $skill = $skills->findSkill($definition->linkedActionId) + ?? throw new InvalidArgumentException(sprintf('%s calls the action %s, which no skill defines.', $definition->name, $definition->linkedActionId)); + $action = new SkillBattleAction($skill); + $opponents = $battle->troop->members->toArray(); + $targets = BattleTargetPolicy::resolveTargets($action->targetScope, $caster, $battlers, $opponents); + if ($targets === []) { + throw new InvalidArgumentException(sprintf('%s has no one to target in the battle test.', $definition->name)); + } + $plan = new BattleCommandTimeline($pacing->getTurnTimings($action), target: $graphical, terminalTarget: $terminal); + + return new self( + new BattleCommandPreview($battle, $plan, $caster, $targets, BattlePoseRole::SUMMON, $catalog, $assetRoot), + $caster->name, + array_map(static fn($target): string => $target->name, $targets), + $plan->phases, + ); + } + + /** + * One command frame as the battle draws it: its phase, the graphical + * canvas (none without a graphical arena), the terminal arena's lines, + * the cues at it and those crossed reaching it, and what went wrong + * presenting it. + * + * @return array + * @throws InvalidArgumentException When the frame is outside the command. + */ + public function readFrame(int $frame, bool $reducedMotion): array + { + return $this->preview->getFrameAtIndex($frame, $reducedMotion)->toArray(); + } +} diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index f9db41d..4e646d6 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -159,7 +159,7 @@ public static function summons(): RecordSchema new RecordField('formatVersion', 'Timeline Format', InputControlType::INTEGER), new RecordField('fps', 'FPS', InputControlType::INTEGER), new RecordField('lengthFrames', 'Length (frames)', InputControlType::INTEGER), - new RecordField('restFrame', 'Rest Frame', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('restFrame', 'Rest Frame', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: 'Last frame'), new RecordField('editor', 'Editor Metadata', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), ], labelKey: 'name', diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 314625e..2d413c2 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -78,6 +78,14 @@ use Ichiloto\Engine\Scenes\Arena\ProjectBattleTest; use Ichiloto\Engine\Util\Config\ConfigStore; use Ichiloto\Engine\Util\Stores\ItemStore; +use Ichiloto\Engine\Util\Stores\EnemyStore; +use Ichiloto\Engine\Battle\BattlePacing; +use Ichiloto\Engine\Entities\Troop; +use Ichiloto\Engine\Scenes\Battle\BattleConfig; +use Ichiloto\Editor\Cutscenes\Preview\SummonBattlePreview; +use Ichiloto\Engine\Battle\Presentation\BattleCommandTimeline; +use Ichiloto\Engine\Cutscenes\Summons\SummonCompiledCutscene; +use Ichiloto\Engine\Cutscenes\Summons\SummonCutsceneDefinition; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasImage; use Closure; use InvalidArgumentException; @@ -108,6 +116,8 @@ final class EditorSession /** The battle test running in its own window, and the troop it fights. */ private ?PlaytestRun $battleTestRun = null; private ?string $battleTestTroop = null; + /** The summon battle preview last built, and what it was built from, so seeking reuses it until either changes. */ + private ?array $summonBattlePreview = null; /** How actors are authored, with what this editor's actor panes show. */ private readonly ActorAuthoring $actorAuthoring; @@ -1285,6 +1295,109 @@ public function readCutscenePreview(string $category, int $index, int $frame, in 'cues' => array_values($preview->cuesAt($frame))]; } + /** + * A summon as the battle test plays it, at one command frame: the + * Engine's command preview of the summon as it stands, unsaved edits + * included, cast by the first member of the battle test party (else the + * starting party) its wielder policy allows, at the targets its linked + * action takes in the battle test troop (else the first troop), over the + * battle test arena, with the project's battle pacing. The graphical + * canvas comes with the terminal arena's lines, so both renderers are + * previewed from the one command. Enemies and the party are read as + * saved; nothing is resolved, spent or played aloud, and nothing is + * written. Seeking reuses the preview until the summon or its battle + * changes. + * + * @return array + * @throws SessionRefusal When the summon does not compile, the battle cannot be set up or the frame is outside the command. + */ + public function readSummonBattlePreview(int $index, int $frame, bool $reducedMotion = false): array + { + $asset = $this->requireTimelineCutscene(CutsceneType::SUMMON->getRecordCategory(), $index); + try { + $definition = $asset->summonDefinition(); + $graphical = $asset->compiledSummon(EffectPresentation::GRAPHICAL); + $terminal = $asset->compiledSummon(EffectPresentation::TERMINAL); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s does not compile: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + $entry = $this->readBattleTestEntry(); + $troop = $this->readPreviewTroop($entry); + $config = $this->workspace->config?->getRecord('ui.battle')->get('value'); + $battleUi = is_array($config) ? $config : []; + $key = hash('xxh128', serialize([$asset->id, $definition->toDataArray(), $graphical->toArray(), $terminal->toArray(), + $entry, $this->readStartingPartyReferences(), $troop, $battleUi, $this->readProposedBattlerBindings()])); + if (($this->summonBattlePreview['key'] ?? null) !== $key) { + $catalog = $this->requireBattleLayoutCatalog('preview summons on'); + $this->summonBattlePreview = ['key' => $key, 'preview' => ProjectDirectoryContext::run($this->workspace->projectRoot, + fn(): SummonBattlePreview => $this->createSummonBattlePreview($definition, $graphical, $terminal, $entry, $troop, + BattlePacing::fromBattleUiConfig($battleUi), $catalog))]; + } + $preview = $this->summonBattlePreview['preview']; + try { + $read = $preview->readFrame(max(0, min($frame, $preview->totalFrames - 1)), $reducedMotion); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s cannot be previewed: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + + return [...$read, 'totalFrames' => $preview->totalFrames, 'fps' => BattleCommandTimeline::FPS, + 'assetRoot' => $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets', + 'caster' => $preview->caster, 'targets' => $preview->targets, 'troop' => $troop['name'], 'phases' => $preview->phases]; + } + + /** + * The battle a summon preview plays in, built the way the battle test + * builds it, and the summon cast in it. + * + * @param array $entry The battle test as system data holds it. + * @param array{name: string, data: array} $troop + * @throws SessionRefusal When the battle cannot be set up or the summon cannot be cast in it. + */ + private function createSummonBattlePreview(SummonCutsceneDefinition $definition, SummonCompiledCutscene $graphical, + SummonCompiledCutscene $terminal, array $entry, array $troop, BattlePacing $pacing, BattlePresentationCatalog $catalog): SummonBattlePreview + { + $actors = $this->workspace->actorDatabase->createActorStore(); + $skills = $this->workspace->loadSkillCatalog(); + $previousEnemies = ConfigStore::has(EnemyStore::class) ? ConfigStore::get(EnemyStore::class) : null; + try { + $setup = ProjectBattleTest::fromArray($entry)->createSetup($actors, $this->readStartingPartyReferences()); + $party = $setup->createParty($actors, $this->loadItemStore(), $skills); + ConfigStore::put(EnemyStore::class, new EnemyStore()); + $battle = new BattleConfig($party, Troop::fromArray($troop['data']), settings: $setup->getBattleSettings()); + + return SummonBattlePreview::create($definition, $graphical, $terminal, $battle, $skills, $pacing, $catalog, + $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); + } catch (SessionRefusal $refusal) { + throw $refusal; + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s cannot be previewed in battle: %s', $definition->name, $failure->getMessage()), previous: $failure); + } finally { + $previousEnemies === null ? ConfigStore::remove(EnemyStore::class) : ConfigStore::put(EnemyStore::class, $previousEnemies); + } + } + + /** + * The troop a summon preview is cast at: the battle test's, as the + * battle test fights it, else the project's first; as it stands now. + * + * @param array $entry The battle test as system data holds it. + * @return array{name: string, data: array} + * @throws SessionRefusal When the project has no troops, or the battle test names one it does not have. + */ + private function readPreviewTroop(array $entry): array + { + $named = is_string($entry['troop'] ?? null) ? trim($entry['troop']) : null; + foreach ($this->requireRecordDatabase('troops')->getRecords() as $record) { + $data = $record->toArray(); + $name = is_array($data) ? trim(strval($data['name'] ?? '')) : ''; + if ($name !== '' && ($named === null || $named === $name)) { + return ['name' => $name, 'data' => $data]; + } + } + + throw new SessionRefusal($named === null ? 'This project has no troop to cast a summon at.' + : sprintf('The battle test fights %s, which no troop is named.', $named)); + } /** * An image track's art with the image's own size, read from the file the * track names inside the asset root; none for a file that is missing or diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 990c93c..a3ebf03 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -334,6 +334,8 @@ private function dispatch(string $method, array $params): array self::requireInt($params, 'index'), self::requireString($params, 'presentation')), 'cutscenes.preview' => $session->readCutscenePreview(self::requireString($params, 'category'), self::requireInt($params, 'index'), self::requireInt($params, 'frame'), self::requireInt($params, 'width'), self::requireInt($params, 'height')), + 'cutscenes.battlePreview' => $session->readSummonBattlePreview(self::requireInt($params, 'index'), self::requireInt($params, 'frame'), + ($params['reducedMotion'] ?? false) === true), 'references.list' => $session->listReferences( is_string($params['map'] ?? null) ? $params['map'] : null, self::requireString($params, 'category'), diff --git a/tests/Support/BattleFormationFixtures.php b/tests/Support/BattleFormationFixtures.php index cf221b1..ec23f04 100644 --- a/tests/Support/BattleFormationFixtures.php +++ b/tests/Support/BattleFormationFixtures.php @@ -52,17 +52,24 @@ function troopFormationProject(): string function enemyPreviewProject(): string { $root = troopFormationProject(); - @mkdir($root . '/assets/Data/Enemies', 0o777, true); @mkdir($root . '/assets/Graphics/Enemies', 0o777, true); - file_put_contents($root . '/assets/Graphics/Enemies/bat.txt', "/\\o/\\\n ' '\n"); file_put_contents($root . '/assets/Graphics/Enemies/wisp.txt', "~*~\n"); file_put_contents($root . '/assets/secret.txt', "not a sprite\n"); + writeRegularBatEnemy($root); + + return $root; +} + +/** The Regular Bat the troop formation project's troop fights, as its one enemy record, with its terminal sprite. */ +function writeRegularBatEnemy(string $root): void +{ + @mkdir($root . '/assets/Data/Enemies', 0o777, true); + @mkdir($root . '/assets/Graphics/Enemies', 0o777, true); + file_put_contents($root . '/assets/Graphics/Enemies/bat.txt', "/\\o/\\\n ' '\n"); file_put_contents($root . '/assets/Data/Enemies/regular-bat.php', " Enemy::class, 'data' => " . var_export(['name' => 'Regular Bat', 'level' => 2, 'imagePath' => 'bat', 'rewards' => ['experience' => 1, 'gold' => 1], 'stats' => [ 'maxHp' => 10, 'maxMp' => 0, 'attack' => 3, 'defence' => 2, 'magicAttack' => 1, 'magicDefence' => 1, 'speed' => 2, 'grace' => 1, 'evasion' => 1, ]], true) . "];\n"); - - return $root; } /** The enemy preview project with battle art for its bat on disk. */ @@ -86,3 +93,17 @@ function setBattlerRow(EditorSession $session, int $index, string $field, string { $session->applyDatabaseRecord('battler_enemies', $index, readBattlerRow($session, $index, $field)['key'], $value); } + +/** The troop formation project with a Kaelion the Engine can build: full stats, as a battle needs. */ +function battleTestProject(): string +{ + $root = troopFormationProject(); + file_put_contents($root . '/assets/Data/Actors/Kaelion.php', " 'Ichiloto\\Engine\\Entities\\Character', 'data' => [ + 'id' => 'Kaelion', 'name' => 'Kaelion', 'level' => 1, 'currentExp' => 0, 'stats' => [ + 'currentHp' => 40, 'currentMp' => 10, 'currentAp' => 3, 'totalHp' => 100, 'totalMp' => 20, 'totalAp' => 3, + 'attack' => 8, 'defence' => 7, 'magicAttack' => 6, 'magicDefence' => 5, 'speed' => 4, 'grace' => 3, 'evasion' => 2, + ], + ]], true) . ";\n"); + + return $root; +} diff --git a/tests/Unit/Session/BattleTestSessionTest.php b/tests/Unit/Session/BattleTestSessionTest.php index d23fe38..862f5c2 100644 --- a/tests/Unit/Session/BattleTestSessionTest.php +++ b/tests/Unit/Session/BattleTestSessionTest.php @@ -11,20 +11,6 @@ /** Choosing the battle test's troop and party in the editor, kept in system data as RPG Maker keeps its Battle Test. Synthetic fixtures only. */ -/** The troop formation project with a Kaelion the Engine can build: full stats, as a battle needs. */ -function battleTestProject(): string -{ - $root = troopFormationProject(); - file_put_contents($root . '/assets/Data/Actors/Kaelion.php', " 'Ichiloto\\Engine\\Entities\\Character', 'data' => [ - 'id' => 'Kaelion', 'name' => 'Kaelion', 'level' => 1, 'currentExp' => 0, 'stats' => [ - 'currentHp' => 40, 'currentMp' => 10, 'currentAp' => 3, 'totalHp' => 100, 'totalMp' => 20, 'totalAp' => 3, - 'attack' => 8, 'defence' => 7, 'magicAttack' => 6, 'magicDefence' => 5, 'speed' => 4, 'grace' => 3, 'evasion' => 2, - ], - ]], true) . ";\n"); - - return $root; -} - it('stands the starting party in for a battle test that sets none, saying so, with what each member may wear', function () { $root = battleTestProject(); $test = EditorSession::open($root)->describeBattleTest(); diff --git a/tests/Unit/Session/SummonBattlePreviewSessionTest.php b/tests/Unit/Session/SummonBattlePreviewSessionTest.php new file mode 100644 index 0000000..d768e80 --- /dev/null +++ b/tests/Unit/Session/SummonBattlePreviewSessionTest.php @@ -0,0 +1,83 @@ + 'Fireball',", " 'linkedActionId' => 'Fireball',\n 'wielders' => " . var_export($wielders, true) . ',', $data); + } + file_put_contents($summon . '/lantern-wisp.data.php', $data); + file_put_contents($summon . '/lantern-wisp.timeline.php', str_replace([" 'lengthFrames' => 24,", " ['type' => 'text', 'id' => 'name',"], [ + " 'lengthFrames' => 24,\n 'restFrame' => 12,", + " ['type' => 'image', 'id' => 'wisp-art', 'presentation' => 'graphical', 'asset' => 'Graphics/Summons/Wisp.png',\n" + . " 'sheet' => ['columns' => 2, 'rows' => 1], 'anchor' => 'target', 'attachment' => 'ground', 'pivot' => ['x' => 0.5, 'y' => 1],\n" + . " 'keyframes' => [['frame' => 0, 'duration' => 12, 'sourceFrame' => 0], ['frame' => 12, 'duration' => 12, 'sourceFrame' => 1]]],\n" + . " ['type' => 'text', 'id' => 'name',"], lanternSummonTimeline())); + mkdir($root . '/assets/Graphics/Summons', 0o777, true); + writeTilesetTestPng($root . '/assets/Graphics/Summons/Wisp.png', 16, 8); + writeFireballSkill($root); + // The battle reads enemies the way the game loads them, one file each through the project's barrel. + writeRegularBatEnemy($root); + // A graphical battle places every enemy it fights. + file_put_contents($root . '/assets/Data/troops.php', str_replace("['enemy' => 'Regular Bat', 'position' => [15, 20]]", + "['enemy' => 'Regular Bat', 'position' => [15, 20], 'graphicalPlacement' => ['x' => 420, 'y' => 380, 'width' => 275, 'height' => 190]]", + (string) file_get_contents($root . '/assets/Data/troops.php'))); + file_put_contents($root . '/assets/Data/enemies.php', "|null The summon's art as the frame's canvas draws it. */ +function findSummonArt(array $frame): ?array +{ + return array_find($frame['canvas']['images'] ?? [], static fn(array $image): bool => $image['asset'] === 'Graphics/Summons/Wisp.png'); +} + +it('previews a summon cast by the battle test party at its troop, for both renderers, seeking freely', function () { + $session = EditorSession::open(summonBattlePreviewProject()); + $first = $session->readSummonBattlePreview(0, 0); + $target = $first['phases']['target']['start'] + 1; + $atTarget = $session->readSummonBattlePreview(0, $target); + + expect($first)->toMatchArray(['caster' => 'Kaelion', 'troop' => 'Pair', 'fps' => 120]) + ->and($first['targets'])->not->toBe([]) + ->and($first['totalFrames'])->toBeGreaterThan($target) + ->and($atTarget['phase'])->toBe('target') + ->and(findSummonArt($atTarget))->not->toBeNull() + ->and($atTarget['terminalLines'])->not->toBe([]) + ->and($atTarget['diagnostics'])->toBe([]) + // A seek past the end holds the last frame; seeking back draws the same frame again. + ->and($session->readSummonBattlePreview(0, PHP_INT_MAX)['frame'])->toBe($first['totalFrames'] - 1) + ->and($session->readSummonBattlePreview(0, $target))->toBe($atTarget) + ->and($session->listUnsavedChanges())->toBe([]); +}); + +it('previews the summon as it stands, unsaved edits included', function () { + $session = EditorSession::open(summonBattlePreviewProject()); + $target = $session->readSummonBattlePreview(0, 0)['phases']['target']['start'] + 1; + $resting = findSummonArt($session->readSummonBattlePreview(0, $target, reducedMotion: true)); + $rest = array_find($session->readDatabaseRecord('cutscenes/summon', 0)['rows'], static fn(array $row): bool => trim($row['label']) === 'Rest Frame'); + + $session->applyDatabaseRecord('cutscenes/summon', 0, $rest['key'], '0'); + + expect($resting['sourceRect']['x'])->toBe(8) + ->and(findSummonArt($session->readSummonBattlePreview(0, $target, reducedMotion: true))['sourceRect']['x'])->toBe(0); +}); + +it('refuses a summon no one in the battle test party may call, naming why', function () { + $session = EditorSession::open(summonBattlePreviewProject(['mode' => 'characters', 'characters' => ['Liora'], 'tenancy' => 'exclusive'])); + + expect(fn() => $session->readSummonBattlePreview(0, 0))->toThrow(SessionRefusal::class, 'No one in the battle test party may call Lantern Wisp') + ->and(fn() => $session->readSummonBattlePreview(4, 0))->toThrow(SessionRefusal::class); +}); From 32ed8fea04a70638abba23860aaf0567db1733bb Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 06:06:16 +0200 Subject: [PATCH 197/344] fix(cutscenes): name summon wielders by stable actor id The Engine matches a summon's character wielders by actor id, so the Editor does too. The Wielder Characters picker offers actor ids, starting assignment verdicts judge eligibility by id (a legacy actor that declares none by its authored name, as the Engine freezes it), and summon wielders join the actor reference inventory: the identity migration rewrites them source preserving, refuses an ambiguous display name, and the actor reference validator reports one that names no actor. A display rename no longer changes who may call a summon. --- docs/manual.md | 8 ++- src/Actors/ActorAuthoring.php | 2 +- src/Actors/ActorReferenceInventory.php | 15 ++++- src/Database/CutsceneSchemas.php | 2 +- src/Database/SummonAssignmentDiagnostics.php | 9 +-- src/ProjectActor.php | 11 ++++ src/Validation/ActorReferenceValidator.php | 8 +++ src/Validation/ProjectValidator.php | 29 +++------ tests/Unit/ActorIdentityMigrationTest.php | 64 ++++++++++++++++++++ tests/Unit/ProjectValidatorTest.php | 2 +- 10 files changed, 117 insertions(+), 33 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 026bd25..1d8062b 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1961,7 +1961,9 @@ strengths, weaknesses, free-form attributes, authoring metadata), **Availability** (conditions through the shared condition editor; an omitted policy is open and is written as nothing), **Wielders** (mode `all`, `roles` or `characters`, with roles picked from classes and -characters from actors; tenancy `shared` or `exclusive`), **Playback** +characters picked by stable actor id, as the battle matches them, so +renaming an actor never changes who may call it; tenancy `shared` or +`exclusive`), **Playback** (default speed, allow skip, loop preview, transitions in and out, effect timing by `end`, `cue` or `frame`, target presentation) and **Timeline** (format version, FPS, length in frames, editor metadata, and the Tracks @@ -2020,8 +2022,8 @@ multi-pick over the project's summons where each pick toggles a member in or out, undoable and dirty-tracked, written as a list of stable ids (and removed entirely when emptied). Under it, one verdict row per assignment, judged by the same rules the validator applies: the summon must exist, the -actor must be eligible under its wielder policy (by character, by role, or -open to all), a story-locked summon cannot be a starting assignment, each +actor must be eligible under its wielder policy (by character id, by role, +or open to all), a story-locked summon cannot be a starting assignment, each id appears once, and an exclusive summon has at most one starting holder across the cast. diff --git a/src/Actors/ActorAuthoring.php b/src/Actors/ActorAuthoring.php index 2156cf6..9455e05 100644 --- a/src/Actors/ActorAuthoring.php +++ b/src/Actors/ActorAuthoring.php @@ -665,7 +665,7 @@ private function actorSummonFields(ProjectWorkspace $workspace, ProjectActor $ac } } - foreach ($diagnostics->forActor($actor->getName(), $actor->getClassName(), $assignments) as $row) { + foreach ($diagnostics->forActor($actor->getRuntimeId(), $actor->getClassName(), $assignments) as $row) { $verdict = SummonAssignmentDiagnostics::describe($row); $id = strtolower($row['id']); diff --git a/src/Actors/ActorReferenceInventory.php b/src/Actors/ActorReferenceInventory.php index 8919c90..5582259 100644 --- a/src/Actors/ActorReferenceInventory.php +++ b/src/Actors/ActorReferenceInventory.php @@ -78,8 +78,17 @@ public static function getReferences(mixed $payload, string $relativePath): arra } } } + // A summon's character wielders, which the Engine matches by stable actor id. + if (str_starts_with($relativePath, 'assets/Cutscenes/Summons/') && str_ends_with($relativePath, '.data.php')) { + $wielders = $payload['wielders'] ?? null; + if (is_array($wielders) && is_array($wielders['characters'] ?? null)) { + foreach ($wielders['characters'] as $index => $id) { + $references[] = ['path' => ['wielders', 'characters', $index], 'reference' => $id, 'kind' => 'value', 'caseInsensitive' => true]; + } + } + } // Troops use enemy IDs; event actorId/actor target staged subjects. - // Display names and summon wielder names are not this identity contract. + // Display names are not this identity contract. return $references; } @@ -98,11 +107,11 @@ public static function getSourcePaths(string $root): array 'assets/Data/Presentation/menus.php'] as $relative) { if (is_file($root . '/' . $relative)) { $paths[] = $root . '/' . $relative; } } - foreach (['assets/Data/Skits', 'assets/Events', 'assets/Maps'] as $directory) { + foreach (['assets/Data/Skits', 'assets/Events', 'assets/Maps', 'assets/Cutscenes/Summons'] as $directory) { if (! is_dir($root . '/' . $directory)) { continue; } $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root . '/' . $directory, \FilesystemIterator::SKIP_DOTS)); foreach ($files as $file) { - if ($file->isFile() && str_ends_with($file->getFilename(), $directory === 'assets/Maps' ? '.data.php' : '.php')) { + if ($file->isFile() && str_ends_with($file->getFilename(), in_array($directory, ['assets/Maps', 'assets/Cutscenes/Summons'], true) ? '.data.php' : '.php')) { $paths[] = $file->getPathname(); } } diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index 4e646d6..e009957 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -138,7 +138,7 @@ public static function summons(): RecordSchema // Wielder policy. new RecordField('wielders.mode', 'Wielders', options: ['all', 'roles', 'characters'], removeWhenEmpty: true, displayDefault: '(omitted: open)'), new RecordField('wielders.roles', 'Wielder Roles', codec: RecordFieldCodec::CSV_LIST, reference: 'classes', removeWhenEmpty: true), - new RecordField('wielders.characters', 'Wielder Characters', codec: RecordFieldCodec::CSV_LIST, reference: 'actors', removeWhenEmpty: true), + new RecordField('wielders.characters', 'Wielder Characters', codec: RecordFieldCodec::CSV_LIST, reference: 'actor_ids', removeWhenEmpty: true), new RecordField('wielders.tenancy', 'Tenancy', options: ['shared', 'exclusive'], removeWhenEmpty: true, displayDefault: 'shared'), // Playback and presentation. new RecordField('playback.defaultSpeed', 'Default Speed', InputControlType::FLOAT, removeWhenEmpty: true, displayDefault: '1'), diff --git a/src/Database/SummonAssignmentDiagnostics.php b/src/Database/SummonAssignmentDiagnostics.php index df1034d..5cf600a 100644 --- a/src/Database/SummonAssignmentDiagnostics.php +++ b/src/Database/SummonAssignmentDiagnostics.php @@ -65,7 +65,7 @@ public function ids(): array * @param mixed $assignments The actor's `summons` value as authored. * @return array}> One row per assignment, in order. */ - public function forActor(string $actorName, string $className, mixed $assignments): array + public function forActor(string $actorId, string $className, mixed $assignments): array { if (! is_array($assignments) || ! array_is_list($assignments)) { return [['id' => '', 'problems' => [['message' => 'Its summon assignments are malformed.', 'hint' => 'Use a list of summon ids.']]]]; @@ -99,7 +99,7 @@ public function forActor(string $actorName, string $className, mixed $assignment continue; } - if (! $this->isEligible($definition, $actorName, $className)) { + if (! $this->isEligible($definition, $actorId, $className)) { $problems[] = ['message' => sprintf('It is not eligible to hold summon "%s".', $summonId), 'hint' => 'Change the actor assignment or the generic wielder policy.']; } @@ -118,7 +118,7 @@ public function forActor(string $actorName, string $className, mixed $assignment * * @param array $definition */ - public function isEligible(array $definition, string $actorName, string $className): bool + public function isEligible(array $definition, string $actorId, string $className): bool { $wielders = is_array($definition['wielders'] ?? null) ? $definition['wielders'] : null; @@ -130,7 +130,8 @@ public function isEligible(array $definition, string $actorName, string $classNa $mode = is_string($modeValue) ? strtolower(trim($modeValue)) : ''; return match ($mode) { - 'characters' => in_array($actorName, (array) ($wielders['characters'] ?? []), true), + // By the actor's stable id, as the Engine matches it, never its display name. + 'characters' => in_array(strtolower(trim($actorId)), array_map(static fn(mixed $id): string => is_string($id) ? strtolower(trim($id)) : '', (array) ($wielders['characters'] ?? [])), true), 'roles' => in_array($className, (array) ($wielders['roles'] ?? []), true), 'all' => true, default => false, diff --git a/src/ProjectActor.php b/src/ProjectActor.php index f5d076e..b2f9ed8 100644 --- a/src/ProjectActor.php +++ b/src/ProjectActor.php @@ -336,6 +336,17 @@ public function getDefinitionId(): string return is_string($id) ? trim($id) : ''; } + /** + * Returns the id the runtime knows this actor by: its declared id, or, + * for a legacy asset that declares none, its authored name, which the + * Engine freezes as a provisional id until the identity migration writes + * one. Never the display name of an actor that declares an id. + */ + public function getRuntimeId(): string + { + return array_key_exists('id', $this->getData()) ? $this->getDefinitionId() : trim($this->getName()); + } + /** * Returns whether the project declares a durable id of its own. * diff --git a/src/Validation/ActorReferenceValidator.php b/src/Validation/ActorReferenceValidator.php index 1912720..0766f3a 100644 --- a/src/Validation/ActorReferenceValidator.php +++ b/src/Validation/ActorReferenceValidator.php @@ -5,6 +5,7 @@ namespace Ichiloto\Editor\Validation; use Ichiloto\Editor\Actors\ActorReferenceInventory; +use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\Database\PhpDataFile; use Ichiloto\Editor\ProjectWorkspace; use Throwable; @@ -37,6 +38,13 @@ public function validate(ProjectWorkspace $workspace): array foreach ($workspace->maps as $map) { $payloads[$map->dataPath] = $map->getEditableData(); } + // Summons as they stand, unsaved edits included; their wielders name actors. + $root = rtrim($workspace->projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + foreach ($workspace->cutscenes?->assets(CutsceneType::SUMMON) ?? [] as $summon) { + if (! $summon->isDeleted() && str_starts_with($summon->dataPath(), $root)) { + $payloads[substr($summon->dataPath(), strlen($root))] = $summon->payload(); + } + } $issues = []; foreach (['battle', 'dialogue', 'menus'] as $name) { $relative = 'assets/Data/Presentation/' . $name . '.php'; diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index ab360ce..243686c 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -1407,10 +1407,6 @@ protected function checkSummons(ProjectWorkspace $workspace): array 'quests' => $catalog->valuesFor('quests'), 'inventory' => $catalog->valuesFor('inventory'), ]; - $actorNames = array_map( - static fn(\Ichiloto\Editor\ProjectActor $actor): string => $actor->getName(), - $workspace->actorDatabase->getActors(), - ); $skillNames = $workspace->getSkillNames(); $definitions = []; @@ -1457,7 +1453,7 @@ protected function checkSummons(ProjectWorkspace $workspace): array $issues = [ ...$issues, ...$this->checkSummonAvailability($data, $where, $known), - ...$this->checkSummonWielders($data, $where, $actorNames), + ...$this->checkSummonWielders($data, $where), ]; } @@ -1525,8 +1521,13 @@ protected function checkSummonAvailability(array $data, string $where, array $kn return [...$issues, ...$this->checkConditions($conditions, $where, $known)]; } - /** @return Issue[] */ - protected function checkSummonWielders(array $data, string $where, array $actorNames): array + /** + * The wielder policy's shape. Which actors it names is checked with every + * other actor reference, by stable id, by ActorReferenceValidator. + * + * @return Issue[] + */ + protected function checkSummonWielders(array $data, string $where): array { if (! array_key_exists('wielders', $data)) { return []; @@ -1557,18 +1558,6 @@ protected function checkSummonWielders(array $data, string $where, array $actorN if (! is_array($characters) || $characters === []) { $issues[] = Issue::error($where, 'Its character eligibility list is empty or malformed.', 'Name at least one project actor.'); - } else { - foreach ($characters as $character) { - $name = is_string($character) ? trim($character) : ''; - - if ($name === '' || ! in_array($name, $actorNames, true)) { - $issues[] = Issue::error( - $where, - sprintf('Its eligible character "%s" does not exist.', $name !== '' ? $name : '(malformed)'), - 'Use an exact actor identity from assets/Data/Actors.', - ); - } - } } } @@ -1607,7 +1596,7 @@ protected function checkActorSummonAssignments(ProjectWorkspace $workspace, arra $where = 'actor ' . $actor->getName(); $reported = []; - foreach ($diagnostics->forActor($actor->getName(), $actor->getClassName(), $actor->getSummons()) as $row) { + foreach ($diagnostics->forActor($actor->getRuntimeId(), $actor->getClassName(), $actor->getSummons()) as $row) { foreach ($row['problems'] as $problem) { // One assignment list reports duplicates once, as before. if (str_starts_with($problem['message'], 'Its summon assignments contain duplicate ids') && isset($reported['duplicates'])) { diff --git a/tests/Unit/ActorIdentityMigrationTest.php b/tests/Unit/ActorIdentityMigrationTest.php index 5a896b2..126cd13 100644 --- a/tests/Unit/ActorIdentityMigrationTest.php +++ b/tests/Unit/ActorIdentityMigrationTest.php @@ -378,3 +378,67 @@ function createLegacyActorProject(): array ["'actor' => 'hero'", " 'hero' => ['artwork' => ['image' => 'Graphics/Kaelion/Idle.png']"], $battlers)) ->and(ActorIdentityMigration::planProject($root)->getChangedPaths())->toBe([]); }); + +/** A summon whose wielder policy names actors, beside the other policy fields a migration must leave alone. */ +function writeWielderSummon(string $root, string $characters): string +{ + $directory = $root . '/assets/Cutscenes/Summons/ember'; + @mkdir($directory, 0777, true); + file_put_contents($directory . '/ember.timeline.php', " 12, 'lengthFrames' => 1, 'tracks' => [], 'cues' => []];"); + $path = $directory . '/ember.data.php'; + file_put_contents($path, " 'ember', 'name' => 'Ember', 'linkedActionId' => 'Fireball',\n 'wielders' => ['mode' => 'characters', 'characters' => [{$characters}], 'tenancy' => 'exclusive']];\n"); + + return $path; +} + +it('repairs summon wielders to stable ids, source preserving, leaving mode and tenancy as they are', function () { + [$root, $actorPath] = createLegacyActorProject(); + file_put_contents($actorPath, " ['id' => 'hero', 'name' => 'Kaelion']];"); + $summon = writeWielderSummon($root, "/* lead */ 'Kaelion'"); + $before = sourceHashTree($root); + + $plan = ActorIdentityMigration::planProject($root); + expect($plan->getChangedPaths())->toContain($summon)->and(sourceHashTree($root))->toBe($before); + $plan->apply(); + + $written = (string) file_get_contents($summon); + expect($written)->toContain('// Keep this summon header.', "/* lead */ 'hero'", "'mode' => 'characters'", "'tenancy' => 'exclusive'") + ->and(ActorIdentityMigration::planProject($root)->getChangedPaths())->toBe([]) + ->and(new \Ichiloto\Editor\Validation\ActorReferenceValidator()->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)))->toBe([]); + $plan->revert(); + expect(sourceHashTree($root))->toBe($before); +}); + +it('refuses a summon wielder two actors are displayed as, and never hands one actor\'s id to another', function () { + [$root, $actorPath] = createLegacyActorProject(); + file_put_contents($actorPath, " ['id' => 'first', 'name' => 'Twin']];"); + file_put_contents(dirname($actorPath) . '/Second.php', " ['id' => 'second', 'name' => 'Twin']];"); + $summon = writeWielderSummon($root, "'Twin'"); + $before = sourceHashTree($root); + expect(fn() => ActorIdentityMigration::planProject($root))->toThrow(RuntimeException::class, 'ambiguous') + ->and(sourceHashTree($root))->toBe($before); + + // An explicit id wins over another actor displayed under the same text. + file_put_contents(dirname($actorPath) . '/Third.php', " ['id' => 'Twin', 'name' => 'Modern']];"); + expect(ActorIdentityMigration::planProject($root)->getChangedPaths())->not->toContain($summon); +}); + +it('judges summon eligibility by stable id, so a display rename keeps a holder and a shared name grants nothing', function () { + $diagnostics = new \Ichiloto\Editor\Database\SummonAssignmentDiagnostics([ + 'ember' => ['id' => 'ember', 'wielders' => ['mode' => 'characters', 'characters' => ['Hero'], 'tenancy' => 'exclusive']], + ]); + [$root, $actorPath] = createLegacyActorProject(); + file_put_contents($actorPath, " ['id' => 'hero', 'name' => 'Kaelion Renamed']];"); + file_put_contents(dirname($actorPath) . '/Impostor.php', " ['id' => 'impostor', 'name' => 'Hero']];"); + file_put_contents(dirname($actorPath) . '/Legacy.php', " ['name' => 'Hero']];"); + $actors = []; + foreach (ProjectActorDatabase::fromProject($root)->getActors() as $actor) { + $actors[$actor->getName() . '/' . ($actor->getDefinitionId() ?: 'legacy')] = $actor->getRuntimeId(); + } + + expect($diagnostics->forActor($actors['Kaelion Renamed/hero'], 'Vanguard', ['ember'])[0]['problems'])->toBe([]) + ->and($diagnostics->forActor($actors['Hero/impostor'], 'Vanguard', ['ember'])[0]['problems'])->not->toBe([]) + // A legacy actor declaring no id is known by its authored name until the migration writes one. + ->and($actors['Hero/legacy'])->toBe('Hero') + ->and($diagnostics->forActor($actors['Hero/legacy'], 'Vanguard', ['ember'])[0]['problems'])->toBe([]); +}); diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index f56548e..4cb4c2e 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -272,7 +272,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string expect(issuesMentioning($issues, 'unknown condition type "lunar_phase"'))->toHaveCount(1) ->and(issuesMentioning($issues, 'invalid tenancy "forever"'))->toHaveCount(1) - ->and(issuesMentioning($issues, 'eligible character "No Such Actor"'))->toHaveCount(1) + ->and(issuesMentioning($issues, "Unresolved actor reference 'No Such Actor'"))->toHaveCount(1) ->and(issuesMentioning($issues, 'links to action "Missing Action"'))->toHaveCount(1); }); From b01ac841147e6df3e6a57fb59533f61d9648addd Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 06:17:34 +0200 Subject: [PATCH 198/344] feat(cutscenes): author how an image track fills its cells Effect and summon image tracks take the Engine's CanvasImageFit as Fit: stretch or contain, removed when emptied so absence keeps stretching. The timeline description reports it with its row key and choices. Appending an entry to an array whose closing bracket shares the last entry's line now writes it after that entry and leaves the bracket where it is. It was inserted at the start of that line, ahead of the last entry, so the edited file would not read back and any new key on such a track could not be saved. --- docs/manual.md | 2 ++ .../Source/PhpArraySourceDocument.php | 18 ++++++++++- src/Database/CutsceneSchemas.php | 5 ++++ src/Session/EditorSession.php | 12 ++++---- .../Cutscenes/ArrayEntryInsertionTest.php | 12 ++++++++ .../Session/CutsceneRecordSessionTest.php | 30 +++++++++++++++++++ 6 files changed, 72 insertions(+), 7 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 1d8062b..923e55a 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -2040,6 +2040,8 @@ command's result lands: at the `end`, a `frame` or a `cue`) and for a battle stroke drawn one way, `Facing` (`west` or `east`; the empty choice removes it, and the stroke stays as drawn). An image track names its PNG through the asset picker, its sheet columns and rows, its cell size, its +`Fit` (`stretch` fills the cells; `contain` keeps the sheet cell's own +proportions inside them; the empty choice removes it, so it stretches), its depth and, in battle, its `Attachment` (`center`, `head` or `ground` on the battler; the empty choice removes it, so the image is centred) and `Pivot` (the point of the sheet cell that sits there, `x, y` from 0 to 1, such as diff --git a/src/Cutscenes/Source/PhpArraySourceDocument.php b/src/Cutscenes/Source/PhpArraySourceDocument.php index b4a87c3..a6d5a00 100644 --- a/src/Cutscenes/Source/PhpArraySourceDocument.php +++ b/src/Cutscenes/Source/PhpArraySourceDocument.php @@ -781,6 +781,22 @@ public function appendEntriesEdit(SourceNode $array, string $lines, ?SourceEntry return [$expansion]; } + $closingLine = $this->lineStartOf($array->bodyEnd); + $last = $array->entries === [] ? null : $array->entries[count($array->entries) - 1]; + + if ($last !== null && $lastSurviving === $last && $last->end > $closingLine) { + // The closing bracket shares the last entry's line, so the + // start of that line is ahead of the entry: the lines follow the + // entry instead, and the bracket stays where it is written, + // after a comma only when the entry had one. + $trailingComma = $last->separatorEnd !== $last->end; + $body = substr(rtrim($lines, "\n"), 0, -1) . ($trailingComma ? ',' : ''); + + return $trailingComma + ? [[$last->separatorEnd, $last->separatorEnd, "\n" . $body]] + : [[$last->end, $last->end, ",\n" . $body]]; + } + $edits = []; if ($lastSurviving !== null && $lastSurviving->separatorEnd === $lastSurviving->end) { @@ -788,7 +804,7 @@ public function appendEntriesEdit(SourceNode $array, string $lines, ?SourceEntry $edits[] = [$lastSurviving->end, $lastSurviving->end, ',']; } - $edits[] = [$this->lineStartOf($array->bodyEnd), $this->lineStartOf($array->bodyEnd), $lines]; + $edits[] = [$closingLine, $closingLine, $lines]; return $edits; } diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index e009957..4fc4c52 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -9,6 +9,7 @@ use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Animations\Timelines\EffectCadence; use Ichiloto\Engine\Animations\Timelines\EffectImageAttachment; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasImageFit; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; @@ -615,6 +616,10 @@ public static function imageTrackFields(): array new RecordField('sheet.rows', 'Sheet Rows', InputControlType::INTEGER, displayDefault: '1'), new RecordField('cells.width', 'Cell Width', InputControlType::INTEGER, displayDefault: '1'), new RecordField('cells.height', 'Cell Height', InputControlType::INTEGER, displayDefault: '1'), + // How a sheet cell fills its cells: stretched to them, or kept to its + // own proportions inside them (the empty choice removes it: stretched). + new RecordField('fit', 'Fit', options: ['', ...array_map(static fn(CanvasImageFit $fit): string => $fit->value, CanvasImageFit::cases())], + removeWhenEmpty: true, displayDefault: '(' . CanvasImageFit::STRETCH->value . ')'), new RecordField('depth', 'Depth', options: ['front', 'behind'], removeWhenEmpty: true, displayDefault: 'front'), // Battle only: the battler point the image is placed on (the empty // choice removes it: its centre), and the point of the sheet cell that diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 2d413c2..76993e8 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1228,15 +1228,15 @@ public function describeCutsceneTimeline(string $category, int $index): array $field = (string) ($row['key']['field'] ?? ''); if (preg_match('/^track(\d+)(Id|Type|Presentation)$/', $field, $match) === 1) { $tracks[(int) $match[1]][strtolower($match[2])] = (string) $row['value']; - } elseif (preg_match('/^track(\d+)(Asset|Sheetcolumns|Sheetrows|Attachment|Pivot)$/', $field, $match) === 1) { - // An image track's art, and the keys that edit where it sits on its anchor. - $art = ['Asset' => 'asset', 'Sheetcolumns' => 'columns', 'Sheetrows' => 'rows', 'Attachment' => 'attachment', 'Pivot' => 'pivot'][$match[2]]; + } elseif (preg_match('/^track(\d+)(Asset|Sheetcolumns|Sheetrows|Fit|Attachment|Pivot)$/', $field, $match) === 1) { + // An image track's art, and the keys that edit how it fills its cells and where it sits on its anchor. + $art = ['Asset' => 'asset', 'Sheetcolumns' => 'columns', 'Sheetrows' => 'rows', 'Fit' => 'fit', 'Attachment' => 'attachment', 'Pivot' => 'pivot'][$match[2]]; $tracks[(int) $match[1]]['art'][$art] = in_array($art, ['columns', 'rows'], true) ? max(1, (int) $row['value']) : (string) ($row['raw'] ?? $row['value']); - if (in_array($art, ['attachment', 'pivot'], true)) { + if (in_array($art, ['fit', 'attachment', 'pivot'], true)) { $tracks[(int) $match[1]]['art'][$art . 'Key'] = $row['key']; } - if ($art === 'attachment') { - $tracks[(int) $match[1]]['art']['attachmentOptions'] = array_values(array_filter($row['options'] ?? [], static fn(mixed $option): bool => $option !== '')); + if (in_array($art, ['fit', 'attachment'], true)) { + $tracks[(int) $match[1]]['art'][$art . 'Options'] = array_values(array_filter($row['options'] ?? [], static fn(mixed $option): bool => $option !== '')); } } elseif (preg_match('/^track(\d+)Keyframe(\d+)(Frame|Duration|SourceFrame)$/', $field, $match) === 1) { $name = lcfirst($match[3]); diff --git a/tests/Unit/Cutscenes/ArrayEntryInsertionTest.php b/tests/Unit/Cutscenes/ArrayEntryInsertionTest.php index 711694f..50f660a 100644 --- a/tests/Unit/Cutscenes/ArrayEntryInsertionTest.php +++ b/tests/Unit/Cutscenes/ArrayEntryInsertionTest.php @@ -94,3 +94,15 @@ function insertEntry(string $source, array $path, int $position, int|string|null ->and(insertEntry($source, ['inline'], 0, null, "'v1'")) ->toBe(" [\n // None yet.\n ],\n 'inline' => [/* none */ 'v1'],\n];\n"); }); + +it('appends after the last entry when the closing bracket shares its line, keeping the bracket and the comma style', function (string $comma) { + $source = " [\n ['type' => 'image', 'sheet' => ['columns' => 2],\n 'keyframes' => [['frame' => 0]]{$comma}],\n ['type' => 'text'],\n ],\n];\n"; + $old = eval('?>' . $source); + $new = $old; + $new['tracks'][0]['fit'] = 'contain'; + $new['tracks'][0]['depth'] = 'behind'; + $written = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source; + + expect($written)->toBe(" [\n ['type' => 'image', 'sheet' => ['columns' => 2],\n 'keyframes' => [['frame' => 0]],\n 'fit' => 'contain',\n 'depth' => 'behind'{$comma}],\n ['type' => 'text'],\n ],\n];\n") + ->and(eval('?>' . $written))->toBe($new); +})->with(['without a trailing comma' => '', 'with a trailing comma' => ',']); diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php index 446aa14..eb387f7 100644 --- a/tests/Unit/Session/CutsceneRecordSessionTest.php +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -165,3 +165,33 @@ writeTilesetTestPng($root . '/assets/Graphics/Summons/Wisp.png', 16, 8); expect($asset->compiledSummon(Ichiloto\Engine\Animations\Timelines\EffectPresentation::GRAPHICAL)->fps)->toBe(12); }); + +it('sets how an image track fills its cells, one undo step, keeping the timeline\'s own source', function () { + $root = cutsceneProject(); + $file = $root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.timeline.php'; + // Written as authors write tracks: the keys run on from the first line and the bracket closes the last. + file_put_contents($file, str_replace([" 'lengthFrames' => 24,", " ['type' => 'text', 'id' => 'name',"], [" 'lengthFrames' => 24,\n 'restFrame' => 0,", + " ['type' => 'image', 'id' => 'wisp-art', 'asset' => 'Graphics/Summons/Wisp.png', 'sheet' => ['columns' => 2, 'rows' => 1],\n" + . " 'keyframes' => [['frame' => 0, 'sourceFrame' => 0]]],\n ['type' => 'text', 'id' => 'name',"], (string) file_get_contents($file))); + mkdir($root . '/assets/Graphics/Summons', 0o777, true); + writeTilesetTestPng($root . '/assets/Graphics/Summons/Wisp.png', 16, 8); + $session = EditorSession::open($root); + $art = fn(): array => array_find($session->describeCutsceneTimeline('cutscenes/summon', 0)['tracks'], static fn(array $track): bool => $track['id'] === 'wisp-art')['art']; + + // Unset reads as the default it falls back to, in brackets. + expect($art()['fit'])->toBe('(stretch)') + ->and($art()['fitOptions'])->toBe(['stretch', 'contain']); + $session->applyDatabaseRecord('cutscenes/summon', 0, $art()['fitKey'], 'contain'); + expect($art()['fit'])->toBe('contain'); + $session->undo(); + expect($art()['fit'])->toBe('(stretch)'); + $session->redo(); + $session->saveAll(); + + $written = (string) file_get_contents($file); + expect($written)->toContain('// The wisp itself.', "'keyframes' => [['frame' => 0, 'sourceFrame' => 0]],\n 'fit' => 'contain'],") + ->and($session->listUnsavedChanges())->toBe([]); + $session->applyDatabaseRecord('cutscenes/summon', 0, $art()['fitKey'], ''); + $session->saveAll(); + expect((string) file_get_contents($file))->not->toContain("'fit'")->toContain('// The wisp itself.'); +}); From b455ec87a22604e0576c0790c327e510a392bd15 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 09:24:59 +0200 Subject: [PATCH 199/344] feat(cutscenes): preview a cinematic through the session as the terminal plays it cutscenes.cinematicPreview starts the cinematic as it stands, unsaved edits included, in the Editor's isolated preview scene with silent audio, from where it is triggered; cutscenes.cinematicControl plays, pauses, steps, advances while playing and restarts it, and cutscenes.cinematicStop ends it. Asked to keep it, an unchanged cinematic carries on where it is and an edited one starts again. Where a cinematic preview starts is now one shared rule, used by the Cutscenes workspace and the session alike. --- src/Cutscenes/Editing/CutscenePreviewPane.php | 67 +---------- .../Preview/CinematicPreviewOrigin.php | 83 +++++++++++++ src/Session/EditorSession.php | 113 ++++++++++++++++++ src/Session/SessionHost.php | 5 + .../Session/CutsceneRecordSessionTest.php | 34 ++++++ 5 files changed, 239 insertions(+), 63 deletions(-) create mode 100644 src/Cutscenes/Preview/CinematicPreviewOrigin.php diff --git a/src/Cutscenes/Editing/CutscenePreviewPane.php b/src/Cutscenes/Editing/CutscenePreviewPane.php index 4aa0352..f3180cb 100644 --- a/src/Cutscenes/Editing/CutscenePreviewPane.php +++ b/src/Cutscenes/Editing/CutscenePreviewPane.php @@ -7,6 +7,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Cutscenes\CutsceneLaneOverview; use Ichiloto\Editor\Cutscenes\CutsceneType; +use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewOrigin; use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewSession; use Ichiloto\Editor\Cutscenes\Preview\EffectPreviewStage; use Ichiloto\Editor\Cutscenes\Preview\PreviewSnapshot; @@ -14,7 +15,6 @@ use Ichiloto\Editor\EditorWindow; use Ichiloto\Editor\Playtest\PlaytestLauncher; use Ichiloto\Editor\Playtest\PlaytestOverlay; -use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Status\StatusLevel; use Ichiloto\Editor\UI\CutscenesScreen; @@ -975,9 +975,7 @@ private function playtestSelectedCinematic(): void } /** - * Where a preview or playtest starts: the map event that triggers the - * cinematic when one exists, otherwise the cinematic's start map (or the - * selected map) at its first open tile. + * Where a preview or playtest starts, shared with every editor; the selected map stands in for a start map. * * @return array{mapId: string|null, x: int, y: int, marker: string|null} */ @@ -985,68 +983,11 @@ private function cinematicPreviewOrigin(CutsceneAsset $asset, ?string $startMap) { $workspace = $this->workspace; - if ($workspace instanceof ProjectWorkspace) { - foreach ($workspace->maps as $map) { - foreach ($map->getEventDefinitions() as $marker => $definition) { - if (! is_array($definition) || ! str_contains(strval($definition['class'] ?? ''), 'CinematicEventTrigger')) { - continue; - } - - $data = is_array($definition['data'] ?? null) ? $definition['data'] : []; - - if (trim(strval($data['cinematicId'] ?? '')) !== $asset->id) { - continue; - } - - // Where the runtime places the marker: its first cell. - $first = $map->getEventArea((string) $marker)?->firstCell; - - if ($first !== null) { - return ['mapId' => $map->mapId, 'x' => (int) $first->x, 'y' => (int) $first->y, 'marker' => (string) $marker]; - } - } - } - } - - $map = null; - - if ($startMap !== null && $workspace instanceof ProjectWorkspace) { - foreach ($workspace->maps as $candidate) { - if ($candidate->mapId === $startMap) { - $map = $candidate; - } - } - } - - $map ??= $this->getSelectedMap(); - - if (! $map instanceof ProjectMap) { + if (! $workspace instanceof ProjectWorkspace) { return ['mapId' => $startMap, 'x' => 1, 'y' => 1, 'marker' => null]; } - [$x, $y] = $this->firstOpenTile($map); - - return ['mapId' => $map->mapId, 'x' => $x, 'y' => $y, 'marker' => null]; - } - - /** - * The first tile that is not a wall-like glyph, row by row. - * - * @return array{0: int, 1: int} - */ - private function firstOpenTile(ProjectMap $map): array - { - for ($y = 0; $y < $map->getHeight(); $y++) { - for ($x = 0; $x < $map->getWidth(); $x++) { - $symbol = $map->getTileSymbol($x, $y); - - if ($symbol === ' ') { - return [$x, $y]; - } - } - } - - return [0, 0]; + return CinematicPreviewOrigin::locate($workspace, $asset, $startMap, $this->getSelectedMap()); } /** diff --git a/src/Cutscenes/Preview/CinematicPreviewOrigin.php b/src/Cutscenes/Preview/CinematicPreviewOrigin.php new file mode 100644 index 0000000..0b526c8 --- /dev/null +++ b/src/Cutscenes/Preview/CinematicPreviewOrigin.php @@ -0,0 +1,83 @@ +maps as $map) { + foreach ($map->getEventDefinitions() as $marker => $definition) { + if (! is_array($definition) || ! str_contains(strval($definition['class'] ?? ''), 'CinematicEventTrigger')) { + continue; + } + + $data = is_array($definition['data'] ?? null) ? $definition['data'] : []; + + if (trim(strval($data['cinematicId'] ?? '')) !== $asset->id) { + continue; + } + + // Where the runtime places the marker: its first cell. + $first = $map->getEventArea((string) $marker)?->firstCell; + + if ($first !== null) { + return ['mapId' => $map->mapId, 'x' => (int) $first->x, 'y' => (int) $first->y, 'marker' => (string) $marker]; + } + } + } + + $map = null; + + if ($startMap !== null) { + foreach ($workspace->maps as $candidate) { + if ($candidate->mapId === $startMap) { + $map = $candidate; + } + } + } + + $map ??= $openMap; + + if (! $map instanceof ProjectMap) { + return ['mapId' => $startMap, 'x' => 1, 'y' => 1, 'marker' => null]; + } + + [$x, $y] = self::findFirstOpenTile($map); + + return ['mapId' => $map->mapId, 'x' => $x, 'y' => $y, 'marker' => null]; + } + + /** + * The first tile that is not a wall-like glyph, row by row. + * + * @return array{0: int, 1: int} + */ + private static function findFirstOpenTile(ProjectMap $map): array + { + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x < $map->getWidth(); $x++) { + if ($map->getTileSymbol($x, $y) === ' ') { + return [$x, $y]; + } + } + } + + return [0, 0]; + } +} diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 76993e8..eae83b6 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -83,6 +83,8 @@ use Ichiloto\Engine\Entities\Troop; use Ichiloto\Engine\Scenes\Battle\BattleConfig; use Ichiloto\Editor\Cutscenes\Preview\SummonBattlePreview; +use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewOrigin; +use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewSession; use Ichiloto\Engine\Battle\Presentation\BattleCommandTimeline; use Ichiloto\Engine\Cutscenes\Summons\SummonCompiledCutscene; use Ichiloto\Engine\Cutscenes\Summons\SummonCutsceneDefinition; @@ -118,6 +120,11 @@ final class EditorSession private ?string $battleTestTroop = null; /** The summon battle preview last built, and what it was built from, so seeking reuses it until either changes. */ private ?array $summonBattlePreview = null; + /** The cinematic previewed as the terminal plays it, and which one it is. */ + private ?CinematicPreviewSession $cinematicPreview = null; + private ?string $cinematicPreviewId = null; + /** The cinematic as it stood when its preview started, so an unchanged one keeps playing. */ + private ?string $cinematicPreviewFingerprint = null; /** How actors are authored, with what this editor's actor panes show. */ private readonly ActorAuthoring $actorAuthoring; @@ -1295,6 +1302,112 @@ public function readCutscenePreview(string $category, int $index, int $frame, in 'cues' => array_values($preview->cuesAt($frame))]; } + /** + * Starts previewing a cinematic as it stands, unsaved edits included, as + * the terminal plays it: from the map event that triggers it, else its + * start map, in an isolated scene with silent audio, the way the + * Cutscenes workspace previews it. Nothing is written. A preview already + * running ends first, unless `keep` is asked and it is of this cinematic + * as it still stands: then it carries on where it is. + * + * @return array The preview, as {@see describeCinematicPreview()}. + * @throws SessionRefusal When the cinematic is unknown or does not compile. + */ + public function startCinematicPreview(int $index, int $width, int $height, bool $play = false, bool $keep = false): array + { + $type = CutsceneType::CINEMATIC; + $id = $this->workspace->cutscenes?->ids($type)[$index] ?? null; + $asset = ($id === null ? null : $this->workspace->cutscenes?->find($type, $id)) + ?? throw new SessionRefusal(sprintf('There is no %s %d.', $type->noun(), $index)); + $fingerprint = hash('xxh128', serialize($asset->payload())); + if ($keep && $this->cinematicPreview !== null && $this->cinematicPreviewId === $asset->id && $this->cinematicPreviewFingerprint === $fingerprint) { + return $this->describeCinematicPreview(); + } + $this->stopCinematicPreview(); + try { + $definition = $asset->cinematicDefinition(); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s does not compile: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + $origin = CinematicPreviewOrigin::locate($this->workspace, $asset, $definition->startMap); + try { + $this->cinematicPreview = CinematicPreviewSession::start($this->workspace->projectRoot, $definition, [ + 'mapId' => $origin['mapId'], 'x' => $origin['x'], 'y' => $origin['y'], 'width' => max(20, $width), 'height' => max(8, $height), + ]); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s cannot be previewed: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + $this->cinematicPreviewId = $asset->id; + $this->cinematicPreviewFingerprint = $fingerprint; + if ($play) { + $this->cinematicPreview->play(); + } + + return $this->describeCinematicPreview(); + } + + /** + * Plays, pauses, steps, advances while playing, or restarts the + * cinematic previewed. An advance is the time since the last one, so the + * preview keeps real time however often it is asked. + * + * @param 'play'|'pause'|'step'|'tick'|'restart' $action + * @return array The preview, as {@see describeCinematicPreview()}. + * @throws SessionRefusal When no cinematic is previewed or the action is unknown. + */ + public function controlCinematicPreview(string $action, float $seconds = 0.0): array + { + $preview = $this->cinematicPreview ?? throw new SessionRefusal('No cinematic is being previewed.'); + match ($action) { + 'play' => $preview->play(), + 'pause' => $preview->pause(), + 'step' => $preview->step(), + 'tick' => $preview->tick(max(0.0, min($seconds, 1.0))), + 'restart' => $this->cinematicPreview = $preview->restart(), + default => throw new SessionRefusal(sprintf('A cinematic preview cannot %s.', $action)), + }; + + return $this->describeCinematicPreview(); + } + + /** + * Ends the cinematic preview, releasing its scene. + * + * @return array{stopped: bool} Whether one was running. + */ + public function stopCinematicPreview(): array + { + $stopped = $this->cinematicPreview !== null; + $this->cinematicPreview?->dispose(); + $this->cinematicPreview = null; + $this->cinematicPreviewId = null; + $this->cinematicPreviewFingerprint = null; + + return ['stopped' => $stopped]; + } + + /** + * The cinematic previewed: which one, its terminal picture now, how long + * it has run, its status, whether it plays or has finished, and why it + * stopped or waits, when it does. + * + * @return array + */ + private function describeCinematicPreview(): array + { + $preview = $this->cinematicPreview ?? throw new SessionRefusal('No cinematic is being previewed.'); + + return [ + 'id' => $this->cinematicPreviewId, + 'lines' => array_values($preview->frame()), + 'elapsed' => $preview->elapsed(), + 'status' => $preview->status(), + 'playing' => $preview->isPlaying(), + 'finished' => $preview->isFinished(), + 'failure' => $preview->failure()['message'] ?? null, + 'wait' => $preview->waitDescription(), + ]; + } /** * A summon as the battle test plays it, at one command frame: the * Engine's command preview of the summon as it stands, unsaved edits diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index a3ebf03..f8fdc10 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -334,6 +334,11 @@ private function dispatch(string $method, array $params): array self::requireInt($params, 'index'), self::requireString($params, 'presentation')), 'cutscenes.preview' => $session->readCutscenePreview(self::requireString($params, 'category'), self::requireInt($params, 'index'), self::requireInt($params, 'frame'), self::requireInt($params, 'width'), self::requireInt($params, 'height')), + 'cutscenes.cinematicPreview' => $session->startCinematicPreview(self::requireInt($params, 'index'), self::requireInt($params, 'width'), + self::requireInt($params, 'height'), ($params['play'] ?? false) === true, ($params['keep'] ?? false) === true), + 'cutscenes.cinematicControl' => $session->controlCinematicPreview(self::requireString($params, 'action'), + is_int($params['seconds'] ?? null) || is_float($params['seconds'] ?? null) ? (float) $params['seconds'] : 0.0), + 'cutscenes.cinematicStop' => $session->stopCinematicPreview(), 'cutscenes.battlePreview' => $session->readSummonBattlePreview(self::requireInt($params, 'index'), self::requireInt($params, 'frame'), ($params['reducedMotion'] ?? false) === true), 'references.list' => $session->listReferences( diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php index eb387f7..7df0b6e 100644 --- a/tests/Unit/Session/CutsceneRecordSessionTest.php +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -195,3 +195,37 @@ $session->saveAll(); expect((string) file_get_contents($file))->not->toContain("'fit'")->toContain('// The wisp itself.'); }); + +it('previews a cinematic as the terminal plays it, and plays, steps, restarts and stops it, writing nothing', function () { + $root = cutsceneProject(); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + $index = array_search('harbour-lanterns', array_map(strval(...), $session->listDatabaseRecords('cutscenes/cinematic')['records']), true); + $index = $index === false ? 0 : $index; + + $started = $session->startCinematicPreview($index, 60, 16); + expect($started)->toMatchArray(['id' => 'harbour-lanterns', 'playing' => false]) + ->and($started['lines'])->not->toBe([]) + ->and($started['failure'])->toBeNull(); + + $session->controlCinematicPreview('play'); + $played = $session->controlCinematicPreview('tick', 0.5); + expect($played['elapsed'])->toBeGreaterThan(0.0); + $session->controlCinematicPreview('pause'); + $paused = $session->controlCinematicPreview('tick', 0.5); + expect($paused['elapsed'])->toBe($played['elapsed']) + ->and($session->controlCinematicPreview('step')['elapsed'])->toBeGreaterThan($played['elapsed']) + // Asked to keep it, an unchanged cinematic carries on; an edited one starts again. + ->and($session->startCinematicPreview($index, 60, 16, keep: true)['elapsed'])->toBeGreaterThan($played['elapsed']) + ->and($session->controlCinematicPreview('restart')['elapsed'])->toBe(0.0) + ->and(fn() => $session->controlCinematicPreview('rewind'))->toThrow(SessionRefusal::class) + ->and($session->controlCinematicPreview('step')['elapsed'])->toBeGreaterThan(0.0); + $name = array_find($session->readDatabaseRecord('cutscenes/cinematic', $index)['rows'], static fn(array $row): bool => trim($row['label']) === 'Name'); + $session->applyDatabaseRecord('cutscenes/cinematic', $index, $name['key'], 'Harbour Lanterns Renamed'); + expect($session->startCinematicPreview($index, 60, 16, keep: true)['elapsed'])->toBe(0.0) + ->and($session->stopCinematicPreview())->toBe(['stopped' => true]) + ->and(fn() => $session->controlCinematicPreview('play'))->toThrow(SessionRefusal::class) + ->and(fn() => $session->startCinematicPreview(9, 60, 16))->toThrow(SessionRefusal::class); + $session->undo(); + expect(sourceHashTree($root))->toBe($before); +}); From 3b98cf99bafb58136cbd7f38b3aeacb96acf0cc7 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 09:47:17 +0200 Subject: [PATCH 200/344] feat(cutscenes): preview a summon in either presentation on one authored playhead cutscenes.battlePreview takes the presentation: graphical plays the graphical compile with the terminal one paired, terminal plays the terminal compile alone at its own cadence and neither compiles nor reads a graphical image. It also takes a frame of the summon's own timeline, drawn where the battle plays it through the Engine's authored-frame mapping, and each frame reports the authored frames it draws, so the timeline and the battle share one playhead. One preview is kept per presentation, so switching between them is cheap. --- src/Cutscenes/Preview/SummonBattlePreview.php | 39 ++++++++++++--- src/Session/EditorSession.php | 49 ++++++++++++------- src/Session/SessionHost.php | 8 ++- .../SummonBattlePreviewSessionTest.php | 34 +++++++++++++ 4 files changed, 105 insertions(+), 25 deletions(-) diff --git a/src/Cutscenes/Preview/SummonBattlePreview.php b/src/Cutscenes/Preview/SummonBattlePreview.php index 032849e..1d21133 100644 --- a/src/Cutscenes/Preview/SummonBattlePreview.php +++ b/src/Cutscenes/Preview/SummonBattlePreview.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor\Cutscenes\Preview; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Battle\Actions\SkillBattleAction; use Ichiloto\Engine\Battle\BattlePacing; use Ichiloto\Engine\Battle\BattleTargetPolicy; @@ -19,7 +20,8 @@ use InvalidArgumentException; /** - * A summon as a battle plays it, frame by frame, for its editors: the + * A summon as a battle plays it, frame by frame, for its editors, in one + * presentation, graphical or terminal: the * Engine's own command preview of the summon's linked action, cast by a * member of the given battle the summon's wielder policy allows, at the * targets that action's scope takes, with the project's battle pacing. @@ -40,6 +42,7 @@ final class SummonBattlePreview */ private function __construct( private readonly BattleCommandPreview $preview, + private readonly BattleCommandTimeline $plan, public readonly string $caster, public readonly array $targets, public readonly array $phases, @@ -47,19 +50,25 @@ private function __construct( } /** - * @param SummonCompiledCutscene $graphical The summon compiled for the graphical battle. + * A graphical preview plays the graphical compile, with the terminal one + * as its paired lane; a terminal preview plays the terminal compile alone, + * at its own cadence, and reads no graphical image. + * + * @param SummonCompiledCutscene|null $graphical The summon compiled for the graphical battle, which a graphical preview requires. * @param SummonCompiledCutscene $terminal The same summon compiled for the terminal arena. + * @param BattlePresentationCatalog|null $catalog The battle presentation, which a graphical preview requires. * @throws InvalidArgumentException When no member may cast it, its action is unknown or it has no one to target. */ public static function create( SummonCutsceneDefinition $definition, - SummonCompiledCutscene $graphical, + ?SummonCompiledCutscene $graphical, SummonCompiledCutscene $terminal, BattleConfig $battle, SkillCatalog $skills, BattlePacing $pacing, - BattlePresentationCatalog $catalog, + ?BattlePresentationCatalog $catalog, string $assetRoot, + EffectPresentation $presentation, ): self { $battlers = $battle->partyRoster->battlers; $policy = $definition->wielders; @@ -74,10 +83,15 @@ public static function create( if ($targets === []) { throw new InvalidArgumentException(sprintf('%s has no one to target in the battle test.', $definition->name)); } - $plan = new BattleCommandTimeline($pacing->getTurnTimings($action), target: $graphical, terminalTarget: $terminal); + $timings = $pacing->getTurnTimings($action); + $plan = $presentation === EffectPresentation::TERMINAL + ? new BattleCommandTimeline($timings, target: $terminal) + : new BattleCommandTimeline($timings, target: $graphical ?? throw new InvalidArgumentException('A graphical preview plays the graphical compile.'), terminalTarget: $terminal); return new self( - new BattleCommandPreview($battle, $plan, $caster, $targets, BattlePoseRole::SUMMON, $catalog, $assetRoot), + new BattleCommandPreview($battle, $plan, $caster, $targets, BattlePoseRole::SUMMON, + $presentation === EffectPresentation::TERMINAL ? null : $catalog, $assetRoot, $presentation), + $plan, $caster->name, array_map(static fn($target): string => $target->name, $targets), $plan->phases, @@ -97,4 +111,17 @@ public function readFrame(int $frame, bool $reducedMotion): array { return $this->preview->getFrameAtIndex($frame, $reducedMotion)->toArray(); } + + /** + * The command frame that draws one of the summon's own frames, so its + * timeline and its battle share one playhead; a frame before the summon + * plays maps to its first, one after it to its last. + */ + public function findCommandFrame(int $authoredFrame): int + { + $target = $this->phases['target']; + $frame = $this->plan->getCommandFrameForAuthoredFrame('target', max(0, $authoredFrame)); + + return $frame ?? ($authoredFrame <= 0 ? $target['start'] : $target['start'] + $target['length'] - 1); + } } diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index eae83b6..47a87c2 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -118,8 +118,12 @@ final class EditorSession /** The battle test running in its own window, and the troop it fights. */ private ?PlaytestRun $battleTestRun = null; private ?string $battleTestTroop = null; - /** The summon battle preview last built, and what it was built from, so seeking reuses it until either changes. */ - private ?array $summonBattlePreview = null; + /** + * The summon battle previews last built, by presentation, and what each was built from, so seeking reuses them until either changes. + * + * @var array + */ + private array $summonBattlePreviews = []; /** The cinematic previewed as the terminal plays it, and which one it is. */ private ?CinematicPreviewSession $cinematicPreview = null; private ?string $cinematicPreviewId = null; @@ -1414,9 +1418,13 @@ private function describeCinematicPreview(): array * included, cast by the first member of the battle test party (else the * starting party) its wielder policy allows, at the targets its linked * action takes in the battle test troop (else the first troop), over the - * battle test arena, with the project's battle pacing. The graphical - * canvas comes with the terminal arena's lines, so both renderers are - * previewed from the one command. Enemies and the party are read as + * battle test arena, with the project's battle pacing, in one + * presentation: graphical, its canvas with the terminal arena's lines + * from the paired lane, or terminal alone, at the terminal sequence's own + * cadence, reading no graphical image. A frame of the summon's own + * timeline may be asked for instead of a command frame; each frame says + * which of the summon's frames it draws, so the timeline and the battle + * share one playhead. Enemies and the party are read as * saved; nothing is resolved, spent or played aloud, and nothing is * written. Seeking reuses the preview until the summon or its battle * changes. @@ -1424,12 +1432,15 @@ private function describeCinematicPreview(): array * @return array * @throws SessionRefusal When the summon does not compile, the battle cannot be set up or the frame is outside the command. */ - public function readSummonBattlePreview(int $index, int $frame, bool $reducedMotion = false): array + public function readSummonBattlePreview(int $index, int $frame, bool $reducedMotion = false, + EffectPresentation $presentation = EffectPresentation::GRAPHICAL, ?int $authoredFrame = null): array { $asset = $this->requireTimelineCutscene(CutsceneType::SUMMON->getRecordCategory(), $index); + $graphic = $presentation === EffectPresentation::GRAPHICAL; try { $definition = $asset->summonDefinition(); - $graphical = $asset->compiledSummon(EffectPresentation::GRAPHICAL); + // The terminal arena plays the terminal compile alone, which needs no graphical image. + $graphical = $graphic ? $asset->compiledSummon(EffectPresentation::GRAPHICAL) : null; $terminal = $asset->compiledSummon(EffectPresentation::TERMINAL); } catch (Throwable $failure) { throw new SessionRefusal(sprintf('%s does not compile: %s', $asset->id, $failure->getMessage()), previous: $failure); @@ -1438,15 +1449,18 @@ public function readSummonBattlePreview(int $index, int $frame, bool $reducedMot $troop = $this->readPreviewTroop($entry); $config = $this->workspace->config?->getRecord('ui.battle')->get('value'); $battleUi = is_array($config) ? $config : []; - $key = hash('xxh128', serialize([$asset->id, $definition->toDataArray(), $graphical->toArray(), $terminal->toArray(), - $entry, $this->readStartingPartyReferences(), $troop, $battleUi, $this->readProposedBattlerBindings()])); - if (($this->summonBattlePreview['key'] ?? null) !== $key) { - $catalog = $this->requireBattleLayoutCatalog('preview summons on'); - $this->summonBattlePreview = ['key' => $key, 'preview' => ProjectDirectoryContext::run($this->workspace->projectRoot, + $key = hash('xxh128', serialize([$asset->id, $definition->toDataArray(), $graphical?->toArray(), $terminal->toArray(), + $entry, $this->readStartingPartyReferences(), $troop, $battleUi, $graphic ? $this->readProposedBattlerBindings() : null])); + if (($this->summonBattlePreviews[$presentation->value]['key'] ?? null) !== $key) { + // The terminal arena needs no graphical layout, and reads no image. + $catalog = $graphic ? $this->requireBattleLayoutCatalog('preview summons on') : null; + $this->summonBattlePreviews[$presentation->value] = ['key' => $key, 'preview' => ProjectDirectoryContext::run($this->workspace->projectRoot, fn(): SummonBattlePreview => $this->createSummonBattlePreview($definition, $graphical, $terminal, $entry, $troop, - BattlePacing::fromBattleUiConfig($battleUi), $catalog))]; + BattlePacing::fromBattleUiConfig($battleUi), $catalog, $presentation))]; } - $preview = $this->summonBattlePreview['preview']; + $preview = $this->summonBattlePreviews[$presentation->value]['preview']; + // A frame of the summon's own timeline is drawn where the battle plays it. + $frame = $authoredFrame === null ? $frame : $preview->findCommandFrame($authoredFrame); try { $read = $preview->readFrame(max(0, min($frame, $preview->totalFrames - 1)), $reducedMotion); } catch (Throwable $failure) { @@ -1466,8 +1480,9 @@ public function readSummonBattlePreview(int $index, int $frame, bool $reducedMot * @param array{name: string, data: array} $troop * @throws SessionRefusal When the battle cannot be set up or the summon cannot be cast in it. */ - private function createSummonBattlePreview(SummonCutsceneDefinition $definition, SummonCompiledCutscene $graphical, - SummonCompiledCutscene $terminal, array $entry, array $troop, BattlePacing $pacing, BattlePresentationCatalog $catalog): SummonBattlePreview + private function createSummonBattlePreview(SummonCutsceneDefinition $definition, ?SummonCompiledCutscene $graphical, + SummonCompiledCutscene $terminal, array $entry, array $troop, BattlePacing $pacing, ?BattlePresentationCatalog $catalog, + EffectPresentation $presentation): SummonBattlePreview { $actors = $this->workspace->actorDatabase->createActorStore(); $skills = $this->workspace->loadSkillCatalog(); @@ -1479,7 +1494,7 @@ private function createSummonBattlePreview(SummonCutsceneDefinition $definition, $battle = new BattleConfig($party, Troop::fromArray($troop['data']), settings: $setup->getBattleSettings()); return SummonBattlePreview::create($definition, $graphical, $terminal, $battle, $skills, $pacing, $catalog, - $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); + $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets', $presentation); } catch (SessionRefusal $refusal) { throw $refusal; } catch (Throwable $failure) { diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index f8fdc10..83ce971 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor\Session; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Editor\Console\ConsoleBinary; use Ichiloto\Editor\Console\ProjectCreator; use JsonException; @@ -339,8 +340,11 @@ private function dispatch(string $method, array $params): array 'cutscenes.cinematicControl' => $session->controlCinematicPreview(self::requireString($params, 'action'), is_int($params['seconds'] ?? null) || is_float($params['seconds'] ?? null) ? (float) $params['seconds'] : 0.0), 'cutscenes.cinematicStop' => $session->stopCinematicPreview(), - 'cutscenes.battlePreview' => $session->readSummonBattlePreview(self::requireInt($params, 'index'), self::requireInt($params, 'frame'), - ($params['reducedMotion'] ?? false) === true), + 'cutscenes.battlePreview' => $session->readSummonBattlePreview(self::requireInt($params, 'index'), is_int($params['frame'] ?? null) ? $params['frame'] : 0, + ($params['reducedMotion'] ?? false) === true, + EffectPresentation::tryFrom(is_string($params['presentation'] ?? null) ? $params['presentation'] : 'graphical') + ?? throw new InvalidRequest('"presentation" must be terminal or graphical.'), + is_int($params['authoredFrame'] ?? null) ? $params['authoredFrame'] : null), 'references.list' => $session->listReferences( is_string($params['map'] ?? null) ? $params['map'] : null, self::requireString($params, 'category'), diff --git a/tests/Unit/Session/SummonBattlePreviewSessionTest.php b/tests/Unit/Session/SummonBattlePreviewSessionTest.php index d768e80..8e87e1e 100644 --- a/tests/Unit/Session/SummonBattlePreviewSessionTest.php +++ b/tests/Unit/Session/SummonBattlePreviewSessionTest.php @@ -4,6 +4,7 @@ use Ichiloto\Editor\Session\EditorSession; use Ichiloto\Editor\Session\SessionRefusal; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; /** A summon previewed as the battle test plays it, through the Engine's command preview. Synthetic fixtures only. */ @@ -81,3 +82,36 @@ function findSummonArt(array $frame): ?array expect(fn() => $session->readSummonBattlePreview(0, 0))->toThrow(SessionRefusal::class, 'No one in the battle test party may call Lantern Wisp') ->and(fn() => $session->readSummonBattlePreview(4, 0))->toThrow(SessionRefusal::class); }); + +it('previews the summon in the terminal arena alone, reading no graphical image', function () { + $root = summonBattlePreviewProject(); + // Every graphical image gone: the terminal arena never needs one. + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/assets/Graphics', FilesystemIterator::SKIP_DOTS)) as $file) { + if (str_ends_with($file->getFilename(), '.png')) { + unlink($file->getPathname()); + } + } + $session = EditorSession::open($root); + $first = $session->readSummonBattlePreview(0, 0, presentation: EffectPresentation::TERMINAL); + $atTarget = $session->readSummonBattlePreview(0, $first['phases']['target']['start'] + 1, presentation: EffectPresentation::TERMINAL); + + expect($first['caster'])->toBe('Kaelion') + ->and($atTarget['phase'])->toBe('target') + ->and($atTarget['canvas'])->toBeNull() + ->and($atTarget['terminalLines'])->not->toBe([]) + ->and(fn() => $session->readSummonBattlePreview(0, 0))->toThrow(SessionRefusal::class); +}); + +it('draws a frame of the summon\'s own timeline where the battle plays it, in either presentation', function () { + $session = EditorSession::open(summonBattlePreviewProject()); + foreach ([EffectPresentation::GRAPHICAL, EffectPresentation::TERMINAL] as $presentation) { + $twelfth = $session->readSummonBattlePreview(0, 0, presentation: $presentation, authoredFrame: 12); + $first = $session->readSummonBattlePreview(0, 0, presentation: $presentation, authoredFrame: -5); + $last = $session->readSummonBattlePreview(0, 0, presentation: $presentation, authoredFrame: 999); + + expect($twelfth['authoredFrames']['target'])->toBe(12) + ->and($twelfth['phase'])->toBe('target') + ->and($first['frame'])->toBe($first['phases']['target']['start']) + ->and($last['frame'])->toBe($last['phases']['target']['start'] + $last['phases']['target']['length'] - 1); + } +}); From 79c72c869aeccea1fbf96a4972ee7fbd4ff7a099 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 10:09:04 +0200 Subject: [PATCH 201/344] feat(cutscenes): preview a battle effect in the action that plays it cutscenes.effectBattlePreview plays an effect as a battle does, in either presentation: in an action of the battle test party (its attacks, skills, magic and items; summons are previewed as summons) that the Engine's own animation selection plays it in, with the animation's other effect beside it, the action's pose and the project's battle pacing. The effects and animations are read as they stand, unsaved edits included. Every action that plays it is listed so another can be chosen, a frame of the effect's own timeline is drawn where the action plays it, and an effect no action plays says so, with nothing invented. The battle a preview plays in is now built once for summons and effects alike. --- src/Cutscenes/Preview/EffectBattlePreview.php | 149 ++++++++++++++++++ src/Session/EditorSession.php | 126 ++++++++++++++- src/Session/SessionHost.php | 6 + tests/Support/BattleFormationFixtures.php | 31 ++++ .../EffectBattlePreviewSessionTest.php | 61 +++++++ .../SummonBattlePreviewSessionTest.php | 31 ---- 6 files changed, 366 insertions(+), 38 deletions(-) create mode 100644 src/Cutscenes/Preview/EffectBattlePreview.php create mode 100644 tests/Unit/Session/EffectBattlePreviewSessionTest.php diff --git a/src/Cutscenes/Preview/EffectBattlePreview.php b/src/Cutscenes/Preview/EffectBattlePreview.php new file mode 100644 index 0000000..1e723b7 --- /dev/null +++ b/src/Cutscenes/Preview/EffectBattlePreview.php @@ -0,0 +1,149 @@ + $this->preview->totalFrames; } + + /** + * @param string $lane `source` or `target`: where the effect plays in the action shown. + * @param list $contexts Every action of the party that plays it. + * @param int $binding Which of them is shown. + * @param list $targets + * @param array $phases + */ + private function __construct( + private readonly BattleCommandPreview $preview, + private readonly BattleCommandTimeline $plan, + public readonly string $lane, + public readonly array $contexts, + public readonly int $binding, + public readonly string $caster, + public readonly array $targets, + public readonly array $phases, + ) { + } + + /** + * @param callable(string, EffectPresentation): CompiledEffectTimeline $compile An effect by id, compiled for battle, as it stands. + * @param BattlePresentationCatalog|null $catalog The battle presentation, which a graphical preview requires. + * @param int $binding Which of the actions that play the effect to show; the first when it is out of range. + * @throws InvalidArgumentException When no action of the battle test party plays the effect. + */ + public static function create( + string $effectId, + BattleConfig $battle, + SkillCatalog $skills, + AnimationLibrary $animations, + BattlePacing $pacing, + ?BattlePresentationCatalog $catalog, + string $assetRoot, + EffectPresentation $presentation, + callable $compile, + int $binding = 0, + ): self { + $state = new GameState(); + $found = []; + foreach ($battle->partyRoster->battlers as $caster) { + if (! $caster instanceof Character) { + continue; + } + $candidates = []; + foreach ([BattleCommandType::ATTACK, BattleCommandType::SKILL, BattleCommandType::MAGIC, BattleCommandType::ITEM] as $type) { + foreach (BattleCommandCatalog::buildOptions($caster, $battle->party, $type->label(), [], $state) as $option) { + // A summon's choreography is previewed as the summon, not through an animation. + if (! $skills->isSummonAction($option->action->name)) { + $candidates[] = $option->action; + } + } + } + foreach (ActionAnimationResolver::findEffectBindings($effectId, $animations, $caster, $candidates) as $match) { + $found[] = [...$match, 'caster' => $caster]; + } + } + if ($found === []) { + throw new InvalidArgumentException(sprintf('No action of the battle test party plays %s in battle, so it has no battle preview.', $effectId)); + } + $binding = $binding >= 0 && $binding < count($found) ? $binding : 0; + ['lane' => $lane, 'animation' => $animation, 'action' => $action, 'caster' => $caster] = $found[$binding]; + + // The animation's two effects as the battle plays them in this presentation; a graphical target + // drawing nothing in the terminal keeps its terminal sequence beside it, as the battle does. + $source = $animation->sourceEffect === null ? null : $compile($animation->sourceEffect, $presentation); + $target = $animation->targetEffect === null ? null : $compile($animation->targetEffect, $presentation); + $terminalTarget = $presentation === EffectPresentation::GRAPHICAL && $target !== null && ! $target->hasTerminalContent + ? $compile($animation->targetEffect, EffectPresentation::TERMINAL) : null; + $plan = new BattleCommandTimeline($pacing->getTurnTimings($action), $source, $target, $terminalTarget); + $targets = BattleTargetPolicy::resolveTargets($action->targetScope, $caster, $battle->partyRoster->battlers, $battle->troop->members->toArray()); + if ($targets === []) { + throw new InvalidArgumentException(sprintf('%s has no one to act on in the battle test.', $action->name)); + } + + return new self( + new BattleCommandPreview($battle, $plan, $caster, $targets, BattlePoseRole::getForAction($action), + $presentation === EffectPresentation::TERMINAL ? null : $catalog, $assetRoot, $presentation), + $plan, + $lane, + array_map(static fn(array $context): array => ['caster' => $context['caster']->name, 'action' => $context['action']->name, 'lane' => $context['lane']], $found), + $binding, + $caster->name, + array_map(static fn($target): string => $target->name, $targets), + $plan->phases, + ); + } + + /** + * One command frame as the battle draws it. + * + * @return array + * @throws InvalidArgumentException When the frame is outside the command. + */ + public function readFrame(int $frame, bool $reducedMotion): array + { + return $this->preview->getFrameAtIndex($frame, $reducedMotion)->toArray(); + } + + /** + * The command frame that draws one of the effect's own frames, in the lane it plays in; a frame + * before it maps to its first, one after it to its last. + */ + public function findCommandFrame(int $authoredFrame): int + { + $phase = $this->phases[$this->lane]; + $frame = $this->plan->getCommandFrameForAuthoredFrame($this->lane, max(0, $authoredFrame)); + + return $frame ?? ($authoredFrame <= 0 ? $phase['start'] : $phase['start'] + $phase['length'] - 1); + } +} diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 47a87c2..1200287 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -83,6 +83,11 @@ use Ichiloto\Engine\Entities\Troop; use Ichiloto\Engine\Scenes\Battle\BattleConfig; use Ichiloto\Editor\Cutscenes\Preview\SummonBattlePreview; +use Ichiloto\Editor\Cutscenes\Preview\EffectBattlePreview; +use Ichiloto\Engine\Animations\Animation; +use Ichiloto\Engine\Animations\AnimationLibrary; +use Ichiloto\Engine\Animations\Timelines\CompiledEffectTimeline; +use Ichiloto\Engine\Entities\Skills\SkillCatalog; use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewOrigin; use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewSession; use Ichiloto\Engine\Battle\Presentation\BattleCommandTimeline; @@ -124,6 +129,12 @@ final class EditorSession * @var array */ private array $summonBattlePreviews = []; + /** + * The effect battle previews last built, by presentation, and what each was built from. + * + * @var array + */ + private array $effectBattlePreviews = []; /** The cinematic previewed as the terminal plays it, and which one it is. */ private ?CinematicPreviewSession $cinematicPreview = null; private ?string $cinematicPreviewId = null; @@ -1484,14 +1495,8 @@ private function createSummonBattlePreview(SummonCutsceneDefinition $definition, SummonCompiledCutscene $terminal, array $entry, array $troop, BattlePacing $pacing, ?BattlePresentationCatalog $catalog, EffectPresentation $presentation): SummonBattlePreview { - $actors = $this->workspace->actorDatabase->createActorStore(); - $skills = $this->workspace->loadSkillCatalog(); - $previousEnemies = ConfigStore::has(EnemyStore::class) ? ConfigStore::get(EnemyStore::class) : null; try { - $setup = ProjectBattleTest::fromArray($entry)->createSetup($actors, $this->readStartingPartyReferences()); - $party = $setup->createParty($actors, $this->loadItemStore(), $skills); - ConfigStore::put(EnemyStore::class, new EnemyStore()); - $battle = new BattleConfig($party, Troop::fromArray($troop['data']), settings: $setup->getBattleSettings()); + [$battle, $skills] = $this->createPreviewBattle($entry, $troop); return SummonBattlePreview::create($definition, $graphical, $terminal, $battle, $skills, $pacing, $catalog, $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets', $presentation); @@ -1499,6 +1504,113 @@ private function createSummonBattlePreview(SummonCutsceneDefinition $definition, throw $refusal; } catch (Throwable $failure) { throw new SessionRefusal(sprintf('%s cannot be previewed in battle: %s', $definition->name, $failure->getMessage()), previous: $failure); + } + } + + /** + * A battle effect as a battle plays it, at one command frame, in one + * presentation: in an action of the battle test party that the Engine's + * own animation selection plays it in (its attacks, skills, magic and + * items; a summon is previewed as a summon), with the animation's other + * effect beside it, the action's pose and the project's battle pacing. + * The effects are compiled as they stand, unsaved edits included, and the + * animations are read as they stand too. Every action that plays the + * effect is listed, so another can be chosen; a frame of the effect's own + * timeline may be asked for instead of a command frame. Nothing is + * resolved, spent or played aloud, and nothing is written. + * + * @return array + * @throws SessionRefusal When the effect does not compile, no action of the party plays it, or the battle cannot be set up. + */ + public function readEffectBattlePreview(int $index, int $frame, bool $reducedMotion = false, + EffectPresentation $presentation = EffectPresentation::GRAPHICAL, ?int $authoredFrame = null, int $binding = 0): array + { + $asset = $this->requireTimelineCutscene(CutsceneType::EFFECT->getRecordCategory(), $index); + $effects = $this->workspace->cutscenes?->assets(CutsceneType::EFFECT) ?? []; + $animations = array_values(array_map(static fn($record): mixed => $record->toArray(), $this->requireRecordDatabase('animations')->getRecords())); + $entry = $this->readBattleTestEntry(); + $troop = $this->readPreviewTroop($entry); + $config = $this->workspace->config?->getRecord('ui.battle')->get('value'); + $battleUi = is_array($config) ? $config : []; + $graphic = $presentation === EffectPresentation::GRAPHICAL; + $key = hash('xxh128', serialize([$asset->id, array_map(static fn(CutsceneAsset $effect): array => $effect->payload(), $effects), $animations, + $entry, $this->readStartingPartyReferences(), $troop, $battleUi, $binding, $graphic ? $this->readProposedBattlerBindings() : null])); + if (($this->effectBattlePreviews[$presentation->value]['key'] ?? null) !== $key) { + $catalog = $graphic ? $this->requireBattleLayoutCatalog('preview effects on') : null; + $this->effectBattlePreviews[$presentation->value] = ['key' => $key, 'preview' => ProjectDirectoryContext::run($this->workspace->projectRoot, + fn(): EffectBattlePreview => $this->createEffectBattlePreview($asset->id, $animations, $entry, $troop, + BattlePacing::fromBattleUiConfig($battleUi), $catalog, $presentation, $binding))]; + } + $preview = $this->effectBattlePreviews[$presentation->value]['preview']; + $frame = $authoredFrame === null ? $frame : $preview->findCommandFrame($authoredFrame); + try { + $read = $preview->readFrame(max(0, min($frame, $preview->totalFrames - 1)), $reducedMotion); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s cannot be previewed: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + + return [...$read, 'totalFrames' => $preview->totalFrames, 'fps' => BattleCommandTimeline::FPS, + 'assetRoot' => $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets', 'lane' => $preview->lane, + 'contexts' => $preview->contexts, 'binding' => $preview->binding, + 'caster' => $preview->caster, 'targets' => $preview->targets, 'troop' => $troop['name'], 'phases' => $preview->phases]; + } + + /** + * The battle an effect preview plays in, and the effect played in it. + * + * @param list $animations The animation records as they stand. + * @param array $entry The battle test as system data holds it. + * @param array{name: string, data: array} $troop + * @throws SessionRefusal When the battle cannot be set up or no action of it plays the effect. + */ + private function createEffectBattlePreview(string $effectId, array $animations, array $entry, array $troop, BattlePacing $pacing, + ?BattlePresentationCatalog $catalog, EffectPresentation $presentation, int $binding): EffectBattlePreview + { + // The animations as authored now; an entry the Engine would not read plays nothing, as in battle. + $library = AnimationLibrary::createFromAnimations(array_values(array_filter(array_map(static function (mixed $data): ?Animation { + try { + return is_array($data) ? Animation::fromArray($data) : null; + } catch (Throwable) { + return null; + } + }, $animations)))); + $compile = function (string $id, EffectPresentation $presentation): CompiledEffectTimeline { + $effect = $this->workspace->cutscenes?->find(CutsceneType::EFFECT, $id) + ?? throw new SessionRefusal(sprintf('The animation plays the effect %s, which does not exist.', $id)); + + return $effect->compiledEffect($presentation, true); + }; + try { + [$battle, $skills] = $this->createPreviewBattle($entry, $troop); + + return EffectBattlePreview::create($effectId, $battle, $skills, $library, $pacing, $catalog, + $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets', $presentation, $compile, $binding); + } catch (SessionRefusal $refusal) { + throw $refusal; + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s cannot be previewed in battle: %s', $effectId, $failure->getMessage()), previous: $failure); + } + } + /** + * The battle a preview plays in, built the way the battle test builds it: + * its party, or the starting party, against its troop, with the skills + * they know. + * + * @param array $entry The battle test as system data holds it. + * @param array{name: string, data: array} $troop + * @return array{0: BattleConfig, 1: SkillCatalog} + */ + private function createPreviewBattle(array $entry, array $troop): array + { + $actors = $this->workspace->actorDatabase->createActorStore(); + $skills = $this->workspace->loadSkillCatalog(); + $setup = ProjectBattleTest::fromArray($entry)->createSetup($actors, $this->readStartingPartyReferences()); + $party = $setup->createParty($actors, $this->loadItemStore(), $skills); + $previousEnemies = ConfigStore::has(EnemyStore::class) ? ConfigStore::get(EnemyStore::class) : null; + try { + ConfigStore::put(EnemyStore::class, new EnemyStore()); + + return [new BattleConfig($party, Troop::fromArray($troop['data']), settings: $setup->getBattleSettings()), $skills]; } finally { $previousEnemies === null ? ConfigStore::remove(EnemyStore::class) : ConfigStore::put(EnemyStore::class, $previousEnemies); } diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 83ce971..2fa903c 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -340,6 +340,12 @@ private function dispatch(string $method, array $params): array 'cutscenes.cinematicControl' => $session->controlCinematicPreview(self::requireString($params, 'action'), is_int($params['seconds'] ?? null) || is_float($params['seconds'] ?? null) ? (float) $params['seconds'] : 0.0), 'cutscenes.cinematicStop' => $session->stopCinematicPreview(), + 'cutscenes.effectBattlePreview' => $session->readEffectBattlePreview(self::requireInt($params, 'index'), is_int($params['frame'] ?? null) ? $params['frame'] : 0, + ($params['reducedMotion'] ?? false) === true, + EffectPresentation::tryFrom(is_string($params['presentation'] ?? null) ? $params['presentation'] : 'graphical') + ?? throw new InvalidRequest('"presentation" must be terminal or graphical.'), + is_int($params['authoredFrame'] ?? null) ? $params['authoredFrame'] : null, + is_int($params['binding'] ?? null) ? $params['binding'] : 0), 'cutscenes.battlePreview' => $session->readSummonBattlePreview(self::requireInt($params, 'index'), is_int($params['frame'] ?? null) ? $params['frame'] : 0, ($params['reducedMotion'] ?? false) === true, EffectPresentation::tryFrom(is_string($params['presentation'] ?? null) ? $params['presentation'] : 'graphical') diff --git a/tests/Support/BattleFormationFixtures.php b/tests/Support/BattleFormationFixtures.php index ec23f04..d1d73f0 100644 --- a/tests/Support/BattleFormationFixtures.php +++ b/tests/Support/BattleFormationFixtures.php @@ -107,3 +107,34 @@ function battleTestProject(): string return $root; } + +/** The battle test project holding the lantern summon, with sheet art that rests on its second cell. */ +function summonBattlePreviewProject(array $wielders = []): string +{ + $root = battleTestProject(); + $summon = $root . '/assets/Cutscenes/Summons/lantern-wisp'; + mkdir($summon, 0o777, true); + $data = lanternSummonData(); + if ($wielders !== []) { + $data = str_replace(" 'linkedActionId' => 'Fireball',", " 'linkedActionId' => 'Fireball',\n 'wielders' => " . var_export($wielders, true) . ',', $data); + } + file_put_contents($summon . '/lantern-wisp.data.php', $data); + file_put_contents($summon . '/lantern-wisp.timeline.php', str_replace([" 'lengthFrames' => 24,", " ['type' => 'text', 'id' => 'name',"], [ + " 'lengthFrames' => 24,\n 'restFrame' => 12,", + " ['type' => 'image', 'id' => 'wisp-art', 'presentation' => 'graphical', 'asset' => 'Graphics/Summons/Wisp.png',\n" + . " 'sheet' => ['columns' => 2, 'rows' => 1], 'anchor' => 'target', 'attachment' => 'ground', 'pivot' => ['x' => 0.5, 'y' => 1],\n" + . " 'keyframes' => [['frame' => 0, 'duration' => 12, 'sourceFrame' => 0], ['frame' => 12, 'duration' => 12, 'sourceFrame' => 1]]],\n" + . " ['type' => 'text', 'id' => 'name',"], lanternSummonTimeline())); + mkdir($root . '/assets/Graphics/Summons', 0o777, true); + writeTilesetTestPng($root . '/assets/Graphics/Summons/Wisp.png', 16, 8); + writeFireballSkill($root); + // The battle reads enemies the way the game loads them, one file each through the project's barrel. + writeRegularBatEnemy($root); + // A graphical battle places every enemy it fights. + file_put_contents($root . '/assets/Data/troops.php', str_replace("['enemy' => 'Regular Bat', 'position' => [15, 20]]", + "['enemy' => 'Regular Bat', 'position' => [15, 20], 'graphicalPlacement' => ['x' => 420, 'y' => 380, 'width' => 275, 'height' => 190]]", + (string) file_get_contents($root . '/assets/Data/troops.php'))); + file_put_contents($root . '/assets/Data/enemies.php', " emberSparkTimeline(), 'dusk-slash' => duskSlashTimeline()] as $id => $source) { + @mkdir($root . '/assets/Animations/' . $id, 0o777, true); + file_put_contents($root . '/assets/Animations/' . $id . '/' . $id . '.timeline.php', $source); + } + @mkdir($root . '/assets/Graphics/Effects', 0o777, true); + writeTilesetTestPng($root . '/assets/Graphics/Effects/dusk-slash.png', 32, 16); + // Dusk Slash is what an unarmed attack plays on its target; Ember Spark nothing plays. + file_put_contents($root . '/assets/Data/animations.php', " 1, 'name' => 'Dusk Slash', 'roles' => ['attack', 'attack-unarmed'], 'targetEffect' => 'dusk-slash'], + ], true) . ";\n"); + + return $root; +} + +/** @return int The effect's place among the effect records. */ +function findEffectIndex(EditorSession $session, string $id): int +{ + foreach ($session->listDatabaseRecords('cutscenes/effect')['records'] as $index => $label) { + if (str_contains(strval($label), $id)) { + return $index; + } + } + throw new RuntimeException("No effect {$id}."); +} + +it('previews an effect in the action that plays it, as the battle draws it, for both presentations', function () { + $session = EditorSession::open(effectBattlePreviewProject()); + $index = findEffectIndex($session, 'dusk-slash'); + $graphical = $session->readEffectBattlePreview($index, 0); + $atLane = $session->readEffectBattlePreview($index, 0, authoredFrame: 1); + $terminal = $session->readEffectBattlePreview($index, 0, presentation: EffectPresentation::TERMINAL, authoredFrame: 0); + + expect($graphical['contexts'])->toBe([['caster' => 'Kaelion', 'action' => $graphical['contexts'][0]['action'], 'lane' => 'target']]) + ->and($graphical)->toMatchArray(['lane' => 'target', 'binding' => 0, 'caster' => 'Kaelion', 'troop' => 'Pair']) + ->and($atLane['phase'])->toBe('target') + ->and($atLane['authoredFrames']['target'])->toBe(1) + ->and(array_find($atLane['canvas']['images'] ?? [], static fn(array $image): bool => $image['asset'] === 'Graphics/Effects/dusk-slash.png'))->not->toBeNull() + ->and($terminal['canvas'])->toBeNull() + ->and($terminal['terminalLines'])->not->toBe([]); +}); + +it('says so when no action of the battle test party plays an effect, inventing none', function () { + $session = EditorSession::open(effectBattlePreviewProject()); + + expect(fn() => $session->readEffectBattlePreview(findEffectIndex($session, 'ember-spark'), 0)) + ->toThrow(SessionRefusal::class, 'No action of the battle test party plays ember-spark in battle'); +}); diff --git a/tests/Unit/Session/SummonBattlePreviewSessionTest.php b/tests/Unit/Session/SummonBattlePreviewSessionTest.php index 8e87e1e..08541cc 100644 --- a/tests/Unit/Session/SummonBattlePreviewSessionTest.php +++ b/tests/Unit/Session/SummonBattlePreviewSessionTest.php @@ -8,37 +8,6 @@ /** A summon previewed as the battle test plays it, through the Engine's command preview. Synthetic fixtures only. */ -/** The battle test project holding the lantern summon, with sheet art that rests on its second cell. */ -function summonBattlePreviewProject(array $wielders = []): string -{ - $root = battleTestProject(); - $summon = $root . '/assets/Cutscenes/Summons/lantern-wisp'; - mkdir($summon, 0o777, true); - $data = lanternSummonData(); - if ($wielders !== []) { - $data = str_replace(" 'linkedActionId' => 'Fireball',", " 'linkedActionId' => 'Fireball',\n 'wielders' => " . var_export($wielders, true) . ',', $data); - } - file_put_contents($summon . '/lantern-wisp.data.php', $data); - file_put_contents($summon . '/lantern-wisp.timeline.php', str_replace([" 'lengthFrames' => 24,", " ['type' => 'text', 'id' => 'name',"], [ - " 'lengthFrames' => 24,\n 'restFrame' => 12,", - " ['type' => 'image', 'id' => 'wisp-art', 'presentation' => 'graphical', 'asset' => 'Graphics/Summons/Wisp.png',\n" - . " 'sheet' => ['columns' => 2, 'rows' => 1], 'anchor' => 'target', 'attachment' => 'ground', 'pivot' => ['x' => 0.5, 'y' => 1],\n" - . " 'keyframes' => [['frame' => 0, 'duration' => 12, 'sourceFrame' => 0], ['frame' => 12, 'duration' => 12, 'sourceFrame' => 1]]],\n" - . " ['type' => 'text', 'id' => 'name',"], lanternSummonTimeline())); - mkdir($root . '/assets/Graphics/Summons', 0o777, true); - writeTilesetTestPng($root . '/assets/Graphics/Summons/Wisp.png', 16, 8); - writeFireballSkill($root); - // The battle reads enemies the way the game loads them, one file each through the project's barrel. - writeRegularBatEnemy($root); - // A graphical battle places every enemy it fights. - file_put_contents($root . '/assets/Data/troops.php', str_replace("['enemy' => 'Regular Bat', 'position' => [15, 20]]", - "['enemy' => 'Regular Bat', 'position' => [15, 20], 'graphicalPlacement' => ['x' => 420, 'y' => 380, 'width' => 275, 'height' => 190]]", - (string) file_get_contents($root . '/assets/Data/troops.php'))); - file_put_contents($root . '/assets/Data/enemies.php', "|null The summon's art as the frame's canvas draws it. */ function findSummonArt(array $frame): ?array { From e309086abb4d5da94b6bacc518602b898b8959d0 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 10:43:59 +0200 Subject: [PATCH 202/344] test(playtest): stop the game and remove its files even when an expectation fails The background playtest tests cleaned up only after their last expectation, so a failure left the fake game running with its overlay and scratch directory behind. The scratch directory is now registered with the suite's shared temporary cleanup, and each test stops its game and destroys its overlay in a finally block. --- tests/Unit/PlaytestTest.php | 80 +++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 34 deletions(-) diff --git a/tests/Unit/PlaytestTest.php b/tests/Unit/PlaytestTest.php index a74d4d9..fa0857b 100644 --- a/tests/Unit/PlaytestTest.php +++ b/tests/Unit/PlaytestTest.php @@ -227,6 +227,15 @@ function writeFakeConsole(string $directory, ?int $exitCode = null): string return $path; } +/** A throwaway directory for a playtest's console and log, removed after the test whatever its outcome. */ +function playtestScratch(): string +{ + $scratch = rememberTemporaryProject(sys_get_temp_dir() . '/' . uniqid('ichiloto-playtest-run-', true)); + mkdir($scratch); + + return $scratch; +} + function playtestProject(): string { $root = makeTemporaryProject(); @@ -256,50 +265,53 @@ function playtestProject(): string it('runs a playtest in the background with its renderer and stops the whole game', function (): void { $root = playtestProject(); - $scratch = sys_get_temp_dir() . '/' . uniqid('ichiloto-playtest-run-', true); - mkdir($scratch); + $scratch = playtestScratch(); $overlay = PlaytestOverlay::create($root, 'test-map', 2, 3); $run = (new PlaytestLauncher(writeFakeConsole($scratch)))->start($overlay, 'gpui', $scratch . '/play.log'); - $deadline = microtime(true) + 5; - while (! str_contains((string) @file_get_contents($scratch . '/play.log'), 'play') && microtime(true) < $deadline) { - usleep(20000); + try { + $deadline = microtime(true) + 5; + while (! str_contains((string) @file_get_contents($scratch . '/play.log'), 'play') && microtime(true) < $deadline) { + usleep(20000); + } + $pid = $run->getProcessId(); + $children = trim((string) shell_exec('pgrep -P ' . $pid)); + + expect($run->isRunning())->toBeTrue() + ->and(file_get_contents($scratch . '/play.log'))->toContain('play --no-tmux --no-interaction --renderer=gpui -d ' . $overlay->root) + ->and($children)->not->toBe(''); + + $run->stop(); + usleep(100000); + expect($run->isRunning())->toBeFalse() + ->and($run->wasStopped())->toBeTrue() + ->and(is_dir($overlay->root))->toBeFalse() + // The child the play command started went with it. + ->and(trim((string) shell_exec('ps -p ' . (int) $children . ' -o pid=')))->toBe(''); + } finally { + // A failed expectation still stops the game it started and takes its overlay with it. + $run->stop(); + $overlay->destroy(); } - $pid = $run->getProcessId(); - $children = trim((string) shell_exec('pgrep -P ' . $pid)); - - expect($run->isRunning())->toBeTrue() - ->and(file_get_contents($scratch . '/play.log'))->toContain('play --no-tmux --no-interaction --renderer=gpui -d ' . $overlay->root) - ->and($children)->not->toBe(''); - - $run->stop(); - usleep(100000); - expect($run->isRunning())->toBeFalse() - ->and($run->wasStopped())->toBeTrue() - ->and(is_dir($overlay->root))->toBeFalse() - // The child the play command started went with it. - ->and(trim((string) shell_exec('ps -p ' . (int) $children . ' -o pid=')))->toBe(''); - - removeDirectoryRecursively($root); - removeDirectoryRecursively($scratch); }); it('reports how a failed playtest ended and removes its overlay', function (): void { $root = playtestProject(); - $scratch = sys_get_temp_dir() . '/' . uniqid('ichiloto-playtest-run-', true); - mkdir($scratch); + $scratch = playtestScratch(); $overlay = PlaytestOverlay::create($root, 'test-map', 0, 0); $run = (new PlaytestLauncher(writeFakeConsole($scratch, 3)))->start($overlay, 'gpui', $scratch . '/play.log'); - $deadline = microtime(true) + 5; - while ($run->isRunning() && microtime(true) < $deadline) { - usleep(20000); - } - - expect($run->getExitCode())->toBe(3) - ->and($run->readLogTail())->toContain('renderer missing') - ->and(is_dir($overlay->root))->toBeFalse(); + try { + $deadline = microtime(true) + 5; + while ($run->isRunning() && microtime(true) < $deadline) { + usleep(20000); + } - removeDirectoryRecursively($root); - removeDirectoryRecursively($scratch); + expect($run->getExitCode())->toBe(3) + ->and($run->readLogTail())->toContain('renderer missing') + ->and(is_dir($overlay->root))->toBeFalse(); + } finally { + $run->stop(); + $overlay->destroy(); + } }); From a7e6f52995c52e145ca73b82826811c29b90ba5c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 12:19:13 +0200 Subject: [PATCH 203/344] test(cutscenes): a rest frame the summon timeline lacked is written to the timeline --- .../Unit/Session/CutsceneRecordSessionTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php index 7df0b6e..909f139 100644 --- a/tests/Unit/Session/CutsceneRecordSessionTest.php +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -57,6 +57,23 @@ ->and($session->listUnsavedChanges())->toBe([]); }); +it('writes a rest frame the timeline did not hold to the timeline, where the summon reads it', function () { + $root = cutsceneProject(); + $folder = $root . '/assets/Cutscenes/Summons/lantern-wisp'; + $dataBefore = (string) file_get_contents($folder . '/lantern-wisp.data.php'); + expect((string) file_get_contents($folder . '/lantern-wisp.timeline.php'))->not->toContain("'restFrame'"); + $session = EditorSession::open($root); + $rest = array_find($session->readDatabaseRecord('cutscenes/summon', 0)['rows'], static fn(array $row): bool => trim($row['label']) === 'Rest Frame'); + + $session->applyDatabaseRecord('cutscenes/summon', 0, $rest['key'], '12'); + $session->saveAll(); + + expect((string) file_get_contents($folder . '/lantern-wisp.timeline.php'))->toContain("'restFrame' => 12") + ->and((string) file_get_contents($folder . '/lantern-wisp.data.php'))->toBe($dataBefore) + ->and(Ichiloto\Editor\Cutscenes\CutsceneLibrary::fromProject($root)->find(Ichiloto\Editor\Cutscenes\CutsceneType::SUMMON, 'lantern-wisp') + ->summonDefinition()->restFrame)->toBe(12); +}); + it('lists a project reference without a map, and a summon\'s own cues from the summon being edited', function () { $root = cutsceneProject(); mkdir($root . '/assets/Graphics/Effects', 0o777, true); From 80fcfcc58b11207b3e66d99532ea553cfe0aaba6 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 12:39:19 +0200 Subject: [PATCH 204/344] feat(cutscenes): author a summon's terminal and graphical sequences and its graphical stage A summon may now be separated into terminal and graphical sequences, as an effect may, and edited one sequence at a time beside its shared definition. Its graphical sequence may hold a cinematic stage: canvas, start and restore frames, background, subjects with named points, camera and cover keys, all as record rows and lists with undo. Image tracks anchored to the stage pick their subject and point, with offset, size, depth and keyframe opacity. Duplicating or renaming a timeline with two sequences now copies both, not only the one shown. Record command lists may be offered per record and are named by their heading. Points and sizes have codecs of their own. The summon cue picker offers restoreBattlefield. --- docs/manual.md | 65 +++- src/Cutscenes/CutsceneAsset.php | 206 ++++++++++-- src/Cutscenes/CutsceneLibrary.php | 11 +- src/Cutscenes/CutscenePairShape.php | 21 +- src/Cutscenes/Editing/CutscenesWorkspace.php | 120 +++++-- src/Database/CutsceneSchemas.php | 293 +++++++++++++++--- src/Database/ProjectRecordDatabase.php | 32 +- src/Database/RecordFieldCodec.php | 15 + src/Database/RecordSchema.php | 23 ++ src/Database/RecordSubList.php | 3 +- src/Database/ReferenceCatalog.php | 64 ++++ src/Session/EditorSession.php | 189 ++++++++++- src/Session/SessionHost.php | 3 + tests/Support/CutsceneFixtures.php | 38 +++ .../Cutscenes/SummonStageAuthoringTest.php | 47 +++ .../Session/SummonSequenceSessionTest.php | 107 +++++++ tests/Unit/Session/SummonStageSessionTest.php | 156 ++++++++++ 17 files changed, 1263 insertions(+), 130 deletions(-) create mode 100644 tests/Unit/Cutscenes/SummonStageAuthoringTest.php create mode 100644 tests/Unit/Session/SummonSequenceSessionTest.php create mode 100644 tests/Unit/Session/SummonStageSessionTest.php diff --git a/docs/manual.md b/docs/manual.md index 923e55a..387a031 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1966,20 +1966,73 @@ renaming an actor never changes who may call it; tenancy `shared` or `exclusive`), **Playback** (default speed, allow skip, loop preview, transitions in and out, effect timing by `end`, `cue` or `frame`, target presentation) and **Timeline** -(format version, FPS, length in frames, editor metadata, and the Tracks -and Cues frames). `allowSkip` is authored data; whether a battle honours it -is the engine's business, and this manual claims nothing more. +(the Sequence row, format version, FPS, length in frames, rest frame, +editor metadata, and the Tracks and Cues frames). `allowSkip` is authored +data; whether a battle honours it is the engine's business, and this manual +claims nothing more. + +**Sequence** works as an effect's does: a summon whose timeline every +renderer shares stays that way unless you choose `separate`, which gives it +a terminal and a graphical sequence, each a copy of the shared one (undo +puts the shared file back). The definition (identity, wielders, playback +and the rest) stays one for both; the timeline rows, Tracks and Cues edit +the sequence chosen, and the other is kept exactly as written. A key a +sequence did not hold, such as a first rest frame, is written into that +sequence. + +#### Stage + +A summon's graphical sequence may have a **stage**: a presentation space of +its own, in stage units, shown in place of the arena from its start frame +until its restore frame, when the arena, the formation and the battlers' +current state return. The terminal sequence never has one. In the graphical +sequence, the **Stage** row is `none` or `added`; adding gives the sequence +the smallest stage the engine reads (a `1280, 720` canvas shown from the +first frame to the last, its camera on the canvas's centre at its own +scale), and `none` takes it away, each one undo step. + +With a stage, its rows are **Stage Canvas** (`width, height`), **Stage Start +Frame**, **Stage Restore Frame** and **Stage Background** (`black`, +`white` or `#RRGGBB`; empty is black), and three frames to open: + +- **Stage Subjects**: each subject has an id, a `Position` (`x, y`), a + `Size` (`width, height`, the box its art is fitted to, not a copy of an + image's size) and a `Pivot` (the point of that box at its position, `x, + y` from 0 to 1; empty is the bottom centre). `Shift+O` on a subject row + adds a named **point** to it, such as `chest` or `ground`: an id and an + `X` and `Y` from 0 to 1 across and down its box. +- **Stage Camera**: keys in frame order from frame 0, each a `Focus` + (`x, y`, the stage point at the centre of the view), a `Zoom` from 0.125 + to 4 and an `Easing` to the next key (`linear`, `hold` or `smoothstep`; + empty is linear). +- **Stage Covers**: full-screen colour keys in frame order, from frame 0 to + a fully clear key on the last frame, each a `Color`, an `Opacity` from 0 + to 1 and an `Easing`. + +An image track whose `Anchor` is `stage` is placed on the stage: its +`Subject` and `Subject Point` are picked from the stage's subjects and +their points (none: the stage origin, or the subject's pivot), with an +`Offset` and a `Size` in stage units (empty: no offset, and the subject's +size), and a `Z Index` among the stage's art. Battler rows (cells, facing, +battler attachment) do not apply on the stage. Its keyframes add an +`Offset` and an `Opacity` (empty is 1). The sequence's rest frame is also +the stage's reduced-motion view, so it must fall inside the stage's frames +with art showing; a save the engine refuses names the stage row it refuses, +such as `stage.camera[initial].zoom`. #### Tracks, keyframes and cues Open `Tracks` and each track is a row (`Id`, `Type`: `glyph`, `text`, -`flash`, `shake`; `Presentation`: `all`, `terminal` or `graphical`, the +`image`, `flash`, `shake`; `Presentation`: `all`, `terminal` or `graphical`, the renderers that draw it) followed by its keyframes: frame, duration, position, content, asset id, color, visible, z-index, blend mode, easing and a -free-form payload. `Content` opens the **multiline editor** (`Enter` on the +free-form payload; an image track has the image rows an effect's has. +`Content` opens the **multiline editor** (`Enter` on the row), which keeps every space, backslash, blank line, tab and wide glyph exactly as typed or pasted; `Ctrl+S` there commits, `Esc` cancels. Open -`Cues` for the cue rows: id, frame, type and payload. `Shift+O` adds a +`Cues` for the cue rows: id, frame, type (including `restoreBattlefield`, +which clears flash, shake and effect art and redraws the field) and +payload. `Shift+O` adds a track, a keyframe under the cursor's track, or a cue; `Shift+X` and `Delete` remove. The Timeline pane lists tracks, keyframes and cues as rows: `[` / `]` reorder, `Shift+D` duplicates (a copied track or cue gets a diff --git a/src/Cutscenes/CutsceneAsset.php b/src/Cutscenes/CutsceneAsset.php index ecc56cb..960861e 100644 --- a/src/Cutscenes/CutsceneAsset.php +++ b/src/Cutscenes/CutsceneAsset.php @@ -16,6 +16,7 @@ use Ichiloto\Engine\Animations\Timelines\CompiledEffectTimeline; use Ichiloto\Engine\Animations\Timelines\EffectCadence; use Ichiloto\Engine\Animations\Timelines\EffectPresentation; +use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicDefinition; use Ichiloto\Engine\Cutscenes\Summons\SummonCompiledCutscene; use Ichiloto\Engine\Cutscenes\Summons\SummonCutsceneDefinition; @@ -55,6 +56,23 @@ final class CutsceneAsset */ public const string COMMANDS_KEY = 'commands'; + /** + * The hidden payload key naming the sequence a record with separate + * terminal and graphical sequences shows, so its schema offers the rows + * that sequence reads (a summon's stage is graphical only). Never shown, + * never written to a file. + */ + public const string SEQUENCE_KEY = '__sequence'; + + /** + * A summon timeline's keys that stay beside its separate sequences; the + * Engine reads every other timeline key inside each sequence. + */ + public const array PAIRED_SUMMON_TIMELINE_FIELDS = ['formatVersion', 'presentations', 'editor']; + + /** The canvas a new summon stage starts with, in stage units. */ + private const array STAGE_CANVAS = ['width' => 1280, 'height' => 720]; + /** * @var array The data file's array as the editor now holds it. */ @@ -85,9 +103,10 @@ final class CutsceneAsset private ?PhpArraySourceDocument $partnerDocument = null; /** - * The sequence an effect with separate terminal and graphical sequences - * is being edited in. The editor shows and edits one at a time; the file - * keeps both, each exactly as written unless it is the one edited. + * The sequence an effect or summon with separate terminal and graphical + * sequences is being edited in. The editor shows and edits one at a + * time; the file keeps both, each exactly as written unless it is the + * one edited. */ private EffectPresentation $presentationView = EffectPresentation::TERMINAL; /** @@ -343,9 +362,16 @@ public function commands(): array */ public function payload(): array { - $payload = $this->hasPresentations() - ? $this->getPresentationSequence($this->partner) - : $this->shape->merge($this->data, $this->partner); + $payload = match (true) { + ! $this->hasPresentations() => $this->shape->merge($this->data, $this->partner), + // An effect is its timeline alone: the sequence is the record. + $this->type === CutsceneType::EFFECT => $this->getPresentationSequence($this->partner), + // A summon is its definition, the timeline's own keys and the sequence. + default => [ + ...$this->shape->merge($this->data, array_diff_key($this->partner, ['presentations' => true])), + ...$this->getPresentationSequence($this->partner), + ], + }; if (! $this->type->hasDataFile()) { // An effect's id is its folder, as the Engine finds it; it is @@ -353,29 +379,33 @@ public function payload(): array $payload = ['id' => $this->id, ...$payload]; } + if ($this->hasPresentations()) { + $payload[self::SEQUENCE_KEY] = $this->presentationView->value; + } + $payload[self::ORIGIN_KEY] = $this->id; return $payload; } /** - * Whether this is an effect with separate terminal and graphical - * sequences, edited one at a time. + * Whether this is an effect or a summon with separate terminal and + * graphical sequences, edited one at a time. */ public function hasPresentations(): bool { - return $this->type === CutsceneType::EFFECT && is_array($this->partner['presentations'] ?? null); + return $this->type !== CutsceneType::CINEMATIC && is_array($this->partner['presentations'] ?? null); } - /** The sequence being edited, for an effect with separate sequences; null otherwise. */ + /** The sequence being edited, for an effect or summon with separate sequences; null otherwise. */ public function getPresentationView(): ?EffectPresentation { return $this->hasPresentations() ? $this->presentationView : null; } /** - * Chooses which of an effect's sequences the editor shows and edits. - * Nothing is written; the other sequence stays as it is. + * Chooses which of the sequences the editor shows and edits. Nothing is + * written; the other sequence stays as it is. */ public function selectPresentation(EffectPresentation $presentation): void { @@ -383,27 +413,140 @@ public function selectPresentation(EffectPresentation $presentation): void } /** - * Gives a flat effect separate terminal and graphical sequences, each - * starting as a copy of the flat one, so each presentation can then be - * authored on its own. A flat effect stays flat unless this is chosen. + * Gives a flat effect or summon separate terminal and graphical + * sequences, each starting as a copy of the flat one, so each + * presentation can then be authored on its own. A summon keeps its + * format version and editor metadata beside them, where the Engine reads + * them. A flat timeline stays flat unless this is chosen. * - * @return bool False when it already has them, is not an effect, or cannot be written. + * @return bool False when it already has them, is a cinematic, or cannot be written. */ public function splitIntoPresentations(): bool { - if ($this->type !== CutsceneType::EFFECT || $this->hasPresentations() || $this->readOnlyReason !== null) { + if ($this->type === CutsceneType::CINEMATIC || $this->hasPresentations() || $this->readOnlyReason !== null) { return false; } - $this->partner = ['presentations' => [ - EffectPresentation::TERMINAL->value => $this->partner, - EffectPresentation::GRAPHICAL->value => $this->partner, - ]]; + $outer = $this->type === CutsceneType::SUMMON + ? array_intersect_key($this->partner, array_flip(self::PAIRED_SUMMON_TIMELINE_FIELDS)) + : []; + $sequence = array_diff_key($this->partner, $outer); + $presentations = [EffectPresentation::TERMINAL->value => $sequence, EffectPresentation::GRAPHICAL->value => $sequence]; + $partner = []; + + // The sequences take the place the first sequence key had. + foreach ($this->partner as $key => $value) { + if (array_key_exists($key, $outer)) { + $partner[$key] = $value; + } elseif (! array_key_exists('presentations', $partner)) { + $partner['presentations'] = $presentations; + } + } + + $this->partner = $partner + ['presentations' => $presentations]; $this->touchState(); return true; } + /** + * Whether the summon's graphical sequence has a cinematic stage. + */ + public function hasStage(): bool + { + return $this->type === CutsceneType::SUMMON && $this->hasPresentations() + && is_array($this->partner['presentations'][EffectPresentation::GRAPHICAL->value]['stage'] ?? null); + } + + /** + * Gives a summon's graphical sequence a cinematic stage, or takes it + * away. A new stage is the smallest the Engine reads: a 16:9 canvas + * shown from the first frame until the last, its camera on the canvas's + * centre at its own scale. Its subjects, art, covers and the sequence's + * rest frame are authored after. The terminal sequence never has one. + * + * @return bool False when nothing changed: not a summon with separate + * sequences, too short to restore before its end, or already so. + */ + public function setStage(bool $present): bool + { + $graphical = EffectPresentation::GRAPHICAL->value; + + if ($this->type !== CutsceneType::SUMMON || ! $this->hasPresentations() || $this->readOnlyReason !== null || $present === $this->hasStage()) { + return false; + } + + $sequence = $this->getSequence(EffectPresentation::GRAPHICAL); + + if (! $present) { + unset($sequence['stage']); + } else { + $length = (int) ($sequence['lengthFrames'] ?? 0); + + if ($length < 2) { + return false; + } + + $stage = [ + 'canvas' => self::STAGE_CANVAS, + 'startFrame' => 0, + 'restoreFrame' => $length - 1, + 'camera' => [['id' => 'initial', 'frame' => 0, + 'focus' => ['x' => intdiv(self::STAGE_CANVAS['width'], 2), 'y' => intdiv(self::STAGE_CANVAS['height'], 2)], 'zoom' => 1]], + ]; + // Where the Engine writes it: after the sequence's clock, before its tracks. + $placed = []; + + foreach ($sequence as $key => $value) { + if (in_array($key, ['tracks', 'cues'], true) && ! array_key_exists('stage', $placed)) { + $placed['stage'] = $stage; + } + + $placed[$key] = $value; + } + + $sequence = $placed + ['stage' => $stage]; + } + + $this->partner['presentations'][$graphical] = $sequence; + $this->touchState(); + + return true; + } + + /** + * @return array + */ + private function getSequence(EffectPresentation $presentation): array + { + $sequence = $this->partner['presentations'][$presentation->value] ?? []; + + return is_array($sequence) ? $sequence : []; + } + + /** + * Starts a new asset with this one's files under another id: both + * sequences of one that has two, whichever is shown. + */ + public function copyAs(string $id, string $root): self + { + $copy = new self($this->type, $id, rtrim($root, '/') . '/' . $id, $this->projectRoot); + $copy->isNew = true; + $copy->data = $this->type->hasDataFile() && array_key_exists('id', $this->data) + ? [...$this->data, 'id' => $id] + : ($this->type->hasDataFile() ? ['id' => $id, ...$this->data] : $this->data); + $copy->partner = $this->partner; + $copy->presentationView = $this->presentationView; + $copy->pacedFps = $this->pacedFps; + $copy->shape = CutscenePairShape::of($this->type, $copy->data, $copy->partner); + $copy->loadedData = []; + $copy->loadedPartner = []; + $copy->persistedFingerprint = null; + $copy->touchState(); + + return $copy; + } + /** * Returns everything an edit can change, for undo and redo: both files' * arrays, the sequence being edited and whether the asset is deleted. @@ -500,15 +643,28 @@ public function apply(array $payload): void $payload = $this->settleEffectCadence($payload); } + unset($payload[self::SEQUENCE_KEY]); + if ($this->hasPresentations()) { // The edited sequence goes back into its place; the other // sequence, and the keys of this one, keep their order. unset($payload[self::ORIGIN_KEY]); $old = $this->getPresentationSequence($this->partner); - $ordered = array_intersect_key(array_replace(array_flip(array_keys($old)), $payload), $payload); - $partner = $this->partner; - $partner['presentations'][$this->presentationView->value] = $ordered; - $data = $this->data; + $edited = $this->type === CutsceneType::EFFECT + ? $payload + : array_filter($payload, static fn(string $key): bool => array_key_exists($key, $old) + || (in_array($key, CinematicCommandSchema::SUMMON_TIMELINE_FIELDS, true) && ! in_array($key, self::PAIRED_SUMMON_TIMELINE_FIELDS, true)), + ARRAY_FILTER_USE_KEY); + $presentations = $this->partner['presentations']; + $presentations[$this->presentationView->value] = array_intersect_key(array_replace(array_flip(array_keys($old)), $edited), $edited); + + if ($this->type === CutsceneType::EFFECT) { + $partner = [...$this->partner, 'presentations' => $presentations]; + $data = $this->data; + } else { + // A summon's definition and the timeline's own keys split as a flat pair does. + [$data, $partner] = $this->shape->split([...array_diff_key($payload, $edited), 'presentations' => $presentations]); + } } else { [$data, $partner] = $this->shape->split($payload); } diff --git a/src/Cutscenes/CutsceneLibrary.php b/src/Cutscenes/CutsceneLibrary.php index 18e755b..3b17169 100644 --- a/src/Cutscenes/CutsceneLibrary.php +++ b/src/Cutscenes/CutsceneLibrary.php @@ -515,10 +515,7 @@ public function renameNew(CutsceneType $type, string $id, string $newId): Cutsce throw new RuntimeException(sprintf('"%s" is not a stable id (%s).', $newId, $type->describeIdCharacters())); } - $payload = $source->payload(); - $payload['id'] = $newId; - unset($payload[CutsceneAsset::ORIGIN_KEY]); - $renamed = CutsceneAsset::create($type, $newId, $this->rootFor($type), $payload, $this->projectRoot); + $renamed = $source->copyAs($newId, $this->rootFor($type)); foreach ($this->assets[$type->value] as $position => $asset) { if ($asset === $source) { @@ -550,10 +547,8 @@ public function duplicate(CutsceneType $type, string $id, string $newId): Cutsce throw new RuntimeException(sprintf('"%s" is not a stable id (%s).', $newId, $type->describeIdCharacters())); } - $payload = $source->payload(); - $payload['id'] = $newId; - unset($payload[CutsceneAsset::ORIGIN_KEY]); - $copy = CutsceneAsset::create($type, $newId, $this->rootFor($type), $payload, $this->projectRoot); + // Both files as they stand: every sequence, not only the one shown. + $copy = $source->copyAs($newId, $this->rootFor($type)); $this->assets[$type->value][] = $copy; $this->refreshRecords($type); diff --git a/src/Cutscenes/CutscenePairShape.php b/src/Cutscenes/CutscenePairShape.php index f22e01b..ad512c9 100644 --- a/src/Cutscenes/CutscenePairShape.php +++ b/src/Cutscenes/CutscenePairShape.php @@ -148,7 +148,7 @@ public function merge(array $data, array $partner): array */ public function split(array $payload): array { - unset($payload[CutsceneAsset::ORIGIN_KEY]); + unset($payload[CutsceneAsset::ORIGIN_KEY], $payload[CutsceneAsset::SEQUENCE_KEY]); $data = []; $partner = []; @@ -272,12 +272,27 @@ private function collisionReasons(array $data, array $partner): array array_keys($partner), static fn(int|string $key): bool => $key !== self::COMMANDS && array_key_exists($key, $data), )); + $reasons = []; + + if ($this->type === CutsceneType::SUMMON && is_array($partner['presentations'] ?? null)) { + // A summon's sequence is edited beside its definition and the + // timeline's own keys, so it may not hold one of theirs. + $outer = [...$data, ...array_diff_key($partner, ['presentations' => true])]; + + foreach ($partner['presentations'] as $name => $sequence) { + $clashes = is_array($sequence) ? array_keys(array_intersect_key($sequence, $outer)) : []; + + if ($clashes !== []) { + $reasons[] = sprintf('the %s sequence also declares %s, which the definition or timeline holds', $name, self::quoteKeys(array_map(strval(...), $clashes))); + } + } + } if ($shared === []) { - return []; + return $reasons; } - return [sprintf( + return [...$reasons, sprintf( 'both files declare the top-level key%s %s, and the editor can hold only one value for each', count($shared) === 1 ? '' : 's', implode(', ', array_map(static fn(int|string $key): string => '"' . $key . '"', $shared)), diff --git a/src/Cutscenes/Editing/CutscenesWorkspace.php b/src/Cutscenes/Editing/CutscenesWorkspace.php index 2f5e83f..03d518d 100644 --- a/src/Cutscenes/Editing/CutscenesWorkspace.php +++ b/src/Cutscenes/Editing/CutscenesWorkspace.php @@ -48,9 +48,12 @@ trait CutscenesWorkspace { /** The row choosing an effect's sequence; no timeline key, so no schema field, has this id. */ - private const string EFFECT_SEQUENCE_FIELD = '@sequence'; - private const string EFFECT_SEQUENCE_SHARED = 'shared'; - private const string EFFECT_SEQUENCE_SEPARATE = 'separate'; + private const string SEQUENCE_FIELD = '@sequence'; + private const string SEQUENCE_SHARED = 'shared'; + private const string SEQUENCE_SEPARATE = 'separate'; + private const string STAGE_FIELD = '@stage'; + private const string STAGE_NONE = 'none'; + private const string STAGE_ADDED = 'added'; /** * The type whose assets the screen shows. @@ -892,7 +895,10 @@ private function groupCutsceneFields(CutsceneAsset $asset, array $fields): array 'Availability' => ['availability.conditions'], 'Wielders' => ['wielders.mode', 'wielders.roles', 'wielders.characters', 'wielders.tenancy'], 'Playback' => ['playback.defaultSpeed', 'playback.allowSkip', 'playback.loopPreview', 'transitionIn.type', 'transitionIn.durationMs', 'transitionIn.color', 'transitionOut.type', 'transitionOut.durationMs', 'transitionOut.color', 'effectTiming.mode', 'effectTiming.cueId', 'effectTiming.frame', 'targetPresentation.mode', 'targetPresentation.showCasterNameBanner'], - 'Timeline' => ['formatVersion', 'fps', 'lengthFrames', 'editor', 'commandListTracks', 'commandListCues'], + 'Timeline' => ['formatVersion', 'fps', 'lengthFrames', 'restFrame', 'editor', 'commandListTracks', 'commandListCues'], + 'Stage' => ['stage.canvas', 'stage.startFrame', 'stage.restoreFrame', 'stage.background', + 'commandList' . ucfirst(CutsceneSchemas::STAGE_SUBJECTS_KEY), 'commandList' . ucfirst(CutsceneSchemas::STAGE_CAMERA_KEY), + 'commandList' . ucfirst(CutsceneSchemas::STAGE_COVERS_KEY)], ], }; $byId = []; @@ -941,12 +947,23 @@ private function groupCutsceneFields(CutsceneAsset $asset, array $fields): array } } - if ($rows === []) { + $stageRow = $title === 'Stage' ? $this->getStageField($asset) : null; + + if ($rows === [] && $stageRow === null) { continue; } $grouped[] = $heading($title); + if ($stageRow !== null) { + $grouped[] = $stageRow; + } + + if ($title === 'Timeline' && $asset->type === CutsceneType::SUMMON) { + // A summon's definition is shared; its timeline may be one per renderer. + $grouped = [...$grouped, ...$this->getSequenceFields($asset)]; + } + if ($title === 'Skip') { // Skip is a second ending, not a cancel: the Engine cancels // every lane and then runs the finalizer to the same final @@ -965,7 +982,7 @@ private function groupCutsceneFields(CutsceneAsset $asset, array $fields): array $grouped = [...$grouped, ...$rows]; if ($title === 'Identity' && $asset->type === CutsceneType::EFFECT) { - $grouped = [...$grouped, ...$this->getEffectSequenceFields($asset)]; + $grouped = [...$grouped, ...$this->getSequenceFields($asset)]; } } @@ -978,14 +995,15 @@ private function groupCutsceneFields(CutsceneAsset $asset, array $fields): array } /** - * Returns the row that chooses which of an effect's sequences the pane - * edits, and a note on what the other one does. A flat effect's single - * sequence plays for every renderer; choosing separate gives it a - * terminal and a graphical sequence, each a copy of it, undoably. + * Returns the row that chooses which of an effect's or summon's sequences + * the pane edits, and a note on what the other one does. A flat + * timeline's single sequence plays for every renderer; choosing separate + * gives it a terminal and a graphical sequence, each a copy of it, + * undoably. * * @return array> */ - private function getEffectSequenceFields(CutsceneAsset $asset): array + private function getSequenceFields(CutsceneAsset $asset): array { $view = $asset->getPresentationView(); @@ -994,8 +1012,8 @@ private function getEffectSequenceFields(CutsceneAsset $asset): array [ 'label' => 'Sequence', 'value' => 'Shared', - 'options' => [self::EFFECT_SEQUENCE_SHARED, self::EFFECT_SEQUENCE_SEPARATE], - 'field' => self::EFFECT_SEQUENCE_FIELD, + 'options' => [self::SEQUENCE_SHARED, self::SEQUENCE_SEPARATE], + 'field' => self::SEQUENCE_FIELD, 'hint' => 'separate gives the terminal and graphical renderers a sequence each', ], ['label' => ' One sequence plays for the terminal and graphical renderers alike.', 'value' => '', 'editable' => false], @@ -1009,7 +1027,7 @@ private function getEffectSequenceFields(CutsceneAsset $asset): array 'label' => 'Sequence', 'value' => ucfirst($view->value), 'options' => array_map(static fn(EffectPresentation $presentation): string => $presentation->value, EffectPresentation::cases()), - 'field' => self::EFFECT_SEQUENCE_FIELD, + 'field' => self::SEQUENCE_FIELD, 'hint' => 'the sequence these rows edit', ], ['label' => sprintf(' Edits apply to the %s sequence; the %s one is kept as written.', $view->value, $other->value), 'value' => '', 'editable' => false], @@ -1017,28 +1035,76 @@ private function getEffectSequenceFields(CutsceneAsset $asset): array } /** - * Shows another of the selected effect's sequences, or gives a flat - * effect separate ones, recorded so undo puts the flat file back. + * Returns the row that gives a summon's graphical sequence a cinematic + * stage or takes it away; none for any other sequence, since the + * terminal never draws one. + * + * @return array|null + */ + private function getStageField(CutsceneAsset $asset): ?array + { + if ($asset->type !== CutsceneType::SUMMON || $asset->getPresentationView() !== EffectPresentation::GRAPHICAL) { + return null; + } + + return [ + 'label' => 'Stage', + 'value' => $asset->hasStage() ? self::STAGE_ADDED : self::STAGE_NONE, + 'options' => [self::STAGE_NONE, self::STAGE_ADDED], + 'field' => self::STAGE_FIELD, + 'hint' => 'a stage of its own in place of the arena, until it is restored', + ]; + } + + /** + * Gives the selected summon's graphical sequence a stage, or takes it + * away, as one undo step. + */ + private function applyStageValue(string $rawValue): void + { + $asset = $this->selectedCutscene(); + $present = strtolower(trim($rawValue)) === self::STAGE_ADDED; + + if ($asset === null || $asset->getPresentationView() !== EffectPresentation::GRAPHICAL || $present === $asset->hasStage()) { + return; + } + + $changed = $this->mutateSelectedCutscene($present ? 'Add stage' : 'Remove stage', static function () use ($asset, $present): void { + $asset->setStage($present); + }); + + if ($changed) { + $this->setStatus($present + ? sprintf('Summon "%s" has a stage. Give it subjects, art on it and a rest frame inside it. It reaches disk on save.', $asset->id) + : sprintf('Summon "%s" no longer has a stage. It reaches disk on save.', $asset->id), StatusLevel::INFO); + } + + $this->renderCutscenesArea(); + } + + /** + * Shows another of the selected effect's or summon's sequences, or gives + * a flat one separate sequences, recorded so undo puts the flat file back. */ - private function applyEffectSequenceValue(string $rawValue): void + private function applySequenceValue(string $rawValue): void { $asset = $this->selectedCutscene(); $value = strtolower(trim($rawValue)); - if ($asset === null || $asset->type !== CutsceneType::EFFECT) { + if ($asset === null || $asset->type === CutsceneType::CINEMATIC) { return; } if (! $asset->hasPresentations()) { - if ($value !== self::EFFECT_SEQUENCE_SEPARATE) { + if ($value !== self::SEQUENCE_SEPARATE) { return; } - $this->mutateSelectedCutscene('Separate effect sequences', static function () use ($asset): void { + $this->mutateSelectedCutscene(sprintf('Separate %s sequences', $asset->type->noun()), static function () use ($asset): void { $asset->selectPresentation(EffectPresentation::TERMINAL); $asset->splitIntoPresentations(); }); - $this->setStatus(sprintf('Effect "%s" now has a terminal and a graphical sequence. It reaches disk on save.', $asset->id), StatusLevel::INFO); + $this->setStatus(sprintf('%s "%s" now has a terminal and a graphical sequence. It reaches disk on save.', ucfirst($asset->type->noun()), $asset->id), StatusLevel::INFO); $this->renderCutscenesArea(); return; @@ -1057,7 +1123,7 @@ private function applyEffectSequenceValue(string $rawValue): void // A running preview plays the other sequence; it starts again on this one. $this->disposeCinematicPreview(); $this->databaseCommandFramePath = []; - $this->setStatus(sprintf('Editing the %s sequence of effect "%s".', $presentation->value, $asset->id), StatusLevel::INFO); + $this->setStatus(sprintf('Editing the %s sequence of %s "%s".', $presentation->value, $asset->type->noun(), $asset->id), StatusLevel::INFO); $this->renderCutscenesArea(); } @@ -1244,8 +1310,14 @@ private function applyCutsceneFieldValueRecorded(array $field, string $rawValue) return; } - if ($fieldId === self::EFFECT_SEQUENCE_FIELD && $this->databaseCommandFramePath === []) { - $this->applyEffectSequenceValue($rawValue); + if ($fieldId === self::SEQUENCE_FIELD && $this->databaseCommandFramePath === []) { + $this->applySequenceValue($rawValue); + + return; + } + + if ($fieldId === self::STAGE_FIELD && $this->databaseCommandFramePath === []) { + $this->applyStageValue($rawValue); return; } diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index 4fc4c52..b733665 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -58,6 +58,21 @@ final class CutsceneSchemas */ public const string CUES_KEY = 'cues'; + /** The payload key a summon stage's subjects are edited under. */ + public const string STAGE_SUBJECTS_KEY = 'stage.subjects'; + + /** The payload key a summon stage's camera keys are edited under. */ + public const string STAGE_CAMERA_KEY = 'stage.camera'; + + /** The payload key a summon stage's cover keys are edited under. */ + public const string STAGE_COVERS_KEY = 'stage.covers'; + + /** Where a summon image track is placed: on a battler, the screen, or the graphical stage. */ + public const array SUMMON_IMAGE_ANCHORS = ['target', 'caster', 'screen', 'stage']; + + /** How a stage camera or cover key moves to the next: the Engine's easings. */ + public const array STAGE_EASINGS = ['linear', 'hold', 'smoothstep']; + /** * Cardinal facings a staged actor and a movement route step use. */ @@ -113,56 +128,58 @@ public static function cinematics(): RecordSchema */ public static function summons(): RecordSchema { + $fields = [ + new RecordField('id', 'Id', isReadOnly: true), + new RecordField('name', 'Name'), + new RecordField('description', 'Description', removeWhenEmpty: true), + new RecordField('moveName', 'Move Name', removeWhenEmpty: true), + new RecordField('version', 'Version', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('linkedSummonId', 'Linked Summon Id', removeWhenEmpty: true), + RecordField::reference('linkedActionId', 'Linked Action', 'skills', allowsNone: true, noneLabel: '(none)'), + new RecordField('tags', 'Tags', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), + // Lore and metadata. + new RecordField('lore', 'Lore', removeWhenEmpty: true), + RecordField::reference('element', 'Element', 'elements', allowsNone: true, noneLabel: '(none)'), + new RecordField('strengths', 'Strengths', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), + new RecordField('weaknesses', 'Weaknesses', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), + new RecordField('attributes', 'Attributes', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), + new RecordField('authoring', 'Authoring Metadata', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), + // Availability: omitted is open; declared conditions must hold. + new RecordField('availability.conditions', 'Availability', codec: RecordFieldCodec::CONDITIONS, removeWhenEmpty: true, displayDefault: '(omitted: open)'), + // Wielder policy. + new RecordField('wielders.mode', 'Wielders', options: ['all', 'roles', 'characters'], removeWhenEmpty: true, displayDefault: '(omitted: open)'), + new RecordField('wielders.roles', 'Wielder Roles', codec: RecordFieldCodec::CSV_LIST, reference: 'classes', removeWhenEmpty: true), + new RecordField('wielders.characters', 'Wielder Characters', codec: RecordFieldCodec::CSV_LIST, reference: 'actor_ids', removeWhenEmpty: true), + new RecordField('wielders.tenancy', 'Tenancy', options: ['shared', 'exclusive'], removeWhenEmpty: true, displayDefault: 'shared'), + // Playback and presentation. + new RecordField('playback.defaultSpeed', 'Default Speed', InputControlType::FLOAT, removeWhenEmpty: true, displayDefault: '1'), + RecordField::boolean('playback.allowSkip', 'Allow Skip'), + RecordField::boolean('playback.loopPreview', 'Loop Preview'), + new RecordField('transitionIn.type', 'Transition In', options: ['fadeToBlack', 'fadeFromBlack', 'none'], removeWhenEmpty: true, displayDefault: 'fadeToBlack'), + new RecordField('transitionIn.durationMs', 'Transition In Ms', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + new RecordField('transitionIn.color', 'Transition In Color', removeWhenEmpty: true), + new RecordField('transitionOut.type', 'Transition Out', options: ['fadeFromBlack', 'fadeToBlack', 'none'], removeWhenEmpty: true, displayDefault: 'fadeFromBlack'), + new RecordField('transitionOut.durationMs', 'Transition Out Ms', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + new RecordField('transitionOut.color', 'Transition Out Color', removeWhenEmpty: true), + new RecordField('effectTiming.mode', 'Effect Timing', options: SummonEffectTiming::AUTHORING_MODES, removeWhenEmpty: true, displayDefault: SummonEffectTiming::DEFAULT_MODE), + RecordField::reference('effectTiming.cueId', 'Effect Cue', 'summon_cues', allowsNone: true, noneLabel: '(none)'), + new RecordField('effectTiming.frame', 'Effect Frame', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('targetPresentation.mode', 'Target Presentation', options: ['full_screen', 'inline'], removeWhenEmpty: true, displayDefault: 'full_screen'), + RecordField::boolean('targetPresentation.showCasterNameBanner', 'Caster Name Banner'), + // Timeline. + new RecordField('formatVersion', 'Timeline Format', InputControlType::INTEGER), + new RecordField('fps', 'FPS', InputControlType::INTEGER), + new RecordField('lengthFrames', 'Length (frames)', InputControlType::INTEGER), + new RecordField('restFrame', 'Rest Frame', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: 'Last frame'), + new RecordField('editor', 'Editor Metadata', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), + ]; + return new RecordSchema( key: self::SUMMONS_KEY, entryNoun: 'summon', storage: RecordStorage::MAP_OWNED, relativePath: 'assets/Cutscenes/Summons', - fields: [ - new RecordField('id', 'Id', isReadOnly: true), - new RecordField('name', 'Name'), - new RecordField('description', 'Description', removeWhenEmpty: true), - new RecordField('moveName', 'Move Name', removeWhenEmpty: true), - new RecordField('version', 'Version', InputControlType::INTEGER, removeWhenEmpty: true), - new RecordField('linkedSummonId', 'Linked Summon Id', removeWhenEmpty: true), - RecordField::reference('linkedActionId', 'Linked Action', 'skills', allowsNone: true, noneLabel: '(none)'), - new RecordField('tags', 'Tags', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), - // Lore and metadata. - new RecordField('lore', 'Lore', removeWhenEmpty: true), - RecordField::reference('element', 'Element', 'elements', allowsNone: true, noneLabel: '(none)'), - new RecordField('strengths', 'Strengths', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), - new RecordField('weaknesses', 'Weaknesses', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), - new RecordField('attributes', 'Attributes', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), - new RecordField('authoring', 'Authoring Metadata', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), - // Availability: omitted is open; declared conditions must hold. - new RecordField('availability.conditions', 'Availability', codec: RecordFieldCodec::CONDITIONS, removeWhenEmpty: true, displayDefault: '(omitted: open)'), - // Wielder policy. - new RecordField('wielders.mode', 'Wielders', options: ['all', 'roles', 'characters'], removeWhenEmpty: true, displayDefault: '(omitted: open)'), - new RecordField('wielders.roles', 'Wielder Roles', codec: RecordFieldCodec::CSV_LIST, reference: 'classes', removeWhenEmpty: true), - new RecordField('wielders.characters', 'Wielder Characters', codec: RecordFieldCodec::CSV_LIST, reference: 'actor_ids', removeWhenEmpty: true), - new RecordField('wielders.tenancy', 'Tenancy', options: ['shared', 'exclusive'], removeWhenEmpty: true, displayDefault: 'shared'), - // Playback and presentation. - new RecordField('playback.defaultSpeed', 'Default Speed', InputControlType::FLOAT, removeWhenEmpty: true, displayDefault: '1'), - RecordField::boolean('playback.allowSkip', 'Allow Skip'), - RecordField::boolean('playback.loopPreview', 'Loop Preview'), - new RecordField('transitionIn.type', 'Transition In', options: ['fadeToBlack', 'fadeFromBlack', 'none'], removeWhenEmpty: true, displayDefault: 'fadeToBlack'), - new RecordField('transitionIn.durationMs', 'Transition In Ms', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), - new RecordField('transitionIn.color', 'Transition In Color', removeWhenEmpty: true), - new RecordField('transitionOut.type', 'Transition Out', options: ['fadeFromBlack', 'fadeToBlack', 'none'], removeWhenEmpty: true, displayDefault: 'fadeFromBlack'), - new RecordField('transitionOut.durationMs', 'Transition Out Ms', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), - new RecordField('transitionOut.color', 'Transition Out Color', removeWhenEmpty: true), - new RecordField('effectTiming.mode', 'Effect Timing', options: SummonEffectTiming::AUTHORING_MODES, removeWhenEmpty: true, displayDefault: SummonEffectTiming::DEFAULT_MODE), - RecordField::reference('effectTiming.cueId', 'Effect Cue', 'summon_cues', allowsNone: true, noneLabel: '(none)'), - new RecordField('effectTiming.frame', 'Effect Frame', InputControlType::INTEGER, removeWhenEmpty: true), - new RecordField('targetPresentation.mode', 'Target Presentation', options: ['full_screen', 'inline'], removeWhenEmpty: true, displayDefault: 'full_screen'), - RecordField::boolean('targetPresentation.showCasterNameBanner', 'Caster Name Banner'), - // Timeline. - new RecordField('formatVersion', 'Timeline Format', InputControlType::INTEGER), - new RecordField('fps', 'FPS', InputControlType::INTEGER), - new RecordField('lengthFrames', 'Length (frames)', InputControlType::INTEGER), - new RecordField('restFrame', 'Rest Frame', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: 'Last frame'), - new RecordField('editor', 'Editor Metadata', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), - ], + fields: $fields, labelKey: 'name', identityKey: 'id', blank: [ @@ -178,7 +195,123 @@ public static function summons(): RecordSchema commandLists: [ self::TRACKS_KEY => self::trackList(), self::CUES_KEY => self::cueList(), + self::STAGE_SUBJECTS_KEY => self::stageSubjectList(), + self::STAGE_CAMERA_KEY => self::stageCameraList(), + self::STAGE_COVERS_KEY => self::stageCoverList(), + ], + // A sequence with a stage edits it as rows and lists of its own. + fieldsFor: static fn(array $payload): array => is_array($payload['stage'] ?? null) ? [...$fields, ...self::stageFields()] : $fields, + commandListsFor: static fn(array $payload): array => is_array($payload['stage'] ?? null) + ? [self::TRACKS_KEY, self::CUES_KEY, self::STAGE_SUBJECTS_KEY, self::STAGE_CAMERA_KEY, self::STAGE_COVERS_KEY] + : [self::TRACKS_KEY, self::CUES_KEY], + ); + } + + /** + * A summon's graphical stage: its own canvas in stage units, the frames + * it is shown from and until the arena returns, and what it is drawn on. + * Its subjects, camera keys and cover keys are lists opened as frames. + * + * @return list + */ + public static function stageFields(): array + { + return [ + new RecordField('stage.canvas', 'Stage Canvas', codec: RecordFieldCodec::SIZE), + new RecordField('stage.startFrame', 'Stage Start Frame', InputControlType::INTEGER), + new RecordField('stage.restoreFrame', 'Stage Restore Frame', InputControlType::INTEGER), + // black, white or #RRGGBB, as cover colours are written. + new RecordField('stage.background', 'Stage Background', removeWhenEmpty: true, displayDefault: 'black'), + ]; + } + + /** + * The subjects a stage places art on: each a stable id, where it stands, + * its registered box and the point of that box that stands there, and + * named points within the box (a chest, the ground) that art and effects + * attach to. + */ + public static function stageSubjectList(): RecordSubList + { + return new RecordSubList( + key: self::STAGE_SUBJECTS_KEY, + prefix: 'subject', + singular: 'subject', + fields: [ + new RecordField('id', 'Id'), + new RecordField('position', 'Position', codec: RecordFieldCodec::COORDINATES), + new RecordField('size', 'Size', codec: RecordFieldCodec::SIZE), + new RecordField('pivot', 'Pivot', codec: RecordFieldCodec::NORMALIZED_POINT, removeWhenEmpty: true, displayDefault: '0.5, 1 (bottom centre)'), + ], + blank: ['id' => 'subject', 'position' => ['x' => 0, 'y' => 0], 'size' => ['width' => 100, 'height' => 100]], + nestedLists: ['*' => self::stageAttachmentList()], + heading: 'Stage Subjects', + removeWhenEmpty: true, + ); + } + + /** + * A stage subject's named points: each from 0 to 1 across and down its + * registered box. + */ + public static function stageAttachmentList(): RecordSubList + { + return new RecordSubList( + key: 'attachments', + prefix: 'attachment', + singular: 'point', + fields: [ + new RecordField('id', 'Id'), + new RecordField('x', 'X', InputControlType::FLOAT), + new RecordField('y', 'Y', InputControlType::FLOAT), + ], + blank: ['id' => 'point', 'x' => 0.5, 'y' => 0.5], + removeWhenEmpty: true, + ); + } + + /** + * The stage camera's keys, in frame order from frame zero: the stage + * point at the centre of the view and how near it is. + */ + public static function stageCameraList(): RecordSubList + { + return new RecordSubList( + key: self::STAGE_CAMERA_KEY, + prefix: 'camera', + singular: 'camera key', + fields: [ + new RecordField('id', 'Id'), + new RecordField('frame', 'Frame', InputControlType::INTEGER), + new RecordField('focus', 'Focus', codec: RecordFieldCodec::COORDINATES), + new RecordField('zoom', 'Zoom', InputControlType::FLOAT), + new RecordField('easing', 'Easing', options: ['', ...self::STAGE_EASINGS], removeWhenEmpty: true, displayDefault: '(linear)'), ], + blank: ['id' => 'key', 'frame' => 0, 'focus' => ['x' => 0, 'y' => 0], 'zoom' => 1], + heading: 'Stage Camera', + ); + } + + /** + * The stage's full-screen covers, in frame order: a colour and how + * opaque it is, from frame zero to a clear last frame. + */ + public static function stageCoverList(): RecordSubList + { + return new RecordSubList( + key: self::STAGE_COVERS_KEY, + prefix: 'cover', + singular: 'cover key', + fields: [ + new RecordField('id', 'Id'), + new RecordField('frame', 'Frame', InputControlType::INTEGER), + new RecordField('color', 'Color'), + new RecordField('opacity', 'Opacity', InputControlType::FLOAT), + new RecordField('easing', 'Easing', options: ['', ...self::STAGE_EASINGS], removeWhenEmpty: true, displayDefault: '(linear)'), + ], + blank: ['id' => 'cover', 'frame' => 0, 'color' => 'black', 'opacity' => 1], + heading: 'Stage Covers', + removeWhenEmpty: true, ); } @@ -652,15 +785,17 @@ public static function trackList(): RecordSubList removeWhenEmpty: true, displayDefault: 'all'), ], blank: ['type' => 'glyph', 'id' => 'track', 'keyframes' => []], - variants: ['image' => [ - new RecordField('anchor', 'Anchor', options: ['target', 'caster', 'screen'], removeWhenEmpty: true, displayDefault: 'target'), + // On a graphical stage, art is placed in stage units on a subject, + // not on a battler; elsewhere it sits on its battle anchor. + variants: ['image' => static fn(array $entry): array => self::isOnStage($entry) ? self::stageImageTrackFields() : [ + new RecordField('anchor', 'Anchor', options: self::SUMMON_IMAGE_ANCHORS, removeWhenEmpty: true, displayDefault: 'target'), // Battle only: the way the art is drawn; the battle mirrors it for a summon cast the other way. new RecordField('facing', 'Facing', options: ['', 'west', 'east'], removeWhenEmpty: true, displayDefault: '(undirected)'), ...self::imageTrackFields(), ]], variantKey: 'type', nestedLists: [ - 'image' => self::effectImageKeyframeList(), + 'image' => static fn(array $entry): RecordSubList => self::isOnStage($entry) ? self::stageImageKeyframeList() : self::effectImageKeyframeList(), // A track whose type is left out is a glyph track. '' => self::keyframeList(), 'glyph' => self::keyframeList(), @@ -671,6 +806,66 @@ public static function trackList(): RecordSubList ); } + /** Whether a summon image track is placed on its sequence's graphical stage. */ + private static function isOnStage(array $track): bool + { + return ($track['anchor'] ?? null) === 'stage'; + } + + /** + * A summon image track on the stage: its sheet and how a cell fills its + * box, the subject and named point it stands on (else the stage origin), + * an offset and box in stage units, and its depth among the stage's art. + * Battler geometry (cells, facing, battler attachment) does not apply. + * + * @return list + */ + public static function stageImageTrackFields(): array + { + $byKey = []; + + foreach (self::imageTrackFields() as $field) { + $byKey[$field->key] = $field; + } + + return [ + new RecordField('anchor', 'Anchor', options: self::SUMMON_IMAGE_ANCHORS, removeWhenEmpty: true, displayDefault: 'target'), + $byKey['asset'], + $byKey['sheet.columns'], + $byKey['sheet.rows'], + $byKey['fit'], + $byKey['pivot'], + RecordField::reference('placement.subject', 'Subject', 'stage_subjects', allowsNone: true, noneLabel: '(stage origin)'), + RecordField::reference('placement.attachment', 'Subject Point', 'stage_attachments', allowsNone: true, noneLabel: "(the subject's pivot)"), + new RecordField('placement.position', 'Offset', codec: RecordFieldCodec::COORDINATES, removeWhenEmpty: true, displayDefault: '0, 0'), + new RecordField('placement.size', 'Size', codec: RecordFieldCodec::SIZE, removeWhenEmpty: true, displayDefault: "(the subject's size)"), + new RecordField('zIndex', 'Z Index', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + ]; + } + + /** + * The keyframes of an image on the stage: which sheet cell shows, its + * offset in stage units, how opaque it is, and whether it is flipped. + */ + public static function stageImageKeyframeList(): RecordSubList + { + return new RecordSubList( + key: 'keyframes', + prefix: 'keyframe', + singular: 'keyframe', + fields: [ + new RecordField('frame', 'Frame', InputControlType::INTEGER), + new RecordField('duration', 'Duration', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '1'), + new RecordField('sourceFrame', 'Sheet Frame', InputControlType::INTEGER), + new RecordField('position', 'Offset', codec: RecordFieldCodec::COORDINATES, removeWhenEmpty: true, displayDefault: '0, 0'), + new RecordField('opacity', 'Opacity', InputControlType::FLOAT, removeWhenEmpty: true, displayDefault: '1'), + RecordField::boolean('flipX', 'Flip Horizontally'), + RecordField::boolean('flipY', 'Flip Vertically'), + ], + blank: ['frame' => 0, 'sourceFrame' => 0], + ); + } + /** * Returns the keyframe list of a track: every engine keyframe field. */ @@ -709,7 +904,7 @@ public static function cueList(): RecordSubList fields: [ new RecordField('id', 'Id'), new RecordField('frame', 'Frame', InputControlType::INTEGER), - new RecordField('type', 'Type', options: ['applyEffect', 'showMessage', 'playSound', 'shake', 'flash'], removeWhenEmpty: true, displayDefault: 'showMessage'), + new RecordField('type', 'Type', options: ['applyEffect', 'showMessage', 'playSound', 'shake', 'flash', 'restoreBattlefield'], removeWhenEmpty: true, displayDefault: 'showMessage'), new RecordField('payload', 'Payload', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), ], blank: ['id' => 'cue', 'frame' => 0, 'type' => 'showMessage', 'payload' => []], diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 0de33e0..69db615 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -488,13 +488,13 @@ public function getSettingsFields(int $index): array } if ($configIssue !== null) { $fields[] = ['label' => 'Read-only', 'value' => $configIssue, 'editable' => false]; } - foreach ($this->schema->commandLists as $listKey => $commandList) { + foreach ($this->schema->commandListsFor($record->toArray()) as $listKey => $commandList) { // A record-level command list (an NPC's inline script) is a // frame to open, like a branch arm, never a wall of rows here. $fields[] = [ // Named for what it is to the record (its Script), with the // count of commands it holds. - 'label' => ucfirst($listKey), + 'label' => $commandList->heading !== '' ? $commandList->heading : ucfirst($listKey), 'value' => sprintf('%d', count($record->getSubList($listKey))), 'field' => 'commandList' . ucfirst($listKey), 'frame' => [$listKey], @@ -3320,6 +3320,26 @@ private static function coerce(RecordField $field, string $rawValue): mixed return $slot; } + if ($field->codec === RecordFieldCodec::COORDINATES || $field->codec === RecordFieldCodec::SIZE) { + if ($trimmed === '' && $field->removeWhenEmpty) { + return null; + } + + $parts = array_map(trim(...), explode(',', $trimmed)); + $names = $field->codec === RecordFieldCodec::SIZE ? ['width', 'height'] : ['x', 'y']; + + if (count($parts) !== 2 || ! is_numeric($parts[0]) || ! is_numeric($parts[1]) + || ($field->codec === RecordFieldCodec::SIZE && min(floatval($parts[0]), floatval($parts[1])) <= 0.0)) { + throw new \InvalidArgumentException(sprintf( + $field->codec === RecordFieldCodec::SIZE ? '%s must be two numbers above 0, width and height, separated by a comma.' : '%s must be two numbers, x and y, separated by a comma.', + $field->label, + )); + } + + // Whole numbers stay integers, as an author writes them. + return array_combine($names, array_map(static fn(string $part): int|float => floor((float) $part) === (float) $part ? (int) $part : (float) $part, $parts)); + } + if ($field->codec === RecordFieldCodec::NORMALIZED_POINT) { if ($trimmed === '' && $field->removeWhenEmpty) { return null; @@ -3510,6 +3530,12 @@ private static function displayValue(RecordField $field, mixed $value): string RecordFieldCodec::NORMALIZED_POINT => is_array($value) ? sprintf('%s, %s', ProjectRecord::stringify($value['x'] ?? ''), ProjectRecord::stringify($value['y'] ?? '')) : ProjectRecord::stringify($value), + RecordFieldCodec::COORDINATES => is_array($value) + ? sprintf('%s, %s', ProjectRecord::stringify($value['x'] ?? ''), ProjectRecord::stringify($value['y'] ?? '')) + : ProjectRecord::stringify($value), + RecordFieldCodec::SIZE => is_array($value) + ? sprintf('%s, %s', ProjectRecord::stringify($value['width'] ?? ''), ProjectRecord::stringify($value['height'] ?? '')) + : ProjectRecord::stringify($value), RecordFieldCodec::NONE => ProjectRecord::stringify($value), }; } @@ -4246,6 +4272,8 @@ private function writeFrameCommands(ProjectRecord $record, RecordSubList $subLis $record->setSubList( $rootKey, self::withFrameList($record->getSubList($rootKey), $relativePath, $commands), + // A record-level list its file leaves out when empty goes with its last entry. + removeWhenEmpty: $this->schema->commandLists[$rootKey]->removeWhenEmpty ?? false, ); $this->touchState(); } diff --git a/src/Database/RecordFieldCodec.php b/src/Database/RecordFieldCodec.php index 584f1bb..35e1e1c 100644 --- a/src/Database/RecordFieldCodec.php +++ b/src/Database/RecordFieldCodec.php @@ -75,6 +75,21 @@ enum RecordFieldCodec: string */ case NORMALIZED_POINT = 'normalized_point'; + /** + * A point in authored units, stored as `['x' => x, 'y' => y]`, shown as + * `x, y`: where a cinematic stage subject stands, a camera key's focus, + * an image's offset on the stage. Whole numbers stay integers; both or + * neither. + */ + case COORDINATES = 'coordinates'; + + /** + * A size in authored units, stored as `['width' => w, 'height' => h]`, + * shown as `width, height`: a cinematic stage's canvas, a subject's + * registered box. Whole numbers stay integers; both or neither. + */ + case SIZE = 'size'; + /** * A battler slot, the Engine's BattlerSlot, stored as `['x' => x, * 'y' => y, 'width' => w, 'height' => h]` with an optional diff --git a/src/Database/RecordSchema.php b/src/Database/RecordSchema.php index e56717b..c2b5277 100644 --- a/src/Database/RecordSchema.php +++ b/src/Database/RecordSchema.php @@ -61,6 +61,9 @@ * @param bool $identityGiven Whether a record only ever belongs to something another category * names (an enemy's battle art), so one is made for that name or not at all, never under an * invented one the file could not key. + * @param Closure(array): string[]|null $commandListsFor The keys of the command lists + * one record offers, when they depend on what the record is: a summon's stage lists exist only + * where its stage does. Absent, every command list is offered. */ public function __construct( public string $key, @@ -86,6 +89,7 @@ public function __construct( public bool $numberedFiles = false, public ?Closure $saveCheck = null, public bool $identityGiven = false, + public ?Closure $commandListsFor = null, ) { if ($projection instanceof WholeFileProjection) { // A field outside the keys the category owns would read blank and @@ -139,6 +143,25 @@ public function fieldsFor(array|object $payload): array return $fields; } + /** + * Returns the command lists one record offers, by key: every one, unless + * the record's shape decides. + * + * @param array|object $payload The record payload. + * @return array The lists. + */ + public function commandListsFor(array|object $payload): array + { + if ($this->commandListsFor === null) { + return $this->commandLists; + } + + /** @var string[] $keys */ + $keys = ($this->commandListsFor)(is_array($payload) ? $payload : (array) $payload); + + return array_intersect_key($this->commandLists, array_flip($keys)); + } + /** * Returns the absolute path this category reads from. * diff --git a/src/Database/RecordSubList.php b/src/Database/RecordSubList.php index 13d4a67..d42a9e2 100644 --- a/src/Database/RecordSubList.php +++ b/src/Database/RecordSubList.php @@ -41,7 +41,8 @@ * @param bool $removeWhenEmpty Whether the list's key is dropped when its last entry goes, * as files that omit an empty list author it (a quest's reward items). * @param string $heading What a list beside the record's own is headed as ("Reward Items"): its - * heading row names it, counts it, and is where an entry is added to it. + * heading row names it, counts it, and is where an entry is added to it. A record-level command + * list is named by it on the row that opens it ("Stage Camera"), rather than by its key. * @param string|null $keyField For a list stored as a map (a tileset's pieces, keyed by piece * id): the field each entry carries its key as. Keys are unique and never empty. * @param string|null $valueField For a keyed list whose values are not entries of their own (a diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 6609b49..d96e369 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -88,6 +88,8 @@ final class ReferenceCatalog 'cinematic_checkpoints', 'summon_cues', 'effect_cues', + 'stage_subjects', + 'stage_attachments', 'event_markers', 'tilesets', 'effects', @@ -222,6 +224,10 @@ public function valuesFor(string $category): array 'cinematic_checkpoints' => $this->checkpointIds(), 'summon_cues' => $this->getTimelineCueIds(CutsceneType::SUMMON), 'effect_cues' => $this->getTimelineCueIds(CutsceneType::EFFECT), + // A summon sequence's stage: the subjects it registers, and every + // named point on them (each image picks one on its own subject). + 'stage_subjects' => array_keys($this->getStageSubjects()), + 'stage_attachments' => array_keys($this->getStageAttachments()), 'event_markers' => $this->currentMap?->getEventMarkers() ?? [], // A map's kind is one of the project's tilesets, by file stem. 'tilesets' => array_keys($this->loadTilesetNames()), @@ -433,6 +439,54 @@ private function getTimelineCueIds(CutsceneType $type): array return array_values($ids); } + /** + * The subjects of the stage the current summon sequence declares, by id. + * + * @return array> + */ + private function getStageSubjects(): array + { + if ($this->currentCutscene?->type !== CutsceneType::SUMMON) { + return []; + } + + $stage = $this->currentCutscene->payload()['stage'] ?? null; + $subjects = []; + + foreach (is_array($stage) && is_array($stage['subjects'] ?? null) ? $stage['subjects'] : [] as $subject) { + $id = is_array($subject) ? trim(strval($subject['id'] ?? '')) : ''; + + if ($id !== '') { + $subjects[$id] ??= $subject; + } + } + + return $subjects; + } + + /** + * The named points of the current stage's subjects, by id, each with the + * subjects that name it. + * + * @return array> + */ + private function getStageAttachments(): array + { + $points = []; + + foreach ($this->getStageSubjects() as $subject => $data) { + foreach (is_array($data['attachments'] ?? null) ? $data['attachments'] : [] as $point) { + $id = is_array($point) ? trim(strval($point['id'] ?? '')) : ''; + + if ($id !== '') { + $points[$id][] = $subject; + } + } + } + + return $points; + } + /** * Returns how each value of a reference kind should be shown, when the * value stored is not what an author recognises. @@ -479,6 +533,16 @@ public function labelsFor(string $category): array return $labels; } + if ($category === 'stage_attachments') { + // A point is picked on the image's own subject: the label says which have it. + $labels = []; + foreach ($this->getStageAttachments() as $id => $subjects) { + $labels[$id] = sprintf('%s (on %s)', $id, implode(', ', $subjects)); + } + + return $labels; + } + if ($category === 'elements_or_any') { return ['*' => '* (whichever element it was)']; } diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 1200287..38f001c 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -10,6 +10,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Cutscenes\CutsceneRecordCategory; use Ichiloto\Editor\Cutscenes\CutsceneType; +use Ichiloto\Editor\Database\CutsceneSchemas; use Ichiloto\Editor\Cutscenes\Preview\TimelinePreviewSession; use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\Canvas\PieceRole; @@ -1250,11 +1251,13 @@ public function describeCutsceneTimeline(string $category, int $index): array $field = (string) ($row['key']['field'] ?? ''); if (preg_match('/^track(\d+)(Id|Type|Presentation)$/', $field, $match) === 1) { $tracks[(int) $match[1]][strtolower($match[2])] = (string) $row['value']; - } elseif (preg_match('/^track(\d+)(Asset|Sheetcolumns|Sheetrows|Fit|Attachment|Pivot)$/', $field, $match) === 1) { - // An image track's art, and the keys that edit how it fills its cells and where it sits on its anchor. - $art = ['Asset' => 'asset', 'Sheetcolumns' => 'columns', 'Sheetrows' => 'rows', 'Fit' => 'fit', 'Attachment' => 'attachment', 'Pivot' => 'pivot'][$match[2]]; + } elseif (preg_match('/^track(\d+)(Asset|Sheetcolumns|Sheetrows|Fit|Attachment|Pivot|Anchor|Placementsubject|Placementattachment)$/', $field, $match) === 1) { + // An image track's art, and the keys that edit how it fills its cells and where it sits on its + // anchor: a battler point, or on the stage a subject and a named point on it. + $art = ['Asset' => 'asset', 'Sheetcolumns' => 'columns', 'Sheetrows' => 'rows', 'Fit' => 'fit', 'Attachment' => 'attachment', 'Pivot' => 'pivot', + 'Anchor' => 'anchor', 'Placementsubject' => 'subject', 'Placementattachment' => 'point'][$match[2]]; $tracks[(int) $match[1]]['art'][$art] = in_array($art, ['columns', 'rows'], true) ? max(1, (int) $row['value']) : (string) ($row['raw'] ?? $row['value']); - if (in_array($art, ['fit', 'attachment', 'pivot'], true)) { + if (in_array($art, ['fit', 'attachment', 'pivot', 'subject', 'point'], true)) { $tracks[(int) $match[1]]['art'][$art . 'Key'] = $row['key']; } if (in_array($art, ['fit', 'attachment'], true)) { @@ -1280,17 +1283,116 @@ public function describeCutsceneTimeline(string $category, int $index): array $payload = $asset->payload(); return [ - // An effect with separate terminal and graphical sequences names the one shown; others name none. + // An effect or summon with separate terminal and graphical sequences names the one shown; others name none. 'presentation' => $asset->getPresentationView()?->value, 'fps' => (int) ($payload['fps'] ?? 0), 'lengthFrames' => (int) ($payload['lengthFrames'] ?? 0), - 'tracks' => array_values(array_map(fn(array $track): array => [...$track, + 'tracks' => array_values(array_map(fn(array $track, int $position): array => self::placeOnStage([...$track, 'keyframes' => array_values($track['keyframes'] ?? []), - ...(isset($track['art']) ? ['art' => $this->measureTrackArt($track['art'])] : [])], $tracks)), + ...(isset($track['art']) ? ['art' => $this->measureTrackArt($track['art'])] : [])], $payload['tracks'][$position] ?? null), $tracks, array_keys($tracks))), 'cues' => array_values($cues), + // Whether this sequence may have a stage (a summon's graphical one), and the stage it has. + 'canHaveStage' => $asset->type === CutsceneType::SUMMON && $asset->getPresentationView() === EffectPresentation::GRAPHICAL, + 'stage' => is_array($payload['stage'] ?? null) ? $this->describeCutsceneStage($category, $index) : null, ]; } + /** + * A stage image's placement in stage units, and each keyframe's offset, + * as the Engine reads them with their absent defaults, so an editor can + * find the subject's box under a point of the art. Other tracks are as + * described. + * + * @param array $described + * @return array + */ + private static function placeOnStage(array $described, mixed $authored): array + { + if (! is_array($authored) || ($authored['anchor'] ?? null) !== 'stage' || ! isset($described['art'])) { + return $described; + } + + $placement = is_array($authored['placement'] ?? null) ? $authored['placement'] : []; + $described['art']['placement'] = [ + 'position' => self::readStagePoint($placement['position'] ?? null, ['x' => 0, 'y' => 0]), + 'size' => is_array($placement['size'] ?? null) ? ['width' => (float) ($placement['size']['width'] ?? 0), 'height' => (float) ($placement['size']['height'] ?? 0)] : null, + // An image's own pivot is its cell's centre unless authored. + 'pivot' => self::readStagePoint($authored['pivot'] ?? null, ['x' => 0.5, 'y' => 0.5]), + ]; + foreach ($described['keyframes'] as $index => $keyframe) { + $described['keyframes'][$index]['offset'] = self::readStagePoint($authored['keyframes'][$index]['position'] ?? null, ['x' => 0, 'y' => 0]); + } + + return $described; + } + + /** + * @param array{x: int|float, y: int|float} $default + * @return array{x: float, y: float} + */ + private static function readStagePoint(mixed $point, array $default): array + { + $point = is_array($point) ? $point : $default; + + return ['x' => (float) ($point['x'] ?? $default['x']), 'y' => (float) ($point['y'] ?? $default['y'])]; + } + + /** + * A summon sequence's stage as a timeline editor lays it out: its canvas + * and the frames it is shown between, each subject with its named points + * (where they are in its box), and its camera and cover keys on the + * clock, each value with the row key that edits it. + * + * @return array + */ + private function describeCutsceneStage(string $category, int $index): array + { + $stage = ['subjects' => [], 'camera' => [], 'covers' => []]; + foreach ($this->readDatabaseRecord($category, $index)['rows'] as $row) { + $name = ['stage.canvas' => 'canvas', 'stage.startFrame' => 'startFrame', 'stage.restoreFrame' => 'restoreFrame'][(string) ($row['key']['field'] ?? '')] ?? null; + if ($name !== null) { + $stage[$name] = $name === 'canvas' ? (string) $row['value'] : (int) $row['value']; + $stage[$name . 'Key'] = $row['key']; + } + } + foreach ($this->readDatabaseRecord($category, $index, [CutsceneSchemas::STAGE_SUBJECTS_KEY])['rows'] as $row) { + $field = (string) ($row['key']['field'] ?? ''); + if (preg_match('/^subject(\d+)Id$/', $field, $match) === 1) { + $stage['subjects'][(int) $match[1]]['id'] = (string) $row['value']; + } elseif (preg_match('/^subject(\d+)Attachment(\d+)(Id|X|Y)$/', $field, $match) === 1) { + $name = strtolower($match[3]); + $stage['subjects'][(int) $match[1]]['points'][(int) $match[2]][$name] = $name === 'id' ? (string) $row['value'] : (float) $row['value']; + $stage['subjects'][(int) $match[1]]['points'][(int) $match[2]][$name . 'Key'] = $row['key']; + } + } + foreach ([CutsceneSchemas::STAGE_CAMERA_KEY => ['camera', 'camera'], CutsceneSchemas::STAGE_COVERS_KEY => ['cover', 'covers']] as $list => [$prefix, $name]) { + foreach ($this->readDatabaseRecord($category, $index, [$list])['rows'] as $row) { + if (preg_match('/^' . $prefix . '(\d+)(Id|Frame)$/', (string) ($row['key']['field'] ?? ''), $match) === 1) { + $stage[$name][(int) $match[1]][strtolower($match[2])] = $match[2] === 'Frame' ? (int) $row['value'] : (string) $row['value']; + if ($match[2] === 'Frame') { + $stage[$name][(int) $match[1]]['frameKey'] = $row['key']; + } + } + } + } + + $authored = $this->requireTimelineCutscene($category, $index)->payload()['stage']['subjects'] ?? []; + foreach ($stage['subjects'] as $position => $subject) { + // Where the subject's box is, as the Engine reads it: its pivot is its bottom centre unless authored. + $data = is_array($authored[$position] ?? null) ? $authored[$position] : []; + $stage['subjects'][$position] += [ + 'position' => self::readStagePoint($data['position'] ?? null, ['x' => 0, 'y' => 0]), + 'size' => ['width' => (float) ($data['size']['width'] ?? 0), 'height' => (float) ($data['size']['height'] ?? 0)], + 'pivot' => self::readStagePoint($data['pivot'] ?? null, ['x' => 0.5, 'y' => 1]), + ]; + } + + return [...$stage, + 'subjects' => array_values(array_map(static fn(array $subject): array => [...$subject, 'points' => array_values($subject['points'] ?? [])], $stage['subjects'])), + 'camera' => array_values($stage['camera']), + 'covers' => array_values($stage['covers'])]; + } + /** * One frame of a summon or effect as its terminal presentation draws it, * from the record as edited, unsaved changes included, with the cues on @@ -1656,18 +1758,18 @@ private function measureTrackArt(array $art): array } /** - * Chooses which of an effect's sequences its record shows and edits, - * terminal or graphical, as the TUI's sequence switch does. Nothing is - * written; the other sequence stays as it is. + * Chooses which of an effect's or summon's sequences its record shows and + * edits, terminal or graphical, as the TUI's sequence switch does. + * Nothing is written; the other sequence stays as it is. * * @return array{presentation: string} - * @throws SessionRefusal When the record is not an effect with separate sequences, or the sequence is unknown. + * @throws SessionRefusal When the record has no separate sequences, or the sequence is unknown. */ public function selectCutscenePresentation(string $category, int $index, string $presentation): array { $asset = $this->requireTimelineCutscene($category, $index); $chosen = EffectPresentation::tryFrom($presentation) - ?? throw new SessionRefusal(sprintf('An effect sequence is terminal or graphical, not %s.', $presentation)); + ?? throw new SessionRefusal(sprintf('A sequence is terminal or graphical, not %s.', $presentation)); if (! $asset->hasPresentations()) { throw new SessionRefusal(sprintf('%s has one sequence for every renderer.', $asset->id)); } @@ -1677,6 +1779,69 @@ public function selectCutscenePresentation(string $category, int $index, string return ['presentation' => $chosen->value]; } + /** + * Gives a summon or effect with one sequence for every renderer a + * terminal and a graphical sequence, each a copy of it, as one undo step, + * as the TUI's sequence choice does; the record then edits the terminal + * one. It reaches disk on save. + * + * @return array{changed: bool, presentation: string} + * @throws SessionRefusal When the record already has them or cannot be written. + */ + public function separateCutsceneSequences(string $category, int $index): array + { + $asset = $this->requireTimelineCutscene($category, $index); + if ($asset->hasPresentations()) { + throw new SessionRefusal(sprintf('%s already has a terminal and a graphical sequence.', $asset->id)); + } + try { + $command = $this->workspace->cutscenes?->changeAsset($asset->type, $index, sprintf('Separate %s sequences', $asset->type->noun()), + static function () use ($asset): void { + $asset->selectPresentation(EffectPresentation::TERMINAL); + $asset->splitIntoPresentations(); + })['command']; + } catch (RuntimeException $failure) { + throw new SessionRefusal($failure->getMessage(), previous: $failure); + } + if ($command !== null) { + $this->history->record($command); + } + + return ['changed' => $command !== null, 'presentation' => EffectPresentation::TERMINAL->value]; + } + + /** + * Gives a summon's graphical sequence a cinematic stage, or takes it + * away, as one undo step, as the TUI's Stage row does. It reaches disk + * on save. + * + * @return array{changed: bool, stage: bool} + * @throws SessionRefusal When the record is not a summon with separate sequences, or cannot hold one. + */ + public function setCutsceneStage(string $category, int $index, bool $present): array + { + $asset = $this->requireTimelineCutscene($category, $index); + if ($asset->type !== CutsceneType::SUMMON || ! $asset->hasPresentations()) { + throw new SessionRefusal(sprintf('A stage is a summon\'s graphical sequence\'s own; separate %s\'s sequences first.', $asset->id)); + } + try { + $command = $this->workspace->cutscenes?->changeAsset($asset->type, $index, $present ? 'Add stage' : 'Remove stage', + static function () use ($asset, $present): void { + $asset->setStage($present); + })['command']; + } catch (RuntimeException $failure) { + throw new SessionRefusal($failure->getMessage(), previous: $failure); + } + if ($present && ! $asset->hasStage()) { + throw new SessionRefusal(sprintf('%s needs at least two frames to restore its stage before it ends.', $asset->id)); + } + if ($command !== null) { + $this->history->record($command); + } + + return ['changed' => $command !== null, 'stage' => $asset->hasStage()]; + } + /** A summon or effect record's asset; a cinematic is a command tree, not a timeline. */ private function requireTimelineCutscene(string $category, int $index): CutsceneAsset { diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 2fa903c..2b75b58 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -333,6 +333,9 @@ private function dispatch(string $method, array $params): array 'cutscenes.timeline' => $session->describeCutsceneTimeline(self::requireString($params, 'category'), self::requireInt($params, 'index')), 'cutscenes.presentation' => $session->selectCutscenePresentation(self::requireString($params, 'category'), self::requireInt($params, 'index'), self::requireString($params, 'presentation')), + 'cutscenes.separate' => $session->separateCutsceneSequences(self::requireString($params, 'category'), self::requireInt($params, 'index')), + 'cutscenes.stage' => $session->setCutsceneStage(self::requireString($params, 'category'), self::requireInt($params, 'index'), + is_bool($params['present'] ?? null) ? $params['present'] : throw new InvalidRequest('"present" must be true or false.')), 'cutscenes.preview' => $session->readCutscenePreview(self::requireString($params, 'category'), self::requireInt($params, 'index'), self::requireInt($params, 'frame'), self::requireInt($params, 'width'), self::requireInt($params, 'height')), 'cutscenes.cinematicPreview' => $session->startCinematicPreview(self::requireInt($params, 'index'), self::requireInt($params, 'width'), diff --git a/tests/Support/CutsceneFixtures.php b/tests/Support/CutsceneFixtures.php index 34e5c57..561c51a 100644 --- a/tests/Support/CutsceneFixtures.php +++ b/tests/Support/CutsceneFixtures.php @@ -121,6 +121,44 @@ function lanternSummonTimeline(): string PHP_SOURCE; } +/** A summon project whose lantern wisp has a terminal and a graphical sequence, its art a synthetic sheet. */ +function stagedSummonProject(): string +{ + $root = cutsceneProject(); + file_put_contents($root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.timeline.php', <<<'PHP_SOURCE' + 1, + 'presentations' => [ + 'terminal' => [ + 'fps' => 12, + 'lengthFrames' => 24, + 'tracks' => [ + ['type' => 'glyph', 'id' => 'wisp', 'keyframes' => [['frame' => 0, 'duration' => 24, 'content' => '*', 'position' => ['x' => 10, 'y' => 5]]]], + ], + 'cues' => [['id' => 'flare', 'frame' => 12, 'type' => 'applyEffect']], + ], + 'graphical' => [ + 'fps' => 12, + 'lengthFrames' => 24, + 'restFrame' => 12, + 'tracks' => [ + // The wisp itself. + ['type' => 'image', 'id' => 'wisp-art', 'asset' => 'Graphics/Summons/Wisp.png', 'sheet' => ['columns' => 2, 'rows' => 1], + 'keyframes' => [['frame' => 0, 'duration' => 24, 'sourceFrame' => 0]]], + ], + 'cues' => [['id' => 'flare', 'frame' => 12, 'type' => 'applyEffect']], + ], + ], +]; +PHP_SOURCE); + mkdir($root . '/assets/Graphics/Summons', 0o777, true); + writeTilesetTestPng($root . '/assets/Graphics/Summons/Wisp.png', 16, 8); + + return $root; +} + /** * A throwaway project holding the cinematic and the summon. */ diff --git a/tests/Unit/Cutscenes/SummonStageAuthoringTest.php b/tests/Unit/Cutscenes/SummonStageAuthoringTest.php new file mode 100644 index 0000000..300d1a5 --- /dev/null +++ b/tests/Unit/Cutscenes/SummonStageAuthoringTest.php @@ -0,0 +1,47 @@ +find(CutsceneType::SUMMON, 'lantern-wisp'); + + expect(cutsceneField($editor, '@sequence')['value'])->toBe('Terminal') + ->and(cutsceneFieldIds($editor))->not->toContain('@stage'); + + setCutsceneField($editor, '@sequence', 'graphical'); + expect(cutsceneField($editor, '@stage')['value'])->toBe('none'); + + setCutsceneField($editor, '@stage', 'added'); + expect($asset->hasStage())->toBeTrue() + ->and(cutsceneFieldIds($editor))->toContain('stage.canvas', 'stage.startFrame', 'stage.restoreFrame', 'commandListStage.subjects', 'commandListStage.camera'); + + callEditorMethod($editor, 'performUndo'); + expect($asset->hasStage())->toBeFalse() + ->and($asset->isDirty())->toBeFalse(); +}); + +it('adds a stage subject in its frame, and a named point to the subject under the cursor', function () { + $editor = cutscenesEditor(stagedSummonProject(), 160, 50); + callEditorMethod($editor, 'switchCutsceneType', CutsceneType::SUMMON); + callEditorMethod($editor, 'selectCutsceneById', 'lantern-wisp'); + setCutsceneField($editor, '@sequence', 'graphical'); + setCutsceneField($editor, '@stage', 'added'); + + openCutsceneFrame($editor, 'commandListStage.subjects'); + pressKeys($editor, 'O'); + expect(cutsceneFieldIds($editor))->toContain('subject0Id', 'subject0Position', 'subject0Size'); + + $ids = addCutsceneAfter($editor, 'subject0Id'); + expect($ids)->toContain('subject0Attachment0Id'); +}); diff --git a/tests/Unit/Session/SummonSequenceSessionTest.php b/tests/Unit/Session/SummonSequenceSessionTest.php new file mode 100644 index 0000000..4fb7ba5 --- /dev/null +++ b/tests/Unit/Session/SummonSequenceSessionTest.php @@ -0,0 +1,107 @@ +readDatabaseRecord('cutscenes/summon', 0)['rows'], static fn(array $row): bool => trim($row['label']) === $label) + ?? throw new RuntimeException(sprintf('No %s row.', $label)); +} + +it('separates a summon\'s timeline into terminal and graphical sequences as one undo step, keeping its definition shared', function () { + $root = cutsceneProject(); + $folder = $root . '/assets/Cutscenes/Summons/lantern-wisp'; + $dataBefore = (string) file_get_contents($folder . '/lantern-wisp.data.php'); + $session = EditorSession::open($root); + + expect($session->describeCutsceneTimeline('cutscenes/summon', 0)['presentation'])->toBeNull() + ->and(fn() => $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical'))->toThrow(SessionRefusal::class, 'one sequence'); + + expect($session->separateCutsceneSequences('cutscenes/summon', 0))->toBe(['changed' => true, 'presentation' => 'terminal']) + ->and($session->describeCutsceneTimeline('cutscenes/summon', 0)['presentation'])->toBe('terminal'); + $session->undo(); + expect($session->describeCutsceneTimeline('cutscenes/summon', 0)['presentation'])->toBeNull(); + $session->redo(); + + // The graphical sequence is edited on its own; the name stays the definition's. + $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical'); + $session->applyDatabaseRecord('cutscenes/summon', 0, summonRow($session, 'FPS')['key'], '24'); + $session->applyDatabaseRecord('cutscenes/summon', 0, summonRow($session, 'Name')['key'], 'Lantern Wraith'); + $session->selectCutscenePresentation('cutscenes/summon', 0, 'terminal'); + expect(summonRow($session, 'FPS')['value'])->toBe('12'); + expect($session->saveAll()['failures'])->toBe([]); + + $timeline = require $folder . '/lantern-wisp.timeline.php'; + expect(array_keys($timeline))->toBe(['formatVersion', 'presentations']) + ->and(array_keys($timeline['presentations']))->toBe(['terminal', 'graphical']) + ->and($timeline['presentations']['terminal']['fps'])->toBe(12) + ->and($timeline['presentations']['graphical']['fps'])->toBe(24) + ->and(array_keys($timeline['presentations']['graphical']))->toBe(['fps', 'lengthFrames', 'tracks', 'cues']) + ->and((string) file_get_contents($folder . '/lantern-wisp.data.php'))->toBe(str_replace("'Lantern Wisp'", "'Lantern Wraith'", $dataBefore)); + + // The Engine plays each renderer its own sequence. + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::SUMMON, 'lantern-wisp'); + expect($asset->compiledSummon(EffectPresentation::TERMINAL)->fps)->toBe(12) + ->and($asset->compiledSummon(EffectPresentation::GRAPHICAL)->fps)->toBe(24); +}); + +it('adds a sequence key a summon sequence did not hold to that sequence, never to the definition', function () { + $root = cutsceneProject(); + $folder = $root . '/assets/Cutscenes/Summons/lantern-wisp'; + $session = EditorSession::open($root); + $session->separateCutsceneSequences('cutscenes/summon', 0); + $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical'); + + $session->applyDatabaseRecord('cutscenes/summon', 0, summonRow($session, 'Rest Frame')['key'], '12'); + $session->saveAll(); + + $timeline = require $folder . '/lantern-wisp.timeline.php'; + expect($timeline['presentations']['graphical']['restFrame'])->toBe(12) + ->and($timeline['presentations']['terminal'])->not->toHaveKey('restFrame') + ->and((string) file_get_contents($folder . '/lantern-wisp.data.php'))->not->toContain('restFrame'); +}); + +it('duplicates a summon or effect with separate sequences whole, whichever sequence is shown', function () { + $root = cutsceneProject(); + $session = EditorSession::open($root); + $session->separateCutsceneSequences('cutscenes/summon', 0); + $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical'); + $session->applyDatabaseRecord('cutscenes/summon', 0, summonRow($session, 'FPS')['key'], '24'); + + $copy = $session->duplicateDatabaseRecord('cutscenes/summon', 0); + $session->saveAll(); + + $timeline = require $root . '/assets/Cutscenes/Summons/lantern-wisp-copy/lantern-wisp-copy.timeline.php'; + $data = require $root . '/assets/Cutscenes/Summons/lantern-wisp-copy/lantern-wisp-copy.data.php'; + expect($copy['index'])->toBe(1) + ->and(array_keys($timeline['presentations']))->toBe(['terminal', 'graphical']) + ->and($timeline['presentations']['terminal']['fps'])->toBe(12) + ->and($timeline['presentations']['graphical']['fps'])->toBe(24) + ->and($data['id'])->toBe('lantern-wisp-copy'); +}); + +it('opens a summon read-only when a sequence declares a key its definition holds', function () { + $root = cutsceneProject(); + $folder = $root . '/assets/Cutscenes/Summons/lantern-wisp'; + file_put_contents($folder . '/lantern-wisp.timeline.php', " 1, 'presentations' => [\n" + . " 'terminal' => ['fps' => 12, 'lengthFrames' => 2, 'tracks' => [], 'cues' => [], 'name' => 'Shadow'],\n" + . " 'graphical' => ['fps' => 12, 'lengthFrames' => 2, 'tracks' => [], 'cues' => []],\n]];\n"); + + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::SUMMON, 'lantern-wisp'); + + expect($asset->isEditable())->toBeFalse() + ->and($asset->readOnlyReason())->toContain('the terminal sequence also declares "name"'); +}); diff --git a/tests/Unit/Session/SummonStageSessionTest.php b/tests/Unit/Session/SummonStageSessionTest.php new file mode 100644 index 0000000..963e169 --- /dev/null +++ b/tests/Unit/Session/SummonStageSessionTest.php @@ -0,0 +1,156 @@ +readDatabaseRecord('cutscenes/summon', 0, $frame)['rows'] as $row) { + $rows[trim($row['label'])] ??= $row; + } + + return $rows; +} + +it('gives only the graphical sequence a stage, as one undo step, with rows of its own', function () { + $session = EditorSession::open(stagedSummonProject()); + $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical'); + + expect(stageRows($session))->not->toHaveKey('Stage Canvas'); + expect($session->setCutsceneStage('cutscenes/summon', 0, true))->toBe(['changed' => true, 'stage' => true]); + $rows = stageRows($session); + expect($rows['Stage Canvas']['value'])->toBe('1280, 720') + ->and($rows['Stage Start Frame']['value'])->toBe('0') + ->and($rows['Stage Restore Frame']['value'])->toBe('23') + ->and($rows['Stage Background']['value'])->toBe('black') + ->and($rows['Stage Subjects']['value'])->toBe('0') + ->and($rows['Stage Camera']['value'])->toBe('1') + ->and($rows['Stage Covers']['value'])->toBe('0'); + + // The terminal sequence never draws a stage. + $session->selectCutscenePresentation('cutscenes/summon', 0, 'terminal'); + expect(stageRows($session))->not->toHaveKey('Stage Canvas'); + $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical'); + + $session->undo(); + expect(stageRows($session))->not->toHaveKey('Stage Canvas'); + $session->redo(); + expect(stageRows($session))->toHaveKey('Stage Canvas'); + expect($session->setCutsceneStage('cutscenes/summon', 0, false))->toBe(['changed' => true, 'stage' => false]) + ->and(stageRows($session))->not->toHaveKey('Stage Canvas'); +}); + +it('refuses a stage for a summon whose timeline is shared by every renderer', function () { + $session = EditorSession::open(cutsceneProject()); + + expect(fn() => $session->setCutsceneStage('cutscenes/summon', 0, true))->toThrow(SessionRefusal::class, 'separate lantern-wisp'); +}); + +it('authors a stage subject with a named point, places the art on it, and saves what the Engine plays', function () { + $root = stagedSummonProject(); + $session = EditorSession::open($root); + $session->setCutsceneStage('cutscenes/summon', 0, true); + $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical'); + $apply = static fn(array $row, string $value) => $session->applyDatabaseRecord('cutscenes/summon', 0, $row['key'], $value); + + // A subject: who stands where, in a box of its own. + $session->addDatabaseItem('cutscenes/summon', 0, ['frame' => ['stage.subjects']]); + $apply(stageRows($session, ['stage.subjects'])['Subject 1 Id'], 'visitor'); + $apply(stageRows($session, ['stage.subjects'])['Subject 1 Position'], '640, 600'); + $apply(stageRows($session, ['stage.subjects'])['Subject 1 Size'], '300, 500.5'); + // Its chest, a point within that box. + $session->addDatabaseItem('cutscenes/summon', 0, stageRows($session, ['stage.subjects'])['Subject 1 Id']['key'], true); + $point = stageRows($session, ['stage.subjects']); + $session->applyDatabaseRecordValues('cutscenes/summon', 0, [ + ['key' => $point['Subject 1 Point 1 Id']['key'], 'value' => 'chest'], + ['key' => $point['Subject 1 Point 1 X']['key'], 'value' => '0.5'], + ['key' => $point['Subject 1 Point 1 Y']['key'], 'value' => '0.35'], + ], 'Chest point'); + + // The art stands on the stage, on the visitor's chest, picked rather than typed. + $apply(stageRows($session, ['tracks'])['Track 1 Anchor'], 'stage'); + $track = stageRows($session, ['tracks']); + expect($track)->toHaveKeys(['Track 1 Subject', 'Track 1 Subject Point', 'Track 1 Offset', 'Track 1 Z Index', 'Track 1 Keyframe 1 Opacity']) + ->not->toHaveKeys(['Track 1 Facing', 'Track 1 Cell Width', 'Track 1 Attachment']); + $choices = $session->listReferences(null, 'stage_subjects', ['category' => 'cutscenes/summon', 'index' => 0]); + expect(array_column($choices, 'value'))->toContain('visitor'); + $apply($track['Track 1 Subject'], 'visitor'); + $apply(stageRows($session, ['tracks'])['Track 1 Subject Point'], 'chest'); + $apply(stageRows($session, ['tracks'])['Track 1 Keyframe 1 Opacity'], '0'); + + expect($session->saveAll()['failures'])->toBe([]); + $graphical = (require $root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.timeline.php')['presentations']['graphical']; + expect(array_keys($graphical))->toBe(['fps', 'lengthFrames', 'restFrame', 'stage', 'tracks', 'cues']) + ->and($graphical['stage']['subjects'])->toBe([['id' => 'visitor', 'position' => ['x' => 640, 'y' => 600], 'size' => ['width' => 300, 'height' => 500.5], + 'attachments' => [['id' => 'chest', 'x' => 0.5, 'y' => 0.35]]]]) + ->and($graphical['tracks'][0]['anchor'])->toBe('stage') + ->and($graphical['tracks'][0]['placement'])->toBe(['subject' => 'visitor', 'attachment' => 'chest']) + ->and($graphical['tracks'][0]['keyframes'][0]['opacity'])->toBe(0.0); + + $compiled = CutsceneLibrary::fromProject($root)->find(CutsceneType::SUMMON, 'lantern-wisp')->compiledSummon(EffectPresentation::GRAPHICAL); + expect($compiled->defaults['stage']['subjects'][0]['id'])->toBe('visitor'); +}); + +it('names the stage subjects and their points to pick from, and nothing for a summon without one', function () { + $session = EditorSession::open(stagedSummonProject()); + $context = ['category' => 'cutscenes/summon', 'index' => 0]; + + expect($session->listReferences(null, 'stage_subjects', $context))->toBe([]); + $session->setCutsceneStage('cutscenes/summon', 0, true); + $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical'); + $session->addDatabaseItem('cutscenes/summon', 0, ['frame' => ['stage.subjects']]); + $session->addDatabaseItem('cutscenes/summon', 0, stageRows($session, ['stage.subjects'])['Subject 1 Id']['key'], true); + + expect(array_column($session->listReferences(null, 'stage_subjects', $context), 'value'))->toBe(['subject']) + ->and($session->listReferences(null, 'stage_attachments', $context))->toBe([['value' => 'point', 'label' => 'point (on subject)']]); +}); + +it('describes the stage for a timeline editor: subjects with their boxes and points, camera and cover keys, and where stage art is placed', function () { + $root = stagedSummonProject(); + $session = EditorSession::open($root); + $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical'); + $timeline = $session->describeCutsceneTimeline('cutscenes/summon', 0); + expect($timeline['canHaveStage'])->toBeTrue() + ->and($timeline['stage'])->toBeNull(); + + $session->setCutsceneStage('cutscenes/summon', 0, true); + $session->addDatabaseItem('cutscenes/summon', 0, ['frame' => ['stage.subjects']]); + $session->addDatabaseItem('cutscenes/summon', 0, stageRows($session, ['stage.subjects'])['Subject 1 Id']['key'], true); + $session->applyDatabaseRecord('cutscenes/summon', 0, stageRows($session, ['tracks'])['Track 1 Anchor']['key'], 'stage'); + $session->applyDatabaseRecord('cutscenes/summon', 0, stageRows($session, ['tracks'])['Track 1 Subject']['key'], 'subject'); + $session->applyDatabaseRecord('cutscenes/summon', 0, stageRows($session, ['tracks'])['Track 1 Keyframe 1 Offset']['key'], '4, -2.5'); + $timeline = $session->describeCutsceneTimeline('cutscenes/summon', 0); + + expect($timeline['stage'])->toMatchArray(['canvas' => '1280, 720', 'startFrame' => 0, 'restoreFrame' => 23]) + ->and($timeline['stage']['subjects'])->toHaveCount(1) + ->and($timeline['stage']['subjects'][0])->toMatchArray(['id' => 'subject', 'position' => ['x' => 0.0, 'y' => 0.0], + 'size' => ['width' => 100.0, 'height' => 100.0], 'pivot' => ['x' => 0.5, 'y' => 1.0]]) + ->and($timeline['stage']['subjects'][0]['points'][0])->toMatchArray(['id' => 'point', 'x' => 0.5, 'y' => 0.5]) + ->and($timeline['stage']['subjects'][0]['points'][0]['xKey']['field'])->toBe('subject0Attachment0X') + ->and($timeline['stage']['camera'])->toHaveCount(1) + ->and($timeline['stage']['camera'][0])->toMatchArray(['id' => 'initial', 'frame' => 0]) + ->and($timeline['stage']['covers'])->toBe([]) + ->and($timeline['tracks'][0]['art'])->toMatchArray(['anchor' => 'stage', 'subject' => 'subject', + 'placement' => ['position' => ['x' => 0.0, 'y' => 0.0], 'size' => null, 'pivot' => ['x' => 0.5, 'y' => 0.5]]]) + ->and($timeline['tracks'][0]['art'])->not->toHaveKey('attachmentOptions') + ->and($timeline['tracks'][0]['keyframes'][0]['offset'])->toBe(['x' => 4.0, 'y' => -2.5]); + + // The terminal sequence never has one. + $session->selectCutscenePresentation('cutscenes/summon', 0, 'terminal'); + expect($session->describeCutsceneTimeline('cutscenes/summon', 0))->toMatchArray(['canHaveStage' => false, 'stage' => null]); +}); From c7b2df3cfd02e05e038fd780d320555e278f2b4f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 13:04:13 +0200 Subject: [PATCH 205/344] refactor(cutscenes): read stage easings and paired timeline keys from the Engine --- src/Cutscenes/CutsceneAsset.php | 10 ++-------- src/Database/CutsceneSchemas.php | 8 +++----- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/Cutscenes/CutsceneAsset.php b/src/Cutscenes/CutsceneAsset.php index 960861e..c3aa1ff 100644 --- a/src/Cutscenes/CutsceneAsset.php +++ b/src/Cutscenes/CutsceneAsset.php @@ -64,12 +64,6 @@ final class CutsceneAsset */ public const string SEQUENCE_KEY = '__sequence'; - /** - * A summon timeline's keys that stay beside its separate sequences; the - * Engine reads every other timeline key inside each sequence. - */ - public const array PAIRED_SUMMON_TIMELINE_FIELDS = ['formatVersion', 'presentations', 'editor']; - /** The canvas a new summon stage starts with, in stage units. */ private const array STAGE_CANVAS = ['width' => 1280, 'height' => 720]; @@ -428,7 +422,7 @@ public function splitIntoPresentations(): bool } $outer = $this->type === CutsceneType::SUMMON - ? array_intersect_key($this->partner, array_flip(self::PAIRED_SUMMON_TIMELINE_FIELDS)) + ? array_intersect_key($this->partner, array_flip(SummonCutsceneDefinition::PAIRED_TIMELINE_FIELDS)) : []; $sequence = array_diff_key($this->partner, $outer); $presentations = [EffectPresentation::TERMINAL->value => $sequence, EffectPresentation::GRAPHICAL->value => $sequence]; @@ -653,7 +647,7 @@ public function apply(array $payload): void $edited = $this->type === CutsceneType::EFFECT ? $payload : array_filter($payload, static fn(string $key): bool => array_key_exists($key, $old) - || (in_array($key, CinematicCommandSchema::SUMMON_TIMELINE_FIELDS, true) && ! in_array($key, self::PAIRED_SUMMON_TIMELINE_FIELDS, true)), + || (in_array($key, CinematicCommandSchema::SUMMON_TIMELINE_FIELDS, true) && ! in_array($key, SummonCutsceneDefinition::PAIRED_TIMELINE_FIELDS, true)), ARRAY_FILTER_USE_KEY); $presentations = $this->partner['presentations']; $presentations[$this->presentationView->value] = array_intersect_key(array_replace(array_flip(array_keys($old)), $edited), $edited); diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index b733665..f40f79b 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -12,6 +12,7 @@ use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasImageFit; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; +use Ichiloto\Engine\Cutscenes\Presentation\CinematicStage; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; use Ichiloto\Engine\Cutscenes\Summons\SummonEffectTiming; @@ -70,9 +71,6 @@ final class CutsceneSchemas /** Where a summon image track is placed: on a battler, the screen, or the graphical stage. */ public const array SUMMON_IMAGE_ANCHORS = ['target', 'caster', 'screen', 'stage']; - /** How a stage camera or cover key moves to the next: the Engine's easings. */ - public const array STAGE_EASINGS = ['linear', 'hold', 'smoothstep']; - /** * Cardinal facings a staged actor and a movement route step use. */ @@ -285,7 +283,7 @@ public static function stageCameraList(): RecordSubList new RecordField('frame', 'Frame', InputControlType::INTEGER), new RecordField('focus', 'Focus', codec: RecordFieldCodec::COORDINATES), new RecordField('zoom', 'Zoom', InputControlType::FLOAT), - new RecordField('easing', 'Easing', options: ['', ...self::STAGE_EASINGS], removeWhenEmpty: true, displayDefault: '(linear)'), + new RecordField('easing', 'Easing', options: ['', ...CinematicStage::EASINGS], removeWhenEmpty: true, displayDefault: '(linear)'), ], blank: ['id' => 'key', 'frame' => 0, 'focus' => ['x' => 0, 'y' => 0], 'zoom' => 1], heading: 'Stage Camera', @@ -307,7 +305,7 @@ public static function stageCoverList(): RecordSubList new RecordField('frame', 'Frame', InputControlType::INTEGER), new RecordField('color', 'Color'), new RecordField('opacity', 'Opacity', InputControlType::FLOAT), - new RecordField('easing', 'Easing', options: ['', ...self::STAGE_EASINGS], removeWhenEmpty: true, displayDefault: '(linear)'), + new RecordField('easing', 'Easing', options: ['', ...CinematicStage::EASINGS], removeWhenEmpty: true, displayDefault: '(linear)'), ], blank: ['id' => 'cover', 'frame' => 0, 'color' => 'black', 'opacity' => 1], heading: 'Stage Covers', From d94e5f64a53c510b2e9f22c9ec273ed8848a9d98 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 13:13:34 +0200 Subject: [PATCH 206/344] feat(database): name the entry each list row belongs to, and its name within it --- src/Database/ProjectRecordDatabase.php | 16 +++++++++++++--- src/Session/EditorSession.php | 3 +++ tests/Unit/Session/SummonStageSessionTest.php | 17 +++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 69db615..c23e0ba 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -538,14 +538,16 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, { $fields = []; + // Each row names the entry it belongs to and its own name within it, so an + // interface can show one selected entry's rows, labelled as that entry's. foreach ($subList->fieldsFor($entry) as $field) { - $fields[] = self::describeField( + $fields[] = [...self::describeField( $field, self::displayValue($field, self::readNested($entry, $field->key)), self::subFieldId($subList->prefix, $entryIndex, $field->key), $isEditable, sprintf('%s %d %s', ucfirst($subList->singular), $entryIndex + 1, $field->label), - ); + ), 'entry' => [$entryIndex], 'name' => $field->label]; } $variant = $subList->variantKey !== null ? strval($entry[$subList->variantKey] ?? '') : ''; @@ -566,6 +568,8 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, 'label' => $label . ' Text', 'value' => $text, 'field' => sprintf('%s%dOption%dText', $subList->prefix, $entryIndex, $optionIndex), + 'entry' => [$entryIndex], + 'name' => sprintf('Option %d Text', $optionIndex + 1), ]; if ($isEditable) { @@ -578,6 +582,8 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, 'value' => sprintf('%d', $armCount), 'field' => sprintf('%s%dOption%dThen', $subList->prefix, $entryIndex, $optionIndex), 'frame' => [...$basePath, $entryIndex, 'options', $optionIndex, 'then'], + 'entry' => [$entryIndex], + 'name' => sprintf('Option %d Commands', $optionIndex + 1), ]; } } @@ -598,6 +604,8 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, 'value' => sprintf('%d', count((array) ($entry[$armKey] ?? []))), 'field' => sprintf('%s%d%s', $subList->prefix, $entryIndex, ucfirst($armKey)), 'frame' => [...$basePath, $entryIndex, $armKey], + 'entry' => [$entryIndex], + 'name' => $armLabel . ' Commands', ]; } @@ -613,7 +621,7 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, } foreach ($nestedList->fieldsFor($nestedEntry) as $field) { - $fields[] = self::describeField( + $fields[] = ['entry' => [$entryIndex, $nestedIndex], 'name' => $field->label] + self::describeField( $field, self::displayValue($field, self::readNested($nestedEntry, $field->key)), self::nestedSubFieldId( @@ -650,6 +658,8 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, 'value' => sprintf('%d', count((array) ($nestedEntry[$armKey] ?? []))), 'field' => self::nestedSubFieldId($subList->prefix, $entryIndex, $nestedList->prefix, $nestedIndex, ucfirst($armKey)), 'frame' => [...$basePath, $entryIndex, $nestedList->key, $nestedIndex, $armKey], + 'entry' => [$entryIndex, $nestedIndex], + 'name' => $armLabel . ' Commands', ]; } } diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 38f001c..73d372a 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -3524,6 +3524,9 @@ private static function describeRow(array $field): array return array_filter([ 'label' => trim($label), + // A list entry's row: which entry (and nested entry) it is part of, and its name within it. + 'entry' => is_array($field['entry'] ?? null) ? array_values($field['entry']) : null, + 'name' => is_string($field['name'] ?? null) ? $field['name'] : null, 'depth' => $depth, // A section heading: a row that only names the rows after it. 'heading' => $kind === 'info' && (string) ($field['value'] ?? '') === '' && $key === null && $list === null ? true : null, diff --git a/tests/Unit/Session/SummonStageSessionTest.php b/tests/Unit/Session/SummonStageSessionTest.php index 963e169..ddcead5 100644 --- a/tests/Unit/Session/SummonStageSessionTest.php +++ b/tests/Unit/Session/SummonStageSessionTest.php @@ -154,3 +154,20 @@ function stageRows(EditorSession $session, array $frame = []): array $session->selectCutscenePresentation('cutscenes/summon', 0, 'terminal'); expect($session->describeCutsceneTimeline('cutscenes/summon', 0))->toMatchArray(['canHaveStage' => false, 'stage' => null]); }); + +it('names the entry each list row belongs to and its name within it, so one selected entry can be shown on its own', function () { + $session = EditorSession::open(stagedSummonProject()); + $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical'); + $session->setCutsceneStage('cutscenes/summon', 0, true); + $session->addDatabaseItem('cutscenes/summon', 0, ['frame' => ['stage.subjects']]); + $session->addDatabaseItem('cutscenes/summon', 0, stageRows($session, ['stage.subjects'])['Subject 1 Id']['key'], true); + $tracks = stageRows($session, ['tracks']); + $subjects = stageRows($session, ['stage.subjects']); + + expect($tracks['Track 1 Id'])->toMatchArray(['entry' => [0], 'name' => 'Id']) + ->and($tracks['Track 1 Keyframe 1 Sheet Frame'])->toMatchArray(['entry' => [0, 0], 'name' => 'Sheet Frame']) + ->and($subjects['Subject 1 Size'])->toMatchArray(['entry' => [0], 'name' => 'Size']) + ->and($subjects['Subject 1 Point 1 X'])->toMatchArray(['entry' => [0, 0], 'name' => 'X']) + // The record's own rows belong to no entry. + ->and(stageRows($session)['Name'])->not->toHaveKey('entry'); +}); From 87ec0261dd53d75a8fdbb335542428c8c74d8bda Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 13:19:46 +0200 Subject: [PATCH 207/344] feat(database): name a list row's entry for the person, such as Point 1 --- src/Database/ProjectRecordDatabase.php | 9 +++++++-- src/Session/EditorSession.php | 1 + tests/Unit/Session/SummonStageSessionTest.php | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index c23e0ba..7dc060e 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -547,7 +547,7 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, self::subFieldId($subList->prefix, $entryIndex, $field->key), $isEditable, sprintf('%s %d %s', ucfirst($subList->singular), $entryIndex + 1, $field->label), - ), 'entry' => [$entryIndex], 'name' => $field->label]; + ), 'entry' => [$entryIndex], 'entryLabel' => sprintf('%s %d', ucfirst($subList->singular), $entryIndex + 1), 'name' => $field->label]; } $variant = $subList->variantKey !== null ? strval($entry[$subList->variantKey] ?? '') : ''; @@ -569,6 +569,7 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, 'value' => $text, 'field' => sprintf('%s%dOption%dText', $subList->prefix, $entryIndex, $optionIndex), 'entry' => [$entryIndex], + 'entryLabel' => sprintf('%s %d', ucfirst($subList->singular), $entryIndex + 1), 'name' => sprintf('Option %d Text', $optionIndex + 1), ]; @@ -583,6 +584,7 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, 'field' => sprintf('%s%dOption%dThen', $subList->prefix, $entryIndex, $optionIndex), 'frame' => [...$basePath, $entryIndex, 'options', $optionIndex, 'then'], 'entry' => [$entryIndex], + 'entryLabel' => sprintf('%s %d', ucfirst($subList->singular), $entryIndex + 1), 'name' => sprintf('Option %d Commands', $optionIndex + 1), ]; } @@ -605,6 +607,7 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, 'field' => sprintf('%s%d%s', $subList->prefix, $entryIndex, ucfirst($armKey)), 'frame' => [...$basePath, $entryIndex, $armKey], 'entry' => [$entryIndex], + 'entryLabel' => sprintf('%s %d', ucfirst($subList->singular), $entryIndex + 1), 'name' => $armLabel . ' Commands', ]; } @@ -621,7 +624,8 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, } foreach ($nestedList->fieldsFor($nestedEntry) as $field) { - $fields[] = ['entry' => [$entryIndex, $nestedIndex], 'name' => $field->label] + self::describeField( + $fields[] = ['entry' => [$entryIndex, $nestedIndex], 'entryLabel' => sprintf('%s %d', ucfirst($nestedList->singular), $nestedIndex + 1), + 'name' => $field->label] + self::describeField( $field, self::displayValue($field, self::readNested($nestedEntry, $field->key)), self::nestedSubFieldId( @@ -659,6 +663,7 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, 'field' => self::nestedSubFieldId($subList->prefix, $entryIndex, $nestedList->prefix, $nestedIndex, ucfirst($armKey)), 'frame' => [...$basePath, $entryIndex, $nestedList->key, $nestedIndex, $armKey], 'entry' => [$entryIndex, $nestedIndex], + 'entryLabel' => sprintf('%s %d', ucfirst($nestedList->singular), $nestedIndex + 1), 'name' => $armLabel . ' Commands', ]; } diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 73d372a..1a012c9 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -3526,6 +3526,7 @@ private static function describeRow(array $field): array 'label' => trim($label), // A list entry's row: which entry (and nested entry) it is part of, and its name within it. 'entry' => is_array($field['entry'] ?? null) ? array_values($field['entry']) : null, + 'entryLabel' => is_string($field['entryLabel'] ?? null) ? $field['entryLabel'] : null, 'name' => is_string($field['name'] ?? null) ? $field['name'] : null, 'depth' => $depth, // A section heading: a row that only names the rows after it. diff --git a/tests/Unit/Session/SummonStageSessionTest.php b/tests/Unit/Session/SummonStageSessionTest.php index ddcead5..7e77c4b 100644 --- a/tests/Unit/Session/SummonStageSessionTest.php +++ b/tests/Unit/Session/SummonStageSessionTest.php @@ -164,10 +164,10 @@ function stageRows(EditorSession $session, array $frame = []): array $tracks = stageRows($session, ['tracks']); $subjects = stageRows($session, ['stage.subjects']); - expect($tracks['Track 1 Id'])->toMatchArray(['entry' => [0], 'name' => 'Id']) + expect($tracks['Track 1 Id'])->toMatchArray(['entry' => [0], 'entryLabel' => 'Track 1', 'name' => 'Id']) ->and($tracks['Track 1 Keyframe 1 Sheet Frame'])->toMatchArray(['entry' => [0, 0], 'name' => 'Sheet Frame']) ->and($subjects['Subject 1 Size'])->toMatchArray(['entry' => [0], 'name' => 'Size']) - ->and($subjects['Subject 1 Point 1 X'])->toMatchArray(['entry' => [0, 0], 'name' => 'X']) + ->and($subjects['Subject 1 Point 1 X'])->toMatchArray(['entry' => [0, 0], 'entryLabel' => 'Point 1', 'name' => 'X']) // The record's own rows belong to no entry. ->and(stageRows($session)['Name'])->not->toHaveKey('entry'); }); From ff8413812b1ef6bf91da4a28e5386d5c8df5748d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 13:59:53 +0200 Subject: [PATCH 208/344] fix(database): a category is unsaved while what its Save writes is, and is named among the unsaved The records listing reported only the host category's own records as unsaved, though its Save also writes the battle art and battle scale its pages edit. It now reports what its Save writes, and lists every unsaved category. The shared enemy fixture created its folders with a suppressed mkdir, which the test runner still reported as a warning in every test that used it. --- src/Session/EditorSession.php | 42 +++++++++++++++++++- tests/Support/BattleFormationFixtures.php | 7 +++- tests/Unit/Session/BattlerArtSessionTest.php | 13 ++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 1a012c9..5fe78d7 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -2319,7 +2319,10 @@ public function listDatabaseRecords(string $category): array 'category' => $category, 'editable' => $database->isEditable(), 'readOnly' => $database->getReadOnlyReason(), - 'dirty' => $database->isDirty(), + // What its Save writes is unsaved: its records, or what their pages edit and save with them. + 'dirty' => $this->isCategoryUnsaved($category), + // Every listed category with something to save, so a list of them can say which. + 'unsavedCategories' => $this->listUnsavedCategories(), 'canCreate' => $database->supportsRecordCreation(), 'canDuplicate' => $database->supportsRecordDuplication(), 'canDelete' => $database->supportsRecordDeletion(), @@ -2676,6 +2679,43 @@ public function saveDatabase(string $category): array return ['saved' => true, 'warnings' => [], 'backupFailures' => [...$embedded['backupFailures'], ...array_values($failures)]]; } + /** + * Whether a category's Save has something to write: its own records, or + * what their pages edit beside them and save with them (an enemy's + * battle art, the battle scale), the same set `saveDatabase` writes. + */ + private function isCategoryUnsaved(string $category): bool + { + if ($this->requireCategory($category)->isDirty()) { + return true; + } + + return array_any(DatabaseCatalog::getEmbedded(), fn($definition): bool => in_array($category, $definition->hosts, true) + && ($this->workspace->getRecordDatabase($definition->key)?->isDirty() ?? false)); + } + + /** + * The listed Database and cutscene categories with something to save. + * + * @return list + */ + private function listUnsavedCategories(): array + { + $keys = [ + ...array_map(static fn($category): string => $category->key, DatabaseCatalog::all()), + ...array_map(static fn(CutsceneType $type): string => $type->getRecordCategory(), CutsceneType::cases()), + ]; + + return array_values(array_filter($keys, function (string $key): bool { + try { + return $this->isCategoryUnsaved($key); + } catch (SessionRefusal) { + // A category this project cannot open has nothing to save. + return false; + } + })); + } + /** * Makes one record change, records it, and turns a refusal into the session's. * diff --git a/tests/Support/BattleFormationFixtures.php b/tests/Support/BattleFormationFixtures.php index d1d73f0..2fa63aa 100644 --- a/tests/Support/BattleFormationFixtures.php +++ b/tests/Support/BattleFormationFixtures.php @@ -63,8 +63,11 @@ function enemyPreviewProject(): string /** The Regular Bat the troop formation project's troop fights, as its one enemy record, with its terminal sprite. */ function writeRegularBatEnemy(string $root): void { - @mkdir($root . '/assets/Data/Enemies', 0o777, true); - @mkdir($root . '/assets/Graphics/Enemies', 0o777, true); + foreach (['/assets/Data/Enemies', '/assets/Graphics/Enemies'] as $folder) { + if (! is_dir($root . $folder)) { + mkdir($root . $folder, 0o777, true); + } + } file_put_contents($root . '/assets/Graphics/Enemies/bat.txt', "/\\o/\\\n ' '\n"); file_put_contents($root . '/assets/Data/Enemies/regular-bat.php', " Enemy::class, 'data' => " . var_export(['name' => 'Regular Bat', 'level' => 2, 'imagePath' => 'bat', 'rewards' => ['experience' => 1, 'gold' => 1], 'stats' => [ diff --git a/tests/Unit/Session/BattlerArtSessionTest.php b/tests/Unit/Session/BattlerArtSessionTest.php index 38ca832..594b0fe 100644 --- a/tests/Unit/Session/BattlerArtSessionTest.php +++ b/tests/Unit/Session/BattlerArtSessionTest.php @@ -58,6 +58,19 @@ ->and($session->listUnsavedChanges())->toBe([]); }); +it('shows the enemy category unsaved while its battle art is, since its Save writes both', function () { + $session = EditorSession::open(battlerArtProject()); + expect($session->listDatabaseRecords('enemies'))->toMatchArray(['dirty' => false, 'unsavedCategories' => []]); + + $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index']; + setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png'); + + expect($session->listDatabaseRecords('enemies'))->toMatchArray(['dirty' => true, 'unsavedCategories' => ['enemies']]) + ->and($session->listDatabaseRecords('actors')['dirty'])->toBeFalse(); + $session->saveDatabase('enemies'); + expect($session->listDatabaseRecords('enemies'))->toMatchArray(['dirty' => false, 'unsavedCategories' => []]); +}); + it('refuses to save art the battle could not read, writing nothing', function () { $root = battlerArtProject(); $session = EditorSession::open($root); From 58c47ed01f729ef5c1c5b7f7fc8ca48e58feabab Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 19:39:13 +0200 Subject: [PATCH 209/344] feat(session): send terminal previews as the Engine's styled canvas Effect and cinematic terminal previews now carry terminalCanvas beside their lines: the Engine's TerminalPresentationComposer projects the styled lines onto the picture's own grid, cells sized as the Engine sizes the battle arena's, so a renderer paints their colours and never counts control codes as text. Battle previews already carry it from the Engine's frame. --- .../Preview/CinematicPreviewSession.php | 12 +++++++ src/Session/EditorSession.php | 33 +++++++++++++++++-- .../Session/CutsceneRecordSessionTest.php | 18 ++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/Cutscenes/Preview/CinematicPreviewSession.php b/src/Cutscenes/Preview/CinematicPreviewSession.php index 1c6bc31..72ac7d3 100644 --- a/src/Cutscenes/Preview/CinematicPreviewSession.php +++ b/src/Cutscenes/Preview/CinematicPreviewSession.php @@ -562,6 +562,18 @@ public function frame(): array return $rows; } + /** + * The captured screen's size, in columns and rows. + * + * @return array{int, int} + */ + public function getScreenSize(): array + { + $screen = $this->scene->previewCamera()->screen; + + return [$screen->getWidth(), $screen->getHeight()]; + } + /** * Resizes the captured screen. */ diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 5fe78d7..1536238 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -62,7 +62,10 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Storage\WorkspaceSave; use Ichiloto\Editor\Validation\MapValidator; +use Ichiloto\Engine\IO\Console\TerminalPresentationComposer; +use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; +use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; use Ichiloto\Engine\Rendering\Presentation\PresentationWorld; use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; use Ichiloto\Engine\Battle\Presentation\BattlerBindings; @@ -136,6 +139,8 @@ final class EditorSession * @var array */ private array $effectBattlePreviews = []; + /** Projects terminal pictures into renderer runs, keeping its parsed styles between frames. */ + private ?TerminalPresentationComposer $terminalComposer = null; /** The cinematic previewed as the terminal plays it, and which one it is. */ private ?CinematicPreviewSession $cinematicPreview = null; private ?string $cinematicPreviewId = null; @@ -1398,7 +1403,7 @@ private function describeCutsceneStage(string $category, int $index): array * from the record as edited, unsaved changes included, with the cues on * that frame: what a timeline editor shows at its playhead. * - * @return array{frame: int, totalFrames: int, fps: int, lines: list, cues: list>} + * @return array{frame: int, totalFrames: int, fps: int, lines: list, terminalCanvas: array, cues: list>} * @throws SessionRefusal When the record is not a summon or effect, or its timeline does not compile. */ public function readCutscenePreview(string $category, int $index, int $frame, int $width, int $height): array @@ -1414,8 +1419,10 @@ public function readCutscenePreview(string $category, int $index, int $frame, in $preview = new TimelinePreviewSession($compiled, $asset->type === CutsceneType::EFFECT ? null : false); $frame = max(0, min($frame, $preview->totalFrames() - 1)); + $lines = array_values($preview->frame(max(1, $width), max(1, $height), $frame)); + return ['frame' => $frame, 'totalFrames' => $preview->totalFrames(), 'fps' => $preview->fps(), - 'lines' => array_values($preview->frame(max(1, $width), max(1, $height), $frame)), + 'lines' => $lines, 'terminalCanvas' => $this->describeTerminalCanvas($lines, max(1, $width), max(1, $height)), 'cues' => array_values($preview->cuesAt($frame))]; } @@ -1503,6 +1510,22 @@ public function stopCinematicPreview(): array return ['stopped' => $stopped]; } + /** + * A terminal picture as a renderer paints it: the Engine's styled runs on the + * picture's own grid, its colours kept and its control codes never counted as + * text. Cells take the share of the Engine's canvas the battle arena's do. + * + * @param list $lines + * @return array + */ + private function describeTerminalCanvas(array $lines, int $columns, int $rows): array + { + $grid = new RendererGridConfig($columns, $rows, max(1, intdiv(PresentationCanvas::DEFAULT_WIDTH, $columns)), + max(1, intdiv(PresentationCanvas::DEFAULT_HEIGHT, $rows))); + + return ($this->terminalComposer ??= new TerminalPresentationComposer())->createCanvasFromLines($lines, $grid)->toArray(); + } + /** * The cinematic previewed: which one, its terminal picture now, how long * it has run, its status, whether it plays or has finished, and why it @@ -1514,9 +1537,13 @@ private function describeCinematicPreview(): array { $preview = $this->cinematicPreview ?? throw new SessionRefusal('No cinematic is being previewed.'); + $lines = array_values($preview->frame()); + [$columns, $rows] = $preview->getScreenSize(); + return [ 'id' => $this->cinematicPreviewId, - 'lines' => array_values($preview->frame()), + 'lines' => $lines, + 'terminalCanvas' => $this->describeTerminalCanvas($lines, $columns, $rows), 'elapsed' => $preview->elapsed(), 'status' => $preview->status(), 'playing' => $preview->isPlaying(), diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php index 909f139..bd7307c 100644 --- a/tests/Unit/Session/CutsceneRecordSessionTest.php +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -111,8 +111,25 @@ expect($preview['frame'])->toBe($preview['totalFrames'] - 1) ->and($preview['lines'])->toHaveCount(10) ->and(mb_strlen($preview['lines'][0]))->toBe(40); + expectTerminalCanvas($preview['terminalCanvas'], 40, 10); }); +/** A terminal picture as the renderer receives it: one text layer on the picture's own grid, its runs free of control codes. */ +function expectTerminalCanvas(array $canvas, int $columns, int $rows): void +{ + expect($canvas['textLayers'])->toHaveCount(1); + $layer = $canvas['textLayers'][0]; + expect($layer['grid']['columns'])->toBe($columns) + ->and($layer['grid']['rows'])->toBe($rows) + ->and($canvas['width'])->toBe($columns * $layer['grid']['cellWidth']) + ->and($canvas['height'])->toBe($rows * $layer['grid']['cellHeight']); + foreach ($layer['runs'] as $run) { + expect($run['row'])->toBeLessThan($rows) + ->and($run['column'] + mb_strlen($run['text']))->toBeLessThanOrEqual($columns) + ->and(preg_match('/\p{Cc}/u', $run['text']))->toBe(0); + } +} + it('shows an effect\'s graphical sequence with its image track\'s art, and sets the pivot as one undo step', function () { $session = EditorSession::open(effectProject()); $index = array_search('dusk-slash', $session->listDatabaseRecords('cutscenes/effect')['records'], true); @@ -224,6 +241,7 @@ expect($started)->toMatchArray(['id' => 'harbour-lanterns', 'playing' => false]) ->and($started['lines'])->not->toBe([]) ->and($started['failure'])->toBeNull(); + expectTerminalCanvas($started['terminalCanvas'], 60, 16); $session->controlCinematicPreview('play'); $played = $session->controlCinematicPreview('tick', 0.5); From 2e64f8b6a492dc1d5b37d0092acf325447848a4c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 6 Oct 2026 22:03:50 +0200 Subject: [PATCH 210/344] fix(battle): say Time Gauge, not ATB, in the battle settings The battle settings named the gauge-driven engine "Active Time Battle" and its fields "ATB", a name associated with Square Enix. They now read Time Gauge; the stored engine key, active_time, is unchanged. --- docs/manual.md | 2 +- src/Database/RecordPanes.php | 10 +++++----- src/Database/RecordSchemaCatalog.php | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 387a031..415951a 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1068,7 +1068,7 @@ skill's effects, a quest's rewards) listed under them. | Optimize Weights | `assets/Data/equipment-optimization.php` | Editable | Same | | Optimize Outcomes | `assets/Data/equipment-optimization.php` | Editable | Same | | Optimize Exclusions | `assets/Data/equipment-optimization.php` | Editable | Same | -| System | `assets/Data/system.php` | Editable - title, starting gold, party, inventory and position, battle engine, openings and ATB settings | Same | +| System | `assets/Data/system.php` | Editable - title, starting gold, party, inventory and position, battle engine, openings and time gauge settings | Same | | Configuration | `config.php` (`save`, `accessibility`, `ui`, `graphics`, `audio`, `inn`) | Editable - one row per setting, typed by what it holds; field zoom is kept from 1 to 8 | Same | | Types | `assets/Data/system.php` | Editable - the project's elements, one per row; an empty list means the Engine's defaults. Weapon, armor and equipment types are the Engine's own | Same | | Terms | `config.php` (`vocab`, `messages`) | Literal terms editable; comments and unrelated expressions preserved | Same | diff --git a/src/Database/RecordPanes.php b/src/Database/RecordPanes.php index 2ec6bee..6fb905c 100644 --- a/src/Database/RecordPanes.php +++ b/src/Database/RecordPanes.php @@ -281,23 +281,23 @@ private static function describeSystem(ProjectWorkspace $workspace): array 'Battle Settings', [ sprintf('Engine: %s', $engine), - sprintf('ATB Mode: %s', $mode), + sprintf('Time Gauge Mode: %s', $mode), sprintf('Base Fill Rate: %d', $fillRate), sprintf('Speed Factor: %d%%', $speedFactor), ], 'Notes', $activeTimeOn - ? ['Active Time Battle is enabled.', 'Mode: wait', 'This first slice uses wait-mode flow', 'during command selection and resolution.'] - : ['Traditional turn-based battles.', 'ATB settings are stored but inactive.', 'Switch Battle Engine to active_time', 'to enable gauge-driven turns.'], + ? ['Time gauge battles are enabled.', 'Mode: wait', 'This first slice uses wait-mode flow', 'during command selection and resolution.'] + : ['Traditional turn-based battles.', 'Time gauge settings are stored but inactive.', 'Switch Battle Engine to active_time', 'to enable gauge-driven turns.'], $activeTimeOn ? [ - 'Battle Engine', 'Active Time Battle', '', + 'Battle Engine', 'Time Gauge', '', sprintf('Mode: %s', $mode), sprintf('Base Fill Rate: %d', $fillRate), sprintf('Speed Factor: %d%%', $speedFactor), '', 'This engine fills battler gauges', 'continuously and resolves actions', 'as battlers become ready.', ] : [ 'Battle Engine', 'Traditional Turn-Based', '', - 'Battlers act in a queued round order.', 'ATB settings are ignored until you', 'switch the project to active_time.', + 'Battlers act in a queued round order.', 'Time gauge settings are ignored until you', 'switch the project to active_time.', ], ); } diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index e16334f..a868dc6 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -923,11 +923,11 @@ private static function system(): RecordSchema ), new RecordField('battle.opening.preemptiveChancePercent', 'Preemptive Chance %', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField('battle.opening.ambushChancePercent', 'Ambush Chance %', InputControlType::INTEGER, removeWhenEmpty: true), - new RecordField('battle.activeTime.mode', 'ATB Mode', options: ['wait']), - new RecordField('battle.activeTime.baseFillRate', 'ATB Base Fill Rate', InputControlType::INTEGER), - new RecordField('battle.activeTime.speedFactorPercent', 'ATB Speed Factor %', InputControlType::INTEGER), - new RecordField('battle.activeTime.openingVariance', 'ATB Opening Variance', InputControlType::INTEGER, removeWhenEmpty: true), - new RecordField('battle.activeTime.openingSpeedFactorPercent', 'ATB Opening Speed %', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('battle.activeTime.mode', 'Time Gauge Mode', options: ['wait']), + new RecordField('battle.activeTime.baseFillRate', 'Time Gauge Fill Rate', InputControlType::INTEGER), + new RecordField('battle.activeTime.speedFactorPercent', 'Time Gauge Speed Factor %', InputControlType::INTEGER), + new RecordField('battle.activeTime.openingVariance', 'Time Gauge Opening Variance', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('battle.activeTime.openingSpeedFactorPercent', 'Time Gauge Opening Speed %', InputControlType::INTEGER, removeWhenEmpty: true), // Test settings, as RPG Maker keeps its Battle Test: normal play never reads them. new RecordField(ProjectBattleTest::SYSTEM_KEY, 'Battle Test', removeWhenEmpty: true, codec: RecordFieldCodec::BATTLE_TEST), ], From 9092b92b57d7c5e8f7b10c15372ea338b5087b8b Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 02:25:41 +0200 Subject: [PATCH 211/344] feat(pieces): draw a piece across an area by drag or two corners Placing a multi-cell piece, Enter stamps it and anchors there; Enter again fills the area to the cursor with whole copies, as RPG Maker repeats a multi-tile selection. A mouse drag does the same in one gesture and a click stamps one copy where it lands. Each fill is one undo step. The map list now carries each map's authored region so the GUI can tell same-named maps apart. --- src/Canvas/PieceCanvas.php | 160 ++++++++++++++++++++++--------- src/Editor.php | 23 ++++- src/ProjectMap.php | 8 ++ src/Session/EditorSession.php | 2 + tests/Unit/TilesetPiecesTest.php | 71 ++++++++++++-- 5 files changed, 209 insertions(+), 55 deletions(-) diff --git a/src/Canvas/PieceCanvas.php b/src/Canvas/PieceCanvas.php index e028b40..adf2d9a 100644 --- a/src/Canvas/PieceCanvas.php +++ b/src/Canvas/PieceCanvas.php @@ -148,7 +148,7 @@ private function choosePiece(string $id): void $this->setFocusedPane(self::FOCUS_CANVAS, false); $this->piecePlacement = ['map' => $map, 'piece' => $piece, 'anchor' => null]; $this->setStatus(sprintf($piece->connects === null - ? 'Placing %s. Arrows move it, Enter stamps, Esc when done.' + ? 'Placing %s. Enter stamps and anchors; Enter again fills the area to the cursor, or drag with the mouse. Esc when done.' : 'Drawing %s. Enter draws at the cursor and anchors there, Del erases, Esc when done.', $piece->name)); $this->renderFocusDependentArea(); } @@ -211,9 +211,11 @@ private function getPiecePreviewCells(): array return $this->getConnectedPreviewCells($placement['map'], $piece, $placement['anchor']); } $cells = []; - foreach ($piece->glyphs as $row => $symbols) { - foreach ($symbols as $column => $symbol) { - $cells[$this->cursorY + $row][$this->cursorX + $column] = $symbol === ' ' ? null : $symbol; + foreach ($this->getPieceAreaOrigins($piece, $placement['anchor']) as ['x' => $x, 'y' => $y]) { + foreach ($piece->glyphs as $row => $symbols) { + foreach ($symbols as $column => $symbol) { + $cells[$y + $row][$x + $column] = $symbol === ' ' ? null : $symbol; + } } } return $cells; @@ -231,7 +233,7 @@ private function applyPieceAtCursor(): void return; } if ($placement['piece']->connects === null) { - $this->stampPiece($placement['map'], $placement['piece']); + $this->stampPieceArea($placement['map'], $placement['piece'], $placement['anchor']); return; } $this->drawConnectedPiece($placement['map'], $placement['piece'], $placement['anchor']); @@ -259,70 +261,139 @@ private function reloadPiecePlacement(): ?array return null; } $this->piecePlacement['piece'] = $piece; - if ($piece->connects === null) { - $this->piecePlacement['anchor'] = null; - } return $this->piecePlacement; } /** - * Stamps the piece at the cursor as one undo step: its glyphs on the - * gameplay layer it names, in the brush colour, and its tiles on the tile - * layers it names, creating any the map does not have yet. Space glyphs - * and `0` tiles leave their cells as they are. A glyph the stamp covers - * takes its own tiles with it, as any glyph edit does - * ({@see GlyphTilePlanner}). A stamp that cannot be made whole changes - * nothing. + * The top-left cells the piece is stamped at: the cursor alone without an + * anchor, otherwise whole copies of the piece side by side and row under + * row across the rectangle from the anchor to the cursor, as RPG Maker + * repeats a multi-tile selection. An area smaller than the piece holds + * one copy, at its top-left. + * + * @param array{x: int, y: int}|null $anchor + * @return list */ - private function stampPiece(ProjectMap $map, TilesetPiece $piece): void + private function getPieceAreaOrigins(TilesetPiece $piece, ?array $anchor): array { - $x = $this->cursorX; - $y = $this->cursorY; + $anchor ??= ['x' => $this->cursorX, 'y' => $this->cursorY]; + $span = static function (int $from, int $to, int $size): array { + [$low, $high] = [min($from, $to), max($from, $to)]; + $starts = [$low]; + for ($start = $low + $size; $start + $size - 1 <= $high; $start += $size) { + $starts[] = $start; + } + return $starts; + }; + $origins = []; + foreach ($span($anchor['y'], $this->cursorY, $piece->height) as $y) { + foreach ($span($anchor['x'], $this->cursorX, $piece->width) as $x) { + $origins[] = ['x' => $x, 'y' => $y]; + } + } + return $origins; + } + + /** + * Stamps the piece across the area from the anchor to the cursor (the + * cursor alone without an anchor) as one undo step, then anchors at the + * cursor so the next Enter fills on from there: its glyphs on the gameplay + * layer it names, in the brush colour, and its tiles on the tile layers it + * names, creating any the map does not have yet. Space glyphs and `0` + * tiles leave their cells as they are. A glyph the stamp covers takes its + * own tiles with it, as any glyph edit does ({@see GlyphTilePlanner}). An + * area that cannot be stamped whole changes nothing. + * + * @param array{x: int, y: int}|null $anchor + */ + private function stampPieceArea(ProjectMap $map, TilesetPiece $piece, ?array $anchor): void + { + $origins = $this->getPieceAreaOrigins($piece, $anchor); $writes = $assigned = []; - foreach ($piece->glyphs as $row => $symbols) { - foreach ($symbols as $column => $symbol) { - if ($symbol !== ' ') { - $writes[] = ['x' => $x + $column, 'y' => $y + $row, 'symbol' => $symbol, 'color' => $this->selectedPaintColor]; + foreach ($origins as ['x' => $x, 'y' => $y]) { + foreach ($piece->glyphs as $row => $symbols) { + foreach ($symbols as $column => $symbol) { + if ($symbol !== ' ') { + $writes[] = ['x' => $x + $column, 'y' => $y + $row, 'symbol' => $symbol, 'color' => $this->selectedPaintColor]; + } } } - } - // Each glyph cell plays its own cell of this piece, never a lookalike. - foreach (PieceRole::readPiece($piece) as $roles) { - foreach ($roles as $role) { - $assigned[($x + $role->cell[1]) . ',' . ($y + $role->cell[0])] = $role->key; + // Each glyph cell plays its own cell of this piece, never a lookalike. + foreach (PieceRole::readPiece($piece) as $roles) { + foreach ($roles as $role) { + $assigned[($x + $role->cell[1]) . ',' . ($y + $role->cell[0])] = $role->key; + } } } try { $layer = $this->findPieceLayer($map, $piece); - foreach ($piece->glyphs as $row => $symbols) { - foreach (array_keys($symbols) as $column) { - if (! $map->hasLayerCell($layer, $x + $column, $y + $row)) { - throw new MapSourceRefusal(sprintf('%s (%d x %d) does not fit at (%d, %d): the map has no cell at (%d, %d). Nothing was changed.', - $piece->name, $piece->width, $piece->height, $x, $y, $x + $column, $y + $row)); + foreach ($origins as ['x' => $x, 'y' => $y]) { + foreach ($piece->glyphs as $row => $symbols) { + foreach (array_keys($symbols) as $column) { + if (! $map->hasLayerCell($layer, $x + $column, $y + $row)) { + throw new MapSourceRefusal(sprintf('%s (%d x %d) does not fit at (%d, %d): the map has no cell at (%d, %d). Nothing was changed.', + $piece->name, $piece->width, $piece->height, $x, $y, $x + $column, $y + $row)); + } } } } $this->finalizeActiveStroke(); $tilesBefore = $map->getTileLayerSources(); - // A piece with glyphs draws through its roles; one without, such as a rug, stands for nothing and is laid as authored. - $writes === [] - ? $map->writeTileEntries($piece->tiles, $x, $y) - : $map->writeTileCells(CanvasEditor::plan($map, $layer, $writes, assigned: $assigned)['tiles'] ?? []); + if ($writes === []) { + // A piece without glyphs, such as a rug, stands for nothing and is laid as authored. + foreach ($origins as ['x' => $x, 'y' => $y]) { + $map->writeTileEntries($piece->tiles, $x, $y); + } + } else { + $map->writeTileCells(CanvasEditor::plan($map, $layer, $writes, assigned: $assigned)['tiles'] ?? []); + } $tilesAfter = $map->getTileLayerSources(); } catch (MapSourceRefusal $refusal) { $this->setStatus($refusal->getMessage(), StatusLevel::WARN); $this->renderCanvasArea(); return; } + $this->piecePlacement['anchor'] = ['x' => $this->cursorX, 'y' => $this->cursorY]; [$stroke] = CanvasEditor::writeCells($map, $layer, $writes, 'Piece stamp'); + $next = ' Enter fills on from here; Esc drops the anchor.'; if (! $stroke->hasChanges() && $tilesAfter === $tilesBefore) { - $this->setStatus(sprintf('%s is already at (%d, %d).', $piece->name, $x, $y)); + $this->setStatus(sprintf('%s is already there.', $piece->name) . $next); $this->renderCanvasArea(); return; } $this->recordStrokeWithTiles('Piece stamp', $map, $stroke, $tilesBefore, $tilesAfter); - $this->setStatus(sprintf('Stamped %s at (%d, %d). Enter stamps again; Esc when done.', $piece->name, $x, $y)); + $this->setStatus(count($origins) === 1 + ? sprintf('Stamped %s at (%d, %d).', $piece->name, $origins[0]['x'], $origins[0]['y']) . $next + : sprintf('Stamped %d %s across (%d, %d) to (%d, %d).', count($origins), $piece->name, + $origins[0]['x'], $origins[0]['y'], $this->cursorX, $this->cursorY) . $next); + $this->renderCanvasArea(); + } + + /** + * A mouse drag while placing a piece: the press anchors it, the drag + * previews the area or line to the pointer, and the release draws it and + * drops the anchor, since the drag was the whole gesture. + */ + private function dragPieceWithMouse(int $x, int $y, bool $isMotion, bool $isRelease): void + { + if ($this->getActivePiecePlacement() === null) { + return; + } + $this->cursorX = $x; + $this->cursorY = $y; + if (! $isMotion && ! $isRelease) { + $this->piecePlacement['anchor'] = ['x' => $x, 'y' => $y]; + } + if ($isRelease) { + $this->applyPieceAtCursor(); + if ($this->piecePlacement !== null) { + $this->piecePlacement['anchor'] = null; + } + return; + } + $anchor = $this->piecePlacement['anchor'] ?? ['x' => $x, 'y' => $y]; + $this->setStatus(sprintf('%s from (%d, %d) to (%d, %d). Release to draw.', $this->piecePlacement['piece']->name, $anchor['x'], $anchor['y'], $x, $y)); $this->renderCanvasArea(); } @@ -516,17 +587,18 @@ private function getPiecePlacementHelp(int $windowWidth): ?string return null; } $piece = $placement['piece']; + $escape = $placement['anchor'] === null ? 'Esc:Done' : 'Esc:Unanchor'; if ($piece->connects === null) { + $enter = $placement['anchor'] === null ? 'Enter:Stamp' : 'Enter:Fill'; return $this->fitHelp( $windowWidth, - sprintf('PIECE %s Enter:Stamp Esc:Done', $piece->name), - sprintf('PIECE %s Enter:Stamp Esc:Done', $piece->name), - 'PIECE Enter:Stamp Esc:Done', - 'Enter:Stamp Esc:Done', - 'Esc:Done', + sprintf('PIECE %s %s Drag:Fill %s', $piece->name, $enter, $escape), + sprintf('PIECE %s %s %s', $piece->name, $enter, $escape), + "PIECE {$enter} {$escape}", + "{$enter} {$escape}", + $escape, ); } - $escape = $placement['anchor'] === null ? 'Esc:Done' : 'Esc:Unanchor'; return $this->fitHelp( $windowWidth, sprintf('PIECE %s Enter:Draw Del:Erase %s', $piece->name, $escape), diff --git a/src/Editor.php b/src/Editor.php index 2bf48e2..8a97007 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -400,8 +400,8 @@ final class Editor } private ?MouseButton $activeMousePaintButton = null; /** - * @var array{start: array{x: int, y: int}, symbol: string}|null A line, rectangle or selection being dragged out - * with the mouse: the cell it started on and the symbol it draws (a space erases). + * @var array{start: array{x: int, y: int}, symbol: string, piece?: bool}|null A line, rectangle, selection or piece + * area being dragged out with the mouse: the cell it started on and the symbol it draws (a space erases). */ private ?array $mouseToolDrag = null; /** @@ -4013,6 +4013,10 @@ private function completeMouseToolDrag(MouseEvent $event): void $this->cursorX = max(0, min($selectedMap->getWidth() - 1, $this->canvasOffsetX + ($event->x - $bounds['left']))); $this->cursorY = max(0, min($selectedMap->getHeight() - 1, $this->canvasOffsetY + ($event->y - $bounds['top']))); } + if ($drag['piece'] ?? false) { + $this->dragPieceWithMouse($this->cursorX, $this->cursorY, false, true); + return; + } $anchor = $this->canvasToolAnchor ?? $drag['start']; $this->canvasToolAnchor = null; if (! $selectedMap instanceof ProjectMap) { @@ -4067,6 +4071,21 @@ private function handleCanvasMouseEdit(MouseEvent $event): bool return true; } + // A piece being placed follows a left drag: the press anchors it, the + // drag previews the area (or a connected piece's line) and the release + // draws it. + if ($this->editingMode === self::MODE_MAP && $this->getActivePiecePlacement() !== null && $event->button === MouseButton::LEFT_BUTTON) { + $starting = ! $event->isMotion || $this->mouseToolDrag === null; + if ($starting) { + $this->mouseToolDrag = ['start' => ['x' => $targetX, 'y' => $targetY], 'symbol' => '', 'piece' => true]; + } + $this->dragPieceWithMouse($targetX, $targetY, ! $starting, false); + if ($focusChanged) { + $this->renderFocusDependentArea(); + } + return true; + } + // A tool that spans an anchor and a corner follows a mouse drag: the // press anchors it, the drag moves the corner with a live preview, // and the release draws (or selects). The right button erases. diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 64f3ca0..d80a08f 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -280,6 +280,14 @@ public function getRegion(): string return (string) ($this->editableData['region'] ?? 'Unknown'); } + /** + * The region the map's author gave it, empty when none was given. + */ + public function getAuthoredRegion(): string + { + return trim((string) ($this->editableData['region'] ?? '')); + } + /** * Returns the map description. * diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 1536238..d0d3abc 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -280,6 +280,8 @@ public function describeMaps(): array return array_map(fn(ProjectMap $map): array => [ 'id' => $map->mapId, 'name' => $map->getDisplayName(), + // Maps are listed under their region, so two maps with one name (an inn in each town) stay apart. + 'region' => $map->getAuthoredRegion(), 'dirty' => $map->isDirty(), 'readOnly' => $map->getGridSourceIssue(), 'revision' => $this->getMapRevision($map), diff --git a/tests/Unit/TilesetPiecesTest.php b/tests/Unit/TilesetPiecesTest.php index f5104ed..2c0cba0 100644 --- a/tests/Unit/TilesetPiecesTest.php +++ b/tests/Unit/TilesetPiecesTest.php @@ -151,14 +151,7 @@ function choosePieceByKeys(Editor $editor, string $id = 'bed'): void ->toContain("\033[7m=\033[0m", "\033[7mH\033[0m"); $frame = renderEditorPlainFrame($editor, 160, 45); - expect($frame)->toContain('.=..', '.=H.', 'PIECE Bed Enter:Stamp Esc:Done') - ->and($map->isDirty())->toBeFalse(); - - // A Normal-mode click only moves the cursor, and the preview with it. - $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); - callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + 2, $bounds['top'])); - callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + 2, $bounds['top'])); - expect(array_keys(callEditorMethod($editor, 'getPiecePreviewCells')[0]))->toBe([2, 3]) + expect($frame)->toContain('.=..', '.=H.', 'PIECE Bed Enter:Stamp Drag:Fill Esc:Done') ->and($map->isDirty())->toBeFalse(); // Esc ends placement and the preview goes with it. @@ -172,7 +165,8 @@ function choosePieceByKeys(Editor $editor, string $id = 'bed'): void expect(callEditorMethod($editor, 'getPiecePlacementHelp', 40))->toBeNull(); choosePieceByKeys($editor); - expect(callEditorMethod($editor, 'getPiecePlacementHelp', 40))->toBe('PIECE Bed Enter:Stamp Esc:Done') + expect(callEditorMethod($editor, 'getPiecePlacementHelp', 46))->toBe('PIECE Bed Enter:Stamp Drag:Fill Esc:Done') + ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 45))->toBe('PIECE Bed Enter:Stamp Esc:Done') ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 33))->toBe('PIECE Bed Enter:Stamp Esc:Done') ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 29))->toBe('PIECE Enter:Stamp Esc:Done') ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 24))->toBe('Enter:Stamp Esc:Done') @@ -236,6 +230,65 @@ function choosePieceByKeys(Editor $editor, string $id = 'bed'): void expect(sourceHashTree($map->directory))->toBe($disk); }); +it('fills the area between two corners with whole copies of the piece in one undo step', function () { + [$editor, $map] = createPieceCanvasEditor(); + $before = $map->captureLayerSnapshot(); + $tilesBefore = $map->getTileLayerSources(); + choosePieceByKeys($editor); + + // Enter stamps at the cursor and anchors there; the next Enter fills the area to the cursor. + callEditorMethod($editor, 'dispatchInput', "\n"); + expect(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBe(['x' => 0, 'y' => 0]) + ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 120))->toContain('Enter:Fill', 'Esc:Unanchor'); + setEditorProperty($editor, 'cursorX', 3); + setEditorProperty($editor, 'cursorY', 1); + expect(array_keys(callEditorMethod($editor, 'getPiecePreviewCells')[1]))->toBe([0, 1, 2, 3]); + callEditorMethod($editor, 'dispatchInput', "\n"); + + foreach ([0, 2] as $x) { + expect($map->getLayerSymbol('map:4', $x, 0))->toBe('=') + ->and($map->getLayerSymbol('map:4', $x + 1, 1))->toBe('H'); + } + // The stamp and the fill are two steps; undoing both restores the map. + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->getLayerSymbol('map:4', 2, 0))->not->toBe('=') + ->and($map->getLayerSymbol('map:4', 0, 0))->toBe('='); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureLayerSnapshot())->toBe($before) + ->and($map->getTileLayerSources())->toBe($tilesBefore); + + // Esc drops the anchor, so Enter stamps a single copy again. + callEditorMethod($editor, 'dispatchInput', "\033"); + expect(getEditorProperty($editor, 'piecePlacement'))->not->toBeNull() + ->and(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBeNull(); +}); + +it('draws a piece area with a mouse drag, and a click stamps one copy where it lands', function () { + [$editor, $map] = createPieceCanvasEditor(); + $before = $map->captureLayerSnapshot(); + choosePieceByKeys($editor); + $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); + $at = static fn(string $kind, int $x, int $y): string => sprintf("\033[<%d;%d;%d%s", $kind === 'move' ? 32 : 0, + $bounds['left'] + $x, $bounds['top'] + $y, $kind === 'release' ? 'm' : 'M'); + + callEditorMethod($editor, 'dispatchInput', $at('press', 0, 0)); + callEditorMethod($editor, 'dispatchInput', $at('move', 3, 1)); + expect(array_keys(callEditorMethod($editor, 'getPiecePreviewCells')[1]))->toBe([0, 1, 2, 3]) + ->and($map->isDirty())->toBeFalse(); + callEditorMethod($editor, 'dispatchInput', $at('release', 3, 1)); + + expect($map->getLayerSymbol('map:4', 0, 0))->toBe('=') + ->and($map->getLayerSymbol('map:4', 2, 0))->toBe('=') + ->and(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBeNull(); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureLayerSnapshot())->toBe($before); + + callEditorMethod($editor, 'dispatchInput', $at('press', 2, 0)); + callEditorMethod($editor, 'dispatchInput', $at('release', 2, 0)); + expect($map->getLayerSymbol('map:4', 2, 0))->toBe('=') + ->and($map->getLayerSymbol('map:4', 0, 0))->not->toBe('='); +}); + it('creates graphics/ for a map whose tileset pieces name tile layers it lacks', function () { $root = layeredMapProject(); writeTestTileset($root, pieces: buildTestPieces()); From 33442caa39ab2be75f66ccb80c5a9b7b25387960 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 03:32:08 +0200 Subject: [PATCH 212/344] feat(pieces): stamp each piece cell in its own authored colours A piece cell with authored markup keeps it byte for byte in the map source; a plain cell still takes the brush colour. Connected pieces do the same for their shapes, and a reshaped neighbour keeps its colour. --- src/Canvas/PieceCanvas.php | 27 ++++++++++++++++++++++++--- tests/Unit/TilesetPiecesTest.php | 22 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/Canvas/PieceCanvas.php b/src/Canvas/PieceCanvas.php index adf2d9a..5e228d7 100644 --- a/src/Canvas/PieceCanvas.php +++ b/src/Canvas/PieceCanvas.php @@ -14,6 +14,7 @@ use Ichiloto\Engine\Rendering\Tilesets\TileId; use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Ichiloto\Engine\Rendering\Tilesets\TilesetPiece; +use Ichiloto\Engine\IO\Console\TerminalText; /** * Tileset pieces on the terminal canvas: choose a whole item from the map's @@ -310,12 +311,14 @@ private function getPieceAreaOrigins(TilesetPiece $piece, ?array $anchor): array private function stampPieceArea(ProjectMap $map, TilesetPiece $piece, ?array $anchor): void { $origins = $this->getPieceAreaOrigins($piece, $anchor); + $sources = $piece->getSourceGrid(); $writes = $assigned = []; foreach ($origins as ['x' => $x, 'y' => $y]) { foreach ($piece->glyphs as $row => $symbols) { foreach ($symbols as $column => $symbol) { if ($symbol !== ' ') { - $writes[] = ['x' => $x + $column, 'y' => $y + $row, 'symbol' => $symbol, 'color' => $this->selectedPaintColor]; + $writes[] = ['x' => $x + $column, 'y' => $y + $row, 'symbol' => $symbol] + + self::resolvePieceCellPaint($sources[$row][$column], $this->selectedPaintColor); } } } @@ -370,6 +373,22 @@ private function stampPieceArea(ProjectMap $map, TilesetPiece $piece, ?array $an $this->renderCanvasArea(); } + /** + * How one piece cell is painted: in the style its tileset authored for it, + * kept byte for byte, or with the fallback colour when it has none (null + * keeps the map cell's own colour). + * + * @return array{style: array{prefix: string, suffix: string}}|array{color: string|null} + */ + private static function resolvePieceCellPaint(string $source, ?string $fallbackColor): array + { + $cell = TerminalText::parseSourceCells($source)[0] ?? null; + + return $cell !== null && ($cell['prefix'] !== '' || $cell['suffix'] !== '') + ? ['style' => ['prefix' => $cell['prefix'], 'suffix' => $cell['suffix']]] + : ['color' => $fallbackColor]; + } + /** * A mouse drag while placing a piece: the press anchors it, the drag * previews the area or line to the pointer, and the release draws it and @@ -488,12 +507,14 @@ private function applyConnectedPiece(ProjectMap $map, TilesetPiece $piece, array } $cells = ConnectedPieceShaper::reshapeCells($piece, $drawn, $erased, $this->resolveConnectedMemberLookup($map, $layer, $piece)); $drawnKeys = array_flip(array_map(static fn(array $cell): string => "{$cell['x']},{$cell['y']}", $drawn)); + $sources = $piece->getSourceShapeGrid(); + // A drawn cell takes the brush colour unless its shape has its own; a reshaped neighbour keeps its colour. $writes = array_map(fn(array $cell): array => [ 'x' => $cell['x'], 'y' => $cell['y'], 'symbol' => $cell['shape'] === null ? ' ' : $piece->shapes[$cell['shape']], - 'color' => isset($drawnKeys["{$cell['x']},{$cell['y']}"]) ? $this->selectedPaintColor : null, - ], $cells); + ] + ($cell['shape'] === null ? ['color' => null] : self::resolvePieceCellPaint($sources[$cell['shape']], + isset($drawnKeys["{$cell['x']},{$cell['y']}"]) ? $this->selectedPaintColor : null)), $cells); // Each shape draws its tiles, and a glyph the wall covers takes its own ({@see GlyphTilePlanner}). $this->finalizeActiveStroke(); $tilesBefore = $map->getTileLayerSources(); diff --git a/tests/Unit/TilesetPiecesTest.php b/tests/Unit/TilesetPiecesTest.php index 2c0cba0..8c1cfba 100644 --- a/tests/Unit/TilesetPiecesTest.php +++ b/tests/Unit/TilesetPiecesTest.php @@ -289,6 +289,28 @@ function choosePieceByKeys(Editor $editor, string $id = 'bed'): void ->and($map->getLayerSymbol('map:4', 0, 0))->not->toBe('='); }); +it('stamps a piece\'s own colours byte for byte and the brush colour where it has none', function () { + $root = mapGraphicsProject(); + // A small tree: two leaves and a trunk in their own colours, and a plain cell that takes the brush. + writeTestTileset($root, pieces: ['tree' => ['name' => 'Tree', 'layer' => 'buildings', + 'glyphs' => ['^^', '|=']]]); + [$editor, $map] = layeredCanvasEditor($root); + setEditorProperty($editor, 'selectedPaintColor', 'cyan'); + selectPieceLayer($editor, 'buildings'); + callEditorMethod($editor, 'dispatchInput', 'P'); + callEditorMethod($editor, 'dispatchInput', "\n"); + callEditorMethod($editor, 'dispatchInput', "\n"); + + expect($map->getLayerCellStyle('map:4', 0, 0))->toBe(['prefix' => '', 'suffix' => '']) + ->and($map->getLayerCellStyle('map:4', 1, 0))->toBe(['prefix' => '', 'suffix' => '']) + ->and($map->getLayerCellStyle('map:4', 0, 1))->toBe(['prefix' => '', 'suffix' => '']) + ->and($map->getLayerSymbol('map:4', 0, 1))->toBe('|') + ->and($map->getLayerColor('map:4', 1, 1))->toBe('cyan'); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect((string) file_get_contents($map->directory . '/layers/04.buildings.map.php')) + ->toContain('^^', '|')->not->toContain("\033"); +}); + it('creates graphics/ for a map whose tileset pieces name tile layers it lacks', function () { $root = layeredMapProject(); writeTestTileset($root, pieces: buildTestPieces()); From fb20161d6b1f194e2550456d649bd0c785dfbeab Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 09:26:50 +0200 Subject: [PATCH 213/344] feat(pieces): place tileset pieces through the session the way the terminal canvas does Piece placement moves out of the terminal canvas into PiecePlacer, the one way every interface stamps an item piece across an area or draws a connected piece along an outline. The session offers it as pieces.list, pieces.preview and pieces.place, so the GUI editor places the same pieces with the same undo step. --- src/Canvas/PieceCanvas.php | 260 +++--------------- src/Canvas/PiecePlacer.php | 346 ++++++++++++++++++++++++ src/ProjectMap.php | 2 +- src/Session/EditorSession.php | 123 +++++++++ src/Session/SessionHost.php | 29 ++ tests/Unit/Session/PieceSessionTest.php | 109 ++++++++ 6 files changed, 641 insertions(+), 228 deletions(-) create mode 100644 src/Canvas/PiecePlacer.php create mode 100644 tests/Unit/Session/PieceSessionTest.php diff --git a/src/Canvas/PieceCanvas.php b/src/Canvas/PieceCanvas.php index 5e228d7..d48fe53 100644 --- a/src/Canvas/PieceCanvas.php +++ b/src/Canvas/PieceCanvas.php @@ -4,17 +4,13 @@ namespace Ichiloto\Editor\Canvas; -use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\Status\StatusLevel; use Ichiloto\Editor\UI\PaletteItem; use Ichiloto\Editor\History\PaintStrokeCommand; -use Ichiloto\Engine\Rendering\Tilesets\TileId; -use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Ichiloto\Engine\Rendering\Tilesets\TilesetPiece; -use Ichiloto\Engine\IO\Console\TerminalText; /** * Tileset pieces on the terminal canvas: choose a whole item from the map's @@ -111,20 +107,11 @@ private function findPieceCanvasLayer(ProjectMap $map): ?array private function loadCanvasPieces(ProjectMap $map): ?array { try { - $tileset = $map->loadTileset(); - } catch (\Throwable $error) { - $this->setStatus('Pieces are unavailable: ' . $error->getMessage(), StatusLevel::WARN); - return null; - } - if ($tileset === null) { - $this->setStatus('This map has no kind yet, so it has no pieces. Set its Kind in the Inspector.', StatusLevel::WARN); - return null; - } - if ($tileset->pieces === []) { - $this->setStatus(sprintf('Tileset %s has no pieces. Add them to assets/%s/%s.php.', $tileset->id, Tileset::DIRECTORY, $tileset->id), StatusLevel::WARN); + return PiecePlacer::loadPieces($map); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); return null; } - return $tileset->pieces; } /** Starts placing the chosen piece on the selected map, in Map mode. */ @@ -194,10 +181,8 @@ private function endPiecePlacement(string $statusMessage = ''): void } /** - * The piece's footprint with its top-left cell at the cursor, for the - * canvas preview: a glyph where the piece writes one, null where a space - * glyph leaves the map's cell as it is. A connected piece previews the - * cells Enter would draw, each with the glyph of the shape it would take. + * What Enter would write with the cursor as the far corner, for the + * canvas preview ({@see PiecePlacer::getPreviewCells()}). * * @return array> Cells by row and column. */ @@ -207,19 +192,10 @@ private function getPiecePreviewCells(): array if ($placement === null) { return []; } - $piece = $placement['piece']; - if ($piece->connects !== null) { - return $this->getConnectedPreviewCells($placement['map'], $piece, $placement['anchor']); - } - $cells = []; - foreach ($this->getPieceAreaOrigins($piece, $placement['anchor']) as ['x' => $x, 'y' => $y]) { - foreach ($piece->glyphs as $row => $symbols) { - foreach ($symbols as $column => $symbol) { - $cells[$y + $row][$x + $column] = $symbol === ' ' ? null : $symbol; - } - } - } - return $cells; + $cursor = ['x' => $this->cursorX, 'y' => $this->cursorY]; + + return array_map(static fn(array $row): array => array_map(static fn(?array $cell): ?string => $cell['symbol'] ?? null, $row), + PiecePlacer::getPreviewCells($placement['map'], $placement['piece'], $placement['anchor'] ?? $cursor, $cursor)); } /** @@ -266,106 +242,35 @@ private function reloadPiecePlacement(): ?array return $this->piecePlacement; } - /** - * The top-left cells the piece is stamped at: the cursor alone without an - * anchor, otherwise whole copies of the piece side by side and row under - * row across the rectangle from the anchor to the cursor, as RPG Maker - * repeats a multi-tile selection. An area smaller than the piece holds - * one copy, at its top-left. - * - * @param array{x: int, y: int}|null $anchor - * @return list - */ - private function getPieceAreaOrigins(TilesetPiece $piece, ?array $anchor): array - { - $anchor ??= ['x' => $this->cursorX, 'y' => $this->cursorY]; - $span = static function (int $from, int $to, int $size): array { - [$low, $high] = [min($from, $to), max($from, $to)]; - $starts = [$low]; - for ($start = $low + $size; $start + $size - 1 <= $high; $start += $size) { - $starts[] = $start; - } - return $starts; - }; - $origins = []; - foreach ($span($anchor['y'], $this->cursorY, $piece->height) as $y) { - foreach ($span($anchor['x'], $this->cursorX, $piece->width) as $x) { - $origins[] = ['x' => $x, 'y' => $y]; - } - } - return $origins; - } /** * Stamps the piece across the area from the anchor to the cursor (the - * cursor alone without an anchor) as one undo step, then anchors at the - * cursor so the next Enter fills on from there: its glyphs on the gameplay - * layer it names, in the brush colour, and its tiles on the tile layers it - * names, creating any the map does not have yet. Space glyphs and `0` - * tiles leave their cells as they are. A glyph the stamp covers takes its - * own tiles with it, as any glyph edit does ({@see GlyphTilePlanner}). An - * area that cannot be stamped whole changes nothing. + * cursor alone without an anchor) as one undo step + * ({@see PiecePlacer::stampArea()}), in the brush colour where the piece + * has none, then anchors at the cursor so the next Enter fills on from + * there. An area that cannot be stamped whole changes nothing. * * @param array{x: int, y: int}|null $anchor */ private function stampPieceArea(ProjectMap $map, TilesetPiece $piece, ?array $anchor): void { - $origins = $this->getPieceAreaOrigins($piece, $anchor); - $sources = $piece->getSourceGrid(); - $writes = $assigned = []; - foreach ($origins as ['x' => $x, 'y' => $y]) { - foreach ($piece->glyphs as $row => $symbols) { - foreach ($symbols as $column => $symbol) { - if ($symbol !== ' ') { - $writes[] = ['x' => $x + $column, 'y' => $y + $row, 'symbol' => $symbol] - + self::resolvePieceCellPaint($sources[$row][$column], $this->selectedPaintColor); - } - } - } - // Each glyph cell plays its own cell of this piece, never a lookalike. - foreach (PieceRole::readPiece($piece) as $roles) { - foreach ($roles as $role) { - $assigned[($x + $role->cell[1]) . ',' . ($y + $role->cell[0])] = $role->key; - } - } - } + $cursor = ['x' => $this->cursorX, 'y' => $this->cursorY]; + $this->finalizeActiveStroke(); try { - $layer = $this->findPieceLayer($map, $piece); - foreach ($origins as ['x' => $x, 'y' => $y]) { - foreach ($piece->glyphs as $row => $symbols) { - foreach (array_keys($symbols) as $column) { - if (! $map->hasLayerCell($layer, $x + $column, $y + $row)) { - throw new MapSourceRefusal(sprintf('%s (%d x %d) does not fit at (%d, %d): the map has no cell at (%d, %d). Nothing was changed.', - $piece->name, $piece->width, $piece->height, $x, $y, $x + $column, $y + $row)); - } - } - } - } - $this->finalizeActiveStroke(); - $tilesBefore = $map->getTileLayerSources(); - if ($writes === []) { - // A piece without glyphs, such as a rug, stands for nothing and is laid as authored. - foreach ($origins as ['x' => $x, 'y' => $y]) { - $map->writeTileEntries($piece->tiles, $x, $y); - } - } else { - $map->writeTileCells(CanvasEditor::plan($map, $layer, $writes, assigned: $assigned)['tiles'] ?? []); - } - $tilesAfter = $map->getTileLayerSources(); + ['command' => $command, 'origins' => $origins] = PiecePlacer::stampArea($map, $piece, $anchor ?? $cursor, $cursor, $this->selectedPaintColor); } catch (MapSourceRefusal $refusal) { $this->setStatus($refusal->getMessage(), StatusLevel::WARN); $this->renderCanvasArea(); return; } - $this->piecePlacement['anchor'] = ['x' => $this->cursorX, 'y' => $this->cursorY]; - [$stroke] = CanvasEditor::writeCells($map, $layer, $writes, 'Piece stamp'); + $this->piecePlacement['anchor'] = $cursor; $next = ' Enter fills on from here; Esc drops the anchor.'; - if (! $stroke->hasChanges() && $tilesAfter === $tilesBefore) { + if ($command === null) { $this->setStatus(sprintf('%s is already there.', $piece->name) . $next); $this->renderCanvasArea(); return; } - $this->recordStrokeWithTiles('Piece stamp', $map, $stroke, $tilesBefore, $tilesAfter); + $this->recordCommand($command); $this->setStatus(count($origins) === 1 ? sprintf('Stamped %s at (%d, %d).', $piece->name, $origins[0]['x'], $origins[0]['y']) . $next : sprintf('Stamped %d %s across (%d, %d) to (%d, %d).', count($origins), $piece->name, @@ -373,21 +278,6 @@ private function stampPieceArea(ProjectMap $map, TilesetPiece $piece, ?array $an $this->renderCanvasArea(); } - /** - * How one piece cell is painted: in the style its tileset authored for it, - * kept byte for byte, or with the fallback colour when it has none (null - * keeps the map cell's own colour). - * - * @return array{style: array{prefix: string, suffix: string}}|array{color: string|null} - */ - private static function resolvePieceCellPaint(string $source, ?string $fallbackColor): array - { - $cell = TerminalText::parseSourceCells($source)[0] ?? null; - - return $cell !== null && ($cell['prefix'] !== '' || $cell['suffix'] !== '') - ? ['style' => ['prefix' => $cell['prefix'], 'suffix' => $cell['suffix']]] - : ['color' => $fallbackColor]; - } /** * A mouse drag while placing a piece: the press anchors it, the drag @@ -418,7 +308,7 @@ private function dragPieceWithMouse(int $x, int $y, bool $isMotion, bool $isRele /** * Records a glyph stroke and the tile layer change made with it, such as - * a stamped piece or a pasted block, as one undo step. + * a pasted block, as one undo step. * * @param array $tilesBefore Tile layer sources before the change. * @param array $tilesAfter Tile layer sources after it. @@ -427,6 +317,7 @@ private function recordStrokeWithTiles(string $label, ProjectMap $map, PaintStro { $this->recordCommand(CanvasEditor::combineStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter)); } + /** * Draws a connected piece: the cell at the cursor without an anchor, * otherwise the line or room outline from the anchor to the cursor. The @@ -436,12 +327,13 @@ private function recordStrokeWithTiles(string $label, ProjectMap $map, PaintStro */ private function drawConnectedPiece(ProjectMap $map, TilesetPiece $piece, ?array $anchor): void { - $cells = $this->getConnectedDrawCells($anchor); + $cursor = ['x' => $this->cursorX, 'y' => $this->cursorY]; + $cells = PiecePlacer::getConnectedDrawCells($anchor ?? $cursor, $cursor); $changed = $this->applyConnectedPiece($map, $piece, $cells, [], 'Piece draw'); if ($changed === null) { return; } - $this->piecePlacement['anchor'] = ['x' => $this->cursorX, 'y' => $this->cursorY]; + $this->piecePlacement['anchor'] = $cursor; $drew = $changed ? sprintf('Drew %d %s %s.', count($cells), mb_strtolower($piece->name), count($cells) === 1 ? 'cell' : 'cells') : sprintf('%s is already drawn there.', $piece->name); @@ -466,13 +358,13 @@ private function eraseConnectedPieceCell(): void $x = $this->cursorX; $y = $this->cursorY; try { - $layer = $this->findPieceLayer($map, $piece); + $layer = PiecePlacer::findLayer($map, $piece); } catch (MapSourceRefusal $refusal) { $this->setStatus($refusal->getMessage(), StatusLevel::WARN); $this->renderCanvasArea(); return; } - if (! $this->resolveConnectedMemberLookup($map, $layer, $piece)($x, $y)) { + if (! PiecePlacer::resolveMemberLookup($map, $layer, $piece)($x, $y)) { $this->setStatus(sprintf('(%d, %d) is not part of a %s, so nothing was erased.', $x, $y, mb_strtolower($piece->name))); $this->renderCanvasArea(); return; @@ -484,12 +376,9 @@ private function eraseConnectedPieceCell(): void } /** - * Draws and erases a connected piece's cells as one undo step: writes - * them, then gives every drawn cell and every member beside a drawn or - * erased cell the glyph and tiles of its shape. Glyphs go on the piece's - * gameplay layer, drawn cells in the brush colour and reshaped cells - * keeping theirs; tiles go on the piece's tile layers, `0` where a cell - * was erased. A cell beyond the map refuses the whole change. + * Draws and erases a connected piece's cells as one undo step + * ({@see PiecePlacer::drawConnected()}), drawn cells in the brush colour + * where their shape has none. * * @param list $drawn * @param list $erased @@ -497,108 +386,25 @@ private function eraseConnectedPieceCell(): void */ private function applyConnectedPiece(ProjectMap $map, TilesetPiece $piece, array $drawn, array $erased, string $label): ?bool { + $this->finalizeActiveStroke(); try { - $layer = $this->findPieceLayer($map, $piece); - foreach ([...$drawn, ...$erased] as $cell) { - if (! $map->hasLayerCell($layer, $cell['x'], $cell['y'])) { - throw new MapSourceRefusal(sprintf('%s cannot reach (%d, %d): the map has no cell there. Nothing was changed.', - $piece->name, $cell['x'], $cell['y'])); - } - } - $cells = ConnectedPieceShaper::reshapeCells($piece, $drawn, $erased, $this->resolveConnectedMemberLookup($map, $layer, $piece)); - $drawnKeys = array_flip(array_map(static fn(array $cell): string => "{$cell['x']},{$cell['y']}", $drawn)); - $sources = $piece->getSourceShapeGrid(); - // A drawn cell takes the brush colour unless its shape has its own; a reshaped neighbour keeps its colour. - $writes = array_map(fn(array $cell): array => [ - 'x' => $cell['x'], - 'y' => $cell['y'], - 'symbol' => $cell['shape'] === null ? ' ' : $piece->shapes[$cell['shape']], - ] + ($cell['shape'] === null ? ['color' => null] : self::resolvePieceCellPaint($sources[$cell['shape']], - isset($drawnKeys["{$cell['x']},{$cell['y']}"]) ? $this->selectedPaintColor : null)), $cells); - // Each shape draws its tiles, and a glyph the wall covers takes its own ({@see GlyphTilePlanner}). - $this->finalizeActiveStroke(); - $tilesBefore = $map->getTileLayerSources(); - $map->writeTileCells(CanvasEditor::plan($map, $layer, $writes)['tiles'] ?? []); - $tilesAfter = $map->getTileLayerSources(); + $command = PiecePlacer::drawConnected($map, $piece, $drawn, $erased, $this->selectedPaintColor, $label); } catch (MapSourceRefusal $refusal) { $this->setStatus($refusal->getMessage(), StatusLevel::WARN); $this->renderCanvasArea(); return null; } - [$stroke] = CanvasEditor::writeCells($map, $layer, $writes, $label); - if (! $stroke->hasChanges() && $tilesAfter === $tilesBefore) { + if ($command === null) { return false; } - $this->recordStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter); + $this->recordCommand($command); return true; } - /** - * The cells Enter would draw: the cursor's cell without an anchor, - * otherwise the outline of the rectangle with the anchor and the cursor - * as opposite corners, which is a straight line when they share a row or - * a column. - * - * @param array{x: int, y: int}|null $anchor - * @return list - */ - private function getConnectedDrawCells(?array $anchor): array - { - $anchor ??= ['x' => $this->cursorX, 'y' => $this->cursorY]; - - return array_values(ToolGeometry::rectangleOutline($anchor['x'], $anchor['y'], $this->cursorX, $this->cursorY)); - } - - /** - * The cells Enter would draw, each with the glyph of the shape it would - * take, for the canvas preview. - * - * @param array{x: int, y: int}|null $anchor - * @return array> Cells by row and column. - */ - private function getConnectedPreviewCells(ProjectMap $map, TilesetPiece $piece, ?array $anchor): array - { - $drawn = $this->getConnectedDrawCells($anchor); - try { - $isMemberCell = $this->resolveConnectedMemberLookup($map, $this->findPieceLayer($map, $piece), $piece); - } catch (MapSourceRefusal) { - $isMemberCell = static fn(int $x, int $y): bool => false; - } - $preview = []; - foreach (array_slice(ConnectedPieceShaper::reshapeCells($piece, $drawn, [], $isMemberCell), 0, count($drawn)) as $cell) { - $preview[$cell['y']][$cell['x']] = $piece->shapes[(string) $cell['shape']]; - } - return $preview; - } - /** - * Whether a cell of the layer belongs to the connected piece now: it is - * on the map and holds one of the piece's glyphs. - * - * @return \Closure(int, int): bool - */ - private function resolveConnectedMemberLookup(ProjectMap $map, string $layer, TilesetPiece $piece): \Closure - { - return static fn(int $x, int $y): bool => $map->hasLayerCell($layer, $x, $y) && $piece->isMember($map->getLayerSymbol($layer, $x, $y)); - } - /** - * The id of the gameplay layer the piece's glyphs go on. - * - * @throws MapSourceRefusal When the map has no gameplay layer with that name. - */ - private function findPieceLayer(ProjectMap $map, TilesetPiece $piece): string - { - foreach ($map->getLayers() as $layer) { - if ($layer['id'] !== MapLayers::EVENT && ! $layer['decoration'] && $layer['name'] === $piece->layer) { - return $layer['id']; - } - } - throw new MapSourceRefusal(sprintf('%s goes on the %s layer, which this map does not have. Create it (Ctrl+P, Layers: Create gameplay layer) first. Nothing was changed.', - $piece->name, $piece->layer)); - } /** The canvas border hint while a piece is being placed, or null when none is. */ private function getPiecePlacementHelp(int $windowWidth): ?string diff --git a/src/Canvas/PiecePlacer.php b/src/Canvas/PiecePlacer.php new file mode 100644 index 0000000..129743d --- /dev/null +++ b/src/Canvas/PiecePlacer.php @@ -0,0 +1,346 @@ + + * @throws MapSourceRefusal When the map has no tileset, it cannot load, or it has no pieces. + */ + public static function loadPieces(ProjectMap $map): array + { + try { + $tileset = $map->loadTileset(); + } catch (\Throwable $error) { + throw new MapSourceRefusal('Pieces are unavailable: ' . $error->getMessage(), previous: $error); + } + if ($tileset === null) { + throw new MapSourceRefusal('This map has no kind yet, so it has no pieces. Set its Kind in the Inspector.'); + } + if ($tileset->pieces === []) { + throw new MapSourceRefusal(sprintf('Tileset %s has no pieces. Add them to assets/%s/%s.php.', $tileset->id, Tileset::DIRECTORY, $tileset->id)); + } + + return $tileset->pieces; + } + + /** + * The id of the gameplay layer the piece's glyphs go on. + * + * @throws MapSourceRefusal When the map has no gameplay layer with that name. + */ + public static function findLayer(ProjectMap $map, TilesetPiece $piece): string + { + foreach ($map->getLayers() as $layer) { + if ($layer['id'] !== MapLayers::EVENT && ! $layer['decoration'] && $layer['name'] === $piece->layer) { + return $layer['id']; + } + } + throw new MapSourceRefusal(sprintf('%s goes on the %s layer, which this map does not have. Create that gameplay layer first. Nothing was changed.', + $piece->name, $piece->layer)); + } + + /** + * The top-left cells an item piece is stamped at across the area from one + * corner to the other: whole copies only, so an area smaller than the + * piece holds one copy, at its top-left. + * + * @param array{x: int, y: int} $from + * @param array{x: int, y: int} $to + * @return list + */ + public static function getAreaOrigins(TilesetPiece $piece, array $from, array $to): array + { + $span = static function (int $from, int $to, int $size): array { + [$low, $high] = [min($from, $to), max($from, $to)]; + $starts = [$low]; + for ($start = $low + $size; $start + $size - 1 <= $high; $start += $size) { + $starts[] = $start; + } + return $starts; + }; + $origins = []; + foreach ($span($from['y'], $to['y'], $piece->height) as $y) { + foreach ($span($from['x'], $to['x'], $piece->width) as $x) { + $origins[] = ['x' => $x, 'y' => $y]; + } + } + + return $origins; + } + + /** + * The cells a connected piece is drawn on between two corners: the + * outline of the rectangle they make, a straight line when they share a + * row or a column, one cell when they are the same. + * + * @param array{x: int, y: int} $from + * @param array{x: int, y: int} $to + * @return list + */ + public static function getConnectedDrawCells(array $from, array $to): array + { + return array_values(ToolGeometry::rectangleOutline($from['x'], $from['y'], $to['x'], $to['y'])); + } + + /** + * What placing the piece between two corners would write, for a preview: + * each cell's glyph and the colour it shows in, null where a space leaves + * the map's cell as it is. A connected piece previews each drawn cell with + * the glyph of the shape it would take beside what is already there. + * + * @param array{x: int, y: int} $from + * @param array{x: int, y: int} $to + * @param string|null $fallbackColor The colour of cells the piece leaves unstyled. + * @return array> Cells by row and column. + */ + public static function getPreviewCells(ProjectMap $map, TilesetPiece $piece, array $from, array $to, ?string $fallbackColor = null): array + { + $cells = []; + if ($piece->connects !== null) { + $drawn = self::getConnectedDrawCells($from, $to); + try { + $isMemberCell = self::resolveMemberLookup($map, self::findLayer($map, $piece), $piece); + } catch (MapSourceRefusal) { + $isMemberCell = static fn(int $x, int $y): bool => false; + } + $sources = $piece->getSourceShapeGrid(); + foreach (array_slice(ConnectedPieceShaper::reshapeCells($piece, $drawn, [], $isMemberCell), 0, count($drawn)) as $cell) { + $shape = (string) $cell['shape']; + $cells[$cell['y']][$cell['x']] = ['symbol' => $piece->shapes[$shape], 'color' => self::readCellColor($sources[$shape]) ?? $fallbackColor]; + } + + return $cells; + } + $sources = $piece->getSourceGrid(); + foreach (self::getAreaOrigins($piece, $from, $to) as ['x' => $x, 'y' => $y]) { + foreach ($piece->glyphs as $row => $symbols) { + foreach ($symbols as $column => $symbol) { + $cells[$y + $row][$x + $column] = $symbol === ' ' + ? null + : ['symbol' => $symbol, 'color' => self::readCellColor($sources[$row][$column]) ?? $fallbackColor]; + } + } + } + + return $cells; + } + + /** + * A picture of what the piece draws, for a picker: an item piece's own + * cells, or a small room of a connected piece's shapes. Each cell holds + * its glyph and authored colour; a space shows nothing. + * + * @return list> Cells by row. + */ + public static function buildPicture(TilesetPiece $piece): array + { + if ($piece->connects !== null) { + $sources = $piece->getSourceShapeGrid(); + $shape = static fn(string $name): array => ['symbol' => $piece->shapes[$name], 'color' => self::readCellColor($sources[$name])]; + $blank = ['symbol' => ' ', 'color' => null]; + + return [ + [$shape('corner'), $shape('horizontal'), $shape('corner')], + [$shape('vertical'), $blank, $shape('vertical')], + [$shape('corner'), $shape('horizontal'), $shape('corner')], + ]; + } + $sources = $piece->getSourceGrid(); + + return array_map(static fn(array $symbols, int $row): array => array_map( + static fn(string $symbol, int $column): array => ['symbol' => $symbol, 'color' => self::readCellColor($sources[$row][$column])], + $symbols, array_keys($symbols), + ), $piece->glyphs, array_keys($piece->glyphs)); + } + + /** + * Stamps an item piece across the area between two corners as one undo + * step. Space glyphs and `0` tiles leave their cells as they are, and a + * glyph the stamp covers takes its own tiles with it, as any glyph edit + * does ({@see GlyphTilePlanner}). A piece without glyphs, such as a rug, + * stands for nothing and is laid as authored. + * + * @param array{x: int, y: int} $from + * @param array{x: int, y: int} $to + * @param string|null $fallbackColor The colour of cells the piece leaves unstyled; null keeps the map cell's own. + * @return array{command: ?Command, origins: list} + * @throws MapSourceRefusal When the piece is connected, its layer is missing or a copy falls off the map. + */ + public static function stampArea(ProjectMap $map, TilesetPiece $piece, array $from, array $to, ?string $fallbackColor, + string $label = 'Piece stamp'): array + { + if ($piece->connects !== null) { + throw new MapSourceRefusal(sprintf('%s is drawn as connected cells, not stamped. Nothing was changed.', $piece->name)); + } + $origins = self::getAreaOrigins($piece, $from, $to); + $sources = $piece->getSourceGrid(); + $writes = $assigned = []; + foreach ($origins as ['x' => $x, 'y' => $y]) { + foreach ($piece->glyphs as $row => $symbols) { + foreach ($symbols as $column => $symbol) { + if ($symbol !== ' ') { + $writes[] = ['x' => $x + $column, 'y' => $y + $row, 'symbol' => $symbol] + + self::resolveCellPaint($sources[$row][$column], $fallbackColor); + } + } + } + // Each glyph cell plays its own cell of this piece, never a lookalike. + foreach (PieceRole::readPiece($piece) as $roles) { + foreach ($roles as $role) { + $assigned[($x + $role->cell[1]) . ',' . ($y + $role->cell[0])] = $role->key; + } + } + } + $layer = self::findLayer($map, $piece); + foreach ($origins as ['x' => $x, 'y' => $y]) { + foreach ($piece->glyphs as $row => $symbols) { + foreach (array_keys($symbols) as $column) { + if (! $map->hasLayerCell($layer, $x + $column, $y + $row)) { + throw new MapSourceRefusal(sprintf('%s (%d x %d) does not fit at (%d, %d): the map has no cell at (%d, %d). Nothing was changed.', + $piece->name, $piece->width, $piece->height, $x, $y, $x + $column, $y + $row)); + } + } + } + } + $tilesBefore = $map->getTileLayerSources(); + if ($writes === []) { + foreach ($origins as ['x' => $x, 'y' => $y]) { + $map->writeTileEntries($piece->tiles, $x, $y); + } + } else { + $map->writeTileCells(CanvasEditor::plan($map, $layer, $writes, assigned: $assigned)['tiles'] ?? []); + } + $tilesAfter = $map->getTileLayerSources(); + [$stroke] = CanvasEditor::writeCells($map, $layer, $writes, $label); + + return [ + 'command' => $stroke->hasChanges() || $tilesAfter !== $tilesBefore + ? CanvasEditor::combineStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter) + : null, + 'origins' => $origins, + ]; + } + + /** + * Draws and erases a connected piece's cells as one undo step: writes + * them, then gives every drawn cell and every member beside a drawn or + * erased cell the glyph and tiles of its shape. Drawn cells take the + * fallback colour unless their shape has its own; reshaped cells keep + * theirs; tiles become `0` where a cell was erased. + * + * @param list $drawn + * @param list $erased + * @return ?Command The edit, or null when it changed nothing. + * @throws MapSourceRefusal When the piece is not connected, its layer is missing or a cell is off the map. + */ + public static function drawConnected(ProjectMap $map, TilesetPiece $piece, array $drawn, array $erased, ?string $fallbackColor, + string $label = 'Piece draw'): ?Command + { + if ($piece->connects === null) { + throw new MapSourceRefusal(sprintf('%s is stamped whole, not drawn as connected cells. Nothing was changed.', $piece->name)); + } + $layer = self::findLayer($map, $piece); + foreach ([...$drawn, ...$erased] as $cell) { + if (! $map->hasLayerCell($layer, $cell['x'], $cell['y'])) { + throw new MapSourceRefusal(sprintf('%s cannot reach (%d, %d): the map has no cell there. Nothing was changed.', + $piece->name, $cell['x'], $cell['y'])); + } + } + $cells = ConnectedPieceShaper::reshapeCells($piece, $drawn, $erased, self::resolveMemberLookup($map, $layer, $piece)); + $drawnKeys = array_flip(array_map(static fn(array $cell): string => "{$cell['x']},{$cell['y']}", $drawn)); + $sources = $piece->getSourceShapeGrid(); + $writes = array_map(static fn(array $cell): array => [ + 'x' => $cell['x'], + 'y' => $cell['y'], + 'symbol' => $cell['shape'] === null ? ' ' : $piece->shapes[$cell['shape']], + ] + ($cell['shape'] === null ? ['color' => null] : self::resolveCellPaint($sources[$cell['shape']], + isset($drawnKeys["{$cell['x']},{$cell['y']}"]) ? $fallbackColor : null)), $cells); + // Each shape draws its tiles, and a glyph the wall covers takes its own ({@see GlyphTilePlanner}). + $tilesBefore = $map->getTileLayerSources(); + $map->writeTileCells(CanvasEditor::plan($map, $layer, $writes)['tiles'] ?? []); + $tilesAfter = $map->getTileLayerSources(); + [$stroke] = CanvasEditor::writeCells($map, $layer, $writes, $label); + + return $stroke->hasChanges() || $tilesAfter !== $tilesBefore + ? CanvasEditor::combineStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter) + : null; + } + + /** + * Whether a cell of the layer belongs to the connected piece now: it is on + * the map and holds one of the piece's glyphs. + * + * @return Closure(int, int): bool + */ + public static function resolveMemberLookup(ProjectMap $map, string $layer, TilesetPiece $piece): Closure + { + return static fn(int $x, int $y): bool => $map->hasLayerCell($layer, $x, $y) && $piece->isMember($map->getLayerSymbol($layer, $x, $y)); + } + + /** + * How one piece cell is painted: in the style its tileset authored for it, + * kept byte for byte, or with the fallback colour when it has none (null + * keeps the map cell's own colour). + * + * @return array{style: array{prefix: string, suffix: string}}|array{color: string|null} + */ + private static function resolveCellPaint(string $source, ?string $fallbackColor): array + { + $style = self::readCellStyle($source); + + return $style !== null ? ['style' => $style] : ['color' => $fallbackColor]; + } + + /** + * A piece cell's authored style, or null when it has none. + * + * @return array{prefix: string, suffix: string}|null + */ + private static function readCellStyle(string $source): ?array + { + $cell = TerminalText::parseSourceCells($source)[0] ?? null; + + return $cell !== null && ($cell['prefix'] !== '' || $cell['suffix'] !== '') + ? ['prefix' => $cell['prefix'], 'suffix' => $cell['suffix']] + : null; + } + + /** A piece cell's authored foreground, or null when it has none. */ + private static function readCellColor(string $source): ?string + { + $style = self::readCellStyle($source); + + return $style === null ? null : ProjectMap::getInnermostForeground($style['prefix']); + } +} diff --git a/src/ProjectMap.php b/src/ProjectMap.php index d80a08f..56543b5 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -1233,7 +1233,7 @@ public function getTileColor(int $x, int $y): ?string * @param string $prefix The cell's styling prefix bytes. * @return string|null The `fg=` value, or null when none is declared. */ - private static function getInnermostForeground(string $prefix): ?string + public static function getInnermostForeground(string $prefix): ?string { $state = new SgrStyleState(); preg_match_all('/\x1b\[[0-9;]*m/', $prefix, $sequences); diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index d0d3abc..b48925d 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -14,6 +14,8 @@ use Ichiloto\Editor\Cutscenes\Preview\TimelinePreviewSession; use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\Canvas\PieceRole; +use Ichiloto\Editor\Canvas\PiecePlacer; +use Ichiloto\Engine\Rendering\Tilesets\TilesetPiece; use Ichiloto\Editor\Database\ConditionCodec; use Ichiloto\Editor\Database\ConditionEditor; use Ichiloto\Editor\Animations\LegacyAnimationConversion; @@ -591,6 +593,127 @@ public function stampTiles(string $mapId, int $revision, string $layerName, arra return ['status' => 'applied', 'changed' => $applied['changed'], 'glyphs' => $applied['glyphs'], 'revision' => $this->getMapRevision($map)]; } + /** + * The pieces of the map's tileset an author places ({@see PiecePlacer}), + * in authored order: each with the gameplay layer its glyphs go on and + * whether the map has it, whether it is drawn as connected cells rather + * than stamped whole, its size, and a picture of what it draws. A map + * whose tileset offers none says why in `issue`. + * + * @return array{map: string, pieces: list>, issue: ?string} + * @throws SessionRefusal When the map is unknown. + */ + public function listPieces(string $mapId): array + { + $map = $this->requireMap($mapId); + try { + $pieces = PiecePlacer::loadPieces($map); + } catch (MapSourceRefusal $refusal) { + return ['map' => $map->mapId, 'pieces' => [], 'issue' => $refusal->getMessage()]; + } + $described = []; + foreach ($pieces as $piece) { + try { + PiecePlacer::findLayer($map, $piece); + $layerIssue = null; + } catch (MapSourceRefusal $refusal) { + $layerIssue = $refusal->getMessage(); + } + $described[] = [ + 'id' => $piece->id, + 'name' => $piece->name, + 'layer' => $piece->layer, + 'layerLabel' => MapLayers::formatLabel($piece->layer), + 'layerIssue' => $layerIssue, + 'connected' => $piece->connects !== null, + 'width' => $piece->width, + 'height' => $piece->height, + 'tileLayers' => array_keys($piece->connects === null ? $piece->tiles : $piece->shapeTiles), + 'picture' => PiecePlacer::buildPicture($piece), + ]; + } + + return ['map' => $map->mapId, 'pieces' => $described, 'issue' => null]; + } + + /** + * What placing a piece between two corners would write, for a preview + * ({@see PiecePlacer::getPreviewCells()}): each cell the piece writes, as + * [x, y, glyph, colour], cells off the map left out. + * + * @param array{0: int, 1: int} $from + * @param array{0: int, 1: int} $to + * @param string|null $color The colour of cells the piece leaves unstyled. + * @return array{cells: list} + * @throws SessionRefusal When the map or the piece is unknown. + */ + public function previewPiece(string $mapId, string $pieceId, array $from, array $to, ?string $color = null): array + { + $map = $this->requireMap($mapId); + $piece = $this->requirePiece($map, $pieceId); + $cells = []; + foreach (PiecePlacer::getPreviewCells($map, $piece, ['x' => $from[0], 'y' => $from[1]], ['x' => $to[0], 'y' => $to[1]], $color) as $y => $row) { + foreach ($row as $x => $cell) { + if ($cell !== null && $x >= 0 && $y >= 0 && $x < $map->getWidth() && $y < $map->getHeight()) { + $cells[] = [$x, $y, $cell['symbol'], $cell['color']]; + } + } + } + + return ['cells' => $cells]; + } + + /** + * Places a piece between two corners as one undo step: an item piece + * stamped as whole copies across the area, a connected piece drawn along + * its outline ({@see PiecePlacer}). An area that cannot be placed whole + * changes nothing. + * + * @param array{0: int, 1: int} $from + * @param array{0: int, 1: int} $to + * @param string|null $color The colour of cells the piece leaves unstyled; null keeps each cell's own. + * @return array{status: 'applied', changed: bool, copies: int, cells: int, revision: int} + * @throws SessionRefusal When the map is unknown, stale or read-only, the piece is unknown, or the placement is refused. + */ + public function placePiece(string $mapId, int $revision, string $pieceId, array $from, array $to, ?string $color = null): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + $piece = $this->requirePiece($map, $pieceId); + [$from, $to] = [['x' => $from[0], 'y' => $from[1]], ['x' => $to[0], 'y' => $to[1]]]; + try { + if ($piece->connects === null) { + ['command' => $command, 'origins' => $origins] = PiecePlacer::stampArea($map, $piece, $from, $to, $color); + [$copies, $cells] = [count($origins), 0]; + } else { + $drawn = PiecePlacer::getConnectedDrawCells($from, $to); + $command = PiecePlacer::drawConnected($map, $piece, $drawn, [], $color); + [$copies, $cells] = [0, count($drawn)]; + } + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($command !== null) { + $this->history->record($command); + } + + return ['status' => 'applied', 'changed' => $command !== null, 'copies' => $copies, 'cells' => $cells, 'revision' => $this->getMapRevision($map)]; + } + + /** @throws SessionRefusal When the map's tileset has no piece with the id. */ + private function requirePiece(ProjectMap $map, string $pieceId): TilesetPiece + { + try { + $pieces = PiecePlacer::loadPieces($map); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + + return $pieces[$pieceId] ?? throw new SessionRefusal(sprintf("%s is no longer in the map's tileset. Choose a piece again.", $pieceId)); + } + /** * Paints one glyph over cells of a layer, with the tiles that follow it, * as one undo step. A glyph that could be several pieces is asked about: diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 2b75b58..2c42cb5 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -148,6 +148,22 @@ private function dispatch(string $method, array $params): array is_string($params['label'] ?? null) ? $params['label'] : 'Place tiles', self::readTileChoices($params), ), + 'pieces.list' => $session->listPieces(self::requireString($params, 'map')), + 'pieces.preview' => $session->previewPiece( + self::requireString($params, 'map'), + self::requireString($params, 'piece'), + self::requireCell($params, 'from'), + self::requireCell($params, 'to'), + self::readOptionalString($params, 'color'), + ), + 'pieces.place' => $session->placePiece( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'piece'), + self::requireCell($params, 'from'), + self::requireCell($params, 'to'), + self::readOptionalString($params, 'color'), + ), 'map.paint' => $session->paint( self::requireString($params, 'map'), self::requireInt($params, 'revision'), @@ -498,6 +514,19 @@ private static function requireCells(array $params): array return $cells; } + /** + * @param array $params + * @return array{0: int, 1: int} + */ + private static function requireCell(array $params, string $key): array + { + $cell = $params[$key] ?? null; + + return is_array($cell) && array_is_list($cell) && count($cell) === 2 && is_int($cell[0]) && is_int($cell[1]) + ? $cell + : throw new InvalidRequest(sprintf('"%s" must be an [x, y] integer pair.', $key)); + } + /** * @param array $params * @return list diff --git a/tests/Unit/Session/PieceSessionTest.php b/tests/Unit/Session/PieceSessionTest.php new file mode 100644 index 0000000..8982c5d --- /dev/null +++ b/tests/Unit/Session/PieceSessionTest.php @@ -0,0 +1,109 @@ + ['name' => 'Tree', 'layer' => 'buildings', 'glyphs' => ['^^', '| ']], + 'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'connects' => 'lines', + 'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'], 'tiles' => ['walls' => '5888']], + 'shelf' => ['name' => 'Shelf', 'layer' => 'storage', 'glyphs' => ['#']], + ]); + + return EditorSession::open($root); +} + +/** @return list The buildings layer, row by row. */ +function readPieceSessionRows(EditorSession $session): array +{ + $layer = array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] === 'map:4'); + + return array_map(static fn(array $row): string => implode('', $row), $layer['rows']); +} + +function placeSessionPiece(EditorSession $session, string $piece, array $from, array $to, ?string $color = null): array +{ + return $session->placePiece('test-map', $session->readMap('test-map')['revision'], $piece, $from, $to, $color); +} + +it('lists the map tileset\'s pieces with the layer each goes on and a picture of what it draws', function () { + $listed = pieceSession()->listPieces('test-map'); + [$tree, $wall, $shelf] = $listed['pieces']; + + expect($listed['issue'])->toBeNull() + ->and($tree)->toMatchArray(['id' => 'tree', 'name' => 'Tree', 'layer' => 'buildings', 'layerIssue' => null, + 'connected' => false, 'width' => 2, 'height' => 2]) + ->and($tree['picture'])->toBe([ + [['symbol' => '^', 'color' => '#5faf00'], ['symbol' => '^', 'color' => '#5faf00']], + [['symbol' => '|', 'color' => '#875f00'], ['symbol' => ' ', 'color' => null]], + ]) + ->and($wall['connected'])->toBeTrue() + ->and(array_map(static fn(array $row): string => implode('', array_column($row, 'symbol')), $wall['picture']))->toBe(['+-+', '| |', '+-+']) + // A piece for a layer the map lacks is listed, saying so. + ->and($shelf['layerIssue'])->toContain('storage layer, which this map does not have'); +}); + +it('says why a map offers no pieces', function () { + $root = mapGraphicsProject(); + writeTestTileset($root); + + expect(EditorSession::open($root)->listPieces('test-map'))->toMatchArray(['pieces' => []]) + ->and(EditorSession::open($root)->listPieces('test-map')['issue'])->toContain('has no pieces'); +}); + +it('stamps whole copies of a piece across the dragged area in their own colours, as previewed, in one undo step', function () { + $session = pieceSession(); + $preview = $session->previewPiece('test-map', 'tree', [0, 0], [3, 1], 'cyan'); + + expect($preview['cells'])->toBe([ + [0, 0, '^', '#5faf00'], [1, 0, '^', '#5faf00'], [2, 0, '^', '#5faf00'], [3, 0, '^', '#5faf00'], + [0, 1, '|', '#875f00'], [2, 1, '|', '#875f00'], + ]); + expect(placeSessionPiece($session, 'tree', [0, 0], [3, 1], 'cyan'))->toMatchArray(['status' => 'applied', 'changed' => true, 'copies' => 2]) + // The gaps leave the map's own cells as they were. + ->and(readPieceSessionRows($session))->toBe(['^^^^', '|x| ']); + $layer = array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] === 'map:4'); + expect($layer['colors'])->toContain([0, 0, '#5faf00'], [2, 1, '#875f00']); + + $session->undo(); + expect(readPieceSessionRows($session))->toBe([' / ', ' xx ']); +}); + +it('draws a connected piece along the outline between the corners, as previewed', function () { + $session = pieceSession(); + $preview = $session->previewPiece('test-map', 'wall', [0, 0], [3, 1]); + + expect(placeSessionPiece($session, 'wall', [0, 0], [3, 1]))->toMatchArray(['changed' => true, 'cells' => 8]); + $rows = readPieceSessionRows($session); + // Every outline cell is a wall, each shaped as the preview showed it. + expect(count($preview['cells']))->toBe(8); + foreach ($preview['cells'] as [$x, $y, $glyph]) { + expect(mb_substr($rows[$y], $x, 1))->toBe($glyph)->toBeIn(['+', '-', '|']); + } + expect(placeSessionPiece($session, 'wall', [0, 0], [3, 1]))->toMatchArray(['changed' => false]); +}); + +it('refuses a placement that cannot be made whole, a stale revision or an unknown piece, changing nothing', function () { + $session = pieceSession(); + $revision = $session->readMap('test-map')['revision']; + + expect(fn() => placeSessionPiece($session, 'tree', [3, 1], [3, 1]))->toThrow(SessionRefusal::class, 'does not fit') + ->and(fn() => placeSessionPiece($session, 'shelf', [0, 0], [0, 0]))->toThrow(SessionRefusal::class, 'storage layer') + ->and(fn() => placeSessionPiece($session, 'gone', [0, 0], [0, 0]))->toThrow(SessionRefusal::class, 'no longer in the map\'s tileset') + ->and(fn() => $session->placePiece('test-map', $revision + 1, 'tree', [0, 0], [0, 0]))->toThrow(SessionRefusal::class) + ->and(readPieceSessionRows($session))->toBe([' / ', ' xx ']) + ->and($session->readMap('test-map')['dirty'])->toBeFalse(); +}); From 993f9c2b9e4ded47b3e54b6592f6513765c467ea Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 09:51:31 +0200 Subject: [PATCH 214/344] feat(canvas): work out every brush shape and flood fill through the session Brush widths and the cells a line, rectangle or filled rectangle covers move onto CanvasTool, which the terminal canvas already used, and the session answers canvas.shape and canvas.fill with them, so the GUI editor's brushes paint exactly what the terminal editor's do. A tile fill counts an autotile as its kind, whatever shape its neighbours give it. --- src/Canvas/CanvasTool.php | 26 ++++++ src/Editor.php | 16 ++-- src/Session/EditorSession.php | 88 ++++++++++++++++++++ src/Session/SessionHost.php | 15 ++++ tests/Unit/Session/CanvasToolSessionTest.php | 67 +++++++++++++++ 5 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 tests/Unit/Session/CanvasToolSessionTest.php diff --git a/src/Canvas/CanvasTool.php b/src/Canvas/CanvasTool.php index 3d0a0d0..86f5f1c 100644 --- a/src/Canvas/CanvasTool.php +++ b/src/Canvas/CanvasTool.php @@ -36,6 +36,32 @@ enum CanvasTool: string */ case SELECT = 'select'; + /** The square brush widths an author chooses between, in cells. */ + public const array BRUSH_SIZES = [1, 2, 3, 5]; + + /** + * The cells the tool paints between an anchor and the cursor: the brush's + * square at the cursor, a line, or a rectangle's outline, each widened by + * the brush; a filled rectangle is already solid, so it is not widened, + * which would only spill past its corners. A selection paints nothing. + * + * @param array{x: int, y: int} $anchor + * @param array{x: int, y: int} $cursor + * @return list + */ + public function getShapeCells(array $anchor, array $cursor, int $brushSize): array + { + $cells = match ($this) { + self::BRUSH => [$cursor], + self::LINE => ToolGeometry::line($anchor['x'], $anchor['y'], $cursor['x'], $cursor['y']), + self::RECTANGLE => ToolGeometry::rectangleOutline($anchor['x'], $anchor['y'], $cursor['x'], $cursor['y']), + self::FILLED_RECTANGLE => ToolGeometry::rectangleFilled($anchor['x'], $anchor['y'], $cursor['x'], $cursor['y']), + self::SELECT => [], + }; + + return array_values($this === self::FILLED_RECTANGLE ? $cells : ToolGeometry::expandByBrush($cells, $brushSize)); + } + /** * Returns the short user-facing tool label. * diff --git a/src/Editor.php b/src/Editor.php index 8a97007..c330672 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -184,7 +184,6 @@ final class Editor /** * The brush widths Ctrl+W cycles through. 1 is the historic single cell. */ - private const array BRUSH_SIZES = [1, 2, 3, 5]; private bool $isRunning = false; private ?ProjectWorkspace $workspace = null; @@ -4539,7 +4538,7 @@ private function cycleCanvasTool(int $step): void */ private function cycleCanvasBrushSize(): void { - $sizes = self::BRUSH_SIZES; + $sizes = CanvasTool::BRUSH_SIZES; $index = array_search($this->canvasBrushSize, $sizes, true); $index = is_int($index) ? $index : 0; $this->canvasBrushSize = $sizes[($index + 1) % count($sizes)]; @@ -4650,16 +4649,11 @@ private function applyCanvasToolAtCursor(): void */ private function getCanvasToolShapeCells(array $anchor): array { - $cells = match ($this->canvasTool) { - CanvasTool::LINE => ToolGeometry::line($anchor['x'], $anchor['y'], $this->cursorX, $this->cursorY), - CanvasTool::RECTANGLE => ToolGeometry::rectangleOutline($anchor['x'], $anchor['y'], $this->cursorX, $this->cursorY), - CanvasTool::FILLED_RECTANGLE => ToolGeometry::rectangleFilled($anchor['x'], $anchor['y'], $this->cursorX, $this->cursorY), - default => [], - }; + if (! in_array($this->canvasTool, [CanvasTool::LINE, CanvasTool::RECTANGLE, CanvasTool::FILLED_RECTANGLE], true)) { + return []; + } - // The brush thickens outlines and lines; a filled rectangle is - // already solid, so widening it would only spill past the corners. - return $this->canvasTool === CanvasTool::FILLED_RECTANGLE ? $cells : ToolGeometry::expandByBrush($cells, $this->canvasBrushSize); + return $this->canvasTool->getShapeCells($anchor, ['x' => $this->cursorX, 'y' => $this->cursorY], $this->canvasBrushSize); } /** diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index b48925d..01cba2b 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -15,6 +15,9 @@ use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\Canvas\PieceRole; use Ichiloto\Editor\Canvas\PiecePlacer; +use Ichiloto\Editor\Canvas\CanvasTool; +use Ichiloto\Editor\Canvas\ToolGeometry; +use Ichiloto\Engine\Rendering\Tilesets\TileId; use Ichiloto\Engine\Rendering\Tilesets\TilesetPiece; use Ichiloto\Editor\Database\ConditionCodec; use Ichiloto\Editor\Database\ConditionEditor; @@ -593,6 +596,91 @@ public function stampTiles(string $mapId, int $revision, string $layerName, arra return ['status' => 'applied', 'changed' => $applied['changed'], 'glyphs' => $applied['glyphs'], 'revision' => $this->getMapRevision($map)]; } + /** + * The cells a canvas tool paints, the one way every interface works them + * out ({@see CanvasTool::getShapeCells()}): a brush stroke's path widened + * by the brush, or a line or rectangle between two corners. Cells off the + * map are left out, so an interface previews and paints exactly these. + * + * @param array{0: int, 1: int} $from The anchor. + * @param array{0: int, 1: int} $to The cursor. + * @param list $path A brush stroke's cells, in the order dragged; the cursor alone when empty. + * @return array{cells: list} + * @throws SessionRefusal When the map, the tool or the brush width is unknown. + */ + public function getToolShape(string $mapId, string $tool, array $from, array $to, int $brushSize, array $path = []): array + { + $map = $this->requireMap($mapId); + $canvasTool = CanvasTool::tryFrom($tool); + if ($canvasTool === null || $canvasTool === CanvasTool::SELECT) { + throw new SessionRefusal(sprintf('%s is not a painting tool.', $tool)); + } + if (! in_array($brushSize, CanvasTool::BRUSH_SIZES, true)) { + throw new SessionRefusal(sprintf('The brush is %s cells wide.', implode(', ', CanvasTool::BRUSH_SIZES))); + } + [$anchor, $cursor] = [['x' => $from[0], 'y' => $from[1]], ['x' => $to[0], 'y' => $to[1]]]; + $cells = $canvasTool === CanvasTool::BRUSH && $path !== [] + ? ToolGeometry::expandByBrush(array_map(static fn(array $cell): array => ['x' => $cell[0], 'y' => $cell[1]], $path), $brushSize) + : $canvasTool->getShapeCells($anchor, $cursor, $brushSize); + + return ['cells' => $this->listCellsOnMap($map, $cells)]; + } + + /** + * The region a flood fill paints from a cell: the cells joined to it + * across and down that hold the same glyph on a glyph layer, or the same + * tile on a tile layer, an autotile counting as its kind whatever shape + * its neighbours give it. A tile layer the map does not have yet is empty + * everywhere. + * + * @return array{cells: list} + * @throws SessionRefusal When the map or the glyph layer is unknown, or not exactly one layer is named. + */ + public function getFillRegion(string $mapId, int $x, int $y, ?string $layerId, ?string $tileLayer): array + { + $map = $this->requireMap($mapId); + if (($layerId === null) === ($tileLayer === null)) { + throw new SessionRefusal('Name the glyph layer or the tile layer to fill.'); + } + if ($layerId !== null) { + if (! array_any($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $layerId)) { + throw new SessionRefusal(sprintf('%s has no layer %s.', $mapId, $layerId)); + } + $identityAt = static fn(int $x, int $y): string => $map->getLayerSymbol($layerId, $x, $y); + } else { + try { + $rows = in_array($tileLayer, $map->getTileLayerNames(), true) + ? $map->readTileEntries([$tileLayer], 0, 0, $map->getWidth(), $map->getHeight())[$tileLayer] ?? [] + : []; + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + $identityAt = static function (int $x, int $y) use ($rows): string { + $tile = (int) ($rows[$y][$x] ?? TileId::EMPTY); + + return TileId::isAutotile($tile) ? 'kind:' . TileId::getKind($tile) : (string) $tile; + }; + } + + return ['cells' => $this->listCellsOnMap($map, ToolGeometry::floodFill($identityAt, $map->getWidth(), $map->getHeight(), $x, $y))]; + } + + /** + * @param array $cells + * @return list + */ + private function listCellsOnMap(ProjectMap $map, array $cells): array + { + $listed = []; + foreach ($cells as ['x' => $x, 'y' => $y]) { + if ($x >= 0 && $y >= 0 && $x < $map->getWidth() && $y < $map->getHeight()) { + $listed[] = [$x, $y]; + } + } + + return $listed; + } + /** * The pieces of the map's tileset an author places ({@see PiecePlacer}), * in authored order: each with the gameplay layer its glyphs go on and diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 2c42cb5..f9d5b08 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -148,6 +148,21 @@ private function dispatch(string $method, array $params): array is_string($params['label'] ?? null) ? $params['label'] : 'Place tiles', self::readTileChoices($params), ), + 'canvas.shape' => $session->getToolShape( + self::requireString($params, 'map'), + self::requireString($params, 'tool'), + self::requireCell($params, 'from'), + self::requireCell($params, 'to'), + self::requireInt($params, 'size'), + array_key_exists('path', $params) ? self::requireCells(['cells' => $params['path']]) : [], + ), + 'canvas.fill' => $session->getFillRegion( + self::requireString($params, 'map'), + self::requireInt($params, 'x'), + self::requireInt($params, 'y'), + self::readOptionalString($params, 'layer'), + self::readOptionalString($params, 'tileLayer'), + ), 'pieces.list' => $session->listPieces(self::requireString($params, 'map')), 'pieces.preview' => $session->previewPiece( self::requireString($params, 'map'), diff --git a/tests/Unit/Session/CanvasToolSessionTest.php b/tests/Unit/Session/CanvasToolSessionTest.php new file mode 100644 index 0000000..ee7040b --- /dev/null +++ b/tests/Unit/Session/CanvasToolSessionTest.php @@ -0,0 +1,67 @@ + Cells as "x,y", sorted, so the order a shape lists them in does not matter. */ +function listShapeCells(array $answer): array +{ + $cells = array_map(static fn(array $cell): string => "{$cell[0]},{$cell[1]}", $answer['cells']); + sort($cells); + + return $cells; +} + +it('widens a brush stroke by the brush and keeps it on the map', function () { + $session = canvasToolSession(); + + expect(listShapeCells($session->getToolShape('test-map', 'brush', [0, 0], [0, 0], 3)))->toBe(['0,0', '0,1', '1,0', '1,1']) + ->and(listShapeCells($session->getToolShape('test-map', 'brush', [0, 0], [0, 0], 1, [[0, 0], [1, 0], [2, 0]])))->toBe(['0,0', '1,0', '2,0']) + ->and(listShapeCells($session->getToolShape('test-map', 'brush', [0, 0], [0, 0], 2, [[2, 0]])))->toBe(['2,0', '2,1', '3,0', '3,1']); +}); + +it('draws lines and rectangles between two corners, a filled rectangle never widened past them', function () { + $session = canvasToolSession(); + + expect(listShapeCells($session->getToolShape('test-map', 'line', [0, 0], [3, 0], 1)))->toBe(['0,0', '1,0', '2,0', '3,0']) + ->and(listShapeCells($session->getToolShape('test-map', 'rectangle', [3, 1], [0, 0], 1)))->toHaveCount(8) + ->and(listShapeCells($session->getToolShape('test-map', 'filled_rectangle', [1, 0], [2, 1], 5)))->toBe(['1,0', '1,1', '2,0', '2,1']); +}); + +it('refuses a tool that does not paint and a brush width the editors do not offer', function () { + $session = canvasToolSession(); + + expect(fn() => $session->getToolShape('test-map', 'select', [0, 0], [1, 1], 1))->toThrow(SessionRefusal::class, 'not a painting tool') + ->and(fn() => $session->getToolShape('test-map', 'brush', [0, 0], [0, 0], 4))->toThrow(SessionRefusal::class, 'cells wide'); +}); + +it('fills the region of one glyph, or of one tile counting an autotile as its kind', function () { + $session = canvasToolSession(); + + expect(listShapeCells($session->getFillRegion('test-map', 2, 1, 'map:4', null)))->toBe(['1,1', '2,1']) + // A tile layer the map lacks is empty everywhere. + ->and(listShapeCells($session->getFillRegion('test-map', 0, 0, null, 'ground')))->toHaveCount(8); + + // Two shapes of one autotile kind, and a plain tile beside them. + $session->stampTiles('test-map', $session->readMap('test-map')['revision'], 'ground', [[0, 0, 2048], [1, 0, 2049], [2, 0, 42]]); + expect(listShapeCells($session->getFillRegion('test-map', 0, 0, null, 'ground')))->toBe(['0,0', '1,0']) + ->and(fn() => $session->getFillRegion('test-map', 0, 0, null, null))->toThrow(SessionRefusal::class, 'Name the glyph layer or the tile layer'); +}); From 6485f5bee5bd514481bca68e6a3c55b6138013bc Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 10:05:56 +0200 Subject: [PATCH 215/344] feat(session): say when a field names a picture, and describe a tileset for drawing ReferenceCatalog declares which reference kinds are pictures and where their files live, and every reference row carries that description, so an interface shows any picture field's image in every category instead of guessing from the kind's name. tilesets.preview lays a tileset record out as the tile palette does, with the tiles it marks and its pieces' tiles, following unsaved edits and still showing the sheets of a tileset the Engine would refuse. --- src/Database/ReferenceCatalog.php | 22 ++++++ src/Maps/TilePalette.php | 38 ++++++++++ src/Session/EditorSession.php | 73 +++++++++++++++++++ src/Session/SessionHost.php | 1 + .../Session/TilesetPreviewSessionTest.php | 62 ++++++++++++++++ 5 files changed, 196 insertions(+) create mode 100644 tests/Unit/Session/TilesetPreviewSessionTest.php diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index d96e369..d2a0148 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -98,6 +98,16 @@ final class ReferenceCatalog 'battle_arenas', ]; + /** + * The reference kinds whose values are pictures an author looks at: how + * an interface shows each value, and the folder under the project root + * its values are relative to. An interface shows a value's picture from + * this, never from the kind's name. + */ + private const array MEDIA = [ + 'png_assets' => ['kind' => 'image', 'root' => 'assets'], + ]; + /** * Where each audio kind's files live, under assets/Audio. */ @@ -106,6 +116,18 @@ final class ReferenceCatalog 'sfx' => 'SFX', ]; + /** + * How an interface shows the values of a reference kind, when they are + * pictures: `kind` (`image`) and `root`, the folder under the project + * root the values are relative to. Null for any other kind. + * + * @return array{kind: string, root: string}|null + */ + public static function describeMedia(string $category): ?array + { + return self::MEDIA[$category] ?? null; + } + /** * @param ProjectWorkspace $workspace The project. * @param ProjectMap|null $currentMap The map the author is working in, diff --git a/src/Maps/TilePalette.php b/src/Maps/TilePalette.php index 65c29a5..d057cbc 100644 --- a/src/Maps/TilePalette.php +++ b/src/Maps/TilePalette.php @@ -11,6 +11,7 @@ use Ichiloto\Engine\Rendering\Presentation\PresentationWorld; use Ichiloto\Engine\Rendering\Tilesets\TileId; use Ichiloto\Engine\Rendering\Tilesets\Tileset; +use Ichiloto\Engine\Rendering\Tilesets\TilesetPiece; use Ichiloto\Engine\Rendering\Tilesets\TilesetSheet; /** @@ -65,6 +66,43 @@ public static function buildWorld(Tileset $tileset, array $ids, string $assetRoo return PresentationWorld::getFromLayers($layers, $id, new MapGraphics($tileset, [$tiles]), $assetRoot); } + /** + * A piece drawn as the game draws its tiles, for a picker: its tile + * layers in authored order over a blank grid of the piece's size, or a + * connected piece's shapes laid out as a small room, as its glyph picture + * is ({@see \Ichiloto\Editor\Canvas\PiecePlacer::buildPicture()}). + * Null when the piece draws no tiles. + */ + public static function buildPieceWorld(Tileset $tileset, TilesetPiece $piece, string $assetRoot, string $id): ?PresentationWorld + { + $grids = []; + if ($piece->connects === null) { + foreach ($piece->tiles as $name => $rows) { + $grids[$name] = array_map(static fn(array $row): array => array_map(intval(...), $row), $rows); + } + } else { + foreach ($piece->shapeTiles as $name => $shapes) { + [$corner, $across, $down] = array_map(static fn(string $shape): int => (int) ($shapes[$shape] ?? TileId::EMPTY), ['corner', 'horizontal', 'vertical']); + $grids[$name] = [[$corner, $across, $corner], [$down, TileId::EMPTY, $down], [$corner, $across, $corner]]; + } + } + if ($grids === []) { + return null; + } + $height = max(array_map(count(...), $grids)); + $width = max(array_map(static fn(array $rows): int => max(array_map(count(...), $rows) ?: [0]), $grids)); + $glyphs = new MapLayerSet([new MapLayer(self::LAYER, 1, false, self::LAYER, implode("\n", array_fill(0, $height, str_repeat(' ', $width))))]); + $layers = []; + $order = 1; + foreach ($grids as $name => $rows) { + $padded = array_map(static fn(array $row): string => implode(' ', array_pad($row, $width, TileId::EMPTY)), + array_pad($rows, $height, [])); + $layers[] = new MapTileLayer((string) $name, $order++, (string) $name, implode("\n", $padded)); + } + + return PresentationWorld::getFromLayers($glyphs, $id, new MapGraphics($tileset, $layers), $assetRoot); + } + /** @return list */ private static function getSheetIds(TilesetSheet $sheet): array { diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 01cba2b..2de9149 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -19,6 +19,8 @@ use Ichiloto\Editor\Canvas\ToolGeometry; use Ichiloto\Engine\Rendering\Tilesets\TileId; use Ichiloto\Engine\Rendering\Tilesets\TilesetPiece; +use Ichiloto\Engine\Rendering\Tilesets\Tileset; +use Ichiloto\Engine\Rendering\Tilesets\TilesetSheet; use Ichiloto\Editor\Database\ConditionCodec; use Ichiloto\Editor\Database\ConditionEditor; use Ichiloto\Editor\Animations\LegacyAnimationConversion; @@ -514,6 +516,75 @@ public function readTilePalette(string $mapId): array return ['tileset' => $tileset->id, 'name' => $tileset->name, 'assetRoot' => $map->getAssetRoot(), 'tabs' => $tabs]; } + /** + * A tileset record as an author sees it, unsaved edits included: its + * sheets as the tile palette lays them out ({@see TilePalette}), the tiles + * it marks (drawn above characters, tables, the missing-art tile), and its + * pieces, each with its glyph picture and its tiles drawn. A tileset the + * Engine would refuse still shows the sheets it can, and `issue` says + * what is wrong; a sheet that cannot be drawn is left out and named. + * + * @return array + * @throws SessionRefusal When the record is unknown. + */ + public function readTilesetPreview(int $index): array + { + $record = $this->requireRecordDatabase('tilesets')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('tilesets has no record %d.', $index)); + $assetRoot = rtrim($this->workspace->projectRoot, '/') . '/assets'; + $data = $record->toArray(); + $data = is_array($data) ? $data : (array) $data; + $issue = null; + try { + $tileset = Tileset::fromArray($record->recordId, $data); + } catch (InvalidArgumentException $error) { + $issue = $error->getMessage(); + // The sheets alone, so what can be seen still is. + $sheets = array_filter(is_array($data['sheets'] ?? null) ? $data['sheets'] : [], static fn(mixed $asset, mixed $sheet): bool => is_string($asset) + && TilesetSheet::tryFrom((string) $sheet) !== null && strtolower(pathinfo($asset, PATHINFO_EXTENSION)) === 'png', ARRAY_FILTER_USE_BOTH); + $tileset = new Tileset($record->recordId, trim((string) ($data['name'] ?? '')) ?: $record->recordId, $sheets); + } + $tabs = $unreadable = []; + foreach (TilePalette::getTabs($tileset) as $tab) { + try { + $world = TilePalette::buildWorld($tileset, $tab['ids'], $assetRoot, 'tileset-preview:' . $tab['name']); + $tabs[] = [...$tab, 'operations' => $world->operations]; + } catch (\Throwable $error) { + $unreadable[] = sprintf('Tab %s cannot be drawn: %s', $tab['name'], $error->getMessage()); + } + } + $pieces = []; + foreach ($tileset->pieces as $piece) { + try { + $world = TilePalette::buildPieceWorld($tileset, $piece, $assetRoot, 'tileset-piece:' . $piece->id); + } catch (\Throwable $error) { + $world = null; + $unreadable[] = sprintf('%s\'s tiles cannot be drawn: %s', $piece->name, $error->getMessage()); + } + $pieces[] = [ + 'id' => $piece->id, + 'name' => $piece->name, + 'layerLabel' => MapLayers::formatLabel($piece->layer), + 'connected' => $piece->connects !== null, + 'width' => $piece->width, + 'height' => $piece->height, + 'tileLayers' => array_keys($piece->connects === null ? $piece->tiles : $piece->shapeTiles), + 'picture' => PiecePlacer::buildPicture($piece), + 'operations' => $world?->operations, + ]; + } + + return [ + 'palette' => ['tileset' => $tileset->id, 'name' => $tileset->name, 'assetRoot' => $assetRoot, 'tabs' => $tabs], + 'missingArt' => $tileset->missingArt, + 'above' => $tileset->above, + 'tables' => $tileset->tables, + 'pieces' => $pieces, + 'issue' => $issue, + 'unreadable' => $unreadable, + ]; + } + /** * Each tile layer's tile identities by row, unsaved edits included, in * drawing order: what a tile picker reads. A layer that cannot be read @@ -3827,6 +3898,8 @@ private static function describeRow(array $field): array 'destination' => 'maps', default => null, }, + // What the value looks like, when the reference names a picture: shown with the row and in its picker. + 'media' => $kind === 'reference' ? ReferenceCatalog::describeMedia((string) $field['reference']) : null, // How the choice of nothing reads, where the row says (the Engine's own attack). 'noneLabel' => $kind === 'reference' && is_string($field['noneLabel'] ?? null) ? $field['noneLabel'] : null, 'action' => $kind === 'action' ? $field['action'] : null, diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index f9d5b08..c644316 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -130,6 +130,7 @@ private function dispatch(string $method, array $params): array 'map.world' => $session->readWorld(self::requireString($params, 'map'), is_bool($params['tileShadows'] ?? false) ? ($params['tileShadows'] ?? false) : throw new InvalidRequest('"tileShadows" must be a boolean.')), 'tiles.palette' => $session->readTilePalette(self::requireString($params, 'map')), + 'tilesets.preview' => $session->readTilesetPreview(self::requireInt($params, 'index')), 'tiles.read' => $session->readTiles(self::requireString($params, 'map')), 'tiles.stamp' => $session->stampTiles( self::requireString($params, 'map'), diff --git a/tests/Unit/Session/TilesetPreviewSessionTest.php b/tests/Unit/Session/TilesetPreviewSessionTest.php new file mode 100644 index 0000000..234b1f1 --- /dev/null +++ b/tests/Unit/Session/TilesetPreviewSessionTest.php @@ -0,0 +1,62 @@ + The tileset record's row with the label. */ +function findTilesetRow(EditorSession $session, string $label): array +{ + return array_find($session->readDatabaseRecord('tilesets', 0)['rows'], static fn(array $row): bool => $row['label'] === $label); +} + +it('lays out a tileset record\'s sheets as the tile palette does, with its marks and pieces drawn', function () { + $session = tilesetPreviewSession( + pieces: ['stool' => ['name' => 'Stool', 'layer' => 'buildings', 'glyphs' => ['o'], 'tiles' => ['furniture' => ['5']]]], + missingArt: 255, + ); + $preview = $session->readTilesetPreview(0); + + expect($preview['issue'])->toBeNull() + ->and($preview['unreadable'])->toBe([]) + ->and(array_column($preview['palette']['tabs'], 'name'))->toBe(['A', 'B']) + ->and($preview['palette']['tabs'][1]['operations'])->not->toBeEmpty() + ->and($preview['missingArt'])->toBe(255) + ->and($preview['pieces'][0])->toMatchArray(['id' => 'stool', 'name' => 'Stool', 'connected' => false, 'tileLayers' => ['furniture']]) + ->and($preview['pieces'][0]['operations'])->not->toBeEmpty(); +}); + +it('follows unsaved edits, and still shows the sheets of a tileset the Engine would refuse, saying why', function () { + $session = tilesetPreviewSession(); + $session->applyDatabaseRecord('tilesets', 0, findTilesetRow($session, 'Sheet A2')['key'], ''); + + expect(array_column($session->readTilesetPreview(0)['palette']['tabs'], 'name'))->toBe(['B']); + + $session->applyDatabaseRecord('tilesets', 0, findTilesetRow($session, 'Missing Art Tile')['key'], '3000'); + $refused = $session->readTilesetPreview(0); + expect($refused['issue'])->toContain('missingArt') + ->and(array_column($refused['palette']['tabs'], 'name'))->toBe(['B']); +}); + +it('says how a picture reference\'s value is shown, and nothing for a reference that is not a picture', function () { + $session = tilesetPreviewSession(); + + expect(findTilesetRow($session, 'Sheet B')['media'])->toBe(['kind' => 'image', 'root' => 'assets']) + ->and(findTilesetRow($session, 'Sheet B')['value'])->toBe('Graphics/Tilesets/Home_B.png') + ->and(array_key_exists('media', findTilesetRow($session, 'Name')))->toBeFalse(); +}); From dafcd2e90acf0cf001e29e4c14a2d467a1d18d6b Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 11:11:31 +0200 Subject: [PATCH 216/344] feat(tilesets): mark a tile above characters or as a table from its picture tilesets.mark toggles one tile's mark through the record's own Above Characters or Tables field, one undo step, an autotile marked by its kind as the Engine reads the list. The tileset preview names the tiles shown that can be tables, so an interface offers the mark only where it holds. --- src/Session/EditorSession.php | 41 ++++++++++++++++++- src/Session/SessionHost.php | 5 +++ .../Session/TilesetPreviewSessionTest.php | 31 ++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 2de9149..c6a2a4d 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -519,7 +519,8 @@ public function readTilePalette(string $mapId): array /** * A tileset record as an author sees it, unsaved edits included: its * sheets as the tile palette lays them out ({@see TilePalette}), the tiles - * it marks (drawn above characters, tables, the missing-art tile), and its + * it marks (drawn above characters, tables, the missing-art tile) and + * those that can be tables ({@see toggleTilesetMark()}), and its * pieces, each with its glyph picture and its tiles drawn. A tileset the * Engine would refuse still shows the sheets it can, and `issue` says * what is wrong; a sheet that cannot be drawn is left out and named. @@ -579,12 +580,50 @@ public function readTilesetPreview(int $index): array 'missingArt' => $tileset->missingArt, 'above' => $tileset->above, 'tables' => $tileset->tables, + // The tiles shown that can be tables, so an interface offers the mark only where it holds. + 'tableTiles' => array_values(array_filter(array_merge(...array_map(static fn(array $tab): array => array_merge(...$tab['ids']), $tabs) ?: [[]]), + static fn(int $tile): bool => TileId::getSheet($tile) === TilesetSheet::A2)), 'pieces' => $pieces, 'issue' => $issue, 'unreadable' => $unreadable, ]; } + /** + * Marks one tile of a tileset record as drawn above characters or as a + * table, or takes the mark away when it has it, as one undo step through + * the record's own Above Characters or Tables field, so every view of + * the record shows the same list. An autotile is marked by its kind, + * whatever shape it is, as the Engine reads the list; only A2 autotiles + * can be tables. + * + * @param 'above'|'tables' $mark + * @return array{changed: bool, marked: bool, records: list} + * @throws SessionRefusal When the record or mark is unknown, or the tile cannot take the mark. + */ + public function toggleTilesetMark(int $index, string $mark, int $tile): array + { + if (! in_array($mark, ['above', 'tables'], true)) { + throw new SessionRefusal(sprintf('%s is not a tile mark; a tile is marked above characters or as a table.', $mark)); + } + if ($tile === TileId::EMPTY || ! TileId::isValid($tile)) { + throw new SessionRefusal(sprintf('%d is not a tile, so it cannot be marked.', $tile)); + } + if ($mark === 'tables' && TileId::getSheet($tile) !== TilesetSheet::A2) { + throw new SessionRefusal(sprintf('Only A2 autotiles can be tables; tile %d is on sheet %s.', $tile, TileId::getSheet($tile)?->value ?? '?')); + } + $record = $this->requireRecordDatabase('tilesets')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('tilesets has no record %d.', $index)); + $flag = TileId::getFlagId($tile); + $listed = array_values(array_filter(is_array($record->get($mark)) ? $record->get($mark) : [], is_int(...))); + $kept = array_values(array_filter($listed, static fn(int $listedTile): bool => TileId::getFlagId($listedTile) !== $flag)); + $marked = count($kept) === count($listed); + $result = $this->applyDatabaseRecord('tilesets', $index, ['field' => $mark, 'frame' => []], + implode(', ', $marked ? [...$kept, $flag] : $kept)); + + return [...$result, 'marked' => $marked]; + } + /** * Each tile layer's tile identities by row, unsaved edits included, in * drawing order: what a tile picker reads. A layer that cannot be read diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index c644316..9483dd4 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -131,6 +131,11 @@ private function dispatch(string $method, array $params): array is_bool($params['tileShadows'] ?? false) ? ($params['tileShadows'] ?? false) : throw new InvalidRequest('"tileShadows" must be a boolean.')), 'tiles.palette' => $session->readTilePalette(self::requireString($params, 'map')), 'tilesets.preview' => $session->readTilesetPreview(self::requireInt($params, 'index')), + 'tilesets.mark' => $session->toggleTilesetMark( + self::requireInt($params, 'index'), + self::requireString($params, 'mark'), + self::requireInt($params, 'tile'), + ), 'tiles.read' => $session->readTiles(self::requireString($params, 'map')), 'tiles.stamp' => $session->stampTiles( self::requireString($params, 'map'), diff --git a/tests/Unit/Session/TilesetPreviewSessionTest.php b/tests/Unit/Session/TilesetPreviewSessionTest.php index 234b1f1..ed61bce 100644 --- a/tests/Unit/Session/TilesetPreviewSessionTest.php +++ b/tests/Unit/Session/TilesetPreviewSessionTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Ichiloto\Editor\Session\EditorSession; +use Ichiloto\Editor\Session\SessionRefusal; /** * A tileset is seen as it is set up, and every field naming a picture says @@ -37,6 +38,9 @@ function findTilesetRow(EditorSession $session, string $label): array ->and(array_column($preview['palette']['tabs'], 'name'))->toBe(['A', 'B']) ->and($preview['palette']['tabs'][1]['operations'])->not->toBeEmpty() ->and($preview['missingArt'])->toBe(255) + // Only the A2 autotiles shown can be tables. + ->and($preview['tableTiles'])->not->toBeEmpty() + ->and(array_filter($preview['tableTiles'], static fn(int $tile): bool => $tile < 2816 || $tile >= 4352))->toBe([]) ->and($preview['pieces'][0])->toMatchArray(['id' => 'stool', 'name' => 'Stool', 'connected' => false, 'tileLayers' => ['furniture']]) ->and($preview['pieces'][0]['operations'])->not->toBeEmpty(); }); @@ -60,3 +64,30 @@ function findTilesetRow(EditorSession $session, string $label): array ->and(findTilesetRow($session, 'Sheet B')['value'])->toBe('Graphics/Tilesets/Home_B.png') ->and(array_key_exists('media', findTilesetRow($session, 'Name')))->toBeFalse(); }); + +it('marks a tile above characters or as a table with a click, and a second click takes the mark away, each one undo step', function () { + $session = tilesetPreviewSession(); + + // Any shape of an autotile marks its kind, as the Engine reads the list. + expect($session->toggleTilesetMark(0, 'above', 2816 + 5))->toMatchArray(['changed' => true, 'marked' => true]) + ->and($session->readTilesetPreview(0)['above'])->toBe([2816]) + ->and(findTilesetRow($session, 'Above Characters')['value'])->toBe('2816'); + $session->toggleTilesetMark(0, 'above', 7); + $session->toggleTilesetMark(0, 'tables', 2816); + expect($session->readTilesetPreview(0))->toMatchArray(['above' => [2816, 7], 'tables' => [2816], 'issue' => null]); + + expect($session->toggleTilesetMark(0, 'above', 2816))->toMatchArray(['marked' => false]) + ->and($session->readTilesetPreview(0)['above'])->toBe([7]); + $session->undo(); + expect($session->readTilesetPreview(0)['above'])->toBe([2816, 7]) + ->and($session->listDatabaseRecords('tilesets')['dirty'])->toBeTrue(); +}); + +it('refuses a mark the tile cannot take, changing nothing', function () { + $session = tilesetPreviewSession(); + + expect(fn() => $session->toggleTilesetMark(0, 'tables', 7))->toThrow(SessionRefusal::class, 'Only A2 autotiles can be tables') + ->and(fn() => $session->toggleTilesetMark(0, 'above', 0))->toThrow(SessionRefusal::class, 'cannot be marked') + ->and(fn() => $session->toggleTilesetMark(0, 'counter', 7))->toThrow(SessionRefusal::class, 'not a tile mark') + ->and($session->listDatabaseRecords('tilesets')['dirty'])->toBeFalse(); +}); From 8b9d842cc1e7b9fc36c18f7305f252fe901be1bd Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 11:47:45 +0200 Subject: [PATCH 217/344] feat(audio): play a music or sound field through the game's own audio AudioAudition extends the Engine's audio manager, so a track is found and played as the game finds and plays it, one at a time, once through, and stopped when the editor closes. ReferenceCatalog describes bgm and sfx values as sounds, and the session answers audio.play, audio.stop and audio.status. Tests use a silent stand-in player. --- src/Audio/AudioAudition.php | 104 +++++++++++++++++++++++++ src/Database/ReferenceCatalog.php | 19 +++-- src/Session/EditorSession.php | 51 ++++++++++++ src/Session/SessionHost.php | 3 + tests/Unit/Audio/AudioAuditionTest.php | 89 +++++++++++++++++++++ 5 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 src/Audio/AudioAudition.php create mode 100644 tests/Unit/Audio/AudioAuditionTest.php diff --git a/src/Audio/AudioAudition.php b/src/Audio/AudioAudition.php new file mode 100644 index 0000000..33b219d --- /dev/null +++ b/src/Audio/AudioAudition.php @@ -0,0 +1,104 @@ + 'BGM', 'sfx' => 'SFX']; + + private ?AudioPlayback $playback = null; + + /** What is playing, as kind and name, while it plays. */ + private ?array $playing = null; + + /** @var list|null The players to use instead of this computer's, as a test gives them. */ + private ?array $players; + + /** @param list|null $players The players to use instead of this computer's; tests give silent ones. */ + public function __construct(Game $game, private readonly string $projectRoot, ?array $players = null) + { + $this->players = $players; + parent::__construct($game); + } + + protected function createBackends(): array + { + return $this->players ?? parent::createBackends(); + } + + /** + * Plays a track or sound by the name a field stores, stopping any other. + * + * @param 'bgm'|'sfx' $kind + * @return string|null Why it cannot play, or null when it plays. + */ + public function play(string $kind, string $name): ?string + { + $this->stop(); + $folder = self::FOLDERS[$kind] ?? null; + if ($folder === null) { + return sprintf('%s is not music or a sound effect.', $kind); + } + if (trim($name) === '' || str_contains($name, '..')) { + return 'Choose a track first.'; + } + $file = $this->resolveAudioPath(rtrim($this->projectRoot, '/') . "/assets/Audio/{$folder}/{$name}", $folder); + if ($file === null) { + return sprintf('%s was not found in assets/Audio/%s.', $name, $folder); + } + $backend = $this->selectBackend($file); + if ($backend === null) { + return sprintf('No audio player on this computer can play %s.', basename($file)); + } + $this->playback = $this->spawn($backend->buildCommand($file, 1.0, false)); + if ($this->playback === null) { + return sprintf('%s could not be started.', $backend->getExecutableName()); + } + $this->playing = ['kind' => $kind, 'name' => $name]; + + return null; + } + + /** Stops the audition, if one is playing. */ + public function stop(): void + { + $this->playback?->stop(); + $this->playback = null; + $this->playing = null; + } + + /** + * What is playing, as kind and name; null once it has finished or been stopped. + * + * @return array{kind: string, name: string}|null + */ + public function describePlaying(): ?array + { + if ($this->playback !== null && ! $this->playback->isRunning) { + $this->stop(); + } + + return $this->playing; + } + + public function shutdown(): void + { + $this->stop(); + parent::shutdown(); + } +} diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index d2a0148..19a51cc 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -99,13 +99,17 @@ final class ReferenceCatalog ]; /** - * The reference kinds whose values are pictures an author looks at: how - * an interface shows each value, and the folder under the project root - * its values are relative to. An interface shows a value's picture from - * this, never from the kind's name. + * The reference kinds whose values are pictures an author looks at or + * sounds an author listens to: how an interface shows or plays each + * value, and the folder under the project root its values are found in. + * An interface knows a value is a picture or a sound from this, never + * from the kind's name. */ private const array MEDIA = [ 'png_assets' => ['kind' => 'image', 'root' => 'assets'], + // Music and sound are named without their extension; the game's players find the file and play it. + 'bgm' => ['kind' => 'audio', 'root' => 'assets/Audio/BGM'], + 'sfx' => ['kind' => 'audio', 'root' => 'assets/Audio/SFX'], ]; /** @@ -117,9 +121,10 @@ final class ReferenceCatalog ]; /** - * How an interface shows the values of a reference kind, when they are - * pictures: `kind` (`image`) and `root`, the folder under the project - * root the values are relative to. Null for any other kind. + * How an interface shows or plays the values of a reference kind, when + * they are pictures or sounds: `kind` (`image` or `audio`) and `root`, + * the folder under the project root the values are found in. Null for + * any other kind. * * @return array{kind: string, root: string}|null */ diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index c6a2a4d..ff16e97 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -15,6 +15,8 @@ use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\Canvas\PieceRole; use Ichiloto\Editor\Canvas\PiecePlacer; +use Ichiloto\Editor\Audio\AudioAudition; +use Ichiloto\Editor\Cutscenes\Preview\PreviewGame; use Ichiloto\Editor\Canvas\CanvasTool; use Ichiloto\Editor\Canvas\ToolGeometry; use Ichiloto\Engine\Rendering\Tilesets\TileId; @@ -133,6 +135,9 @@ final class EditorSession private ?PlaytestRun $playtest = null; /** The battle test running in its own window, and the troop it fights. */ private ?PlaytestRun $battleTestRun = null; + + /** The music or sound an author is hearing from a field, once one has been played. */ + private ?AudioAudition $audition = null; private ?string $battleTestTroop = null; /** * The summon battle previews last built, by presentation, and what each was built from, so seeking reuses them until either changes. @@ -3775,6 +3780,52 @@ public function close(): void { $this->playtest?->stop(); $this->battleTestRun?->stop(); + $this->audition?->stop(); + } + + /** + * Plays a music track or sound effect a field names, once through, to + * hear it ({@see AudioAudition}): the game's own players find and play + * the file. Another audition, or stopping, ends it. + * + * @param 'bgm'|'sfx' $kind The reference kind of the field. + * @return array{playing: array{kind: string, name: string}|null} + * @throws SessionRefusal When the kind is not audio, the file is missing or nothing on this computer can play it. + */ + public function playAudio(string $kind, string $name): array + { + if ((ReferenceCatalog::describeMedia($kind)['kind'] ?? null) !== 'audio') { + throw new SessionRefusal(sprintf('%s is not music or a sound effect.', $kind)); + } + $this->audition ??= new AudioAudition(new PreviewGame(), $this->workspace->projectRoot); + $refusal = $this->audition->play($kind, $name); + if ($refusal !== null) { + throw new SessionRefusal($refusal); + } + + return $this->describeAudio(); + } + + /** + * Stops the audition, if one is playing. + * + * @return array{playing: null} + */ + public function stopAudio(): array + { + $this->audition?->stop(); + + return ['playing' => null]; + } + + /** + * What the audition is playing, as kind and name; null once it has finished. + * + * @return array{playing: array{kind: string, name: string}|null} + */ + public function describeAudio(): array + { + return ['playing' => $this->audition?->describePlaying()]; } /** Whether any map or database has changes not yet saved. */ diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 9483dd4..1051dad 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -136,6 +136,9 @@ private function dispatch(string $method, array $params): array self::requireString($params, 'mark'), self::requireInt($params, 'tile'), ), + 'audio.play' => $session->playAudio(self::requireString($params, 'kind'), self::requireString($params, 'name')), + 'audio.stop' => $session->stopAudio(), + 'audio.status' => $session->describeAudio(), 'tiles.read' => $session->readTiles(self::requireString($params, 'map')), 'tiles.stamp' => $session->stampTiles( self::requireString($params, 'map'), diff --git a/tests/Unit/Audio/AudioAuditionTest.php b/tests/Unit/Audio/AudioAuditionTest.php new file mode 100644 index 0000000..f966abe --- /dev/null +++ b/tests/Unit/Audio/AudioAuditionTest.php @@ -0,0 +1,89 @@ + */ + public array $files = []; + + public function __construct(private float $seconds) {} + public function getExecutableName(): string { return 'sleep'; } + public function isAvailable(): bool { return true; } + public function supportsNativeLooping(): bool { return false; } + public function supports(string $filePath): bool { return str_ends_with($filePath, '.ogg'); } + public function supportsSeeking(): bool { return false; } + public function buildCommand(string $filePath, float $volume, bool $loop, float $startAtSeconds = 0.0): array + { + $this->files[] = $filePath; + + return ['sleep', (string) $this->seconds]; + } + }; +} + +/** A project with one music track, `theme.ogg`, and one sound the stand-in player cannot play. */ +function auditionProject(): string +{ + $root = rememberTemporaryProject(sys_get_temp_dir() . '/ichiloto-audition-' . bin2hex(random_bytes(4))); + mkdir($root . '/assets/Audio/BGM', 0o777, true); + mkdir($root . '/assets/Audio/SFX', 0o777, true); + touch($root . '/assets/Audio/BGM/theme.ogg'); + touch($root . '/assets/Audio/SFX/chime.wav'); + + return $root; +} + +it('finds a track by the name a field stores, as the game does, and plays it until it ends or is stopped', function () { + $root = auditionProject(); + $player = silentAuditionPlayer(5); + $audition = new AudioAudition(new PreviewGame(), $root, [$player]); + + expect($audition->play('bgm', 'theme'))->toBeNull() + ->and($player->files)->toBe([$root . '/assets/Audio/BGM/theme.ogg']) + ->and($audition->describePlaying())->toBe(['kind' => 'bgm', 'name' => 'theme']); + $audition->stop(); + expect($audition->describePlaying())->toBeNull(); + + $finishing = new AudioAudition(new PreviewGame(), $root, [silentAuditionPlayer(0)]); + $finishing->play('bgm', 'theme'); + usleep(300_000); + expect($finishing->describePlaying())->toBeNull(); +}); + +it('says why a track cannot play: missing, unplayable here, or not chosen', function () { + $audition = new AudioAudition(new PreviewGame(), auditionProject(), [silentAuditionPlayer(5)]); + + expect($audition->play('bgm', 'missing'))->toBe('missing was not found in assets/Audio/BGM.') + ->and($audition->play('sfx', 'chime'))->toBe('No audio player on this computer can play chime.wav.') + ->and($audition->play('bgm', ''))->toBe('Choose a track first.') + ->and($audition->play('bgm', '../BGM/theme'))->toBe('Choose a track first.') + ->and($audition->describePlaying())->toBeNull(); +}); + +it('marks music and sound fields as sounds to play, and refuses what is not one', function () { + // A project with no audio at all, so nothing this session is asked for can be found, let alone heard. + $session = EditorSession::open(mapGraphicsProject()); + + expect(\Ichiloto\Editor\Database\ReferenceCatalog::describeMedia('bgm'))->toBe(['kind' => 'audio', 'root' => 'assets/Audio/BGM']) + ->and(\Ichiloto\Editor\Database\ReferenceCatalog::describeMedia('sfx'))->toBe(['kind' => 'audio', 'root' => 'assets/Audio/SFX']) + ->and(fn() => $session->playAudio('maps', 'theme'))->toThrow(SessionRefusal::class, 'not music or a sound effect') + ->and(fn() => $session->playAudio('bgm', 'missing'))->toThrow(SessionRefusal::class, 'was not found') + ->and($session->describeAudio())->toBe(['playing' => null]) + ->and($session->stopAudio())->toBe(['playing' => null]); +}); From c695f09110e7ff189208e1e6ac687212544e4986 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 21:18:21 +0200 Subject: [PATCH 218/344] feat(session): draw a layer's tiles, count untiled glyphs and set an NPC's field sheet Drawing the tiles for the glyphs already on a layer moves into CanvasEditor, which the terminal editor's T now uses, and the session offers it as layer.drawTiles with the same piece question. map.coverage lists the cells whose glyph still shows in the graphical field, from the validator's own rule. The graphical editor's NPC page gains a Field Sheet picture row, written through NpcAuthoring::setFieldSheet; a sheet the game could not read is refused like any other edit the map cannot take. --- src/Canvas/CanvasEditor.php | 44 ++++++++ src/Canvas/GlyphTileCanvas.php | 36 ++---- src/Field/NpcAuthoring.php | 20 ++++ src/Field/NpcSpriteArt.php | 7 +- src/Session/EditorSession.php | 104 +++++++++++++++++- src/Session/SessionHost.php | 7 ++ src/Validation/MapGraphicsValidator.php | 63 ++++++++--- .../Session/DrawLayerTilesSessionTest.php | 84 ++++++++++++++ .../Unit/Session/NpcFieldSheetSessionTest.php | 64 +++++++++++ 9 files changed, 382 insertions(+), 47 deletions(-) create mode 100644 tests/Unit/Session/DrawLayerTilesSessionTest.php create mode 100644 tests/Unit/Session/NpcFieldSheetSessionTest.php diff --git a/src/Canvas/CanvasEditor.php b/src/Canvas/CanvasEditor.php index f3181f3..fc0f44b 100644 --- a/src/Canvas/CanvasEditor.php +++ b/src/Canvas/CanvasEditor.php @@ -66,6 +66,50 @@ public static function plan(ProjectMap $map, string $layerId, array $writes, arr ); } + /** + * Draws the tiles of the glyphs already on a layer, as if each were + * painted again: every glyph a piece draws gets that piece's tiles, as one + * step the caller records. A glyph that could be several pieces is + * reported unresolved, as painting does, and its answer in `$choices` + * applies wherever its neighbours do not decide. Glyphs no piece draws, + * and the glyphs themselves, are left as they are. + * + * @param array $choices The role key chosen for a glyph, or null for no tiles. + * @return array{command: ?Command, cells: int, unresolved: array>}|null + * The applied step and how many tiles it drew; or, when a glyph must be asked about, nothing applied and the + * roles of the first glyph unresolved. Null when no piece in the map's kind draws this layer's glyphs. + * @throws MapSourceRefusal When the tile layers cannot take the tiles; nothing is written then. + */ + public static function drawTilesForGlyphs(ProjectMap $map, string $layerId, array $choices = []): ?array + { + $writes = []; + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x < $map->getWidth(); $x++) { + if ($map->hasLayerCell($layerId, $x, $y) && ($symbol = $map->getLayerSymbol($layerId, $x, $y)) !== ' ') { + $writes[] = ['x' => $x, 'y' => $y, 'symbol' => $symbol]; + } + } + } + $plan = self::plan($map, $layerId, $writes, $choices, true); + if ($plan === null) { + return null; + } + if ($plan['unresolved'] !== []) { + return ['command' => null, 'cells' => 0, 'unresolved' => $plan['unresolved']]; + } + $before = $map->getTileLayerSources(); + $map->writeTileCells($plan['tiles']); + $after = $map->getTileLayerSources(); + $layer = array_find($map->getLayers(), static fn(array $candidate): bool => $candidate['id'] === $layerId); + $command = $after === $before ? null : new GenericCommand( + sprintf('Draw %s tiles', MapLayers::formatLabel((string) ($layer['name'] ?? $layerId))), + static fn() => $map->restoreTileLayerSources($after), + static fn() => $map->restoreTileLayerSources($before), + ); + + return ['command' => $command, 'cells' => $command === null ? 0 : array_sum(array_map(count(...), $plan['tiles'])), 'unresolved' => []]; + } + /** * Writes glyphs onto a layer and tiles onto the tile layers, and returns * the command that undoes and redoes both, already applied. The command diff --git a/src/Canvas/GlyphTileCanvas.php b/src/Canvas/GlyphTileCanvas.php index b53b83f..c9098cf 100644 --- a/src/Canvas/GlyphTileCanvas.php +++ b/src/Canvas/GlyphTileCanvas.php @@ -5,7 +5,6 @@ namespace Ichiloto\Editor\Canvas; use Closure; -use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\ProjectMap; @@ -157,50 +156,31 @@ private function drawTilesForLayerGlyphs(array $choices = []): void $layerId = $this->getActiveCanvasLayer(); $layer = array_find($map->getLayers(), static fn(array $candidate): bool => $candidate['id'] === $layerId); $label = MapLayers::formatLabel((string) ($layer['name'] ?? $layerId)); - $writes = []; - for ($y = 0; $y < $map->getHeight(); $y++) { - for ($x = 0; $x < $map->getWidth(); $x++) { - if ($map->hasLayerCell($layerId, $x, $y) && ($symbol = $map->getLayerSymbol($layerId, $x, $y)) !== ' ') { - $writes[] = ['x' => $x, 'y' => $y, 'symbol' => $symbol]; - } - } - } try { - $plan = $this->planGlyphTiles($map, $layerId, $writes, $choices, true); + $drawn = CanvasEditor::drawTilesForGlyphs($map, $layerId, $choices); } catch (MapSourceRefusal | InvalidArgumentException | RuntimeException $refusal) { $this->setStatus('Tiles were not drawn: ' . $refusal->getMessage(), StatusLevel::WARN); $this->renderFooter(); return; } - if ($plan === null) { + if ($drawn === null) { $this->setStatus(sprintf('The %s layer has no pieces in this map\'s kind to draw tiles for.', $label), StatusLevel::WARN); $this->renderFooter(); return; } - if ($plan['unresolved'] !== []) { - $glyph = (string) array_key_first($plan['unresolved']); - $this->askForGlyphPiece($glyph, $plan['unresolved'][$glyph], $choices, + if ($drawn['unresolved'] !== []) { + $glyph = (string) array_key_first($drawn['unresolved']); + $this->askForGlyphPiece($glyph, $drawn['unresolved'][$glyph], $choices, fn(array $chosen) => $this->drawTilesForLayerGlyphs($chosen)); return; } - $before = $map->getTileLayerSources(); - try { - $map->writeTileCells($plan['tiles']); - } catch (MapSourceRefusal $refusal) { - $this->setStatus($refusal->getMessage(), StatusLevel::WARN); - $this->renderFooter(); - return; - } - $after = $map->getTileLayerSources(); - if ($after === $before) { + if ($drawn['command'] === null) { $this->setStatus(sprintf('Every glyph on the %s layer already has its tiles.', $label)); $this->renderFooter(); return; } - $this->recordCommand(new GenericCommand(sprintf('Draw %s tiles', $label), - static fn() => $map->restoreTileLayerSources($after), - static fn() => $map->restoreTileLayerSources($before))); - $cells = array_sum(array_map(count(...), $plan['tiles'])); + $this->recordCommand($drawn['command']); + $cells = $drawn['cells']; $this->setStatus(sprintf('Drew %d tile%s for the %s layer\'s glyphs; save to keep them.', $cells, $cells === 1 ? '' : 's', $label), StatusLevel::INFO); $this->renderCanvasArea(); diff --git a/src/Field/NpcAuthoring.php b/src/Field/NpcAuthoring.php index f306914..dadff67 100644 --- a/src/Field/NpcAuthoring.php +++ b/src/Field/NpcAuthoring.php @@ -383,6 +383,26 @@ private function requireFrame(NpcInspector $inspector, NpcCollection $npcs, int } } + /** + * Gives an NPC the RPG Maker character sheet it walks with in the + * graphical field, or takes it away (null), as one undo step. The sheet's + * other settings are kept, the terminal sprite is untouched, and the sheet + * is checked as the game will read it ({@see ProjectMap::setNpcGraphicalSprites()}). + * + * @throws NpcRefusal When there is no such NPC. + * @throws MapSourceRefusal When the sheet is not a usable character sheet or the map's source cannot take the change. + */ + public function setFieldSheet(ProjectMap $map, int $index, ?string $sheet): NpcChange + { + $before = $map->getNpcs(); + $npc = $this->requireNpc($map, $before, $index); + $current = $npc->toArray()['sprites2d'] ?? null; + $sheet = $sheet === null ? '' : trim($sheet); + $map->setNpcGraphicalSprites($index, $sheet === '' ? null : [...(is_array($current) ? $current : []), 'sheet' => $sheet]); + + return $this->createRowChange($map, 'NPC field sheet', $before, $index); + } + /** The change a row edit made to one NPC, with its command when it changed anything. */ private function createRowChange(ProjectMap $map, string $label, NpcCollection $before, int $index): NpcChange { diff --git a/src/Field/NpcSpriteArt.php b/src/Field/NpcSpriteArt.php index adf732e..fbfa18a 100644 --- a/src/Field/NpcSpriteArt.php +++ b/src/Field/NpcSpriteArt.php @@ -25,7 +25,12 @@ public function setNpcGraphicalSprites(int $index, ?array $data): void } if ($node !== null) { $this->assertNpcArtLiteral($node, true); } if ($data !== null) { - $sheet = NpcCharacterSheet::validate($data, dirname($this->getMapsRoot())); + try { + $sheet = NpcCharacterSheet::validate($data, dirname($this->getMapsRoot())); + } catch (\InvalidArgumentException | \RuntimeException $invalid) { + // A sheet the game could not read is refused like any other edit the map cannot take. + throw new MapSourceRefusal($invalid->getMessage(), previous: $invalid); + } if (! in_array($sheet->asset, ReferenceCatalog::getPngAssets(dirname($this->getMapsRoot(), 2)), true)) { throw new MapSourceRefusal('Choose the character sheet through the project asset picker.'); } diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index ff16e97..836abcf 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -50,6 +50,7 @@ use Ichiloto\Editor\Field\NpcChange; use Ichiloto\Editor\Field\NpcInspector; use Ichiloto\Editor\Field\NpcRefusal; +use Ichiloto\Editor\Field\ProjectNpc; use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\CommandHistory; use Ichiloto\Editor\Inspector\InputControlType; @@ -70,6 +71,7 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Storage\WorkspaceSave; +use Ichiloto\Editor\Validation\MapGraphicsValidator; use Ichiloto\Editor\Validation\MapValidator; use Ichiloto\Engine\IO\Console\TerminalPresentationComposer; use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; @@ -917,6 +919,70 @@ private function requirePiece(ProjectMap $map, string $pieceId): TilesetPiece return $pieces[$pieceId] ?? throw new SessionRefusal(sprintf("%s is no longer in the map's tileset. Choose a piece again.", $pieceId)); } + /** + * The cells of a map whose glyph still shows in the graphical field, by + * the Engine's glyph fallback rule ({@see MapGraphicsValidator::listShownGlyphCells()}), + * unsaved edits included: what is left to tile. `available` is false for + * a map without a kind, or whose kind or tile layers cannot be read. + * + * @return array{map: string, revision: int, available: bool, cells: list} + * @throws SessionRefusal When the map is unknown. + */ + public function readCoverage(string $mapId): array + { + $map = $this->requireMap($mapId); + $cells = MapGraphicsValidator::listShownGlyphCells($map); + + return [ + 'map' => $map->mapId, + 'revision' => $this->getMapRevision($map), + 'available' => $cells !== null, + 'cells' => array_map(static fn(array $cell): array => [$cell['x'], $cell['y'], $cell['glyph'], $cell['layer']], $cells ?? []), + ]; + } + + /** + * Draws the tiles of the glyphs already on a layer as one undo step + * ({@see CanvasEditor::drawTilesForGlyphs()}): every glyph a piece of the + * map's kind draws gets that piece's tiles. A glyph that could be several + * pieces is asked about, and nothing changes until the edit is made again + * with the answer in `$choices` (a role key, or null for no tiles). + * + * @param array $choices + * @return array{status: 'applied', cells: int, revision: int}|array{status: 'question', glyph: string, roles: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, the layer is unknown or no piece draws its glyphs. + */ + public function drawLayerTiles(string $mapId, int $revision, string $layerId, array $choices = []): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + $layer = array_find($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $layerId) + ?? throw new SessionRefusal(sprintf('%s has no layer %s.', $mapId, $layerId)); + try { + $drawn = CanvasEditor::drawTilesForGlyphs($map, $layerId, $choices); + } catch (MapSourceRefusal | InvalidArgumentException | \RuntimeException $refusal) { + throw new SessionRefusal('Tiles were not drawn: ' . $refusal->getMessage(), previous: $refusal); + } + if ($drawn === null) { + throw new SessionRefusal(sprintf('The %s layer has no pieces in this map\'s kind to draw tiles for.', MapLayers::formatLabel((string) $layer['name']))); + } + if ($drawn['unresolved'] !== []) { + $glyph = (string) array_key_first($drawn['unresolved']); + + return ['status' => 'question', 'glyph' => $glyph, 'roles' => array_map( + static fn(PieceRole $role): array => ['key' => $role->key, 'label' => $role->label], + $drawn['unresolved'][$glyph], + )]; + } + if ($drawn['command'] !== null) { + $this->history->record($drawn['command']); + } + + return ['status' => 'applied', 'cells' => $drawn['cells'], 'revision' => $this->getMapRevision($map)]; + } + /** * Paints one glyph over cells of a layer, with the tiles that follow it, * as one undo step. A glyph that could be several pieces is asked about: @@ -3253,6 +3319,7 @@ public function readNpc(string $mapId, int $index, array $frame = []): array $inspector = new NpcInspector($map); $npc = $map->getNpcs()->get($index) ?? throw new SessionRefusal(sprintf('%s has no NPC %d.', $mapId, $index)); $fields = $this->collectNpcFields($inspector, $index, $frame); + $rows = array_map(static fn(array $field): array => self::describeNpcRow($inspector->records(), $index, $field, $frame), $fields); return [ 'map' => $mapId, @@ -3268,10 +3335,41 @@ public function readNpc(string $mapId, int $index, array $frame = []): array 'y' => $npc->getY(), 'sprite' => $npc->getVisibleSprite(), ], - 'rows' => array_map(static fn(array $field): array => self::describeNpcRow($inspector->records(), $index, $field, $frame), $fields), + 'rows' => $frame === [] ? self::insertNpcFieldSheetRow($rows, $npc) : $rows, ]; } + /** The field id of an NPC's graphical character sheet, a row only the graphical editor shows. */ + private const string NPC_FIELD_SHEET = 'sprites2d.sheet'; + + /** + * An NPC's rows with its graphical character sheet after its terminal + * appearance: a picture picked from the project's images, set through + * {@see NpcAuthoring::setFieldSheet()}. The terminal editor's rows are + * its own; graphical art never changes them. + * + * @param list> $rows + * @return list> + */ + private static function insertNpcFieldSheetRow(array $rows, ProjectNpc $npc): array + { + $sprites = $npc->toArray()['sprites2d'] ?? null; + $row = self::describeRow([ + 'label' => ' Field Sheet', + 'field' => self::NPC_FIELD_SHEET, + 'value' => is_array($sprites) && is_string($sprites['sheet'] ?? null) ? $sprites['sheet'] : '', + 'reference' => 'png_assets', + 'noneLabel' => 'None: the glyph shows', + 'target' => 'npc', + ]); + $row['key'] = [...$row['key'], 'frame' => []]; + $appearance = array_keys(array_filter($rows, static fn(array $candidate): bool => + in_array($candidate['key']['field'] ?? null, ['sprite', 'sprites.north', 'sprites.south', 'sprites.east', 'sprites.west'], true))); + $at = $appearance === [] ? count($rows) : max($appearance) + 1; + + return [...array_slice($rows, 0, $at), $row, ...array_slice($rows, $at)]; + } + /** * Applies one NPC row's edit as one undo step. The row is found again by * its key among the NPC's current rows. A rename carries the id along @@ -3287,6 +3385,10 @@ public function applyNpc(string $mapId, int $revision, int $index, array $key, s $inspector = new NpcInspector($map); $frame = self::requireNpcFrame($key['frame'] ?? []); $fieldId = $key['field'] ?? null; + if ($fieldId === self::NPC_FIELD_SHEET && $frame === []) { + return $this->applyNpcChange($map, + static fn(NpcAuthoring $authoring): NpcChange => $authoring->setFieldSheet($map, $index, $value === '' ? null : $value)); + } // Field ids are unique within a frame, so the field and frame name the row. $field = is_string($fieldId) ? array_find($this->collectNpcFields($inspector, $index, $frame), static fn(array $candidate): bool => ($candidate['field'] ?? null) === $fieldId) : null; diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 1051dad..671c204 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -188,6 +188,13 @@ private function dispatch(string $method, array $params): array self::requireCell($params, 'to'), self::readOptionalString($params, 'color'), ), + 'map.coverage' => $session->readCoverage(self::requireString($params, 'map')), + 'layer.drawTiles' => $session->drawLayerTiles( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + self::readChoices($params), + ), 'map.paint' => $session->paint( self::requireString($params, 'map'), self::requireInt($params, 'revision'), diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php index 5f730a7..9274b31 100644 --- a/src/Validation/MapGraphicsValidator.php +++ b/src/Validation/MapGraphicsValidator.php @@ -92,26 +92,13 @@ public static function validate(ProjectMap $map): array */ public static function validateCoverage(ProjectMap $map): array { - $ignored = []; - $tileset = is_string($tilesetId = $map->getMapDataField(['tileset'])) ? self::loadTileset($map, $tilesetId, $ignored) : null; - if ($tileset === null) { + $coverage = self::readCoverage($map); + if ($coverage === null) { return []; } - $layers = self::readLayers($map, $map->getTileLayerSources(), $ignored); - - try { - $layerSet = $map->getLayerSet(); - $owners = MapGraphics::resolveLayerOwners($map->getMapDataField([MapGraphics::SETTINGS_KEY]), - array_map(static fn(MapTileLayer $layer): string => $layer->name, $layers), - array_column(array_filter($map->getLayers(), static fn(array $layer): bool => - $layer['id'] !== MapLayers::EVENT && ! $layer['decoration']), 'name'), $tileset, $map->mapId); - } catch (Throwable) { - // The terminal layers' and tile layer settings' own checks report why. - return []; - } - + [$tileset, $layers, $shownCells] = $coverage; $shown = []; - foreach (new MapGraphics($tileset, $layers, owners: $owners)->getShownGlyphCells($layerSet, $map->getAssetRoot()) as $cell) { + foreach ($shownCells as $cell) { $shown[$cell['y']][$cell['x']] = $cell; } @@ -169,6 +156,48 @@ public static function validateCoverage(ProjectMap $map): array return $issues; } + /** + * The cells whose glyph still shows in the graphical field, by the + * Engine's glyph fallback rule ({@see MapGraphics::getShownGlyphCells()}), + * in row order. Null when the map has no kind, or its kind or tile layers + * cannot be read; {@see validate()} reports why. + * + * @return list|null + */ + public static function listShownGlyphCells(ProjectMap $map): ?array + { + return self::readCoverage($map)[2] ?? null; + } + + /** + * The map's tileset, its readable tile layers and the cells whose glyph + * still shows; null when they cannot be read. + * + * @return array{0: Tileset, 1: list, 2: list}|null + */ + private static function readCoverage(ProjectMap $map): ?array + { + $ignored = []; + $tileset = is_string($tilesetId = $map->getMapDataField(['tileset'])) ? self::loadTileset($map, $tilesetId, $ignored) : null; + if ($tileset === null) { + return null; + } + $layers = self::readLayers($map, $map->getTileLayerSources(), $ignored); + + try { + $layerSet = $map->getLayerSet(); + $owners = MapGraphics::resolveLayerOwners($map->getMapDataField([MapGraphics::SETTINGS_KEY]), + array_map(static fn(MapTileLayer $layer): string => $layer->name, $layers), + array_column(array_filter($map->getLayers(), static fn(array $layer): bool => + $layer['id'] !== MapLayers::EVENT && ! $layer['decoration']), 'name'), $tileset, $map->mapId); + } catch (Throwable) { + // The terminal layers' and tile layer settings' own checks report why. + return null; + } + + return [$tileset, $layers, new MapGraphics($tileset, $layers, owners: $owners)->getShownGlyphCells($layerSet, $map->getAssetRoot())]; + } + /** * Warns for tiles a tileset piece draws that no glyph on the map accounts * for, left behind when their glyph went without them (an edit outside the diff --git a/tests/Unit/Session/DrawLayerTilesSessionTest.php b/tests/Unit/Session/DrawLayerTilesSessionTest.php new file mode 100644 index 0000000..8892600 --- /dev/null +++ b/tests/Unit/Session/DrawLayerTilesSessionTest.php @@ -0,0 +1,84 @@ +readTiles('test-map')['layers'], static fn(array $found): bool => $found['name'] === $layer)['rows'] ?? []; +} + +it('draws every glyph its piece\'s tiles in one undo step, and nothing more when they are already there', function () { + $session = drawLayerTilesSession([ + 'ramp' => ['name' => 'Ramp', 'layer' => 'buildings', 'glyphs' => ['/'], 'tiles' => ['furniture' => ['60']]], + 'crate' => ['name' => 'Crate', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['61']]], + ]); + $revision = $session->readMap('test-map')['revision']; + + expect($session->drawLayerTiles('test-map', $revision, 'map:4'))->toMatchArray(['status' => 'applied', 'cells' => 3]) + ->and(readDrawnTiles($session, 'furniture'))->toBe([[0, 60, 0, 0], [0, 61, 61, 0]]) + ->and($session->drawLayerTiles('test-map', $session->readMap('test-map')['revision'], 'map:4'))->toMatchArray(['cells' => 0]); + + $session->undo(); + expect(readDrawnTiles($session, 'furniture'))->toBe([]); +}); + +it('asks which piece a glyph is when several draw it, and draws with the answer', function () { + $session = drawLayerTilesSession([ + 'crate' => ['name' => 'Crate', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['61']]], + 'barrel' => ['name' => 'Barrel', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['62']]], + ]); + $revision = $session->readMap('test-map')['revision']; + $question = $session->drawLayerTiles('test-map', $revision, 'map:4'); + + expect($question['status'])->toBe('question') + ->and($question['glyph'])->toBe('x') + ->and(array_column($question['roles'], 'label'))->toHaveCount(2) + ->and(readDrawnTiles($session, 'furniture'))->toBe([]); + $barrel = array_find($question['roles'], static fn(array $role): bool => str_contains($role['label'], 'Barrel'))['key']; + expect($session->drawLayerTiles('test-map', $revision, 'map:4', ['x' => $barrel]))->toMatchArray(['status' => 'applied', 'cells' => 2]) + ->and(readDrawnTiles($session, 'furniture')[1])->toBe([0, 62, 62, 0]); +}); + +it('refuses a layer no piece draws, or one the map does not have, changing nothing', function () { + $session = drawLayerTilesSession([]); + $revision = $session->readMap('test-map')['revision']; + + expect(fn() => $session->drawLayerTiles('test-map', $revision, 'map:4'))->toThrow(SessionRefusal::class) + ->and(fn() => $session->drawLayerTiles('test-map', $revision, 'map:99'))->toThrow(SessionRefusal::class, 'has no layer') + ->and($session->readMap('test-map')['dirty'])->toBeFalse(); +}); + +it('lists the cells still showing glyphs, and they go once their tiles are drawn', function () { + $session = drawLayerTilesSession([ + 'ramp' => ['name' => 'Ramp', 'layer' => 'buildings', 'glyphs' => ['/'], 'tiles' => ['furniture' => ['60']]], + ]); + // Without its floor and decor tiles, every glyph on the map shows. + foreach (['floor', 'decor'] as $layer) { + $session->removeTileLayer('test-map', $session->readMap('test-map')['revision'], $layer); + } + $before = $session->readCoverage('test-map'); + $shownRamps = static fn(array $coverage): array => array_values(array_filter($coverage['cells'], static fn(array $cell): bool => $cell[2] === '/')); + + expect($before['available'])->toBeTrue() + ->and($shownRamps($before))->toBe([[1, 0, '/', 'buildings']]); + $session->drawLayerTiles('test-map', $before['revision'], 'map:4', ['x' => null]); + expect($shownRamps($session->readCoverage('test-map')))->toBe([]); +}); diff --git a/tests/Unit/Session/NpcFieldSheetSessionTest.php b/tests/Unit/Session/NpcFieldSheetSessionTest.php new file mode 100644 index 0000000..aa12910 --- /dev/null +++ b/tests/Unit/Session/NpcFieldSheetSessionTest.php @@ -0,0 +1,64 @@ +createNpc('test-map', $session->readMap('test-map')['revision'], 2, 1, 'Gate Guard'); + + return [$session, 0]; +} + +function findFieldSheetRow(EditorSession $session, int $index): ?array +{ + return array_find($session->readNpc('test-map', $index)['rows'], static fn(array $row): bool => $row['label'] === 'Field Sheet'); +} + +it('shows the field sheet as a picture to pick, after the terminal appearance', function () { + [$session, $index] = npcFieldSheetSession(); + $row = findFieldSheetRow($session, $index); + $labels = array_column($session->readNpc('test-map', $index)['rows'], 'label'); + + expect($row)->toMatchArray(['kind' => 'reference', 'reference' => 'png_assets', 'value' => '', + 'media' => ['kind' => 'image', 'root' => 'assets'], 'noneLabel' => 'None: the glyph shows']) + ->and(array_search('Field Sheet', $labels, true))->toBeGreaterThan(array_search('Sprite', $labels, true)); +}); + +it('sets and clears the sheet as one undo step each, keeping the terminal sprite', function () { + [$session, $index] = npcFieldSheetSession(); + $sprite = $session->readNpc('test-map', $index)['npc']['sprite']; + $key = findFieldSheetRow($session, $index)['key']; + + $session->applyNpc('test-map', $session->readMap('test-map')['revision'], $index, $key, 'Graphics/Characters/$Guard.png'); + expect(findFieldSheetRow($session, $index)['value'])->toBe('Graphics/Characters/$Guard.png') + ->and($session->readNpc('test-map', $index)['npc']['sprite'])->toBe($sprite); + $session->applyNpc('test-map', $session->readMap('test-map')['revision'], $index, $key, ''); + expect(findFieldSheetRow($session, $index)['value'])->toBe(''); + $session->undo(); + expect(findFieldSheetRow($session, $index)['value'])->toBe('Graphics/Characters/$Guard.png'); +}); + +it('refuses a picture the game could not read as a character sheet, changing nothing', function () { + [$session, $index] = npcFieldSheetSession(); + $key = findFieldSheetRow($session, $index)['key']; + + expect(fn() => $session->applyNpc('test-map', $session->readMap('test-map')['revision'], $index, $key, 'Graphics/Characters/Odd.png')) + ->toThrow(SessionRefusal::class) + ->and(fn() => $session->applyNpc('test-map', $session->readMap('test-map')['revision'], $index, $key, 'Graphics/Characters/Missing.png')) + ->toThrow(SessionRefusal::class) + ->and(findFieldSheetRow($session, $index)['value'])->toBe(''); +}); From 232d0f0ae2d71b1e842f7f55706cb1fcc1f31a4d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 21:23:40 +0200 Subject: [PATCH 219/344] fix(cutscenes): pass over a folder of shared artwork as the game does A cutscene folder holding files but no PHP at all, such as artwork several summons share, is no longer reported as a broken pair; the Engine's libraries already pass over it. An empty folder and half a pair are still reported. --- src/Cutscenes/CutsceneLibrary.php | 19 +++++++++++++++++++ .../Cutscenes/EffectTimelineAssetTest.php | 6 ++++++ 2 files changed, 25 insertions(+) diff --git a/src/Cutscenes/CutsceneLibrary.php b/src/Cutscenes/CutsceneLibrary.php index 3b17169..7624eff 100644 --- a/src/Cutscenes/CutsceneLibrary.php +++ b/src/Cutscenes/CutsceneLibrary.php @@ -112,6 +112,11 @@ private function discover(CutsceneType $type): void $hasPartner = is_file($partnerPath); if (($type->hasDataFile() && ! $hasData) || ! $hasPartner) { + // A folder of resources with no PHP at all, such as artwork several cutscenes share, is not a + // cutscene; the game passes over it as well. An empty folder or half a pair is reported. + if (! $hasData && ! $hasPartner && self::holdsOnlyResources($folder)) { + continue; + } $this->reportIncomplete($type, $folder, $entry, $hasData, $hasPartner); continue; @@ -145,6 +150,20 @@ private function discover(CutsceneType $type): void } } + /** Whether a folder holds something, none of it a PHP file, at any depth. */ + private static function holdsOnlyResources(string $folder): bool + { + $found = false; + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($folder, \FilesystemIterator::SKIP_DOTS)) as $file) { + if (str_ends_with($file->getFilename(), '.php')) { + return false; + } + $found = true; + } + + return $found; + } + /** * Reports a folder that holds part of a pair, and says which part. */ diff --git a/tests/Unit/Cutscenes/EffectTimelineAssetTest.php b/tests/Unit/Cutscenes/EffectTimelineAssetTest.php index 7b8427c..66ebd2e 100644 --- a/tests/Unit/Cutscenes/EffectTimelineAssetTest.php +++ b/tests/Unit/Cutscenes/EffectTimelineAssetTest.php @@ -129,6 +129,12 @@ function effectPath(string $root, string $id): string file_put_contents(effectPath($root, 'old.style'), emberSparkTimeline()); $messages = array_column(CutsceneLibrary::fromProject($root)->issues(CutsceneType::EFFECT), 'message'); + // A folder of shared artwork, with no PHP at all, is not an effect; the game passes over it as well. + @mkdir($root . '/assets/Animations/shared/graphical', 0o777, true); + file_put_contents($root . '/assets/Animations/shared/graphical/flare.png', 'png'); + $messages = array_column(CutsceneLibrary::fromProject($root)->issues(CutsceneType::EFFECT), 'message'); + expect(array_filter($messages, static fn(string $message): bool => str_contains($message, '"shared"')))->toBe([]); + expect(implode("\n", $messages))->toContain('Folder "empty-effect" is missing empty-effect.timeline.php; an effect is its timeline file.') ->and(implode("\n", $messages))->toContain('The folder name "old.style" is not a stable id (lowercase letters, digits, "_" and "-").'); }); From 6055cc5ebcbfec7ab7fb09a63e6f810fdf5ab71e Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 21:40:23 +0200 Subject: [PATCH 220/344] feat(preview): compose a previewed cinematic as the game's graphical field The cinematic preview's dialogue and choices are paged by the Engine's shared dialogue pagination and offered to its scene composer as the game's windows are. PreviewSceneHost drives the Engine's own renderer client and presentation through an in-memory relay to the editor window, in the order RendererRuntime uses, with the scene's terminal text from an isolated Console capture and the preview's playhead as its clock. The preview player is built through Player's own constructor with the project's player field art, and the sample project carries the player file every project has. --- .../Preview/CinematicPreviewSession.php | 55 ++++- src/Cutscenes/Preview/PreviewChoiceOwner.php | 44 ++++ .../Preview/PreviewDialogueOwner.php | 66 ++++++ src/Cutscenes/Preview/PreviewGameScene.php | 1 - src/Cutscenes/Preview/PreviewPlayer.php | 25 +- src/Cutscenes/Preview/PreviewPresentation.php | 214 +++++++++++------- .../Preview/PreviewRendererRelay.php | 152 +++++++++++++ src/Cutscenes/Preview/PreviewSceneHost.php | 126 +++++++++++ .../assets/Data/Entities/player.php | 11 + tests/Support/CutsceneFixtures.php | 12 + .../Cutscenes/CinematicPreviewSessionTest.php | 9 - .../Cutscenes/PreviewRendererRelayTest.php | 66 ++++++ tests/Unit/Cutscenes/PreviewSceneHostTest.php | 69 ++++++ 13 files changed, 729 insertions(+), 121 deletions(-) create mode 100644 src/Cutscenes/Preview/PreviewChoiceOwner.php create mode 100644 src/Cutscenes/Preview/PreviewDialogueOwner.php create mode 100644 src/Cutscenes/Preview/PreviewRendererRelay.php create mode 100644 src/Cutscenes/Preview/PreviewSceneHost.php create mode 100644 tests/Fixtures/sample-project/assets/Data/Entities/player.php create mode 100644 tests/Unit/Cutscenes/PreviewRendererRelayTest.php create mode 100644 tests/Unit/Cutscenes/PreviewSceneHostTest.php diff --git a/src/Cutscenes/Preview/CinematicPreviewSession.php b/src/Cutscenes/Preview/CinematicPreviewSession.php index 72ac7d3..ca1f3f2 100644 --- a/src/Cutscenes/Preview/CinematicPreviewSession.php +++ b/src/Cutscenes/Preview/CinematicPreviewSession.php @@ -13,6 +13,7 @@ use Ichiloto\Engine\Events\Interpreter\EventExecutionSession; use Ichiloto\Engine\Events\Interpreter\EventExecutionStatus; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; +use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; use Ichiloto\Engine\Util\Config\ConfigStore; use Ichiloto\Engine\Util\Config\PlaySettings; use Ichiloto\Engine\Util\Config\ProjectConfig; @@ -68,6 +69,8 @@ final class CinematicPreviewSession private bool $outcomeAnnounced = false; /** What was playing before the cinematic started, as the engine held it. */ private ?BackgroundMusicState $musicBefore = null; + /** The editor window's graphical view of the scene, while one is attached. */ + private ?PreviewSceneHost $sceneHost = null; /** * @param array{mapId?: string|null, x?: int, y?: int, width?: int, height?: int, autoAdvance?: bool, battleOutcome?: string} $options @@ -120,7 +123,7 @@ private function boot(): void $this->presentation->autoAdvance = (bool) ($this->options['autoAdvance'] ?? false); $this->interpreter = new EventInterpreter($this->scene, $this->presentation); $this->scene->installInterpreter($this->interpreter); - $player = new PreviewPlayer(new Vector2(intval($this->options['x'] ?? 0), intval($this->options['y'] ?? 0))); + $player = new PreviewPlayer($this->scene, new Vector2(intval($this->options['x'] ?? 0), intval($this->options['y'] ?? 0))); $this->scene->installPlayer($player); if ($mapId !== '') { @@ -295,6 +298,7 @@ public function runToCompletion(float $maxSeconds = 600.0): bool */ public function dispose(): void { + $this->detachScene(); if (! $this->configured) { return; } @@ -550,18 +554,55 @@ public function frame(): array $this->run(function () use (&$rows): void { $camera = $this->scene->previewCamera(); $camera->clearFrame(); - $this->scene->previewMap->render(); - $this->scene->npcManager?->render(); - $this->scene->player?->render(); - $this->scene->cinematicStage?->render(); - $this->scene->cinematicPresentation?->render(); - $this->presentation->render(); + $this->renderScene(); $rows = $camera->frame(); }); return $rows; } + /** + * One exchange with the editor window's graphical view of the scene + * ({@see PreviewSceneHost::exchange()}): its renderer event lines in, what + * it should apply out. A grid other than the attached view's starts the + * view again, as a resized renderer session does. + * + * @param list $events + * @return list}> + */ + public function exchangeScene(RendererGridConfig $grid, array $events): array + { + if ($this->sceneHost !== null && $this->sceneHost->grid != $grid) { + $this->detachScene(); + } + $messages = []; + $this->run(function () use ($grid, $events, &$messages): void { + $this->sceneHost ??= new PreviewSceneHost($this->scene, $this->presentation, + $this->projectRoot . '/assets', $grid, $this->renderScene(...), fn(): float => $this->elapsed); + $messages = $this->sceneHost->exchange($events); + }); + + return $messages; + } + + /** Ends the editor window's graphical view of the scene, if one is attached. */ + public function detachScene(): void + { + $this->sceneHost?->dispose(); + $this->sceneHost = null; + } + + /** Draws everything the field draws during a cinematic, in the game's order. */ + private function renderScene(): void + { + $this->scene->previewMap->render(); + $this->scene->npcManager?->render(); + $this->scene->player?->render(); + $this->scene->cinematicStage?->render(); + $this->scene->cinematicPresentation?->render(); + $this->presentation->render(); + } + /** * The captured screen's size, in columns and rows. * diff --git a/src/Cutscenes/Preview/PreviewChoiceOwner.php b/src/Cutscenes/Preview/PreviewChoiceOwner.php new file mode 100644 index 0000000..d8e6227 --- /dev/null +++ b/src/Cutscenes/Preview/PreviewChoiceOwner.php @@ -0,0 +1,44 @@ + $options */ + public function __construct( + public readonly string $title, + public readonly string $prompt, + public readonly array $options, + public int $highlighted, + private readonly Rect $bounds, + ) { + } + + public function getModalPresentation(): ModalPresentation + { + return new ModalPresentation($this->title, $this->prompt, $this->options, $this->highlighted, true); + } + + public function getPresentationBounds(): Rect + { + return $this->bounds; + } + + public function getPresentationPriority(): PresentationPriority + { + return PresentationPriority::MODAL; + } +} diff --git a/src/Cutscenes/Preview/PreviewDialogueOwner.php b/src/Cutscenes/Preview/PreviewDialogueOwner.php new file mode 100644 index 0000000..61105cc --- /dev/null +++ b/src/Cutscenes/Preview/PreviewDialogueOwner.php @@ -0,0 +1,66 @@ +pagination->getSnapshot($this->page); + } + + public function getPresentationBounds(): Rect + { + $origin = $this->pagination->position->getCoordinates($this->pagination->windowWidth, $this->pagination->windowHeight); + + return new Rect((int) $origin->x, (int) $origin->y, $this->pagination->windowWidth, $this->pagination->windowHeight); + } + + public function getPresentationPriority(): PresentationPriority + { + return PresentationPriority::MODAL; + } + + /** The page shown, from the first. */ + public function getPage(): int + { + return $this->page; + } + + /** The text of the page shown. */ + public function getPageText(): string + { + return $this->pagination->pages[$this->page]; + } + + /** Moves to the next page; false on the last, which the author's continue closes instead. */ + public function turnPage(): bool + { + if ($this->page >= count($this->pagination->pages) - 1) { + return false; + } + $this->page++; + + return true; + } +} diff --git a/src/Cutscenes/Preview/PreviewGameScene.php b/src/Cutscenes/Preview/PreviewGameScene.php index 0608359..f7cc0f7 100644 --- a/src/Cutscenes/Preview/PreviewGameScene.php +++ b/src/Cutscenes/Preview/PreviewGameScene.php @@ -62,7 +62,6 @@ public function __construct( public function installPlayer(PreviewPlayer $player): void { - $player->bindScene($this); $this->player = $player; } diff --git a/src/Cutscenes/Preview/PreviewPlayer.php b/src/Cutscenes/Preview/PreviewPlayer.php index f266d00..8450f03 100644 --- a/src/Cutscenes/Preview/PreviewPlayer.php +++ b/src/Cutscenes/Preview/PreviewPlayer.php @@ -4,15 +4,11 @@ namespace Ichiloto\Editor\Cutscenes\Preview; -use Assegai\Collections\ItemList; use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\Core\Vector2; -use Ichiloto\Engine\Events\EventManager; use Ichiloto\Engine\Events\Interfaces\EventInterface; -use Ichiloto\Engine\Events\Interfaces\ObserverInterface; -use Ichiloto\Engine\Events\Interfaces\StaticObserverInterface; -use Ichiloto\Engine\Events\Triggers\EventTrigger; use Ichiloto\Engine\Field\Player; +use Ichiloto\Engine\Field\PlayerPresentationConfig; use Ichiloto\Engine\Rendering\Camera; use Ichiloto\Engine\Scenes\Game\GameScene; @@ -29,22 +25,15 @@ final class PreviewPlayer extends Player public array $blockedMessages = []; /** + * The player of a preview scene, with the field art the project gives the + * player ({@see PlayerPresentationConfig}), as the game's field builds it. + * * @param string[] $sprite */ - public function __construct(Vector2 $position, array $sprite = ['@']) + public function __construct(GameScene $scene, Vector2 $position, array $sprite = ['@']) { - $this->position = $position; - $this->shape = new Rect(0, 0, 1, 1); - $this->sprite = $sprite; - $this->observers = new ItemList(ObserverInterface::class); - $this->staticObservers = new ItemList(StaticObserverInterface::class); - $this->events = new ItemList(EventTrigger::class); - $this->eventManager = EventManager::getInstance(new PreviewGame()); - } - - public function bindScene(GameScene $scene): void - { - $this->scene = $scene; + parent::__construct($scene, 'Player', $position, new Rect(0, 0, 1, 1), $sprite, + graphicalSprites: PlayerPresentationConfig::load()->graphical); } public function tryMove(Vector2 $direction, Camera $camera): bool diff --git a/src/Cutscenes/Preview/PreviewPresentation.php b/src/Cutscenes/Preview/PreviewPresentation.php index 556869d..b6de73c 100644 --- a/src/Cutscenes/Preview/PreviewPresentation.php +++ b/src/Cutscenes/Preview/PreviewPresentation.php @@ -4,29 +4,42 @@ namespace Ichiloto\Editor\Cutscenes\Preview; -use Ichiloto\Engine\Events\Interpreter\EventPresentationInterface; +use Closure; +use Ichiloto\Engine\Core\Rect; +use Ichiloto\Engine\Events\Interpreter\EventDialoguePresentationInterface; use Ichiloto\Engine\IO\Console\TerminalText; +use Ichiloto\Engine\Messaging\Dialogue\Presentation\DialogueContext; +use Ichiloto\Engine\Messaging\Dialogue\Presentation\DialoguePageLayout; +use Ichiloto\Engine\Messaging\Dialogue\Presentation\DialoguePaginationBuilder; +use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; +use Ichiloto\Engine\UI\Interfaces\LayeredPresentationInterface; /** * Dialogue and choices as the preview pane presents them. * * The interpreter hands over text and choice prompts exactly as it would to - * the field's windows; here they wait for the author (Enter to continue, - * ↑/↓ and Enter to choose) unless the session is told to advance on its own, - * which a run to completion or a skip comparison does. + * the field's windows. A line is paged as the game pages it + * ({@see DialoguePaginationBuilder}) and each page waits for the author + * (Enter to continue, ↑/↓ and Enter to choose) unless the session is told to + * advance on its own, which a run to completion or a skip comparison does. + * What is on screen is offered to the Engine's scene composer as the game's + * own windows are ({@see getActivePresentations()}), so a graphical preview + * draws it the game's way, and is drawn into the Terminal picture on its own + * UI layer. */ -final class PreviewPresentation implements EventPresentationInterface +final class PreviewPresentation implements EventDialoguePresentationInterface { - private ?string $text = null; - private string $speaker = ''; - /** @var string[]|null */ - private ?array $options = null; - private string $prompt = ''; - private string $title = ''; - private int $highlighted = 0; - private ?int $choice = null; + private ?PreviewDialogueOwner $dialogue = null; + private ?PreviewChoiceOwner $choice = null; + private ?int $result = null; private bool $confirmed = false; public bool $autoAdvance = false; + /** + * The graphical dialogue's page layout while a graphical preview is attached; null pages for the terminal. + * + * @var (Closure(string, DialogueContext, string): ?DialoguePageLayout)|null + */ + public ?Closure $pageLayout = null; /** @var array */ public array $log = []; @@ -35,110 +48,87 @@ public function __construct(private readonly PreviewCamera $camera) } public function beginText(string $text, string $name = ''): void + { + $this->beginDialogue($text, $name, new DialogueContext()); + } + + public function beginDialogue(string $text, string $speaker, DialogueContext $context): void { $this->reset(); - $this->text = $text; - $this->speaker = $name; - $this->log[] = ['kind' => 'text', 'text' => $text, 'speaker' => $name]; + $layout = $this->pageLayout === null ? null : ($this->pageLayout)($speaker, $context, ''); + $this->dialogue = new PreviewDialogueOwner(DialoguePaginationBuilder::buildPagination($text, $speaker, '', $context, + $this->camera->screen->getWidth(), $this->camera->screen->getHeight(), $layout)); + $this->log[] = ['kind' => 'text', 'text' => $text, 'speaker' => $speaker]; } public function beginChoice(string $prompt, array $options, string $title = ''): void { $this->reset(); - $this->prompt = $prompt; - $this->title = $title; - $this->options = array_values(array_map(strval(...), $options)); + $options = array_values(array_map(strval(...), $options)); + $width = $this->getBoxWidth(); + $height = count($options) + count(TerminalText::wrapToWidth($prompt, $width - 4)) + ($title === '' ? 2 : 3); + $this->choice = new PreviewChoiceOwner($title, $prompt, $options, 0, new Rect( + max(0, intdiv($this->camera->screen->getWidth() - $width, 2)), max(0, $this->camera->screen->getHeight() - $height - 1), $width, $height)); $this->log[] = ['kind' => 'choice', 'text' => $prompt, 'speaker' => $title]; } public function update(): void { - if ($this->autoAdvance) { - if ($this->options !== null && $this->choice === null) { - $this->choice = 0; - } - - $this->confirmed = true; + if (! $this->autoAdvance) { + return; + } + if ($this->choice !== null && $this->result === null) { + $this->result = 0; } + while ($this->dialogue?->turnPage()) { + } + $this->confirmed = true; } public function render(): void { - if ($this->text === null && $this->options === null) { + if ($this->isComplete()) { return; } - - $width = $this->camera->screen->getWidth(); - $height = $this->camera->screen->getHeight(); - $boxWidth = max(12, min($width, 60)); - $inner = $boxWidth - 4; - $rows = []; - $border = '+' . str_repeat('-', $boxWidth - 2) . '+'; - $rows[] = $border; - - if ($this->text !== null) { - if ($this->speaker !== '') { - $rows[] = '| ' . TerminalText::padRight($this->speaker . ':', $inner) . ' |'; - } - - foreach (TerminalText::wrapToWidth($this->text, $inner) as $line) { - $rows[] = '| ' . TerminalText::padRight($line, $inner) . ' |'; - } - - $rows[] = '| ' . TerminalText::padRight('', $inner - 1) . '▼ |'; - } else { - if ($this->title !== '') { - $rows[] = '| ' . TerminalText::padRight($this->title . ':', $inner) . ' |'; - } - - foreach (TerminalText::wrapToWidth($this->prompt, $inner) as $line) { - $rows[] = '| ' . TerminalText::padRight($line, $inner) . ' |'; - } - - foreach ($this->options ?? [] as $index => $option) { - $marker = $index === $this->highlighted ? '>' : ' '; - $rows[] = '| ' . TerminalText::padRight($marker . ' ' . $option, $inner) . ' |'; - } - } - - $rows[] = $border; - $x = max(0, intdiv($width - $boxWidth, 2)); - $y = max(0, $height - count($rows) - 1); - $this->camera->draw($rows, $x, $y); + $owner = $this->dialogue ?? $this->choice; + PresentationLayerPolicy::ui($owner, fn() => $this->drawBox()); } public function isComplete(): bool { - if ($this->text === null && $this->options === null) { - return true; - } - - return $this->confirmed; + return ($this->dialogue === null && $this->choice === null) || $this->confirmed; } public function choiceResult(): ?int { - return $this->choice; + return $this->result; } public function reset(): void { - $this->text = null; - $this->speaker = ''; - $this->options = null; - $this->prompt = ''; - $this->title = ''; - $this->highlighted = 0; + $this->dialogue = null; $this->choice = null; + $this->result = null; $this->confirmed = false; } + /** + * What is on screen waiting for the author, as the Engine's presentation + * owners, the one taking input first. + * + * @return list + */ + public function getActivePresentations(): array + { + return $this->isWaiting() ? array_values(array_filter([$this->choice, $this->dialogue])) : []; + } + /** * Whether something is on screen waiting for the author. */ public function isWaiting(): bool { - return ($this->text !== null || $this->options !== null) && ! $this->confirmed; + return ($this->dialogue !== null || $this->choice !== null) && ! $this->confirmed; } /** @@ -150,15 +140,20 @@ public function describeWait(): ?string return null; } - if ($this->options !== null) { - return sprintf('Choice (%d options): ↑/↓ select, Enter confirm', count($this->options)); + if ($this->choice !== null) { + return sprintf('Choice (%d options): ↑/↓ select, Enter confirm', count($this->choice->options)); } - return 'Dialogue waiting: Enter to continue'; + $pages = count($this->dialogue->pagination->pages); + + return $pages > 1 + ? sprintf('Dialogue waiting (page %d of %d): Enter to continue', $this->dialogue->getPage() + 1, $pages) + : 'Dialogue waiting: Enter to continue'; } /** - * Confirms the text, or the highlighted option. + * Turns to the dialogue's next page, or confirms its last page or the + * highlighted option. */ public function confirm(): bool { @@ -166,8 +161,12 @@ public function confirm(): bool return false; } - if ($this->options !== null) { - $this->choice = $this->highlighted; + if ($this->dialogue?->turnPage()) { + return true; + } + + if ($this->choice !== null) { + $this->result = $this->choice->highlighted; } $this->confirmed = true; @@ -177,13 +176,56 @@ public function confirm(): bool public function moveHighlight(int $delta): bool { - if ($this->options === null || $this->confirmed) { + if ($this->choice === null || $this->confirmed) { return false; } - $count = count($this->options); - $this->highlighted = (($this->highlighted + $delta) % $count + $count) % $count; + $count = count($this->choice->options); + $this->choice->highlighted = (($this->choice->highlighted + $delta) % $count + $count) % $count; return true; } + + /** The Terminal window: the dialogue's page or the choice, bordered, where the game places it. */ + private function drawBox(): void + { + $boxWidth = $this->getBoxWidth(); + $inner = $boxWidth - 4; + $border = '+' . str_repeat('-', $boxWidth - 2) . '+'; + $rows = [$border]; + + if ($this->dialogue !== null) { + $speaker = $this->dialogue->pagination->speaker; + if ($speaker !== '') { + $rows[] = '| ' . TerminalText::padRight($speaker . ':', $inner) . ' |'; + } + foreach (explode("\n", $this->dialogue->getPageText()) as $line) { + foreach (TerminalText::wrapToWidth($line, $inner) as $wrapped) { + $rows[] = '| ' . TerminalText::padRight($wrapped, $inner) . ' |'; + } + } + $rows[] = '| ' . TerminalText::padRight('', $inner - 1) . '▼ |'; + } elseif ($this->choice !== null) { + if ($this->choice->title !== '') { + $rows[] = '| ' . TerminalText::padRight($this->choice->title . ':', $inner) . ' |'; + } + foreach (TerminalText::wrapToWidth($this->choice->prompt, $inner) as $line) { + $rows[] = '| ' . TerminalText::padRight($line, $inner) . ' |'; + } + foreach ($this->choice->options as $index => $option) { + $marker = $index === $this->choice->highlighted ? '>' : ' '; + $rows[] = '| ' . TerminalText::padRight($marker . ' ' . $option, $inner) . ' |'; + } + } + + $rows[] = $border; + $width = $this->camera->screen->getWidth(); + $height = $this->camera->screen->getHeight(); + $this->camera->draw($rows, max(0, intdiv($width - $boxWidth, 2)), max(0, $height - count($rows) - 1)); + } + + private function getBoxWidth(): int + { + return $this->dialogue?->pagination->windowWidth ?? max(12, min($this->camera->screen->getWidth(), 60)); + } } diff --git a/src/Cutscenes/Preview/PreviewRendererRelay.php b/src/Cutscenes/Preview/PreviewRendererRelay.php new file mode 100644 index 0000000..3ca2211 --- /dev/null +++ b/src/Cutscenes/Preview/PreviewRendererRelay.php @@ -0,0 +1,152 @@ + Sent and not yet collected by the window, in order. */ + private array $outgoing = []; + private int $outgoingBytes = 0; + /** @var list Answered by the window and not yet polled by the client. */ + private array $incoming = []; + private ?RendererSessionConfig $session = null; + + public function start(RendererSessionConfig $session): void + { + $this->session = $session; + $this->outgoing = $this->incoming = []; + $this->outgoingBytes = 0; + $this->state = RendererTransportState::RUNNING; + } + + public function isRunning(): bool + { + return $this->state === RendererTransportState::RUNNING; + } + + public function send(RendererMessage $message): void + { + if (! $this->isRunning()) { + throw new RendererTransportException('The preview window is not attached.'); + } + $this->outgoing[] = $message; + $this->outgoingBytes += strlen($message->encode()); + } + + public function trySend(RendererMessage $message): bool + { + if (! $this->isRunning()) { + throw new RendererTransportException('The preview window is not attached.'); + } + if ($this->outgoingBytes >= self::MAX_PENDING_BYTES) { + return false; + } + $this->send($message); + + return true; + } + + public function getPendingWriteBytes(): int + { + return $this->outgoingBytes; + } + + public function pollEvents(float $waitSeconds = 0.0): array + { + $events = $this->incoming; + $this->incoming = []; + + return $events; + } + + public function shutdown(): ?int + { + $this->state = RendererTransportState::STOPPED; + $this->outgoing = []; + $this->outgoingBytes = 0; + + return 0; + } + + public function getState(): RendererTransportState + { + return $this->state; + } + + public function getExitCode(): ?int + { + return $this->state === RendererTransportState::STOPPED ? 0 : null; + } + + public function getDiagnostics(): string + { + return sprintf('Preview window relay: %d message(s), %d byte(s) waiting; %d event(s) unread.', + count($this->outgoing), $this->outgoingBytes, count($this->incoming)); + } + + /** + * The window's answers, as renderer event lines, for the client to poll. + * + * @param list $lines + * @throws \Ichiloto\Engine\Rendering\Transport\Exceptions\RendererProtocolException When a line is not a renderer event. + */ + public function receive(array $lines): void + { + foreach ($lines as $line) { + $this->incoming[] = RendererEvent::fromJson($line); + } + } + + /** + * What has been sent since the window last collected, in order: each + * message's type and payload, never its envelope. + * + * @return list}> + */ + public function collect(): array + { + $messages = array_map(static fn(RendererMessage $message): array => ['type' => $message->type->value, 'payload' => $message->payload], + $this->outgoing); + $this->outgoing = []; + $this->outgoingBytes = 0; + + return $messages; + } + + /** The session the client started, with the capabilities it requires and asks for. */ + public function getSession(): ?RendererSessionConfig + { + return $this->session; + } + + /** Whether a frame is waiting for the window. */ + public function hasFrame(): bool + { + return array_any($this->outgoing, static fn(RendererMessage $message): bool => $message->type === RendererMessageType::FRAME); + } +} diff --git a/src/Cutscenes/Preview/PreviewSceneHost.php b/src/Cutscenes/Preview/PreviewSceneHost.php new file mode 100644 index 0000000..bea0fd8 --- /dev/null +++ b/src/Cutscenes/Preview/PreviewSceneHost.php @@ -0,0 +1,126 @@ +relay = new PreviewRendererRelay(); + $this->client = new RendererClient($this->relay); + $this->client->start(new RendererSessionConfig('Cinematic preview', $assetRoot, $grid, RendererProtocolVersion::V2)); + $this->presentation = new RendererPresentation($this->client, $grid); + $this->composer = new SceneFrameComposer($assetRoot); + } + + /** + * One exchange with the window: takes its answers (READY, frame + * acknowledgements and rejections, as renderer event lines), presents the + * scene as it is now, and returns what the window should apply, in order. + * + * @param list $events + * @return list}> + * @throws RendererTransportException When the window reports an error. + */ + public function exchange(array $events): array + { + $this->relay->receive($events); + $this->client->pump(); + $invalidate = false; + $expectedGeneration = null; + foreach ($this->client->drainEvents() as $event) { + if ($event->type === RendererEventType::READY) { + $this->ready = true; + } elseif ($event->type === RendererEventType::FRAME_REJECTED) { + $invalidate = true; + $expectedGeneration = max($expectedGeneration ?? 0, $event->expectedGeneration ?? 0); + } elseif ($event->type === RendererEventType::FRAME_ACK) { + $this->presentation->acknowledge($event->generation, $event->presented); + } elseif ($event->type === RendererEventType::ERROR) { + throw new RendererTransportException('Preview window error: ' . $event->message, $this->relay->getDiagnostics()); + } + } + foreach ($this->client->drainFrameAcknowledgements() as $acknowledgement) { + $this->presentation->acknowledge($acknowledgement->generation, $acknowledgement->presented); + } + if ($invalidate) { + $this->presentation->invalidate(expectedGeneration: $expectedGeneration); + } + if ($this->ready) { + $this->attachContext(); + $composition = $this->composer->composeFrame($this->scene, $this->grid, $this->client->supports(...), + fn(array $excluded, array $worldLayers) => Console::capturePresentation($this->grid->columns, $this->grid->rows, + $this->renderTerminal, $excluded, $worldLayers, + $this->scene instanceof RetainedWorldProviderInterface && $this->scene->getPresentationWorld() !== null)); + $this->presentation->presentFrame($composition->prepareFrame($this->presentation), $composition->screenOverlay); + } + + return $this->relay->collect(); + } + + /** Detaches the scene's presentation context and the dialogue's graphical paging, and closes the relay. */ + public function dispose(): void + { + if ($this->scene->getPresentationContext() !== null) { + $this->scene->setPresentationContext(null); + } + $this->dialogue->pageLayout = null; + $this->client->shutdown(); + } + + /** Selects the graphical field for the composer, with the window's capabilities and the preview's playhead. */ + private function attachContext(): void + { + if ($this->scene->getPresentationContext() !== null) { + return; + } + $this->scene->setPresentationContext(new ScenePresentationContext($this->grid, $this->client->supports(...), + collectPresentations: fn(): array => $this->dialogue->getActivePresentations(), readTime: $this->readTime)); + $width = $this->grid->columns * $this->grid->cellWidth; + $this->dialogue->pageLayout = fn(string $speaker, $context, string $help) => $this->composer->getDialoguePageLayout($speaker, $context, $help, $width); + } +} diff --git a/tests/Fixtures/sample-project/assets/Data/Entities/player.php b/tests/Fixtures/sample-project/assets/Data/Entities/player.php new file mode 100644 index 0000000..0530425 --- /dev/null +++ b/tests/Fixtures/sample-project/assets/Data/Entities/player.php @@ -0,0 +1,11 @@ + [ + 'north' => ['^'], + 'east' => ['>'], + 'south' => ['v'], + 'west' => ['<'], + ], +]; diff --git a/tests/Support/CutsceneFixtures.php b/tests/Support/CutsceneFixtures.php index 561c51a..eb96da0 100644 --- a/tests/Support/CutsceneFixtures.php +++ b/tests/Support/CutsceneFixtures.php @@ -2,8 +2,10 @@ declare(strict_types=1); +use Ichiloto\Editor\Cutscenes\CutsceneHydration; use Ichiloto\Editor\Cutscenes\CutsceneLibrary; use Ichiloto\Editor\Editor; +use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\UI\CutscenesScreen; @@ -565,3 +567,13 @@ function effectProject(): string return $root; } + +/** The harbour cinematic of {@see cutsceneProject()}, as the Engine reads it. */ +function harbourDefinition(string $root): \Ichiloto\Engine\Cutscenes\Cinematics\CinematicDefinition +{ + $folder = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns'; + $data = ProjectDirectoryContext::run($root, static fn(): mixed => require $folder . '/harbour-lanterns.data.php'); + $script = ProjectDirectoryContext::run($root, static fn(): mixed => require $folder . '/harbour-lanterns.script.php'); + + return CutsceneHydration::cinematic($data, $script, $root); +} diff --git a/tests/Unit/Cutscenes/CinematicPreviewSessionTest.php b/tests/Unit/Cutscenes/CinematicPreviewSessionTest.php index 26d78c5..4a30607 100644 --- a/tests/Unit/Cutscenes/CinematicPreviewSessionTest.php +++ b/tests/Unit/Cutscenes/CinematicPreviewSessionTest.php @@ -4,7 +4,6 @@ use Ichiloto\Editor\Cutscenes\CutsceneHydration; use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewSession; -use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Engine\Util\Config\ConfigStore; use Ichiloto\Engine\Util\Config\ProjectConfig; @@ -13,14 +12,6 @@ * controller, stage and presentation are the Engine's; the editor supplies a * clock, a frame buffer, and read-only views. */ -function harbourDefinition(string $root): \Ichiloto\Engine\Cutscenes\Cinematics\CinematicDefinition -{ - $folder = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns'; - $data = ProjectDirectoryContext::run($root, static fn(): mixed => require $folder . '/harbour-lanterns.data.php'); - $script = ProjectDirectoryContext::run($root, static fn(): mixed => require $folder . '/harbour-lanterns.script.php'); - - return CutsceneHydration::cinematic($data, $script, $root); -} it('plays the harbour cinematic to completion through the Engine and records its final state', function () { $root = cutsceneProject(); diff --git a/tests/Unit/Cutscenes/PreviewRendererRelayTest.php b/tests/Unit/Cutscenes/PreviewRendererRelayTest.php new file mode 100644 index 0000000..578fba3 --- /dev/null +++ b/tests/Unit/Cutscenes/PreviewRendererRelayTest.php @@ -0,0 +1,66 @@ +start(new RendererSessionConfig('Preview', sys_get_temp_dir(), new RendererGridConfig(10, 4, 16, 24), RendererProtocolVersion::V2)); + $relay->receive([json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => $capabilities])]); + $client->pump(); + + return $client; +} + +it('negotiates what the window supports from its READY, as with a renderer process', function () { + $client = startRelayClient(new PreviewRendererRelay(), [RendererSessionConfig::GRAPHICAL_CANVAS]); + + expect($client->supports(RendererSessionConfig::GRAPHICAL_CANVAS))->toBeTrue() + ->and($client->supports(RendererSessionConfig::CANVAS_OVERLAY))->toBeFalse(); +}); + +it('queues each frame for the window in order, payload without envelope, and returns its acknowledgements', function () { + $relay = new PreviewRendererRelay(); + $client = startRelayClient($relay, [RendererSessionConfig::GRAPHICAL_CANVAS]); + $presentation = new RendererPresentation($client, new RendererGridConfig(10, 4, 16, 24)); + + $presentation->presentFrame($presentation->prepareCanvas(new PresentationCanvas(160, 96))); + $sent = $relay->collect(); + expect($sent)->not->toBeEmpty() + ->and(array_column($sent, 'type'))->toContain('frame') + ->and(array_key_exists('protocol', $sent[0]['payload']))->toBeFalse() + ->and($relay->collect())->toBe([]) + ->and($relay->getPendingWriteBytes())->toBe(0); + + $generation = $sent[array_key_last($sent)]['payload']['generation'] ?? 1; + $relay->receive([json_encode(['protocol' => 2, 'type' => 'frame_ack', 'generation' => $generation, 'frame' => 1, 'presented' => true])]); + $client->pump(); + expect(array_map(static fn($event) => $event->generation, $client->drainFrameAcknowledgements()))->toBe([$generation]); +}); + +it('refuses an answer that is not a renderer event, and anything sent once the window is detached', function () { + $relay = new PreviewRendererRelay(); + startRelayClient($relay, []); + + expect(fn() => $relay->receive(['not json']))->toThrow(RendererProtocolException::class); + $relay->shutdown(); + expect(fn() => $relay->trySend(new \Ichiloto\Engine\Rendering\Transport\RendererMessage( + \Ichiloto\Engine\Rendering\Transport\Enumerations\RendererMessageType::FRAME)))->toThrow(RendererTransportException::class); +}); diff --git a/tests/Unit/Cutscenes/PreviewSceneHostTest.php b/tests/Unit/Cutscenes/PreviewSceneHostTest.php new file mode 100644 index 0000000..b26f0d6 --- /dev/null +++ b/tests/Unit/Cutscenes/PreviewSceneHostTest.php @@ -0,0 +1,69 @@ + 2, 'type' => 'ready', 'capabilities' => PREVIEW_WINDOW_CAPABILITIES]); +} + +/** @param list}> $messages */ +function previewFrameGenerations(array $messages): array +{ + return array_values(array_filter(array_map(static fn(array $message): ?int => $message['type'] === 'frame' + ? ($message['payload']['generation'] ?? null) : null, $messages), static fn(?int $generation): bool => $generation !== null)); +} + +it('sends nothing until the window says what it can draw, then the scene as the game composes it', function () { + $root = cutsceneProject(); + $preview = CinematicPreviewSession::start($root, harbourDefinition($root), ['x' => 2, 'y' => 3, 'width' => 40, 'height' => 12]); + $grid = new RendererGridConfig(40, 12, 10, 20); + + try { + expect($preview->exchangeScene($grid, []))->toBe([]); + $sent = $preview->exchangeScene($grid, [previewWindowReady()]); + expect(array_column($sent, 'type'))->toContain('frame') + ->and(previewFrameGenerations($sent))->not->toBe([]); + } finally { + $preview->dispose(); + } +}); + +it('sends nothing while nothing visible changes, and starts again from a reset when the view is attached again', function () { + $root = cutsceneProject(); + $preview = CinematicPreviewSession::start($root, harbourDefinition($root), ['x' => 2, 'y' => 3, 'width' => 40, 'height' => 12]); + $grid = new RendererGridConfig(40, 12, 10, 20); + + try { + $first = $preview->exchangeScene($grid, [previewWindowReady()]); + $generation = max(previewFrameGenerations($first)); + expect($preview->exchangeScene($grid, [json_encode(['protocol' => 2, 'type' => 'frame_ack', + 'generation' => $generation, 'frame' => 1, 'presented' => true])]))->toBe([]); + + $preview->detachScene(); + // The Terminal picture is unchanged by the graphical view coming and going. + expect(count($preview->frame()))->toBe(12); + $again = array_values(array_filter($preview->exchangeScene($grid, [previewWindowReady()]), + static fn(array $message): bool => $message['type'] === 'frame')); + expect($again)->not->toBe([])->and($again[0]['payload']['reset'] ?? false)->toBeTrue(); + } finally { + $preview->dispose(); + } +}); From 08b33120505bf12ddefa023416f5c790a379d6bc Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 21:53:18 +0200 Subject: [PATCH 221/344] feat(canvas): copy, cut and paste a selection through one shared clipboard CanvasClipboard copies a layer's block with the tiles that move with it, and cuts and pastes it as one undo step each, a shared pasted glyph asked about as painting asks. The terminal editor's copy, cut and paste now use it, and the session offers selection.copy, selection.cut, selection.paste and selection.clipboard. The preview relay loses a helper nothing used. --- src/Canvas/CanvasClipboard.php | 111 +++++++++++++++++ .../Preview/PreviewRendererRelay.php | 7 -- src/Editor.php | 117 +++++------------- src/Session/EditorSession.php | 109 ++++++++++++++++ src/Session/SessionHost.php | 8 ++ tests/Unit/Session/SelectionSessionTest.php | 74 +++++++++++ 6 files changed, 331 insertions(+), 95 deletions(-) create mode 100644 src/Canvas/CanvasClipboard.php create mode 100644 tests/Unit/Session/SelectionSessionTest.php diff --git a/src/Canvas/CanvasClipboard.php b/src/Canvas/CanvasClipboard.php new file mode 100644 index 0000000..ef1de36 --- /dev/null +++ b/src/Canvas/CanvasClipboard.php @@ -0,0 +1,111 @@ +getLayerSymbol($layerId, $x + $column, $y + $row); + $styles[$row][$column] = $map->getLayerCellStyle($layerId, $x + $column, $y + $row); + } + } + $tiles = $withTiles ? $map->readTileEntries($map->getTileLayersMovingWith($layerId), $x, $y, $width, $height) : []; + $clipboard->store($rows, $layerId, $styles, $tiles); + } + + /** + * Copies the block into the clipboard, then clears its glyphs and the + * tiles that went with them, as one undo step. + * + * @return array{command: ?\Ichiloto\Editor\History\Command, changed: int} + * @throws MapSourceRefusal When the map cannot take the change; nothing is written then. + */ + public static function cut(ProjectMap $map, string $layerId, int $x, int $y, int $width, int $height, Clipboard $clipboard, + bool $withTiles = true): array + { + self::copy($map, $layerId, $x, $y, $width, $height, $clipboard, $withTiles); + $writes = array_map(static fn(array $cell): array => ['x' => $cell['x'], 'y' => $cell['y'], 'symbol' => ' ', 'color' => null], + ToolGeometry::rectangleFilled($x, $y, $x + $width - 1, $y + $height - 1)); + $clears = array_map(static fn(array $tiles): array => array_map( + static fn(array $cell): array => ['entry' => (string) TileId::EMPTY] + $cell, $tiles), + $clipboard->projectTiles($x, $y, $map->getWidth(), $map->getHeight())); + $applied = self::applyWithTiles($map, $layerId, $writes, 'Cut selection', $clears); + + return ['command' => $applied['command'], 'changed' => $applied['changed']]; + } + + /** + * Stamps the clipboard with its top-left cell at a map cell, as one undo + * step: its glyphs on the layer it came from, and its tiles on the tile + * layers that move with that layer. + * + * @param array $choices The role key chosen for a glyph that could be several pieces, or null for no tiles. + * @return array{command: ?\Ichiloto\Editor\History\Command, changed: int, unresolved: array>} + * @throws MapSourceRefusal When the clipboard is empty or from another layer, or the map cannot take the change. + */ + public static function paste(ProjectMap $map, string $layerId, Clipboard $clipboard, int $x, int $y, array $choices = []): array + { + if ($clipboard->isEmpty()) { + throw new MapSourceRefusal('The clipboard is empty: select part of the map and copy it first.'); + } + if ($clipboard->layer !== $layerId) { + $source = array_find($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $clipboard->layer); + throw new MapSourceRefusal($source === null + ? 'The clipboard holds a block from another layer; choose that layer before pasting.' + : sprintf('The clipboard holds a block from the %s layer; choose it before pasting.', MapLayers::formatLabel((string) $source['name']))); + } + $tiles = array_intersect_key($clipboard->projectTiles($x, $y, $map->getWidth(), $map->getHeight()), + array_flip($map->getTileLayersMovingWith($layerId))); + + return self::applyWithTiles($map, $layerId, $clipboard->project($x, $y, $map->getWidth(), $map->getHeight()), + 'Paste selection', $tiles, $choices); + } + + /** + * Writes glyphs and the tiles that follow them as one undo step, the + * edit's own tiles winning on their layers. + * + * @param array $writes + * @param array> $tiles + * @param array $choices + * @return array{command: ?\Ichiloto\Editor\History\Command, changed: int, unresolved: array>} + */ + private static function applyWithTiles(ProjectMap $map, string $layerId, array $writes, string $label, array $tiles, + array $choices = []): array + { + $plan = CanvasEditor::plan($map, $layerId, $writes, $choices, false, array_keys($tiles)); + if ($plan !== null && $plan['unresolved'] !== []) { + return ['command' => null, 'changed' => 0, 'unresolved' => $plan['unresolved']]; + } + $applied = CanvasEditor::apply($map, $layerId, $writes, $label, [...($plan['tiles'] ?? []), ...$tiles]); + + return ['command' => $applied['command'], 'changed' => $applied['changed'], 'unresolved' => []]; + } +} diff --git a/src/Cutscenes/Preview/PreviewRendererRelay.php b/src/Cutscenes/Preview/PreviewRendererRelay.php index 3ca2211..4de10ad 100644 --- a/src/Cutscenes/Preview/PreviewRendererRelay.php +++ b/src/Cutscenes/Preview/PreviewRendererRelay.php @@ -4,7 +4,6 @@ namespace Ichiloto\Editor\Cutscenes\Preview; -use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererMessageType; use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererTransportState; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererTransportException; use Ichiloto\Engine\Rendering\Transport\Interfaces\RendererTransportInterface; @@ -143,10 +142,4 @@ public function getSession(): ?RendererSessionConfig { return $this->session; } - - /** Whether a frame is waiting for the window. */ - public function hasFrame(): bool - { - return array_any($this->outgoing, static fn(RendererMessage $message): bool => $message->type === RendererMessageType::FRAME); - } } diff --git a/src/Editor.php b/src/Editor.php index c330672..f1bbd79 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -13,6 +13,7 @@ use Ichiloto\Editor\Actors\ActorIdentityMigration; use Ichiloto\Editor\Actors\ActorIdentityMigrationPlan; use Ichiloto\Editor\Backup\BackupWriter; +use Ichiloto\Editor\Canvas\CanvasClipboard; use Ichiloto\Editor\Canvas\CanvasEditor; use Ichiloto\Editor\Canvas\CanvasTool; use Ichiloto\Editor\Canvas\Clipboard; @@ -4871,45 +4872,30 @@ private function cutCanvasSelection(): void { $selectedMap = $this->getSelectedMap(); - if (! $selectedMap instanceof ProjectMap || $this->captureCanvasSelection() === 0) { + if (! $selectedMap instanceof ProjectMap || ! is_array($this->canvasSelection)) { + $this->captureCanvasSelection(); return; } $selection = $this->canvasSelection; - $cells = ToolGeometry::rectangleFilled( - $selection['x'], - $selection['y'], - $selection['x'] + $selection['width'] - 1, - $selection['y'] + $selection['height'] - 1, - ); - // The tiles that moved into the clipboard with the glyphs leave too. - $clears = array_map( - static fn(array $tiles): array => array_map( - static fn(array $cell): array => ['entry' => (string) TileId::EMPTY] + $cell, - $tiles, - ), - $this->clipboard->projectTiles($selection['x'], $selection['y'], $selectedMap->getWidth(), $selectedMap->getHeight()), - ); - $changed = $this->commitCanvasWrites( - $selectedMap, - array_map( - fn(array $cell): array => ['x' => $cell['x'], 'y' => $cell['y'], 'symbol' => ' ', 'color' => $this->selectedPaintColor], - $cells, - ), - 'cut selection', - $clears, - ); - - if ($changed === null) { + $this->finalizeActiveStroke(); + try { + $cut = CanvasClipboard::cut($selectedMap, $this->getActiveCanvasLayer(), $selection['x'], $selection['y'], + $selection['width'], $selection['height'], $this->clipboard, $this->editingMode !== self::MODE_NPC); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); $this->renderCanvasArea(); return; } + if ($cut['command'] !== null) { + $this->recordCommand($cut['command']); + } $this->setStatus(sprintf( 'Cut %d x %d (%d cell%s cleared).', $this->clipboard->getWidth(), $this->clipboard->getHeight(), - $changed, - $changed === 1 ? '' : 's', + $cut['changed'], + $cut['changed'] === 1 ? '' : 's', )); $this->renderCanvasArea(); } @@ -4934,40 +4920,16 @@ private function captureCanvasSelection(): int } $selection = $this->canvasSelection; - $rows = []; - $styles = []; - - for ($rowIndex = 0; $rowIndex < $selection['height']; $rowIndex++) { - $row = []; - $styleRow = []; - - for ($columnIndex = 0; $columnIndex < $selection['width']; $columnIndex++) { - $row[] = $this->readCanvasSymbol($selectedMap, $selection['x'] + $columnIndex, $selection['y'] + $rowIndex); - - $styleRow[] = $selectedMap->getLayerCellStyle($this->getActiveCanvasLayer(), $selection['x'] + $columnIndex, $selection['y'] + $rowIndex); - } - - $rows[] = $row; - $styles[] = $styleRow; - } - // The tiles that move with this layer's glyphs travel with the block. try { - $tiles = $this->editingMode === self::MODE_NPC ? [] : $selectedMap->readTileEntries( - $selectedMap->getTileLayersMovingWith($this->getActiveCanvasLayer()), - $selection['x'], - $selection['y'], - $selection['width'], - $selection['height'], - ); + CanvasClipboard::copy($selectedMap, $this->getActiveCanvasLayer(), $selection['x'], $selection['y'], + $selection['width'], $selection['height'], $this->clipboard, $this->editingMode !== self::MODE_NPC); } catch (MapSourceRefusal $refusal) { $this->setStatus($refusal->getMessage(), StatusLevel::WARN); $this->renderFooter(); return 0; } - $this->clipboard->store($rows, $this->getActiveCanvasLayer(), $styles, $tiles); - return $selection['width'] * $selection['height']; } @@ -4985,43 +4947,22 @@ private function pasteCanvasClipboard(array $choices = []): void return; } - if ($this->clipboard->isEmpty()) { - $this->setStatus('The clipboard is empty - select a region and press Ctrl+L.', StatusLevel::WARN); + $this->finalizeActiveStroke(); + try { + $pasted = CanvasClipboard::paste($selectedMap, $this->getActiveCanvasLayer(), $this->clipboard, + $this->cursorX, $this->cursorY, $choices); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); $this->renderFooter(); return; } - - if ($this->clipboard->layer !== $this->getActiveCanvasLayer()) { - $source = array_find($this->getSelectedMap()?->getLayers() ?? [], - fn(array $layer): bool => $layer['id'] === $this->clipboard->layer); - $this->setStatus( - $source === null - ? 'The clipboard holds a block from another layer; switch layers before pasting.' - : sprintf('The clipboard holds a block from the %s layer; switch to it before pasting.', - MapLayers::formatLabel($source['name'])), - StatusLevel::WARN, - ); - $this->renderFooter(); + if ($pasted['unresolved'] !== []) { + $glyph = (string) array_key_first($pasted['unresolved']); + $this->askForGlyphPiece($glyph, $pasted['unresolved'][$glyph], $choices, $this->pasteCanvasClipboard(...)); return; } - - $writes = $this->clipboard->project( - $this->cursorX, - $this->cursorY, - $selectedMap->getWidth(), - $selectedMap->getHeight(), - ); - // Tiles land only on the layers that move with this map's layer. - $tiles = array_intersect_key( - $this->clipboard->projectTiles($this->cursorX, $this->cursorY, $selectedMap->getWidth(), $selectedMap->getHeight()), - array_flip($selectedMap->getTileLayersMovingWith($this->getActiveCanvasLayer())), - ); - $changed = $this->commitCanvasWrites($selectedMap, $writes, 'pasted selection', $tiles, - $this->pasteCanvasClipboard(...), $choices); - - if ($changed === null) { - $this->renderCanvasArea(); - return; + if ($pasted['command'] !== null) { + $this->recordCommand($pasted['command']); } $this->setStatus(sprintf( 'Stamped %d x %d at (%d, %d): %d cell%s changed.', @@ -5029,8 +4970,8 @@ private function pasteCanvasClipboard(array $choices = []): void $this->clipboard->getHeight(), $this->cursorX, $this->cursorY, - $changed, - $changed === 1 ? '' : 's', + $pasted['changed'], + $pasted['changed'] === 1 ? '' : 's', )); $this->renderCanvasArea(); } diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 836abcf..b841fbe 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -6,7 +6,9 @@ use Ichiloto\Editor\Backup\BackupSettings; use Ichiloto\Editor\Backup\BackupWriter; +use Ichiloto\Editor\Canvas\CanvasClipboard; use Ichiloto\Editor\Canvas\CanvasEditor; +use Ichiloto\Editor\Canvas\Clipboard; use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Cutscenes\CutsceneRecordCategory; use Ichiloto\Editor\Cutscenes\CutsceneType; @@ -138,6 +140,9 @@ final class EditorSession /** The battle test running in its own window, and the troop it fights. */ private ?PlaytestRun $battleTestRun = null; + /** The block an author last copied or cut, for pasting on its layer. */ + private ?Clipboard $clipboard = null; + /** The music or sound an author is hearing from a field, once one has been played. */ private ?AudioAudition $audition = null; private ?string $battleTestTroop = null; @@ -941,6 +946,110 @@ public function readCoverage(string $mapId): array ]; } + /** + * Copies a rectangle of a layer into the session's clipboard: its glyphs + * and styles, and the tiles that move with that layer ({@see CanvasClipboard::copy()}). + * + * @return array{layer: string, width: int, height: int} + * @throws SessionRefusal When the map, layer or rectangle is unknown, or a tile layer cannot be read. + */ + public function copySelection(string $mapId, string $layerId, int $x, int $y, int $width, int $height): array + { + $map = $this->requireMap($mapId); + $this->requireSelection($map, $layerId, $x, $y, $width, $height); + try { + CanvasClipboard::copy($map, $layerId, $x, $y, $width, $height, $this->clipboard ??= new Clipboard()); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + + return $this->describeClipboard(); + } + + /** + * Copies a rectangle of a layer into the session's clipboard and clears + * it, glyphs and the tiles that went with them, as one undo step. + * + * @return array{layer: string, width: int, height: int, changed: int, revision: int} + * @throws SessionRefusal When the map is unknown, stale or read-only, or the layer or rectangle is unknown. + */ + public function cutSelection(string $mapId, int $revision, string $layerId, int $x, int $y, int $width, int $height): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + $this->requireSelection($map, $layerId, $x, $y, $width, $height); + try { + $cut = CanvasClipboard::cut($map, $layerId, $x, $y, $width, $height, $this->clipboard ??= new Clipboard()); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($cut['command'] !== null) { + $this->history->record($cut['command']); + } + + return [...$this->describeClipboard(), 'changed' => $cut['changed'], 'revision' => $this->getMapRevision($map)]; + } + + /** + * Stamps the session's clipboard with its top-left cell at a map cell as + * one undo step. A glyph that could be several pieces is asked about, and + * nothing changes until the paste is made again with the answer. + * + * @param array $choices + * @return array{status: 'applied', changed: int, revision: int}|array{status: 'question', glyph: string, roles: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, the clipboard is empty or from another layer. + */ + public function pasteSelection(string $mapId, int $revision, string $layerId, int $x, int $y, array $choices = []): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + try { + $pasted = CanvasClipboard::paste($map, $layerId, $this->clipboard ??= new Clipboard(), $x, $y, $choices); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($pasted['unresolved'] !== []) { + $glyph = (string) array_key_first($pasted['unresolved']); + + return ['status' => 'question', 'glyph' => $glyph, 'roles' => array_map( + static fn(PieceRole $role): array => ['key' => $role->key, 'label' => $role->label], + $pasted['unresolved'][$glyph], + )]; + } + if ($pasted['command'] !== null) { + $this->history->record($pasted['command']); + } + + return ['status' => 'applied', 'changed' => $pasted['changed'], 'revision' => $this->getMapRevision($map)]; + } + + /** + * What the session's clipboard holds: the layer it came from and its size; an empty layer when it holds nothing. + * + * @return array{layer: string, width: int, height: int} + */ + public function describeClipboard(): array + { + $clipboard = $this->clipboard ?? new Clipboard(); + + return ['layer' => $clipboard->layer, 'width' => $clipboard->getWidth(), 'height' => $clipboard->getHeight()]; + } + + /** @throws SessionRefusal When the layer is not the map's, or the rectangle is empty or leaves the map. */ + private function requireSelection(ProjectMap $map, string $layerId, int $x, int $y, int $width, int $height): void + { + if (! array_any($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $layerId)) { + throw new SessionRefusal(sprintf('%s has no layer %s.', $map->mapId, $layerId)); + } + if ($width < 1 || $height < 1 || $x < 0 || $y < 0 || $x + $width > $map->getWidth() || $y + $height > $map->getHeight()) { + throw new SessionRefusal(sprintf('A selection of %d x %d at (%d, %d) does not fit %s.', $width, $height, $x, $y, $map->mapId)); + } + } + /** * Draws the tiles of the glyphs already on a layer as one undo step * ({@see CanvasEditor::drawTilesForGlyphs()}): every glyph a piece of the diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 671c204..da9ea0a 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -188,6 +188,14 @@ private function dispatch(string $method, array $params): array self::requireCell($params, 'to'), self::readOptionalString($params, 'color'), ), + 'selection.copy' => $session->copySelection(self::requireString($params, 'map'), self::requireString($params, 'layer'), + self::requireInt($params, 'x'), self::requireInt($params, 'y'), self::requireInt($params, 'width'), self::requireInt($params, 'height')), + 'selection.cut' => $session->cutSelection(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), + self::requireInt($params, 'width'), self::requireInt($params, 'height')), + 'selection.paste' => $session->pasteSelection(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), self::readChoices($params)), + 'selection.clipboard' => $session->describeClipboard(), 'map.coverage' => $session->readCoverage(self::requireString($params, 'map')), 'layer.drawTiles' => $session->drawLayerTiles( self::requireString($params, 'map'), diff --git a/tests/Unit/Session/SelectionSessionTest.php b/tests/Unit/Session/SelectionSessionTest.php new file mode 100644 index 0000000..8e8fe3c --- /dev/null +++ b/tests/Unit/Session/SelectionSessionTest.php @@ -0,0 +1,74 @@ +readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] === 'map:4'); + + return array_map(static fn(array $row): string => implode('', $row), $layer['rows']); +} + +it('copies a block and pastes it elsewhere on its layer as one undo step', function () { + $session = selectionSession(); + + expect($session->copySelection('test-map', 'map:4', 1, 0, 2, 1))->toBe(['layer' => 'map:4', 'width' => 2, 'height' => 1]); + expect($session->pasteSelection('test-map', $session->readMap('test-map')['revision'], 'map:4', 1, 1)) + ->toMatchArray(['status' => 'applied', 'changed' => 2]) + ->and(readSelectionRows($session))->toBe([' / ', ' / ']); + + $session->undo(); + expect(readSelectionRows($session))->toBe([' / ', ' xx ']); +}); + +it('cuts a block, leaving its cells empty, and puts it back where it is pasted', function () { + $session = selectionSession(); + + expect($session->cutSelection('test-map', $session->readMap('test-map')['revision'], 'map:4', 1, 1, 2, 1)) + ->toMatchArray(['width' => 2, 'height' => 1, 'changed' => 2]) + ->and(readSelectionRows($session))->toBe([' / ', ' ']); + $session->pasteSelection('test-map', $session->readMap('test-map')['revision'], 'map:4', 2, 1); + expect(readSelectionRows($session))->toBe([' / ', ' xx']); +}); + +it('asks which piece a pasted glyph is when several draw it', function () { + $session = selectionSession([ + 'crate' => ['name' => 'Crate', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['61']]], + 'barrel' => ['name' => 'Barrel', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['62']]], + ]); + $session->copySelection('test-map', 'map:4', 1, 1, 1, 1); + + expect($session->pasteSelection('test-map', $session->readMap('test-map')['revision'], 'map:4', 0, 0)) + ->toMatchArray(['status' => 'question', 'glyph' => 'x']) + ->and(readSelectionRows($session)[0])->toBe(' / '); +}); + +it('refuses an empty clipboard, another layer\'s block, or a selection off the map, changing nothing', function () { + $session = selectionSession(); + $revision = $session->readMap('test-map')['revision']; + + expect(fn() => $session->pasteSelection('test-map', $revision, 'map:4', 0, 0))->toThrow(SessionRefusal::class, 'clipboard is empty') + ->and(fn() => $session->copySelection('test-map', 'map:4', 3, 1, 2, 1))->toThrow(SessionRefusal::class, 'does not fit'); + $session->copySelection('test-map', 'map:4', 0, 0, 1, 1); + $other = array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] !== 'map:4' && ! $layer['event'])['id']; + expect(fn() => $session->pasteSelection('test-map', $revision, $other, 0, 0))->toThrow(SessionRefusal::class, 'before pasting') + ->and($session->readMap('test-map')['dirty'])->toBeFalse(); +}); From 10e036831dbb88b3986c36d4269211d60c46bd0c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 21:55:11 +0200 Subject: [PATCH 222/344] feat(canvas): the eyedropper picks up the piece a glyph plays CanvasEditor::findPlayedRole reads which piece a layer's glyph plays at a cell from the tiles around it; the terminal editor's eyedropper uses it, and the session answers map.pieceAt with it. --- src/Canvas/CanvasEditor.php | 19 ++++++++++++++++ src/Canvas/GlyphTileCanvas.php | 13 ++--------- src/Session/EditorSession.php | 22 +++++++++++++++++++ src/Session/SessionHost.php | 2 ++ .../Session/DrawLayerTilesSessionTest.php | 13 +++++++++++ 5 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/Canvas/CanvasEditor.php b/src/Canvas/CanvasEditor.php index fc0f44b..6657e6e 100644 --- a/src/Canvas/CanvasEditor.php +++ b/src/Canvas/CanvasEditor.php @@ -66,6 +66,25 @@ public static function plan(ProjectMap $map, string $layerId, array $writes, arr ); } + /** + * The piece a layer's glyph plays at a cell, read from the tiles around + * it: what an eyedropper picks up with the glyph, so painting it again + * draws the same piece. Null when no piece of the map's kind draws it, or + * its tiles do not tell which. + */ + public static function findPlayedRole(ProjectMap $map, string $layerId, int $x, int $y): ?PieceRole + { + $planner = self::loadGlyphTilePlanner($map, $layerId); + if ($planner === null || ! $map->hasLayerCell($layerId, $x, $y)) { + return null; + } + try { + return $planner->findPlayedRole($map->getLayerSymbol($layerId, $x, $y), $x, $y, self::createTileReader($map)); + } catch (MapSourceRefusal) { + return null; + } + } + /** * Draws the tiles of the glyphs already on a layer, as if each were * painted again: every glyph a piece draws gets that piece's tiles, as one diff --git a/src/Canvas/GlyphTileCanvas.php b/src/Canvas/GlyphTileCanvas.php index c9098cf..22aee42 100644 --- a/src/Canvas/GlyphTileCanvas.php +++ b/src/Canvas/GlyphTileCanvas.php @@ -264,20 +264,11 @@ private function pickPaintPieceRole(ProjectMap $map, int $x, int $y): string { $this->paintPieceRole = null; $layerId = $this->getActiveCanvasLayer(); - $planner = CanvasEditor::loadGlyphTilePlanner($map, $layerId); - if ($planner === null || ! $map->hasLayerCell($layerId, $x, $y)) { - return ''; - } - $symbol = $map->getLayerSymbol($layerId, $x, $y); - try { - $role = $planner->findPlayedRole($symbol, $x, $y, CanvasEditor::createTileReader($map)); - } catch (MapSourceRefusal) { - return ''; - } + $role = CanvasEditor::findPlayedRole($map, $layerId, $x, $y); if ($role === null) { return ''; } - $this->paintPieceRole = ['symbol' => $symbol, 'role' => $role->key]; + $this->paintPieceRole = ['symbol' => $map->getLayerSymbol($layerId, $x, $y), 'role' => $role->key]; return $role->label; } diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index b841fbe..e650fee 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -946,6 +946,28 @@ public function readCoverage(string $mapId): array ]; } + /** + * The piece a layer's glyph plays at a cell ({@see CanvasEditor::findPlayedRole()}): + * what an eyedropper picks up with the glyph, so painting it again draws + * the same piece. Null role when no piece draws it or its tiles do not tell. + * + * @return array{glyph: string, role: ?array{key: string, label: string}} + * @throws SessionRefusal When the map or layer is unknown. + */ + public function readPieceAt(string $mapId, string $layerId, int $x, int $y): array + { + $map = $this->requireMap($mapId); + if (! array_any($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $layerId)) { + throw new SessionRefusal(sprintf('%s has no layer %s.', $mapId, $layerId)); + } + $role = CanvasEditor::findPlayedRole($map, $layerId, $x, $y); + + return [ + 'glyph' => $map->hasLayerCell($layerId, $x, $y) ? $map->getLayerSymbol($layerId, $x, $y) : ' ', + 'role' => $role === null ? null : ['key' => $role->key, 'label' => $role->label], + ]; + } + /** * Copies a rectangle of a layer into the session's clipboard: its glyphs * and styles, and the tiles that move with that layer ({@see CanvasClipboard::copy()}). diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index da9ea0a..380c36c 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -188,6 +188,8 @@ private function dispatch(string $method, array $params): array self::requireCell($params, 'to'), self::readOptionalString($params, 'color'), ), + 'map.pieceAt' => $session->readPieceAt(self::requireString($params, 'map'), self::requireString($params, 'layer'), + self::requireInt($params, 'x'), self::requireInt($params, 'y')), 'selection.copy' => $session->copySelection(self::requireString($params, 'map'), self::requireString($params, 'layer'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), self::requireInt($params, 'width'), self::requireInt($params, 'height')), 'selection.cut' => $session->cutSelection(self::requireString($params, 'map'), self::requireInt($params, 'revision'), diff --git a/tests/Unit/Session/DrawLayerTilesSessionTest.php b/tests/Unit/Session/DrawLayerTilesSessionTest.php index 8892600..50433b0 100644 --- a/tests/Unit/Session/DrawLayerTilesSessionTest.php +++ b/tests/Unit/Session/DrawLayerTilesSessionTest.php @@ -82,3 +82,16 @@ function readDrawnTiles(EditorSession $session, string $layer): array $session->drawLayerTiles('test-map', $before['revision'], 'map:4', ['x' => null]); expect($shownRamps($session->readCoverage('test-map')))->toBe([]); }); + +it('says which piece a glyph plays at a cell, from the tiles around it, for the eyedropper', function () { + $session = drawLayerTilesSession([ + 'crate' => ['name' => 'Crate', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['61']]], + 'barrel' => ['name' => 'Barrel', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['62']]], + ]); + expect($session->readPieceAt('test-map', 'map:4', 1, 1))->toBe(['glyph' => 'x', 'role' => null]); + + $revision = $session->readMap('test-map')['revision']; + $barrel = array_find($session->drawLayerTiles('test-map', $revision, 'map:4')['roles'], static fn(array $role): bool => str_contains($role['label'], 'Barrel'))['key']; + $session->drawLayerTiles('test-map', $revision, 'map:4', ['x' => $barrel]); + expect($session->readPieceAt('test-map', 'map:4', 1, 1)['role']['key'])->toBe($barrel); +}); From f87b04b6032f6169c2b7b49a016e6fff29c9ee77 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 22:09:03 +0200 Subject: [PATCH 223/344] feat(preview): one isolated capture for both views, and the graphical view on the session The preview camera is the field's own camera, so a previewed scene draws through the Engine's Console and is taken by its isolated capture: the Terminal picture is that capture's rows, and the graphical view composes from the same capture. The graphical view's grid is the preview's screen in the game's named GPUI text cells, and the session offers it as cutscenes.cinematicScene and cutscenes.cinematicSceneDetach. While attached the Engine sizes the camera to the graphical field's viewport, as the game does; detaching gives the Terminal picture its own screen back. --- .../Preview/CinematicPreviewSession.php | 39 ++-- src/Cutscenes/Preview/PreviewCamera.php | 183 +----------------- src/Cutscenes/Preview/PreviewGameScene.php | 2 +- src/Session/EditorSession.php | 36 ++++ src/Session/SessionHost.php | 16 ++ tests/Unit/Cutscenes/PreviewSceneHostTest.php | 37 +++- tests/Unit/LayeredPreviewTest.php | 6 +- .../Session/CutsceneRecordSessionTest.php | 18 ++ 8 files changed, 134 insertions(+), 203 deletions(-) diff --git a/src/Cutscenes/Preview/CinematicPreviewSession.php b/src/Cutscenes/Preview/CinematicPreviewSession.php index ca1f3f2..fe016cb 100644 --- a/src/Cutscenes/Preview/CinematicPreviewSession.php +++ b/src/Cutscenes/Preview/CinematicPreviewSession.php @@ -13,6 +13,8 @@ use Ichiloto\Engine\Events\Interpreter\EventExecutionSession; use Ichiloto\Engine\Events\Interpreter\EventExecutionStatus; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; +use Ichiloto\Engine\IO\Console\Console; +use Ichiloto\Engine\Rendering\Runtime\RendererRuntimeConfig; use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; use Ichiloto\Engine\Util\Config\ConfigStore; use Ichiloto\Engine\Util\Config\PlaySettings; @@ -71,6 +73,9 @@ final class CinematicPreviewSession private ?BackgroundMusicState $musicBefore = null; /** The editor window's graphical view of the scene, while one is attached. */ private ?PreviewSceneHost $sceneHost = null; + /** The preview's screen in Terminal cells, which the graphical view's camera viewport never changes. */ + private int $screenWidth = 60; + private int $screenHeight = 18; /** * @param array{mapId?: string|null, x?: int, y?: int, width?: int, height?: int, autoAdvance?: bool, battleOutcome?: string} $options @@ -111,8 +116,8 @@ public function restart(): self private function boot(): void { - $width = max(20, intval($this->options['width'] ?? 60)); - $height = max(8, intval($this->options['height'] ?? 18)); + $width = $this->screenWidth = max(20, intval($this->options['width'] ?? 60)); + $height = $this->screenHeight = max(8, intval($this->options['height'] ?? 18)); $mapId = $this->options['mapId'] ?? $this->definition->startMap; $mapId = is_string($mapId) && trim($mapId) !== '' ? trim($mapId) : ''; @@ -552,10 +557,8 @@ public function frame(): array { $rows = []; $this->run(function () use (&$rows): void { - $camera = $this->scene->previewCamera(); - $camera->clearFrame(); - $this->renderScene(); - $rows = $camera->frame(); + // The same isolated capture the graphical view composes from, with nothing left out. + $rows = Console::capturePresentation($this->screenWidth, $this->screenHeight, $this->renderScene(...))->rows ?? []; }); return $rows; @@ -564,17 +567,19 @@ public function frame(): array /** * One exchange with the editor window's graphical view of the scene * ({@see PreviewSceneHost::exchange()}): its renderer event lines in, what - * it should apply out. A grid other than the attached view's starts the - * view again, as a resized renderer session does. + * it should apply out. The view's grid is the preview's screen in the + * game's graphical text cells, so it shows what the game's graphical + * field would. While attached, the Engine sizes the preview's camera to + * the graphical field's viewport, as the game does, so the Terminal + * picture is the game's Terminal view only while detached. * * @param list $events * @return list}> */ - public function exchangeScene(RendererGridConfig $grid, array $events): array + public function exchangeScene(array $events): array { - if ($this->sceneHost !== null && $this->sceneHost->grid != $grid) { - $this->detachScene(); - } + $grid = new RendererGridConfig($this->screenWidth, $this->screenHeight, + RendererRuntimeConfig::GPUI_CELL_WIDTH, RendererRuntimeConfig::GPUI_CELL_HEIGHT); $messages = []; $this->run(function () use ($grid, $events, &$messages): void { $this->sceneHost ??= new PreviewSceneHost($this->scene, $this->presentation, @@ -610,9 +615,7 @@ private function renderScene(): void */ public function getScreenSize(): array { - $screen = $this->scene->previewCamera()->screen; - - return [$screen->getWidth(), $screen->getHeight()]; + return [$this->screenWidth, $this->screenHeight]; } /** @@ -620,7 +623,11 @@ public function getScreenSize(): array */ public function resize(int $width, int $height): void { - $this->scene->previewCamera()->resize(max(20, $width), max(8, $height)); + // The graphical view's grid is the screen's, so a new size starts it again. + $this->detachScene(); + $this->screenWidth = max(20, $width); + $this->screenHeight = max(8, $height); + $this->scene->previewCamera()->resize($this->screenWidth, $this->screenHeight); } /** diff --git a/src/Cutscenes/Preview/PreviewCamera.php b/src/Cutscenes/Preview/PreviewCamera.php index e04cb1a..b8e6bb0 100644 --- a/src/Cutscenes/Preview/PreviewCamera.php +++ b/src/Cutscenes/Preview/PreviewCamera.php @@ -4,33 +4,22 @@ namespace Ichiloto\Editor\Cutscenes\Preview; -use Ichiloto\Engine\Core\Rect; -use Ichiloto\Engine\Core\Vector2; -use Ichiloto\Engine\Field\MapLayerSet; -use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\Rendering\Camera; +use Ichiloto\Engine\Scenes\Interfaces\SceneInterface; /** - * A camera that draws into a frame buffer instead of the terminal. - * - * Everything the Engine paints during a cinematic — the map, staged actors, - * NPCs, the player, title cards, narration boxes, field animations, and - * transition covers — reaches the terminal through the camera, so capturing - * here gives the preview pane an honest picture of the moment without the - * Engine ever writing a byte over the editor's own screen. + * The camera of a preview scene: the field's own camera, drawing through the + * Engine's Console as the game's does, so a preview's picture is taken in an + * isolated Console capture ({@see \Ichiloto\Engine\IO\Console\Console::capturePresentation()}) + * and the editor's own screen is never written. The field camera's UI + * lifecycle belongs to the game's UI manager, which a preview scene has none + * of, so it does nothing here. */ final class PreviewCamera extends Camera { - /** @var string[] The captured rows, one per screen line. */ - private array $frame = []; - - public function __construct(int $width, int $height) + public function __construct(SceneInterface $scene, int $width, int $height) { - $this->screen = new Rect(0, 0, max(1, $width), max(1, $height)); - $this->position = new Vector2(0, 0); - $this->player = null; - $this->worldSpace = []; - $this->clearFrame(); + parent::__construct($scene, max(1, $width), max(1, $height)); } /** @@ -39,97 +28,6 @@ public function __construct(int $width, int $height) public function resize(int $width, int $height): void { $this->resizeViewport($width, $height); - $this->clearFrame(); - } - - /** - * Blanks the frame before the scene is composed again. - */ - public function clearFrame(): void - { - $this->frame = array_fill(0, $this->screen->getHeight(), str_repeat(' ', $this->screen->getWidth())); - } - - /** - * Returns the captured rows. - * - * @return string[] - */ - public function frame(): array - { - return $this->frame; - } - - /** - * Paints the world (the map tiles) into the frame. - */ - public function renderMap(): void - { - $renderOffset = $this->getRenderOffset(); - $visibleWidth = $this->getVisibleWorldWidth(); - $visibleHeight = $this->getVisibleWorldHeight(); - - for ($row = 0; $row < $visibleHeight; $row++) { - $worldRow = $this->worldSpace[(int) $this->position->y + $row] ?? null; - - if ($worldRow === null) { - continue; - } - - $content = is_array($worldRow) - ? implode('', array_slice($worldRow, (int) $this->position->x, $visibleWidth)) - : TerminalText::sliceSymbols((string) $worldRow, (int) $this->position->x, $visibleWidth); - - $this->draw(TerminalText::padRight($content, $visibleWidth), (int) $renderOffset->x, (int) $renderOffset->y + $row); - } - } - - public function renderLayeredMap(MapLayerSet $layers): void - { - $this->renderMap(); - } - - public function draw(iterable|string $content, int $x = 0, int $y = 0): void - { - $lines = []; - - // A row handed over with a newline inside it would break the - // terminal's row, so it continues on the next row here as well. - foreach (is_string($content) ? [$content] : $content as $line) { - foreach (explode("\n", (string) $line) as $part) { - $lines[] = $part; - } - } - - $height = $this->screen->getHeight(); - $width = $this->screen->getWidth(); - $index = 0; - - foreach ($lines as $line) { - $row = $y + $index; - $index++; - - if ($row < 0 || $row >= $height) { - continue; - } - - $this->writeRow($row, max(0, min($x, $width - 1)), (string) $line); - } - } - - public function renderOnScreen(array $output, Vector2 $worldSpacePosition): void - { - $screenSpacePosition = $this->getScreenSpacePosition($worldSpacePosition); - $this->renderAtScreenPosition($output, $screenSpacePosition); - } - - public function renderAtScreenPosition(array|string $output, Vector2 $screenSpacePosition): void - { - $rows = is_array($output) ? array_values($output) : [$output]; - - foreach ($rows as $rowIndex => $row) { - $this->draw((string) $row, (int) $screenSpacePosition->x, (int) $screenSpacePosition->y + $rowIndex); - } } public function start(): void @@ -159,67 +57,4 @@ public function suspend(): void public function update(): void { } - - /** - * Writes text into one frame row, symbol by symbol, honouring display - * width so a wide glyph occupies the cells it would on a terminal. - */ - private function writeRow(int $row, int $x, string $text): void - { - $width = $this->screen->getWidth(); - $plain = TerminalText::stripAnsi(TerminalText::formatStyles($text)); - $plain = preg_replace('/\x1b\[[0-9;]*m/', '', $plain) ?? $plain; - $cells = TerminalText::visibleSymbols($this->frame[$row] ?? str_repeat(' ', $width)); - $cells = $this->normalizeCells($cells, $width); - $cursor = $x; - - foreach (TerminalText::visibleSymbols($plain) as $symbol) { - $symbolWidth = max(1, TerminalText::displayWidth($symbol)); - - if ($cursor >= $width || $cursor + $symbolWidth > $width) { - break; - } - - $cells[$cursor] = $symbol; - - for ($offset = 1; $offset < $symbolWidth; $offset++) { - $cells[$cursor + $offset] = ''; - } - - $cursor += $symbolWidth; - } - - $this->frame[$row] = implode('', $cells); - } - - /** - * Expands a row into exactly `$width` cells, one per terminal column. - * - * @param string[] $symbols - * @return string[] - */ - private function normalizeCells(array $symbols, int $width): array - { - $cells = []; - $column = 0; - - foreach ($symbols as $symbol) { - $symbolWidth = max(1, TerminalText::displayWidth($symbol)); - $cells[$column] = $symbol; - - for ($offset = 1; $offset < $symbolWidth; $offset++) { - $cells[$column + $offset] = ''; - } - - $column += $symbolWidth; - } - - for (; $column < $width; $column++) { - $cells[$column] = ' '; - } - - ksort($cells); - - return array_slice($cells, 0, $width, true); - } } diff --git a/src/Cutscenes/Preview/PreviewGameScene.php b/src/Cutscenes/Preview/PreviewGameScene.php index f7cc0f7..8a166f9 100644 --- a/src/Cutscenes/Preview/PreviewGameScene.php +++ b/src/Cutscenes/Preview/PreviewGameScene.php @@ -48,7 +48,7 @@ public function __construct( $this->gameState = new GameState(); $this->hasDeferredAutoSave = false; $this->currentMapId = $initialMapId; - $this->camera = new PreviewCamera($screenWidth, $screenHeight); + $this->camera = new PreviewCamera($this, $screenWidth, $screenHeight); $this->party = new Party(); $this->knowledge = new KnowledgeProgressService(KnowledgeCatalog::fromProject()); $this->bestiary = new Bestiary($this->knowledge); diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index e650fee..575b238 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -23,6 +23,8 @@ use Ichiloto\Editor\Canvas\ToolGeometry; use Ichiloto\Engine\Rendering\Tilesets\TileId; use Ichiloto\Engine\Rendering\Tilesets\TilesetPiece; +use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererProtocolException; +use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererTransportException; use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Ichiloto\Engine\Rendering\Tilesets\TilesetSheet; use Ichiloto\Editor\Database\ConditionCodec; @@ -2035,6 +2037,40 @@ public function stopCinematicPreview(): array return ['stopped' => $stopped]; } + /** + * One exchange with the editor window's graphical view of the cinematic + * being previewed ({@see CinematicPreviewSession::exchangeScene()}): the + * window's renderer event lines in (its READY, frame acknowledgements and + * rejections, exactly as the game's renderer writes them), the frames it + * should apply out, in order, each its FRAME payload without envelope. + * + * @param list $events + * @return array{messages: list}>} + * @throws SessionRefusal When no cinematic is previewed or a line is not a renderer event. + */ + public function exchangeCinematicScene(array $events): array + { + $preview = $this->cinematicPreview ?? throw new SessionRefusal('No cinematic is being previewed.'); + try { + return ['messages' => $preview->exchangeScene($events)]; + } catch (RendererProtocolException | RendererTransportException $error) { + throw new SessionRefusal('The graphical preview stopped: ' . $error->getMessage(), previous: $error); + } + } + + /** + * Ends the graphical view of the cinematic being previewed, giving the + * Terminal picture back the preview's own screen. + * + * @return array{detached: bool} + */ + public function detachCinematicScene(): array + { + $this->cinematicPreview?->detachScene(); + + return ['detached' => $this->cinematicPreview !== null]; + } + /** * A terminal picture as a renderer paints it: the Engine's styled runs on the * picture's own grid, its colours kept and its control codes never counted as diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 380c36c..2b4536c 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -400,6 +400,8 @@ private function dispatch(string $method, array $params): array 'cutscenes.cinematicControl' => $session->controlCinematicPreview(self::requireString($params, 'action'), is_int($params['seconds'] ?? null) || is_float($params['seconds'] ?? null) ? (float) $params['seconds'] : 0.0), 'cutscenes.cinematicStop' => $session->stopCinematicPreview(), + 'cutscenes.cinematicScene' => $session->exchangeCinematicScene(self::requireLines($params, 'events')), + 'cutscenes.cinematicSceneDetach' => $session->detachCinematicScene(), 'cutscenes.effectBattlePreview' => $session->readEffectBattlePreview(self::requireInt($params, 'index'), is_int($params['frame'] ?? null) ? $params['frame'] : 0, ($params['reducedMotion'] ?? false) === true, EffectPresentation::tryFrom(is_string($params['presentation'] ?? null) ? $params['presentation'] : 'graphical') @@ -555,6 +557,20 @@ private static function requireCells(array $params): array return $cells; } + /** + * @param array $params + * @return list + */ + private static function requireLines(array $params, string $key): array + { + $lines = $params[$key] ?? []; + if (! is_array($lines) || ! array_is_list($lines) || ! array_all($lines, is_string(...))) { + throw new InvalidRequest(sprintf('"%s" must be a list of strings.', $key)); + } + + return $lines; + } + /** * @param array $params * @return array{0: int, 1: int} diff --git a/tests/Unit/Cutscenes/PreviewSceneHostTest.php b/tests/Unit/Cutscenes/PreviewSceneHostTest.php index b26f0d6..dbb0817 100644 --- a/tests/Unit/Cutscenes/PreviewSceneHostTest.php +++ b/tests/Unit/Cutscenes/PreviewSceneHostTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewSession; -use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; /** @@ -34,11 +33,10 @@ function previewFrameGenerations(array $messages): array it('sends nothing until the window says what it can draw, then the scene as the game composes it', function () { $root = cutsceneProject(); $preview = CinematicPreviewSession::start($root, harbourDefinition($root), ['x' => 2, 'y' => 3, 'width' => 40, 'height' => 12]); - $grid = new RendererGridConfig(40, 12, 10, 20); try { - expect($preview->exchangeScene($grid, []))->toBe([]); - $sent = $preview->exchangeScene($grid, [previewWindowReady()]); + expect($preview->exchangeScene([]))->toBe([]); + $sent = $preview->exchangeScene([previewWindowReady()]); expect(array_column($sent, 'type'))->toContain('frame') ->and(previewFrameGenerations($sent))->not->toBe([]); } finally { @@ -49,21 +47,40 @@ function previewFrameGenerations(array $messages): array it('sends nothing while nothing visible changes, and starts again from a reset when the view is attached again', function () { $root = cutsceneProject(); $preview = CinematicPreviewSession::start($root, harbourDefinition($root), ['x' => 2, 'y' => 3, 'width' => 40, 'height' => 12]); - $grid = new RendererGridConfig(40, 12, 10, 20); try { - $first = $preview->exchangeScene($grid, [previewWindowReady()]); + $first = $preview->exchangeScene([previewWindowReady()]); $generation = max(previewFrameGenerations($first)); - expect($preview->exchangeScene($grid, [json_encode(['protocol' => 2, 'type' => 'frame_ack', + expect($preview->exchangeScene([json_encode(['protocol' => 2, 'type' => 'frame_ack', 'generation' => $generation, 'frame' => 1, 'presented' => true])]))->toBe([]); $preview->detachScene(); - // The Terminal picture is unchanged by the graphical view coming and going. - expect(count($preview->frame()))->toBe(12); - $again = array_values(array_filter($preview->exchangeScene($grid, [previewWindowReady()]), + // Detached, the Terminal picture has the preview's own size again. + expect(count($preview->frame()))->toBe(12) + ->and(mb_strlen(Ichiloto\Engine\IO\Console\TerminalText::stripAnsi($preview->frame()[0])))->toBe(40); + $again = array_values(array_filter($preview->exchangeScene([previewWindowReady()]), static fn(array $message): bool => $message['type'] === 'frame')); expect($again)->not->toBe([])->and($again[0]['payload']['reset'] ?? false)->toBeTrue(); } finally { $preview->dispose(); } }); + +it('follows the playhead: what the cinematic shows next arrives as the next generation once the last is acknowledged', function () { + $root = cutsceneProject(); + $preview = CinematicPreviewSession::start($root, harbourDefinition($root), ['x' => 2, 'y' => 3, 'width' => 40, 'height' => 12]); + + try { + $generation = max(previewFrameGenerations($preview->exchangeScene([previewWindowReady()]))); + $preview->play(); + for ($tick = 0; $tick < 5; $tick++) { + $preview->tick(CinematicPreviewSession::TICK_SECONDS); + } + // The harbour's narration is on screen by now; the graphical view receives it as the next generation. + $next = previewFrameGenerations($preview->exchangeScene([json_encode(['protocol' => 2, 'type' => 'frame_ack', + 'generation' => $generation, 'frame' => 1, 'presented' => true])])); + expect($next)->not->toBe([])->and(min($next))->toBeGreaterThan($generation); + } finally { + $preview->dispose(); + } +}); diff --git a/tests/Unit/LayeredPreviewTest.php b/tests/Unit/LayeredPreviewTest.php index 7b71de5..d6f55a7 100644 --- a/tests/Unit/LayeredPreviewTest.php +++ b/tests/Unit/LayeredPreviewTest.php @@ -26,10 +26,12 @@ ->and(new ReflectionProperty(MapManager::class, 'collisionMap')->getValue($manager))->toBe([[0, 1, 0, 0], [0, 0, 0, 0]]); $camera = $scene->previewCamera(); ob_start(); - $manager->render(); + $frame = $preview->frame(); $output = ob_get_clean(); + // The preview's picture is captured, never written over the editor's screen. expect($output)->toBe('') - ->and(implode("\n", $camera->frame()))->toContain('./..', '.xx.'); + // The layers composed, with the player standing on the map's first cell. + ->and(implode("\n", array_map(Ichiloto\Engine\IO\Console\TerminalText::stripAnsi(...), $frame)))->toContain('/..', '.xx.'); $manager->unload(); expect($manager->layers)->toBeNull() ->and($manager->tileMap)->toBe([]) diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php index bd7307c..74dbfa7 100644 --- a/tests/Unit/Session/CutsceneRecordSessionTest.php +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -264,3 +264,21 @@ function expectTerminalCanvas(array $canvas, int $columns, int $rows): void $session->undo(); expect(sourceHashTree($root))->toBe($before); }); + +it('draws the previewed cinematic in the window\'s graphical view through the session, writing nothing', function () { + $root = cutsceneProject(); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + $session->startCinematicPreview(0, 60, 16); + $ready = json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => ['sprite_source_rect', 'graphical_canvas', 'canvas_overlay', + 'canvas_clip_opacity', 'frame_viewport', 'field_motion']]); + + $sent = $session->exchangeCinematicScene([$ready])['messages']; + expect(array_column($sent, 'type'))->toContain('frame') + ->and(array_key_exists('protocol', $sent[0]['payload']))->toBeFalse() + ->and(fn() => $session->exchangeCinematicScene(['not a renderer event']))->toThrow(SessionRefusal::class, 'graphical preview stopped') + ->and($session->detachCinematicScene())->toBe(['detached' => true]); + $session->stopCinematicPreview(); + expect(fn() => $session->exchangeCinematicScene([$ready]))->toThrow(SessionRefusal::class, 'No cinematic is being previewed') + ->and(sourceHashTree($root))->toBe($before); +}); From 34aded54d098e886da198501ac2f3295be8a4de8 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 22:09:48 +0200 Subject: [PATCH 224/344] feat(preview): name the graphical view's grid in each scene exchange --- src/Cutscenes/Preview/CinematicPreviewSession.php | 10 ++++++++-- src/Session/EditorSession.php | 10 ++++++++-- tests/Unit/Session/CutsceneRecordSessionTest.php | 5 ++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/Cutscenes/Preview/CinematicPreviewSession.php b/src/Cutscenes/Preview/CinematicPreviewSession.php index fe016cb..7c3b51d 100644 --- a/src/Cutscenes/Preview/CinematicPreviewSession.php +++ b/src/Cutscenes/Preview/CinematicPreviewSession.php @@ -578,8 +578,7 @@ public function frame(): array */ public function exchangeScene(array $events): array { - $grid = new RendererGridConfig($this->screenWidth, $this->screenHeight, - RendererRuntimeConfig::GPUI_CELL_WIDTH, RendererRuntimeConfig::GPUI_CELL_HEIGHT); + $grid = $this->getSceneGrid(); $messages = []; $this->run(function () use ($grid, $events, &$messages): void { $this->sceneHost ??= new PreviewSceneHost($this->scene, $this->presentation, @@ -590,6 +589,13 @@ public function exchangeScene(array $events): array return $messages; } + /** The graphical view's grid: the preview's screen in the game's graphical text cells. */ + public function getSceneGrid(): RendererGridConfig + { + return new RendererGridConfig($this->screenWidth, $this->screenHeight, + RendererRuntimeConfig::GPUI_CELL_WIDTH, RendererRuntimeConfig::GPUI_CELL_HEIGHT); + } + /** Ends the editor window's graphical view of the scene, if one is attached. */ public function detachScene(): void { diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 575b238..790f063 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -2044,15 +2044,21 @@ public function stopCinematicPreview(): array * rejections, exactly as the game's renderer writes them), the frames it * should apply out, in order, each its FRAME payload without envelope. * + * The answer names the view's grid, which the window's scene session is built with. + * * @param list $events - * @return array{messages: list}>} + * @return array{grid: array{columns: int, rows: int, cellWidth: int, cellHeight: int}, messages: list}>} * @throws SessionRefusal When no cinematic is previewed or a line is not a renderer event. */ public function exchangeCinematicScene(array $events): array { $preview = $this->cinematicPreview ?? throw new SessionRefusal('No cinematic is being previewed.'); + $grid = $preview->getSceneGrid(); try { - return ['messages' => $preview->exchangeScene($events)]; + return [ + 'grid' => ['columns' => $grid->columns, 'rows' => $grid->rows, 'cellWidth' => $grid->cellWidth, 'cellHeight' => $grid->cellHeight], + 'messages' => $preview->exchangeScene($events), + ]; } catch (RendererProtocolException | RendererTransportException $error) { throw new SessionRefusal('The graphical preview stopped: ' . $error->getMessage(), previous: $error); } diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php index 74dbfa7..3feb136 100644 --- a/tests/Unit/Session/CutsceneRecordSessionTest.php +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -273,7 +273,10 @@ function expectTerminalCanvas(array $canvas, int $columns, int $rows): void $ready = json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => ['sprite_source_rect', 'graphical_canvas', 'canvas_overlay', 'canvas_clip_opacity', 'frame_viewport', 'field_motion']]); - $sent = $session->exchangeCinematicScene([$ready])['messages']; + $exchange = $session->exchangeCinematicScene([$ready]); + // The window builds its scene with the grid the session names: the preview's screen in the game's graphical text cells. + expect($exchange['grid'])->toBe(['columns' => 60, 'rows' => 16, 'cellWidth' => 10, 'cellHeight' => 20]); + $sent = $exchange['messages']; expect(array_column($sent, 'type'))->toContain('frame') ->and(array_key_exists('protocol', $sent[0]['payload']))->toBeFalse() ->and(fn() => $session->exchangeCinematicScene(['not a renderer event']))->toThrow(SessionRefusal::class, 'graphical preview stopped') From 9f5c56ab5390d34b25be05795bf97e10171f7948 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 22:41:20 +0200 Subject: [PATCH 225/344] feat(preview): play the map's own field effects in a previewed scene The preview scene has the field's effect manager, for the Terminal picture or, while the graphical view is attached, as graphical sprites, and a map loaded for a preview installs its declared effects, piece effects and event cues as the game's field does, under the map data's own id. --- .../Preview/CinematicPreviewSession.php | 8 ++++- src/Cutscenes/Preview/PreviewGameScene.php | 14 +++++++++ src/Cutscenes/Preview/PreviewMapManager.php | 19 ++++++++++++ src/Cutscenes/Preview/PreviewSceneHost.php | 8 +++++ tests/Unit/LayeredPreviewTest.php | 30 +++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/Cutscenes/Preview/CinematicPreviewSession.php b/src/Cutscenes/Preview/CinematicPreviewSession.php index 7c3b51d..516b35e 100644 --- a/src/Cutscenes/Preview/CinematicPreviewSession.php +++ b/src/Cutscenes/Preview/CinematicPreviewSession.php @@ -5,6 +5,7 @@ namespace Ichiloto\Editor\Cutscenes\Preview; use Ichiloto\Editor\ProjectDirectoryContext; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Audio\BackgroundMusicState; use Ichiloto\Engine\Battle\BattleResult; use Ichiloto\Engine\Core\Vector2; @@ -123,6 +124,7 @@ private function boot(): void $this->run(function () use ($width, $height, $mapId): void { $this->scene = new PreviewGameScene(new PreviewSceneManager(), $width, $height, $mapId); + $this->scene->installFieldEffects(EffectPresentation::TERMINAL); $camera = $this->scene->previewCamera(); $this->presentation = new PreviewPresentation($camera); $this->presentation->autoAdvance = (bool) ($this->options['autoAdvance'] ?? false); @@ -599,7 +601,11 @@ public function getSceneGrid(): RendererGridConfig /** Ends the editor window's graphical view of the scene, if one is attached. */ public function detachScene(): void { - $this->sceneHost?->dispose(); + if ($this->sceneHost === null) { + return; + } + // In the project, as every step of the preview runs, so the Terminal effects it restores find their files. + $this->run(fn() => $this->sceneHost->dispose()); $this->sceneHost = null; } diff --git a/src/Cutscenes/Preview/PreviewGameScene.php b/src/Cutscenes/Preview/PreviewGameScene.php index 8a166f9..ffa3d0b 100644 --- a/src/Cutscenes/Preview/PreviewGameScene.php +++ b/src/Cutscenes/Preview/PreviewGameScene.php @@ -4,6 +4,8 @@ namespace Ichiloto\Editor\Cutscenes\Preview; +use Ichiloto\Engine\Animations\Field\FieldEffectManager; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Core\GameState; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicController; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicPresentationManager; @@ -60,6 +62,18 @@ public function __construct( $this->cinematicController = new CinematicController($this); } + /** + * Gives the scene the field's effects for a presentation, with the loaded + * map's installed, as the game's field has them: Terminal glyphs for the + * Terminal picture, graphical sprites for the graphical view. + */ + public function installFieldEffects(EffectPresentation $presentation): void + { + $this->fieldEffects?->clear(); + $this->fieldEffects = new FieldEffectManager(getcwd() . '/assets', $presentation); + $this->previewMap->installFieldEffects(); + } + public function installPlayer(PreviewPlayer $player): void { $this->player = $player; diff --git a/src/Cutscenes/Preview/PreviewMapManager.php b/src/Cutscenes/Preview/PreviewMapManager.php index c0c9dd0..eff73f9 100644 --- a/src/Cutscenes/Preview/PreviewMapManager.php +++ b/src/Cutscenes/Preview/PreviewMapManager.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor\Cutscenes\Preview; +use Ichiloto\Engine\Events\Triggers\EventTriggerFactory; use Ichiloto\Engine\Core\Game; use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\Field\MapManager; @@ -45,10 +46,28 @@ public function loadForPreview(string $mapId): array ? $this->generateCollisionMap($this->tileMap, $dictionary) : $this->generateLayerCollisionMap($this->layers, $dictionary); $this->gameScene->npcManager?->configure(is_array($map['npcs'] ?? null) ? $map['npcs'] : []); + $this->installFieldEffects(); return $map; } + /** + * Gives the scene's field effects the loaded map's own, as the game's + * field installs them when it loads a map: its declared effects, piece + * effects and event cues. + */ + public function installFieldEffects(): void + { + if ($this->mapData === [] || $this->gameScene->fieldEffects === null) { + return; + } + // The map names itself by its data's id, as the game's field reads it. + $mapId = strval($this->mapData['id'] ?? ''); + $triggers = array_map(static fn(array $event) => EventTriggerFactory::create($event, $mapId !== '' ? $mapId : null), + array_values(array_filter((array) ($this->mapData['events'] ?? []), is_array(...)))); + $this->gameScene->fieldEffects->installMap($mapId, $this->mapData['fieldEffects'] ?? null, $this->graphics, $triggers); + } + /** * Clears the world so an unknown map reads as undefined terrain. */ diff --git a/src/Cutscenes/Preview/PreviewSceneHost.php b/src/Cutscenes/Preview/PreviewSceneHost.php index bea0fd8..25199d2 100644 --- a/src/Cutscenes/Preview/PreviewSceneHost.php +++ b/src/Cutscenes/Preview/PreviewSceneHost.php @@ -5,6 +5,7 @@ namespace Ichiloto\Editor\Cutscenes\Preview; use Closure; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\IO\Console\Console; use Ichiloto\Engine\Rendering\Presentation\RendererPresentation; use Ichiloto\Engine\Rendering\Presentation\RetainedWorldProviderInterface; @@ -107,6 +108,9 @@ public function dispose(): void { if ($this->scene->getPresentationContext() !== null) { $this->scene->setPresentationContext(null); + if ($this->scene instanceof PreviewGameScene) { + $this->scene->installFieldEffects(EffectPresentation::TERMINAL); + } } $this->dialogue->pageLayout = null; $this->client->shutdown(); @@ -120,6 +124,10 @@ private function attachContext(): void } $this->scene->setPresentationContext(new ScenePresentationContext($this->grid, $this->client->supports(...), collectPresentations: fn(): array => $this->dialogue->getActivePresentations(), readTime: $this->readTime)); + // The graphical field draws its effects as sprites, as the game's does in a graphical window. + if ($this->scene instanceof PreviewGameScene) { + $this->scene->installFieldEffects(EffectPresentation::GRAPHICAL); + } $width = $this->grid->columns * $this->grid->cellWidth; $this->dialogue->pageLayout = fn(string $speaker, $context, string $help) => $this->composer->getDialoguePageLayout($speaker, $context, $help, $width); } diff --git a/tests/Unit/LayeredPreviewTest.php b/tests/Unit/LayeredPreviewTest.php index d6f55a7..cbbd4ec 100644 --- a/tests/Unit/LayeredPreviewTest.php +++ b/tests/Unit/LayeredPreviewTest.php @@ -42,3 +42,33 @@ $preview->dispose(); } }); + +it('plays the map\'s own field effects in the preview, as the game\'s field has them in either presentation', function () { + $root = layeredMapProject(); + file_put_contents($root . '/assets/Maps/test-map/test-map.event.php', + Ichiloto\Engine\Field\MapGridSource::buildSource(" \n ", 'EVENT')); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace("'events' => [],", "'events' => [], 'fieldEffects' => [['id' => 'glow', 'effect' => 'energy', 'anchor' => ['cell' => ['x' => 1, 'y' => 0]]]],", + (string) file_get_contents($data))); + writeTilesetTestPng($root . '/assets/Graphics/strip.png', 8, 2); + mkdir($root . '/assets/Animations/energy', 0o777, true); + file_put_contents($root . '/assets/Animations/energy/energy.timeline.php', ' 4, 'lengthFrames' => 4, 'playback' => 'loop', 'restFrame' => 2, + 'tracks' => [['id' => 'motes', 'type' => 'image', 'asset' => 'Graphics/strip.png', + 'sheet' => ['columns' => 4, 'rows' => 1], 'cells' => ['width' => 1, 'height' => 1], 'depth' => 'front', + 'keyframes' => array_map(static fn(int $frame): array => ['frame' => $frame, 'sourceFrame' => $frame], range(0, 3))]]], true) . ';'); + $definition = CutsceneHydration::cinematic(['id' => 'preview', 'name' => 'Preview'], [['type' => 'wait', 'seconds' => 1]], $root); + $preview = CinematicPreviewSession::start($root, $definition, ['mapId' => 'test-map', 'x' => 0, 'y' => 0]); + try { + $scene = new ReflectionProperty($preview, 'scene')->getValue($preview); + expect($scene->fieldEffects->count)->toBe(1); + // The graphical view's field has the same effect, drawn its own way. + $preview->exchangeScene([json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => ['sprite_source_rect', + 'graphical_canvas', 'canvas_overlay', 'frame_viewport', 'field_motion']])]); + expect($scene->fieldEffects->count)->toBe(1); + $preview->detachScene(); + expect($scene->fieldEffects->count)->toBe(1); + } finally { + $preview->dispose(); + } +}); From cb51f9b93278a7d65da9314f496c5145ffe64df2 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 22:57:25 +0200 Subject: [PATCH 226/344] feat(cutscenes): preview an effect on the game's field PreviewField owns the isolated field every preview stands on: the preview configuration, project directory, scene, player, map, camera and the editor window's graphical view. The cinematic preview plays its cinematic in one, and its clock now advances the field's own presentation through GameScene::advanceFieldPresentation. cutscenes.effectField shows an effect as edited at the player where a new game starts, held at the timeline's frame as a cinematic field animation presents it, so no cue plays. cutscenes.effectFieldScene and cutscenes.effectFieldClose give the window its graphical view. Only one isolated field is open at a time. --- .../Preview/CinematicPreviewSession.php | 176 ++--------- src/Cutscenes/Preview/PreviewField.php | 276 ++++++++++++++++++ src/Cutscenes/Preview/PreviewGameScene.php | 37 +++ src/Session/EditorSession.php | 97 ++++++ src/Session/SessionHost.php | 4 + tests/Support/CutsceneFixtures.php | 18 ++ .../Unit/Cutscenes/EffectFieldPreviewTest.php | 97 ++++++ tests/Unit/Cutscenes/PreviewSceneHostTest.php | 19 -- tests/Unit/LayeredPreviewTest.php | 5 + 9 files changed, 562 insertions(+), 167 deletions(-) create mode 100644 src/Cutscenes/Preview/PreviewField.php create mode 100644 tests/Unit/Cutscenes/EffectFieldPreviewTest.php diff --git a/src/Cutscenes/Preview/CinematicPreviewSession.php b/src/Cutscenes/Preview/CinematicPreviewSession.php index 516b35e..0c4f971 100644 --- a/src/Cutscenes/Preview/CinematicPreviewSession.php +++ b/src/Cutscenes/Preview/CinematicPreviewSession.php @@ -4,8 +4,6 @@ namespace Ichiloto\Editor\Cutscenes\Preview; -use Ichiloto\Editor\ProjectDirectoryContext; -use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Audio\BackgroundMusicState; use Ichiloto\Engine\Battle\BattleResult; use Ichiloto\Engine\Core\Vector2; @@ -14,14 +12,7 @@ use Ichiloto\Engine\Events\Interpreter\EventExecutionSession; use Ichiloto\Engine\Events\Interpreter\EventExecutionStatus; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; -use Ichiloto\Engine\IO\Console\Console; -use Ichiloto\Engine\Rendering\Runtime\RendererRuntimeConfig; use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; -use Ichiloto\Engine\Util\Config\ConfigStore; -use Ichiloto\Engine\Util\Config\PlaySettings; -use Ichiloto\Engine\Util\Config\ProjectConfig; -use Ichiloto\Engine\Util\Debug; -use Ichiloto\Engine\Util\Interfaces\ConfigInterface; use Throwable; /** @@ -61,9 +52,6 @@ final class CinematicPreviewSession private ?string $battleOutcome = null; /** @var array */ private array $log = []; - /** @var array */ - private array $previousConfigs = []; - private bool $configured = false; /** @var array Row keys executing when the session last ticked. */ private array $lastActiveKeys = []; /** @var array|null}> The same, with the commands. */ @@ -72,11 +60,8 @@ final class CinematicPreviewSession private bool $outcomeAnnounced = false; /** What was playing before the cinematic started, as the engine held it. */ private ?BackgroundMusicState $musicBefore = null; - /** The editor window's graphical view of the scene, while one is attached. */ - private ?PreviewSceneHost $sceneHost = null; - /** The preview's screen in Terminal cells, which the graphical view's camera viewport never changes. */ - private int $screenWidth = 60; - private int $screenHeight = 18; + /** The isolated field the cinematic plays in. */ + private PreviewField $field; /** * @param array{mapId?: string|null, x?: int, y?: int, width?: int, height?: int, autoAdvance?: bool, battleOutcome?: string} $options @@ -117,35 +102,25 @@ public function restart(): self private function boot(): void { - $width = $this->screenWidth = max(20, intval($this->options['width'] ?? 60)); - $height = $this->screenHeight = max(8, intval($this->options['height'] ?? 18)); $mapId = $this->options['mapId'] ?? $this->definition->startMap; $mapId = is_string($mapId) && trim($mapId) !== '' ? trim($mapId) : ''; + $this->field = PreviewField::open($this->projectRoot, $mapId, + new Vector2(intval($this->options['x'] ?? 0), intval($this->options['y'] ?? 0)), + intval($this->options['width'] ?? 60), intval($this->options['height'] ?? 18), fn(): float => $this->elapsed); + $this->scene = $this->field->scene; + $this->presentation = $this->field->presentation; + if ($mapId === '') { + $this->note('No start map: the cinematic plays over undefined terrain.'); + } elseif ($this->field->mapFailure !== null) { + $this->note(sprintf('Map %s could not be loaded: %s', $mapId, $this->field->mapFailure)); + } else { + $this->note(sprintf('Map %s loaded.', $mapId)); + } - $this->run(function () use ($width, $height, $mapId): void { - $this->scene = new PreviewGameScene(new PreviewSceneManager(), $width, $height, $mapId); - $this->scene->installFieldEffects(EffectPresentation::TERMINAL); - $camera = $this->scene->previewCamera(); - $this->presentation = new PreviewPresentation($camera); + $this->run(function (): void { $this->presentation->autoAdvance = (bool) ($this->options['autoAdvance'] ?? false); $this->interpreter = new EventInterpreter($this->scene, $this->presentation); $this->scene->installInterpreter($this->interpreter); - $player = new PreviewPlayer($this->scene, new Vector2(intval($this->options['x'] ?? 0), intval($this->options['y'] ?? 0))); - $this->scene->installPlayer($player); - - if ($mapId !== '') { - try { - $this->scene->previewMap->loadForPreview($mapId); - $this->note(sprintf('Map %s loaded.', $mapId)); - } catch (Throwable $throwable) { - $this->scene->previewMap->unload(); - $this->note(sprintf('Map %s could not be loaded: %s', $mapId, $throwable->getMessage())); - } - } else { - $this->note('No start map: the cinematic plays over undefined terrain.'); - } - - $camera->attach($player); $audio = $this->scene->getGame()->audioManager; $startingMusic = is_array($this->options['startingMusic'] ?? null) ? $this->options['startingMusic'] : null; @@ -305,20 +280,7 @@ public function runToCompletion(float $maxSeconds = 600.0): bool */ public function dispose(): void { - $this->detachScene(); - if (! $this->configured) { - return; - } - - foreach ([ProjectConfig::class, PlaySettings::class] as $class) { - if (isset($this->previousConfigs[$class])) { - ConfigStore::put($class, $this->previousConfigs[$class]); - } else { - ConfigStore::remove($class); - } - } - - $this->configured = false; + $this->field->dispose(); } // ------------------------------------------------------------------ @@ -557,67 +519,31 @@ public function activeKeys(): array */ public function frame(): array { - $rows = []; - $this->run(function () use (&$rows): void { - // The same isolated capture the graphical view composes from, with nothing left out. - $rows = Console::capturePresentation($this->screenWidth, $this->screenHeight, $this->renderScene(...))->rows ?? []; - }); - - return $rows; + return $this->field->frame(); } /** * One exchange with the editor window's graphical view of the scene - * ({@see PreviewSceneHost::exchange()}): its renderer event lines in, what - * it should apply out. The view's grid is the preview's screen in the - * game's graphical text cells, so it shows what the game's graphical - * field would. While attached, the Engine sizes the preview's camera to - * the graphical field's viewport, as the game does, so the Terminal - * picture is the game's Terminal view only while detached. + * ({@see PreviewField::exchangeScene()}), at the preview's playhead. * * @param list $events * @return list}> */ public function exchangeScene(array $events): array { - $grid = $this->getSceneGrid(); - $messages = []; - $this->run(function () use ($grid, $events, &$messages): void { - $this->sceneHost ??= new PreviewSceneHost($this->scene, $this->presentation, - $this->projectRoot . '/assets', $grid, $this->renderScene(...), fn(): float => $this->elapsed); - $messages = $this->sceneHost->exchange($events); - }); - - return $messages; + return $this->field->exchangeScene($events); } - /** The graphical view's grid: the preview's screen in the game's graphical text cells. */ + /** The graphical view's grid ({@see PreviewField::getSceneGrid()}). */ public function getSceneGrid(): RendererGridConfig { - return new RendererGridConfig($this->screenWidth, $this->screenHeight, - RendererRuntimeConfig::GPUI_CELL_WIDTH, RendererRuntimeConfig::GPUI_CELL_HEIGHT); + return $this->field->getSceneGrid(); } /** Ends the editor window's graphical view of the scene, if one is attached. */ public function detachScene(): void { - if ($this->sceneHost === null) { - return; - } - // In the project, as every step of the preview runs, so the Terminal effects it restores find their files. - $this->run(fn() => $this->sceneHost->dispose()); - $this->sceneHost = null; - } - - /** Draws everything the field draws during a cinematic, in the game's order. */ - private function renderScene(): void - { - $this->scene->previewMap->render(); - $this->scene->npcManager?->render(); - $this->scene->player?->render(); - $this->scene->cinematicStage?->render(); - $this->scene->cinematicPresentation?->render(); - $this->presentation->render(); + $this->field->detachScene(); } /** @@ -627,7 +553,7 @@ private function renderScene(): void */ public function getScreenSize(): array { - return [$this->screenWidth, $this->screenHeight]; + return $this->field->getScreenSize(); } /** @@ -635,11 +561,7 @@ public function getScreenSize(): array */ public function resize(int $width, int $height): void { - // The graphical view's grid is the screen's, so a new size starts it again. - $this->detachScene(); - $this->screenWidth = max(20, $width); - $this->screenHeight = max(8, $height); - $this->scene->previewCamera()->resize($this->screenWidth, $this->screenHeight); + $this->field->resize($width, $height); } /** @@ -739,6 +661,8 @@ private function advance(float $seconds): void $this->resolveBattle(); } + // What the field shows by itself, then the cinematic, in a game frame's order. + $this->scene->advanceFieldPresentation($seconds); $this->presentation->update(); $this->interpreter->update($seconds); // The engine's own audio upkeep, as a game frame runs it. @@ -1035,54 +959,10 @@ private function note(string $text): void } /** - * Runs Engine code inside the project root with the preview - * configuration installed and any stray terminal output discarded. + * Runs Engine code in the preview's field ({@see PreviewField::run()}). */ private function run(callable $operation): void { - $this->installConfiguration(); - - ProjectDirectoryContext::run($this->projectRoot, function () use ($operation): void { - ob_start(); - - try { - $operation(); - } finally { - ob_end_clean(); - } - }); - } - - private function installConfiguration(): void - { - if ($this->configured) { - return; - } - - foreach ([ProjectConfig::class, PlaySettings::class] as $class) { - if (ConfigStore::has($class)) { - $this->previousConfigs[$class] = ConfigStore::get($class); - } - } - - $width = max(20, intval($this->options['width'] ?? 60)); - $height = max(8, intval($this->options['height'] ?? 18)); - ConfigStore::put(ProjectConfig::class, new PreviewConfig([ - 'accessibility' => ['reducedMotion' => false], - 'save' => ['autosave' => false], - 'ui' => ['hud' => ['location' => false]], - // The engine records what a cinematic asked for either way; with - // music off it never starts a player process for it. - 'audio' => ['music' => false, 'sfx' => false, 'voice' => false], - ])); - ConfigStore::put(PlaySettings::class, new PreviewConfig([ - 'screen' => ['width' => $width, 'height' => $height], - 'width' => $width, - 'height' => $height, - ])); - - $logDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ichiloto-editor-preview-logs'; - Debug::configure(['log_level' => Debug::ERROR, 'log_directory' => $logDirectory]); - $this->configured = true; + $this->field->run($operation); } } diff --git a/src/Cutscenes/Preview/PreviewField.php b/src/Cutscenes/Preview/PreviewField.php new file mode 100644 index 0000000..67721e3 --- /dev/null +++ b/src/Cutscenes/Preview/PreviewField.php @@ -0,0 +1,276 @@ + */ + private array $previousConfigs = []; + private bool $configured = false; + /** The field's screen in Terminal cells, which the graphical view's camera viewport never changes. */ + private int $screenWidth; + private int $screenHeight; + + /** + * @param Closure(): float $readTime The owner's clock, in seconds. + */ + private function __construct( + private readonly string $projectRoot, + int $width, + int $height, + private readonly Closure $readTime, + ) { + $this->screenWidth = max(20, $width); + $this->screenHeight = max(8, $height); + } + + /** + * Opens the field on a map, with the player standing on a cell and the + * camera following them. A map that cannot be loaded leaves undefined + * terrain and says why ({@see $mapFailure}). + * + * @param Closure(): float $readTime The owner's clock, in seconds. + */ + public static function open(string $projectRoot, string $mapId, Vector2 $spawn, int $width, int $height, Closure $readTime): self + { + $field = new self($projectRoot, $width, $height, $readTime); + $field->run(function () use ($field, $mapId, $spawn): void { + $field->scene = new PreviewGameScene(new PreviewSceneManager(), $field->screenWidth, $field->screenHeight, $mapId); + $field->scene->installFieldEffects(EffectPresentation::TERMINAL); + $camera = $field->scene->previewCamera(); + $field->presentation = new PreviewPresentation($camera); + $player = new PreviewPlayer($field->scene, $spawn); + $field->scene->installPlayer($player); + if ($mapId !== '') { + try { + $field->scene->previewMap->loadForPreview($mapId); + } catch (Throwable $throwable) { + $field->scene->previewMap->unload(); + $field->mapFailure = $throwable->getMessage(); + } + } + $camera->attach($player); + }); + + return $field; + } + + /** + * Runs Engine code inside the project root with the preview + * configuration installed and any stray terminal output discarded. + */ + public function run(callable $operation): void + { + $this->installConfiguration(); + + ProjectDirectoryContext::run($this->projectRoot, function () use ($operation): void { + ob_start(); + + try { + $operation(); + } finally { + ob_end_clean(); + } + }); + } + + /** + * Advances what the field shows by itself on the owner's clock: walking + * frames, NPC and stage animation and the map's effects, as a game frame + * advances them ({@see \Ichiloto\Engine\Scenes\Game\GameScene::advanceFieldPresentation()}). + */ + public function advance(float $seconds): void + { + $this->run(fn() => $this->scene->advanceFieldPresentation(max(0.0, $seconds))); + } + + /** + * Shows an effect on the field at one of its frames, at the player + * ({@see PreviewGameScene::showFieldEffect()}). + * + * @param Closure(EffectPresentation): CompiledEffectTimeline $compile The effect, compiled for a presentation. + */ + public function showFieldEffect(Closure $compile, int $frame): void + { + $this->run(fn() => $this->scene->showFieldEffect($compile, $frame)); + } + + /** + * The composed picture of the field at this moment. + * + * @return string[] + */ + public function frame(): array + { + $rows = []; + $this->run(function () use (&$rows): void { + // The same isolated capture the graphical view composes from, with nothing left out. + $rows = Console::capturePresentation($this->screenWidth, $this->screenHeight, $this->renderScene(...))->rows ?? []; + }); + + return $rows; + } + + /** + * One exchange with the editor window's graphical view of the field + * ({@see PreviewSceneHost::exchange()}): its renderer event lines in, what + * it should apply out. The view's grid is the field's screen in the + * game's graphical text cells, so it shows what the game's graphical + * field would. While attached, the Engine sizes the field's camera to + * the graphical field's viewport, as the game does, so the Terminal + * picture is the game's Terminal view only while detached. + * + * @param list $events + * @return list}> + */ + public function exchangeScene(array $events): array + { + $grid = $this->getSceneGrid(); + $messages = []; + $this->run(function () use ($grid, $events, &$messages): void { + $this->sceneHost ??= new PreviewSceneHost($this->scene, $this->presentation, + $this->projectRoot . '/assets', $grid, $this->renderScene(...), $this->readTime); + $messages = $this->sceneHost->exchange($events); + }); + + return $messages; + } + + /** The graphical view's grid: the field's screen in the game's graphical text cells. */ + public function getSceneGrid(): RendererGridConfig + { + return new RendererGridConfig($this->screenWidth, $this->screenHeight, + RendererRuntimeConfig::GPUI_CELL_WIDTH, RendererRuntimeConfig::GPUI_CELL_HEIGHT); + } + + /** Ends the editor window's graphical view of the field, if one is attached. */ + public function detachScene(): void + { + if ($this->sceneHost === null) { + return; + } + // In the project, as every step of the field runs, so the Terminal effects it restores find their files. + $this->run(fn() => $this->sceneHost->dispose()); + $this->sceneHost = null; + } + + /** + * The captured screen's size, in columns and rows. + * + * @return array{int, int} + */ + public function getScreenSize(): array + { + return [$this->screenWidth, $this->screenHeight]; + } + + /** + * Resizes the captured screen. + */ + public function resize(int $width, int $height): void + { + // The graphical view's grid is the screen's, so a new size starts it again. + $this->detachScene(); + $this->screenWidth = max(20, $width); + $this->screenHeight = max(8, $height); + $this->scene->previewCamera()->resize($this->screenWidth, $this->screenHeight); + } + + /** + * Ends the graphical view and releases the Engine configuration the field installed. + */ + public function dispose(): void + { + $this->detachScene(); + if (! $this->configured) { + return; + } + + foreach ([ProjectConfig::class, PlaySettings::class] as $class) { + if (isset($this->previousConfigs[$class])) { + ConfigStore::put($class, $this->previousConfigs[$class]); + } else { + ConfigStore::remove($class); + } + } + + $this->configured = false; + } + + /** Draws everything the field draws, in the game's order. */ + private function renderScene(): void + { + $this->scene->previewMap->render(); + $this->scene->npcManager?->render(); + $this->scene->player?->render(); + $this->scene->cinematicStage?->render(); + $this->scene->cinematicPresentation?->render(); + $this->presentation->render(); + } + + private function installConfiguration(): void + { + if ($this->configured) { + return; + } + + foreach ([ProjectConfig::class, PlaySettings::class] as $class) { + if (ConfigStore::has($class)) { + $this->previousConfigs[$class] = ConfigStore::get($class); + } + } + + ConfigStore::put(ProjectConfig::class, new PreviewConfig([ + 'accessibility' => ['reducedMotion' => false], + 'save' => ['autosave' => false], + 'ui' => ['hud' => ['location' => false]], + // The engine records what the field asks for either way; with + // music off it never starts a player process for it. + 'audio' => ['music' => false, 'sfx' => false, 'voice' => false], + ])); + ConfigStore::put(PlaySettings::class, new PreviewConfig([ + 'screen' => ['width' => $this->screenWidth, 'height' => $this->screenHeight], + 'width' => $this->screenWidth, + 'height' => $this->screenHeight, + ])); + + $logDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ichiloto-editor-preview-logs'; + Debug::configure(['log_level' => Debug::ERROR, 'log_directory' => $logDirectory]); + $this->configured = true; + } +} diff --git a/src/Cutscenes/Preview/PreviewGameScene.php b/src/Cutscenes/Preview/PreviewGameScene.php index ffa3d0b..bf824e2 100644 --- a/src/Cutscenes/Preview/PreviewGameScene.php +++ b/src/Cutscenes/Preview/PreviewGameScene.php @@ -4,12 +4,17 @@ namespace Ichiloto\Editor\Cutscenes\Preview; +use Closure; use Ichiloto\Engine\Animations\Field\FieldEffectManager; +use Ichiloto\Engine\Animations\Field\FieldEffectAnchor; +use Ichiloto\Engine\Animations\Field\FieldEffectSession; use Ichiloto\Engine\Animations\Timelines\EffectPresentation; +use Ichiloto\Engine\Animations\Timelines\CompiledEffectTimeline; use Ichiloto\Engine\Core\GameState; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicController; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicPresentationManager; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicStageManager; +use Ichiloto\Engine\Cutscenes\Cinematics\CinematicSubjectResolver; use Ichiloto\Engine\Entities\Party; use Ichiloto\Engine\Events\Interpreter\EventExecutionSession; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; @@ -40,6 +45,12 @@ final class PreviewGameScene extends GameScene public PreviewMapManager $previewMap; + /** The id the effect an author previews plays under on the field. */ + private const string SHOWN_EFFECT_ID = 'editor-preview'; + + /** @var array{0: Closure(EffectPresentation): CompiledEffectTimeline, 1: int}|null The effect an author previews, at its frame. */ + private ?array $shownEffect = null; + public function __construct( public readonly PreviewSceneManager $previewSceneManager, int $screenWidth, @@ -72,6 +83,32 @@ public function installFieldEffects(EffectPresentation $presentation): void $this->fieldEffects?->clear(); $this->fieldEffects = new FieldEffectManager(getcwd() . '/assets', $presentation); $this->previewMap->installFieldEffects(); + if ($this->shownEffect !== null) { + $this->showFieldEffect(...$this->shownEffect); + } + } + + /** + * Shows an effect on the field at one of its frames, at the player the + * camera follows, as a cinematic's field animation presents one: an + * effect session this scene owns, compiled for the field's presentation + * and held at the frame for inspection, so no cue plays and no clock + * moves it. It stays shown, in whichever presentation the field is, + * until another is shown. + * + * @param Closure(EffectPresentation): CompiledEffectTimeline $compile The effect, compiled for a presentation. + */ + public function showFieldEffect(Closure $compile, int $frame): void + { + $this->shownEffect = [$compile, $frame]; + $presentation = $this->cinematicPresentation ?? throw new \LogicException('The preview scene has no cinematic presentation.'); + $presentation->removeEffect(self::SHOWN_EFFECT_ID); + $session = new FieldEffectSession(self::SHOWN_EFFECT_ID, FieldEffectAnchor::createAtPosition( + new CinematicSubjectResolver($this)->position(['kind' => 'player'])), + $compile($this->isGraphicalFieldPresented() ? EffectPresentation::GRAPHICAL : EffectPresentation::TERMINAL)); + $session->playback->seek($frame); + $session->playback->pause(); + $presentation->presentEffect($session); } public function installPlayer(PreviewPlayer $player): void diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 790f063..8476f05 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -106,9 +106,11 @@ use Ichiloto\Engine\Animations\Animation; use Ichiloto\Engine\Animations\AnimationLibrary; use Ichiloto\Engine\Animations\Timelines\CompiledEffectTimeline; +use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\Entities\Skills\SkillCatalog; use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewOrigin; use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewSession; +use Ichiloto\Editor\Cutscenes\Preview\PreviewField; use Ichiloto\Engine\Battle\Presentation\BattleCommandTimeline; use Ichiloto\Engine\Cutscenes\Summons\SummonCompiledCutscene; use Ichiloto\Engine\Cutscenes\Summons\SummonCutsceneDefinition; @@ -167,6 +169,14 @@ final class EditorSession private ?string $cinematicPreviewId = null; /** The cinematic as it stood when its preview started, so an unchanged one keeps playing. */ private ?string $cinematicPreviewFingerprint = null; + /** + * The field an effect is previewed on, where it stands and at what size, + * and the effect's playhead in seconds. It and the cinematic preview are + * never open together: each installs the preview's Engine configuration. + */ + private ?PreviewField $effectField = null; + private ?string $effectFieldKey = null; + private float $effectFieldSeconds = 0.0; /** How actors are authored, with what this editor's actor panes show. */ private readonly ActorAuthoring $actorAuthoring; @@ -1975,6 +1985,7 @@ public function startCinematicPreview(int $index, int $width, int $height, bool return $this->describeCinematicPreview(); } $this->stopCinematicPreview(); + $this->closeEffectField(); try { $definition = $asset->cinematicDefinition(); } catch (Throwable $failure) { @@ -2077,6 +2088,92 @@ public function detachCinematicScene(): array return ['detached' => $this->cinematicPreview !== null]; } + /** + * Shows an effect, as edited, unsaved changes included, on the game's + * field at one of its frames: at the player, where a new game starts + * (System's player starting position), in an isolated field with silent + * audio, as a cinematic's field animation presents it. Nothing is + * written. The field stays open while it stands where it did, so the + * editor window's graphical view of it ({@see exchangeEffectFieldScene()}) + * carries on; a cinematic preview running ends first. + * + * @return array{frame: int, totalFrames: int, fps: int, mapId: string, lines: list, issue: string|null} + * @throws SessionRefusal When the record is not an effect, it does not compile, or no starting position names a map. + */ + public function showEffectOnField(int $index, int $frame, int $width, int $height): array + { + $asset = $this->requireTimelineCutscene(CutsceneType::EFFECT->getRecordCategory(), $index); + try { + // The timeline editor's own playhead: the same frames and rate its Terminal preview steps through. + $timeline = new TimelinePreviewSession($asset->compiledEffect(EffectPresentation::TERMINAL, false), null); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s does not compile: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + $start = $this->workspace->getSystemField('startingPositions'); + $player = is_array($start) && is_array($start['player'] ?? null) ? $start['player'] : []; + $mapId = is_string($player['destinationMap'] ?? null) ? trim($player['destinationMap']) : ''; + if ($mapId === '') { + throw new SessionRefusal('An effect is shown on the field where a new game starts; System names no player starting map.'); + } + $spawn = new Vector2(intval($player['spawnPoint']['x'] ?? 0), intval($player['spawnPoint']['y'] ?? 0)); + $key = implode(':', [$mapId, (int) $spawn->x, (int) $spawn->y, max(20, $width), max(8, $height)]); + if ($this->effectField === null || $this->effectFieldKey !== $key) { + $this->stopCinematicPreview(); + $this->closeEffectField(); + $this->effectField = PreviewField::open($this->workspace->projectRoot, $mapId, $spawn, $width, $height, + fn(): float => $this->effectFieldSeconds); + $this->effectFieldKey = $key; + } + $frame = max(0, min($frame, $timeline->totalFrames() - 1)); + $this->effectFieldSeconds = $frame / $timeline->fps(); + try { + $this->effectField->showFieldEffect(static fn(EffectPresentation $presentation): CompiledEffectTimeline + => $asset->compiledEffect($presentation, false), $frame); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s cannot be shown on the field: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + + return ['frame' => $frame, 'totalFrames' => $timeline->totalFrames(), 'fps' => $timeline->fps(), 'mapId' => $mapId, + 'lines' => array_values($this->effectField->frame()), 'issue' => $this->effectField->mapFailure]; + } + + /** + * One exchange with the editor window's graphical view of the field an + * effect is shown on, as {@see exchangeCinematicScene()} is for a cinematic. + * + * @param list $events + * @return array{grid: array{columns: int, rows: int, cellWidth: int, cellHeight: int}, messages: list}>} + * @throws SessionRefusal When no effect is shown on the field or a line is not a renderer event. + */ + public function exchangeEffectFieldScene(array $events): array + { + $field = $this->effectField ?? throw new SessionRefusal('No effect is shown on the field.'); + $grid = $field->getSceneGrid(); + try { + return [ + 'grid' => ['columns' => $grid->columns, 'rows' => $grid->rows, 'cellWidth' => $grid->cellWidth, 'cellHeight' => $grid->cellHeight], + 'messages' => $field->exchangeScene($events), + ]; + } catch (RendererProtocolException | RendererTransportException $error) { + throw new SessionRefusal('The graphical preview stopped: ' . $error->getMessage(), previous: $error); + } + } + + /** + * Closes the field an effect is shown on, with its graphical view. + * + * @return array{closed: bool} Whether one was open. + */ + public function closeEffectField(): array + { + $closed = $this->effectField !== null; + $this->effectField?->dispose(); + $this->effectField = null; + $this->effectFieldKey = null; + + return ['closed' => $closed]; + } + /** * A terminal picture as a renderer paints it: the Engine's styled runs on the * picture's own grid, its colours kept and its control codes never counted as diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 2b4536c..d0efec6 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -402,6 +402,10 @@ private function dispatch(string $method, array $params): array 'cutscenes.cinematicStop' => $session->stopCinematicPreview(), 'cutscenes.cinematicScene' => $session->exchangeCinematicScene(self::requireLines($params, 'events')), 'cutscenes.cinematicSceneDetach' => $session->detachCinematicScene(), + 'cutscenes.effectField' => $session->showEffectOnField(self::requireInt($params, 'index'), is_int($params['frame'] ?? null) ? $params['frame'] : 0, + self::requireInt($params, 'width'), self::requireInt($params, 'height')), + 'cutscenes.effectFieldScene' => $session->exchangeEffectFieldScene(self::requireLines($params, 'events')), + 'cutscenes.effectFieldClose' => $session->closeEffectField(), 'cutscenes.effectBattlePreview' => $session->readEffectBattlePreview(self::requireInt($params, 'index'), is_int($params['frame'] ?? null) ? $params['frame'] : 0, ($params['reducedMotion'] ?? false) === true, EffectPresentation::tryFrom(is_string($params['presentation'] ?? null) ? $params['presentation'] : 'graphical') diff --git a/tests/Support/CutsceneFixtures.php b/tests/Support/CutsceneFixtures.php index eb96da0..22504b5 100644 --- a/tests/Support/CutsceneFixtures.php +++ b/tests/Support/CutsceneFixtures.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneLibrary; use Ichiloto\Editor\Editor; use Ichiloto\Editor\ProjectDirectoryContext; +use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\UI\CutscenesScreen; @@ -577,3 +578,20 @@ function harbourDefinition(string $root): \Ichiloto\Engine\Cutscenes\Cinematics\ return CutsceneHydration::cinematic($data, $script, $root); } + +/** The editor window's READY: what its graphical view can draw, as the game's renderer says it. */ +function previewWindowReady(): string +{ + return json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => [ + RendererSessionConfig::SPRITE_SOURCE_RECT, RendererSessionConfig::GRAPHICAL_CANVAS, RendererSessionConfig::CANVAS_OVERLAY, + RendererSessionConfig::CANVAS_CLIP_OPACITY, RendererSessionConfig::FRAME_VIEWPORT, RendererSessionConfig::FIELD_MOTION, + RendererSessionConfig::SPRITE_LIFT, RendererSessionConfig::CANVAS_IMAGE_TONE, RendererSessionConfig::CANVAS_IMAGE_FLIP, + ]]); +} + +/** @param list}> $messages */ +function previewFrameGenerations(array $messages): array +{ + return array_values(array_filter(array_map(static fn(array $message): ?int => $message['type'] === 'frame' + ? ($message['payload']['generation'] ?? null) : null, $messages), static fn(?int $generation): bool => $generation !== null)); +} diff --git a/tests/Unit/Cutscenes/EffectFieldPreviewTest.php b/tests/Unit/Cutscenes/EffectFieldPreviewTest.php new file mode 100644 index 0000000..7e20cfe --- /dev/null +++ b/tests/Unit/Cutscenes/EffectFieldPreviewTest.php @@ -0,0 +1,97 @@ + 4, 'lengthFrames' => 4, 'playback' => 'loop', + 'tracks' => [['id' => 'motes', 'type' => 'image', 'asset' => 'Graphics/Effects/mote.png', + 'sheet' => ['columns' => 4, 'rows' => 1], 'cells' => ['width' => 1, 'height' => 1], 'depth' => 'front', + 'keyframes' => array_map(static fn(int $frame): array => ['frame' => $frame, 'sourceFrame' => $frame], range(0, 3))]]], true) . ';'); + file_put_contents($root . '/assets/Data/system.php', " ['player' => [ + 'destinationMap' => 'harbour', 'spawnPoint' => ['x' => 4, 'y' => 4], 'spawnSprite' => ['^']]]], true) . ";\n"); + + return $root; +} + +function effectIndex(EditorSession $session, string $id): int +{ + return array_search($id, $session->listDatabaseRecords('cutscenes/effect')['records'], true); +} + +/** @param list $lines */ +function plainField(array $lines): string +{ + return implode("\n", array_map(TerminalText::stripAnsi(...), $lines)); +} + +it('shows an effect at the player where a new game starts, held at the playhead\'s frame', function () { + $session = EditorSession::open(effectFieldProject()); + $index = effectIndex($session, 'ember-spark'); + + try { + $first = $session->showEffectOnField($index, 0, 40, 12); + expect($first)->toMatchArray(['frame' => 0, 'totalFrames' => 6, 'fps' => 10, 'mapId' => 'harbour', 'issue' => null]) + ->and(plainField($first['lines']))->toContain('*')->not->toContain('+'); + // Another frame of the same effect is the same field, the effect moved to that frame. + $later = $session->showEffectOnField($index, 4, 40, 12); + expect($later['frame'])->toBe(4) + ->and(plainField($later['lines']))->toContain('+')->not->toContain('*') + // A frame past the end is the last one. + ->and($session->showEffectOnField($index, 99, 40, 12)['frame'])->toBe(5); + } finally { + $session->closeEffectField(); + } +}); + +it('gives the editor window the field with the effect\'s own graphical art', function () { + $session = EditorSession::open(effectFieldProject()); + $session->showEffectOnField(effectIndex($session, 'mote'), 1, 40, 12); + + try { + $answer = $session->exchangeEffectFieldScene([previewWindowReady()]); + expect($answer['grid'])->toBe(['columns' => 40, 'rows' => 12, 'cellWidth' => 10, 'cellHeight' => 20]) + ->and(json_encode($answer['messages']))->toContain('Graphics\/Effects\/mote.png'); + expect($session->closeEffectField())->toBe(['closed' => true]) + ->and(fn() => $session->exchangeEffectFieldScene([]))->toThrow(SessionRefusal::class); + } finally { + $session->closeEffectField(); + } +}); + +it('keeps one isolated field open: an effect on the field ends a cinematic preview, and the reverse', function () { + $session = EditorSession::open(effectFieldProject()); + $session->startCinematicPreview(0, 40, 12); + + try { + $session->showEffectOnField(effectIndex($session, 'ember-spark'), 0, 40, 12); + expect(fn() => $session->exchangeCinematicScene([]))->toThrow(SessionRefusal::class); + $session->startCinematicPreview(0, 40, 12); + expect($session->closeEffectField())->toBe(['closed' => false]); + } finally { + $session->stopCinematicPreview(); + $session->closeEffectField(); + } +}); + +it('refuses to show an effect on the field when no new game start names a map', function () { + $session = EditorSession::open(effectProject()); + + expect(fn() => $session->showEffectOnField(effectIndex($session, 'ember-spark'), 0, 40, 12)) + ->toThrow(SessionRefusal::class, 'System names no player starting map'); +}); diff --git a/tests/Unit/Cutscenes/PreviewSceneHostTest.php b/tests/Unit/Cutscenes/PreviewSceneHostTest.php index dbb0817..8acef7b 100644 --- a/tests/Unit/Cutscenes/PreviewSceneHostTest.php +++ b/tests/Unit/Cutscenes/PreviewSceneHostTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewSession; -use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; /** * The editor window draws a previewed cinematic as the game's graphical field @@ -12,24 +11,6 @@ * acknowledges each. Synthetic cutscene project only. */ -const PREVIEW_WINDOW_CAPABILITIES = [ - RendererSessionConfig::SPRITE_SOURCE_RECT, RendererSessionConfig::GRAPHICAL_CANVAS, RendererSessionConfig::CANVAS_OVERLAY, - RendererSessionConfig::CANVAS_CLIP_OPACITY, RendererSessionConfig::FRAME_VIEWPORT, RendererSessionConfig::FIELD_MOTION, - RendererSessionConfig::SPRITE_LIFT, RendererSessionConfig::CANVAS_IMAGE_TONE, RendererSessionConfig::CANVAS_IMAGE_FLIP, -]; - -function previewWindowReady(): string -{ - return json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => PREVIEW_WINDOW_CAPABILITIES]); -} - -/** @param list}> $messages */ -function previewFrameGenerations(array $messages): array -{ - return array_values(array_filter(array_map(static fn(array $message): ?int => $message['type'] === 'frame' - ? ($message['payload']['generation'] ?? null) : null, $messages), static fn(?int $generation): bool => $generation !== null)); -} - it('sends nothing until the window says what it can draw, then the scene as the game composes it', function () { $root = cutsceneProject(); $preview = CinematicPreviewSession::start($root, harbourDefinition($root), ['x' => 2, 'y' => 3, 'width' => 40, 'height' => 12]); diff --git a/tests/Unit/LayeredPreviewTest.php b/tests/Unit/LayeredPreviewTest.php index cbbd4ec..6089699 100644 --- a/tests/Unit/LayeredPreviewTest.php +++ b/tests/Unit/LayeredPreviewTest.php @@ -62,6 +62,11 @@ try { $scene = new ReflectionProperty($preview, 'scene')->getValue($preview); expect($scene->fieldEffects->count)->toBe(1); + // The preview's clock moves the map's effects as a game frame does, the cinematic's commands aside. + $glow = static fn(): int => new ReflectionProperty($scene->fieldEffects, 'sessions')->getValue($scene->fieldEffects)['map-glow']->playback->currentFrame; + $before = $glow(); + $preview->step(0.5); + expect($glow())->not->toBe($before); // The graphical view's field has the same effect, drawn its own way. $preview->exchangeScene([json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => ['sprite_source_rect', 'graphical_canvas', 'canvas_overlay', 'frame_viewport', 'field_motion']])]); From c7899162964c16b173d515691aeeb0f8884e4ef7 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 23:32:15 +0200 Subject: [PATCH 227/344] feat(inn): choose an inn's rest stage from the stage timelines stage_timelines lists the timelines the Engine's stage loading admits. A Sleep event offers its optional rest music and rest stage unset, writes one only when chosen and removes it again when cleared, with undo restoring its absence. Configuration offers graphics.inn.presentation as a stage timeline reference. Validation reports a rest stage the Engine would not admit, named by a Sleep event, an inn command at any depth or config, and an effect's uses include its rest stage uses. --- src/Cutscenes/Editing/CutscenesWorkspace.php | 2 +- src/Database/RecordSchemaCatalog.php | 5 + src/Database/ReferenceCatalog.php | 30 ++++ src/Editor.php | 12 +- src/Events/EventTypeCatalog.php | 10 ++ src/Events/EventTypeDefinition.php | 6 + src/Inspector/MapInspector.php | 40 ++++-- src/ProjectConfig.php | 18 ++- src/ProjectMap.php | 40 ++++++ src/Validation/EffectValidator.php | 78 +++++++++- tests/Unit/InnStagePresentationTest.php | 141 +++++++++++++++++++ 11 files changed, 361 insertions(+), 21 deletions(-) create mode 100644 tests/Unit/InnStagePresentationTest.php diff --git a/src/Cutscenes/Editing/CutscenesWorkspace.php b/src/Cutscenes/Editing/CutscenesWorkspace.php index 03d518d..6483950 100644 --- a/src/Cutscenes/Editing/CutscenesWorkspace.php +++ b/src/Cutscenes/Editing/CutscenesWorkspace.php @@ -1694,7 +1694,7 @@ private function describeCutsceneReferences(CutsceneAsset $asset): array if ($this->workspace instanceof ProjectWorkspace) { $uses = EffectValidator::findUses($this->workspace)[$asset->id] ?? []; $scripts = EffectValidator::findScriptUses($this->workspace, $this->cutsceneLibrary()?->assets(CutsceneType::CINEMATIC) ?? []); - $references = [...($uses['battle'] ?? []), ...($uses['field'] ?? []), ...($scripts[$asset->id] ?? [])]; + $references = [...($uses['battle'] ?? []), ...($uses['field'] ?? []), ...($uses['stage'] ?? []), ...($scripts[$asset->id] ?? [])]; } } else { foreach ($this->workspace?->actorDatabase->getActors() ?? [] as $actor) { diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index a868dc6..bead8f7 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -17,6 +17,7 @@ use Ichiloto\Editor\Database\Projections\OptimizationOutcomeProjection; use Ichiloto\Editor\Database\Projections\OptimizationWeightProjection; use Ichiloto\Editor\EquipmentOptimizationPolicy; +use Ichiloto\Editor\ProjectConfig; use Ichiloto\Editor\Field\ProjectNpc; use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Editor\PermanentGrowthCatalog; @@ -1457,9 +1458,13 @@ private static function configuration(): RecordSchema $value = array_key_exists('default', $row) ? $row['default'] : ($row['value'] ?? null); $default = array_key_exists('default', $row) ? ProjectRecord::stringify($row['default']) : null; + $reference = ProjectConfig::ENGINE_REFERENCES[strval($row['path'] ?? '')] ?? null; + return [ new RecordField('path', 'Setting', isReadOnly: true), match (true) { + // A setting naming another resource is chosen, never spelled. + $reference !== null => RecordField::reference('value', 'Value', $reference, allowsNone: true, noneLabel: 'None'), // An enum setting is a choice of its cases by name: some // enums' values (a colour's terminal code) are not text // an author can read. diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 19a51cc..1f95c90 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\PermanentGrowthCatalog; use Ichiloto\Editor\ProjectActor; +use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\ProjectQuest; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\Cutscenes\CutsceneAsset; @@ -24,6 +25,7 @@ use Ichiloto\Engine\Entities\Magic\MagicEffectType; use Ichiloto\Engine\Entities\Skills\SkillResolutionScope; use InvalidArgumentException; +use Throwable; /** * What a field pointing at another resource may point at. @@ -93,6 +95,7 @@ final class ReferenceCatalog 'event_markers', 'tilesets', 'effects', + 'stage_timelines', 'map_regions', 'animation_roles', 'battle_arenas', @@ -269,10 +272,37 @@ public function valuesFor(string $category): array 'animation_roles' => \Ichiloto\Engine\Animations\ActionAnimationResolver::getSupportedRoles(), // Effect timelines are folders the Engine lists by stable id. 'effects' => new EffectTimelineLibrary($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets')->findTimelineIds(), + 'stage_timelines' => $this->stageTimelineIds(), default => $this->recordValues($category), }; } + /** + * The effect timelines the Engine admits as a stage of their own, such + * as an inn's rest, by stable id: those its stage loading accepts (fixed, + * once, a stage descriptor, stage images, no cues). Admission is the + * Engine's; a timeline it refuses is not offered. + * + * @return list + */ + private function stageTimelineIds(): array + { + $library = new EffectTimelineLibrary($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); + + return ProjectDirectoryContext::run($this->workspace->projectRoot, static fn(): array => array_values(array_filter( + $library->findTimelineIds(), + static function (string $id) use ($library): bool { + try { + $library->loadStage($id); + + return true; + } catch (Throwable) { + return false; + } + }, + ))); + } + /** @return string[] The catalogue's basic skills usable in battle, in catalogue order. */ private function attackSkillNames(): array { diff --git a/src/Editor.php b/src/Editor.php index f1bbd79..0117a35 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -12380,7 +12380,17 @@ private function openInspectorReferenceDialog(array $field): bool ]); } - if ($entries === [] || ($category === 'bgm' && count($entries) === 1 && $target === 'map-data')) { + if (($field['optional'] ?? false) === true) { + // Optional data is cleared by choosing nothing, which removes it. + array_unshift($entries, [ + 'label' => 'None', + 'value' => '', + 'description' => 'Unset: the game uses its default.', + ]); + } + + $unset = ($field['optional'] ?? false) === true && (string) ($field['value'] ?? '') === ''; + if ($entries === [] || (($unset || ($category === 'bgm' && $target === 'map-data')) && count($entries) === 1)) { $this->setStatus( sprintf('This project has no %s to choose from.', mb_strtolower($title)), StatusLevel::WARN, diff --git a/src/Events/EventTypeCatalog.php b/src/Events/EventTypeCatalog.php index 573505d..9c86b9e 100644 --- a/src/Events/EventTypeCatalog.php +++ b/src/Events/EventTypeCatalog.php @@ -125,6 +125,10 @@ className: SleepEventTrigger::class, 'cost' => 0, ], defaultDefinitionFields: ['cue' => ['symbol' => '', 'color' => 'bright-yellow']], + // InnOffer's own optional data: the rest music, else the + // project's sleep theme; the rest stage timeline, else + // config's graphics.inn.presentation. + optionalData: ['bgm' => '', 'presentation' => ''], ), new EventTypeDefinition( label: 'Chest', @@ -166,6 +170,12 @@ public static function findByLabel(string $label): ?EventTypeDefinition return array_find(self::all(), static fn(EventTypeDefinition $definition): bool => $definition->label === $label); } + /** Returns the type of a trigger class, or null when the catalog does not list it. */ + public static function findByClass(?string $className): ?EventTypeDefinition + { + return array_find(self::all(), static fn(EventTypeDefinition $definition): bool => $definition->className === $className); + } + /** * Returns the index for the given trigger class. * diff --git a/src/Events/EventTypeDefinition.php b/src/Events/EventTypeDefinition.php index 6c681e3..0975543 100644 --- a/src/Events/EventTypeDefinition.php +++ b/src/Events/EventTypeDefinition.php @@ -13,6 +13,11 @@ final class EventTypeDefinition * @param array $defaultData * @param array $defaultDefinitionFields Root fields added * beside `class` and `data` when a new event of this type is created. + * @param array $optionalData Data keys an event of this + * type may hold and the Engine reads as unset when absent, with the + * value an inspector shows for one that is absent. They are offered on + * every such event and written only when an author sets one; clearing + * one removes it again. */ public function __construct( public readonly string $label, @@ -20,6 +25,7 @@ public function __construct( public readonly string $description, public readonly array $defaultData, public readonly array $defaultDefinitionFields = [], + public readonly array $optionalData = [], ) { } diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index d8d4154..f892919 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -569,6 +569,8 @@ public static function findEventReference(array $field): ?array return match (true) { $path === ['data', 'scriptId'] => ['category' => 'common_events', 'title' => 'Event Script'], $path === ['data', 'cinematicId'] => ['category' => 'cinematics', 'title' => 'Cinematic'], + // An inn's rest stage, as InnOffer reads it. + $path === ['data', 'presentation'] => ['category' => 'stage_timelines', 'title' => 'Rest Presentation'], $leaf === 'bgm' => ['category' => 'bgm', 'title' => 'Music'], $leaf === 'sfx' => ['category' => 'sfx', 'title' => 'Sound Effect'], // A shop's stock is data.items.N.item. The leaf alone would also @@ -670,14 +672,22 @@ private function applyEventField(ProjectMap $map, array $field, mixed $value): ? { $marker = (string) $field['marker']; $path = (array) ($field['path'] ?? []); + $held = $map->hasEventField($marker, $path); $oldValue = $map->getEventField($marker, $path); - $map->setEventField($marker, $path, $value); - - return new GenericCommand( - sprintf('%s edit', $field['label'] ?? 'Event field'), - static fn() => $map->setEventField($marker, $path, $value), - static fn() => $map->setEventField($marker, $path, $oldValue), - ); + // Optional data cleared is removed, as it was before it was set. + $removed = ($field['optional'] ?? false) === true && ($value === null || $value === ''); + if ($removed && ! $held) { + return null; + } + $apply = $removed + ? static fn() => $map->removeEventField($marker, $path) + : static fn() => $map->setEventField($marker, $path, $value); + $restore = $held + ? static fn() => $map->setEventField($marker, $path, $oldValue) + : static fn() => $map->removeEventField($marker, $path); + $apply(); + + return new GenericCommand(sprintf('%s edit', $field['label'] ?? 'Event field'), $apply, $restore); } /** @@ -1114,10 +1124,20 @@ public function buildEventDataFields(string $marker, array $definition): array $eventData = $definition['data'] ?? []; if (is_array($eventData)) { - $fields = $this->decorateEventInspectorFields( + // The type's optional data is offered as it reads when absent, + // and stays out of the stored event until an author sets it. + $optional = EventTypeCatalog::findByClass(is_string($definition['class'] ?? null) ? $definition['class'] : null)?->optionalData ?? []; + $fields = array_map(static function (array $field) use ($optional): array { + $path = array_values((array) ($field['path'] ?? [])); + if (count($path) === 2 && array_key_exists((string) $path[1], $optional)) { + $field['optional'] = true; + } + + return $field; + }, $this->decorateEventInspectorFields( $marker, - $this->flattenInspectorFields($eventData, ['data']), - ); + $this->flattenInspectorFields([...$eventData, ...array_diff_key($optional, $eventData)], ['data']), + )); } $rootFields = array_diff_key($definition, ['class' => true, 'data' => true]); diff --git a/src/ProjectConfig.php b/src/ProjectConfig.php index f2bf7b5..7ba5480 100644 --- a/src/ProjectConfig.php +++ b/src/ProjectConfig.php @@ -13,6 +13,7 @@ use Ichiloto\Editor\Storage\FileSetOperations; use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Editor\Storage\FilesystemFileSetOperations; +use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Rendering\FieldViewport; use RuntimeException; use Throwable; @@ -21,12 +22,20 @@ final class ProjectConfig { public const string FIELD_ZOOM = 'graphics.field.zoom'; + /** The inn's rest stage timeline when an inn names none of its own (InnStay). */ + public const string INN_PRESENTATION = 'graphics.inn.presentation'; /** * Settings the engine reads with a default when the file leaves them * out, by path, with that default. */ - private const array ENGINE_DEFAULTS = [self::FIELD_ZOOM => FieldViewport::DEFAULT_ZOOM]; + private const array ENGINE_DEFAULTS = [self::FIELD_ZOOM => FieldViewport::DEFAULT_ZOOM, self::INN_PRESENTATION => null]; + + /** + * Settings that name another resource, by path, with the reference kind + * ({@see \Ichiloto\Editor\Database\ReferenceCatalog}) they are chosen from. + */ + public const array ENGINE_REFERENCES = [self::INN_PRESENTATION => 'stage_timelines']; private ?PhpArraySourceDocument $document = null; private ?string $issue = null; private array $payload = []; @@ -109,7 +118,8 @@ public function getReadOnlyReason(): ?string /** * Refuses a value the engine would not accept at a path, before anything - * changes: a field zoom outside its range. + * changes: a field zoom outside its range, or an inn presentation that + * is not a timeline identity. * * @throws \InvalidArgumentException */ @@ -119,6 +129,10 @@ public function assertAcceptableValue(string $path, mixed $value): void || ! is_finite((float) $value) || $value < FieldViewport::MIN_ZOOM || $value > FieldViewport::MAX_ZOOM)) { throw new \InvalidArgumentException(sprintf('Field zoom must be a finite number from %g to %g.', FieldViewport::MIN_ZOOM, FieldViewport::MAX_ZOOM)); } + if ($path === self::INN_PRESENTATION && $value !== null) { + // A stable timeline identity, as InnStay loads it; null leaves inns without one. + EffectTimelineLibrary::assertId(is_string($value) ? $value : ''); + } } public function getFieldIssue(string $path): ?string diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 56543b5..eced3c3 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -1692,6 +1692,22 @@ public function getEventField(string $marker, array $path): mixed return $reference; } + /** Whether an event holds a field at a path, even one whose value is null. */ + public function hasEventField(string $marker, array $path): bool + { + $reference = $this->editableData['events'][$marker] ?? null; + + foreach ($path as $segment) { + if (! is_array($reference) || ! array_key_exists($segment, $reference)) { + return false; + } + + $reference = $reference[$segment]; + } + + return $path !== []; + } + /** * Returns the event marker located at the given coordinate. * @@ -1929,6 +1945,30 @@ public function setEventField(string $marker, array $path, mixed $value): void } } + /** + * Removes an event's field at a path, leaving everything else it holds; + * nothing changes when it holds none there. + */ + public function removeEventField(string $marker, array $path): void + { + $this->assertEditable(); + if (! $this->hasEventField($marker, $path)) { + return; + } + + $next = $this->editableData; + $reference = &$next['events'][$marker]; + $last = array_pop($path); + + foreach ($path as $segment) { + $reference = &$reference[$segment]; + } + + unset($reference[$last]); + unset($reference); + $this->writeData($next); + } + /** * Replaces the definition for the given event marker. * diff --git a/src/Validation/EffectValidator.php b/src/Validation/EffectValidator.php index fd421d9..b7de80d 100644 --- a/src/Validation/EffectValidator.php +++ b/src/Validation/EffectValidator.php @@ -5,6 +5,8 @@ namespace Ichiloto\Editor\Validation; use Ichiloto\Editor\Cutscenes\CutsceneAsset; +use Ichiloto\Editor\Cutscenes\CutsceneType; +use Ichiloto\Editor\ProjectConfig; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Animations\Field\FieldEffectManager; use Ichiloto\Engine\Animations\Field\FieldPresentationCatalog; @@ -17,9 +19,11 @@ * Checks every effect timeline the project uses, as the Engine plays it for * each consumer: battle animations' source and target effects in battle; * the field presentation's cue and action prompt effects, maps' field - * effects and tileset pieces' effects on the field. Each is compiled for - * both the terminal and the graphical presentation, so a sequence that only - * one renderer would refuse is still found. + * effects and tileset pieces' effects on the field; inns' rest stages as a + * stage of their own. Battle and field effects are compiled for both the + * terminal and the graphical presentation, so a sequence that only one + * renderer would refuse is still found; a stage is admitted as the Engine's + * stage loading admits it. */ final class EffectValidator { @@ -36,6 +40,16 @@ public function validate(ProjectWorkspace $workspace): array foreach ($uses as $effect => $contexts) { foreach ($contexts as $context => $places) { + if ($context === 'stage') { + try { + $library->loadStage($effect); + } catch (Throwable $failure) { + $issues[] = Issue::error($places[0], sprintf('Effect %s cannot be played as a stage: %s', $effect, $failure->getMessage()), + sprintf('Fix assets/Animations/%s, or choose a stage timeline. Until then the rest shows no graphical stage.%s', $effect, + count($places) > 1 ? sprintf(' Also used by %d more.', count($places) - 1) : '')); + } + continue; + } foreach (EffectPresentation::cases() as $presentation) { try { $library->load($effect, $context === 'battle', $presentation); @@ -90,10 +104,11 @@ public static function checkOneShotFieldEffect(EffectTimelineLibrary $library, s * Returns where the project plays each effect, by context: battle * animations' source and target effects in battle; the field * presentation's cues and action prompt, maps' field effects and tileset - * pieces' effects on the field. + * pieces' effects on the field; and the rest stage of Sleep events, `inn` + * commands at any depth and the project's default inn presentation. * * @param Issue[] $issues Receives what could not be read. - * @return array, field?: list}> + * @return array, field?: list, stage?: list}> */ public static function findUses(ProjectWorkspace $workspace, array &$issues = []): array { @@ -112,6 +127,10 @@ public static function findUses(ProjectWorkspace $workspace, array &$issues = [] } } + foreach (self::findStageUses($workspace) as $effect => $places) { + $uses[$effect]['stage'] = $places; + } + try { $catalog = FieldPresentationCatalog::load($assetRoot); @@ -157,6 +176,38 @@ public static function findUses(ProjectWorkspace $workspace, array &$issues = [] return $uses; } + /** + * Returns where each rest stage timeline is named, as InnOffer reads it: + * a Sleep event's data, an `inn` command at any depth of a map event, + * event script or cinematic, and config's graphics.inn.presentation for + * an inn naming none. + * + * @return array> Timeline id to where it is named. + */ + private static function findStageUses(ProjectWorkspace $workspace): array + { + $uses = []; + $name = static function (mixed $presentation, string $where) use (&$uses): void { + if (is_string($presentation) && trim($presentation) !== '') { + $uses[trim($presentation)][] = $where; + } + }; + + foreach ($workspace->maps as $map) { + foreach ((array) ($map->data['events'] ?? []) as $marker => $definition) { + if (is_array($definition) && str_ends_with(strval($definition['class'] ?? ''), 'SleepEventTrigger')) { + $name(((array) ($definition['data'] ?? []))['presentation'] ?? null, sprintf('map %s event %s', $map->mapId, strval($marker))); + } + } + } + + self::visitCommands($workspace, $workspace->cutscenes?->assets(CutsceneType::CINEMATIC) ?? [], 'inn', + static fn(array $command, string $where) => $name($command['presentation'] ?? null, $where)); + $name($workspace->config?->getRecord(ProjectConfig::INN_PRESENTATION)->get('value'), 'config.php: ' . ProjectConfig::INN_PRESENTATION); + + return array_map(static fn(array $places): array => array_values(array_unique($places)), $uses); + } + /** * Returns the scripts whose `field_animation` commands play each effect: * map events, event scripts and cinematics, at any depth of branches, @@ -189,12 +240,25 @@ public static function findScriptUses(ProjectWorkspace $workspace, iterable $cin */ public static function visitFieldAnimations(ProjectWorkspace $workspace, iterable $cinematics, \Closure $visit): void { - $collect = static function (mixed $value, string $where) use (&$collect, $visit): void { + self::visitCommands($workspace, $cinematics, 'field_animation', $visit); + } + + /** + * Visits every command of a type a script runs: map events, event + * scripts and cinematics, at any depth of branches, lanes and choices. + * What cannot be read is not visited; validation reports it. + * + * @param iterable $cinematics The project's cinematics, as the editor holds them. + * @param \Closure(array, string): void $visit Given the command and where it is. + */ + private static function visitCommands(ProjectWorkspace $workspace, iterable $cinematics, string $type, \Closure $visit): void + { + $collect = static function (mixed $value, string $where) use (&$collect, $visit, $type): void { if (! is_array($value)) { return; } - if (($value['type'] ?? null) === 'field_animation') { + if (($value['type'] ?? null) === $type) { $visit($value, $where); } diff --git a/tests/Unit/InnStagePresentationTest.php b/tests/Unit/InnStagePresentationTest.php new file mode 100644 index 0000000..18a2b7f --- /dev/null +++ b/tests/Unit/InnStagePresentationTest.php @@ -0,0 +1,141 @@ + 12, 'lengthFrames' => 6, 'restFrame' => 2, + 'stage' => ['canvas' => ['width' => 320, 'height' => 180], 'startFrame' => 0, 'restoreFrame' => 5, + 'camera' => [['id' => 'hold', 'frame' => 0, 'focus' => ['x' => 160, 'y' => 90], 'zoom' => 1, 'easing' => 'hold']], + 'subjects' => [['id' => 'sleeper', 'position' => ['x' => 160, 'y' => 150], 'size' => ['width' => 64, 'height' => 96]]]], + 'tracks' => [['id' => 'sleeper', 'type' => 'image', 'asset' => 'Graphics/Rest/sleeper.png', 'sheet' => ['columns' => 2, 'rows' => 1], + 'anchor' => 'stage', 'placement' => ['subject' => 'sleeper'], 'pivot' => ['x' => .5, 'y' => 1], + 'keyframes' => [['frame' => 0, 'duration' => 3], ['frame' => 3, 'duration' => 2, 'sourceFrame' => 1]]]], + ], true) . ';'); + @mkdir($root . '/assets/Animations/flicker', 0o777, true); + file_put_contents($root . '/assets/Animations/flicker/flicker.timeline.php', ' 10, 'lengthFrames' => 4, + 'tracks' => [['id' => 'glyph', 'type' => 'glyph', 'keyframes' => [['frame' => 0, 'duration' => 4, 'content' => '*']]]], + ], true) . ';'); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace(" 'events' => [\n", " 'events' => [\n 'S' => [\n 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\SleepEventTrigger',\n 'data' => ['confirmDialogue' => ['name' => '', 'text' => 'Rest here?'], 'cost' => 5],\n ],\n", + (string) file_get_contents($data))); + + return $root; +} + +function innBedMap(ProjectWorkspace $workspace): ProjectMap +{ + return array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); +} + +/** @return array The bed's data row at a path. */ +function innBedField(MapInspector $inspector, ProjectMap $map, array $path): array +{ + return array_find($inspector->buildEventDataFields('S', $map->getEventDefinition('S') ?? []), + static fn(array $field): bool => ($field['path'] ?? null) === $path) ?? []; +} + +it('offers only the timelines the Engine admits as a stage', function () { + $catalog = new ReferenceCatalog(ProjectWorkspace::fromProject(innStageProject())); + + expect($catalog->valuesFor('stage_timelines'))->toBe(['quiet-night']) + ->and($catalog->valuesFor('effects'))->toBe(['flicker', 'quiet-night']); +}); + +it('offers a Sleep event\'s rest music and stage unset, writes one only when chosen, and removes it when cleared', function () { + $root = innStageProject(); + $workspace = ProjectWorkspace::fromProject($root); + $map = innBedMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace)); + + $stage = innBedField($inspector, $map, ['data', 'presentation']); + expect($stage)->toMatchArray(['reference' => 'stage_timelines', 'optional' => true, 'value' => '']) + ->and(innBedField($inspector, $map, ['data', 'bgm']))->toMatchArray(['reference' => 'bgm', 'optional' => true]) + // Offered, not stored: the event holds only what was authored. + ->and(array_keys($map->getEventDefinition('S')['data']))->toBe(['confirmDialogue', 'cost']); + + $chosen = $inspector->apply($map, $stage, 'quiet-night'); + expect($map->getEventDefinition('S')['data']['presentation'])->toBe('quiet-night'); + $chosen->undo(); + expect($map->getEventDefinition('S')['data'])->not->toHaveKey('presentation'); + $chosen->execute(); + + $cleared = $inspector->apply($map, innBedField($inspector, $map, ['data', 'presentation']), ''); + expect($map->getEventDefinition('S')['data'])->not->toHaveKey('presentation'); + $cleared->undo(); + expect($map->getEventDefinition('S')['data']['presentation'])->toBe('quiet-night'); + $cleared->execute(); + // Clearing what is already unset changes nothing. + expect($inspector->apply($map, innBedField($inspector, $map, ['data', 'presentation']), ''))->toBeNull(); + + $map->save(); + expect((require $root . '/assets/Maps/test-map/test-map.data.php')['events']['S']['data']) + ->toBe(['confirmDialogue' => ['name' => '', 'text' => 'Rest here?'], 'cost' => 5]); +}); + +it('chooses the project\'s default rest stage in Configuration, writing nothing when left unset', function () { + $root = innStageProject(); + $workspace = ProjectWorkspace::fromProject($root); + $database = $workspace->getRecordDatabase('configuration'); + $index = array_search(ProjectConfig::INN_PRESENTATION, $database->getEntryLabels(), true); + $value = array_find($database->getSettingsFields($index), static fn(array $field): bool => ($field['field'] ?? null) === 'value'); + $before = (string) file_get_contents($root . '/config.php'); + + expect($value['reference'] ?? null)->toBe('stage_timelines'); + $database->setField($index, 'value', 'None'); + expect($workspace->config->isDirty())->toBeFalse(); + + $database->setField($index, 'value', 'quiet-night'); + $workspace->config->save(); + expect((require $root . '/config.php')['graphics']['inn']['presentation'])->toBe('quiet-night') + ->and(fn() => $database->setField($index, 'value', 'Not An Id'))->toThrow(InvalidArgumentException::class); + expect(str_starts_with((string) file_get_contents($root . '/config.php'), substr($before, 0, 20)))->toBeTrue(); +}); + +it('reports a rest stage the Engine would not admit, wherever it is named', function () { + $root = innStageProject(); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace("'cost' => 5]", "'cost' => 5, 'presentation' => 'flicker']", (string) file_get_contents($data))); + file_put_contents($root . '/assets/Events/inn-keeper.php', " 'if', 'condition' => ['switch' => 'late'], 'then' => [ + ['type' => 'inn', 'confirmDialogue' => ['text' => 'A bed?'], 'presentation' => 'flicker'], + ]], + ], true) . ";\n"); + $stages = static fn(): array => array_values(array_map(static fn(Issue $issue): string => $issue->where . ': ' . $issue->message . ' ' . $issue->hint, + array_filter(new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), + static fn(Issue $issue): bool => str_contains($issue->message, 'as a stage')))); + + // One report per timeline, naming its first use and counting the nested inn command's. + expect($stages())->toHaveCount(1) + ->and($stages()[0])->toStartWith('map test-map event S: Effect flicker cannot be played as a stage') + ->toContain('Also used by 1 more.'); + + // An admitted stage named anywhere is not reported. + file_put_contents($data, str_replace("'presentation' => 'flicker'", "'presentation' => 'quiet-night'", (string) file_get_contents($data))); + file_put_contents($root . '/assets/Events/inn-keeper.php', str_replace("'flicker'", "'quiet-night'", (string) file_get_contents($root . '/assets/Events/inn-keeper.php'))); + expect($stages())->toBe([]); +}); From 4bfdcc679463365a57ed017726623d5ae8bd28b9 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 7 Oct 2026 23:32:50 +0200 Subject: [PATCH 228/344] test(cutscenes): shift every authored sequence of a paired summon timeline --- .../Unit/Cutscenes/ArraySourceWriterTest.php | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/Unit/Cutscenes/ArraySourceWriterTest.php b/tests/Unit/Cutscenes/ArraySourceWriterTest.php index 05d45c1..d76db61 100644 --- a/tests/Unit/Cutscenes/ArraySourceWriterTest.php +++ b/tests/Unit/Cutscenes/ArraySourceWriterTest.php @@ -361,15 +361,24 @@ function rewriteAuthored(callable $change): string // patched in place and evaluates exactly. expect(ArraySourceWriter::rewrite($document, $evaluated, $evaluated)->source)->toBe($source); + // Every sequence the file authors: its own tracks, or each + // presentation's when it pairs terminal and graphical ones. $new = $evaluated; - - foreach ($new['tracks'] as &$track) { - foreach ($track['keyframes'] as &$keyframe) { - $keyframe['frame'] = $keyframe['frame'] + 1; + $shift = static function (array $sequence): array { + foreach ($sequence['tracks'] ?? [] as $track => $authored) { + foreach ($authored['keyframes'] ?? [] as $keyframe => $key) { + $sequence['tracks'][$track]['keyframes'][$keyframe]['frame'] = $key['frame'] + 1; + } } + + return $sequence; + }; + $new = $shift($new); + foreach (array_keys($new['presentations'] ?? []) as $presentation) { + $new['presentations'][$presentation] = $shift($new['presentations'][$presentation]); } - unset($track, $keyframe); + expect($new)->not->toBe($evaluated); $rewritten = ArraySourceWriter::rewrite($document, $evaluated, $new); expect(evaluateSource($rewritten->source))->toBe($new) From 2bf2694f5c75d096ce36ea079ad334d24fff2ada Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 8 Oct 2026 11:02:14 +0200 Subject: [PATCH 229/344] 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 | 54 +++++++---- CLAUDE.md | 6 ++ GIT_WORKFLOW.md | 103 ++++++++++++++++++++ README.md | 6 ++ scripts/install-git-guards.sh | 33 +++++++ 12 files changed, 446 insertions(+), 21 deletions(-) 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 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 index dc90ce8..51a0fd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,21 +1,33 @@ -# Editor Engineering Rules - -These rules intentionally repeat the repository-level rules so editor work cannot drift into local-only shortcuts. - -## Correctness First - -Always favor correctness, maintainability, and complete design over speed. No shortcuts. No hacky solutions. If a problem deserves a proper model, architecture, state machine, picker, abstraction, or validation layer, build that instead of patching symptoms. - -## Solve Globally By Default - -When fixing a problem, solve the root issue globally rather than making a narrow local workaround, unless Andrew explicitly asks for a local-only change. Look for the broader pattern, existing architecture, and future maintenance cost before implementing. - -## Best Practices Are Required - -Apply best practices by default: explicit data models, stable references, validation at boundaries, clear ownership, cohesive APIs, tests for behavior, and clean separation of concerns. Avoid hidden conventions, magic strings, fragile coupling, and UI flows that rely on memory or perfect spelling. - -## Editor References Must Be Selected - -When editor fields reference another resource, they must use a selector, picker, dialog, browser, or constrained list. Do not require manual typing for references to actors, classes, items, skills, animations, summon cutscenes, maps, events, files, or other resources known to the editor. Manual text is for authored free text, not resource references. - -Persist the selected stable reference after selection. Do not make authors remember or spell resource ids by hand. +# Editor Engineering Rules + +## Binding Git workflow + +Read [GIT_WORKFLOW.md](GIT_WORKFLOW.md) before work, after compaction and before +publishing. On Andrew's workstation also read the original +`/Users/andrewmasiye/.codex/AGENTS.md`. 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. + +These rules intentionally repeat the repository-level rules so editor work cannot drift into local-only shortcuts. + +## Correctness First + +Always favor correctness, maintainability, and complete design over speed. No shortcuts. No hacky solutions. If a problem deserves a proper model, architecture, state machine, picker, abstraction, or validation layer, build that instead of patching symptoms. + +## Solve Globally By Default + +When fixing a problem, solve the root issue globally rather than making a narrow local workaround, unless Andrew explicitly asks for a local-only change. Look for the broader pattern, existing architecture, and future maintenance cost before implementing. + +## Best Practices Are Required + +Apply best practices by default: explicit data models, stable references, validation at boundaries, clear ownership, cohesive APIs, tests for behavior, and clean separation of concerns. Avoid hidden conventions, magic strings, fragile coupling, and UI flows that rely on memory or perfect spelling. + +## Editor References Must Be Selected + +When editor fields reference another resource, they must use a selector, picker, dialog, browser, or constrained list. Do not require manual typing for references to actors, classes, items, skills, animations, summon cutscenes, maps, events, files, or other resources known to the editor. Manual text is for authored free text, not resource references. + +Persist the selected stable reference after selection. Do not make authors remember or spell resource ids by hand. diff --git a/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 e99cd42..e33ff3c 100644 --- a/README.md +++ b/README.md @@ -126,3 +126,9 @@ against that checkout instead of the installed Engine sources. - Console repository: [github.com/ichiloto/console](https://github.com/ichiloto/console) - Website repository: [github.com/ichiloto/website-v2](https://github.com/ichiloto/website-v2) - Editor issues: [github.com/ichiloto/editor/issues](https://github.com/ichiloto/editor/issues) + +## Contributing and Git workflow + +Read [GIT_WORKFLOW.md](GIT_WORKFLOW.md) and install the Git guards with +`sh scripts/install-git-guards.sh` before contributing. All changes integrate +into `develop`; `main` is updated only by a PR from this repository's `develop`. diff --git a/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 31df57f7435d88c92bc12c4690841a7e896effdc Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 8 Oct 2026 11:17:23 +0200 Subject: [PATCH 230/344] 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 d6624149e857c5eb5c585002e2d477b18aff3771 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 8 Oct 2026 11:24:08 +0200 Subject: [PATCH 231/344] 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 51a0fd3..e0e2661 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,17 @@ 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. These rules intentionally repeat the repository-level rules so editor work cannot drift into local-only shortcuts. 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 ec0f0130fdbac9d6d6d88738d0f75e8b51397107 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 9 Oct 2026 16:11:34 +0200 Subject: [PATCH 232/344] docs(manual): link the GUI editor plan at its new place --- docs/manual.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/manual.md b/docs/manual.md index 415951a..b127cd3 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -516,7 +516,7 @@ replace it. RPG Maker-like GUI editor for graphical materials, atlases and crop authoring. The planned `ichiloto edit` entry point will offer a TUI/GUI choice; that choice and the GUI itself are not delivered by this TUI boundary correction. See the -[GUI editor plan](../../gui-editor/README.md). +[GUI editor plan](../../gui-editor/docs/plan.md). ### Normal mode From 2e88734a2c95f8e1a56747f774bc8716d1b717d4 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 10 Oct 2026 00:54:48 +0200 Subject: [PATCH 233/344] 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 | 58 +++++++---- CLAUDE.md | 11 +++ GIT_WORKFLOW.md | 117 ++++++++++++++++++++++ README.md | 6 ++ scripts/install-git-guards.sh | 33 +++++++ 12 files changed, 475 insertions(+), 21 deletions(-) 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 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 index dc90ce8..e0e2661 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,21 +1,37 @@ -# Editor Engineering Rules - -These rules intentionally repeat the repository-level rules so editor work cannot drift into local-only shortcuts. - -## Correctness First - -Always favor correctness, maintainability, and complete design over speed. No shortcuts. No hacky solutions. If a problem deserves a proper model, architecture, state machine, picker, abstraction, or validation layer, build that instead of patching symptoms. - -## Solve Globally By Default - -When fixing a problem, solve the root issue globally rather than making a narrow local workaround, unless Andrew explicitly asks for a local-only change. Look for the broader pattern, existing architecture, and future maintenance cost before implementing. - -## Best Practices Are Required - -Apply best practices by default: explicit data models, stable references, validation at boundaries, clear ownership, cohesive APIs, tests for behavior, and clean separation of concerns. Avoid hidden conventions, magic strings, fragile coupling, and UI flows that rely on memory or perfect spelling. - -## Editor References Must Be Selected - -When editor fields reference another resource, they must use a selector, picker, dialog, browser, or constrained list. Do not require manual typing for references to actors, classes, items, skills, animations, summon cutscenes, maps, events, files, or other resources known to the editor. Manual text is for authored free text, not resource references. - -Persist the selected stable reference after selection. Do not make authors remember or spell resource ids by hand. +# Editor Engineering Rules + +## Binding Git workflow + +Read [GIT_WORKFLOW.md](GIT_WORKFLOW.md) before work, after compaction and before +publishing. On Andrew's workstation also read the original +`/Users/andrewmasiye/.codex/AGENTS.md`. Andrew's develop-to-main rule applies +only where he has created a `develop` branch. This repository has that existing +branch: NEVER commit, merge locally or push directly to `main`, including private +documentation. Changes integrate into `develop`; `main` receives only a PR from +this repository's `develop`. +Repositories found without Andrew-created `develop` are outside this remediation +and must be left alone; do not create that branch or infer direct-main permission. +Remote working branches, history rewrites and branch deletion require explicit +scoped authorization. Within the existing-develop scope, prior direct-main +exception claims are superseded by Andrew's 2026-10-08 rule. +Install the tracked Git guards before committing or publishing here. + +These rules intentionally repeat the repository-level rules so editor work cannot drift into local-only shortcuts. + +## Correctness First + +Always favor correctness, maintainability, and complete design over speed. No shortcuts. No hacky solutions. If a problem deserves a proper model, architecture, state machine, picker, abstraction, or validation layer, build that instead of patching symptoms. + +## Solve Globally By Default + +When fixing a problem, solve the root issue globally rather than making a narrow local workaround, unless Andrew explicitly asks for a local-only change. Look for the broader pattern, existing architecture, and future maintenance cost before implementing. + +## Best Practices Are Required + +Apply best practices by default: explicit data models, stable references, validation at boundaries, clear ownership, cohesive APIs, tests for behavior, and clean separation of concerns. Avoid hidden conventions, magic strings, fragile coupling, and UI flows that rely on memory or perfect spelling. + +## Editor References Must Be Selected + +When editor fields reference another resource, they must use a selector, picker, dialog, browser, or constrained list. Do not require manual typing for references to actors, classes, items, skills, animations, summon cutscenes, maps, events, files, or other resources known to the editor. Manual text is for authored free text, not resource references. + +Persist the selected stable reference after selection. Do not make authors remember or spell resource ids by hand. diff --git a/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 d6f5517..50d2735 100644 --- a/README.md +++ b/README.md @@ -126,3 +126,9 @@ against that checkout instead of the installed Engine sources. - Console repository: [github.com/ichiloto/console](https://github.com/ichiloto/console) - Website repository: [github.com/ichiloto/website-v2](https://github.com/ichiloto/website-v2) - Editor issues: [github.com/ichiloto/editor/issues](https://github.com/ichiloto/editor/issues) + +## Contributing and Git workflow + +Read [GIT_WORKFLOW.md](GIT_WORKFLOW.md) and install the Git guards with +`sh scripts/install-git-guards.sh` before contributing. All changes integrate +into `develop`; `main` is updated only by a PR from this repository's `develop`. diff --git a/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 c03c22d9836823fa4182a1766868f981a160a513 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 10 Oct 2026 02:29:28 +0200 Subject: [PATCH 234/344] feat(editor): add G4 field authoring and owned-stage previews Add shared map placement and lifecycle operations, owner-aware route/rest and staged-actor selectors, current-file directional sheet previews, cue-kind editing and field effect pivots. Preserve independent Terminal data and authoring, map collision, source syntax, external-change refusal and reversible transactions. Preview declared owned stages through Engine admission and composition. Remove fabricated battle/field fallback for declared stages and remove saved-undo resets of validation/conflict checkpoints. Preserve source templates after saved deletions for maps, effects, summons and cinematics. Verified with live Engine: complete Unit suite, 2084 passed, 15 optional real-Game cases skipped, 13807 assertions, exit 0 with strict warning/risky/deprecation/notice/empty-suite gates. Original production all-effects preview separately passed 1 test and 56 assertions. Native visual acceptance, other platforms and the separately assigned retained-underlay physical-role restoration remain open. Local develop checkpoint only; publication permission is pending. --- docs/manual.md | 72 +- docs/roadmap.md | 34 + src/Canvas/GlyphTilePlanner.php | 28 +- src/Canvas/PieceRole.php | 15 +- src/Cutscenes/CutsceneAsset.php | 151 +++- src/Cutscenes/CutsceneHydration.php | 36 +- src/Cutscenes/CutsceneLibrary.php | 43 +- src/Cutscenes/CutsceneRecordCategory.php | 4 +- src/Cutscenes/Editing/CutscenePreviewPane.php | 28 +- .../Preview/CinematicPreviewSession.php | 17 +- src/Cutscenes/Preview/EffectPreviewStage.php | 36 +- src/Cutscenes/Preview/PreviewField.php | 40 +- src/Cutscenes/Preview/PreviewGameScene.php | 6 +- src/Cutscenes/Preview/PreviewMapManager.php | 56 +- src/Cutscenes/Preview/PreviewPlayer.php | 55 +- src/Cutscenes/Preview/PreviewSceneHost.php | 6 +- src/Database/CutsceneSchemas.php | 165 ++-- src/Database/InnPresentationFields.php | 166 ++++ src/Database/MovementRouteFields.php | 195 +++++ src/Database/ProjectRecordDatabase.php | 224 +++-- src/Database/RecordAuthoring.php | 2 +- src/Database/RecordFieldCodec.php | 3 + src/Database/RecordSchema.php | 13 +- src/Database/RecordSchemaCatalog.php | 101 ++- src/Database/RecordSubList.php | 5 + src/Database/ReferenceCatalog.php | 72 +- src/Database/StagedActorFields.php | 177 ++++ src/Editor.php | 12 +- src/Events/ProjectScriptCommands.php | 1 + src/Field/CharacterSheetPreview.php | 53 ++ src/Field/NpcAuthoring.php | 24 + src/Field/NpcInspector.php | 10 +- src/Inspector/MapInspector.php | 281 +++++- src/Maps/ConnectedPiecePreview.php | 83 ++ src/Maps/LineInsertionPlan.php | 2 + src/Maps/LineInsertionPlanner.php | 2 +- src/Maps/MapLayers.php | 12 + src/Maps/MapPlacement.php | 290 +++++++ src/Maps/TilesetAuthoring.php | 69 ++ src/ProjectConfig.php | 66 +- src/ProjectMap.php | 135 ++- src/ProjectWorkspace.php | 14 +- src/Session/EditorSession.php | 341 +++++++- src/Session/MapPlacementSession.php | 202 +++++ src/Session/SessionHost.php | 27 +- src/Storage/FileSetTransaction.php | 80 +- src/Validation/EffectValidator.php | 61 +- src/Validation/MapGraphicsValidator.php | 21 +- src/Validation/ProjectValidator.php | 11 +- tests/Unit/CharacterSheetPreviewTest.php | 41 + .../CinematicNarrationPresentationTest.php | 198 +++++ .../Cutscenes/CutsceneSourceHistoryTest.php | 127 +++ .../Unit/Cutscenes/EffectFieldPreviewTest.php | 15 +- .../FieldEffectPivotAuthoringTest.php | 194 +++++ tests/Unit/Cutscenes/OwnedStageEffectTest.php | 366 ++++++++ .../PreviewLifetimeDiagnosticsTest.php | 375 ++++++++ .../Cutscenes/PreviewPlayerMovementTest.php | 158 ++++ tests/Unit/Cutscenes/PreviewSceneHostTest.php | 12 +- .../Cutscenes/StagedActorAuthoringTest.php | 248 ++++++ tests/Unit/EventAuthoringTest.php | 5 +- tests/Unit/EventCueFrontendTest.php | 111 +++ tests/Unit/EventCueKindAuthoringTest.php | 267 ++++++ tests/Unit/FileSetTransactionTest.php | 63 ++ tests/Unit/InnDescriptorAuthoringTest.php | 515 +++++++++++ tests/Unit/LayeredMapTest.php | 8 +- tests/Unit/LayeredPreviewTest.php | 2 +- tests/Unit/MapGraphicsTest.php | 8 +- tests/Unit/MapSourceHistoryTest.php | 93 ++ tests/Unit/MovementRouteAuthoringTest.php | 821 ++++++++++++++++++ tests/Unit/ScriptCommandAuthoringTest.php | 19 +- .../Session/CinematicPreviewControlTest.php | 187 ++++ .../Session/CutsceneRecordSessionTest.php | 6 +- .../Unit/Session/MapLifecycleSessionTest.php | 199 +++++ .../Session/MapLineInsertionSessionTest.php | 263 ++++++ .../Unit/Session/MapPlacementSessionTest.php | 492 +++++++++++ .../Unit/Session/NpcFieldSheetSessionTest.php | 191 ++++ tests/Unit/Session/SummonStageSessionTest.php | 2 + .../Session/TilesetAuthoringSessionTest.php | 140 +++ tests/Unit/SessionHostTest.php | 74 ++ tests/Unit/SourceFreshnessTest.php | 439 ++++++++++ .../Unit/StagedCharacterSheetPreviewTest.php | 84 ++ tests/Unit/TilesetPieceKeepsTest.php | 271 ++++++ 82 files changed, 9081 insertions(+), 460 deletions(-) create mode 100644 src/Database/InnPresentationFields.php create mode 100644 src/Database/MovementRouteFields.php create mode 100644 src/Database/StagedActorFields.php create mode 100644 src/Field/CharacterSheetPreview.php create mode 100644 src/Maps/ConnectedPiecePreview.php create mode 100644 src/Maps/MapPlacement.php create mode 100644 src/Maps/TilesetAuthoring.php create mode 100644 src/Session/MapPlacementSession.php create mode 100644 tests/Unit/CharacterSheetPreviewTest.php create mode 100644 tests/Unit/Cutscenes/CinematicNarrationPresentationTest.php create mode 100644 tests/Unit/Cutscenes/CutsceneSourceHistoryTest.php create mode 100644 tests/Unit/Cutscenes/FieldEffectPivotAuthoringTest.php create mode 100644 tests/Unit/Cutscenes/OwnedStageEffectTest.php create mode 100644 tests/Unit/Cutscenes/PreviewLifetimeDiagnosticsTest.php create mode 100644 tests/Unit/Cutscenes/PreviewPlayerMovementTest.php create mode 100644 tests/Unit/Cutscenes/StagedActorAuthoringTest.php create mode 100644 tests/Unit/EventCueFrontendTest.php create mode 100644 tests/Unit/EventCueKindAuthoringTest.php create mode 100644 tests/Unit/InnDescriptorAuthoringTest.php create mode 100644 tests/Unit/MapSourceHistoryTest.php create mode 100644 tests/Unit/MovementRouteAuthoringTest.php create mode 100644 tests/Unit/Session/CinematicPreviewControlTest.php create mode 100644 tests/Unit/Session/MapLifecycleSessionTest.php create mode 100644 tests/Unit/Session/MapLineInsertionSessionTest.php create mode 100644 tests/Unit/Session/MapPlacementSessionTest.php create mode 100644 tests/Unit/Session/TilesetAuthoringSessionTest.php create mode 100644 tests/Unit/SessionHostTest.php create mode 100644 tests/Unit/SourceFreshnessTest.php create mode 100644 tests/Unit/StagedCharacterSheetPreviewTest.php create mode 100644 tests/Unit/TilesetPieceKeepsTest.php diff --git a/docs/manual.md b/docs/manual.md index b127cd3..8b02705 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1648,6 +1648,29 @@ the map data. Routes in this phase are sequential and awaited, so `Wait` must remain true. Set either seconds-per-step (with an optional per-step override) or speed in steps per second. +The shared route fields also preserve `waypoints` and `retrace` as distinct +authored modes. A waypoint's omitted X or Y keeps the subject's coordinate for +that leg; adding a point leaves both axes blank until authored, not zero. +Incomplete new or changed routes refuse Save without discarding the draft or +undo history. Unchanged legacy data survives unrelated edits. + +`Remember As` declares a session-local identity. `Recorded Route` selects only +declarations in that script invocation and its referenced common events, +including nested command arms, not other NPCs, dialogue variants, map events or +cinematics. Ordinary NPC/map/common-event sessions and cinematics all support +recording real subjects. Runtime verifies completion, subject/map/generation, +endpoint and one-use consumption; the reference choice does not guarantee a +conditional record has executed. Retrace uses actual successful movement, not +authored reverse geometry, and records disappear at completion or cancellation. + +Graphical clients request these choices with the explicit owning row context: +`references.list` keeps its optional `record` argument as `{category,index,frame}` +for Database, `{kind:'npc',index,frame}` for NPC, or +`{kind:'event',marker,path}` for an inline event, with the owning `map` for the +latter two. Existing `{category,index}` cutscene requests remain compatible. +Omitting the owner does not create a global recorded-route catalogue. This +transport contract does not require graphical controls in the Terminal editor. + A `choice` command's options and a `branch` command's `Then` / `Else` arms are not flattened into this list. Each arm opens as its own frame, described under [Command Frames](#command-frames). @@ -1913,6 +1936,14 @@ The preview starts from the map event that triggers the cinematic when one exists, otherwise from the start map at its first open tile. It writes nothing: not your saves, not your files. +Missing or malformed preview maps are reported beside the controls; the stage +remains usable on explicitly identified undefined terrain. The diagnostic +follows the current map and clears after a successful transfer. GPUI restarts, +resizes and reopened views reconnect to the new scene automatically, even when +the grid is unchanged; a retired view cannot supply its picture or feedback to +the replacement. An effect without a selected start map now previews on +diagnosed undefined terrain instead of refusing to open. + `Ctrl+T` plays the saved cinematic in the real game through the playtest overlay: the start map is copied into the throwaway root with one extra event, an automatic single-use `CinematicEventTrigger` on the spawn tile, @@ -2095,16 +2126,25 @@ choice removes it, and the stroke stays as drawn). An image track names its PNG through the asset picker, its sheet columns and rows, its cell size, its `Fit` (`stretch` fills the cells; `contain` keeps the sheet cell's own proportions inside them; the empty choice removes it, so it stretches), its -depth and, in battle, its `Attachment` (`center`, `head` or `ground` on the -battler; the empty choice removes it, so the image is centred) and `Pivot` -(the point of the sheet cell that sits there, `x, y` from 0 to 1, such as -`0.5, 0.92` for a ring at the cell's foot; empty removes it, so the cell's -middle sits there); its keyframes give the sheet frame, the position and, in battle, +depth and its `Pivot` (the point of the sheet cell placed at the effect's +position, two finite coordinates `x, y` from 0 to 1). The GUI's image-cell +picker sets that point through the same undoable record edit. Empty removes +the authored pivot: field images use bottom-centre (`0.5, 1`), while battle +images keep their centred default (`0.5, 0.5`). Opening or saving an effect +does not materialize either default in the file; an effect shared between +field and battle keeps each consumer's default until a pivot is authored. +The picker marks an explicit pivot, or the default for the current preview +context when omitted, and labels which it is. While a shared effect's preview +context is still loading, no default is guessed. Stage images retain their +centred default. In battle only, `Attachment` selects `center`, +`head` or `ground` on the battler; empty removes it and uses the centre. +Field image tracks do not accept battler attachments. Image keyframes give +the sheet frame, the position and, in battle, `Flip Horizontally` and `Flip Vertically` for an authored reverse stroke. Glyph and text keyframes give frame, duration, position `x` and `y`, content (the multiline editor), asset id, color, visibility, z-index and a payload. Cues are `playSound` on the field and any battle cue in battle. -Impact timing, facing, image attachment and pivot, flips, flashes and shakes +Impact timing, facing, image attachment, flips, flashes and shakes are battle-only; validation names the place that uses an effect where they are refused. @@ -2119,12 +2159,21 @@ sequence an edit was made in. The TUI has no graphical workflow: an image is chosen by path and its frames by number, and pictures belong to the GUI editor. +An effect may instead own a graphical stage. In the GUI, its Stage, subject, +attachment and camera controls use the same record services and Engine stage +contract as summons. The stage owns its surroundings: it is never previewed +against a fabricated battle or field. Its independent Terminal sequence keeps +its own tracks, cues and clock without needing to decode graphical images. +An incomplete stage can be edited and undone, but the Engine must admit it +before Save; removing it preserves the sequence's tracks and cues. + `Shift+O` on a Timeline row inserts the blank the effect's own lists take: an image keyframe after an image keyframe, starting where the selected one ends, and a track whose keyframe has a position `x` and `y`. A save is -compiled by the engine for battle and for the field, for each sequence, and -refused before anything is written unless every sequence plays in at least -one of them. `Delete` names everything that plays the effect first: battle +compiled by the engine in its declared stage, or for battle and the field +when no stage is declared. Save is refused before writing unless each sequence +has a valid context; a paired Terminal sequence retains its independent +admission. `Delete` names everything that plays the effect first: battle animations, the field presentation's cues and action prompt, maps' field effects, tileset pieces, and the `field_animation` commands of map events, event scripts and cinematics. @@ -2149,6 +2198,11 @@ frames with their flips, flashes and shakes, each with its subject. The preview starts as battle when a battle animation uses the effect or nothing on the field does. In battle a stroke with a facing is turned toward its target exactly as the battle turns it, so `D` shows both directions. +An owned stage instead starts in its declared context; `B` does not change its +ownership. The GUI GPUI tab paints the Engine's seekable stage canvas through +the runtime's shared painter. A stage error is shown rather than replaced by +a field frame, and late seek replies cannot overwrite a newer frame. The +Terminal tab shows its separately authored sequence, not a graphical imitation. ### Validation diff --git a/docs/roadmap.md b/docs/roadmap.md index 9cc9752..0d049d7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -838,6 +838,13 @@ Runtime-authored cue conditions use the same fail-closed world-condition vocabulary as trigger conditions; strict validation accepts and checks that shared contract even though nested cue-condition authoring remains deferred. +October 10 G4 authoring adds explicit Story, Route and Unclassified (clear kind) +choices to the same inspector in Terminal and GUI. Clearing removes only kind; +omission retains legacy guidance without inferred classification or authored +defaults. Invalid kinds are rejected even when the symbol is blank. Actual +Terminal controls and the GUI session wire are checked; native picker pixels +remain unobserved. + ## Sequencing notes - Phase 1 is days of work and transforms perceived quality; do it first and ship it alone. @@ -864,6 +871,16 @@ saved wholesale is byte-for-byte unchanged (pinned by test against a disposable copy of the full game), and file-per-entry categories write only dirty, new, or deleted entries. +October 10 source-history correction keeps the original map rewrite basis +separate from the last successful save checkpoint. Removing a field, saving, +undoing and saving again restores its original expression, reference, comments +and key position. Layer undo restores edits without resetting that checkpoint, +so incomplete route/rest drafts cannot bypass save validation. Ordered lists, +external-source refusal and transactional read-back checks remain intact. +Affected Editor families pass 406 tests / 2,636 assertions; the two existing +conditional-cue validator regressions pass separately / three assertions. +These are live-Engine synthetic checks, not native or whole-project acceptance. + ## Map NPC authoring — shipped 2026-08 Genuine map-local NPCs — the engine's `npcs` collection, not painted glyphs, @@ -1079,6 +1096,23 @@ Cinematic story cutscenes and summon presentations are authored on their own screen (`F4`), previewed through the engine itself, validated, and saved without rewriting a byte the author did not change. +October 10 G4 checkpoint adds declared owned-stage effects through the existing +Engine admission and canvas projection. GUI stage authoring uses shared record +services, reference selectors and undo; stage ownership cannot fall back to +battle or field surroundings. Paired Terminal sequences retain their independent +admission and clock. Saved cutscene undo now retains source templates and their +values separately from persisted conflict and changed-reference checkpoints: +removed arrays, nowdocs and comments are restored for effects, summons and +cinematics without weakening external-change or proposed-source refusal. +The complete Editor Unit run against live Engine passes 2,084 tests / 13,807 +assertions under strict warning/risky/deprecation/notice/empty-suite gates, +with 15 optional real-Game cases skipped because no Game source was pinned. +The original production all-effects preview regression separately passes +one test / 56 assertions on current Game; it was not weakened or replaced. +GUI's complete offline release suite passes 140 tests. These checks do not +close native visual acceptance, other platforms, or the separately assigned +retained-underlay terminal/collision restoration correction. + **A cutscene is a pair, and the pair is one asset.** `CutsceneAsset` reads `.data.php` with `.script.php` or `.timeline.php`, knows which keys belong to which file, is dirty by content, and saves atomically: diff --git a/src/Canvas/GlyphTilePlanner.php b/src/Canvas/GlyphTilePlanner.php index d255185..750575b 100644 --- a/src/Canvas/GlyphTilePlanner.php +++ b/src/Canvas/GlyphTilePlanner.php @@ -20,8 +20,8 @@ final readonly class GlyphTilePlanner { /** - * @param array> $roles Roles by glyph, distinct by the tiles they draw. - * @param array $twins The kept role for each key of a role that draws the same tiles with the same glyph. + * @param array> $roles Roles by glyph, distinct by the tiles they draw and keep. + * @param array $twins The kept role for each key of a role that draws and keeps the same tiles with the same glyph. */ private function __construct(private array $roles, private array $twins = []) {} @@ -56,7 +56,7 @@ public function listRoles(): array return $this->roles; } - /** @return list The roles a glyph can play, one for each set of tiles. */ + /** @return list The roles a glyph can play, one for each drawing and keeping behaviour. */ public function getRoles(string $glyph): array { return $this->roles[$glyph] ?? []; @@ -64,7 +64,7 @@ public function getRoles(string $glyph): array /** * The role with this key that a glyph can play, or null when it has none. - * A role kept for another that draws the same tiles answers for its key. + * A role kept for another with the same drawing and keeping behaviour answers for its key. */ public function findRole(string $glyph, string $key): ?PieceRole { @@ -136,6 +136,7 @@ public function plan(array $changes, Closure $glyphAt, Closure $tileAt, array $c $overlay = []; $resolved = []; $unresolved = []; + $kept = []; foreach ($changes as $index => $change) { $key = $assigned["{$change['x']},{$change['y']}"] ?? null; @@ -144,9 +145,17 @@ public function plan(array $changes, Closure $glyphAt, Closure $tileAt, array $c $key !== null => $this->findRole($change['new'], $key), default => $this->resolveRole($change, $glyphAt, $tileAt, $changed, $choices, $unresolved), }; + foreach ($resolved[$index]?->keeps ?? [] as $layer => $cells) { + foreach ($cells as $cell) { + [$x, $y] = [$change['x'] + $cell['dx'], $change['y'] + $cell['dy']]; + if ($glyphAt($x, $y) !== null) { + $kept[$layer]["{$x},{$y}"] = true; + } + } + } } // Every leaving role takes its tiles before any arriving role draws, - // so a glyph moved within one edit keeps the tiles it brings. + // except existing underlay kept by any arriving role, including its blank cells. foreach ($changes as $index => $change) { $leaving = $this->findPlayedRole($change['old'], $change['x'], $change['y'], $tileAt); if ($leaving === null || ($resolved[$index] ?? null) === $leaving) { @@ -155,7 +164,8 @@ public function plan(array $changes, Closure $glyphAt, Closure $tileAt, array $c foreach ($leaving->tiles as $layer => $cells) { foreach ($cells as $cell) { [$x, $y] = [$change['x'] + $cell['dx'], $change['y'] + $cell['dy']]; - if ($glyphAt($x, $y) !== null && ($overlay[$layer]["{$x},{$y}"] ?? $tileAt($layer, $x, $y)) === $cell['entry']) { + if (! isset($kept[$layer]["{$x},{$y}"]) && $glyphAt($x, $y) !== null + && ($overlay[$layer]["{$x},{$y}"] ?? $tileAt($layer, $x, $y)) === $cell['entry']) { $overlay[$layer]["{$x},{$y}"] = (string) TileId::EMPTY; } } @@ -269,12 +279,14 @@ private static function getOffset(PieceRole $role, PieceRole $other): ?array return [$other->cell[1] - $role->cell[1], $other->cell[0] - $role->cell[0]]; } - /** What a role draws, so roles that draw the same tiles count once. */ + /** What a role draws and keeps, so only behaviourally identical roles count once. */ private static function getSignature(PieceRole $role): string { $tiles = $role->tiles; + $keeps = $role->keeps; ksort($tiles); + ksort($keeps); - return json_encode($tiles, JSON_THROW_ON_ERROR); + return json_encode([$tiles, $keeps], JSON_THROW_ON_ERROR); } } diff --git a/src/Canvas/PieceRole.php b/src/Canvas/PieceRole.php index 9977365..34ba2ae 100644 --- a/src/Canvas/PieceRole.php +++ b/src/Canvas/PieceRole.php @@ -23,8 +23,10 @@ * @param string $label What an author calls it, such as `Dining table (left)`. * @param array> $tiles Tile entries relative to the glyph cell, keyed by tile layer name; never `0`. * @param array{int, int}|null $cell An item piece's cell, as row and column; null for a connected piece's shape. + * @param array> $keeps Cells whose existing tiles stay, keyed by tile layer; these are not owned tiles to remove. */ - public function __construct(public string $pieceId, string $part, public string $label, public array $tiles, public ?array $cell = null) + public function __construct(public string $pieceId, string $part, public string $label, public array $tiles, public ?array $cell = null, + public array $keeps = []) { $this->key = "{$pieceId}:{$part}"; } @@ -82,15 +84,22 @@ public static function readPiece(TilesetPiece $piece, array $excludedLayers = [] $roles = []; foreach ($glyphCells as $index => [$row, $column]) { $tiles = []; + $ownedCells = [[$row, $column], ...($attached[$index] ?? [])]; foreach (array_diff_key($piece->tiles, $excluded) as $layer => $entries) { - foreach ([[$row, $column], ...($attached[$index] ?? [])] as [$cellRow, $cellColumn]) { + foreach ($ownedCells as [$cellRow, $cellColumn]) { if ($entries[$cellRow][$cellColumn] !== (string) TileId::EMPTY) { $tiles[$layer][] = ['dx' => $cellColumn - $column, 'dy' => $cellRow - $row, 'entry' => $entries[$cellRow][$cellColumn]]; } } } + $keeps = []; + foreach (array_diff($piece->keeps, $excludedLayers) as $layer) { + foreach ($ownedCells as [$cellRow, $cellColumn]) { + $keeps[$layer][] = ['dx' => $cellColumn - $column, 'dy' => $cellRow - $row]; + } + } $roles[$piece->glyphs[$row][$column]][] = new self($piece->id, "{$row}:{$column}", - self::describeCell($piece->name, $glyphCells, $row, $column), $tiles, [$row, $column]); + self::describeCell($piece->name, $glyphCells, $row, $column), $tiles, [$row, $column], $keeps); } return $roles; diff --git a/src/Cutscenes/CutsceneAsset.php b/src/Cutscenes/CutsceneAsset.php index c3aa1ff..cb78b72 100644 --- a/src/Cutscenes/CutsceneAsset.php +++ b/src/Cutscenes/CutsceneAsset.php @@ -10,7 +10,9 @@ use Ichiloto\Editor\Cutscenes\Source\SourceUnreadable; use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Editor\Storage\FileSetTransactionFailure; +use Ichiloto\Editor\Database\InnPresentationFields; use Ichiloto\Editor\Database\PhpValueExporter; +use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\History\TracksPersistedState; use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Engine\Animations\Timelines\CompiledEffectTimeline; @@ -96,6 +98,12 @@ final class CutsceneAsset private ?PhpArraySourceDocument $dataDocument = null; private ?PhpArraySourceDocument $partnerDocument = null; + /** @var array Authored bytes at load/save, even for read-only sources. */ + private array $baselineSources = []; + + /** @var array}> Undo templates, never persisted conflict/validation baselines. */ + private array $sourceTemplates = []; + /** * The sequence an effect or summon with separate terminal and graphical * sequences is being edited in. The editor shows and edits one at a @@ -152,6 +160,9 @@ public static function load(CutsceneType $type, string $folder, ?string $project $dataSource = $hasData ? (string) file_get_contents($dataPath) : ''; $partnerSource = (string) file_get_contents($partnerPath); + $asset->baselineSources = $hasData + ? [$dataPath => $dataSource, $partnerPath => $partnerSource] + : [$partnerPath => $partnerSource]; $reasons = []; try { @@ -420,6 +431,7 @@ public function splitIntoPresentations(): bool if ($this->type === CutsceneType::CINEMATIC || $this->hasPresentations() || $this->readOnlyReason !== null) { return false; } + $this->assertSourcesUnchanged(); $outer = $this->type === CutsceneType::SUMMON ? array_intersect_key($this->partner, array_flip(SummonCutsceneDefinition::PAIRED_TIMELINE_FIELDS)) @@ -444,33 +456,39 @@ public function splitIntoPresentations(): bool } /** - * Whether the summon's graphical sequence has a cinematic stage. + * Whether the graphical effect or summon owns a cinematic stage. */ public function hasStage(): bool { - return $this->type === CutsceneType::SUMMON && $this->hasPresentations() - && is_array($this->partner['presentations'][EffectPresentation::GRAPHICAL->value]['stage'] ?? null); + return in_array($this->type, [CutsceneType::SUMMON, CutsceneType::EFFECT], true) + && ($this->type === CutsceneType::EFFECT || $this->hasPresentations()) + && is_array(($this->hasPresentations() + ? $this->getSequence(EffectPresentation::GRAPHICAL) : $this->partner)['stage'] ?? null); } /** - * Gives a summon's graphical sequence a cinematic stage, or takes it + * Gives a graphical effect or summon a cinematic stage, or takes it * away. A new stage is the smallest the Engine reads: a 16:9 canvas * shown from the first frame until the last, its camera on the canvas's * centre at its own scale. Its subjects, art, covers and the sequence's * rest frame are authored after. The terminal sequence never has one. * - * @return bool False when nothing changed: not a summon with separate - * sequences, too short to restore before its end, or already so. + * @return bool False when nothing changed: ineligible, read-only, too + * short to restore before its end, or already so. Existing tracks/cues + * are preserved; incomplete authoring must compile before it can save. */ public function setStage(bool $present): bool { $graphical = EffectPresentation::GRAPHICAL->value; - if ($this->type !== CutsceneType::SUMMON || ! $this->hasPresentations() || $this->readOnlyReason !== null || $present === $this->hasStage()) { + if (! in_array($this->type, [CutsceneType::SUMMON, CutsceneType::EFFECT], true) + || ($this->type === CutsceneType::SUMMON && ! $this->hasPresentations()) + || $this->readOnlyReason !== null || $present === $this->hasStage()) { return false; } + $this->assertSourcesUnchanged(); - $sequence = $this->getSequence(EffectPresentation::GRAPHICAL); + $sequence = $this->hasPresentations() ? $this->getSequence(EffectPresentation::GRAPHICAL) : $this->partner; if (! $present) { unset($sequence['stage']); @@ -502,7 +520,13 @@ public function setStage(bool $present): bool $sequence = $placed + ['stage' => $stage]; } - $this->partner['presentations'][$graphical] = $sequence; + $partner = $this->partner; + if ($this->hasPresentations()) { + $partner['presentations'][$graphical] = $sequence; + } else { + $partner = $sequence; + } + $this->partner = $partner; $this->touchState(); return true; @@ -524,6 +548,7 @@ private function getSequence(EffectPresentation $presentation): array */ public function copyAs(string $id, string $root): self { + $this->assertSourcesUnchanged(); $copy = new self($this->type, $id, rtrim($root, '/') . '/' . $id, $this->projectRoot); $copy->isNew = true; $copy->data = $this->type->hasDataFile() && array_key_exists('id', $this->data) @@ -547,20 +572,33 @@ public function copyAs(string $id, string $root): self * The payload alone is not enough: it is one sequence of an effect with * two, and a split leaves it unchanged. * - * @return array{data: array, partner: array, view: EffectPresentation, deleted: bool, pacedFps: array} + * @return array{data: array, partner: array, view: EffectPresentation, deleted: bool, pacedFps: array, sourceTemplates: array}>} */ public function captureEditState(): array { - return ['data' => $this->data, 'partner' => $this->partner, 'view' => $this->presentationView, 'deleted' => $this->isDeleted, 'pacedFps' => $this->pacedFps]; + $templates = []; + if ($this->readOnlyReason === null) { + if ($this->type->hasDataFile()) { + $templates['data'] = ['source' => $this->proposedSource($this->dataDocument, $this->loadedData, $this->data, 'data'), 'values' => $this->data]; + } + $templates[$this->type->partnerNoun()] = ['source' => $this->proposedSource($this->partnerDocument, $this->loadedPartner, + $this->partner, $this->type->partnerNoun()), 'values' => $this->partner]; + } + + return ['data' => $this->data, 'partner' => $this->partner, 'view' => $this->presentationView, 'deleted' => $this->isDeleted, + 'pacedFps' => $this->pacedFps, 'sourceTemplates' => $templates]; } /** * Puts the asset back to a state `captureEditState` returned. * - * @param array{data: array, partner: array, view: EffectPresentation, deleted: bool, pacedFps?: array} $state + * @param array{data: array, partner: array, view: EffectPresentation, deleted: bool, pacedFps?: array, sourceTemplates?: array}>} $state */ public function restoreEditState(array $state): void { + // Retain the actual last-saved baseline for conflict checks and + // changed-reference validation, while recovering authored syntax. + $this->sourceTemplates = $state['sourceTemplates'] ?? []; $this->presentationView = $state['view']; $this->pacedFps = $state['pacedFps'] ?? []; $this->markDeleted($state['deleted']); @@ -571,6 +609,7 @@ public function restoreEditState(array $state): void $this->data = $state['data']; $this->partner = $state['partner']; + $this->shape = CutscenePairShape::of($this->type, $this->data, $this->partner); $this->touchState(); } @@ -628,6 +667,7 @@ public function apply(array $payload): void // including the ones an author was not editing. return; } + $this->assertSourcesUnchanged(); if (! $this->type->hasDataFile()) { unset($payload['id']); @@ -687,6 +727,46 @@ private function adoptArrays(array $data, array $partner): void $this->shape = CutscenePairShape::of($this->type, $data, $partner); } + /** Checks the owner's loaded source set, allowing recovery but never external replacement. */ + public function assertSourcesUnchanged(): void + { + $this->createSourceTransaction()->assertSourcesUnchanged(); + } + + private function createSourceTransaction(): FileSetTransaction + { + $transaction = new FileSetTransaction($this->folder); + foreach ($this->paths() as $path) { + // Null requires an unwritten/deleted member to remain absent. + $transaction->expectSource($path, $this->baselineSources[$path] ?? null, allowMissing: true); + } + return $transaction; + } + + /** Checks both source edits without writing, so unsupported expressions never enter history. */ + public function assertSourceAccepts(): void + { + $this->assertSourcesUnchanged(); + if ($this->type->hasDataFile()) { + $this->proposedSource($this->dataDocument, $this->loadedData, $this->data, 'data'); + } + $this->proposedSource($this->partnerDocument, $this->loadedPartner, $this->partner, $this->type->partnerNoun()); + } + + /** Changed rest bindings must be complete; untouched graphical references survive Terminal edits. */ + public function assertInnPresentationsValid(?ReferenceCatalog $references = null): void + { + InnPresentationFields::assertChangedCommandsValid($this->loadedData, $this->data, $references); + InnPresentationFields::assertChangedCommandsValid($this->loadedPartner, $this->partner, $references); + } + + /** Only changed/new route paths must be complete before either source can be installed. */ + public function assertMovementRoutesValid(): void + { + \Ichiloto\Editor\Database\MovementRouteFields::assertChangedCommandsValid($this->loadedData, $this->data); + \Ichiloto\Editor\Database\MovementRouteFields::assertChangedCommandsValid($this->loadedPartner, $this->partner); + } + /** * Writes both files as one logical transaction. * @@ -701,16 +781,20 @@ private function adoptArrays(array $data, array $partner): void * complete new one. * * @param callable(string ...$paths): void|null $backup Called with the paths about to be overwritten, before they are. + * @param ReferenceCatalog|null $references The workspace's live authoring references. * @return bool True when anything was written. * @throws FileSetTransactionFailure When the pair could not be installed. */ - public function save(?callable $backup = null): bool + public function save(?callable $backup = null, ?ReferenceCatalog $references = null): bool { + $transaction = $this->createSourceTransaction(); + $transaction->assertSourcesUnchanged(); if ($this->isDeleted) { return $this->delete($backup); } - if (! $this->isDirty() && ! $this->isNew) { + $missing = array_any($this->paths(), static fn(string $path): bool => ! is_file($path)); + if (! $this->isDirty() && ! $this->isNew && ! $missing) { return false; } @@ -718,12 +802,15 @@ public function save(?callable $backup = null): bool throw new RuntimeException(sprintf('%s "%s" is read-only: %s.', ucfirst($this->type->noun()), $this->id, $this->readOnlyReason)); } + $this->assertInnPresentationsValid($references); + $this->assertMovementRoutesValid(); + // 1. Both proposed sources, each touched only where it changed. $hasData = $this->type->hasDataFile(); $dataSource = $hasData ? $this->proposedSource($this->dataDocument, $this->loadedData, $this->data, 'data') : ''; $partnerSource = $this->proposedSource($this->partnerDocument, $this->loadedPartner, $this->partner, $this->type->partnerNoun()); - $writeData = $hasData && ($this->isNew || $this->dataDocument === null || $dataSource !== $this->dataDocument->source); - $writePartner = $this->isNew || $this->partnerDocument === null || $partnerSource !== $this->partnerDocument->source; + $writeData = $hasData && (! is_file($this->dataPath()) || $this->isNew || $this->dataDocument === null || $dataSource !== $this->dataDocument->source); + $writePartner = ! is_file($this->partnerPath()) || $this->isNew || $this->partnerDocument === null || $partnerSource !== $this->partnerDocument->source; if (! $writeData && ! $writePartner) { // Dirty by fingerprint but identical in source: a same-value @@ -733,8 +820,6 @@ public function save(?callable $backup = null): bool return false; } - $transaction = new FileSetTransaction($this->folder); - if ($writeData) { $transaction->write($this->dataPath(), $dataSource); } @@ -822,17 +907,29 @@ public function hydrate(?array $data = null, ?array $partner = null): void /** * Compiles the effect as it stands in memory, unsaved edits included, as - * battle or the field plays it in one presentation. + * battle, the field or an owned stage plays it in one presentation. * * @throws RuntimeException When the Engine refuses it, or for another type. */ - public function compiledEffect(EffectPresentation $presentation, bool $forBattle): CompiledEffectTimeline + public function compileEffect(EffectPresentation $presentation, bool $forBattle, bool $forStage = false): CompiledEffectTimeline { if ($this->type !== CutsceneType::EFFECT) { throw new RuntimeException(sprintf('%s is a %s, not an effect.', $this->id, $this->type->noun())); } - return CutsceneHydration::compileEffect($this->id, $this->partner, $presentation, $forBattle, $this->projectRoot); + return CutsceneHydration::compileEffect($this->id, $this->partner, $presentation, $forBattle, $this->projectRoot, $forStage); + } + + /** Preserves the existing battle/field API while new consumers use the action-named compiler. */ + public function compiledEffect(EffectPresentation $presentation, bool $forBattle): CompiledEffectTimeline + { + return $this->compileEffect($presentation, $forBattle); + } + + /** Whether this effect declares its own graphical space, independent of its selected sequence. */ + public function isOwnedStageEffect(): bool + { + return $this->type === CutsceneType::EFFECT && CutsceneHydration::isOwnedStage($this->partner); } /** @@ -892,12 +989,13 @@ private function delete(?callable $backup): bool { if (! is_dir($this->folder)) { // Never written: nothing on disk to take away. + $this->baselineSources = []; $this->captureBaseline(); return false; } - $transaction = new FileSetTransaction($this->folder); + $transaction = $this->createSourceTransaction(); foreach ($this->paths() as $path) { $transaction->remove($path); @@ -906,6 +1004,7 @@ private function delete(?callable $backup): bool // Only the two files are the asset's; anything else in the folder is // left for its author, and the folder goes only when empty. $transaction->commit($backup); + $this->baselineSources = []; $this->captureBaseline(); return true; @@ -919,6 +1018,11 @@ private function delete(?callable $backup): bool */ private function proposedSource(?PhpArraySourceDocument $document, array $loaded, array $current, string $noun): string { + if (isset($this->sourceTemplates[$noun])) { + $template = $this->sourceTemplates[$noun]; + $document = PhpArraySourceDocument::parse($template['source']); + $loaded = $template['values']; + } if ($document === null) { // No file yet: a new asset's file is written whole, in the // editor's own layout. @@ -937,6 +1041,9 @@ private function proposedSource(?PhpArraySourceDocument $document, array $loaded */ private function adoptWritten(string $dataSource, string $partnerSource): void { + $this->baselineSources = $this->type->hasDataFile() + ? [$this->dataPath() => $dataSource, $this->partnerPath() => $partnerSource] + : [$this->partnerPath() => $partnerSource]; $this->dataDocument = $this->type->hasDataFile() ? PhpArraySourceDocument::parse($dataSource) : null; $this->partnerDocument = PhpArraySourceDocument::parse($partnerSource); $this->loadedData = $this->data; diff --git a/src/Cutscenes/CutsceneHydration.php b/src/Cutscenes/CutsceneHydration.php index 1862ffb..88b98d9 100644 --- a/src/Cutscenes/CutsceneHydration.php +++ b/src/Cutscenes/CutsceneHydration.php @@ -80,7 +80,7 @@ public static function compileSummon(array $data, array $timeline, ?string $proj /** * Compiles an effect timeline through the Engine's effect library, as - * battle or the field plays it in one presentation. + * battle, the field or an owned stage plays it in one presentation. * * @param array $timeline The timeline file's array. */ @@ -90,18 +90,33 @@ public static function compileEffect( EffectPresentation $presentation, bool $forBattle, ?string $projectRoot = null, + bool $forStage = false, ): CompiledEffectTimeline { + // A paired Terminal sequence owns its cues and tracks, not the + // graphical consumer's stage/audio lifecycle. + if ($forStage && ! $forBattle && $presentation === EffectPresentation::TERMINAL && isset($timeline['presentations'])) { + try { + return self::compileEffect($id, $timeline, $presentation, false, $projectRoot); + } catch (RuntimeException $fieldFailure) { + try { + return self::compileEffect($id, $timeline, $presentation, true, $projectRoot); + } catch (RuntimeException $battleFailure) { + throw new RuntimeException(sprintf('Independent Terminal timeline is not admitted: %s; %s', + $fieldFailure->getMessage(), $battleFailure->getMessage()), previous: $battleFailure); + } + } + } return self::inProject( $projectRoot, static fn(): CompiledEffectTimeline => new EffectTimelineLibrary(self::getAssetRoot($projectRoot)) - ->compile($id, $timeline, $forBattle, $presentation), + ->compile($id, $timeline, $forBattle, $presentation, forStage: $forStage), ); } /** * Proves an effect timeline can be played: each presentation must - * compile for battle or for the field, the two places the Engine plays - * effects. Which one a project uses it in is validation's to check. + * compile in an admitted context. An authored stage owns its space; + * battle/field admission cannot substitute for that ownership. * * @param array $timeline The timeline file's array. * @throws RuntimeException Naming each presentation neither accepts, with the Engine's reasons. @@ -113,9 +128,10 @@ public static function checkEffect(string $id, array $timeline, ?string $project foreach (EffectPresentation::cases() as $presentation) { $reasons = []; - foreach (['battle' => true, 'field' => false] as $context => $forBattle) { + $contexts = self::isOwnedStage($timeline) ? ['stage'] : ['battle', 'field']; + foreach ($contexts as $context) { try { - self::compileEffect($id, $timeline, $presentation, $forBattle, $projectRoot); + self::compileEffect($id, $timeline, $presentation, $context === 'battle', $projectRoot, $context === 'stage'); $reasons = []; break; } catch (RuntimeException $exception) { @@ -133,6 +149,14 @@ public static function checkEffect(string $id, array $timeline, ?string $project } } + /** Stage ownership belongs to the complete asset, not the tab being inspected. */ + public static function isOwnedStage(array $timeline): bool + { + $graphical = $timeline['presentations'][EffectPresentation::GRAPHICAL->value] ?? $timeline; + + return is_array($graphical) && array_key_exists('stage', $graphical); + } + /** The asset root the Engine resolves an effect's images against. */ private static function getAssetRoot(?string $projectRoot): string { diff --git a/src/Cutscenes/CutsceneLibrary.php b/src/Cutscenes/CutsceneLibrary.php index 7624eff..c991040 100644 --- a/src/Cutscenes/CutsceneLibrary.php +++ b/src/Cutscenes/CutsceneLibrary.php @@ -10,6 +10,7 @@ use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\Database\RecordSchema; use Ichiloto\Editor\Database\RecordSchemaCatalog; +use Ichiloto\Editor\Database\ReferenceCatalog; use RuntimeException; use Throwable; @@ -33,6 +34,13 @@ */ final class CutsceneLibrary { + private ?ReferenceCatalog $authoringReferences = null; + + public function useAuthoringReferences(ReferenceCatalog $references): void + { + $this->authoringReferences = $references; + foreach ($this->databases as $database) { $database->useAuthoringReferences($references); } + } /** * @var array Assets by type value, in list order. */ @@ -281,9 +289,11 @@ public function dirtyAssets(): array * Returns the record category editing a type's assets, built over the * library and written back into it on every change. */ - public function records(CutsceneType $type): ProjectRecordDatabase + public function records(CutsceneType $type, bool $graphical = false): ProjectRecordDatabase { - return $this->databases[$type->value] ??= $this->buildRecords($type); + $key = $type->value . ($graphical ? ':graphical' : ''); + + return $this->databases[$key] ??= $this->buildRecords($type, $graphical); } /** @@ -294,6 +304,7 @@ public function records(CutsceneType $type): ProjectRecordDatabase public function refreshRecords(CutsceneType $type): void { $this->databases[$type->value] = $this->buildRecords($type); + unset($this->databases[$type->value . ':graphical']); } /** @@ -311,18 +322,23 @@ public function refreshRecords(CutsceneType $type): void * @throws RuntimeException When the asset is missing or read-only; nothing is changed. * @throws Throwable Whatever the change throws; the records are rebuilt from the untouched asset. */ - public function changeAsset(CutsceneType $type, int $index, string $label, callable $change, ?Closure $restored = null): array + public function changeAsset(CutsceneType $type, int $index, string $label, callable $change, ?Closure $restored = null, bool $graphical = false): array { $id = $this->ids($type)[$index] ?? throw new RuntimeException(sprintf('There is no %s %d.', $type->noun(), $index)); $asset = $this->find($type, $id) ?? throw new RuntimeException(sprintf('There is no %s %s.', $type->noun(), $id)); if (! $asset->isEditable()) { throw new RuntimeException(sprintf('%s is read-only: %s.', ucfirst($type->noun()), $asset->readOnlyReason())); } - $records = $this->records($type); + $asset->assertSourcesUnchanged(); + $records = $this->records($type, $graphical); $before = $asset->captureEditState(); try { $result = $change($records, $index); $records->save(); + $asset->assertSourceAccepts(); + } catch (Throwable $failure) { + $asset->restoreEditState($before); + throw $failure; } finally { $this->refreshRecords($type); } @@ -401,33 +417,36 @@ private function createRestoringCommand(string $label, CutsceneType $type, strin return new GenericCommand($label, static fn() => $restore($after), static fn() => $restore($before)); } - private function buildRecords(CutsceneType $type): ProjectRecordDatabase + private function buildRecords(CutsceneType $type, bool $graphical = false): ProjectRecordDatabase { - $schema = $this->schemaFor($type); + $schema = $this->schemaFor($type, $graphical); $entries = array_map( static fn(CutsceneAsset $asset): array => $asset->payload(), array_values(array_filter($this->assets($type), static fn(CutsceneAsset $asset): bool => ! $asset->isDeleted())), ); - return ProjectRecordDatabase::overOwnedList( + $records = ProjectRecordDatabase::overOwnedList( $schema, $this->rootFor($type), $entries, function (array $written) use ($type): void { $this->writeBack($type, $written); }, + graphical: $graphical, ); + if ($this->authoringReferences !== null) { $records->useAuthoringReferences($this->authoringReferences); } + return $records; } /** * Returns the schema a type's records are edited with. */ - public function schemaFor(CutsceneType $type): RecordSchema + public function schemaFor(CutsceneType $type, bool $graphical = false): RecordSchema { return match ($type) { - CutsceneType::CINEMATIC => RecordSchemaCatalog::cinematics(), + CutsceneType::CINEMATIC => \Ichiloto\Editor\Database\CutsceneSchemas::cinematics($graphical), CutsceneType::SUMMON => RecordSchemaCatalog::summons(), - CutsceneType::EFFECT => RecordSchemaCatalog::effects(), + CutsceneType::EFFECT => \Ichiloto\Editor\Database\CutsceneSchemas::effects($graphical), }; } @@ -618,7 +637,7 @@ public function saveAll(?Closure $backup = null): array } try { - $asset->save($backup); + $asset->save($backup, $this->authoringReferences); $saved[] = $type->noun() . ' ' . $asset->id; } catch (Throwable $throwable) { $failed[$type->noun() . ' ' . $asset->id] = $throwable->getMessage(); @@ -644,7 +663,7 @@ public function save(CutsceneType $type, string $id, ?Closure $backup = null): b throw new RuntimeException(sprintf('No %s "%s" to save.', $type->noun(), $id)); } - $written = $asset->save($backup); + $written = $asset->save($backup, $this->authoringReferences); $this->forgetDeleted($type); return $written; diff --git a/src/Cutscenes/CutsceneRecordCategory.php b/src/Cutscenes/CutsceneRecordCategory.php index 086c84c..378eee5 100644 --- a/src/Cutscenes/CutsceneRecordCategory.php +++ b/src/Cutscenes/CutsceneRecordCategory.php @@ -179,7 +179,7 @@ private function findIndex(string $id): ?int /** The type's records, read through the shared record rules. */ private function read(): RecordCategory { - return new RecordCategory($this->library->records($this->type)); + return new RecordCategory($this->library->records($this->type, graphical: true)); } /** @@ -193,7 +193,7 @@ private function change(int $index, string $label, callable $change): RecordChan { try { $applied = $this->library->changeAsset($this->type, $index, $label, - static fn(ProjectRecordDatabase $records, int $at): RecordChange => $change(new RecordCategory($records), $at)); + static fn(ProjectRecordDatabase $records, int $at): RecordChange => $change(new RecordCategory($records), $at), graphical: true); } catch (RecordRefusal $refusal) { throw $refusal; } catch (RuntimeException $failure) { diff --git a/src/Cutscenes/Editing/CutscenePreviewPane.php b/src/Cutscenes/Editing/CutscenePreviewPane.php index f3180cb..0a1b327 100644 --- a/src/Cutscenes/Editing/CutscenePreviewPane.php +++ b/src/Cutscenes/Editing/CutscenePreviewPane.php @@ -585,6 +585,11 @@ private function handleTimelinePreviewInput(string $input): bool } if ($asset?->type === CutsceneType::EFFECT && ($input === 'b' || $input === 'd')) { + if ($asset->isOwnedStageEffect()) { + $this->setStatus('This effect owns its stage; it has no field target or battle caster.', StatusLevel::INFO); + + return true; + } if ($input === 'b') { $this->effectPreviewInBattle[$asset->id] = ! $this->isEffectPreviewInBattle($asset); } elseif ($this->isEffectPreviewInBattle($asset)) { @@ -707,7 +712,8 @@ private function startTimelinePreview(bool $play): void try { $compiled = $asset->type === CutsceneType::EFFECT - ? $asset->compiledEffect($asset->getPresentationView() ?? EffectPresentation::TERMINAL, $this->isEffectPreviewInBattle($asset)) + ? $asset->compileEffect($asset->getPresentationView() ?? EffectPresentation::TERMINAL, + $this->isEffectPreviewInBattle($asset), forStage: $asset->isOwnedStageEffect()) : $asset->compiledSummon(); } catch (Throwable $throwable) { $this->timelinePreview = null; @@ -738,7 +744,8 @@ private function startTimelinePreview(bool $play): void ? sprintf('at %d fps', $this->timelinePreview->fps()) : sprintf('over %.2fs, %s (A, P, S change it)', $phaseSeconds, $this->describeEffectPreviewPacing($asset)), $play ? 'playing' : 'paused', - $asset->type === CutsceneType::EFFECT ? sprintf(', as %s plays it', $this->isEffectPreviewInBattle($asset) ? 'battle' : 'the field') : '', + $asset->type === CutsceneType::EFFECT ? sprintf(', in %s', $asset->isOwnedStageEffect() ? 'its owned stage' + : ($this->isEffectPreviewInBattle($asset) ? 'battle' : 'the field')) : '', ), StatusLevel::INFO); $this->renderDatabasePanes(['preview', 'tree']); } @@ -774,6 +781,9 @@ private function tickTimelinePreview(): void */ private function isEffectPreviewInBattle(CutsceneAsset $asset): bool { + if ($asset->isOwnedStageEffect()) { + return false; + } if (isset($this->effectPreviewInBattle[$asset->id])) { return $this->effectPreviewInBattle[$asset->id]; } @@ -855,7 +865,8 @@ private static function cycleCase(array $cases, \UnitEnum $current): \UnitEnum */ private function createEffectPreviewStage(CutsceneAsset $asset): EffectPreviewStage { - return new EffectPreviewStage($asset->getPresentationView() ?? EffectPresentation::TERMINAL, $this->isEffectPreviewInBattle($asset), $this->isEffectPreviewCasterOnLeft); + return new EffectPreviewStage($asset->getPresentationView() ?? EffectPresentation::TERMINAL, $this->isEffectPreviewInBattle($asset), + $this->isEffectPreviewCasterOnLeft, forStage: $asset->isOwnedStageEffect()); } /** @@ -1453,11 +1464,13 @@ private function timelinePreviewLines(CutsceneAsset $asset, int $contentWidth, i return [ ...$this->describeCutsceneStanding($asset), ...($stage === null ? [] : [sprintf(' Plays as %s, the %s sequence.%s', - $stage->forBattle ? 'battle' : 'the field', $stage->presentation->value, - $stage->forBattle ? sprintf(' %s is the caster, %s the target.', EffectPreviewStage::CASTER_MARKER, EffectPreviewStage::TARGET_MARKER) : sprintf(' %s is the target.', EffectPreviewStage::TARGET_MARKER))]), + $stage->forStage ? 'an owned stage' : ($stage->forBattle ? 'battle' : 'the field'), $stage->presentation->value, + $stage->forStage ? ' No field or battle surroundings.' : ($stage->forBattle + ? sprintf(' %s is the caster, %s the target.', EffectPreviewStage::CASTER_MARKER, EffectPreviewStage::TARGET_MARKER) + : sprintf(' %s is the target.', EffectPreviewStage::TARGET_MARKER)))]), '', ' Space: play through the Engine session · . , : step · < > : keyframe boundaries · R: restart · L: timeline/stage' - . ($stage === null ? '' : ' · B: battle/field · D: caster side'), + . ($stage === null || $stage->forStage ? '' : ' · B: battle/field · D: caster side'), ]; } @@ -1488,7 +1501,8 @@ private function timelinePreviewLines(CutsceneAsset $asset, int $contentWidth, i $info[] = sprintf(' %gx speed · %s', $preview->speed(), $preview->isLooping() ? 'looping' : 'once'); if ($stage !== null) { - $info[] = sprintf(' %s · %s sequence', $stage->forBattle ? sprintf('battle, caster %s', $stage->isCasterOnLeft ? 'left' : 'right') : 'field', $stage->presentation->value); + $info[] = sprintf(' %s · %s sequence', $stage->forStage ? 'owned stage' + : ($stage->forBattle ? sprintf('battle, caster %s', $stage->isCasterOnLeft ? 'left' : 'right') : 'field'), $stage->presentation->value); if ($preview->phaseDurationSeconds !== null) { $info[] = sprintf(' paced over %.2fs:', $preview->phaseDurationSeconds); diff --git a/src/Cutscenes/Preview/CinematicPreviewSession.php b/src/Cutscenes/Preview/CinematicPreviewSession.php index 0c4f971..1cce97e 100644 --- a/src/Cutscenes/Preview/CinematicPreviewSession.php +++ b/src/Cutscenes/Preview/CinematicPreviewSession.php @@ -527,11 +527,12 @@ public function frame(): array * ({@see PreviewField::exchangeScene()}), at the preview's playhead. * * @param list $events + * @param string|null $sessionId The field epoch the feedback answers. * @return list}> */ - public function exchangeScene(array $events): array + public function exchangeScene(array $events, ?string $sessionId = null): array { - return $this->field->exchangeScene($events); + return $this->field->exchangeScene($events, $sessionId); } /** The graphical view's grid ({@see PreviewField::getSceneGrid()}). */ @@ -540,6 +541,18 @@ public function getSceneGrid(): RendererGridConfig return $this->field->getSceneGrid(); } + /** The field's graphical-host epoch, without attaching a graphical view. */ + public function getSceneSessionId(): string + { + return $this->field->sessionId; + } + + /** @return list The current map's diagnostics, including transfers and recovery. */ + public function getDiagnostics(): array + { + return $this->field->getDiagnostics(); + } + /** Ends the editor window's graphical view of the scene, if one is attached. */ public function detachScene(): void { diff --git a/src/Cutscenes/Preview/EffectPreviewStage.php b/src/Cutscenes/Preview/EffectPreviewStage.php index 4cc7cbe..9193e8e 100644 --- a/src/Cutscenes/Preview/EffectPreviewStage.php +++ b/src/Cutscenes/Preview/EffectPreviewStage.php @@ -4,10 +4,15 @@ namespace Ichiloto\Editor\Cutscenes\Preview; +use Ichiloto\Engine\Animations\Timelines\CompiledEffectTimeline; use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Animations\Timelines\EffectSegmentComposition; use Ichiloto\Engine\Battle\Presentation\BattleEffectDirection; +use Ichiloto\Engine\Cutscenes\Presentation\CinematicStage; +use Ichiloto\Engine\Cutscenes\Presentation\CinematicStagePresentation; use Ichiloto\Engine\IO\Console\TerminalText; +use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; +use InvalidArgumentException; /** * An effect's frame as a terminal consumer places it. @@ -36,7 +41,11 @@ public function __construct( public EffectPresentation $presentation, public bool $forBattle, public bool $isCasterOnLeft = true, + public bool $forStage = false, ) { + if ($forStage && $forBattle) { + throw new InvalidArgumentException('An owned stage cannot also preview battle surroundings.'); + } } /** @@ -79,7 +88,7 @@ public function drawFrame(array $segments, int $width, int $height): array $anchor = strval($command['payload']['anchor'] ?? 'target'); $base = in_array($anchor, ['screen', 'legacy-screen'], true) ? ['x' => 0, 'y' => 0] - : ($origins[$anchor] ?? $origins['target']); + : ($origins[$anchor] ?? $origins['target'] ?? ['x' => 0, 'y' => 0]); $command = $this->orient($command); $position = is_array($command['position'] ?? null) ? $command['position'] : []; @@ -94,6 +103,22 @@ public function drawFrame(array $segments, int $width, int $height): array return array_map(static fn(array $row): string => implode('', $row), $cells); } + /** Seekable Engine projection, with no scene, audio or gameplay state to retain. */ + public function renderCanvas(CompiledEffectTimeline $timeline, string $assetRoot, int $frame, + int $width, int $height, bool $reducedMotion = false): ?PresentationCanvas + { + if (! $this->forStage || $this->presentation !== EffectPresentation::GRAPHICAL) { + return null; + } + + $length = intval($timeline->defaults['lengthFrames'] ?? 1); + $stage = CinematicStage::fromArray($timeline->defaults['stage'] ?? [], $length, intval($timeline->defaults['restFrame'] ?? 0)); + $frame = max(0, min($frame, $length - 1)); + + return CinematicStagePresentation::compose($stage->getFrame($frame, $reducedMotion), + $timeline->playbackSegments, $assetRoot, max(1, $width), max(1, $height)); + } + /** * Describes the active draws a terminal cannot show: images with their * sheet frame and flips, flashes and shakes with their subject. @@ -103,7 +128,8 @@ public function drawFrame(array $segments, int $width, int $height): array */ public function describeUndrawn(array $segments): array { - $lines = []; + $lines = $this->forStage && $this->presentation === EffectPresentation::TERMINAL + ? ['stage images and camera: graphical only'] : []; foreach ($this->composeSegments($segments) as $segment) { $layer = strval($segment['layer'] ?? ''); @@ -168,12 +194,16 @@ private function composeSegments(array $segments): array /** * Where each subject stands on a stage of this size. * - * @return array{caster: array{x: int, y: int}, target: array{x: int, y: int}}|array{target: array{x: int, y: int}} + * @return array */ private function findOrigins(int $width, int $height): array { $y = intdiv($height, 2); + if ($this->forStage) { + return []; + } + if (! $this->forBattle) { return ['target' => ['x' => intdiv($width, 2), 'y' => $y]]; } diff --git a/src/Cutscenes/Preview/PreviewField.php b/src/Cutscenes/Preview/PreviewField.php index 67721e3..e5464cb 100644 --- a/src/Cutscenes/Preview/PreviewField.php +++ b/src/Cutscenes/Preview/PreviewField.php @@ -35,8 +35,12 @@ final class PreviewField public private(set) PreviewGameScene $scene; /** The field's dialogue and choices, drawn on its camera. */ public private(set) PreviewPresentation $presentation; - /** Why the map could not be loaded, when it could not. */ - public private(set) ?string $mapFailure = null; + /** Why the current map could not be loaded, when it could not. */ + public ?string $mapFailure { + get => $this->scene->previewMap->mapFailure; + } + /** Opaque graphical-host epoch, reserved without opening a graphical view. */ + public private(set) string $sessionId; /** The editor window's graphical view of the field, while one is attached. */ private ?PreviewSceneHost $sceneHost = null; /** @var array */ @@ -57,6 +61,7 @@ private function __construct( ) { $this->screenWidth = max(20, $width); $this->screenHeight = max(8, $height); + $this->sessionId = bin2hex(random_bytes(16)); } /** @@ -76,13 +81,10 @@ public static function open(string $projectRoot, string $mapId, Vector2 $spawn, $field->presentation = new PreviewPresentation($camera); $player = new PreviewPlayer($field->scene, $spawn); $field->scene->installPlayer($player); - if ($mapId !== '') { - try { - $field->scene->previewMap->loadForPreview($mapId); - } catch (Throwable $throwable) { - $field->scene->previewMap->unload(); - $field->mapFailure = $throwable->getMessage(); - } + try { + $field->scene->previewMap->loadForPreview($mapId); + } catch (Throwable) { + // The map manager owns the undefined terrain and its diagnostic. } $camera->attach($player); }); @@ -156,15 +158,20 @@ public function frame(): array * picture is the game's Terminal view only while detached. * * @param list $events + * @param string|null $sessionId The epoch the feedback answers; null discovers the current epoch without applying events. * @return list}> */ - public function exchangeScene(array $events): array + public function exchangeScene(array $events, ?string $sessionId = null): array { + // Check ownership before allocating a host or decoding old READY/ACK/error lines. + if ($sessionId !== $this->sessionId) { + return []; + } $grid = $this->getSceneGrid(); $messages = []; $this->run(function () use ($grid, $events, &$messages): void { $this->sceneHost ??= new PreviewSceneHost($this->scene, $this->presentation, - $this->projectRoot . '/assets', $grid, $this->renderScene(...), $this->readTime); + $this->projectRoot . '/assets', $grid, $this->renderScene(...), $this->readTime, $this->sessionId); $messages = $this->sceneHost->exchange($events); }); @@ -178,6 +185,12 @@ public function getSceneGrid(): RendererGridConfig RendererRuntimeConfig::GPUI_CELL_WIDTH, RendererRuntimeConfig::GPUI_CELL_HEIGHT); } + /** @return list Why the current field has no usable map, if it does not. */ + public function getDiagnostics(): array + { + return $this->scene->previewMap->getDiagnostics(); + } + /** Ends the editor window's graphical view of the field, if one is attached. */ public function detachScene(): void { @@ -187,6 +200,7 @@ public function detachScene(): void // In the project, as every step of the field runs, so the Terminal effects it restores find their files. $this->run(fn() => $this->sceneHost->dispose()); $this->sceneHost = null; + $this->sessionId = bin2hex(random_bytes(16)); } /** @@ -205,7 +219,11 @@ public function getScreenSize(): array public function resize(int $width, int $height): void { // The graphical view's grid is the screen's, so a new size starts it again. + $previousSessionId = $this->sessionId; $this->detachScene(); + if ($this->sessionId === $previousSessionId) { + $this->sessionId = bin2hex(random_bytes(16)); + } $this->screenWidth = max(20, $width); $this->screenHeight = max(8, $height); $this->scene->previewCamera()->resize($this->screenWidth, $this->screenHeight); diff --git a/src/Cutscenes/Preview/PreviewGameScene.php b/src/Cutscenes/Preview/PreviewGameScene.php index bf824e2..753ce06 100644 --- a/src/Cutscenes/Preview/PreviewGameScene.php +++ b/src/Cutscenes/Preview/PreviewGameScene.php @@ -156,10 +156,8 @@ public function transferPlayer(Location $location, bool $useConfiguredTransition try { $this->previewMap->loadForPreview($location->mapFilename); } catch (\Throwable) { - // An unknown destination is a validation finding, not a preview - // crash: the cinematic continues over undefined terrain and the - // final state still reports the map it asked for. - $this->previewMap->unload(); + // The map manager clears failed geometry and records why; the + // cinematic can continue over explicitly diagnosed undefined terrain. } if ($this->player !== null && $this->camera->followsPlayer) { diff --git a/src/Cutscenes/Preview/PreviewMapManager.php b/src/Cutscenes/Preview/PreviewMapManager.php index eff73f9..f39b37f 100644 --- a/src/Cutscenes/Preview/PreviewMapManager.php +++ b/src/Cutscenes/Preview/PreviewMapManager.php @@ -6,10 +6,10 @@ use Ichiloto\Engine\Events\Triggers\EventTriggerFactory; use Ichiloto\Engine\Core\Game; -use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\Field\MapManager; use Ichiloto\Engine\Field\Player; use Ichiloto\Engine\Scenes\Game\GameScene; +use Throwable; /** * A map manager that loads the author's map files for their tiles, collision @@ -22,8 +22,13 @@ */ final class PreviewMapManager extends MapManager { + private const string NO_MAP_DIAGNOSTIC = 'No map is selected: the preview shows undefined terrain.'; + /** @var array The data array of the map last loaded. */ public array $mapData = []; + public private(set) ?string $mapFailure = null; + /** @var list The outcome of the current map load, never a retired map's failure. */ + private array $diagnostics = [self::NO_MAP_DIAGNOSTIC]; public function __construct(Game $game, GameScene $gameScene) { @@ -37,20 +42,42 @@ public function __construct(Game $game, GameScene $gameScene) */ public function loadForPreview(string $mapId): array { - $map = $this->readMapDataFromFile($mapId); - $this->mapData = $map; - $this->calculateMapDimensions(); - $dictionaryPath = getcwd() . '/assets/Maps/collisions.php'; - $dictionary = is_file($dictionaryPath) ? $this->loadCollisionDictionary($dictionaryPath) : []; - $this->collisionMap = $this->layers === null - ? $this->generateCollisionMap($this->tileMap, $dictionary) - : $this->generateLayerCollisionMap($this->layers, $dictionary); - $this->gameScene->npcManager?->configure(is_array($map['npcs'] ?? null) ? $map['npcs'] : []); - $this->installFieldEffects(); + if ($mapId === '') { + $this->unload(); + + return []; + } + try { + $map = $this->readMapDataFromFile($mapId); + $this->mapData = $map; + $this->calculateMapDimensions(); + $dictionaryPath = getcwd() . '/assets/Maps/collisions.php'; + $dictionary = is_file($dictionaryPath) ? $this->loadCollisionDictionary($dictionaryPath) : []; + $this->collisionMap = $this->layers === null + ? $this->generateCollisionMap($this->tileMap, $dictionary) + : $this->generateLayerCollisionMap($this->layers, $dictionary); + $this->gameScene->npcManager?->configure(is_array($map['npcs'] ?? null) ? $map['npcs'] : []); + $this->installFieldEffects(); + } catch (Throwable $failure) { + $this->unload(); + $this->mapFailure = $failure->getMessage(); + $this->diagnostics = [sprintf('Map %s could not be loaded: %s The preview shows undefined terrain.', + $mapId, $this->mapFailure)]; + + throw $failure; + } + $this->mapFailure = null; + $this->diagnostics = []; return $map; } + /** @return list The current map's load diagnostics. */ + public function getDiagnostics(): array + { + return $this->diagnostics; + } + /** * Gives the scene's field effects the loaded map's own, as the game's * field installs them when it loads a map: its declared effects, piece @@ -76,6 +103,8 @@ public function unload(): void $this->mapData = []; $this->clearMapGeometry(); $this->gameScene->npcManager?->configure([]); + $this->mapFailure = null; + $this->diagnostics = [self::NO_MAP_DIAGNOSTIC]; } public function loadMap(string $filename, Player $player): self @@ -86,11 +115,6 @@ public function loadMap(string $filename, Player $player): self return $this; } - public function scrollMap(Player $player, Vector2 $moveDirection): bool - { - return false; - } - public function render(?int $x = null, ?int $y = null): void { $this->camera->renderMap(); diff --git a/src/Cutscenes/Preview/PreviewPlayer.php b/src/Cutscenes/Preview/PreviewPlayer.php index 8450f03..537f861 100644 --- a/src/Cutscenes/Preview/PreviewPlayer.php +++ b/src/Cutscenes/Preview/PreviewPlayer.php @@ -4,22 +4,30 @@ namespace Ichiloto\Editor\Cutscenes\Preview; +use Ichiloto\Engine\Core\Enumerations\MovementHeading; use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\Events\Interfaces\EventInterface; use Ichiloto\Engine\Field\Player; use Ichiloto\Engine\Field\PlayerPresentationConfig; -use Ichiloto\Engine\Rendering\Camera; use Ichiloto\Engine\Scenes\Game\GameScene; /** - * The player as a cinematic sees them: a position, a facing and a sprite that - * the interpreter's player commands move, with the field's own input, HUD and - * trigger machinery left out. + * Runtime player movement and presentation on an isolated preview field. + * PreviewMapManager omits ordinary map events and encounters; notifications + * stay local instead of reaching the game's movement observers. */ final class PreviewPlayer extends Player { - public ?string $facing = null; + public ?string $facing { + get => match ($this->heading) { + MovementHeading::NORTH => 'up', + MovementHeading::EAST => 'right', + MovementHeading::SOUTH => 'down', + MovementHeading::WEST => 'left', + default => null, + }; + } /** @var string[] */ public array $blockedMessages = []; @@ -32,41 +40,10 @@ final class PreviewPlayer extends Player */ public function __construct(GameScene $scene, Vector2 $position, array $sprite = ['@']) { + $presentation = PlayerPresentationConfig::load(); parent::__construct($scene, 'Player', $position, new Rect(0, 0, 1, 1), $sprite, - graphicalSprites: PlayerPresentationConfig::load()->graphical); - } - - public function tryMove(Vector2 $direction, Camera $camera): bool - { - $scene = $this->scene ?? null; - $destinationX = intval($this->position->x + $direction->x); - $destinationY = intval($this->position->y + $direction->y); - - if ($scene instanceof GameScene && ! $scene->mapManager?->canMoveTo($destinationX, $destinationY)) { - $this->face($direction, $camera); - - return false; - } - - $this->position->x = $destinationX; - $this->position->y = $destinationY; - $this->face($direction, $camera); - - if ($camera->followsPlayer) { - $camera->resetPosition($this); - } - - return true; - } - - public function face(Vector2 $direction, Camera $camera): void - { - $this->facing = match (true) { - $direction->y < 0 => 'up', - $direction->y > 0 => 'down', - $direction->x < 0 => 'left', - default => 'right', - }; + directionalSprites: $presentation->terminal->toArray(), + graphicalSprites: $presentation->graphical); } public function render(): void diff --git a/src/Cutscenes/Preview/PreviewSceneHost.php b/src/Cutscenes/Preview/PreviewSceneHost.php index 25199d2..cd9aac8 100644 --- a/src/Cutscenes/Preview/PreviewSceneHost.php +++ b/src/Cutscenes/Preview/PreviewSceneHost.php @@ -46,10 +46,11 @@ final class PreviewSceneHost public function __construct( private readonly GameScene $scene, private readonly PreviewPresentation $dialogue, - string $assetRoot, + private readonly string $assetRoot, public readonly RendererGridConfig $grid, private readonly Closure $renderTerminal, private readonly Closure $readTime, + public readonly string $sessionId, ) { $this->relay = new PreviewRendererRelay(); $this->client = new RendererClient($this->relay); @@ -123,7 +124,8 @@ private function attachContext(): void return; } $this->scene->setPresentationContext(new ScenePresentationContext($this->grid, $this->client->supports(...), - collectPresentations: fn(): array => $this->dialogue->getActivePresentations(), readTime: $this->readTime)); + collectPresentations: fn(): array => $this->dialogue->getActivePresentations(), readTime: $this->readTime, + assetRoot: $this->assetRoot)); // The graphical field draws its effects as sprites, as the game's does in a graphical window. if ($this->scene instanceof PreviewGameScene) { $this->scene->installFieldEffects(EffectPresentation::GRAPHICAL); diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index f40f79b..964a5c1 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -81,7 +81,7 @@ final class CutsceneSchemas * fields, its cast as a sub-list, and its script and finalizer as * command lists opened as frames. */ - public static function cinematics(): RecordSchema + public static function cinematics(bool $graphical = false): RecordSchema { return new RecordSchema( key: self::CINEMATICS_KEY, @@ -112,9 +112,9 @@ public static function cinematics(): RecordSchema 'cast' => [], CutsceneAsset::COMMANDS_KEY => [], ], - subList: self::castList(), + subList: self::castList($graphical), commandLists: [ - CutsceneAsset::COMMANDS_KEY => self::cinematicCommandList(CutsceneAsset::COMMANDS_KEY), + CutsceneAsset::COMMANDS_KEY => self::cinematicCommandList(CutsceneAsset::COMMANDS_KEY, $graphical), self::FINALIZER_KEY => self::finalizerCommandList(), ], ); @@ -316,7 +316,7 @@ public static function stageCoverList(): RecordSubList /** * Returns the cast sub-list: who a cinematic addresses. */ - public static function castList(): RecordSubList + public static function castList(bool $graphical = false): RecordSubList { return new RecordSubList( key: 'cast', @@ -330,9 +330,11 @@ public static function castList(): RecordSubList 'player' => [new RecordField('id', 'Id')], 'party_actor' => [RecordField::reference('id', 'Actor', 'actors')], 'npc' => [new RecordField('id', 'NPC Id', reference: 'map_npcs')], - 'staged_actor' => self::stagedActorFields(), + 'staged_actor' => static fn(array $entry): array => self::stagedActorFields($graphical, $entry), ], variantKey: 'kind', + nestedLists: $graphical ? ['staged_actor' => StagedActorFields::getSuppressionList(...)] : [], + prepareEdit: $graphical ? StagedActorFields::prepareEdit(...) : null, ); } @@ -342,22 +344,33 @@ public static function castList(): RecordSubList * * @return RecordField[] */ - public static function stagedActorFields(): array + public static function stagedActorFields(bool $graphical = false, array $entry = []): array { - return [ + $fields = [ new RecordField('id', 'Id'), new RecordField('sprite', 'Sprite', InputControlType::MULTILINE, codec: RecordFieldCodec::LINES, removeWhenEmpty: true), new RecordField('asset', 'Asset', removeWhenEmpty: true), new RecordField('x', 'X', InputControlType::INTEGER), new RecordField('y', 'Y', InputControlType::INTEGER), new RecordField('facing', 'Facing', options: self::FACINGS, removeWhenEmpty: true, displayDefault: 'south'), - RecordField::boolean('visible', 'Visible'), + RecordField::boolean('visible', 'Visible', removeWhenEmpty: false, displayDefault: 'true'), RecordField::boolean('collision', 'Collision'), new RecordField('sprites.north', 'Sprite North', InputControlType::MULTILINE, codec: RecordFieldCodec::LINES, removeWhenEmpty: true), new RecordField('sprites.south', 'Sprite South', InputControlType::MULTILINE, codec: RecordFieldCodec::LINES, removeWhenEmpty: true), new RecordField('sprites.east', 'Sprite East', InputControlType::MULTILINE, codec: RecordFieldCodec::LINES, removeWhenEmpty: true), new RecordField('sprites.west', 'Sprite West', InputControlType::MULTILINE, codec: RecordFieldCodec::LINES, removeWhenEmpty: true), ]; + + if (! $graphical) { + return $fields; + } + + if (isset($entry['subject'])) { + $fields = array_values(array_filter($fields, static fn(RecordField $field): bool + => ! in_array($field->key, ['x', 'y', 'facing', 'collision'], true))); + } + + return [...$fields, ...StagedActorFields::getFields($entry)]; } /** @@ -365,7 +378,7 @@ public static function stagedActorFields(): array * the interpreter understands, each with its fields, nested blocks as * frames, and routes, lanes and camera points as nested lists. */ - public static function cinematicCommandList(string $key): RecordSubList + public static function cinematicCommandList(string $key, bool $graphical = false): RecordSubList { return new RecordSubList( key: $key, @@ -375,19 +388,25 @@ public static function cinematicCommandList(string $key): RecordSubList new RecordField('type', 'Type', options: [...CinematicCommandSchema::COMMAND_TYPES, ...ScriptCommandRegistry::getCatalog()->types]), ], blank: ['type' => 'wait', 'seconds' => 0.5], - variants: self::cinematicCommandVariants(), + variants: self::cinematicCommandVariants($graphical), variantKey: 'type', nestedLists: [ - 'move_route' => RecordSchemaCatalog::routeStepList(), + 'move_route' => MovementRouteFields::getPointList(...), 'parallel' => self::laneList(), 'camera' => static fn(array $entry): ?RecordSubList => strtolower(strval($entry['operation'] ?? '')) === 'route' ? self::cameraPointList() : null, - ...RecordSchemaCatalog::getRegisteredCommandLists(), + ...RecordSchemaCatalog::getRegisteredCommandLists($graphical), + ...($graphical ? ['stage_actor' => StagedActorFields::getSuppressionList(...)] : []), ], variantArms: [ 'sequence' => ['commands' => 'Sequence'], 'choice' => ['cancel' => 'Cancel'], ], exclusiveFields: [['effect', 'animation'], ['effect', 'id'], ['effect', 'secondsPerFrame']], + prepareEdit: static function (array $entry, string $field) use ($graphical): array { + $entry = InnPresentationFields::prepareEdit($entry, $field); + $entry = $graphical ? StagedActorFields::prepareEdit($entry, $field) : $entry; + return MovementRouteFields::prepareEdit($entry, $field); + }, ); } @@ -439,9 +458,9 @@ public static function finalizerCommandList(): RecordSubList * * @return array): RecordField[]> */ - public static function cinematicCommandVariants(): array + public static function cinematicCommandVariants(bool $graphical = false): array { - $base = RecordSchemaCatalog::eventCommandVariants(); + $base = RecordSchemaCatalog::eventCommandVariants($graphical); $subject = static fn(string $prefix, string $label, bool $screen = false): array => [ new RecordField( $prefix . '.kind', @@ -457,14 +476,7 @@ public static function cinematicCommandVariants(): array return [ ...$base, - 'move_route' => [ - new RecordField('subject', 'Subject', options: ['player', 'npc', 'staged_actor']), - new RecordField('npcId', 'NPC Id', reference: 'map_npcs', removeWhenEmpty: true, allowsNone: true), - new RecordField('actorId', 'Staged Actor', reference: 'cinematic_cast', removeWhenEmpty: true, allowsNone: true), - new RecordField('secondsPerStep', 'Seconds Per Step', InputControlType::FLOAT, removeWhenEmpty: true), - new RecordField('speed', 'Steps Per Second', InputControlType::FLOAT, removeWhenEmpty: true), - new RecordField('wait', 'Wait For Completion', InputControlType::BOOLEAN, ['true'], removeWhenEmpty: false), - ], + 'move_route' => static fn(array $entry): array => MovementRouteFields::getFields($entry, cinematic: true), 'transfer' => [ RecordField::reference('map', 'Map', 'maps'), new RecordField('x', 'X', InputControlType::INTEGER), @@ -485,7 +497,9 @@ public static function cinematicCommandVariants(): array ? [new RecordField('magnitude', 'Magnitude', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '1')] : []), ], - 'stage_actor' => self::stagedActorFields(), + 'stage_actor' => static fn(array $entry): array => $graphical + ? StagedActorFields::getCommandFields($entry) + : self::stagedActorFields(), 'show_actor' => [new RecordField('actorId', 'Actor', reference: 'cinematic_cast')], 'hide_actor' => [new RecordField('actorId', 'Actor', reference: 'cinematic_cast')], 'remove_actor' => [new RecordField('actorId', 'Actor', reference: 'cinematic_cast')], @@ -571,38 +585,40 @@ public static function cameraPointList(): RecordSubList * as frames. Every row is a key the Engine's effect library reads; an * effect with separate terminal and graphical sequences is edited one * sequence at a time. Battle-only keys (impact timing, facing, image - * attachment and pivot, flips, flash and shake) are offered for every + * attachment, flips, flash and shake) are offered for every * effect; validation says where * an effect is used that refuses them. */ - public static function effects(): RecordSchema + public static function effects(bool $graphical = false): RecordSchema { + $fields = [ + new RecordField('id', 'Id', isReadOnly: true), + new RecordField('fps', 'FPS', InputControlType::INTEGER), + new RecordField('lengthFrames', 'Length (frames)', InputControlType::INTEGER), + new RecordField('playback', 'Playback', options: ['once', 'loop'], removeWhenEmpty: true, displayDefault: 'once'), + new RecordField('loopFrom', 'Loop From', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + new RecordField('restFrame', 'Rest Frame', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + // Battle only: fixed plays at its FPS; battle_phase spreads its frames + // over the battle phase it plays in, as the battle's pace sets it. + new RecordField( + 'cadence', + 'Cadence', + options: array_map(static fn(EffectCadence $cadence): string => $cadence->value, EffectCadence::cases()), + removeWhenEmpty: true, + displayDefault: EffectCadence::FIXED->value, + ), + // Battle only: when the command's result lands. + new RecordField('effectTiming.mode', 'Impact Timing', options: ['end', 'frame', 'cue'], removeWhenEmpty: true, displayDefault: '(omitted)'), + new RecordField('effectTiming.frame', 'Impact Frame', InputControlType::INTEGER, removeWhenEmpty: true), + RecordField::reference('effectTiming.cueId', 'Impact Cue', 'effect_cues', allowsNone: true, noneLabel: '(none)'), + ]; + return new RecordSchema( key: self::EFFECTS_KEY, entryNoun: 'effect', storage: RecordStorage::MAP_OWNED, relativePath: 'assets/' . EffectTimelineLibrary::DIRECTORY, - fields: [ - new RecordField('id', 'Id', isReadOnly: true), - new RecordField('fps', 'FPS', InputControlType::INTEGER), - new RecordField('lengthFrames', 'Length (frames)', InputControlType::INTEGER), - new RecordField('playback', 'Playback', options: ['once', 'loop'], removeWhenEmpty: true, displayDefault: 'once'), - new RecordField('loopFrom', 'Loop From', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), - new RecordField('restFrame', 'Rest Frame', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), - // Battle only: fixed plays at its FPS; battle_phase spreads its frames - // over the battle phase it plays in, as the battle's pace sets it. - new RecordField( - 'cadence', - 'Cadence', - options: array_map(static fn(EffectCadence $cadence): string => $cadence->value, EffectCadence::cases()), - removeWhenEmpty: true, - displayDefault: EffectCadence::FIXED->value, - ), - // Battle only: when the command's result lands. - new RecordField('effectTiming.mode', 'Impact Timing', options: ['end', 'frame', 'cue'], removeWhenEmpty: true, displayDefault: '(omitted)'), - new RecordField('effectTiming.frame', 'Impact Frame', InputControlType::INTEGER, removeWhenEmpty: true), - RecordField::reference('effectTiming.cueId', 'Impact Cue', 'effect_cues', allowsNone: true, noneLabel: '(none)'), - ], + fields: $fields, identityKey: 'id', blank: [ 'id' => 'new-effect', @@ -616,9 +632,19 @@ public static function effects(): RecordSchema self::CUES_KEY => [], ], commandLists: [ - self::TRACKS_KEY => self::effectTrackList(), + self::TRACKS_KEY => self::effectTrackList($graphical), self::CUES_KEY => self::effectCueList(), - ], + ...($graphical ? [ + self::STAGE_SUBJECTS_KEY => self::stageSubjectList(), + self::STAGE_CAMERA_KEY => self::stageCameraList(), + self::STAGE_COVERS_KEY => self::stageCoverList(), + ] : []), + ], + fieldsFor: static fn(array $payload): array => $graphical && is_array($payload['stage'] ?? null) + ? [...$fields, ...self::stageFields()] : $fields, + commandListsFor: static fn(array $payload): array => $graphical && is_array($payload['stage'] ?? null) + ? [self::TRACKS_KEY, self::CUES_KEY, self::STAGE_SUBJECTS_KEY, self::STAGE_CAMERA_KEY, self::STAGE_COVERS_KEY] + : [self::TRACKS_KEY, self::CUES_KEY], ); } @@ -627,9 +653,13 @@ public static function effects(): RecordSchema * reads, an image track's sheet and cells as its own rows, and each * track's keyframes as a nested list of the keys its type reads. */ - public static function effectTrackList(): RecordSubList + public static function effectTrackList(bool $graphical = false): RecordSubList { $keyframes = self::effectKeyframeList(); + $anchors = [ + new RecordField('anchor', 'Anchor', options: ['target', 'caster', 'screen'], removeWhenEmpty: true, displayDefault: 'target'), + new RecordField('facing', 'Facing', options: ['', 'west', 'east'], removeWhenEmpty: true, displayDefault: '(undirected)'), + ]; return new RecordSubList( key: self::TRACKS_KEY, @@ -641,16 +671,22 @@ public static function effectTrackList(): RecordSubList new RecordField('presentation', 'Presentation', options: ['all', ...array_map(static fn(EffectPresentation $presentation): string => $presentation->value, EffectPresentation::cases())], removeWhenEmpty: true, displayDefault: 'all'), - new RecordField('anchor', 'Anchor', options: ['target', 'caster', 'screen'], removeWhenEmpty: true, displayDefault: 'target'), - // Battle only: the way the stroke is drawn; the battle mirrors it for an attack the other way. - // The empty choice removes it: an undirected track stays as drawn. - new RecordField('facing', 'Facing', options: ['', 'west', 'east'], removeWhenEmpty: true, displayDefault: '(undirected)'), ], blank: ['id' => 'track', 'type' => 'glyph', 'keyframes' => [['frame' => 0, 'duration' => 1, 'content' => '*', 'position' => ['x' => 0, 'y' => 0]]]], - variants: ['image' => self::imageTrackFields()], + variants: [ + 'image' => static fn(array $entry): array => $graphical && self::isOnStage($entry) + ? self::stageImageTrackFields() + : [new RecordField('anchor', 'Anchor', options: $graphical ? self::SUMMON_IMAGE_ANCHORS : ['target', 'caster', 'screen'], + removeWhenEmpty: true, displayDefault: 'target'), $anchors[1], ...self::imageTrackFields(sharedWithField: true)], + 'glyph' => $anchors, + 'text' => $anchors, + 'flash' => $anchors, + 'shake' => $anchors, + ], variantKey: 'type', nestedLists: [ - 'image' => self::effectImageKeyframeList(), + 'image' => static fn(array $entry): RecordSubList => $graphical && self::isOnStage($entry) + ? self::stageImageKeyframeList() : self::effectImageKeyframeList(), 'glyph' => $keyframes, 'text' => $keyframes, 'flash' => $keyframes, @@ -734,12 +770,13 @@ public static function effectCueList(): RecordSubList */ /** * An image track's own fields, the same on an effect and a summon: the - * sheet it draws from, its depth, and (in battle) the battler point it is - * placed on and the point of the sheet cell that sits there. + * sheet it draws from, its depth, its normalized pivot and (in battle) + * the battler point it is placed on. An effect may be used in both field + * and battle: omission keeps the consumer's default, not an authored pivot. * * @return list */ - public static function imageTrackFields(): array + public static function imageTrackFields(bool $sharedWithField = false): array { return [ RecordField::reference('asset', 'Image', 'png_assets'), @@ -752,13 +789,15 @@ public static function imageTrackFields(): array new RecordField('fit', 'Fit', options: ['', ...array_map(static fn(CanvasImageFit $fit): string => $fit->value, CanvasImageFit::cases())], removeWhenEmpty: true, displayDefault: '(' . CanvasImageFit::STRETCH->value . ')'), new RecordField('depth', 'Depth', options: ['front', 'behind'], removeWhenEmpty: true, displayDefault: 'front'), - // Battle only: the battler point the image is placed on (the empty - // choice removes it: its centre), and the point of the sheet cell that - // sits there, x and y from 0 to 1 (removed: the cell's middle). - new RecordField('attachment', 'Attachment', + // Attachments are battle-only; a field image has only its pivot. + new RecordField('attachment', $sharedWithField ? 'Attachment (battle only)' : 'Attachment', options: ['', ...array_map(static fn(EffectImageAttachment $attachment): string => $attachment->value, EffectImageAttachment::cases())], removeWhenEmpty: true, displayDefault: '(center)'), - new RecordField('pivot', 'Pivot', codec: RecordFieldCodec::NORMALIZED_POINT, removeWhenEmpty: true, displayDefault: '0.5, 0.5'), + // Keep context-dependent defaults descriptive, never write one into + // a shared effect just because it was opened in a particular preview. + new RecordField('pivot', $sharedWithField ? 'Pivot (empty: field 0.5, 1; battle 0.5, 0.5)' : 'Pivot', + codec: RecordFieldCodec::NORMALIZED_POINT, removeWhenEmpty: true, + displayDefault: $sharedWithField ? '(field 0.5, 1; battle 0.5, 0.5)' : '0.5, 0.5'), ]; } diff --git a/src/Database/InnPresentationFields.php b/src/Database/InnPresentationFields.php new file mode 100644 index 0000000..8d53f5b --- /dev/null +++ b/src/Database/InnPresentationFields.php @@ -0,0 +1,166 @@ + */ + public static function getFields(mixed $value, string $key, string $label, bool $graphical): array + { + if (! $graphical) { + return [is_array($value) || is_object($value) + ? new RecordField($key, $label, isReadOnly: true) + : RecordField::reference($key, $label, 'stage_timelines', allowsNone: true, noneLabel: 'None')]; + } + + return [ + ...(! is_array($value) && ! is_object($value) + ? [RecordField::reference($key, $label, 'stage_timelines', allowsNone: true, noneLabel: 'None')] + : []), + new RecordField($key . '.treatment', $label . ' Treatment', options: [PartyStageSelection::LEADER, PartyStageSelection::PARTY]), + ]; + } + + /** @return list */ + public static function getBindingLists(string $key): array + { + return [ + new RecordSubList( + key: $key . '.leaders', prefix: 'innLeader', singular: 'leader binding', + fields: [RecordField::reference('actor', 'Leader', 'actor_ids'), RecordField::reference('timeline', 'Stage', 'stage_timelines')], + blank: ['actor' => '', 'timeline' => ''], heading: 'Leader Bindings', keyField: 'actor', valueField: 'timeline', + ), + new RecordSubList( + key: $key . '.parties', prefix: 'innParty', singular: 'party binding', + fields: [new RecordField('actors', 'Guests', codec: RecordFieldCodec::CSV_LIST, reference: 'actor_ids'), + RecordField::reference('timeline', 'Stage', 'stage_timelines')], + blank: ['actors' => [], 'timeline' => ''], heading: 'Exact Party Bindings', + ), + ]; + } + + public static function getBindingList(mixed $value, string $key): ?RecordSubList + { + if (! is_array($value)) { return null; } + return match ($value['treatment'] ?? null) { + PartyStageSelection::LEADER => self::getBindingLists($key)[0], + PartyStageSelection::PARTY => self::getBindingLists($key)[1], + default => null, + }; + } + + /** + * Blank picker rows are authoring drafts, never invented resource ids. + * Only complete rows participate in draft DTO validation; saving always + * validates the actual, unfiltered value with the authoritative DTO. + */ + public static function assertValid(mixed $value, ?ReferenceCatalog $references = null, bool $complete = true): void + { + if ($value === null) { return; } + if (is_string($value)) { + EffectTimelineLibrary::assertId($value); + self::assertReference($value, 'stage_timelines', $references); + return; + } + if ($value instanceof PartyStageSelection) { $value = $value->toArray(); } + if (! is_array($value)) { throw new InvalidArgumentException('A rest presentation must be a stage id or party stage selection.'); } + + $checked = $value; + if (! $complete && ($value['treatment'] ?? null) === PartyStageSelection::LEADER && is_array($value['leaders'] ?? null)) { + $checked['leaders'] = array_filter($value['leaders'], static fn(mixed $timeline, mixed $actor): bool => $actor !== '' && $timeline !== '', ARRAY_FILTER_USE_BOTH); + } + if (! $complete && ($value['treatment'] ?? null) === PartyStageSelection::PARTY && is_array($value['parties'] ?? null)) { + $checked['parties'] = array_values(array_filter($value['parties'], static fn(mixed $binding): bool => + ! is_array($binding) || array_diff(array_keys($binding), ['actors', 'timeline']) !== [] + || (($binding['actors'] ?? null) !== [] && ($binding['timeline'] ?? null) !== ''))); + } + // Mixed bindings, unknown keys, duplicate compositions and limits remain Engine-owned. + PartyStageSelection::fromArray($checked); + if (count((array) ($value['leaders'] ?? [])) > 1024 || count((array) ($value['parties'] ?? [])) > 1024) { + throw new InvalidArgumentException('A rest presentation has too many bindings.'); + } + foreach ((array) ($value['leaders'] ?? []) as $actor => $timeline) { + self::assertReference((string) $actor, 'actor_ids', $references, ! $complete); + self::assertReference($timeline, 'stage_timelines', $references, ! $complete); + } + foreach ((array) ($value['parties'] ?? []) as $binding) { + if (! is_array($binding) || ! is_array($binding['actors'] ?? null) || ! array_is_list($binding['actors']) + || array_diff(array_keys($binding), ['actors', 'timeline']) !== [] || ! array_key_exists('timeline', $binding)) { + throw new InvalidArgumentException('A party binding needs an actor list and stage timeline.'); + } + foreach ($binding['actors'] as $actor) { self::assertReference($actor, 'actor_ids', $references); } + if (count(array_unique($binding['actors'], SORT_REGULAR)) !== count($binding['actors'])) { + throw new InvalidArgumentException('A party binding cannot repeat a guest.'); + } + self::assertReference($binding['timeline'], 'stage_timelines', $references, ! $complete); + } + } + + private static function assertReference(mixed $value, string $category, ?ReferenceCatalog $references, bool $blankAllowed = false): void + { + if ($value === '' && $blankAllowed) { return; } + if (! is_string($value) || trim($value) === '' || $value !== trim($value)) { + throw new InvalidArgumentException('Choose a nonempty ' . ($category === 'actor_ids' ? 'actor identity' : 'stage timeline') . '.'); + } + if ($category === 'stage_timelines') { EffectTimelineLibrary::assertId($value); } + if ($references !== null && ! in_array($value, $references->valuesFor($category), true)) { + throw new InvalidArgumentException(sprintf('Unknown %s "%s"; choose a project resource.', $category, $value)); + } + } + + /** Validates registered stage references at any command depth, not just inn commands. */ + public static function assertCommandsValid(array $payload, ?ReferenceCatalog $references = null, bool $complete = true): void + { + $definition = is_string($payload['type'] ?? null) ? (ScriptCommandRegistry::getCatalog()->definitions[$payload['type']] ?? null) : null; + foreach ($definition?->fields ?? [] as $field) { + if ($field->reference !== ScriptCommandReference::STAGE_TIMELINE) { continue; } + $value = $payload; + foreach (explode('.', $field->key) as $segment) { $value = is_array($value) ? ($value[$segment] ?? null) : null; } + self::assertValid($value, $references, $complete); + } + foreach ($payload as $nested) { + if (is_array($nested)) { self::assertCommandsValid($nested, $references, $complete); } + } + } + + public static function prepareEdit(array $entry, string $field): array + { + $definition = is_string($entry['type'] ?? null) ? (ScriptCommandRegistry::getCatalog()->definitions[$entry['type']] ?? null) : null; + foreach ($definition?->fields ?? [] as $declared) { + if ($declared->reference === ScriptCommandReference::STAGE_TIMELINE + && ($field === $declared->key || str_starts_with($field, $declared->key . '.'))) { + self::assertCommandsValid($entry, complete: false); + } + } + return $entry; + } + + /** Only changed presentation values are authored; unrelated Terminal edits preserve graphical data. */ + public static function assertChangedCommandsValid(array $old, array $new, ?ReferenceCatalog $references = null, bool $complete = true): void + { + if ($old === $new) { return; } + $definition = is_string($new['type'] ?? null) ? (ScriptCommandRegistry::getCatalog()->definitions[$new['type']] ?? null) : null; + foreach ($definition?->fields ?? [] as $field) { + if ($field->reference !== ScriptCommandReference::STAGE_TIMELINE) { continue; } + $before = $old; + $after = $new; + foreach (explode('.', $field->key) as $segment) { + $before = is_array($before) ? ($before[$segment] ?? null) : null; + $after = is_array($after) ? ($after[$segment] ?? null) : null; + } + if ($before !== $after) { self::assertValid($after, $references, $complete); } + } + foreach ($new as $key => $nested) { + if (is_array($nested)) { self::assertChangedCommandsValid(is_array($old[$key] ?? null) ? $old[$key] : [], $nested, $references, $complete); } + } + } +} diff --git a/src/Database/MovementRouteFields.php b/src/Database/MovementRouteFields.php new file mode 100644 index 0000000..8fe1d20 --- /dev/null +++ b/src/Database/MovementRouteFields.php @@ -0,0 +1,195 @@ +commandLists[$frame[0]]) + ? $frame : [$schema->subList?->key, ...$frame]; + } + + /** Resolve the invocation's outer command list, not sibling scripts or a nested branch alone. */ + public static function getOwnerCommands(array $payload, array $path = []): array + { + if ($path === []) { + return (array) ($payload['commands'] ?? $payload['script'] ?? []); + } + $current = $payload; + foreach ($path as $segment) { + if (!is_array($current) || !array_key_exists($segment, $current)) { return []; } + $current = $current[$segment]; + if (in_array($segment, ['commands', 'script'], true)) { + return is_array($current) ? $current : []; + } + } + return []; + } + + /** Only executable arms may declare a route; preserved annotations are not scripts. */ + public static function getChildCommandLists(array $command): array + { + return match ($command['type'] ?? '') { + 'sequence' => [(array) ($command['commands'] ?? [])], + 'branch' => [(array) ($command['then'] ?? []), (array) ($command['else'] ?? [])], + 'choice' => [...array_map(static fn(array $option): array => (array) ($option['then'] ?? []), + array_filter((array) ($command['options'] ?? []), is_array(...))), (array) ($command['cancel'] ?? [])], + 'parallel' => array_map(static fn(array $lane): array => array_is_list($lane) ? $lane : (array) ($lane['commands'] ?? []), + array_filter((array) ($command['lanes'] ?? []), is_array(...))), + default => [], + }; + } + + public static function getMode(array $entry): ?string + { + $modes = array_values(array_intersect(self::MODES, array_keys($entry))); + // New commands retain the established Add Step workflow; no mode is stored until authored. + if ($modes === []) { return 'steps'; } + return count($modes) === 1 ? $modes[0] : null; + } + + /** @return list */ + public static function getFields(array $entry, bool $cinematic = false): array + { + $mode = self::getMode($entry); + return [ + new RecordField('subject', 'Subject', options: $cinematic ? ['player', 'npc', 'staged_actor'] : ['player', 'npc']), + new RecordField('npcId', 'NPC Id', reference: 'map_npcs', removeWhenEmpty: true, allowsNone: true), + ...($cinematic ? [new RecordField('actorId', 'Staged Actor', reference: 'cinematic_cast', removeWhenEmpty: true, allowsNone: true)] : []), + new RecordField(self::MODE_FIELD, 'Route Mode', options: self::MODES, displayDefault: $mode ?? '(Choose one route mode)'), + new RecordField('secondsPerStep', 'Seconds Per Step', InputControlType::FLOAT, removeWhenEmpty: true), + new RecordField('speed', 'Steps Per Second', InputControlType::FLOAT, removeWhenEmpty: true), + new RecordField('wait', 'Wait For Completion', InputControlType::BOOLEAN, ['true'], removeWhenEmpty: false), + ...($mode === 'retrace' + ? [RecordField::reference('retrace', 'Recorded Route', 'cinematic_movement_routes')] + : [new RecordField('remember', 'Remember As', removeWhenEmpty: true)]), + ]; + } + + public static function getPointList(array $entry): ?RecordSubList + { + return match (self::getMode($entry)) { + 'steps' => RecordSchemaCatalog::routeStepList(), + 'waypoints' => new RecordSubList( + key: 'waypoints', prefix: 'waypoint', singular: 'waypoint', + fields: [ + new RecordField('x', 'X', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '(Keep current X)'), + new RecordField('y', 'Y', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '(Keep current Y)'), + ], + blank: [], + prepareEdit: static function (array $point, string $field): array { + self::assertPathOptions(['waypoints' => [$point]]); + return $point; + }, + ), + default => null, + }; + } + + public static function prepareEdit(array $entry, string $field): array + { + if (($entry['type'] ?? '') !== 'move_route') { return $entry; } + if ($field === self::MODE_FIELD) { + $mode = $entry[self::MODE_FIELD] ?? null; + if (!in_array($mode, self::MODES, true)) { + throw new InvalidArgumentException('Choose steps, waypoints or retrace.'); + } + unset($entry[self::MODE_FIELD]); + foreach (self::MODES as $other) { + if ($other !== $mode) { unset($entry[$other]); } + } + // Empty drafts ask the author for points/a reference, never invent coordinates or ids. + $entry[$mode] ??= $mode === 'retrace' ? '' : []; + if ($mode === 'retrace') { unset($entry['remember']); } + } + if (in_array($field, ['remember', 'retrace'], true) && array_key_exists($field, $entry)) { + $binding = [$field === 'retrace' ? 'retrace' : 'steps' => $field === 'retrace' ? $entry[$field] : [], + 'subject' => $entry['subject'] ?? 'player']; + if (isset($entry['remember'])) { $binding['remember'] = $entry['remember']; } + self::assertPathOptions($binding); + } + if (in_array($field, [self::MODE_FIELD, 'subject', 'remember', 'retrace'], true) + && strtolower(trim(strval($entry['subject'] ?? 'player'))) === 'staged_actor' + && (isset($entry['waypoints']) || isset($entry['remember']) || isset($entry['retrace']))) { + throw new InvalidArgumentException('Waypoints and recorded routes require a real player or NPC subject.'); + } + return $entry; + } + + /** Complete changed path options at the real owner's save boundary, not while authoring drafts. */ + public static function assertChangedCommandsValid(array $old, array $new): void + { + if ($old === $new) { return; } + $loadedPaths = array_map(self::getPathOptions(...), self::getRouteCommands($old)); + foreach (self::getRouteCommands($new) as $command) { + $unchanged = array_search(self::getPathOptions($command), $loadedPaths, true); + // Reordering unrelated commands preserves legacy paths, but adding another does not. + if ($unchanged !== false) { unset($loadedPaths[$unchanged]); continue; } + self::assertPathOptions($command); + if (array_key_exists('steps', $command)) { self::assertStepsComplete($command['steps']); } + } + } + + /** @return list */ + private static function getRouteCommands(array $payload): array + { + $commands = ($payload['type'] ?? '') === 'move_route' ? [$payload] : []; + foreach ($payload as $child) { + if (is_array($child)) { array_push($commands, ...self::getRouteCommands($child)); } + } + return $commands; + } + + private static function getPathOptions(array $command): array + { + $path = []; + foreach (['steps', 'waypoints', 'retrace', 'remember', 'subject'] as $key) { + if (array_key_exists($key, $command)) { $path[$key] = $command[$key]; } + } + return $path; + } + + /** The Engine extension validator handles waypoints/retrace, not cardinal draft completeness. */ + private static function assertStepsComplete(mixed $steps): void + { + if (!is_array($steps) || $steps === []) { + throw new InvalidArgumentException('A movement route requires at least one step.'); + } + foreach ($steps as $step) { + if (!is_array($step) || !is_string($step['direction'] ?? null) + || !in_array(strtolower(trim($step['direction'])), MovementRouteRunner::DIRECTIONS, true)) { + throw new InvalidArgumentException('Each movement-route step requires a supported direction.'); + } + $count = $step['count'] ?? 1; + if (!is_numeric($count) || floatval($count) !== floatval(intval($count)) || intval($count) < 0) { + throw new InvalidArgumentException('Movement-route step count must be a non-negative integer.'); + } + if (array_key_exists('faceOnly', $step) && !is_bool($step['faceOnly'])) { + throw new InvalidArgumentException('Movement-route step faceOnly must be a boolean.'); + } + } + } + + private static function assertPathOptions(array $entry): void + { + try { + MovementRouteRunner::validatePathOptions($entry); + } catch (MovementRouteException $error) { + throw new InvalidArgumentException($error->getMessage(), previous: $error); + } + } +} diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 7dc060e..2d4c502 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -43,6 +43,56 @@ final class ProjectRecordDatabase * @var list */ private array $retiredIdentities = []; + private ?ReferenceCatalog $authoringReferences = null; + private bool $graphical = false; + + public function useAuthoringReferences(ReferenceCatalog $references): void + { + $this->authoringReferences = $references; + } + + private function coerceReferenceField(ProjectRecord $record, RecordField $field, string $rawValue, array $ownerPath = []): mixed + { + $value = self::coerce($field, $rawValue); + if ($this->authoringReferences !== null && in_array($field->reference, ['actor_ids', 'stage_timelines', 'cinematic_movement_routes'], true) && $value !== null) { + $choices = $field->reference === 'cinematic_movement_routes' + ? $this->authoringReferences->getMovementRouteIds($record, $ownerPath) + : $this->authoringReferences->valuesFor($field->reference); + foreach (is_array($value) ? $value : [$value] as $selected) { + if (! in_array($selected, $choices, true)) { + throw new \InvalidArgumentException(sprintf('Unknown %s "%s"; choose a project resource.', $field->reference, strval($selected))); + } + } + } + return $value; + } + + /** Proves a proposed payload is valid and source-editable without changing the actual record. */ + private function assertProposedRecord(ProjectRecord $record, ProjectRecord $draft): void + { + if ($record->toArray() === $draft->toArray()) { return; } + if ($this->schema->storage === RecordStorage::CONFIG_SUBTREE) { + $path = strval($draft->get('path')); + $this->config?->assertAcceptableValue($path, $draft->get('value'), complete: false); + if ($path === ProjectConfig::INN_PRESENTATION) { + InnPresentationFields::assertValid($draft->get('value'), $this->authoringReferences, complete: false); + } + $this->config?->assertSourceAcceptsValue($path, $draft->get('value')); + } elseif (is_array($draft->toArray())) { + InnPresentationFields::assertChangedCommandsValid((array) $record->toArray(), $draft->toArray(), $this->authoringReferences, complete: false); + if ($this->schema->storage === RecordStorage::DIRECTORY) { + $record->file?->composeContents($this->composeRecordFilePayload($draft)); + } + } + } + + private function writeRecordEntries(ProjectRecord $record, RecordSubList $list, array $entries): void + { + $draft = clone $record; + $draft->setEntries($list, $entries); + $this->assertProposedRecord($record, $draft); + $record->restorePayload($draft->toArray()); + } /** * @param RecordSchema $schema The category schema. @@ -263,7 +313,7 @@ private function sourcePositionsIn(PhpDataFile $file): array * @param \Closure(array): void $writeBack Receives the rewritten list. * @return self The category. */ - public static function overOwnedList(RecordSchema $schema, string $ownerPath, array $entries, \Closure $writeBack): self + public static function overOwnedList(RecordSchema $schema, string $ownerPath, array $entries, \Closure $writeBack, bool $graphical = false): self { $records = []; @@ -275,6 +325,7 @@ public static function overOwnedList(RecordSchema $schema, string $ownerPath, ar $database = new self($schema, $ownerPath, $records); $database->writeBack = $writeBack; + $database->graphical = $graphical; return $database; } @@ -286,7 +337,7 @@ public static function overOwnedList(RecordSchema $schema, string $ownerPath, ar * @param RecordSchema $schema The category schema. * @return self */ - public static function fromProject(string $projectRoot, RecordSchema $schema, ?ProjectConfig $config = null): self + public static function fromProject(string $projectRoot, RecordSchema $schema, ?ProjectConfig $config = null, bool $graphical = false): self { // The categories evaluate authored files that construct engine // objects, and creating an entry constructs them too. The runtime @@ -296,12 +347,14 @@ public static function fromProject(string $projectRoot, RecordSchema $schema, ?P $path = $schema->resolvePath($projectRoot); - return match ($schema->storage) { + $database = match ($schema->storage) { RecordStorage::LIST_FILE => self::loadListFile($path, $schema, $projectRoot), RecordStorage::DIRECTORY => self::loadDirectory($path, $schema, $projectRoot), RecordStorage::CONFIG_SUBTREE => self::loadConfigSubtree($path, $schema, $projectRoot, $config), RecordStorage::MAP_OWNED => throw new RuntimeException('Map-owned records are built over their owner, not loaded from a path.'), }; + $database->graphical = $graphical; + return $database; } /** @@ -501,7 +554,7 @@ public function getSettingsFields(int $index): array ]; } - foreach ($this->schema->getInlineSubLists() as $subList) { + foreach ($this->schema->getInlineSubLists($record->toArray()) as $subList) { if ($subList !== $this->schema->subList) { // A list beside the record's own is headed by its name and // count; its heading is where an entry is added to it. @@ -547,11 +600,16 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, self::subFieldId($subList->prefix, $entryIndex, $field->key), $isEditable, sprintf('%s %d %s', ucfirst($subList->singular), $entryIndex + 1, $field->label), - ), 'entry' => [$entryIndex], 'entryLabel' => sprintf('%s %d', ucfirst($subList->singular), $entryIndex + 1), 'name' => $field->label]; + ), 'entry' => [$entryIndex], 'entryLabel' => sprintf('%s %d', ucfirst($subList->singular), $entryIndex + 1), 'name' => $field->label, 'sourceKey' => $field->key, + 'dataPath' => [...($basePath === [] ? explode('.', $subList->key) : $basePath), $entryIndex, ...explode('.', $field->key)]]; } $variant = $subList->variantKey !== null ? strval($entry[$subList->variantKey] ?? '') : ''; + if ($this->graphical) { + $fields = StagedActorFields::describeFields($entry, $fields, $this->authoringReferences?->getAssetRoot()); + } + if ($variant === 'choice') { // Each option is a row to rename and a frame to open. The arm's // commands are edited inside the frame, exactly as the runtime @@ -615,17 +673,18 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, $nestedList = $subList->nestedListFor($entry); if ($nestedList === null) { - return $fields; + return \Ichiloto\Editor\Maps\MapPlacement::describeFields($entry, $fields); } - foreach ($nestedList->readEntries($entry[$nestedList->key] ?? []) as $nestedIndex => $nestedEntry) { + foreach ($nestedList->readEntries(self::readNested($entry, $nestedList->key)) as $nestedIndex => $nestedEntry) { if (! is_array($nestedEntry)) { continue; } foreach ($nestedList->fieldsFor($nestedEntry) as $field) { $fields[] = ['entry' => [$entryIndex, $nestedIndex], 'entryLabel' => sprintf('%s %d', ucfirst($nestedList->singular), $nestedIndex + 1), - 'name' => $field->label] + self::describeField( + 'name' => $field->label, 'sourceKey' => $field->key, + 'dataPath' => [...($basePath === [] ? explode('.', $subList->key) : $basePath), $entryIndex, ...explode('.', $nestedList->key), $nestedIndex, ...explode('.', $field->key)]] + self::describeField( $field, self::displayValue($field, self::readNested($nestedEntry, $field->key)), self::nestedSubFieldId( @@ -669,7 +728,7 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, } } - return $fields; + return \Ichiloto\Editor\Maps\MapPlacement::describeFields($entry, $fields); } /** @@ -698,7 +757,8 @@ public function setField(int $index, string $fieldId, string $rawValue): void . preg_quote($subList->prefix, '/') . '(\d+)([A-Za-z][A-Za-z0-9]*?)(\d+)([A-Za-z][A-Za-z0-9]*)$/'; - if (preg_match($nestedPattern, $fieldId, $matches) === 1) { + if (preg_match($nestedPattern, $fieldId, $matches) === 1 + && ! self::hasEntryFieldToken($subList, $record->getEntries($subList)[intval($matches[1])] ?? [], $matches[2] . $matches[3] . $matches[4])) { $this->setNestedSubField( $record, $subList, @@ -712,7 +772,7 @@ public function setField(int $index, string $fieldId, string $rawValue): void } } - foreach ($this->schema->getInlineSubLists() as $inlineList) { + foreach ($this->schema->getInlineSubLists($record->toArray()) as $inlineList) { if (preg_match('/^' . preg_quote($inlineList->prefix, '/') . '(\d+)([A-Za-z][A-Za-z0-9]*)$/', $fieldId, $matches) === 1) { $this->setSubField($record, $inlineList, intval($matches[1]), $matches[2], $rawValue); return; @@ -739,9 +799,8 @@ public function setField(int $index, string $fieldId, string $rawValue): void return; } - $value = self::coerce($field, $rawValue); + $value = $this->coerceReferenceField($record, $field, $rawValue); if ($this->schema->storage === RecordStorage::CONFIG_SUBTREE) { - $this->config?->assertAcceptableValue(strval($record->get('path')), $value); // A setting the file leaves out already reads as its default: // choosing that default writes nothing. if ($record->get('value') === null && ! $this->config?->holds(strval($record->get('path'))) && $value == $record->get('default')) { @@ -755,7 +814,11 @@ public function setField(int $index, string $fieldId, string $rawValue): void } $previousIdentity = $this->schema->identityKey === null ? null : $record->get($this->schema->identityKey); $before = $record->toArray(); - $record->set($field->key, $value); + $draft = clone $record; + $draft->set($field->key, $value); + $this->dropStaleShapeFields($draft, $before); + $this->assertProposedRecord($record, $draft); + $record->restorePayload($draft->toArray()); if ($field->key === $this->schema->labelKey && $this->schema->identityFollowsLabel) { $this->followLabelWithIdentity($record, strval($previousIdentity)); } @@ -781,6 +844,14 @@ private static function withUniqueKey(RecordSubList $list, array $entries, array } $taken = array_map(static fn(mixed $other): string => is_array($other) ? strval($other[$list->keyField] ?? '') : '', $entries); + $keyField = array_find($list->fields, static fn(RecordField $field): bool => $field->key === $list->keyField); + if ($keyField?->reference !== null) { + $key = strval($entry[$list->keyField] ?? ''); + if (in_array($key, $taken, true)) { + throw new \InvalidArgumentException($key === '' ? 'Finish choosing the unbound resource before adding another binding.' : 'That resource already has a binding.'); + } + return $entry; + } $base = trim(strval($entry[$list->keyField] ?? '')) ?: Slug::of('new ' . $list->singular); $key = $base; @@ -837,6 +908,8 @@ private function dropStaleShapeFields(ProjectRecord $record, array|object $befor $offered = $keys($this->schema->fieldsFor($record->toArray())); foreach (array_diff($keys($this->schema->fieldsFor($before)), $offered) as $stale) { + // A scalar becoming structured still owns its parent value. + if (array_any($offered, static fn(string $key): bool => str_starts_with($key, $stale . '.'))) { continue; } $record->set($stale, null); } } @@ -1331,7 +1404,7 @@ public function duplicateRecord(int $index): ?int public function addSubItem(int $index, ?array $entry = null, ?int $at = null, ?string $listKey = null): ?int { $record = $this->getRecordByIndex($index); - $subList = $this->schema->findInlineSubList($listKey); + $subList = $this->schema->findInlineSubList($listKey, $record?->toArray()); if ($subList === null || ! $record instanceof ProjectRecord || ! $this->isEditable() || ! $record->isEditable()) { return null; @@ -1340,7 +1413,7 @@ public function addSubItem(int $index, ?array $entry = null, ?int $at = null, ?s $entries = $record->getEntries($subList); $position = $at === null ? count($entries) : max(0, min(count($entries), $at)); array_splice($entries, $position, 0, [self::withUniqueKey($subList, $entries, $entry ?? $subList->blank)]); - $record->setEntries($subList, $entries); + $this->writeRecordEntries($record, $subList, $entries); $this->touchState(); return $position; @@ -1356,7 +1429,7 @@ public function addSubItem(int $index, ?array $entry = null, ?int $at = null, ?s public function removeSubItem(int $index, int $entryIndex, ?string $listKey = null): ?array { $record = $this->getRecordByIndex($index); - $subList = $this->schema->findInlineSubList($listKey); + $subList = $this->schema->findInlineSubList($listKey, $record?->toArray()); if ($subList === null || ! $record instanceof ProjectRecord || ! $this->isEditable() || ! $record->isEditable()) { return null; @@ -1369,7 +1442,7 @@ public function removeSubItem(int $index, int $entryIndex, ?string $listKey = nu } [$removed] = array_splice($entries, $entryIndex, 1); - $record->setEntries($subList, $entries); + $this->writeRecordEntries($record, $subList, $entries); $this->touchState(); return $removed; @@ -1386,7 +1459,7 @@ public function removeSubItem(int $index, int $entryIndex, ?string $listKey = nu public function insertSubItem(int $index, int $entryIndex, array $entry, ?string $listKey = null): void { $record = $this->getRecordByIndex($index); - $subList = $this->schema->findInlineSubList($listKey); + $subList = $this->schema->findInlineSubList($listKey, $record?->toArray()); if ($subList === null || ! $record instanceof ProjectRecord) { return; @@ -1395,7 +1468,7 @@ public function insertSubItem(int $index, int $entryIndex, array $entry, ?string $entries = $record->getEntries($subList); $entryIndex = max(0, min(count($entries), $entryIndex)); array_splice($entries, $entryIndex, 0, [$entry]); - $record->setEntries($subList, $entries); + $this->writeRecordEntries($record, $subList, $entries); $this->touchState(); } @@ -1407,7 +1480,7 @@ public function insertSubItem(int $index, int $entryIndex, array $entry, ?string */ public function countSubItems(int $index, ?string $listKey = null): int { - $subList = $this->schema->findInlineSubList($listKey); + $subList = $this->schema->findInlineSubList($listKey, $this->getRecordByIndex($index)?->toArray()); if ($subList === null) { return 0; @@ -1425,7 +1498,7 @@ public function countSubItems(int $index, ?string $listKey = null): int */ public function getSubItems(int $index, ?string $listKey = null): array { - $subList = $this->schema->findInlineSubList($listKey); + $subList = $this->schema->findInlineSubList($listKey, $this->getRecordByIndex($index)?->toArray()); return $subList === null ? [] : ($this->getRecordByIndex($index)?->getEntries($subList) ?? []); } @@ -1479,7 +1552,7 @@ public function addNestedSubItem(int $index, int $parentIndex, ?array $entry = n return null; } - $entries = $context['list']->readEntries($context['parent'][$context['list']->key] ?? []); + $entries = $context['list']->readEntries(self::readNested($context['parent'], $context['list']->key)); $position = $at === null ? count($entries) : max(0, min(count($entries), $at)); array_splice($entries, $position, 0, [self::withUniqueKey($context['list'], $entries, $entry ?? $context['list']->blank)]); $this->writeNestedSubList($context, $entries); @@ -1496,7 +1569,7 @@ public function removeNestedSubItem(int $index, int $parentIndex, int $nestedInd return null; } - $entries = $context['list']->readEntries($context['parent'][$context['list']->key] ?? []); + $entries = $context['list']->readEntries(self::readNested($context['parent'], $context['list']->key)); if (! array_key_exists($nestedIndex, $entries) || ! is_array($entries[$nestedIndex])) { return null; @@ -1517,7 +1590,7 @@ public function insertNestedSubItem(int $index, int $parentIndex, int $nestedInd return; } - $entries = $context['list']->readEntries($context['parent'][$context['list']->key] ?? []); + $entries = $context['list']->readEntries(self::readNested($context['parent'], $context['list']->key)); $nestedIndex = max(0, min(count($entries), $nestedIndex)); array_splice($entries, $nestedIndex, 0, [$entry]); $this->writeNestedSubList($context, $entries); @@ -1530,7 +1603,7 @@ public function countNestedSubItems(int $index, int $parentIndex): int return $context === null ? 0 - : count((array) ($context['parent'][$context['list']->key] ?? [])); + : count((array) self::readNested($context['parent'], $context['list']->key)); } /** @@ -1554,6 +1627,15 @@ public function save(): void return; } + foreach ($this->getRecords() as $record) { + if ($this->schema->storage === RecordStorage::DIRECTORY && $record->isDirty() && is_array($record->toArray())) { + $original = is_array($record->file?->payload) ? $record->file->payload : []; + if ($this->schema->listPayloadKey !== null) { $original = [$this->schema->listPayloadKey => $original]; } + InnPresentationFields::assertChangedCommandsValid($original, $record->toArray(), $this->authoringReferences); + MovementRouteFields::assertChangedCommandsValid($original, $record->toArray()); + } + } + if ($this->schema->storage === RecordStorage::LIST_FILE) { // The transaction adopts the written file and cleans every // category it wrote for, this one included. @@ -2698,6 +2780,7 @@ public function assertSourceAccepts(int $index): void match ($this->schema->storage) { RecordStorage::LIST_FILE => SharedFileTransaction::preview([$this]), RecordStorage::DIRECTORY => $record->file?->composeContents($this->composeRecordFilePayload($record)), + RecordStorage::CONFIG_SUBTREE => $this->config?->getSource(), default => null, }; } @@ -2751,7 +2834,8 @@ private function setSubField(ProjectRecord $record, RecordSubList $subList, int } $before = $entries[$entryIndex]; - $value = self::coerce($field, $rawValue); + $value = $this->coerceReferenceField($record, $field, $rawValue, + [...explode('.', $subList->key), $entryIndex, ...explode('.', $field->key)]); self::assertKeyAvailable($subList, $entries, $entryIndex, $field, $value); $entries[$entryIndex] = self::writeNested( $entries[$entryIndex], @@ -2765,7 +2849,7 @@ private function setSubField(ProjectRecord $record, RecordSubList $subList, int $entries[$entryIndex] = self::withoutStaleVariantFields($subList, $before, $entries[$entryIndex]); } - $record->setEntries($subList, $entries); + $this->writeRecordEntries($record, $subList, $entries); $this->touchState(); return; @@ -2789,11 +2873,12 @@ private function setNestedSubField( return; } - $written = self::withNestedFieldWritten($subList, $entry, $nestedPrefixToken, $nestedIndex, $fieldToken, $rawValue); + $written = $this->withNestedFieldWritten($record, $subList, $entry, $nestedPrefixToken, $nestedIndex, $fieldToken, $rawValue, + [...explode('.', $subList->key), $entryIndex]); if ($written !== null) { $entries[$entryIndex] = $written; - $record->setEntries($subList, $entries); + $this->writeRecordEntries($record, $subList, $entries); $this->touchState(); } } @@ -2806,6 +2891,7 @@ private function setNestedSubField( * frame's commands both own their nested lists this way. * * @param RecordSubList $subList The list the entry belongs to. + * @param ProjectRecord $record The live owning record that constrains local references. * @param array $entry The entry. * @param string $nestedPrefixToken The nested list's prefix as it appears in the field id. * @param int $nestedIndex The nested entry. @@ -2813,13 +2899,15 @@ private function setNestedSubField( * @param string $rawValue The raw edited value. * @return array|null The rewritten entry. */ - private static function withNestedFieldWritten( + private function withNestedFieldWritten( + ProjectRecord $record, RecordSubList $subList, array $entry, string $nestedPrefixToken, int $nestedIndex, string $fieldToken, string $rawValue, + array $ownerPath = [], ): ?array { $nestedList = $subList->nestedListFor($entry); @@ -2830,7 +2918,7 @@ private static function withNestedFieldWritten( return null; } - $nestedEntries = $nestedList->readEntries($entry[$nestedList->key] ?? []); + $nestedEntries = $nestedList->readEntries(self::readNested($entry, $nestedList->key)); if (! is_array($nestedEntries[$nestedIndex] ?? null)) { return null; @@ -2841,14 +2929,20 @@ private static function withNestedFieldWritten( continue; } - $value = self::coerce($field, $rawValue); + $value = $this->coerceReferenceField($record, $field, $rawValue, + [...$ownerPath, ...explode('.', $nestedList->key), $nestedIndex, ...explode('.', $field->key)]); self::assertKeyAvailable($nestedList, $nestedEntries, $nestedIndex, $field, $value); + $before = $nestedEntries[$nestedIndex]; $nestedEntries[$nestedIndex] = self::writeNested( $nestedEntries[$nestedIndex], explode('.', $field->key), $value, ); - $entry[$nestedList->key] = $nestedList->writeEntries($nestedEntries); + $nestedEntries[$nestedIndex] = $nestedList->removeConflictingFields($nestedEntries[$nestedIndex], $field->key); + if ($field->key === $nestedList->variantKey) { + $nestedEntries[$nestedIndex] = self::withoutStaleVariantFields($nestedList, $before, $nestedEntries[$nestedIndex]); + } + $entry = self::writeNested($entry, explode('.', $nestedList->key), $nestedList->writeEntries($nestedEntries)); return $entry; } @@ -2923,7 +3017,8 @@ public function frameNestedContext(int $recordIndex, array $framePath, string $f public function locateItem(int $recordIndex, array $framePath, string $fieldId): ?RecordItem { if ($framePath === []) { - foreach ($this->schema->subLists as $inlineList) { + foreach ($this->schema->getInlineSubLists($this->getRecordByIndex($recordIndex)?->toArray()) as $inlineList) { + if ($inlineList === $this->schema->subList) { continue; } if ($fieldId === $inlineList->prefix . 'List') { return new RecordItem(RecordItem::LIST, [], -1, null, $inlineList->heading, RecordItem::ENTRY, $inlineList->singular, $inlineList->key); } @@ -2995,7 +3090,7 @@ public function countFrameNestedItems(int $recordIndex, array $framePath, int $p $parent = $commands[$parentIndex] ?? null; $nestedList = is_array($parent) && $frameList !== null ? $frameList->nestedListFor($parent) : null; - return $nestedList === null ? 0 : count($nestedList->readEntries($parent[$nestedList->key] ?? [])); + return $nestedList === null ? 0 : count($nestedList->readEntries(self::readNested($parent, $nestedList->key))); } /** @@ -3027,10 +3122,10 @@ public function addFrameNestedItem(int $recordIndex, array $framePath, int $pare return null; } - $nestedEntries = $nestedList->readEntries($parent[$nestedList->key] ?? []); + $nestedEntries = $nestedList->readEntries(self::readNested($parent, $nestedList->key)); $position = $at === null ? count($nestedEntries) : max(0, min(count($nestedEntries), $at)); array_splice($nestedEntries, $position, 0, [self::withUniqueKey($nestedList, $nestedEntries, $entry ?? $nestedList->blank)]); - $commands[$parentIndex][$nestedList->key] = $nestedList->writeEntries($nestedEntries); + $commands[$parentIndex] = self::writeNested($parent, explode('.', $nestedList->key), $nestedList->writeEntries($nestedEntries)); $this->writeFrameCommands($record, $rootList, $framePath, $commands); return $position; @@ -3062,7 +3157,7 @@ public function removeFrameNestedItem(int $recordIndex, array $framePath, int $p return null; } - $nestedEntries = $nestedList->readEntries($parent[$nestedList->key] ?? []); + $nestedEntries = $nestedList->readEntries(self::readNested($parent, $nestedList->key)); if (! is_array($nestedEntries[$nestedIndex] ?? null)) { return null; @@ -3075,9 +3170,9 @@ public function removeFrameNestedItem(int $recordIndex, array $framePath, int $p // entry leaves the command as it was created rather than with an // empty list the runtime reads the same and the fingerprint does // not. - unset($commands[$parentIndex][$nestedList->key]); + $commands[$parentIndex] = self::writeNested($parent, explode('.', $nestedList->key), null); } else { - $commands[$parentIndex][$nestedList->key] = $nestedList->writeEntries($nestedEntries); + $commands[$parentIndex] = self::writeNested($parent, explode('.', $nestedList->key), $nestedList->writeEntries($nestedEntries)); } $this->writeFrameCommands($record, $rootList, $framePath, $commands); @@ -3131,8 +3226,9 @@ private function writeNestedSubList(array $context, array $nestedEntries): void } $entries = $context['entries']; - $entries[$context['parentIndex']][$context['list']->key] = $context['list']->writeEntries($nestedEntries); - $context['record']->setEntries($subList, $entries); + $entries[$context['parentIndex']] = self::writeNested($context['parent'], explode('.', $context['list']->key), + $nestedEntries === [] && $context['list']->removeWhenEmpty ? null : $context['list']->writeEntries($nestedEntries)); + $this->writeRecordEntries($context['record'], $subList, $entries); $this->touchState(); } @@ -3270,6 +3366,18 @@ public static function summarizeLines(string $value): string private static function coerce(RecordField $field, string $rawValue): mixed { + if ($field->codec === RecordFieldCodec::SOURCE_RECT) { + if (trim($rawValue) === '' && $field->removeWhenEmpty) { + return null; + } + $parts = array_map(trim(...), explode(',', $rawValue)); + if (count($parts) !== 4 || ! array_all($parts, static fn(string $part): bool => preg_match('/\A\d+\z/', $part) === 1)) { + throw new \InvalidArgumentException(sprintf('%s requires four whole numbers: x, y, width, height.', $field->label)); + } + $rect = new \Ichiloto\Engine\Rendering\Presentation\SpriteSourceRect(...array_map(intval(...), $parts)); + + return ['x' => $rect->x, 'y' => $rect->y, 'width' => $rect->width, 'height' => $rect->height]; + } if ($field->type === InputControlType::MULTILINE || $field->codec === RecordFieldCodec::LINES) { // Exact text: every space, backslash and blank line is the // author's. Only a wholly empty block is nothing. @@ -3538,6 +3646,9 @@ private static function displayValue(RecordField $field, mixed $value): string RecordFieldCodec::BATTLE_TEST => is_array($value) ? (string) json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : ProjectRecord::stringify($value), + RecordFieldCodec::SOURCE_RECT => is_array($value) + ? implode(', ', array_map(static fn(string $key): string => ProjectRecord::stringify($value[$key] ?? ''), ['x', 'y', 'width', 'height'])) + : ProjectRecord::stringify($value), RecordFieldCodec::BATTLER_SLOT => is_array($value) ? implode(', ', array_map(static fn(string $key): string => ProjectRecord::stringify($value[$key] ?? ''), array_key_exists('displayScale', $value) ? ['x', 'y', 'width', 'height', 'displayScale'] : ['x', 'y', 'width', 'height'])) @@ -3728,7 +3839,7 @@ public function getFrameSubList(array $framePath): ?RecordSubList // Descended from the sub-list into a command arm: commands from here // down. The catalog's shared command list is what every arm holds. - return RecordSchemaCatalog::eventCommandList('commands'); + return RecordSchemaCatalog::eventCommandList('commands', $this->graphical); } /** @@ -3865,16 +3976,19 @@ public function setFrameField(int $recordIndex, array $framePath, string $fieldI $framePath !== [] && preg_match('/^' . $prefix . '(\\d+)([A-Za-z][A-Za-z0-9]*?)(\\d+)([A-Za-z][A-Za-z0-9]*)$/', $fieldId, $matches) === 1 && is_array($commands[intval($matches[1])] ?? null) + && ! self::hasEntryFieldToken($frameList, $commands[intval($matches[1])], $matches[2] . $matches[3] . $matches[4]) ) { // A nested entry of a command inside the frame (a route step): // the same write the root list gets, at this depth. - $written = self::withNestedFieldWritten( + $written = $this->withNestedFieldWritten( + $record, $frameList, $commands[intval($matches[1])], $matches[2], intval($matches[3]), $matches[4], $rawValue, + [...MovementRouteFields::getFrameOwnerPath($this->schema, $framePath), intval($matches[1])], ); if ($written === null) { @@ -3895,7 +4009,8 @@ public function setFrameField(int $recordIndex, array $framePath, string $fieldI return; } - $written = $this->writeEntryFieldToken($frameList, $entry, $matches[2], $rawValue); + $written = $this->writeEntryFieldToken($record, $frameList, $entry, $matches[2], $rawValue, + [...MovementRouteFields::getFrameOwnerPath($this->schema, $framePath), $entryIndex]); if ($written === null) { return; @@ -4284,12 +4399,15 @@ private function writeFrameCommands(ProjectRecord $record, RecordSubList $subLis [$rootKey, $relativePath] = $this->splitFramePath($framePath); $rootKey ??= $subList->key; - $record->setSubList( + $draft = clone $record; + $draft->setSubList( $rootKey, self::withFrameList($record->getSubList($rootKey), $relativePath, $commands), // A record-level list its file leaves out when empty goes with its last entry. removeWhenEmpty: $this->schema->commandLists[$rootKey]->removeWhenEmpty ?? false, ); + $this->assertProposedRecord($record, $draft); + $record->restorePayload($draft->toArray()); $this->touchState(); } @@ -4298,18 +4416,20 @@ private function writeFrameCommands(ProjectRecord $record, RecordSubList $subLis * * @param RecordSubList $subList The sub-list schema. * @param array $entry The entry payload. + * @param ProjectRecord $record The live owning record that constrains local references. * @param string $token The field token. * @param string $rawValue The raw edited value. * @return array|null The rewritten entry, or null when no field matched. */ - private function writeEntryFieldToken(RecordSubList $subList, array $entry, string $token, string $rawValue): ?array + private function writeEntryFieldToken(ProjectRecord $record, RecordSubList $subList, array $entry, string $token, string $rawValue, array $ownerPath = []): ?array { foreach ($subList->fieldsFor($entry) as $field) { if (self::fieldToken($field->key) !== $token || $field->isReadOnly) { continue; } - $written = self::writeNested($entry, explode('.', $field->key), self::coerce($field, $rawValue)); + $written = self::writeNested($entry, explode('.', $field->key), $this->coerceReferenceField($record, $field, $rawValue, + [...$ownerPath, ...explode('.', $field->key)])); $written = $subList->removeConflictingFields($written, $field->key); if ($field->key === $subList->variantKey && $written !== null) { @@ -4433,6 +4553,12 @@ private static function fieldToken(string $key): string return (string) preg_replace('/[^A-Za-z0-9]/', '', ucfirst($key)); } + /** Declared fields take precedence over the encoded nested-list shape, even with digits in their names. */ + private static function hasEntryFieldToken(RecordSubList $list, array $entry, string $token): bool + { + return array_any($list->fieldsFor($entry), static fn(RecordField $field): bool => self::fieldToken($field->key) === $token); + } + /** * Returns an identity not already used by another record. * diff --git a/src/Database/RecordAuthoring.php b/src/Database/RecordAuthoring.php index 2d0cc57..3912f88 100644 --- a/src/Database/RecordAuthoring.php +++ b/src/Database/RecordAuthoring.php @@ -257,7 +257,7 @@ public function addNestedItem(ProjectRecordDatabase $database, int $index, array $at = $nestedList === null ? null : $database->addFrameNestedItem($index, $framePath, $parentIndex, null, $after === null ? null : $after + 1); $entry = $at === null || $nestedList === null ? null - : ($database->getFrameCommands($index, $framePath)[$parentIndex][$nestedList->key][$at] ?? null); + : (new ProjectRecord($database->getFrameCommands($index, $framePath)[$parentIndex]))->getEntries($nestedList)[$at] ?? null; if ($at === null || $nestedList === null || ! is_array($entry)) { return new RecordChange(null); diff --git a/src/Database/RecordFieldCodec.php b/src/Database/RecordFieldCodec.php index 35e1e1c..d5f2f2c 100644 --- a/src/Database/RecordFieldCodec.php +++ b/src/Database/RecordFieldCodec.php @@ -90,6 +90,9 @@ enum RecordFieldCodec: string */ case SIZE = 'size'; + /** One optional pixel crop, stored atomically as integer x, y, width and height. */ + case SOURCE_RECT = 'source_rect'; + /** * A battler slot, the Engine's BattlerSlot, stored as `['x' => x, * 'y' => y, 'width' => w, 'height' => h]` with an optional diff --git a/src/Database/RecordSchema.php b/src/Database/RecordSchema.php index c2b5277..5355516 100644 --- a/src/Database/RecordSchema.php +++ b/src/Database/RecordSchema.php @@ -90,6 +90,8 @@ public function __construct( public ?Closure $saveCheck = null, public bool $identityGiven = false, public ?Closure $commandListsFor = null, + /** @var Closure(array): list|null Selects the inline lists offered by this record's shape. */ + public ?Closure $subListsFor = null, ) { if ($projection instanceof WholeFileProjection) { // A field outside the keys the category owns would read blank and @@ -110,19 +112,22 @@ public function __construct( * * @return list */ - public function getInlineSubLists(): array + public function getInlineSubLists(array|object|null $payload = null): array { - return $this->subList === null ? $this->subLists : [$this->subList, ...$this->subLists]; + $lists = $this->subList === null ? $this->subLists : [$this->subList, ...$this->subLists]; + if ($payload === null || $this->subListsFor === null) { return $lists; } + $keys = ($this->subListsFor)((array) $payload); + return array_values(array_filter($lists, static fn(RecordSubList $list): bool => in_array($list->key, $keys, true))); } /** The inline list held under a payload key; the record's own when the key is null. */ - public function findInlineSubList(?string $key): ?RecordSubList + public function findInlineSubList(?string $key, array|object|null $payload = null): ?RecordSubList { if ($key === null) { return $this->subList; } - return array_find($this->getInlineSubLists(), static fn(RecordSubList $list): bool => $list->key === $key); + return array_find($this->getInlineSubLists($payload), static fn(RecordSubList $list): bool => $list->key === $key); } /** diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index bead8f7..7c04e2d 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -28,6 +28,7 @@ use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandDefinition; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandField; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandFieldKind; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandReference; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; use Ichiloto\Engine\Entities\Enemies\Enemy; @@ -97,7 +98,7 @@ final class RecordSchemaCatalog * * @return array */ - public static function all(): array + public static function all(bool $graphical = false): array { $schemas = [ self::classes(), @@ -121,9 +122,9 @@ public static function all(): array self::optimizeWeights(), self::optimizeOutcomes(), self::optimizeExclusions(), - self::commonEvents(), + self::commonEvents($graphical), self::terms(), - self::configuration(), + self::configuration($graphical), self::types(), self::tilesets(), self::battlerArt('actors'), @@ -1405,7 +1406,7 @@ private static function skits(): RecordSchema * * @return RecordSchema */ - private static function commonEvents(): RecordSchema + private static function commonEvents(bool $graphical = false): RecordSchema { return new RecordSchema( key: 'common_events', @@ -1422,7 +1423,7 @@ private static function commonEvents(): RecordSchema ['type' => 'text', 'name' => '', 'text' => 'Something happens.'], ], ], - subList: self::eventCommandList('commands'), + subList: self::eventCommandList('commands', $graphical), listPayloadKey: 'commands', ); } @@ -1438,7 +1439,7 @@ private static function commonEvents(): RecordSchema * * @return RecordSchema */ - private static function configuration(): RecordSchema + private static function configuration(bool $graphical = false): RecordSchema { return new RecordSchema( key: 'configuration', @@ -1452,7 +1453,14 @@ private static function configuration(): RecordSchema labelKey: 'path', identityKey: 'path', configPath: ['save', 'accessibility', 'ui', 'graphics', 'audio', 'inn'], - fieldsFor: static function (array $row): array { + subLists: $graphical ? InnPresentationFields::getBindingLists('value') : [], + subListsFor: static fn(array $row): array => ($row['path'] ?? null) === ProjectConfig::INN_PRESENTATION + && ($list = InnPresentationFields::getBindingList($row['value'] ?? null, 'value')) !== null ? [$list->key] : [], + fieldsFor: static function (array $row) use ($graphical): array { + if (($row['path'] ?? null) === ProjectConfig::INN_PRESENTATION) { + return [new RecordField('path', 'Setting', isReadOnly: true), + ...InnPresentationFields::getFields($row['value'] ?? null, 'value', 'Rest Presentation', $graphical)]; + } // A setting the engine knows is the type of its default, so an // authored value of another type is repaired on edit. $value = array_key_exists('default', $row) ? $row['default'] : ($row['value'] ?? null); @@ -1919,7 +1927,7 @@ private static function equipmentFields(EquipmentSlotType $defaultSlot, array $t * * @return RecordSchema The schema. */ - public static function mapNpcs(): RecordSchema + public static function mapNpcs(bool $graphical = false): RecordSchema { return new RecordSchema( key: 'map_npcs', @@ -1999,7 +2007,7 @@ public static function mapNpcs(): RecordSchema ), // The NPC's inline script: the shared command vocabulary, in a // frame. The runtime runs it INSTEAD of dialogue when non-empty. - commandLists: ['script' => self::eventCommandList('script')], + commandLists: ['script' => self::eventCommandList('script', $graphical)], ); } @@ -2014,7 +2022,7 @@ public static function mapNpcs(): RecordSchema * @param string $key The payload key holding the list. * @return RecordSubList The command list. */ - public static function eventCommandList(string $key): RecordSubList + public static function eventCommandList(string $key, bool $graphical = false): RecordSubList { return new RecordSubList( key: $key, @@ -2024,12 +2032,14 @@ public static function eventCommandList(string $key): RecordSubList new RecordField('type', 'Type', options: self::getEventCommandTypes()), ], blank: ['type' => 'text', 'name' => '', 'text' => 'Something happens.'], - variants: self::eventCommandVariants(), + variants: self::eventCommandVariants($graphical), variantKey: 'type', nestedLists: [ - 'move_route' => self::routeStepList(), - ...self::getRegisteredCommandLists(), + 'move_route' => MovementRouteFields::getPointList(...), + ...self::getRegisteredCommandLists($graphical), ], + prepareEdit: static fn(array $entry, string $field): array => MovementRouteFields::prepareEdit( + InnPresentationFields::prepareEdit($entry, $field), $field), ); } @@ -2089,7 +2099,7 @@ public static function effects(): RecordSchema * * @return array): RecordField[]> */ - public static function eventCommandVariants(): array + public static function eventCommandVariants(bool $graphical = false): array { return [ 'text' => [ @@ -2153,22 +2163,7 @@ public static function eventCommandVariants(): array ), ...KnowledgeCommandShape::fieldsFor(strval($entry['operation'] ?? '')), ], - 'move_route' => [ - new RecordField('subject', 'Subject', options: ['player', 'npc']), - // The stable ids of the map an author is working in; the - // picker reads the live collection, so a just-created NPC - // is offered at once. - new RecordField('npcId', 'NPC Id', reference: 'map_npcs', removeWhenEmpty: true, allowsNone: true), - new RecordField('secondsPerStep', 'Seconds Per Step', InputControlType::FLOAT, removeWhenEmpty: true), - new RecordField('speed', 'Steps Per Second', InputControlType::FLOAT, removeWhenEmpty: true), - new RecordField( - 'wait', - 'Wait For Completion', - InputControlType::BOOLEAN, - ['true'], - removeWhenEmpty: false, - ), - ], + 'move_route' => MovementRouteFields::getFields(...), 'transfer' => [ RecordField::reference('map', 'Map', 'maps'), new RecordField('x', 'X', InputControlType::INTEGER), @@ -2186,7 +2181,7 @@ public static function eventCommandVariants(): array new RecordField('conditions', 'Conditions', codec: RecordFieldCodec::CONDITIONS), // The arms become frames; see describeSubEntryFields. ], - ...self::getRegisteredCommandVariants(), + ...self::getRegisteredCommandVariants($graphical), ]; } @@ -2209,27 +2204,31 @@ public static function getEventCommandTypes(): array * A command's first list field is edited as its entries, like a route's * steps; any further list field is shown read-only and kept as written. * - * @return array + * @return array> */ - public static function getRegisteredCommandVariants(): array + public static function getRegisteredCommandVariants(bool $graphical = false): array { $variants = []; foreach (ScriptCommandRegistry::getCatalog()->definitions as $type => $definition) { - $fields = []; $listField = self::findRegisteredListField($definition); - - foreach ($definition->fields as $field) { - if ($field === $listField) { - continue; + $getFields = static function (array $entry) use ($definition, $listField, $graphical): array { + $fields = []; + foreach ($definition->fields as $field) { + if ($field === $listField) { continue; } + if ($field->reference === ScriptCommandReference::STAGE_TIMELINE) { + $value = $entry; + foreach (explode('.', $field->key) as $segment) { $value = is_array($value) ? ($value[$segment] ?? null) : null; } + array_push($fields, ...InnPresentationFields::getFields($value, $field->key, $field->label, $graphical)); + } else { + array_push($fields, ...($field->kind === ScriptCommandFieldKind::LIST + ? [new RecordField($field->key, $field->label, isReadOnly: true)] : self::describeRegisteredField($field))); + } } - - array_push($fields, ...($field->kind === ScriptCommandFieldKind::LIST - ? [new RecordField($field->key, $field->label, isReadOnly: true)] - : self::describeRegisteredField($field))); - } - - $variants[$type] = $fields; + return $fields; + }; + $variants[$type] = array_any($definition->fields, static fn(ScriptCommandField $field): bool => $field->reference === ScriptCommandReference::STAGE_TIMELINE) + ? $getFields : $getFields([]); } return $variants; @@ -2238,9 +2237,9 @@ public static function getRegisteredCommandVariants(): array /** * Returns the entries list of each registered command that has one. * - * @return array + * @return array */ - public static function getRegisteredCommandLists(): array + public static function getRegisteredCommandLists(bool $graphical = false): array { $lists = []; @@ -2248,6 +2247,14 @@ public static function getRegisteredCommandLists(): array $listField = self::findRegisteredListField($definition); if ($listField === null) { + $stageField = array_find($definition->fields, static fn(ScriptCommandField $field): bool => $field->reference === ScriptCommandReference::STAGE_TIMELINE); + if ($graphical && $stageField !== null) { + $lists[$type] = static function (array $entry) use ($stageField): ?RecordSubList { + $value = $entry; + foreach (explode('.', $stageField->key) as $segment) { $value = is_array($value) ? ($value[$segment] ?? null) : null; } + return InnPresentationFields::getBindingList($value, $stageField->key); + }; + } continue; } diff --git a/src/Database/RecordSubList.php b/src/Database/RecordSubList.php index d42a9e2..e46079b 100644 --- a/src/Database/RecordSubList.php +++ b/src/Database/RecordSubList.php @@ -47,6 +47,7 @@ * id): the field each entry carries its key as. Keys are unique and never empty. * @param string|null $valueField For a keyed list whose values are not entries of their own (a * piece's tiles: a layer name keyed to its rows): the field each entry carries its value as. + * @param Closure(array, string): array|null $prepareEdit Normalizes dependent fields after a field edit, before mutation. */ public function __construct( public string $key, @@ -65,6 +66,7 @@ public function __construct( public bool $removeWhenEmpty = false, public ?string $keyField = null, public ?string $valueField = null, + public ?Closure $prepareEdit = null, ) { } @@ -150,6 +152,9 @@ public function writeEntries(array $entries): array /** @param array $entry @return array */ public function removeConflictingFields(array $entry, string $editedField): array { + if ($this->prepareEdit !== null) { + $entry = ($this->prepareEdit)($entry, $editedField); + } if (! array_key_exists($editedField, $entry)) { return $entry; } foreach ($this->exclusiveFields as $fields) { if (! in_array($editedField, $fields, true)) { continue; } diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 1f95c90..d18e9de 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -88,6 +88,7 @@ final class ReferenceCatalog 'cinematic_cast', 'cinematic_subjects', 'cinematic_checkpoints', + 'cinematic_movement_routes', 'summon_cues', 'effect_cues', 'stage_subjects', @@ -136,6 +137,12 @@ public static function describeMedia(string $category): ?array return self::MEDIA[$category] ?? null; } + /** The same live workspace root that supplies the project image picker. */ + public function getAssetRoot(): string + { + return rtrim($this->workspace->projectRoot, '/') . '/assets'; + } + /** * @param ProjectWorkspace $workspace The project. * @param ProjectMap|null $currentMap The map the author is working in, @@ -153,6 +160,7 @@ public function __construct( private readonly ProjectWorkspace $workspace, private readonly ?ProjectMap $currentMap = null, private readonly ?CutsceneAsset $currentCutscene = null, + private readonly ?array $movementCommands = null, ) { } @@ -233,7 +241,7 @@ public function valuesFor(string $category): array // NPC ids are map-local, so the choices are the current map's: // read live from the collection, a just-created NPC is offered // at once and a deleted one is gone. - 'map_npcs' => $this->currentMap?->getNpcs()->ids() ?? [], + 'map_npcs' => $this->getSubjectMap()?->getNpcs()->ids() ?? [], // Older references name an animation; current ones store its id. 'animations' => array_map( static fn(ProjectRecord $animation): string => strval($animation->get('name')), @@ -252,6 +260,7 @@ public function valuesFor(string $category): array 'cinematic_cast' => $this->castIds(['staged_actor']), 'cinematic_subjects' => $this->subjectIds(), 'cinematic_checkpoints' => $this->checkpointIds(), + 'cinematic_movement_routes' => $this->getMovementRouteIds(), 'summon_cues' => $this->getTimelineCueIds(CutsceneType::SUMMON), 'effect_cues' => $this->getTimelineCueIds(CutsceneType::EFFECT), // A summon sequence's stage: the subjects it registers, and every @@ -447,12 +456,28 @@ private function subjectIds(): array { return array_values(array_unique([ ...$this->castIds(['staged_actor', 'npc', 'party_actor', 'player']), - ...($this->currentMap?->getNpcs()->ids() ?? []), + ...($this->getSubjectMap()?->getNpcs()->ids() ?? []), ...$this->valuesFor('actors'), ...($this->currentMap?->getEventMarkers() ?? []), ])); } + /** Without an explicitly selected map context, a cinematic owns references on its declared start map. */ + private function getSubjectMap(): ?ProjectMap + { + if ($this->currentMap !== null) { + return $this->currentMap; + } + if ($this->currentCutscene?->type === CutsceneType::CINEMATIC) { + $id = $this->currentCutscene->data()['startMap'] ?? null; + if (is_string($id) && $id !== '') { + return array_find($this->workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === $id); + } + } + + return null; + } + /** * Returns the checkpoints the current cinematic declares. * @@ -470,6 +495,45 @@ private function checkpointIds(): array ), static fn(string $checkpoint): bool => $checkpoint !== '')); } + /** A projection keeps the actual invocation as reference owner, rather than its one displayed command. */ + public function createMovementContext(array $commands): self + { + return new self($this->workspace, $this->currentMap, $this->currentCutscene, $commands); + } + + /** Live invocation-local declarations; runtime checks completion, subject and one-time consumption. */ + public function getMovementRouteIds(?ProjectRecord $record = null, array $path = []): array + { + $commands = $this->movementCommands ?? ($record === null + ? ($this->currentCutscene?->type === CutsceneType::CINEMATIC ? $this->currentCutscene->commands() : []) + : MovementRouteFields::getOwnerCommands((array) $record->toArray(), $path)); + $ids = $visited = $events = []; + foreach ($this->workspace->getRecordDatabase('common_events')?->getRecords() ?? [] as $eventRecord) { + $events[$eventRecord->recordId] = $eventRecord->getSubList('commands'); + } + $walk = static function (array $commands) use (&$walk, &$ids, &$visited, $events): void { + foreach ($commands as $value) { + if (!is_array($value)) { continue; } + if (($value['type'] ?? '') === 'move_route' && is_string($value['remember'] ?? null) + && preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/', $value['remember']) === 1 + && strtolower(trim(strval($value['subject'] ?? 'player'))) !== 'staged_actor') { + $ids[$value['remember']] = $value['remember']; + } + $eventId = is_string($value['id'] ?? null) ? trim($value['id']) : ''; + if (($value['type'] ?? '') === 'common_event' + && isset($events[$eventId]) && !isset($visited[$eventId])) { + $visited[$eventId] = true; + $walk($events[$eventId]); + } + foreach (MovementRouteFields::getChildCommandLists($value) as $child) { + $walk($child); + } + } + }; + $walk($commands); + return array_values($ids); + } + /** * Returns the stable cue ids the current summon's or effect's timeline declares. * @@ -497,13 +561,13 @@ private function getTimelineCueIds(CutsceneType $type): array } /** - * The subjects of the stage the current summon sequence declares, by id. + * The subjects of the stage the current timeline sequence declares, by id. * * @return array> */ private function getStageSubjects(): array { - if ($this->currentCutscene?->type !== CutsceneType::SUMMON) { + if (! in_array($this->currentCutscene?->type, [CutsceneType::SUMMON, CutsceneType::EFFECT], true)) { return []; } diff --git a/src/Database/StagedActorFields.php b/src/Database/StagedActorFields.php new file mode 100644 index 0000000..7206cdf --- /dev/null +++ b/src/Database/StagedActorFields.php @@ -0,0 +1,177 @@ +> $fields + * @return list> + */ + public static function describeFields(array $entry, array $fields, ?string $assetRoot): array + { + $wrapped = ($entry['type'] ?? null) === 'stage_actor' && is_array($entry['actor'] ?? null); + $actor = $wrapped ? $entry['actor'] : $entry; + if (($entry['type'] ?? null) !== 'stage_actor' && ($entry['kind'] ?? null) !== 'staged_actor') { + return $fields; + } + $sprites = $actor['sprites2d'] ?? null; + if (! is_array($sprites) || ! array_key_exists('sheet', $sprites)) { + return $fields; + } + $key = ($wrapped ? 'actor.' : '') . 'sprites2d.sheet'; + foreach ($fields as &$field) { + if (($field['sourceKey'] ?? null) === $key) { + $field['imagePreview'] = CharacterSheetPreview::describe($sprites, $assetRoot); + } + } + unset($field); + + return $fields; + } + + /** The runtime accepts inline stage commands and an explicit actor envelope. */ + public static function getCommandFields(array $command): array + { + if (! is_array($command['actor'] ?? null)) { + return CutsceneSchemas::stagedActorFields(true, $command); + } + + return array_map(static fn(RecordField $field): RecordField => new RecordField(...array_replace( + get_object_vars($field), ['key' => 'actor.' . $field->key], + )), CutsceneSchemas::stagedActorFields(true, $command['actor'])); + } + + /** @return RecordField[] */ + public static function getFields(array $entry): array + { + $contract = CinematicCommandSchema::export(); + $animation = $contract['stagedPoseAnimation']; + $fields = [ + RecordField::reference('sprites2d.asset', 'Graphical Image / Pose Sheet', 'png_assets', allowsNone: true, noneLabel: '(inherit / no graphical override)'), + RecordField::reference('sprites2d.sheet', 'Walking Character Sheet', 'png_assets', allowsNone: true, noneLabel: '(not a walking sheet)'), + new RecordField('subject.kind', 'Bind Visual To', options: ['', ...explode('|', $contract['stagedActorBinding']['subject']['kind'])], + removeWhenEmpty: true, displayDefault: '(independent staged actor)'), + RecordField::boolean('replace', 'Replace Existing Visual'), + ]; + if (($entry['subject']['kind'] ?? null) === 'npc') { + $fields[] = RecordField::reference('subject.id', 'Bound NPC', 'map_npcs'); + } + $sprites = $entry['sprites2d'] ?? []; + if (! is_array($sprites) || $sprites === []) { + return $fields; + } + $fields[] = new RecordField('sprites2d.layer', 'Graphical Layer', InputControlType::INTEGER, displayDefault: '0'); + if (isset($sprites['sheet'])) { + $fields[] = new RecordField('sprites2d.index', 'Sheet Character Index', InputControlType::INTEGER, displayDefault: '0'); + + return $fields; + } + $fields[] = new RecordField('sprites2d.cells', 'Visual Size (cells: width, height)', codec: RecordFieldCodec::SIZE, + removeWhenEmpty: true, displayDefault: '1, 1'); + $fields[] = new RecordField('sprites2d.sourceRect', 'Sheet Region (x, y, width, height)', codec: RecordFieldCodec::SOURCE_RECT, + removeWhenEmpty: true, displayDefault: '(whole image)'); + $controls = [ + 'columns' => new RecordField('sprites2d.animation.columns', 'Pose Grid Columns', InputControlType::INTEGER, displayDefault: (string) $animation['defaults']['columns']), + 'rows' => new RecordField('sprites2d.animation.rows', 'Pose Grid Rows', InputControlType::INTEGER, displayDefault: (string) $animation['defaults']['rows']), + 'frames' => new RecordField('sprites2d.animation.frames', 'Pose Frame Order (empty: static)', codec: RecordFieldCodec::CSV_INTEGERS, removeWhenEmpty: true), + 'fps' => new RecordField('sprites2d.animation.fps', 'Pose Frames Per Second', InputControlType::INTEGER, displayDefault: (string) $animation['defaultFps']), + 'loop' => RecordField::boolean('sprites2d.animation.loop', 'Loop Pose', removeWhenEmpty: false, displayDefault: $animation['defaults']['loop'] ? 'true' : 'false'), + 'restFrame' => new RecordField('sprites2d.animation.restFrame', 'Reduced-Motion Rest Cell', InputControlType::INTEGER, displayDefault: (string) $animation['defaults']['restFrame']), + ]; + foreach ($animation['fields'] as $key) { + if (isset($controls[$key])) { + $fields[] = $controls[$key]; + } + } + + return $fields; + } + + public static function getSuppressionList(array $entry): ?RecordSubList + { + $wrapped = is_array($entry['actor'] ?? null); + $entry = $wrapped ? $entry['actor'] : $entry; + if (! isset($entry['subject'])) { + return null; + } + $kinds = explode('|', CinematicCommandSchema::export()['stagedActorBinding']['subject']['kind']); + + return new RecordSubList( + key: $wrapped ? 'actor.suppress' : 'suppress', prefix: 'suppress', singular: 'suppressed subject', + fields: [new RecordField('kind', 'Subject Kind', options: $kinds)], + blank: ['kind' => 'player'], + variants: ['npc' => [RecordField::reference('id', 'NPC', 'map_npcs')]], + variantKey: 'kind', removeWhenEmpty: true, + ); + } + + /** Changing a representation clears only the fields owned by the old representation. */ + public static function prepareEdit(array $entry, string $field): array + { + if (($entry['type'] ?? null) === 'stage_actor' && is_array($entry['actor'] ?? null) && str_starts_with($field, 'actor.')) { + $actor = self::prepareEdit(['type' => 'stage_actor', ...$entry['actor']], substr($field, strlen('actor.'))); + if (! array_key_exists('type', $entry['actor'])) { + unset($actor['type']); + } + $entry['actor'] = $actor; + + return $entry; + } + if (($entry['type'] ?? null) !== 'stage_actor' && ($entry['kind'] ?? null) !== 'staged_actor') { + return $entry; + } + if ($field === 'subject.kind') { + $kind = $entry['subject']['kind'] ?? null; + if ($kind === null) { + unset($entry['subject'], $entry['suppress']); + } else { + unset($entry['x'], $entry['y'], $entry['facing'], $entry['collision']); + if ($kind === 'player') { + unset($entry['subject']['id']); + } + } + } + if (! str_starts_with($field, 'sprites2d.')) { + return $entry; + } + $sprites = $entry['sprites2d'] ?? []; + if ($field === 'sprites2d.sheet' || $field === 'sprites2d.asset') { + $key = substr($field, strlen('sprites2d.')); + if (! isset($sprites[$key])) { + // Clearing an already absent alternate picker must not erase the active form. + if (($key === 'sheet' && isset($sprites['asset'])) || ($key === 'asset' && isset($sprites['sheet']))) { + return $entry; + } + unset($entry['sprites2d']); + + return $entry; + } + if ($key === 'sheet') { + unset($sprites['asset'], $sprites['cells'], $sprites['sourceRect'], $sprites['animation']); + } else { + unset($sprites['sheet'], $sprites['index']); + } + } + if ($field === 'sprites2d.animation.frames' && ! isset($sprites['animation']['frames'])) { + unset($sprites['animation']); + } elseif (str_starts_with($field, 'sprites2d.animation.')) { + $sprites['animation']['frames'] ??= [0]; + } + // Use runtime validation rather than a second copy of its bounds and shape rules. + CinematicStageManager::getGraphicalSprites($sprites); + $entry['sprites2d'] = $sprites; + + return $entry; + } +} diff --git a/src/Editor.php b/src/Editor.php index 0117a35..e7ca1bf 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -6591,12 +6591,7 @@ private function performExplicitMapMove(): void */ private function deriveProposedMapId(ProjectMap $map): string { - $name = strtolower(trim((string) preg_replace('/[^A-Za-z0-9]+/', '-', $map->getDisplayName()))); - $name = trim($name, '-') !== '' ? trim($name, '-') : basename($map->directory); - $region = strtolower(trim((string) preg_replace('/[^A-Za-z0-9]+/', '-', $map->getRegion()))); - $region = trim($region, '-'); - - return $region !== '' ? $region . '/' . $name : $name; + return $map->getProposedMapId(); } /** @@ -13550,10 +13545,13 @@ private function inspectorPaneLayout(?array $fields = null): SettingsPaneLayout foreach ($fields as $index => $field) { $editing = $this->isInspectorEditing && $index === $this->selectedInspectorFieldIndex; + $value = (string) ($field['value'] ?? ''); + $choice = array_find((array) ($field['choices'] ?? []), + static fn(array $choice): bool => (string) $choice['value'] === $value); $rows[] = [ 'prefix' => $this->focusedPane === self::FOCUS_INSPECTOR && $index === $this->selectedInspectorFieldIndex ? '> ' : ' ', 'label' => (string) ($field['label'] ?? 'Field'), - 'value' => $editing ? $this->inspectorEditBuffer : (string) ($field['value'] ?? ''), + 'value' => $editing ? $this->inspectorEditBuffer : (string) ($choice['label'] ?? $value), 'editable' => $editing || $this->isInspectorFieldInteractive($field), 'singleLine' => $editing, ]; diff --git a/src/Events/ProjectScriptCommands.php b/src/Events/ProjectScriptCommands.php index 0e8b48e..5f97e4e 100644 --- a/src/Events/ProjectScriptCommands.php +++ b/src/Events/ProjectScriptCommands.php @@ -90,6 +90,7 @@ public static function getReferenceCategory(ScriptCommandReference $reference): ScriptCommandReference::TROOP => 'troops', ScriptCommandReference::QUEST => 'quests', ScriptCommandReference::ACTOR => 'actors', + ScriptCommandReference::STAGE_TIMELINE => 'stage_timelines', }; } } diff --git a/src/Field/CharacterSheetPreview.php b/src/Field/CharacterSheetPreview.php new file mode 100644 index 0000000..042bd54 --- /dev/null +++ b/src/Field/CharacterSheetPreview.php @@ -0,0 +1,53 @@ +, issue?: string} */ + public static function describe(mixed $data, ?string $assetRoot): array + { + try { + if (! is_array($data)) { + throw new InvalidArgumentException('A character sheet requires sheet, index and layer data.'); + } + if ($assetRoot === null) { + throw new RuntimeException('The current owner has no project asset root for character-sheet preview.'); + } + $sheet = NpcCharacterSheet::validate($data); + $image = PngAssetPreflight::inspect($assetRoot, $sheet->asset); + $size = $sheet->getFrameSize($image['width'], $image['height']); + $frames = []; + foreach (['South' => MovementHeading::SOUTH, 'West' => MovementHeading::WEST, + 'East' => MovementHeading::EAST, 'North' => MovementHeading::NORTH] as $label => $heading) { + $frames[] = ['label' => $label, 'sourceRect' => $sheet->getFrame($heading, 1, $size)->sourceRect->toArray()]; + } + + return ['frames' => $frames]; + } catch (InvalidArgumentException|RuntimeException $issue) { + return ['frames' => [], 'issue' => $issue->getMessage()]; + } + } + + /** @return list Character identity choices, independent of stale optional index/layer values. */ + public static function getIndexOptions(string $asset): array + { + try { + $sheet = new CharacterSheet($asset); + + return array_map(strval(...), range(0, $sheet->isSingleCharacter ? 0 + : CharacterSheet::SHEET_CHARACTER_COLUMNS * CharacterSheet::SHEET_CHARACTER_ROWS - 1)); + } catch (InvalidArgumentException) { + return []; + } + } +} diff --git a/src/Field/NpcAuthoring.php b/src/Field/NpcAuthoring.php index dadff67..18b0336 100644 --- a/src/Field/NpcAuthoring.php +++ b/src/Field/NpcAuthoring.php @@ -403,6 +403,30 @@ public function setFieldSheet(ProjectMap $map, int $index, ?string $sheet): NpcC return $this->createRowChange($map, 'NPC field sheet', $before, $index); } + public function setFieldSheetIndex(ProjectMap $map, int $index, int $characterIndex): NpcChange + { + return $this->setFieldSheetValue($map, $index, 'index', $characterIndex); + } + + public function setFieldSheetLayer(ProjectMap $map, int $index, int $layer): NpcChange + { + return $this->setFieldSheetValue($map, $index, 'layer', $layer); + } + + /** Changes only the selected sheet setting through the map's validated, source-safe writer. */ + private function setFieldSheetValue(ProjectMap $map, int $index, string $field, int $value): NpcChange + { + $before = $map->getNpcs(); + $npc = $this->requireNpc($map, $before, $index); + $current = $npc->toArray()['sprites2d'] ?? null; + if (! is_array($current) || ! is_string($current['sheet'] ?? null) || $current['sheet'] === '') { + throw new NpcRefusal('Select an NPC field sheet before editing its character index or graphical layer.'); + } + $map->setNpcGraphicalSprites($index, [...$current, $field => $value]); + + return $this->createRowChange($map, 'NPC field sheet ' . $field, $before, $index); + } + /** The change a row edit made to one NPC, with its command when it changed anything. */ private function createRowChange(ProjectMap $map, string $label, NpcCollection $before, int $index): NpcChange { diff --git a/src/Field/NpcInspector.php b/src/Field/NpcInspector.php index 59372ab..8d2cc2b 100644 --- a/src/Field/NpcInspector.php +++ b/src/Field/NpcInspector.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\RecordSchemaCatalog; +use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\ProjectMap; /** @@ -53,7 +54,7 @@ final class NpcInspector private ProjectRecordDatabase $records; - public function __construct(public readonly ProjectMap $map) + public function __construct(public readonly ProjectMap $map, private readonly bool $graphical = false, private readonly ?ReferenceCatalog $references = null) { $this->records = $this->build(); } @@ -260,8 +261,8 @@ private function build(): ProjectRecordDatabase $entries[] = $entry; } - return ProjectRecordDatabase::overOwnedList( - RecordSchemaCatalog::mapNpcs(), + $records = ProjectRecordDatabase::overOwnedList( + RecordSchemaCatalog::mapNpcs($this->graphical), $this->map->dataPath, $entries, function (array $written): void { @@ -277,6 +278,9 @@ function (array $written): void { $this->map->setNpcs(NpcCollection::fromMapData($stored)); }, + graphical: $this->graphical, ); + if ($this->references !== null) { $records->useAuthoringReferences($this->references); } + return $records; } } diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index f892919..ca192ab 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -6,6 +6,13 @@ use Ichiloto\Editor\Database\ConditionCodec; use Ichiloto\Editor\Database\ReferenceCatalog; +use Ichiloto\Editor\Database\InnPresentationFields; +use Ichiloto\Editor\Database\MovementRouteFields; +use Ichiloto\Editor\Database\ProjectRecord; +use Ichiloto\Editor\Database\ProjectRecordDatabase; +use Ichiloto\Editor\Database\RecordField; +use Ichiloto\Editor\Database\RecordFieldCodec; +use Ichiloto\Editor\Database\RecordSchemaCatalog; use Ichiloto\Editor\Events\EventAuthoring; use Ichiloto\Editor\Events\EventRefusal; use Ichiloto\Editor\Events\EventTypeCatalog; @@ -17,8 +24,11 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Engine\Events\Enumerations\ChestType; use Ichiloto\Engine\Events\Enumerations\LootType; +use Ichiloto\Engine\Events\Triggers\EventCueKind; use Ichiloto\Engine\Field\MapGraphics; use Ichiloto\Engine\Rendering\Tilesets\Tileset; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandReference; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; use InvalidArgumentException; use Throwable; @@ -48,6 +58,13 @@ public const string MAP_ARENA_NONE = '(default arena)'; public const string ENCOUNTER_ARENA_NONE = '(map arena)'; + /** Classification is authored explicitly, never inferred from cue presentation. */ + public const array EVENT_CUE_KIND_CHOICES = [ + ['label' => 'Unclassified (clear kind)', 'value' => '', 'description' => 'Omit kind, preserving legacy unclassified guidance and the rest of the cue.'], + ['label' => 'Story', 'value' => EventCueKind::STORY->value, 'description' => 'Story guidance that persists at the screen edge.'], + ['label' => 'Route', 'value' => EventCueKind::ROUTE->value, 'description' => 'Route guidance that belongs to its map cell.'], + ]; + /** A chest event's presentations, as an author picks one. */ public const array CHEST_TYPE_CHOICES = [ ['label' => 'Common', 'value' => ChestType::COMMON->value, 'description' => 'Standard chest presentation for ordinary treasure.'], @@ -217,6 +234,7 @@ public function getEventFields(ProjectMap $map, string $marker): array if ($definition !== null) { $fields = [...$fields, ...$this->buildEventDataFields($marker, $definition)]; + $fields = \Ichiloto\Editor\Maps\MapPlacement::describeEventFields($definition, $fields); } return $fields; @@ -234,6 +252,12 @@ public function getEventFields(ProjectMap $map, string $marker): array */ public function apply(ProjectMap $map, array $field, string $rawValue): ?Command { + if (($field['innPresentation'] ?? false) === true) { + return $this->applyInnPresentationField($map, $field, $rawValue); + } + if (isset($field['movementRouteField'])) { + return $this->applyMovementRouteField($map, $field, $rawValue); + } if (($field['destination'] ?? false) === true) { throw new InspectorRefusal('A destination is set with its spawn point; choose both together.'); } @@ -341,6 +365,7 @@ public function setTransferDestination(ProjectMap $map, string $marker, ProjectM */ public function addListEntry(ProjectMap $map, array $field): ?InspectorListEdit { + if (isset($field['innPresentationList'])) { return $this->editInnPresentationList($map, $field, false); } if (($field['target'] ?? null) === 'map-encounters' && is_array($field['encounterList'] ?? null)) { $encounters = $this->requireEditableEncounters($map); $troops = $this->references->valuesFor('troops'); @@ -372,9 +397,10 @@ public function addListEntry(ProjectMap $map, array $field): ?InspectorListEdit // The new entry is shaped like the one it follows -- the same keys, // their values cleared -- because an author adding a second shop // line means another line like the first, not an empty hole. - $template = $entries[$list['index']] ?? ($list['blank'] ?? ($entries === [] ? '' : end($entries))); + $routePoints = ($field['movementRoutePoints'] ?? false) === true; + $template = $routePoints ? $list['blank'] : ($entries[$list['index']] ?? ($list['blank'] ?? ($entries === [] ? '' : end($entries)))); $position = min(count($entries), $list['index'] + 1); - array_splice($entries, $position, 0, [self::createBlankLike($template)]); + array_splice($entries, $position, 0, [$routePoints ? $template : self::createBlankLike($template)]); return new InspectorListEdit($this->writeEventList($map, $marker, $list['path'], $entries, 'Add list entry'), sprintf('Added %s %d.', self::describeListPath($list['path']), $position + 1)); @@ -390,6 +416,7 @@ public function addListEntry(ProjectMap $map, array $field): ?InspectorListEdit */ public function removeListEntry(ProjectMap $map, array $field): ?InspectorListEdit { + if (isset($field['innPresentationList'])) { return $this->editInnPresentationList($map, $field, true); } if (($field['target'] ?? null) === 'map-encounters' && is_array($field['encounterList'] ?? null)) { $encounters = $this->requireEditableEncounters($map); $index = (int) $field['encounterList']['index']; @@ -672,6 +699,9 @@ private function applyEventField(ProjectMap $map, array $field, mixed $value): ? { $marker = (string) $field['marker']; $path = (array) ($field['path'] ?? []); + if ($path === ['cue', 'kind']) { + return $this->applyEventCueKind($map, $marker, $value); + } $held = $map->hasEventField($marker, $path); $oldValue = $map->getEventField($marker, $path); // Optional data cleared is removed, as it was before it was set. @@ -690,6 +720,33 @@ private function applyEventField(ProjectMap $map, array $field, mixed $value): ? return new GenericCommand(sprintf('%s edit', $field['label'] ?? 'Event field'), $apply, $restore); } + /** Kind edits own the cue's optional parent so undo also restores its omission. */ + private function applyEventCueKind(ProjectMap $map, string $marker, mixed $value): ?Command + { + if (! is_string($value) || ($value !== '' && EventCueKind::tryFrom($value) === null)) { + throw new InspectorRefusal('Choose story or route, or clear the cue kind.'); + } + $held = $map->hasEventField($marker, ['cue']); + $old = $map->getEventField($marker, ['cue']); + if ($old !== null && ! is_array($old)) { + throw new InspectorRefusal('The event cue must be an array before its kind can be edited.'); + } + $next = $old ?? []; + if ($value === '') { + if (! array_key_exists('kind', $next)) { return null; } + unset($next['kind']); + } else { + $next['kind'] = $value; + } + if ($old === $next) { return null; } + $apply = static fn() => $map->setEventField($marker, ['cue'], $next); + $restore = $held ? static fn() => $map->setEventField($marker, ['cue'], $old) + : static fn() => $map->removeEventField($marker, ['cue']); + $apply(); + + return new GenericCommand('Cue kind edit', $apply, $restore); + } + /** * Moves or resizes an event through {@see EventAuthoring}, as the canvas * does. Its position moves every cell, keeping its shape; a marker @@ -1127,6 +1184,9 @@ public function buildEventDataFields(string $marker, array $definition): array // The type's optional data is offered as it reads when absent, // and stays out of the stored event until an author sets it. $optional = EventTypeCatalog::findByClass(is_string($definition['class'] ?? null) ? $definition['class'] : null)?->optionalData ?? []; + $hasPresentation = array_key_exists('presentation', $optional) || array_key_exists('presentation', $eventData); + $presentation = $eventData['presentation'] ?? null; + if ($hasPresentation) { unset($eventData['presentation'], $optional['presentation']); } $fields = array_map(static function (array $field) use ($optional): array { $path = array_values((array) ($field['path'] ?? [])); if (count($path) === 2 && array_key_exists((string) $path[1], $optional)) { @@ -1138,6 +1198,7 @@ public function buildEventDataFields(string $marker, array $definition): array $marker, $this->flattenInspectorFields([...$eventData, ...array_diff_key($optional, $eventData)], ['data']), )); + if ($hasPresentation) { $fields = [...$fields, ...$this->buildInnPresentationFields($marker, $presentation)]; } } $rootFields = array_diff_key($definition, ['class' => true, 'data' => true]); @@ -1146,16 +1207,206 @@ public function buildEventDataFields(string $marker, array $definition): array // default exposes the opt-in without changing the stored event until // the author actually edits it. $rootFields['cue'] ??= ['symbol' => '', 'color' => 'bright-yellow']; + $cueKindFields = []; + if (is_array($rootFields['cue'])) { + $hasKind = array_key_exists('kind', $rootFields['cue']); + $kind = $hasKind ? $rootFields['cue']['kind'] : ''; + $validKind = ! $hasKind || (is_string($kind) && EventCueKind::tryFrom($kind) !== null); + // A malformed kind remains a single repairable property, not a + // nested array of freely editable fields. Only this display copy changes. + unset($rootFields['cue']['kind']); + $cueKindFields[] = [ + 'label' => 'Cue Kind', + 'value' => $validKind ? $kind : '(invalid kind)', + 'selectedValue' => $validKind ? $kind : '(invalid kind)', + 'optionLabels' => array_column(self::EVENT_CUE_KIND_CHOICES, 'label', 'value'), + 'path' => ['cue', 'kind'], + 'optional' => true, + ]; + } return [ ...$fields, ...$this->decorateEventInspectorFields( $marker, - $this->flattenInspectorFields($rootFields, []), + [...$this->flattenInspectorFields($rootFields, []), ...$cueKindFields], ), ]; } + /** Inline routes use the same schema/coercion as command records; the map remains their owner. */ + private function projectMovementRoute(array $route, ?array $ownerCommands = null): ProjectRecordDatabase + { + $schema = RecordSchemaCatalog::forKey('common_events') + ?? throw new \LogicException('The event command schema is unavailable.'); + $records = ProjectRecordDatabase::overOwnedList($schema, '', [['commands' => [$route]]], static function (array $entries): void {}, $this->graphical); + $records->useAuthoringReferences($ownerCommands === null ? $this->references + : $this->references->createMovementContext($ownerCommands)); + return $records; + } + + /** @return list> */ + private function buildMovementRouteFields(array $route, array $path, ?array $parentList): array + { + $fields = []; + $points = MovementRouteFields::getPointList($route); + foreach ($this->projectMovementRoute($route)->getSettingsFields(0) as $row) { + if (! isset($row['dataPath']) || ($row['sourceKey'] ?? '') === 'type') { continue; } + $relative = array_slice($row['dataPath'], 2); + $row['path'] = [...$path, ...array_map('strval', $relative)]; + $row['movementRouteField'] = $row['field']; + $row['movementRoutePath'] = $path; + unset($row['mapPlacement']); + if (count($relative) > 1 && $points !== null) { + $row['movementRoutePoints'] = true; + $row['list'] = ['path' => [...$path, $points->key], 'index' => (int) $relative[1], 'blank' => $points->blank]; + } elseif ($parentList !== null) { $row['list'] = $parentList; } + $fields[] = $row; + } + if ($points !== null) { + $entries = $points->readEntries($route[$points->key] ?? []); + $fields[] = ['label' => ucfirst($points->key), 'value' => strval(count($entries)), 'editable' => false, + 'path' => [...$path, $points->key], 'movementRoutePath' => $path, 'movementRoutePoints' => true, + 'list' => ['path' => [...$path, $points->key], 'index' => count($entries) - 1, 'blank' => $points->blank]]; + } + return $fields; + } + + private function applyMovementRouteField(ProjectMap $map, array $field, string $raw): ?Command + { + $marker = (string) $field['marker']; + $path = (array) $field['movementRoutePath']; + $old = $map->getEventField($marker, $path); + if (! is_array($old) || ($old['type'] ?? '') !== 'move_route') { + throw new InspectorRefusal('That movement route is no longer present.'); + } + try { + $commands = MovementRouteFields::getOwnerCommands($map->getEventDefinition($marker) ?? [], $path); + $records = $this->projectMovementRoute($old, $commands); + $records->setField(0, (string) $field['movementRouteField'], $raw); + $next = $records->getRecordByIndex(0)?->getSubList('commands')[0] ?? $old; + } catch (\Ichiloto\Editor\Database\RecordRefusal|InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + if ($old === $next) { return null; } + $apply = static fn() => $map->setEventField($marker, $path, $next); + $apply(); + return new GenericCommand('Movement route edit', $apply, static fn() => $map->setEventField($marker, $path, $old)); + } + + /** The same actor/stage pickers and binding lists that Configuration and commands use. */ + private function buildInnPresentationFields(string $marker, mixed $value, array $presentationPath = ['data', 'presentation']): array + { + $fields = []; + foreach (InnPresentationFields::getFields($value, 'presentation', 'Rest Presentation', $this->graphical) as $field) { + $selected = $field->key === 'presentation' ? $value : (is_array($value) ? ($value['treatment'] ?? '') : ''); + $path = $field->key === 'presentation' ? $presentationPath : [...$presentationPath, 'treatment']; + $fields[] = [...$this->describeInnField($marker, $field, $selected, $path), 'innPresentationPath' => $presentationPath]; + } + $list = $this->graphical ? InnPresentationFields::getBindingList($value, 'presentation') : null; + if ($list === null) { return $fields; } + $entries = $list->readEntries($value[substr($list->key, strlen('presentation.'))] ?? []); + $context = ['key' => $list->key, 'index' => -1]; + $listPath = [...$presentationPath, substr($list->key, strlen('presentation.'))]; + $fields[] = ['label' => $list->heading, 'value' => (string) count($entries), 'editable' => false, + 'target' => 'event', 'marker' => $marker, 'path' => $listPath, 'innPresentationPath' => $presentationPath, + 'innPresentationList' => $context, 'list' => ['path' => $listPath, 'index' => -1]]; + foreach ($entries as $index => $entry) { + if (! is_array($entry)) { continue; } + foreach ($list->fieldsFor($entry) as $field) { + $row = $this->describeInnField($marker, $field, $entry[$field->key] ?? '', + [...$listPath, (string) $index, $field->key]); + $row['innPresentationPath'] = $presentationPath; + $row['innPresentationList'] = [...$context, 'index' => $index]; + $row['innEntryField'] = $field->key; + $row['list'] = ['path' => $listPath, 'index' => $index]; + $fields[] = $row; + } + } + return $fields; + } + + private function describeInnField(string $marker, RecordField $field, mixed $value, array $path): array + { + $display = $field->codec === RecordFieldCodec::CSV_LIST && is_array($value) ? implode(', ', $value) : ProjectRecord::stringify($value); + $row = ['label' => $field->label, 'value' => $display, 'selectedValue' => $display, 'path' => $path, + 'marker' => $marker, 'target' => 'event', 'innPresentation' => true, 'optional' => $field->allowsNone]; + if ($field->isReadOnly) { $row['editable'] = false; } + elseif ($field->reference !== null) { + $row['reference'] = $field->reference; + $row['multi'] = $field->codec === RecordFieldCodec::CSV_LIST; + } else { $row['options'] = $field->options; } + return $row; + } + + private function applyInnPresentationField(ProjectMap $map, array $field, string $raw): ?Command + { + if (($field['editable'] ?? true) === false) { throw new InspectorRefusal('Graphical rest bindings are preserved read-only in Terminal.'); } + $marker = (string) $field['marker']; + $presentationPath = $field['innPresentationPath']; + $old = $map->getEventField($marker, $presentationPath); + $record = new ProjectRecord(['presentation' => $old]); + if (isset($field['innEntryField'])) { + if (! $this->graphical) { throw new InspectorRefusal('Graphical rest bindings are not authored in Terminal.'); } + $list = InnPresentationFields::getBindingList($old, 'presentation'); + $index = (int) ($field['innPresentationList']['index'] ?? -1); + $entries = $list?->readEntries(is_array($old) ? ($old[substr($list->key, strlen('presentation.'))] ?? []) : []) ?? []; + if ($list === null || ! is_array($entries[$index] ?? null)) { throw new InspectorRefusal('That binding is no longer present.'); } + $key = (string) $field['innEntryField']; + $selected = ($field['multi'] ?? false) === true ? array_values(array_filter(array_map(trim(...), explode(',', $raw)), static fn(string $id): bool => $id !== '')) : trim($raw); + if ($key === $list->keyField && array_any($entries, static fn(array $entry, int $at): bool => $at !== $index && ($entry[$key] ?? null) === $selected)) { + throw new InspectorRefusal('That actor already has a leader binding.'); + } + $entries[$index][$key] = $selected; + $record->setEntries($list, $entries); + } else { + $path = array_slice((array) $field['path'], count($presentationPath)); + if ($path !== [] && ! $this->graphical) { throw new InspectorRefusal('Graphical rest bindings are not authored in Terminal.'); } + $record->set(implode('.', ['presentation', ...$path]), $raw === '' ? null : $raw); + } + return $this->writeInnPresentation($map, $marker, $record->get('presentation'), 'Rest presentation edit', $presentationPath); + } + + private function editInnPresentationList(ProjectMap $map, array $field, bool $remove): ?InspectorListEdit + { + if (! $this->graphical) { throw new InspectorRefusal('Graphical rest bindings are not authored in Terminal.'); } + $marker = (string) $field['marker']; + $presentationPath = $field['innPresentationPath']; + $value = $map->getEventField($marker, $presentationPath); + $list = InnPresentationFields::getBindingList($value, 'presentation'); + if ($list === null || $list->key !== ($field['innPresentationList']['key'] ?? null)) { throw new InspectorRefusal('That binding list is no longer present.'); } + $record = new ProjectRecord(['presentation' => $value]); + $entries = $record->getEntries($list); + $at = (int) $field['innPresentationList']['index']; + if ($remove) { + if (! array_key_exists($at, $entries)) { return null; } + array_splice($entries, $at, 1); + } else { + if ($list->keyField !== null && array_any($entries, static fn(array $entry): bool => ($entry[$list->keyField] ?? '') === '')) { + throw new InspectorRefusal('Finish choosing the unbound leader before adding another binding.'); + } + array_splice($entries, min(count($entries), $at + 1), 0, [$list->blank]); + } + $record->setEntries($list, $entries); + return new InspectorListEdit($this->writeInnPresentation($map, $marker, $record->get('presentation'), $remove ? 'Rest binding remove' : 'Rest binding add', $presentationPath), + $remove ? 'Removed rest binding.' : 'Added rest binding; choose its guests and stage before saving.'); + } + + private function writeInnPresentation(ProjectMap $map, string $marker, mixed $value, string $label, array $presentationPath): ?Command + { + try { InnPresentationFields::assertValid($value, $this->references, complete: false); } + catch (InvalidArgumentException $error) { throw new InspectorRefusal($error->getMessage(), previous: $error); } + $old = $map->getEventField($marker, $presentationPath); + if ($old === $value) { return null; } + $held = $map->hasEventField($marker, $presentationPath); + $apply = $value === null ? static fn() => $map->removeEventField($marker, $presentationPath) + : static fn() => $map->setEventField($marker, $presentationPath, $value); + $undo = $held ? static fn() => $map->setEventField($marker, $presentationPath, $old) + : static fn() => $map->removeEventField($marker, $presentationPath); + $apply(); + return new GenericCommand($label, $apply, $undo); + } + /** * Adds event context, pickers, and enum controls to flattened fields. * @@ -1178,6 +1429,7 @@ private function decorateEventInspectorFields(string $marker, array $fields): ar } $choices = match ($path) { + ['cue', 'kind'] => self::EVENT_CUE_KIND_CHOICES, ['data', 'chestType'] => self::CHEST_TYPE_CHOICES, ['data', 'lootType'] => self::LOOT_TYPE_CHOICES, default => null, @@ -1230,6 +1482,29 @@ private static function formatFieldLabel(string $part): string private function flattenInspectorFields(array $data, array $path, ?array $list = null): array { $fields = []; + $definition = is_string($data['type'] ?? null) ? (ScriptCommandRegistry::getCatalog()->definitions[$data['type']] ?? null) : null; + foreach ($definition?->fields ?? [] as $field) { + if ($field->reference !== ScriptCommandReference::STAGE_TIMELINE) { continue; } + $segments = explode('.', $field->key); + $value = new ProjectRecord($data)->get($field->key); + $fields = [...$fields, ...$this->buildInnPresentationFields('', $value, [...$path, ...$segments])]; + // This is only the display copy; the map owner retains every authored key. + $parent = &$data; + $present = true; + foreach (array_slice($segments, 0, -1) as $segment) { + if (! is_array($parent[$segment] ?? null)) { $present = false; break; } + $parent = &$parent[$segment]; + } + if ($present) { unset($parent[end($segments)]); } + unset($parent); + } + + if (($data['type'] ?? '') === 'move_route') { + $fields = [...$fields, ...$this->buildMovementRouteFields($data, $path, $list)]; + // Only the display projection is removed; unrecognised authored keys stay visible and intact. + foreach (MovementRouteFields::getFields($data) as $field) { unset($data[$field->key]); } + unset($data['steps'], $data['waypoints'], $data['retrace']); + } foreach ($data as $key => $value) { $segment = (string) $key; diff --git a/src/Maps/ConnectedPiecePreview.php b/src/Maps/ConnectedPiecePreview.php new file mode 100644 index 0000000..1daa3a6 --- /dev/null +++ b/src/Maps/ConnectedPiecePreview.php @@ -0,0 +1,83 @@ +> */ + public static function describe(Tileset $tileset, TilesetPiece $piece, string $assetRoot, array $sheetIssues = []): array + { + if ($piece->connects === null) { + return []; + } + $previews = []; + $usedIssues = []; + foreach ($piece->shapeTiles as $shapes) { + foreach ($shapes as $tile) { + $sheet = TileId::getSheet((int) $tile)?->value; + if ((int) $tile !== TileId::EMPTY && isset($sheetIssues[$sheet])) { + $usedIssues[$sheet] = $sheetIssues[$sheet]; + } + } + } + foreach (self::buildSamples($piece) as $sample) { + $world = null; + $issue = $usedIssues === [] ? null : implode(' ', $usedIssues); + try { + $world = TilePalette::buildPieceWorld($tileset, $sample['piece'], $assetRoot, 'connection:' . $piece->id . ':' . $sample['mask']); + } catch (\Throwable $error) { + $issue = $error->getMessage(); + } + $previews[] = ['mask' => $sample['mask'], 'label' => $sample['label'], 'shape' => $sample['shape'], + 'picture' => PiecePlacer::buildPicture($sample['piece']), 'operations' => $world?->operations, 'issue' => $issue]; + } + + return $previews; + } + + /** @return list */ + public static function buildSamples(TilesetPiece $piece): array + { + if ($piece->connects === null) { + return []; + } + $samples = []; + $sources = $piece->getSourceShapeGrid(); + for ($mask = 0; $mask < 16; ++$mask) { + // The empty border prevents Engine's beyond-map-edge rule joining these samples. + $drawn = [['x' => 2, 'y' => 2]]; + $labels = []; + foreach ([[0, -1, 'N'], [1, 0, 'E'], [0, 1, 'S'], [-1, 0, 'W']] as $bit => [$dx, $dy, $label]) { + if (($mask & (1 << $bit)) !== 0) { + $drawn[] = ['x' => 2 + $dx, 'y' => 2 + $dy]; + $labels[] = $label; + } + } + $cells = ConnectedPieceShaper::reshapeCells($piece, $drawn, [], static fn(int $x, int $y): bool => false); + $glyphs = array_fill(0, 5, array_fill(0, 5, ' ')); + $tiles = array_fill_keys(array_keys($piece->shapeTiles), array_fill(0, 5, array_fill(0, 5, 0))); + foreach ($cells as $cell) { + $glyphs[$cell['y']][$cell['x']] = $sources[$cell['shape']]; + foreach ($piece->shapeTiles as $layer => $shapes) { + $tiles[$layer][$cell['y']][$cell['x']] = (int) $shapes[$cell['shape']]; + } + } + $tiles = array_map(static fn(array $grid): array => array_map( + static fn(array $row): array => array_map(strval(...), $row), AutotileShape::resolveLayer($grid)), $tiles); + $samples[] = ['mask' => $mask, 'label' => $labels === [] ? 'Isolated' : implode(' + ', $labels), + 'shape' => $cells[0]['shape'], 'piece' => new TilesetPiece($piece->id, $piece->name, $piece->layer, $glyphs, $tiles)]; + } + + return $samples; + } +} diff --git a/src/Maps/LineInsertionPlan.php b/src/Maps/LineInsertionPlan.php index db8057a..f162fe1 100644 --- a/src/Maps/LineInsertionPlan.php +++ b/src/Maps/LineInsertionPlan.php @@ -25,6 +25,8 @@ public function __construct( private readonly SourceSetPlan $sources, public readonly array $handEdits, public readonly array $notes, + public readonly int $width, + public readonly int $height, ) { } diff --git a/src/Maps/LineInsertionPlanner.php b/src/Maps/LineInsertionPlanner.php index 887c1bc..e5f03d8 100644 --- a/src/Maps/LineInsertionPlanner.php +++ b/src/Maps/LineInsertionPlanner.php @@ -145,7 +145,7 @@ private static function buildPlan(string $root, LineInsertion $insertion): LineI 'Map and project data files', ); - return new LineInsertionPlan($insertion, $sources, $handEdits, $notes); + return new LineInsertionPlan($insertion, $sources, $handEdits, $notes, $map->getWidth(), $map->getHeight()); } /** diff --git a/src/Maps/MapLayers.php b/src/Maps/MapLayers.php index cc3b569..7ede37c 100644 --- a/src/Maps/MapLayers.php +++ b/src/Maps/MapLayers.php @@ -687,6 +687,18 @@ public function assertSourcesUnchanged(): void } } + /** Retired/renamed members remain read dependencies until a successful save. */ + public function expectSources(FileSetTransaction $transaction): void + { + $recoverFolder = ! is_dir($this->directory); + foreach ($this->baselineSources as $path => $source) { + $transaction->expectSource($path, $source, allowMissing: $recoverFolder); + } + foreach (array_diff_key($this->getSources(), $this->baselineSources) as $path => $source) { + $transaction->expectSource($path, null); + } + } + public function getSources(?string $directory = null, ?string $baseName = null): array { $sources = []; diff --git a/src/Maps/MapPlacement.php b/src/Maps/MapPlacement.php new file mode 100644 index 0000000..352356c --- /dev/null +++ b/src/Maps/MapPlacement.php @@ -0,0 +1,290 @@ + $field) { + $fieldPath = $field['path'] ?? []; + if (array_slice($fieldPath, 0, count($path)) !== array_map(strval(...), $path)) { + continue; + } + $tail = array_slice($fieldPath, count($path)); + $entry = [0]; + if (in_array($tail[0] ?? '', ['steps', 'waypoints', 'points'], true) && ctype_digit(strval($tail[1] ?? ''))) { + $entry[] = (int) $tail[1]; + $tail = array_slice($tail, 2); + } + $original[] = $i; + $commandFields[] = [...$field, 'entry' => $entry, 'sourceKey' => implode('.', $tail), 'dataPath' => $fieldPath, 'field' => json_encode($fieldPath, JSON_THROW_ON_ERROR)]; + } + foreach (self::describeFields($value, $commandFields) as $i => $field) { + if (isset($field['mapPlacement'])) { + $fields[$original[$i]]['mapPlacement'] = $field['mapPlacement']; + } + } + } + foreach ($value as $key => $child) { + if (is_array($child)) { + $walk($child, [...$path, $key]); + } + } + }; + $walk($definition, []); + foreach ($fields as &$field) { + if (($field['destination'] ?? false) === true) { + $data = $definition['data'] ?? []; + $position = $data['spawnPoint'] ?? []; + $valid = self::isMapPoint($position) && is_string($data['destinationMap'] ?? null); + $field['mapPlacement'] = ['kind' => 'event-transfer', 'map' => is_string($data['destinationMap'] ?? null) ? $data['destinationMap'] : null, + 'points' => [['point' => $valid ? [$position['x'], $position['y']] : [0, 0], + 'fields' => [], 'label' => 'Arrival']], + 'sourcePaths' => [['data', 'destinationMap'], ['data', 'spawnPoint', 'x'], ['data', 'spawnPoint', 'y']], + 'issue' => $valid ? null : 'The arrival needs a map reference and literal integer x/y coordinates.']; + } + } + return $fields; + } + + /** @param array $entry @param list> $fields */ + public static function describeFields(array $entry, array $fields): array + { + $own = []; + $nested = []; + foreach ($fields as $i => $field) { + $path = $field['entry'] ?? []; + if (!isset($field['sourceKey'], $field['field'])) { + continue; + } + if (count($path) === 1) { + $own[$field['sourceKey']] = $i; + } elseif (count($path) === 2) { + $nested[$path[1]][$field['sourceKey']] = $i; + } + } + $keys = static fn(array $indexes): array => array_map(static fn(int $i): string => $fields[$i]['field'], $indexes); + $type = $entry['type'] ?? ''; + if ($type === 'move_route' && isset($own['subject'])) { + $steps = []; + $x = $y = 0; + $issue = null; + try { + MovementRouteRunner::validatePathOptions($entry); + } catch (\Throwable $error) { + $issue = $error->getMessage(); + } + if (array_key_exists('waypoints', $entry)) { + $coordinates = ['x' => null, 'y' => null]; + foreach ((array) $entry['waypoints'] as $i => $waypoint) { + if (!is_array($waypoint)) { break; } + $axes = array_intersect_key($nested[$i] ?? [], $waypoint, array_flip(['x', 'y'])); + $coordinates = array_replace($coordinates, array_intersect_key($waypoint, $coordinates)); + $steps[] = ['point' => [$coordinates['x'], $coordinates['y']], 'fields' => $keys($axes), + 'label' => 'Waypoint ' . ($i + 1) . ' (authored target only)']; + } + } elseif (array_key_exists('retrace', $entry)) { + $issue ??= 'Retrace geometry belongs to the completed runtime route record; choose its stable reference in Recorded Route, not a fabricated map path.'; + } + foreach ((array) ($entry['steps'] ?? []) as $i => $step) { + try { + if (!is_array($step) || !is_string($step['direction'] ?? null)) { + throw new \InvalidArgumentException('Invalid direction.'); + } + $direction = MovementRouteRunner::directionVector($step['direction']); + $count = $step['count'] ?? 1; + if (!is_numeric($count) || !is_finite((float) $count) || (float) $count !== (float) (int) $count || $count < 0 || !is_bool($step['faceOnly'] ?? false)) { + throw new \InvalidArgumentException('Invalid count or face-only value.'); + } + $from = [$x, $y]; + if (!($step['faceOnly'] ?? false)) { + $x += (int) $direction->x * (int) $count; + $y += (int) $direction->y * (int) $count; + if (!is_int($x) || !is_int($y)) { + throw new \InvalidArgumentException('The route offsets exceed the supported integer range.'); + } + } + $steps[] = ['from' => $from, 'point' => [$x, $y], 'faceOnly' => $step['faceOnly'] ?? false, + 'fields' => $keys($nested[$i] ?? []), 'label' => 'Step ' . ($i + 1)]; + } catch (\Throwable $error) { + $issue = sprintf('Step %d: %s', $i + 1, $error->getMessage()); + break; + } + } + $fields[$own['subject']]['mapPlacement'] = ['kind' => isset($entry['waypoints']) ? 'waypoints' : 'route', 'points' => $steps, 'issue' => $issue, + 'subject' => $entry['subject'] ?? 'player', 'subjectId' => $entry['npcId'] ?? $entry['actorId'] ?? null, + 'sourcePaths' => array_values(array_column(array_filter($fields, static fn(array $field): bool => + in_array($field['sourceKey'] ?? '', ['type', 'subject', 'npcId', 'actorId', 'direction', 'count', 'faceOnly', 'x', 'y', 'retrace', 'remember'], true)), 'dataPath'))]; + } + $point = static function (array $indexes, array $position, string $kind, ?string $map = null) use (&$fields, $keys, $own): void { + if (!isset($indexes['x'], $indexes['y'])) { + return; + } + $valid = self::isMapPoint($position); + $fields[$indexes['x']]['mapPlacement'] = ['kind' => $kind, 'map' => $map, + 'points' => [['point' => $valid ? [$position['x'], $position['y']] : [0, 0], + 'fields' => $keys($indexes), 'label' => ucfirst($kind)]], + 'sourcePaths' => array_values(array_map(static fn(int $i): array => $fields[$i]['dataPath'] ?? [], + [...$indexes, ...array_intersect_key($own, array_flip(['type', 'target.kind', 'operation']))])), + 'issue' => $valid ? null : 'Map placement needs explicit non-negative integer x/y coordinates; relative or invalid axes are not inferred.']; + }; + if (in_array($type, ['transfer', 'move_player'], true)) { + $point(array_intersect_key($own, array_flip(['x', 'y', 'map'])), $entry, $type === 'transfer' ? 'transfer' : 'position', is_string($entry['map'] ?? null) ? $entry['map'] : null); + } + if (in_array($type, ['camera', 'field_animation'], true) && ($entry['target']['kind'] ?? '') === 'position') { + $indexes = []; + foreach (['x', 'y'] as $axis) { + if (isset($own['target.' . $axis])) { + $indexes[$axis] = $own['target.' . $axis]; + } + } + $point($indexes, $entry['target'], $type === 'camera' ? 'camera' : 'effect'); + } + if ($type === 'camera' && ($entry['operation'] ?? '') === 'route') { + $points = $indexes = $sourcePaths = []; + foreach (array_intersect_key($own, array_flip(['type', 'operation'])) as $index) { + $sourcePaths[] = $fields[$index]['dataPath'] ?? []; + } + $issue = null; + $from = null; + foreach ((array) ($entry['points'] ?? []) as $i => $target) { + if (!is_array($target) || ($target['kind'] ?? '') !== 'position') { + // Actor/screen-relative targets break the known map-space line. + $from = null; + continue; + } + $coordinates = array_intersect_key($nested[$i] ?? [], array_flip(['x', 'y'])); + if (!isset($coordinates['x'], $coordinates['y'])) { + continue; + } + $valid = self::isMapPoint($target); + $issue ??= $valid ? null : 'Camera route placement needs explicit non-negative integer x/y coordinates.'; + $indexes[$coordinates['x']] = count($points); + $position = $valid ? [$target['x'], $target['y']] : [0, 0]; + $points[] = ['point' => $position, 'from' => $from, 'fields' => $keys($coordinates), 'label' => 'Point ' . ($i + 1)]; + $from = $valid ? $position : null; + foreach ($coordinates as $index) { + $sourcePaths[] = $fields[$index]['dataPath'] ?? []; + } + if (isset($nested[$i]['kind'])) { + $sourcePaths[] = $fields[$nested[$i]['kind']]['dataPath'] ?? []; + } + } + foreach ($indexes as $index => $selected) { + $fields[$index]['mapPlacement'] = ['kind' => 'camera', 'points' => $points, 'selectedPoint' => $selected, + 'sourcePaths' => $sourcePaths, 'issue' => $issue]; + } + } + return $fields; + } + + private static function isMapPoint(mixed $position): bool + { + return is_array($position) && is_int($position['x'] ?? null) && is_int($position['y'] ?? null) + && $position['x'] >= 0 && $position['y'] >= 0; + } + + /** Object member order is transport trivia, but types and list order are not. */ + public static function areValuesEqual(mixed $left, mixed $right): bool + { + if (!is_array($left) || !is_array($right)) { + return $left === $right; + } + if (array_is_list($left) !== array_is_list($right) || count($left) !== count($right)) { + return false; + } + foreach ($left as $key => $value) { + if (!array_key_exists($key, $right) || !self::areValuesEqual($value, $right[$key])) { + return false; + } + } + return true; + } + + /** Refuse even unchanged expressions; a click must not depend on evaluated PHP. */ + public static function getSourceIssue(PhpArraySourceDocument $document, array $paths): ?string + { + foreach ($paths as $path) { + $node = $document->root(); + foreach ($path as $segment) { + if ($node->kind !== SourceNode::ARRAY || $node->hasOpaqueKey) { + return 'Map placement requires literal source fields, not expressions, variables or spread arrays.'; + } + $key = is_string($segment) && (string) (int) $segment === $segment ? (int) $segment : $segment; + $node = $node->entryFor($key)?->value; + if ($node === null) { + // A shared optional field may be inserted into a literal array. + break; + } + } + if ($node !== null && $node->kind !== SourceNode::SCALAR) { + return 'Map placement requires literal source fields, not expressions or variables.'; + } + } + return null; + } + + /** Convert one map click to shared row values, keeping all other authored fields. */ + public static function getChanges(array $placement, int $point, int $x, int $y, ?array $origin, string $map): array + { + if (($placement['issue'] ?? null) !== null) { + throw new \InvalidArgumentException($placement['issue']); + } + $selected = $placement['points'][$point] ?? throw new \InvalidArgumentException('Choose an authored point first.'); + $fields = $selected['fields']; + if ($placement['kind'] === 'waypoints') { + // A click changes only authored axes. Omitted axes retain the prior runtime coordinate. + $values = array_intersect_key(['x' => (string) $x, 'y' => (string) $y], $fields); + if ($values === []) { + throw new \InvalidArgumentException('Author at least one waypoint axis before placing it.'); + } + } elseif ($placement['kind'] !== 'route') { + $values = ['x' => (string) $x, 'y' => (string) $y]; + if (isset($fields['map'])) { + $values['map'] = $map; + } + } else { + if ($origin === null || count($origin) !== 2 || !is_int($origin[0]) || !is_int($origin[1])) { + throw new \InvalidArgumentException('Choose a preview origin; it is not saved as the actor position.'); + } + $dx = $x - $origin[0] - $selected['from'][0]; + $dy = $y - $origin[1] - $selected['from'][1]; + if (($dx !== 0 && $dy !== 0) || ($dx === 0 && $dy === 0)) { + throw new \InvalidArgumentException('Choose a cell in one cardinal direction from the previous point.'); + } + $direction = null; + foreach (MovementRouteRunner::DIRECTIONS as $name) { + $vector = MovementRouteRunner::directionVector($name); + if ($vector->x === (float) ($dx <=> 0) && $vector->y === (float) ($dy <=> 0)) { + $direction = $name; + break; + } + } + $values = ['direction' => $direction]; + if (!$selected['faceOnly']) { + $values['count'] = (string) (abs($dx) + abs($dy)); + } + } + $changes = []; + foreach ($values as $name => $value) { + if (!isset($fields[$name]) || $value === null) { + throw new \InvalidArgumentException('The shared schema does not expose that coordinate.'); + } + $changes[$fields[$name]] = $value; + } + return $changes; + } +} diff --git a/src/Maps/TilesetAuthoring.php b/src/Maps/TilesetAuthoring.php new file mode 100644 index 0000000..6fa3d47 --- /dev/null +++ b/src/Maps/TilesetAuthoring.php @@ -0,0 +1,69 @@ +getUsableSheets($assetRoot)['sheets'] ?? []; + $issues = []; + foreach (array_diff_key($tileset->sheets, $usable) as $name => $asset) { + $issues[$name] = sprintf('Sheet %s (%s) cannot be drawn. Check that its PNG is readable, fits the sheet layout and shares the tileset tile size.', $name, $asset); + } + + return $issues; + } + + /** @param array $data @param list> $rows */ + public static function describePieces(array $data, array $rows): array + { + $keys = []; + foreach ($rows as $row) { + if (isset($row['key']['field']) && ($row['kind'] ?? 'info') !== 'info') { + $keys[$row['key']['field']] = $row['key']; + } + } + $pieces = []; + foreach (array_keys(is_array($data['pieces'] ?? null) ? $data['pieces'] : []) as $index => $id) { + $dataPiece = $data['pieces'][$id]; + if (! is_array($dataPiece)) { + continue; + } + $prefix = 'piece' . $index; + $connected = ($dataPiece['connects'] ?? null) === TilesetPiece::LINES; + $issue = null; + $blueprint = null; + try { + // The glyph footprint remains author-owned even while tile rows are being repaired. + $glyphData = $dataPiece; + unset($glyphData['tiles'], $glyphData['effect']); + $blueprint = TilesetPiece::fromArray((string) $id, $glyphData, 'Tileset authoring'); + } catch (InvalidArgumentException $error) { + $issue = $error->getMessage(); + } + $layers = []; + foreach (array_keys(is_array($dataPiece['tiles'] ?? null) ? $dataPiece['tiles'] : []) as $layerIndex => $name) { + $field = $prefix . 'Tiles' . $layerIndex . ($connected ? 'Tile' : 'Rows'); + $entries = $dataPiece['tiles'][$name]; + $shapes = $connected ? (is_string($entries) ? array_fill_keys(TilesetPiece::LINE_SHAPES, $entries) : $entries) : []; + $layers[] = ['name' => (string) $name, 'key' => $keys[$field] ?? null, + 'shapes' => (object) (is_array($shapes) ? $shapes : [])]; + } + $pieces[] = ['id' => (string) $id, 'name' => (string) ($dataPiece['name'] ?? $id), + 'layer' => (string) ($dataPiece['layer'] ?? ''), 'connected' => $connected, + 'width' => $blueprint?->width, 'height' => $blueprint?->height, + 'key' => $keys[$prefix . 'Name'] ?? null, 'layers' => $layers, 'issue' => $issue]; + } + + return $pieces; + } +} diff --git a/src/ProjectConfig.php b/src/ProjectConfig.php index 7ba5480..6e957fd 100644 --- a/src/ProjectConfig.php +++ b/src/ProjectConfig.php @@ -5,15 +5,16 @@ namespace Ichiloto\Editor; use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; +use Ichiloto\Editor\Cutscenes\Source\ArraySourceWriter; use Ichiloto\Editor\Cutscenes\Source\SourceNode; use Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal; use Ichiloto\Editor\Database\PhpDataFile; use Ichiloto\Editor\Database\PhpValueExporter; use Ichiloto\Editor\Database\ProjectRecord; +use Ichiloto\Editor\Database\InnPresentationFields; use Ichiloto\Editor\Storage\FileSetOperations; use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Editor\Storage\FilesystemFileSetOperations; -use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Rendering\FieldViewport; use RuntimeException; use Throwable; @@ -88,7 +89,8 @@ public function getTermRecords(array $roots): array foreach ($tree as $key => $value) { $path = $prefix . '.' . $key; $hasDottedKey = $ambiguous || str_contains((string) $key, '.'); - if (is_array($value)) { $visit($value, $path, $hasDottedKey); } + if ($path === self::INN_PRESENTATION && ! $hasDottedKey) { $records[] = $this->getRecord($path); } + elseif (is_array($value)) { $visit($value, $path, $hasDottedKey); } elseif (! is_object($value) || $value instanceof \UnitEnum) { if ($hasDottedKey) { $this->fieldIssues[$path] = $path . ' contains a dotted literal key, ambiguous with a nested path; it is preserved read-only.'; @@ -104,7 +106,7 @@ public function getTermRecords(array $roots): array // out, so it can be set without being typed in by hand. foreach (array_keys(self::ENGINE_DEFAULTS) as $path) { $root = explode('.', $path)[0]; - if (in_array($root, $roots, true) && self::getValue($this->payload, $path) === null && $this->issue === null) { + if (in_array($root, $roots, true) && ! in_array($path, array_map(static fn(ProjectRecord $record): mixed => $record->get('path'), $records), true) && $this->issue === null) { $records[] = $this->getRecord($path); } } @@ -123,15 +125,14 @@ public function getReadOnlyReason(): ?string * * @throws \InvalidArgumentException */ - public function assertAcceptableValue(string $path, mixed $value): void + public function assertAcceptableValue(string $path, mixed $value, bool $complete = true): void { if ($path === self::FIELD_ZOOM && (! is_int($value) && ! is_float($value) || ! is_finite((float) $value) || $value < FieldViewport::MIN_ZOOM || $value > FieldViewport::MAX_ZOOM)) { throw new \InvalidArgumentException(sprintf('Field zoom must be a finite number from %g to %g.', FieldViewport::MIN_ZOOM, FieldViewport::MAX_ZOOM)); } if ($path === self::INN_PRESENTATION && $value !== null) { - // A stable timeline identity, as InnStay loads it; null leaves inns without one. - EffectTimelineLibrary::assertId(is_string($value) ? $value : ''); + InnPresentationFields::assertValid($value, complete: $complete); } } @@ -139,7 +140,7 @@ public function getFieldIssue(string $path): ?string { if (isset($this->fieldIssues[$path])) { return $this->fieldIssues[$path]; } try { - $this->createFieldEdit($this->document, $path, '1'); + $this->createFieldEdit($this->document, $path, '1', $path === self::INN_PRESENTATION); return null; } catch (Throwable $error) { return $error->getMessage(); @@ -162,20 +163,63 @@ public function isDirty(): bool /** Rebuild only the changed leaf spans over the original bytes, including after save/undo. */ public function getSource(): string + { + return $this->composeSource(); + } + + /** Proves a proposed structured value is source-editable before changing its shared record. */ + public function assertSourceAcceptsValue(string $path, mixed $value): void + { + $this->composeSource([$path => $value]); + } + + private function composeSource(array $overrides = []): string { $document = $this->document; foreach ($this->records as $path => $record) { - if ($record->get('value') === $this->originalValues[$path]) { continue; } - $edit = $this->createFieldEdit($document, $path, PhpValueExporter::export($record->get('value'))); + $value = array_key_exists($path, $overrides) ? $overrides[$path] : $record->get('value'); + if ($value === $this->originalValues[$path]) { continue; } + if ($path === self::INN_PRESENTATION && is_array($this->originalValues[$path])) { + $this->createFieldEdit($document, $path, '1', true); + $old = $this->payload; + $new = $old; + $next = &$new; + foreach (explode('.', $path) as $segment) { $next = &$next[$segment]; } + $next = $value; + unset($next); + $document = ArraySourceWriter::rewrite($document, $old, $new, $this->getLeaderKeyRenames($this->originalValues[$path], $value)); + continue; + } + $edit = $this->createFieldEdit($document, $path, PhpValueExporter::export($value)); $document = $document->withEdits([$edit]); } return $document?->source ?? $this->persistedSource; } + /** One picker key replacement keeps the binding's authored comments and value span. */ + private function getLeaderKeyRenames(array $old, mixed $new): array + { + if (! is_array($new) || ($old['treatment'] ?? null) !== 'leader' || ($new['treatment'] ?? null) !== 'leader' + || ! is_array($old['leaders'] ?? null) || ! is_array($new['leaders'] ?? null)) { return []; } + $before = array_keys($old['leaders']); + $after = array_keys($new['leaders']); + if (count($before) !== count($after)) { return []; } + $renames = []; + foreach ($before as $index => $key) { + if ($key === $after[$index]) { continue; } + if (array_key_exists($key, $new['leaders']) || array_key_exists($after[$index], $old['leaders'])) { return []; } + $renames[] = ['path' => ['graphics', 'inn', 'presentation', 'leaders', $key], 'key' => $after[$index]]; + } + return $renames; + } + /** Saves all pending configuration leaves together, never a second whole-value rewrite. */ public function save(?FileSetOperations $files = null): void { if (! $this->isDirty()) { return; } + foreach ($this->records as $path => $record) { + if ($record->isDirty() && $record->get('value') !== $this->originalValues[$path]) { $this->assertAcceptableValue($path, $record->get('value')); } + } $source = $this->getSource(); $transaction = new FileSetTransaction($this->projectRoot, $files ?? new FilesystemFileSetOperations()); $transaction->write($this->path, $source); @@ -200,7 +244,7 @@ public function save(?FileSetOperations $files = null): void foreach ($this->records as $record) { $record->markClean(); } } - private function createFieldEdit(?PhpArraySourceDocument $document, string $path, string $literal): array + private function createFieldEdit(?PhpArraySourceDocument $document, string $path, string $literal, bool $allowArray = false): array { if ($this->issue !== null || $document === null) { throw new SourcePreservationRefusal($this->issue ?? 'config.php cannot be preserved.'); } $segments = explode('.', $path); @@ -222,7 +266,7 @@ private function createFieldEdit(?PhpArraySourceDocument $document, string $path } // An enum case is written as one: replacing it with another case of // its enum keeps the file reading the same kind of value. - if ($node->kind !== SourceNode::SCALAR && ! self::getValue($this->payload, $path) instanceof \UnitEnum) { + if ($node->kind !== SourceNode::SCALAR && ! ($allowArray && $node->kind === SourceNode::ARRAY) && ! self::getValue($this->payload, $path) instanceof \UnitEnum) { throw new SourcePreservationRefusal($path . ' is an authored expression, not an editable literal.'); } return $document->replaceValueEdit($walked, $literal); diff --git a/src/ProjectMap.php b/src/ProjectMap.php index eced3c3..ac3372c 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\Cutscenes\Source\ArraySourceWriter; use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; +use Ichiloto\Editor\Cutscenes\Source\SourceNode; use Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal; use Ichiloto\Editor\Cutscenes\Source\SourceUnreadable; use Ichiloto\Editor\Database\PhpDataFile; @@ -73,10 +74,12 @@ final class ProjectMap private ?string $unparsedDataSource = null; /** - * @var array The data array the document corresponds to: - * what the file held at load, or at the last successful save. + * @var array The data at the last load or successful save, + * used to validate changes against the saved checkpoint. */ private array $loadedData; + /** @var array The values of the retained authored source document. */ + private array $sourceData; private string $baselineDataSource; /** @@ -115,6 +118,7 @@ public function __construct( 'path' => $mapPath, 'grid' => new EditableGrid(implode("\n", $tileLines), legacyTags: true), ]], new EditableGrid(implode("\n", $eventLines)), $eventPath); $this->loadedData = $data; + $this->sourceData = $data; $this->adoptDataSource($dataSource ?? "baselineDataSource = $this->dataDocument?->source ?? (string) $this->unparsedDataSource; $this->baselineMapPayload = $this->buildMapPayload(); @@ -223,6 +227,18 @@ private function assertEditable(): void if ($this->gridSourceIssue !== null) { throw new MapSourceRefusal("{$this->mapId} is read-only: {$this->gridSourceIssue}"); } + $this->assertSourcesUnchanged(); + } + + /** Refuses external source edits without comparing against unsaved internal changes. */ + public function assertSourcesUnchanged(): void + { + $this->assertGridSourcesCanonical(); + try { + $this->createSourceTransaction($this->directory)->assertSourcesUnchanged(); + } catch (FileSetTransactionFailure $failure) { + throw new MapSourceRefusal($failure->getMessage() . '. Nothing was changed.', previous: $failure); + } } /** Reads one canonical grid, refusing executable or generated source. */ @@ -238,7 +254,7 @@ private static function readGridSource(string $path, string $mapId): string } } - /** Refuses a source changed on disk after this map was opened. */ + /** Refuses grid sources changed on disk after this map was opened. */ private function assertGridSourcesCanonical(): void { try { @@ -248,6 +264,20 @@ private function assertGridSourcesCanonical(): void } } + private function createSourceTransaction( + string $directory, + ?FileSetOperations $files = null, + bool $reserveFolder = false, + ): FileSetTransaction { + $transaction = new FileSetTransaction($directory, $files ?? new FilesystemFileSetOperations(), $reserveFolder); + // Missing data can be recovered from the loaded source. An existing + // file must still match it; the transaction captures metadata before + // reading, so a failed move restores the original access time too. + $transaction->expectSource($this->dataPath, $this->baselineDataSource, allowMissing: true); + $this->layers->expectSources($transaction); + return $transaction; + } + /** * Adopts the just-loaded content as the saved baseline. * @@ -939,8 +969,9 @@ public function captureLayerSnapshot(): array public function restoreLayerSnapshot(array $snapshot): void { $this->restoreGridSnapshot($snapshot['grid']); - $this->editableData = $this->loadedData = $snapshot['data']; - $this->adoptDataSource($snapshot['source']); + // History restores edits, never the last successful save checkpoint + // or the original source needed by other outstanding undo entries. + $this->editableData = $snapshot['data']; $this->touchState(); } @@ -1494,24 +1525,46 @@ public function getMapField(string $field): mixed */ private function writeData(array $next): void { + $this->assertSourcesUnchanged(); if ($next === $this->editableData) { // Setting data to what it already is neither dirties nor // deserves a history entry at the call site. return; } - $this->assertDataPreservable($next); - $this->editableData = $next; + $document = $this->assertDataPreservable($next); + $this->editableData = $this->alignDataWithSource($document->root(), $next); $this->touchState(); } + /** Field edits retain authored key positions; ordered lists retain their requested order. */ + private function alignDataWithSource(SourceNode $node, array $data): array + { + if ($node->kind !== SourceNode::ARRAY || $node->hasOpaqueKey) { + return $data; + } + + $ordered = []; + foreach ($node->entries as $index => $entry) { + $key = $node->isList ? $index : $entry->key; + if ($key === null || ! array_key_exists($key, $data)) { + continue; + } + $ordered[$key] = is_array($data[$key]) + ? $this->alignDataWithSource($entry->value, $data[$key]) + : $data[$key]; + } + + return $ordered + $data; + } + /** * Proves the authored data source can be rewritten into this array. * * @param array $next * @throws MapSourceRefusal When it cannot. */ - private function assertDataPreservable(array $next): void + private function assertDataPreservable(array $next): PhpArraySourceDocument { if ($this->dataDocument === null) { throw new MapSourceRefusal(sprintf( @@ -1523,7 +1576,7 @@ private function assertDataPreservable(array $next): void } try { - ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $next); + return ArraySourceWriter::rewrite($this->dataDocument, $this->sourceData, $next); } catch (SourcePreservationRefusal $refusal) { throw new MapSourceRefusal(sprintf( '%s: %s — %s Everything else in the file is untouched.', @@ -1542,7 +1595,7 @@ private function assertDataPreservable(array $next): void private function proposedDataSource(): string { if ($this->dataDocument === null) { - if ($this->editableData === $this->loadedData) { + if ($this->editableData === $this->sourceData) { throw new RuntimeException(sprintf('%s has no preservable data source to rewrite.', $this->mapId)); } @@ -1550,7 +1603,7 @@ private function proposedDataSource(): string } try { - return ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $this->editableData)->source; + return ArraySourceWriter::rewrite($this->dataDocument, $this->sourceData, $this->editableData)->source; } catch (SourcePreservationRefusal $refusal) { throw new MapSourceRefusal(sprintf( '%s: %s — %s', @@ -1933,6 +1986,7 @@ public function setEventField(string $marker, array $path, mixed $value): void if ($index === array_key_last($path)) { $reference[$segment] = $value; unset($reference); + $this->assertChangedInnPresentations($this->editableData, $next, complete: false); $this->writeData($next); return; } @@ -1966,9 +2020,28 @@ public function removeEventField(string $marker, array $path): void unset($reference[$last]); unset($reference); + $this->assertChangedInnPresentations($this->editableData, $next, complete: false); $this->writeData($next); } + /** Incomplete picker drafts may be edited, but no incomplete descriptor can be saved. */ + private function assertChangedInnPresentations(array $old, array $next, bool $complete): void + { + foreach ((array) ($next['events'] ?? []) as $marker => $event) { + if (! is_array($event)) { continue; } + $before = is_array($old['events'][$marker] ?? null) ? $old['events'][$marker] : []; + if (str_ends_with(strval($event['class'] ?? ''), 'SleepEventTrigger')) { + $value = $event['data']['presentation'] ?? null; + if ($value !== ($before['data']['presentation'] ?? null)) { + \Ichiloto\Editor\Database\InnPresentationFields::assertValid($value, complete: $complete); + } + } + \Ichiloto\Editor\Database\InnPresentationFields::assertChangedCommandsValid($before, $event, complete: $complete); + } + \Ichiloto\Editor\Database\InnPresentationFields::assertChangedCommandsValid( + (array) ($old['npcs'] ?? []), (array) ($next['npcs'] ?? []), complete: $complete); + } + /** * Replaces the definition for the given event marker. * @@ -2094,9 +2167,12 @@ public function willMoveOnSave(): bool public function save(?callable $backup = null, ?FileSetOperations $files = null): string { $this->assertEditable(); + $this->assertChangedInnPresentations($this->loadedData, $this->editableData, complete: true); + \Ichiloto\Editor\Database\MovementRouteFields::assertChangedCommandsValid($this->loadedData, $this->editableData); $target = $this->resolveSaveTarget(); $moving = $target['directory'] !== $this->directory; $this->assertGridSourcesCanonical(); + $transaction = $this->createSourceTransaction($target['directory'], $files); $tripletExists = is_file($this->dataPath) && count($this->layers->getSources()) > 0 @@ -2105,6 +2181,7 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) if (! $this->isDirty() && ! $moving && is_dir($this->directory) && $tripletExists) { // Nothing diverges from the last save: writing would only // canonicalize hand-authored formatting and churn mtimes. + $transaction->commit(); return $target['mapId']; } @@ -2114,9 +2191,8 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) // against the grids as of the last save, never against the bytes on // disk. Existing grid sources have already passed the literal-nowdoc // check, so an edited grid cannot silently flatten authored PHP. - // A data file the parser cannot hold never reaches the writer unless - // the on-disk member disappeared: its edits were refused, so its - // cached source bytes are its own and are restored unchanged. + // A data file the parser cannot hold keeps its original bytes; + // source freshness is checked by the transaction before writing. $dataSource = $this->dataDocument === null ? (string) $this->unparsedDataSource : $this->proposedDataSource(); $writeData = ! is_file($this->dataPath) || $dataSource !== $this->baselineDataSource; @@ -2128,13 +2204,12 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) if (! $moving && ! $writeData && ! $writeMap && ! $writeEvent) { // Dirty by fingerprint but identical in content: a same-value // round trip. Clean without writing. + $transaction->commit(); $this->adoptWritten($dataSource, $mapPayload, $eventPayload); return $target['mapId']; } - $transaction = new FileSetTransaction($target['directory'], $files ?? new FilesystemFileSetOperations()); - if ($writeData || $moving) { $transaction->write($target['dataPath'], $dataSource); } @@ -2201,7 +2276,8 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) */ private function adoptWritten(string $dataSource, string $mapPayload, string $eventPayload): void { - $this->adoptDataSource($dataSource); + // Saving moves the checkpoint, not the rewrite basis. History still + // needs removed expressions, references and comments for exact undo. $this->baselineDataSource = $dataSource; $this->loadedData = $this->editableData; $this->baselineMapPayload = $mapPayload; @@ -2343,7 +2419,7 @@ public function duplicateTo(string $directory, string $baseName, string $display $duplicatedData['name'] = $displayName; try { - $dataSource = ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $duplicatedData)->source; + $dataSource = ArraySourceWriter::rewrite($this->dataDocument, $this->sourceData, $duplicatedData)->source; } catch (SourcePreservationRefusal $refusal) { throw new MapSourceRefusal(sprintf( '%s: %s — %s Nothing was duplicated.', @@ -2353,7 +2429,7 @@ public function duplicateTo(string $directory, string $baseName, string $display ), previous: $refusal); } - $transaction = new FileSetTransaction($directory, $files ?? new FilesystemFileSetOperations()); + $transaction = $this->createSourceTransaction($directory, $files); $duplicatedDataPath = $directory . DIRECTORY_SEPARATOR . $baseName . '.data.php'; $transaction->write($duplicatedDataPath, $dataSource); $this->validateLayerContracts(); @@ -2487,6 +2563,15 @@ private function buildEventPayload(): string return $this->layers->getEventGrid()->getSource(); } + /** The metadata-derived path proposed by an explicit move, never an ordinary save. */ + public function getProposedMapId(): string + { + $name = self::slugify($this->getDisplayName(), basename($this->directory)); + $region = self::slugify($this->getRegion(), ''); + + return $region !== '' ? $region . '/' . $name : $name; + } + /** * @inheritDoc */ @@ -2541,7 +2626,7 @@ public function moveTo(string $newRelativeId): self $baseName = basename($directory); try { - $transaction = new FileSetTransaction($directory, reserveFolder: true); + $transaction = $this->createSourceTransaction($directory, reserveFolder: true); // A move carries the map's content as authored: the data file // is the preserved source (unsaved edits rewritten into it, not // a regeneration of the whole array), and untouched grids keep @@ -2712,13 +2797,9 @@ private function resolveSaveTarget(): array } $mapsRoot = $this->getMapsRoot(); - $baseName = self::slugify($this->getDisplayName(), basename($this->directory)); - // An empty region must stay empty — falling back to the default slug - // would silently relocate region-less maps into a "new-map" folder. - $region = self::slugify($this->getRegion(), ''); - $relativePath = $region !== '' ? $region . DIRECTORY_SEPARATOR . $baseName : $baseName; - $directory = $mapsRoot . DIRECTORY_SEPARATOR . $relativePath; - $mapId = str_replace(DIRECTORY_SEPARATOR, '/', $relativePath); + $mapId = $this->getProposedMapId(); + $baseName = basename($mapId); + $directory = $mapsRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $mapId); if (is_dir($directory)) { throw new RuntimeException("Map path {$mapId} already exists."); diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index 4196fea..a227831 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -13,6 +13,7 @@ use Ichiloto\Editor\Database\QuestReferences; use Ichiloto\Editor\Database\RecordSchema; use Ichiloto\Editor\Database\RecordSchemaCatalog; +use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Events\ProjectScriptCommands; use Ichiloto\Editor\Storage\FileSetOperations; use Ichiloto\Editor\Storage\FileSetTransaction; @@ -59,6 +60,9 @@ public function __construct( $this->getRecordDatabase('quests')?->useIdentityReferences( fn(string $questId): bool => new QuestReferences($this)->exist($questId), ); + $references = new ReferenceCatalog($this); + foreach ($this->recordDatabases as $database) { $database->useAuthoringReferences($references); } + $this->cutscenes?->useAuthoringReferences($references); } /** @@ -162,11 +166,11 @@ public function getMapsRoot(): string * @param string $projectRoot The project root. * @return self */ - public static function fromProject(string $projectRoot): self + public static function fromProject(string $projectRoot, bool $graphical = false): self { return ProjectDirectoryContext::run( $projectRoot, - static fn(string $canonicalRoot): self => self::loadFromProject($canonicalRoot), + static fn(string $canonicalRoot): self => self::loadFromProject($canonicalRoot, $graphical), ); } @@ -176,7 +180,7 @@ public static function fromProject(string $projectRoot): self * @param string $projectRoot Absolute project root. * @return self */ - private static function loadFromProject(string $projectRoot): self + private static function loadFromProject(string $projectRoot, bool $graphical): self { $configPath = rtrim($projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'ichiloto.json'; @@ -217,8 +221,8 @@ private static function loadFromProject(string $projectRoot): self mapIds: array_map(static fn(ProjectMap $map): string => $map->mapId, $maps), actorDatabase: ProjectActorDatabase::fromProject($projectRoot), recordDatabases: array_map( - static fn(RecordSchema $schema): ProjectRecordDatabase => ProjectRecordDatabase::fromProject($projectRoot, $schema, $projectConfig), - RecordSchemaCatalog::all(), + static fn(RecordSchema $schema): ProjectRecordDatabase => ProjectRecordDatabase::fromProject($projectRoot, $schema, $projectConfig, $graphical), + RecordSchemaCatalog::all($graphical), ), cutscenes: CutsceneLibrary::fromProject($projectRoot), config: $projectConfig, diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 8476f05..5520557 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -39,6 +39,7 @@ use Ichiloto\Editor\Database\RecordChange; use Ichiloto\Editor\Database\RecordRefusal; use Ichiloto\Editor\Database\ReferenceCatalog; +use Ichiloto\Editor\Database\MovementRouteFields; use Ichiloto\Editor\Database\DatabaseCategory; use Ichiloto\Editor\Database\RecordCategory; use Ichiloto\Editor\Database\RecordPanes; @@ -51,6 +52,7 @@ use Ichiloto\Editor\Events\EventTypeCatalog; use Ichiloto\Editor\Events\EventTypeDefinition; use Ichiloto\Editor\Field\NpcAuthoring; +use Ichiloto\Editor\Field\CharacterSheetPreview; use Ichiloto\Editor\Field\NpcChange; use Ichiloto\Editor\Field\NpcInspector; use Ichiloto\Editor\Field\NpcRefusal; @@ -58,6 +60,7 @@ use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\CommandHistory; use Ichiloto\Editor\Inspector\InputControlType; +use Ichiloto\Editor\Inspector\InputControl; use Ichiloto\Editor\Inspector\InspectorListEdit; use Ichiloto\Editor\Inspector\InspectorRefusal; use Ichiloto\Editor\Inspector\MapInspector; @@ -66,6 +69,7 @@ use Ichiloto\Editor\Playtest\PlaytestOverlay; use Ichiloto\Editor\Playtest\PlaytestRun; use Ichiloto\Editor\Maps\LayerEditor; +use Ichiloto\Editor\Maps\LineInsertionPlanner; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Maps\MapReferences; use Ichiloto\Editor\Maps\TilePalette; @@ -74,12 +78,14 @@ use Ichiloto\Editor\History\SourceSetRequired; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Editor\Storage\FileSetTransactionFailure; use Ichiloto\Editor\Storage\WorkspaceSave; use Ichiloto\Editor\Validation\MapGraphicsValidator; use Ichiloto\Editor\Validation\MapValidator; use Ichiloto\Engine\IO\Console\TerminalPresentationComposer; use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; +use Ichiloto\Engine\Rendering\Sprites\CharacterSheet; use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; use Ichiloto\Engine\Rendering\Presentation\PresentationWorld; use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; @@ -134,6 +140,8 @@ */ final class EditorSession { + use MapPlacementSession; + /** The renderer a playtest started from a graphical editor uses: it owns no terminal to hand over. */ public const string PLAYTEST_RENDERER = 'gpui'; @@ -202,7 +210,7 @@ private function __construct( /** Opens the project at a root, as the terminal editor opens it. */ public static function open(string $projectRoot): self { - $workspace = ProjectWorkspace::fromProject($projectRoot); + $workspace = ProjectWorkspace::fromProject($projectRoot, graphical: true); return new self($workspace, new CommandHistory(), new BackupWriter(BackupSettings::fromProject($workspace->projectRoot), $workspace->projectRoot)); @@ -360,6 +368,111 @@ public function createMap(?string $name, ?string $kind, int $width, int $height) return ['map' => $mapId, 'maps' => $this->describeMaps()]; } + /** + * Copies the current authored map through the workspace's transactional + * duplicate operation. Unsaved source edits are included in the copy; + * the original and every other loaded document and undo step remain. + * + * @return array{map: string, maps: list>} + * @throws SessionRefusal When the revision is stale or source cannot be copied safely. + */ + public function duplicateMap(string $mapId, int $revision): array + { + $this->requireCurrentMap($mapId, $revision); + try { + $created = $this->workspace->duplicateMap((int) array_search($mapId, $this->workspace->mapIds, true)) + ?? throw new RuntimeException('The map is no longer in the workspace.'); + } catch (MapSourceRefusal|RuntimeException $error) { + throw new SessionRefusal(sprintf('%s was not duplicated: %s', $mapId, $error->getMessage()), previous: $error); + } + $this->workspace = $this->workspace->withLoadedMap($created); + + return ['map' => $created, 'maps' => $this->describeMaps()]; + } + + /** + * Explicitly moves a map to its metadata-derived path after the author + * reviews that exact destination. As in the TUI, references are not + * migrated and a successful move clears undo history naming retired maps. + * Ordinary saves never call this operation. + * + * @return array + * @throws SessionRefusal When the revision or reviewed path changed, or source cannot move safely. + */ + public function moveMap(string $mapId, int $revision, ?string $destination = null, bool $confirmed = false): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + $proposed = $map->getProposedMapId(); + if ($destination !== null && $destination !== $proposed) { + throw new SessionRefusal('The derived destination changed; review the proposed move again.'); + } + if ($proposed === $mapId) { + return ['status' => 'unchanged', 'map' => $mapId, 'destination' => $proposed, 'maps' => $this->describeMaps()]; + } + if (! $confirmed) { + return [ + 'status' => 'question', 'map' => $mapId, 'revision' => $revision, 'destination' => $proposed, + 'references' => new MapReferences($this->workspace)->describe($mapId), + 'warning' => 'References are not migrated: doors, quests, saves and one-shot events naming the old id keep naming it. Moving clears the undo history. The original map folder is removed only after the new map validates.', + ]; + } + if ($destination === null) { + throw new SessionRefusal('Review the proposed destination before confirming the move.'); + } + try { + $moved = $map->moveTo($proposed); + } catch (MapSourceRefusal|RuntimeException $error) { + throw new SessionRefusal(sprintf('%s was not moved: %s', $mapId, $error->getMessage()), previous: $error); + } + $this->workspace = $this->workspace->withReplacedMap((int) array_search($mapId, $this->workspace->mapIds, true), $moved); + $this->history->clear(); + + return ['status' => 'moved', 'previousMap' => $mapId, 'map' => $moved->mapId, 'maps' => $this->describeMaps()]; + } + + /** + * Reviews a project-wide row or column insertion before writing it as + * one reversible source set. The plan owns coordinate rewrites and save + * shifts; both editor interfaces use the same planner and transaction. + * + * @return array + * @throws SessionRefusal When the map or review is stale, edits are pending, or the plan cannot be applied safely. + */ + public function insertMapLines(string $mapId, int $revision, string $axis, int $at, int $count, ?string $answer = null, ?string $confirm = null): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + if ($answer === 'cancel') { + return ['status' => 'cancelled', 'map' => $mapId, 'revision' => $revision, 'changed' => false, 'maps' => $this->describeMaps()]; + } + if ($this->workspace->hasUnsavedChanges()) { + throw new SessionRefusal('Save or undo pending edits before inserting map lines. No files were changed.'); + } + try { + $plan = LineInsertionPlanner::planProject($this->workspace->projectRoot, $mapId, $axis, $at, $count); + } catch (InvalidArgumentException|RuntimeException $failure) { + throw new SessionRefusal($failure->getMessage(), previous: $failure); + } + $paths = array_map(fn(string $path): string => substr($path, strlen($this->workspace->projectRoot) + 1), $plan->getChangedPaths()); + $description = $plan->insertion->description; + $required = new SourceSetRequired($plan->getSourceSet(), 'Insert ' . $plan->insertion->noun, + 'this map line insertion', 'Insert ' . $description . '?', $paths); + $result = $this->writeSourceSet($required, $answer, static fn(): array => [], $confirm); + if (($result['status'] ?? null) === 'question') { + return [...$result, 'map' => $mapId, 'revision' => $revision, 'axis' => $axis, 'at' => $at, 'count' => $count, + 'paths' => $paths, 'handEdits' => $plan->handEdits, 'notes' => $plan->notes, + 'width' => $plan->width, 'height' => $plan->height]; + } + + return ['status' => 'inserted', 'map' => $mapId, 'revision' => $this->getMapRevision($this->requireMap($mapId)), + 'changed' => true, 'maps' => $this->describeMaps()]; + } + /** * Deletes a map's files from disk at once, as the terminal editor does. * While something still sends the player there ({@see MapReferences}), @@ -569,7 +682,9 @@ public function readTilesetPreview(int $index): array && TilesetSheet::tryFrom((string) $sheet) !== null && strtolower(pathinfo($asset, PATHINFO_EXTENSION)) === 'png', ARRAY_FILTER_USE_BOTH); $tileset = new Tileset($record->recordId, trim((string) ($data['name'] ?? '')) ?: $record->recordId, $sheets); } - $tabs = $unreadable = []; + $tabs = []; + $sheetIssues = \Ichiloto\Editor\Maps\TilesetAuthoring::getSheetIssues($tileset, $assetRoot); + $unreadable = array_values($sheetIssues); foreach (TilePalette::getTabs($tileset) as $tab) { try { $world = TilePalette::buildWorld($tileset, $tab['ids'], $assetRoot, 'tileset-preview:' . $tab['name']); @@ -596,6 +711,7 @@ public function readTilesetPreview(int $index): array 'tileLayers' => array_keys($piece->connects === null ? $piece->tiles : $piece->shapeTiles), 'picture' => PiecePlacer::buildPicture($piece), 'operations' => $world?->operations, + 'connections' => \Ichiloto\Editor\Maps\ConnectedPiecePreview::describe($tileset, $piece, $assetRoot, $sheetIssues), ]; } @@ -608,6 +724,8 @@ public function readTilesetPreview(int $index): array 'tableTiles' => array_values(array_filter(array_merge(...array_map(static fn(array $tab): array => array_merge(...$tab['ids']), $tabs) ?: [[]]), static fn(int $tile): bool => TileId::getSheet($tile) === TilesetSheet::A2)), 'pieces' => $pieces, + 'authoring' => \Ichiloto\Editor\Maps\TilesetAuthoring::describePieces($data, $this->readDatabaseRecord('tilesets', $index)['rows']), + 'connectionShapes' => TilesetPiece::LINE_SHAPES, 'issue' => $issue, 'unreadable' => $unreadable, ]; @@ -1383,7 +1501,8 @@ public function readInspector(string $mapId, ?string $marker = null): array 'map' => $mapId, 'revision' => $this->getMapRevision($map), 'event' => $marker, - 'rows' => array_map(self::describeRow(...), $this->collectInspectorFields($map, $marker)), + 'rows' => $this->describePlacementRows(array_map(self::describeRow(...), $this->collectInspectorFields($map, $marker)), + ['kind' => 'event', 'map' => $mapId, 'marker' => $marker]), ]; } @@ -1822,6 +1941,14 @@ public function describeCutsceneTimeline(string $category, int $index): array } } } + // Glyph tracks also have anchors, but those are not image art. Do not + // project image defaults or an image picker onto terminal-only tracks. + foreach ($tracks as &$track) { + if (($track['type'] ?? '') !== 'image') { + unset($track['art']); + } + } + unset($track); $payload = $asset->payload(); return [ @@ -1831,14 +1958,39 @@ public function describeCutsceneTimeline(string $category, int $index): array 'lengthFrames' => (int) ($payload['lengthFrames'] ?? 0), 'tracks' => array_values(array_map(fn(array $track, int $position): array => self::placeOnStage([...$track, 'keyframes' => array_values($track['keyframes'] ?? []), - ...(isset($track['art']) ? ['art' => $this->measureTrackArt($track['art'])] : [])], $payload['tracks'][$position] ?? null), $tracks, array_keys($tracks))), + ...(isset($track['art']) ? ['art' => [...$this->measureTrackArt($track['art']), + 'pivotDefaults' => self::getTimelinePivotDefaults($asset->type, $payload['tracks'][$position] ?? []), + ]] : [])], $payload['tracks'][$position] ?? null), $tracks, array_keys($tracks))), 'cues' => array_values($cues), - // Whether this sequence may have a stage (a summon's graphical one), and the stage it has. - 'canHaveStage' => $asset->type === CutsceneType::SUMMON && $asset->getPresentationView() === EffectPresentation::GRAPHICAL, + // Stage ownership survives switching to an independent Terminal sequence. + 'ownedStage' => $asset->isOwnedStageEffect(), + 'canHaveStage' => $asset->getPresentationView() !== EffectPresentation::TERMINAL + && ($asset->type === CutsceneType::EFFECT || $asset->hasPresentations()), 'stage' => is_array($payload['stage'] ?? null) ? $this->describeCutsceneStage($category, $index) : null, ]; } + /** + * Consumer defaults are preview metadata, never authored values. A shared + * effect can play in field or battle; the client selects the context it is + * actually previewing. Stage tracks cannot acquire field/battler geometry. + * + * @return array + */ + private static function getTimelinePivotDefaults(CutsceneType $type, array $track): array + { + $center = ['x' => 0.5, 'y' => 0.5]; + if (($track['anchor'] ?? null) === 'stage') { + return ['stage' => $center]; + } + + return [ + ...($type === CutsceneType::EFFECT + ? ['field' => new \Ichiloto\Engine\Rendering\Presentation\PresentationSpritePivot()->toArray()] : []), + 'battle' => $center, + ]; + } + /** * A stage image's placement in stage units, and each keyframe's offset, * as the Engine reads them with their absent defaults, so an editor can @@ -1936,9 +2088,8 @@ private function describeCutsceneStage(string $category, int $index): array } /** - * One frame of a summon or effect as its terminal presentation draws it, - * from the record as edited, unsaved changes included, with the cues on - * that frame: what a timeline editor shows at its playhead. + * One authored frame, with independent Terminal output and, for an + * owned graphical effect stage, the Engine's shared presentation canvas. * * @return array{frame: int, totalFrames: int, fps: int, lines: list, terminalCanvas: array, cues: list>} * @throws SessionRefusal When the record is not a summon or effect, or its timeline does not compile. @@ -1948,18 +2099,39 @@ public function readCutscenePreview(string $category, int $index, int $frame, in $asset = $this->requireTimelineCutscene($category, $index); try { $compiled = $asset->type === CutsceneType::EFFECT - ? $asset->compiledEffect(EffectPresentation::TERMINAL, false) + ? $asset->compileEffect(EffectPresentation::TERMINAL, false, forStage: $asset->isOwnedStageEffect()) : $asset->compiledSummon(); } catch (Throwable $failure) { throw new SessionRefusal(sprintf('%s does not compile: %s', $asset->id, $failure->getMessage()), previous: $failure); } $preview = new TimelinePreviewSession($compiled, $asset->type === CutsceneType::EFFECT ? null : false); + $requestedFrame = $frame; $frame = max(0, min($frame, $preview->totalFrames() - 1)); - $lines = array_values($preview->frame(max(1, $width), max(1, $height), $frame)); + $lines = array_values($asset->isOwnedStageEffect() + ? new \Ichiloto\Editor\Cutscenes\Preview\EffectPreviewStage(EffectPresentation::TERMINAL, false, forStage: true) + ->drawFrame($preview->activeSegments($frame), max(1, $width), max(1, $height)) + : $preview->frame(max(1, $width), max(1, $height), $frame)); + $stageCanvas = null; + $stageCanvasError = null; + if ($asset->isOwnedStageEffect() && $asset->getPresentationView() !== EffectPresentation::TERMINAL) { + try { + $graphical = $asset->compileEffect(EffectPresentation::GRAPHICAL, false, forStage: true); + $preview = new TimelinePreviewSession($graphical); + $frame = max(0, min($requestedFrame, $preview->totalFrames() - 1)); + $stageCanvas = new \Ichiloto\Editor\Cutscenes\Preview\EffectPreviewStage(EffectPresentation::GRAPHICAL, false, forStage: true) + ->renderCanvas($graphical, $this->workspace->projectRoot . '/assets', $frame, + (int) $graphical->defaults['stage']['canvas']['width'], (int) $graphical->defaults['stage']['canvas']['height'])?->toArray(); + } catch (Throwable $failure) { + // A graphical failure is visible in its tab, never a field + // fallback or a dependency of independent Terminal playback. + $stageCanvasError = $failure->getMessage(); + } + } return ['frame' => $frame, 'totalFrames' => $preview->totalFrames(), 'fps' => $preview->fps(), 'lines' => $lines, 'terminalCanvas' => $this->describeTerminalCanvas($lines, max(1, $width), max(1, $height)), + 'stageCanvas' => $stageCanvas, 'stageCanvasError' => $stageCanvasError, 'cues' => array_values($preview->cuesAt($frame))]; } @@ -1982,6 +2154,9 @@ public function startCinematicPreview(int $index, int $width, int $height, bool ?? throw new SessionRefusal(sprintf('There is no %s %d.', $type->noun(), $index)); $fingerprint = hash('xxh128', serialize($asset->payload())); if ($keep && $this->cinematicPreview !== null && $this->cinematicPreviewId === $asset->id && $this->cinematicPreviewFingerprint === $fingerprint) { + if ($this->cinematicPreview->getScreenSize() !== [max(20, $width), max(8, $height)]) { + $this->cinematicPreview->resize($width, $height); + } return $this->describeCinematicPreview(); } $this->stopCinematicPreview(); @@ -2009,17 +2184,28 @@ public function startCinematicPreview(int $index, int $width, int $height, bool } /** - * Plays, pauses, steps, advances while playing, or restarts the + * Plays, pauses, steps, advances while playing, skips, or restarts the * cinematic previewed. An advance is the time since the last one, so the - * preview keeps real time however often it is asked. + * preview keeps real time however often it is asked. An accepted skip + * plays the authored finalizer, even when the preview was paused. * - * @param 'play'|'pause'|'step'|'tick'|'restart' $action + * @param 'play'|'pause'|'step'|'tick'|'skip'|'restart' $action * @return array The preview, as {@see describeCinematicPreview()}. - * @throws SessionRefusal When no cinematic is previewed or the action is unknown. + * @throws SessionRefusal When no cinematic is previewed, the action is unknown, or a skip is refused. */ public function controlCinematicPreview(string $action, float $seconds = 0.0): array { $preview = $this->cinematicPreview ?? throw new SessionRefusal('No cinematic is being previewed.'); + if ($action === 'skip') { + if ($preview->skip()) { + $preview->play(); + } elseif ($preview->failure() === null) { + throw new SessionRefusal('Skip refused: ' . ($preview->skipRefusalReason() ?? 'the Engine refused the skip.')); + } + + // A finalizer failure is the preview's normal failure state, not a policy refusal. + return $this->describeCinematicPreview(); + } match ($action) { 'play' => $preview->play(), 'pause' => $preview->pause(), @@ -2055,20 +2241,24 @@ public function stopCinematicPreview(): array * rejections, exactly as the game's renderer writes them), the frames it * should apply out, in order, each its FRAME payload without envelope. * - * The answer names the view's grid, which the window's scene session is built with. + * The answer names the current epoch and grid. Feedback for another epoch, + * or without one, is discarded before it can reach the graphical host. * * @param list $events - * @return array{grid: array{columns: int, rows: int, cellWidth: int, cellHeight: int}, messages: list}>} + * @param string|null $sessionId The epoch the window is answering; null permits initial empty discovery. + * @return array{sessionId: non-empty-string, diagnostics: list, grid: array{columns: int, rows: int, cellWidth: int, cellHeight: int}, messages: list}>} * @throws SessionRefusal When no cinematic is previewed or a line is not a renderer event. */ - public function exchangeCinematicScene(array $events): array + public function exchangeCinematicScene(array $events, ?string $sessionId = null): array { $preview = $this->cinematicPreview ?? throw new SessionRefusal('No cinematic is being previewed.'); $grid = $preview->getSceneGrid(); try { return [ + 'sessionId' => $preview->getSceneSessionId(), + 'diagnostics' => $preview->getDiagnostics(), 'grid' => ['columns' => $grid->columns, 'rows' => $grid->rows, 'cellWidth' => $grid->cellWidth, 'cellHeight' => $grid->cellHeight], - 'messages' => $preview->exchangeScene($events), + 'messages' => $preview->exchangeScene($events, $sessionId), ]; } catch (RendererProtocolException | RendererTransportException $error) { throw new SessionRefusal('The graphical preview stopped: ' . $error->getMessage(), previous: $error); @@ -2097,8 +2287,8 @@ public function detachCinematicScene(): array * editor window's graphical view of it ({@see exchangeEffectFieldScene()}) * carries on; a cinematic preview running ends first. * - * @return array{frame: int, totalFrames: int, fps: int, mapId: string, lines: list, issue: string|null} - * @throws SessionRefusal When the record is not an effect, it does not compile, or no starting position names a map. + * @return array{sessionId: non-empty-string, diagnostics: list, frame: int, totalFrames: int, fps: int, mapId: string, lines: list, issue: string|null} + * @throws SessionRefusal When the record is not an effect or it does not compile. */ public function showEffectOnField(int $index, int $frame, int $width, int $height): array { @@ -2112,9 +2302,6 @@ public function showEffectOnField(int $index, int $frame, int $width, int $heigh $start = $this->workspace->getSystemField('startingPositions'); $player = is_array($start) && is_array($start['player'] ?? null) ? $start['player'] : []; $mapId = is_string($player['destinationMap'] ?? null) ? trim($player['destinationMap']) : ''; - if ($mapId === '') { - throw new SessionRefusal('An effect is shown on the field where a new game starts; System names no player starting map.'); - } $spawn = new Vector2(intval($player['spawnPoint']['x'] ?? 0), intval($player['spawnPoint']['y'] ?? 0)); $key = implode(':', [$mapId, (int) $spawn->x, (int) $spawn->y, max(20, $width), max(8, $height)]); if ($this->effectField === null || $this->effectFieldKey !== $key) { @@ -2134,6 +2321,7 @@ public function showEffectOnField(int $index, int $frame, int $width, int $heigh } return ['frame' => $frame, 'totalFrames' => $timeline->totalFrames(), 'fps' => $timeline->fps(), 'mapId' => $mapId, + 'sessionId' => $this->effectField->sessionId, 'diagnostics' => $this->effectField->getDiagnostics(), 'lines' => array_values($this->effectField->frame()), 'issue' => $this->effectField->mapFailure]; } @@ -2142,17 +2330,20 @@ public function showEffectOnField(int $index, int $frame, int $width, int $heigh * effect is shown on, as {@see exchangeCinematicScene()} is for a cinematic. * * @param list $events - * @return array{grid: array{columns: int, rows: int, cellWidth: int, cellHeight: int}, messages: list}>} + * @param string|null $sessionId The epoch the window is answering; unqualified or stale events are discarded. + * @return array{sessionId: non-empty-string, diagnostics: list, grid: array{columns: int, rows: int, cellWidth: int, cellHeight: int}, messages: list}>} * @throws SessionRefusal When no effect is shown on the field or a line is not a renderer event. */ - public function exchangeEffectFieldScene(array $events): array + public function exchangeEffectFieldScene(array $events, ?string $sessionId = null): array { $field = $this->effectField ?? throw new SessionRefusal('No effect is shown on the field.'); $grid = $field->getSceneGrid(); try { return [ + 'sessionId' => $field->sessionId, + 'diagnostics' => $field->getDiagnostics(), 'grid' => ['columns' => $grid->columns, 'rows' => $grid->rows, 'cellWidth' => $grid->cellWidth, 'cellHeight' => $grid->cellHeight], - 'messages' => $field->exchangeScene($events), + 'messages' => $field->exchangeScene($events, $sessionId), ]; } catch (RendererProtocolException | RendererTransportException $error) { throw new SessionRefusal('The graphical preview stopped: ' . $error->getMessage(), previous: $error); @@ -2206,6 +2397,8 @@ private function describeCinematicPreview(): array return [ 'id' => $this->cinematicPreviewId, + 'sessionId' => $preview->getSceneSessionId(), + 'diagnostics' => $preview->getDiagnostics(), 'lines' => $lines, 'terminalCanvas' => $this->describeTerminalCanvas($lines, $columns, $rows), 'elapsed' => $preview->elapsed(), @@ -2507,12 +2700,12 @@ static function () use ($asset): void { * on save. * * @return array{changed: bool, stage: bool} - * @throws SessionRefusal When the record is not a summon with separate sequences, or cannot hold one. + * @throws SessionRefusal When the graphical sequence cannot safely own or release a stage. */ public function setCutsceneStage(string $category, int $index, bool $present): array { $asset = $this->requireTimelineCutscene($category, $index); - if ($asset->type !== CutsceneType::SUMMON || ! $asset->hasPresentations()) { + if ($asset->type === CutsceneType::SUMMON && ! $asset->hasPresentations()) { throw new SessionRefusal(sprintf('A stage is a summon\'s graphical sequence\'s own; separate %s\'s sequences first.', $asset->id)); } try { @@ -2552,7 +2745,8 @@ private function requireTimelineCutscene(string $category, int $index): Cutscene * regions) are read from the map named, and a cutscene's own (a summon's * cues) from the cutscene record named, as `{category, index}`. * - * @param array{category?: mixed, index?: mixed}|null $record The record being edited, when it gives the context. + * @param array{category?: mixed, index?: mixed, kind?: mixed, marker?: mixed, frame?: mixed, path?: mixed}|null $record + * Database {category,index,frame}, NPC {kind:'npc',index,frame}, or event {kind:'event',marker,path}. * @return list * @throws SessionRefusal When the category, map or record is unknown. */ @@ -2569,6 +2763,9 @@ public function listReferences(?string $mapId, string $category, ?array $record ?? throw new SessionRefusal(sprintf('There is no %s %s.', $type->noun(), strval($record['index'] ?? ''))); } $catalog = new ReferenceCatalog($this->workspace, $mapId === null ? null : $this->requireMap($mapId), $cutscene); + if ($category === 'cinematic_movement_routes' && $record !== null) { + $catalog = $catalog->createMovementContext($this->getMovementOwnerCommands($mapId, $record)); + } $labels = $catalog->labelsFor($category); return array_map(static fn(mixed $value): array => [ @@ -2577,6 +2774,35 @@ public function listReferences(?string $mapId, string $category, ?array $record ], array_values($catalog->valuesFor($category))); } + /** Resolve only the real source owner supplied by the row, never the current selection. */ + private function getMovementOwnerCommands(?string $mapId, array $owner): array + { + if (($owner['kind'] ?? '') === 'event') { + $map = $this->requireMap($mapId ?? throw new SessionRefusal('An event route reference requires its map.')); + $marker = is_string($owner['marker'] ?? null) ? $owner['marker'] : ''; + $event = $map->getEventDefinition($marker) + ?? throw new SessionRefusal('That event route owner is no longer present.'); + $path = self::requireFrame($owner['path'] ?? [], 'references.list'); + return MovementRouteFields::getOwnerCommands($event, $path); + } + if (($owner['kind'] ?? '') === 'npc') { + $map = $this->requireMap($mapId ?? throw new SessionRefusal('An NPC route reference requires its map.')); + $database = (new NpcInspector($map, graphical: true))->records(); + } else { + $category = is_string($owner['category'] ?? null) ? $owner['category'] : ''; + $database = $this->requireRecordDatabase($category); + } + $index = filter_var($owner['index'] ?? null, FILTER_VALIDATE_INT); + $record = $index === false || $index === null ? null : $database->getRecordByIndex($index); + if ($record === null) { throw new SessionRefusal('That route command owner is no longer present.'); } + $frame = self::requireFrame($owner['frame'] ?? [], 'references.list'); + if ($frame !== [] && $database->getFrameCommands($index, $frame) === null) { + throw new SessionRefusal('That route command frame is no longer present.'); + } + return MovementRouteFields::getOwnerCommands((array) $record->toArray(), + MovementRouteFields::getFrameOwnerPath($database->schema, $frame)); + } + /** * A troop's graphical formation as an arranger draws it, composed by the * Engine's BattleFormationLayout, the placement the battle itself uses: @@ -3060,13 +3286,13 @@ public function readDatabaseRecord(string $category, int $index, array $frame = 'listNoun' => $database->getListNoun($frame), // A row that names a field keeps its key whether or not it can be // edited; a read-only one is an `info` row the edit refuses. - 'rows' => array_map(static fn(array $field): array => self::describeRecordRow( + 'rows' => $this->describePlacementRows(array_map(static fn(array $field): array => self::describeRecordRow( $database, $index, $frame, $field, isset($field['field']) ? 'record' : null, - ), $fields), + ), $fields), ['kind' => 'database', 'category' => $category, 'index' => $index]), 'panes' => $frame === [] ? array_values(RecordPanes::describe($this->workspace, $category, $index)) : [], ]; } @@ -3586,7 +3812,7 @@ public function readNpc(string $mapId, int $index, array $frame = []): array { $frame = self::requireNpcFrame($frame); $map = $this->requireMap($mapId); - $inspector = new NpcInspector($map); + $inspector = new NpcInspector($map, graphical: true, references: new ReferenceCatalog($this->workspace, $map)); $npc = $map->getNpcs()->get($index) ?? throw new SessionRefusal(sprintf('%s has no NPC %d.', $mapId, $index)); $fields = $this->collectNpcFields($inspector, $index, $frame); $rows = array_map(static fn(array $field): array => self::describeNpcRow($inspector->records(), $index, $field, $frame), $fields); @@ -3605,7 +3831,8 @@ public function readNpc(string $mapId, int $index, array $frame = []): array 'y' => $npc->getY(), 'sprite' => $npc->getVisibleSprite(), ], - 'rows' => $frame === [] ? self::insertNpcFieldSheetRow($rows, $npc) : $rows, + 'rows' => $this->describePlacementRows($frame === [] ? self::insertNpcFieldSheetRow($rows, $npc, $map->getAssetRoot()) : $rows, + ['kind' => 'npc', 'map' => $mapId, 'index' => $index]), ]; } @@ -3621,7 +3848,7 @@ public function readNpc(string $mapId, int $index, array $frame = []): array * @param list> $rows * @return list> */ - private static function insertNpcFieldSheetRow(array $rows, ProjectNpc $npc): array + private static function insertNpcFieldSheetRow(array $rows, ProjectNpc $npc, string $assetRoot): array { $sprites = $npc->toArray()['sprites2d'] ?? null; $row = self::describeRow([ @@ -3633,11 +3860,33 @@ private static function insertNpcFieldSheetRow(array $rows, ProjectNpc $npc): ar 'target' => 'npc', ]); $row['key'] = [...$row['key'], 'frame' => []]; + $sheetRows = []; + if ($sprites !== null) { + $row['imagePreview'] = CharacterSheetPreview::describe($sprites, $assetRoot); + if (is_array($sprites) && is_string($sprites['sheet'] ?? null) && $sprites['sheet'] !== '') { + $characterIndex = $sprites['index'] ?? 0; + $layer = $sprites['layer'] ?? CharacterSheet::DEFAULT_LAYER; + $indexValue = is_scalar($characterIndex) ? strval($characterIndex) : '(invalid index)'; + $layerValue = is_scalar($layer) ? strval($layer) : '(invalid layer)'; + $sheetRows = [ + self::describeRow(['label' => ' Sheet Character Index', 'field' => 'sprites2d.index', + 'value' => $indexValue, 'target' => 'npc', + 'options' => CharacterSheetPreview::getIndexOptions($sprites['sheet'])]), + self::describeRow(['label' => ' Graphical Layer', 'field' => 'sprites2d.layer', + 'value' => $layerValue, 'target' => 'npc', + 'control' => new InputControl(InputControlType::INTEGER, $layerValue)]), + ]; + foreach ($sheetRows as &$sheetRow) { + $sheetRow['key']['frame'] = []; + } + unset($sheetRow); + } + } $appearance = array_keys(array_filter($rows, static fn(array $candidate): bool => in_array($candidate['key']['field'] ?? null, ['sprite', 'sprites.north', 'sprites.south', 'sprites.east', 'sprites.west'], true))); $at = $appearance === [] ? count($rows) : max($appearance) + 1; - return [...array_slice($rows, 0, $at), $row, ...array_slice($rows, $at)]; + return [...array_slice($rows, 0, $at), $row, ...$sheetRows, ...array_slice($rows, $at)]; } /** @@ -3652,13 +3901,23 @@ private static function insertNpcFieldSheetRow(array $rows, ProjectNpc $npc): ar public function applyNpc(string $mapId, int $revision, int $index, array $key, string $value): array { $map = $this->requireCurrentMap($mapId, $revision); - $inspector = new NpcInspector($map); + $inspector = new NpcInspector($map, graphical: true, references: new ReferenceCatalog($this->workspace, $map)); $frame = self::requireNpcFrame($key['frame'] ?? []); $fieldId = $key['field'] ?? null; if ($fieldId === self::NPC_FIELD_SHEET && $frame === []) { return $this->applyNpcChange($map, static fn(NpcAuthoring $authoring): NpcChange => $authoring->setFieldSheet($map, $index, $value === '' ? null : $value)); } + if (in_array($fieldId, ['sprites2d.index', 'sprites2d.layer'], true) && $frame === []) { + $integer = filter_var($value, FILTER_VALIDATE_INT); + if ($integer === false) { + throw new SessionRefusal('A sheet character index or graphical layer must be an integer.'); + } + + return $this->applyNpcChange($map, static fn(NpcAuthoring $authoring): NpcChange => $fieldId === 'sprites2d.index' + ? $authoring->setFieldSheetIndex($map, $index, $integer) + : $authoring->setFieldSheetLayer($map, $index, $integer)); + } // Field ids are unique within a frame, so the field and frame name the row. $field = is_string($fieldId) ? array_find($this->collectNpcFields($inspector, $index, $frame), static fn(array $candidate): bool => ($candidate['field'] ?? null) === $fieldId) : null; @@ -3687,7 +3946,7 @@ public function applyNpc(string $mapId, int $revision, int $index, array $key, s public function addNpcItem(string $mapId, int $revision, int $index, array $key): array { $map = $this->requireCurrentMap($mapId, $revision); - $inspector = new NpcInspector($map); + $inspector = new NpcInspector($map, graphical: true, references: new ReferenceCatalog($this->workspace, $map)); $frame = self::requireNpcFrame($key['frame'] ?? []); $fieldId = is_string($key['field'] ?? null) ? $key['field'] : ''; @@ -3707,7 +3966,7 @@ public function addNpcItem(string $mapId, int $revision, int $index, array $key) public function removeNpcItem(string $mapId, int $revision, int $index, array $key): array { $map = $this->requireCurrentMap($mapId, $revision); - $inspector = new NpcInspector($map); + $inspector = new NpcInspector($map, graphical: true, references: new ReferenceCatalog($this->workspace, $map)); $frame = self::requireNpcFrame($key['frame'] ?? []); $fieldId = is_string($key['field'] ?? null) ? $key['field'] : ''; @@ -3830,7 +4089,7 @@ public function saveMap(string $mapId): array $failures = $this->backups->backup(...$paths)['failed']; } }); - } catch (MapSourceRefusal $refusal) { + } catch (MapSourceRefusal|FileSetTransactionFailure $refusal) { throw new SessionRefusal($refusal->getMessage(), previous: $refusal); } @@ -4337,6 +4596,7 @@ private static function describeRow(array $field): array return array_filter([ 'label' => trim($label), + 'mapPlacement' => $field['mapPlacement'] ?? null, // A list entry's row: which entry (and nested entry) it is part of, and its name within it. 'entry' => is_array($field['entry'] ?? null) ? array_values($field['entry']) : null, 'entryLabel' => is_string($field['entryLabel'] ?? null) ? $field['entryLabel'] : null, @@ -4362,6 +4622,7 @@ private static function describeRow(array $field): array }, // What the value looks like, when the reference names a picture: shown with the row and in its picker. 'media' => $kind === 'reference' ? ReferenceCatalog::describeMedia((string) $field['reference']) : null, + 'imagePreview' => is_array($field['imagePreview'] ?? null) ? $field['imagePreview'] : null, // How the choice of nothing reads, where the row says (the Engine's own attack). 'noneLabel' => $kind === 'reference' && is_string($field['noneLabel'] ?? null) ? $field['noneLabel'] : null, 'action' => $kind === 'action' ? $field['action'] : null, diff --git a/src/Session/MapPlacementSession.php b/src/Session/MapPlacementSession.php new file mode 100644 index 0000000..ca296af --- /dev/null +++ b/src/Session/MapPlacementSession.php @@ -0,0 +1,202 @@ +requireCurrentMap($context['map'], $context['revision']); + } + $rows = match ($kind) { + 'database' => $this->readDatabaseRecord(strval($context['category'] ?? ''), $index, $frame)['rows'], + 'npc' => $this->readNpc($map->mapId, $index, $frame)['rows'], + 'event' => $this->readInspector($map->mapId, strval($key['marker'] ?? ''))['rows'], + default => throw new SessionRefusal('Unknown map-placement owner.'), + }; + $row = array_find($rows, static fn(array $row): bool => MapPlacement::areValuesEqual($row['key'] ?? null, $key)); + $placement = $row['mapPlacement'] ?? null; + if ($placement === null || !MapPlacement::areValuesEqual($placement, $expected) || ($row['kind'] ?? 'info') === 'info') { + throw new SessionRefusal('The authored placement changed or is read-only; open it again.'); + } + $preview = $this->requireCurrentMap($previewMap, $previewRevision); + try { + $preview->assertSourcesUnchanged(); + } catch (\RuntimeException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + if (($placement['issue'] ?? null) !== null) { + throw new SessionRefusal($placement['issue']); + } + if (!isset($placement['points'][$point]) || $point < 0) { + throw new SessionRefusal('Choose an authored point first.'); + } + if ($x < 0 || $y < 0 || $x >= $preview->getWidth() || $y >= $preview->getHeight()) { + throw new SessionRefusal('Choose a cell inside the displayed map.'); + } + if ($origin !== null && (!array_is_list($origin) || count($origin) !== 2 || !is_int($origin[0]) || !is_int($origin[1]) + || $origin[0] < 0 || $origin[1] < 0 || $origin[0] >= $preview->getWidth() || $origin[1] >= $preview->getHeight())) { + throw new SessionRefusal('Choose a preview origin inside the displayed map.'); + } + if ($placement['kind'] === 'event-transfer') { + return $this->setEventDestination($map->mapId, $context['revision'], $key['marker'], $previewMap, $x, $y); + } + try { + $values = MapPlacement::getChanges($placement, $point, $x, $y, $origin, $previewMap); + if ($placement['kind'] === 'route' && $origin !== null) { + $selected = $placement['points'][$point]; + $shift = $selected['faceOnly'] ? [0, 0] : [$x - $origin[0] - $selected['point'][0], $y - $origin[1] - $selected['point'][1]]; + foreach ($placement['points'] as $i => $step) { + $px = $origin[0] + $step['point'][0] + ($i >= $point ? $shift[0] : 0); + $py = $origin[1] + $step['point'][1] + ($i >= $point ? $shift[1] : 0); + if ($px < 0 || $py < 0 || $px >= $preview->getWidth() || $py >= $preview->getHeight()) { + throw new \InvalidArgumentException(sprintf('Step %d would leave the preview map; adjust its steps or preview origin first.', $i + 1)); + } + } + } + } catch (\InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + $changes = []; + foreach ($values as $field => $value) { + $found = array_find($rows, static fn(array $candidate): bool => $kind === 'event' + ? json_encode($candidate['key']['path'] ?? null, JSON_THROW_ON_ERROR) === $field + : ($candidate['key']['field'] ?? null) === $field); + if ($found === null || ($found['kind'] ?? 'info') === 'info') { + throw new SessionRefusal('That coordinate is not editable through the shared inspector.'); + } + if (($found['raw'] ?? $found['value'] ?? null) === $value) { + continue; + } + $changes[] = ['key' => $found['key'], 'value' => $value]; + } + if ($kind === 'database') { + return $this->applyDatabaseRecordValues($context['category'], $index, $changes, 'Place on map'); + } + $commands = []; + try { + foreach ($changes as $change) { + if ($kind === 'npc') { + $inspector = new NpcInspector($map, graphical: true, references: new ReferenceCatalog($this->workspace, $map)); + $field = array_find($this->collectNpcFields($inspector, $index, $frame), + static fn(array $field): bool => ($field['field'] ?? null) === $change['key']['field']); + $command = (new NpcAuthoring($this->workspace))->applyField($inspector, $index, $frame, $field, $change['value'])->command; + } else { + $field = $this->requireInspectorField($map, $change['key']); + $command = $this->createMapInspector($map)->apply($map, $field, $change['value']); + } + if ($command !== null) { + $commands[] = $command; + } + } + } catch (\Throwable $error) { + foreach (array_reverse($commands) as $command) { + $command->undo(); + } + throw new SessionRefusal($error->getMessage(), previous: $error); + } + if ($commands !== []) { + $this->history->record(new CommandGroup('Place on map', $commands)); + } + return ['changed' => $commands !== [], 'revision' => $this->getMapRevision($map)]; + } + + /** Attach a review of the actual owner, never a second editable model. */ + private function describePlacementRows(array $rows, array $owner): array + { + $hasPlacement = array_any($rows, static fn(array $row): bool => isset($row['mapPlacement'])); + if (!$hasPlacement && $owner['kind'] === 'database' && CutsceneType::findByRecordCategory($owner['category']) === null) { + return $rows; + } + $prefix = []; + $stripRoot = null; + $version = null; + $paths = []; + $document = null; + $sourceIssue = null; + try { + if ($owner['kind'] === 'database') { + $database = $this->requireRecordDatabase($owner['category']); + $record = $database->getRecordByIndex($owner['index']) + ?? throw new SessionRefusal('That placement record is gone.'); + $type = CutsceneType::findByRecordCategory($owner['category']); + if ($type !== null) { + $id = $this->workspace->cutscenes?->ids($type)[$owner['index']] ?? null; + $asset = ($id === null ? null : $this->workspace->cutscenes?->find($type, $id)) + ?? throw new SessionRefusal('That placement asset is gone.'); + $asset->assertSourcesUnchanged(); + $paths = $asset->paths(); + $sourcePath = $paths[array_key_last($paths)]; + $stripRoot = 'commands'; + $version = [spl_object_id($asset), $asset->stateVersion()]; + } else { + $sourcePath = $record->sourcePath ?? throw new SessionRefusal('This placement has no supported source-file owner.'); + $paths = [$sourcePath]; + $stripRoot = $database->schema->listPayloadKey; + $version = [spl_object_id($database), $database->getContentVersion(), $record->recordId]; + // A late external rewrite is not part of the loaded record. + $record->file?->composeContents($record->file->payload, checkDisk: true); + } + } else { + $map = $this->requireMap($owner['map']); + $map->assertSourcesUnchanged(); + $sourcePath = $map->dataPath; + $paths = [$sourcePath]; + $prefix = $owner['kind'] === 'npc' ? ['npcs', $owner['index']] : ['events', $owner['marker']]; + } + if (!$hasPlacement) { + return $rows; + } + $document = PhpArraySourceDocument::parse((string) file_get_contents($sourcePath)); + } catch (\Throwable $error) { + if (!$hasPlacement) { + throw new SessionRefusal('Authoring cannot safely read its current source: ' . $error->getMessage(), previous: $error); + } + $sourceIssue = 'Map placement cannot safely read its current source: ' . $error->getMessage(); + } + $sources = []; + foreach ($paths as $path) { + $sources[$path] = is_file($path) ? hash_file('sha256', $path) : null; + } + foreach ($rows as &$row) { + if (!isset($row['mapPlacement'])) { + continue; + } + $placement = &$row['mapPlacement']; + $placement['review'] = ['version' => $version, 'sources' => $sources]; + $sourcePaths = array_map(static function (array $path) use ($prefix, $stripRoot): array { + if ($stripRoot !== null && ($path[0] ?? null) === $stripRoot) { + array_shift($path); + } + return [...$prefix, ...$path]; + }, $placement['sourcePaths'] ?? []); + $issue = $sourceIssue ?? ($document === null ? null : MapPlacement::getSourceIssue($document, $sourcePaths)); + $placement['issue'] ??= $issue; + unset($placement); + } + unset($row); + return $rows; + } +} diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index d0efec6..15b5fc4 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -125,7 +125,17 @@ private function dispatch(string $method, array $params): array self::requireInt($params, 'width'), self::requireInt($params, 'height'), ), + 'map.duplicate' => $session->duplicateMap(self::requireString($params, 'map'), self::requireInt($params, 'revision')), + 'map.move' => $session->moveMap( + self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::readOptionalString($params, 'destination'), ($params['confirm'] ?? false) === true, + ), 'map.delete' => $session->deleteMap(self::requireString($params, 'map'), ($params['confirm'] ?? false) === true), + 'map.insertLines' => $session->insertMapLines( + self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'axis'), self::requireInt($params, 'at'), self::requireInt($params, 'count'), + self::readOptionalString($params, 'answer'), self::readOptionalString($params, 'confirm'), + ), 'map.read' => $session->readMap(self::requireString($params, 'map')), 'map.world' => $session->readWorld(self::requireString($params, 'map'), is_bool($params['tileShadows'] ?? false) ? ($params['tileShadows'] ?? false) : throw new InvalidRequest('"tileShadows" must be a boolean.')), @@ -283,6 +293,15 @@ private function dispatch(string $method, array $params): array ? $params['movesWith'] : throw new InvalidRequest('"movesWith" must be a gameplay layer name or null.'), ), 'map.save' => $session->saveMap(self::requireString($params, 'map')), + 'mapPlacement.apply' => $session->applyMapPlacement( + is_array($params['context'] ?? null) ? $params['context'] : throw new InvalidRequest('A placement needs its owner.'), + self::requireKey($params), + is_array($params['expected'] ?? null) ? $params['expected'] : throw new InvalidRequest('A placement needs its descriptor.'), + self::requireString($params, 'previewMap'), self::requireInt($params, 'point'), + self::requireInt($params, 'x'), self::requireInt($params, 'y'), + isset($params['origin']) ? self::requireCell($params, 'origin') : null, + self::requireInt($params, 'previewRevision'), + ), 'inspector.read' => $session->readInspector( self::requireString($params, 'map'), is_string($params['event'] ?? null) ? $params['event'] : null, @@ -400,11 +419,11 @@ private function dispatch(string $method, array $params): array 'cutscenes.cinematicControl' => $session->controlCinematicPreview(self::requireString($params, 'action'), is_int($params['seconds'] ?? null) || is_float($params['seconds'] ?? null) ? (float) $params['seconds'] : 0.0), 'cutscenes.cinematicStop' => $session->stopCinematicPreview(), - 'cutscenes.cinematicScene' => $session->exchangeCinematicScene(self::requireLines($params, 'events')), + 'cutscenes.cinematicScene' => $session->exchangeCinematicScene(self::requireLines($params, 'events'), self::readOptionalString($params, 'sessionId')), 'cutscenes.cinematicSceneDetach' => $session->detachCinematicScene(), 'cutscenes.effectField' => $session->showEffectOnField(self::requireInt($params, 'index'), is_int($params['frame'] ?? null) ? $params['frame'] : 0, self::requireInt($params, 'width'), self::requireInt($params, 'height')), - 'cutscenes.effectFieldScene' => $session->exchangeEffectFieldScene(self::requireLines($params, 'events')), + 'cutscenes.effectFieldScene' => $session->exchangeEffectFieldScene(self::requireLines($params, 'events'), self::readOptionalString($params, 'sessionId')), 'cutscenes.effectFieldClose' => $session->closeEffectField(), 'cutscenes.effectBattlePreview' => $session->readEffectBattlePreview(self::requireInt($params, 'index'), is_int($params['frame'] ?? null) ? $params['frame'] : 0, ($params['reducedMotion'] ?? false) === true, @@ -567,8 +586,8 @@ private static function requireCells(array $params): array */ private static function requireLines(array $params, string $key): array { - $lines = $params[$key] ?? []; - if (! is_array($lines) || ! array_is_list($lines) || ! array_all($lines, is_string(...))) { + $lines = array_key_exists($key, $params) ? $params[$key] : []; + if (! is_array($lines) || ! array_is_list($lines) || ! array_all($lines, static fn(mixed $line): bool => is_string($line))) { throw new InvalidRequest(sprintf('"%s" must be a list of strings.', $key)); } diff --git a/src/Storage/FileSetTransaction.php b/src/Storage/FileSetTransaction.php index e3189e1..437978e 100644 --- a/src/Storage/FileSetTransaction.php +++ b/src/Storage/FileSetTransaction.php @@ -57,6 +57,9 @@ final class FileSetTransaction */ private array $original = []; + /** @var array Read dependencies checked before any write. */ + private array $expectedSources = []; + /** * @var string[] Folders this transaction created, deepest last. */ @@ -105,6 +108,31 @@ public function remove(string $path): void $this->targets[] = ['path' => $path, 'intent' => self::INTENT_REMOVE, 'contents' => null]; } + /** Requires the source used to build this proposal to remain unchanged. */ + public function expectSource(string $path, ?string $contents, bool $allowMissing = false): void + { + $this->expectedSources[$path] = ['contents' => $contents, 'allowMissing' => $allowMissing]; + } + + /** Checks loaded bytes without staging, creating folders or adopting new baselines. */ + public function assertSourcesUnchanged(): void + { + foreach ($this->expectedSources as $path => $expected) { + clearstatcache(true, $path); + $metadata = $this->files->isFile($path) ? $this->files->metadata($path) : null; + $contents = $this->files->read($path); + clearstatcache(true, $path); + $after = $this->files->isFile($path) ? $this->files->metadata($path) : null; + // Restore only a read-induced access-time change, never another author's metadata. + if ($metadata !== null && $after !== null && $after['accessedAt'] !== $metadata['accessedAt'] + && array_diff_assoc($after, $metadata) === ['accessedAt' => $after['accessedAt']] + && ! $this->files->restoreMetadata($path, $metadata)) { + throw new FileSetTransactionFailure(sprintf('%s access metadata could not be preserved during source review', $path)); + } + $this->assertExpectedSource($path, $expected, $contents); + } + } + /** * Whether anything has been declared. */ @@ -168,12 +196,7 @@ private function stageReserved(): array throw new FileSetTransactionFailure(sprintf('%s already exists', $this->folder)); } - foreach ($this->targets as $target) { - $path = $target['path']; - - if (isset($this->original[$path])) { - continue; - } + foreach (array_unique([...array_keys($this->expectedSources), ...$this->paths()]) as $path) { if ($this->files->isFile($path)) { // Capture metadata before reading: even a read can update the @@ -199,6 +222,10 @@ private function stageReserved(): array $this->original[$path] = ['contents' => null, 'metadata' => null]; } + foreach ($this->expectedSources as $path => $expected) { + $this->assertExpectedSource($path, $expected, $this->original[$path]['contents']); + } + foreach ($foldersToCreate as $missing) { if (! $this->files->makeDirectory($missing)) { $this->discard(); @@ -264,6 +291,16 @@ public function commit( $this->stage(); } + // Callers may evaluate staged files before accepting them. Refuse an + // external edit made during that review, before installing anything. + try { + $this->assertSourcesUnchanged(); + } catch (Throwable $error) { + $this->isFinished = true; + $this->discard(); + throw $error; + } + if ($backup !== null) { // Once, before any destructive work, for the whole set. The // accepted backup policy normally absorbs its own failures; a @@ -290,6 +327,15 @@ public function commit( } } + // A backup callback may yield to an external author too. + try { + $this->assertSourcesUnchanged(); + } catch (Throwable $error) { + $this->isFinished = true; + $this->discard(); + throw $error; + } + $installed = []; foreach ($this->targets as $target) { @@ -358,6 +404,24 @@ public function rollBack(): void $this->discard(); } + /** @param array{contents: ?string, allowMissing: bool} $expected */ + private function assertExpectedSource(string $path, array $expected, ?string $contents): void + { + if (($contents !== null && $contents === $expected['contents']) + || ($contents === null && $expected['contents'] === null + && ! $this->files->isFile($path) && ! $this->files->isDirectory($path)) + || ($contents === null && $expected['allowMissing'] + && ! $this->files->isFile($path) && ! $this->files->isDirectory($path)) + ) { + return; + } + + throw new FileSetTransactionFailure(sprintf( + '%s changed after opening; reload before editing, saving, copying or moving', + $path, + )); + } + /** * Restores everything already installed, then refuses. * @@ -471,8 +535,8 @@ private function acquireReservations(): void } $directories = array_values(array_unique(array_map( - static fn(array $target): string => rtrim(dirname($target['path']), DIRECTORY_SEPARATOR), - $this->targets, + static fn(string $path): string => rtrim(dirname($path), DIRECTORY_SEPARATOR), + [...$this->paths(), ...array_keys($this->expectedSources)], ))); if ($directories === []) { diff --git a/src/Validation/EffectValidator.php b/src/Validation/EffectValidator.php index b7de80d..62136bc 100644 --- a/src/Validation/EffectValidator.php +++ b/src/Validation/EffectValidator.php @@ -8,10 +8,14 @@ use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\ProjectConfig; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Engine\Animations\Field\FieldEffectManager; use Ichiloto\Engine\Animations\Field\FieldPresentationCatalog; use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; +use Ichiloto\Engine\Cutscenes\Presentation\PartyStageSelection; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandReference; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Throwable; @@ -22,8 +26,8 @@ * effects and tileset pieces' effects on the field; inns' rest stages as a * stage of their own. Battle and field effects are compiled for both the * terminal and the graphical presentation, so a sequence that only one - * renderer would refuse is still found; a stage is admitted as the Engine's - * stage loading admits it. + * renderer would refuse is still found; graphical stages use stage admission, + * while a paired Terminal sequence retains its own cues and tracks. */ final class EffectValidator { @@ -43,9 +47,12 @@ public function validate(ProjectWorkspace $workspace): array if ($context === 'stage') { try { $library->loadStage($effect); + $asset = $workspace->cutscenes?->find(CutsceneType::EFFECT, $effect) + ?? throw new \RuntimeException('The effect timeline could not be read.'); + $asset->compileEffect(EffectPresentation::TERMINAL, false, forStage: true); } catch (Throwable $failure) { $issues[] = Issue::error($places[0], sprintf('Effect %s cannot be played as a stage: %s', $effect, $failure->getMessage()), - sprintf('Fix assets/Animations/%s, or choose a stage timeline. Until then the rest shows no graphical stage.%s', $effect, + sprintf('Fix assets/Animations/%s, or choose a stage timeline. Until then its consumer cannot show the stage.%s', $effect, count($places) > 1 ? sprintf(' Also used by %d more.', count($places) - 1) : '')); } continue; @@ -127,7 +134,7 @@ public static function findUses(ProjectWorkspace $workspace, array &$issues = [] } } - foreach (self::findStageUses($workspace) as $effect => $places) { + foreach (self::findStageUses($workspace, $issues) as $effect => $places) { $uses[$effect]['stage'] = $places; } @@ -182,14 +189,36 @@ public static function findUses(ProjectWorkspace $workspace, array &$issues = [] * event script or cinematic, and config's graphics.inn.presentation for * an inn naming none. * + * @param Issue[] $issues + * @param-out Issue[] $issues * @return array> Timeline id to where it is named. */ - private static function findStageUses(ProjectWorkspace $workspace): array + private static function findStageUses(ProjectWorkspace $workspace, array &$issues): array { $uses = []; - $name = static function (mixed $presentation, string $where) use (&$uses): void { + $actors = new ReferenceCatalog($workspace)->valuesFor('actor_ids'); + $name = static function (mixed $presentation, string $where) use (&$uses, &$issues, $actors): void { if (is_string($presentation) && trim($presentation) !== '') { $uses[trim($presentation)][] = $where; + } elseif (is_array($presentation) || $presentation instanceof PartyStageSelection) { + try { + $selection = $presentation instanceof PartyStageSelection ? $presentation : PartyStageSelection::fromArray($presentation); + $bindings = $selection->toArray(); + $subjects = array_keys($bindings['leaders']); + foreach ($bindings['parties'] as $binding) { $subjects = [...$subjects, ...$binding['actors']]; } + foreach (array_unique(array_map('strval', $subjects)) as $actor) { + if (! in_array($actor, $actors, true)) { + $issues[] = Issue::error($where, sprintf('Its rest presentation names unknown actor identity %s.', $actor), + 'Choose a stable project actor identity; do not use a display name or guessed guest.'); + } + } + foreach ($selection->getTimelineIds() as $timeline) { + $uses[$timeline][] = $where; + } + } catch (Throwable $failure) { + $issues[] = Issue::error($where, 'Its rest presentation is invalid: ' . $failure->getMessage(), + 'Choose explicit leader or party treatment and valid bindings; no implicit fallback is used.'); + } } }; @@ -201,8 +230,16 @@ private static function findStageUses(ProjectWorkspace $workspace): array } } - self::visitCommands($workspace, $workspace->cutscenes?->assets(CutsceneType::CINEMATIC) ?? [], 'inn', - static fn(array $command, string $where) => $name($command['presentation'] ?? null, $where)); + self::visitCommands($workspace, $workspace->cutscenes?->assets(CutsceneType::CINEMATIC) ?? [], null, + static function (array $command, string $where) use ($name): void { + $definition = is_string($command['type'] ?? null) ? (ScriptCommandRegistry::getCatalog()->definitions[$command['type']] ?? null) : null; + foreach ($definition?->fields ?? [] as $field) { + if ($field->reference !== ScriptCommandReference::STAGE_TIMELINE) { continue; } + $value = $command; + foreach (explode('.', $field->key) as $segment) { $value = is_array($value) ? ($value[$segment] ?? null) : null; } + $name($value, $where); + } + }); $name($workspace->config?->getRecord(ProjectConfig::INN_PRESENTATION)->get('value'), 'config.php: ' . ProjectConfig::INN_PRESENTATION); return array_map(static fn(array $places): array => array_values(array_unique($places)), $uses); @@ -251,14 +288,14 @@ public static function visitFieldAnimations(ProjectWorkspace $workspace, iterabl * @param iterable $cinematics The project's cinematics, as the editor holds them. * @param \Closure(array, string): void $visit Given the command and where it is. */ - private static function visitCommands(ProjectWorkspace $workspace, iterable $cinematics, string $type, \Closure $visit): void + private static function visitCommands(ProjectWorkspace $workspace, iterable $cinematics, ?string $type, \Closure $visit): void { $collect = static function (mixed $value, string $where) use (&$collect, $visit, $type): void { if (! is_array($value)) { return; } - if (($value['type'] ?? null) === $type) { + if (($type === null && is_string($value['type'] ?? null)) || ($type !== null && ($value['type'] ?? null) === $type)) { $visit($value, $where); } @@ -271,6 +308,10 @@ private static function visitCommands(ProjectWorkspace $workspace, iterable $cin foreach ((array) ($map->data['events'] ?? []) as $marker => $definition) { $collect($definition, sprintf('map %s event %s', $map->mapId, strval($marker))); } + foreach ((array) ($map->data['npcs'] ?? []) as $index => $npc) { + $id = is_array($npc) && is_string($npc['id'] ?? null) ? $npc['id'] : strval($index); + $collect($npc, sprintf('map %s NPC %s', $map->mapId, $id)); + } } foreach ($workspace->getRecordDatabase('common_events')?->getRecords() ?? [] as $record) { diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php index 9274b31..074eedf 100644 --- a/src/Validation/MapGraphicsValidator.php +++ b/src/Validation/MapGraphicsValidator.php @@ -226,6 +226,11 @@ private static function checkStalePieceTiles(ProjectMap $map, Tileset $tileset, // Every role a glyph on the map could play accounts for its tiles, // including those of a piece's blank cells beside it. $explained = []; + $kept = []; + $entries = []; + foreach ($layers as $tileLayer) { + $entries[$tileLayer->name] = $tileLayer->getEntries(); + } foreach ($map->getLayers() as $layer) { if ($layer['id'] === MapLayers::EVENT || $layer['decoration']) { continue; @@ -242,6 +247,19 @@ private static function checkStalePieceTiles(ProjectMap $map, Tileset $tileset, $explained[$tileLayer][$y + $cell['dy']][$x + $cell['dx']][(int) $cell['entry']] = true; } } + // A matching role explains any underlay it keeps, only at its owned cells. + if (array_filter($role->getOwnEntries(), static fn(string $entry, string $tileLayer): bool => + ($entries[$tileLayer][$y][$x] ?? (string) TileId::EMPTY) !== $entry, ARRAY_FILTER_USE_BOTH) !== []) { + continue; + } + foreach ($role->keeps as $tileLayer => $cells) { + foreach ($cells as $cell) { + [$cellX, $cellY] = [$x + $cell['dx'], $y + $cell['dy']]; + if ($map->hasLayerCell($layer['id'], $cellX, $cellY)) { + $kept[$tileLayer][$cellY][$cellX] = true; + } + } + } } } } @@ -252,7 +270,8 @@ private static function checkStalePieceTiles(ProjectMap $map, Tileset $tileset, $stale = []; foreach ($layer->tiles as $y => $row) { foreach ($row as $x => $id) { - if (isset($drawn[$layer->name][$id]) && ! isset($explained[$layer->name][$y][$x][$id])) { + if (isset($drawn[$layer->name][$id]) && ! isset($explained[$layer->name][$y][$x][$id]) + && ! isset($kept[$layer->name][$y][$x])) { $stale[] = "({$x}, {$y})"; } } diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 243686c..1231833 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -36,6 +36,7 @@ use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; use Ichiloto\Engine\Events\Interpreter\MovementRouteRunner; +use Ichiloto\Engine\Events\Triggers\EventCueKind; use Ichiloto\Engine\IO\Console\TerminalText; use InvalidArgumentException; use Throwable; @@ -4207,8 +4208,14 @@ protected function checkEventCue(mixed $cue, string $where, array $known): array } $issues = []; - foreach (array_diff(array_keys($cue), ['symbol', 'color', 'conditions']) as $field) { - $issues[] = Issue::error($where, sprintf('Its event cue uses unsupported field "%s".', $field), 'Use symbol, color, or conditions.'); + foreach (array_diff(array_keys($cue), ['symbol', 'color', 'conditions', 'kind']) as $field) { + $issues[] = Issue::error($where, sprintf('Its event cue uses unsupported field "%s".', $field), 'Use symbol, color, conditions, or kind.'); + } + + // Kind is a contract even for a cue whose blank symbol disables display. + if (array_key_exists('kind', $cue) + && (! is_string($cue['kind']) || EventCueKind::tryFrom($cue['kind']) === null)) { + $issues[] = Issue::error($where, 'Its authored event cue kind must be story or route.', 'Choose story or route, or omit kind for an unclassified cue.'); } $issues = [ diff --git a/tests/Unit/CharacterSheetPreviewTest.php b/tests/Unit/CharacterSheetPreviewTest.php new file mode 100644 index 0000000..dead577 --- /dev/null +++ b/tests/Unit/CharacterSheetPreviewTest.php @@ -0,0 +1,41 @@ + $asset, 'index' => $index, 'layer' => 10], $root . '/assets'); + expect($preview)->toBe(['frames' => [ + ['label' => 'South', 'sourceRect' => ['x' => $x, 'y' => $y, 'width' => 4, 'height' => 4]], + ['label' => 'West', 'sourceRect' => ['x' => $x, 'y' => $y + 4, 'width' => 4, 'height' => 4]], + ['label' => 'East', 'sourceRect' => ['x' => $x, 'y' => $y + 8, 'width' => 4, 'height' => 4]], + ['label' => 'North', 'sourceRect' => ['x' => $x, 'y' => $y + 12, 'width' => 4, 'height' => 4]], + ]]); +})->with([ + ['People.png', 0, 48, 32, 4, 0], ['People.png', 3, 48, 32, 40, 0], + ['People.png', 4, 48, 32, 4, 16], ['People.png', 7, 48, 32, 40, 16], + ['$Guard.png', 0, 12, 16, 4, 0], ['!$Door.png', 0, 12, 16, 4, 0], ['$!Door.png', 0, 12, 16, 4, 0], +]); + +it('reports malformed sheet data and unavailable owner roots without manufacturing frames', function (mixed $data, bool $hasRoot) { + $root = makeTemporaryProject('sheet-preview-invalid-'); + writeTilesetTestPng($root . '/assets/People.png', 48, 32); + $preview = CharacterSheetPreview::describe($data, $hasRoot ? $root . '/assets' : null); + expect($preview['frames'])->toBe([])->and($preview['issue'])->not->toBeEmpty(); +})->with([ + [null, true], [[], true], [['sheet' => 'People.png', 'index' => 8], true], + [['sheet' => 'People.png', 'index' => '2'], true], [['sheet' => 'People.png', 'index' => []], true], + [['sheet' => 'People.png', 'layer' => 1000], true], [['sheet' => 'People.png', 'width' => 48], true], + [['sheet' => '../People.png'], true], [['sheet' => 'People.png'], false], +]); + +it('uses runtime filename semantics for character choices rather than inferring from image dimensions', function () { + expect(CharacterSheetPreview::getIndexOptions('People.png'))->toBe(['0', '1', '2', '3', '4', '5', '6', '7']) + ->and(CharacterSheetPreview::getIndexOptions('!$Door.png'))->toBe(['0']) + ->and(CharacterSheetPreview::getIndexOptions('$!Door.png'))->toBe(['0']) + ->and(CharacterSheetPreview::getIndexOptions('People$Guard.png'))->toHaveCount(8) + ->and(CharacterSheetPreview::getIndexOptions('../People.png'))->toBe([]); +}); diff --git a/tests/Unit/Cutscenes/CinematicNarrationPresentationTest.php b/tests/Unit/Cutscenes/CinematicNarrationPresentationTest.php new file mode 100644 index 0000000..860cccb --- /dev/null +++ b/tests/Unit/Cutscenes/CinematicNarrationPresentationTest.php @@ -0,0 +1,198 @@ +}> $messages */ +function getNarrationPreviewFrame(array $messages): array +{ + $frames = array_values(array_filter($messages, static fn(array $message): bool => $message['type'] === 'frame')); + $last = $frames[array_key_last($frames)]['payload'] ?? throw new RuntimeException('No preview frame was sent.'); + if (! $last['present']) { + throw new RuntimeException('The preview frame upload has not finished.'); + } + $operations = []; + $reset = false; + $viewport = []; + // A retained frame may span several packets, with reset and images before the final present packet. + foreach ($frames as $message) { + $packet = $message['payload']; + if ($packet['frame'] !== $last['frame']) { + continue; + } + array_push($operations, ...$packet['operations']); + $reset = $reset || $packet['reset']; + if (array_key_exists('viewport', $packet)) { + $viewport = ['viewport' => $packet['viewport']]; + } + } + + return [...$last, 'operations' => $operations, 'reset' => $reset, ...$viewport]; +} + +/** Returns the objects written by this frame, not objects retained from earlier frames. */ +function getNarrationPreviewObjects(array $frame, string $kind): array +{ + return array_values(array_map(static fn(array $operation): array => $operation['value'], + array_filter($frame['operations'], static fn(array $operation): bool => $operation['op'] === 'put' + && $operation['kind'] === $kind))); +} + +function acknowledgeNarrationPreviewFrame(array $frame): string +{ + return json_encode(['protocol' => 2, 'type' => 'frame_ack', 'generation' => $frame['generation'], + 'frame' => $frame['frame'], 'presented' => true], JSON_THROW_ON_ERROR); +} + +beforeEach(function () { + $this->root = cutsceneProject(); + writeOpeningMaps($this->root); + $directory = $this->root . '/assets/Data/Presentation'; + if (! is_dir($directory)) { + mkdir($directory, 0777, true); + } + $artDirectory = $this->root . '/assets/Graphics/UI'; + if (! is_dir($artDirectory)) { + mkdir($artDirectory, 0777, true); + } + writeTilesetTestPng($artDirectory . '/narration-frame.png', 12, 12); + $frame = ['asset' => 'Graphics/UI/narration-frame.png', 'cuts' => [2, 2, 2, 2]]; + file_put_contents($directory . '/dialogue.php', ' 'ichiloto.dialogue/1', + 'theme' => ['schema' => 'ichiloto.menu/1', 'colors' => ['text' => [31, 79, 113]], + 'frames' => ['dialogue' => $frame, 'nameplate' => $frame]], + ], true) . ';'); + $this->text = "Synthetic narration stays readable while the camera travels across the field.\n" + . 'A separate line belongs to the same timed cue.'; + $this->definition = CinematicDefinition::fromArrays([ + 'id' => 'preview-narration', 'name' => 'Preview Narration', 'startMap' => 'skyfield-night', + ], [ + ['type' => 'parallel', 'lanes' => [ + ['id' => 'words', 'commands' => [['type' => 'narration', 'title' => 'Preview narrator', + 'text' => $this->text, 'seconds' => 20.0]]], + // Begin the pan after READY has selected the graphical field's viewport. + ['id' => 'camera', 'commands' => [ + ['type' => 'wait', 'seconds' => 0.2], + ['type' => 'camera', 'operation' => 'pan', + 'target' => ['kind' => 'position', 'x' => 45, 'y' => 8], 'seconds' => 2.0], + ]], + ]], + ]); +}); + +it('uses the themed wide dialogue reading area without controls or duplicate ASCII while the field camera advances', function () { + $preview = CinematicPreviewSession::start($this->root, $this->definition, + ['x' => 10, 'y' => 8, 'width' => 100, 'height' => 30]); + + try { + $preview->step(); + expect($preview->failure())->toBeNull(); + $first = getNarrationPreviewFrame($preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId())); + $images = getNarrationPreviewObjects($first, 'canvas_image'); + $body = array_values(array_filter($images, static fn(array $image): bool => str_contains($image['id'], 'dialogue-body'))); + expect($body)->not->toBeEmpty(); + foreach ($body as $image) { + expect($image['asset'])->toBe('Graphics/UI/narration-frame.png'); + } + + $text = getNarrationPreviewObjects($first, 'canvas_text'); + $prose = array_find($text, static fn(array $layer): bool => str_ends_with($layer['id'], 'dialogue-text')); + expect($prose)->not->toBeNull(); + $grid = $preview->getSceneGrid(); + $width = $grid->columns * $grid->cellWidth; + $height = $grid->rows * $grid->cellHeight; + $bounds = $prose['clipRect']; + expect($bounds['width'])->toBeGreaterThan($width * 0.75) + ->and($bounds['width'])->toBeGreaterThan($bounds['height'] * 3) + ->and($bounds['x'])->toBeGreaterThanOrEqual(0) + ->and($bounds['x'] + $bounds['width'])->toBeLessThanOrEqual($width) + ->and($bounds['y'])->toBeGreaterThanOrEqual(0) + ->and($bounds['y'] + $bounds['height'])->toBeLessThanOrEqual($height) + ->and(implode("\n", array_column($prose['runs'], 'text')))->toBe($this->text) + ->and($prose['runs'][0]['foreground'])->toBe(['kind' => 'rgb', 'r' => 31, 'g' => 79, 'b' => 113]); + expect(array_any([...$text, ...$images], static fn(array $object): bool => preg_match( + '/dialogue-(auto|advance|ready)/', $object['id']) === 1))->toBeFalse(); + $scalarText = getNarrationPreviewObjects($first, 'text'); + expect(array_column($scalarText, 'id'))->not->toContain('cinematic-overlay') + ->and(implode(' ', array_merge([], ...array_map(static fn(array $layer): array => array_column($layer['runs'], 'text'), $text)))) + ->not->toContain('Auto Off', 'Auto On', 'Continue', 'Reveal') + ->and(getNarrationPreviewObjects($first, 'world'))->not->toBeEmpty() + ->and($first['viewport']['worldId'])->not->toBeEmpty() + ->and($preview->confirm())->toBeFalse() + ->and($preview->hasActiveSession())->toBeTrue(); + + $before = $preview->snapshot()->values; + $preview->play(); + $preview->tick(0.5); + $preview->tick(0.5); + $next = getNarrationPreviewFrame($preview->exchangeScene([acknowledgeNarrationPreviewFrame($first)], $preview->getSceneSessionId())); + $after = $preview->snapshot()->values; + expect($after['camera'])->not->toBe($before['camera']) + ->and($after['player position'])->toBe($before['player position']) + ->and($after['map'])->toBe($before['map']) + ->and($next['viewport']['worldOrigin'])->not->toBe($first['viewport']['worldOrigin']) + ->and($next['viewport']['worldId'])->toBe($first['viewport']['worldId']) + ->and($preview->hasActiveSession())->toBeTrue(); + + $preview->stop(); + $stopped = getNarrationPreviewFrame($preview->exchangeScene([acknowledgeNarrationPreviewFrame($next)], $preview->getSceneSessionId())); + $removed = array_column(array_filter($stopped['operations'], static fn(array $operation): bool => $operation['op'] === 'remove'), 'id'); + expect($removed)->toContain($prose['id'], ...array_column($body, 'id')) + ->and($preview->status())->toBe(CinematicPreviewSession::STATUS_STOPPED) + ->and($preview->hasActiveSession())->toBeFalse() + ->and(implode("\n", array_map(TerminalText::stripAnsi(...), $preview->frame()))) + ->not->toContain('Synthetic narration', 'Preview narrator'); + } finally { + $preview->dispose(); + } +}); + +it('returns to terminal narration on detach and does not retain stopped text on reattachment', function () { + $preview = CinematicPreviewSession::start($this->root, $this->definition, + ['x' => 10, 'y' => 8, 'width' => 100, 'height' => 30]); + + try { + $preview->step(); + $first = getNarrationPreviewFrame($preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId())); + expect(getNarrationPreviewObjects($first, 'canvas_text'))->not->toBeEmpty(); + $preview->detachScene(); + $terminal = array_map(TerminalText::stripAnsi(...), $preview->frame()); + expect($terminal)->toHaveCount(30) + ->and(mb_strlen($terminal[0]))->toBe(100) + ->and(implode("\n", $terminal))->toContain('Synthetic narration', 'Preview narrator', 'A separate line') + ->and($preview->hasActiveSession())->toBeTrue(); + $preview->stop(); + $again = getNarrationPreviewFrame($preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId())); + expect($again['reset'])->toBeTrue() + ->and(getNarrationPreviewObjects($again, 'canvas_text'))->toBeEmpty() + ->and(getNarrationPreviewObjects($again, 'canvas_image'))->toBeEmpty() + ->and(array_column(getNarrationPreviewObjects($again, 'text'), 'id'))->not->toContain('cinematic-overlay'); + } finally { + $preview->dispose(); + } +}); + +it('keeps terminal text without graphical theme objects when the preview window has no canvas capability', function () { + $preview = CinematicPreviewSession::start($this->root, $this->definition, + ['x' => 10, 'y' => 8, 'width' => 100, 'height' => 30]); + + try { + $preview->step(); + $terminal = implode("\n", array_map(TerminalText::stripAnsi(...), $preview->frame())); + expect($terminal)->toContain('Synthetic narration', 'Preview narrator', 'A separate line'); + $frame = getNarrationPreviewFrame($preview->exchangeScene([ + json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => []], JSON_THROW_ON_ERROR), + ], $preview->getSceneSessionId())); + expect(getNarrationPreviewObjects($frame, 'canvas_text'))->toBeEmpty() + ->and(getNarrationPreviewObjects($frame, 'canvas_image'))->toBeEmpty(); + $overlay = array_find(getNarrationPreviewObjects($frame, 'text'), static fn(array $layer): bool => $layer['id'] === 'cinematic-overlay'); + expect($overlay)->not->toBeNull() + ->and(implode(' ', array_column($overlay['runs'], 'text')))->toContain('Synthetic narration', 'Preview narrator') + ->and($preview->hasActiveSession())->toBeTrue(); + } finally { + $preview->dispose(); + } +}); diff --git a/tests/Unit/Cutscenes/CutsceneSourceHistoryTest.php b/tests/Unit/Cutscenes/CutsceneSourceHistoryTest.php new file mode 100644 index 0000000..47b7bb1 --- /dev/null +++ b/tests/Unit/Cutscenes/CutsceneSourceHistoryTest.php @@ -0,0 +1,127 @@ + 'ember-spark', + CutsceneType::SUMMON => 'lantern-wisp', + CutsceneType::CINEMATIC => 'harbour-lanterns', + }; + if ($type === CutsceneType::EFFECT) { + $path = $root . '/assets/Animations/' . $id . '/' . $id . '.timeline.php'; + $values = require $path; + file_put_contents($path, "ids($type), true); + $asset = $library->find($type, $id); + + return [$root, $library, $index, $asset]; +} + +function readCutsceneHistorySources(CutsceneAsset $asset): array +{ + return array_combine($asset->paths(), array_map(file_get_contents(...), $asset->paths())); +} + +function removeCutsceneHistoryBlock(CutsceneAsset $asset): void +{ + $payload = $asset->payload(); + switch ($asset->type) { + case CutsceneType::EFFECT: + unset($payload['cues']); + break; + case CutsceneType::SUMMON: + unset($payload['playback']); + array_shift($payload['tracks']); + break; + case CutsceneType::CINEMATIC: + unset($payload['authoring']); + array_pop($payload['commands']); + break; + } + $asset->apply($payload); +} + +it('preserves effect summon and cinematic source syntax through remove save undo redo and later edits', function (CutsceneType $type) { + [, $library, $index, $asset] = sourceHistoryCutscene($type); + $before = readCutsceneHistorySources($asset); + $command = $library->changeAsset($type, $index, 'Remove authored block', static fn() => removeCutsceneHistoryBlock($asset))['command']; + expect($command)->not->toBeNull()->and($asset->save())->toBeTrue(); + $removed = readCutsceneHistorySources($asset); + $baseline = []; + foreach (['baselineSources', 'loadedData', 'loadedPartner'] as $property) { + $baseline[$property] = new ReflectionProperty(CutsceneAsset::class, $property)->getValue($asset); + } + $command->undo(); + // History remembers syntax, not an obsolete persisted baseline or an + // obsolete changed-reference checkpoint. + foreach ($baseline as $property => $value) { + expect(new ReflectionProperty(CutsceneAsset::class, $property)->getValue($asset))->toBe($value); + } + $asset->assertSourcesUnchanged(); + expect($asset->save())->toBeTrue()->and(readCutsceneHistorySources($asset))->toBe($before); + $command->execute(); + expect($asset->save())->toBeTrue()->and(readCutsceneHistorySources($asset))->toBe($removed); + $command->undo(); + $payload = $asset->payload(); + if ($type === CutsceneType::CINEMATIC) { + $payload['name'] = 'A newly authored name'; + } else { + $payload['fps'] = 8; + } + $asset->apply($payload); + expect($asset->save())->toBeTrue(); + $changed = readCutsceneHistorySources($asset); + $expected = $before; + $path = $type === CutsceneType::CINEMATIC ? $asset->dataPath() : $asset->partnerPath(); + $expected[$path] = match ($type) { + CutsceneType::EFFECT => str_replace("'fps' => 10", "'fps' => 8", $before[$path]), + CutsceneType::SUMMON => str_replace("'fps' => 12", "'fps' => 8", $before[$path]), + CutsceneType::CINEMATIC => str_replace("'name' => 'Harbour Lanterns'", "'name' => 'A newly authored name'", $before[$path]), + }; + expect($changed)->toBe($expected)->and($asset->save())->toBeFalse(); +})->with(CutsceneType::cases()); + +it('refuses external replacement after saved undo without overwriting it or accepting an old disk baseline', function (CutsceneType $type) { + [, $library, $index, $asset] = sourceHistoryCutscene($type); + $command = $library->changeAsset($type, $index, 'Remove authored block', static fn() => removeCutsceneHistoryBlock($asset))['command']; + $asset->save(); + $command->undo(); + $asset->assertSourcesUnchanged(); + $path = $asset->partnerPath(); + $external = file_get_contents($path) . "\n// Another author changed this source.\n"; + file_put_contents($path, $external); + $state = $asset->captureEditState(); + expect(fn() => $asset->save())->toThrow(FileSetTransactionFailure::class, 'changed after opening') + ->and(file_get_contents($path))->toBe($external) + ->and($asset->captureEditState())->toBe($state); +})->with(CutsceneType::cases()); + +it('still validates proposed source templates before installing restored histories', function (CutsceneType $type) { + [, $library, $index, $asset] = sourceHistoryCutscene($type); + $command = $library->changeAsset($type, $index, 'Remove authored block', static fn() => removeCutsceneHistoryBlock($asset))['command']; + $asset->save(); + $written = readCutsceneHistorySources($asset); + $command->undo(); + $payload = $asset->payload(); + if ($type === CutsceneType::CINEMATIC) { + $payload['commands'][0]['type'] = 'not_a_cinematic_command'; + } elseif ($type === CutsceneType::SUMMON) { + $payload['tracks'][0]['presentation'] = 'not_a_presentation'; + } else { + $payload['fps'] = 0; + } + $asset->apply($payload); + expect(fn() => $asset->save())->toThrow(RuntimeException::class) + ->and(readCutsceneHistorySources($asset))->toBe($written); +})->with(CutsceneType::cases()); diff --git a/tests/Unit/Cutscenes/EffectFieldPreviewTest.php b/tests/Unit/Cutscenes/EffectFieldPreviewTest.php index 7e20cfe..0ce67b3 100644 --- a/tests/Unit/Cutscenes/EffectFieldPreviewTest.php +++ b/tests/Unit/Cutscenes/EffectFieldPreviewTest.php @@ -61,10 +61,10 @@ function plainField(array $lines): string it('gives the editor window the field with the effect\'s own graphical art', function () { $session = EditorSession::open(effectFieldProject()); - $session->showEffectOnField(effectIndex($session, 'mote'), 1, 40, 12); + $shown = $session->showEffectOnField(effectIndex($session, 'mote'), 1, 40, 12); try { - $answer = $session->exchangeEffectFieldScene([previewWindowReady()]); + $answer = $session->exchangeEffectFieldScene([previewWindowReady()], $shown['sessionId']); expect($answer['grid'])->toBe(['columns' => 40, 'rows' => 12, 'cellWidth' => 10, 'cellHeight' => 20]) ->and(json_encode($answer['messages']))->toContain('Graphics\/Effects\/mote.png'); expect($session->closeEffectField())->toBe(['closed' => true]) @@ -89,9 +89,14 @@ function plainField(array $lines): string } }); -it('refuses to show an effect on the field when no new game start names a map', function () { +it('shows an effect over undefined terrain with a diagnostic when no new game start names a map', function () { $session = EditorSession::open(effectProject()); - expect(fn() => $session->showEffectOnField(effectIndex($session, 'ember-spark'), 0, 40, 12)) - ->toThrow(SessionRefusal::class, 'System names no player starting map'); + try { + $answer = $session->showEffectOnField(effectIndex($session, 'ember-spark'), 0, 40, 12); + expect($answer['mapId'])->toBe('')->and($answer['lines'])->toHaveCount(12) + ->and(implode('\n', $answer['diagnostics']))->toContain('No map')->toContain('undefined terrain'); + } finally { + $session->close(); + } }); diff --git a/tests/Unit/Cutscenes/FieldEffectPivotAuthoringTest.php b/tests/Unit/Cutscenes/FieldEffectPivotAuthoringTest.php new file mode 100644 index 0000000..351c3b6 --- /dev/null +++ b/tests/Unit/Cutscenes/FieldEffectPivotAuthoringTest.php @@ -0,0 +1,194 @@ + [ + 'terminal' => ['fps' => 4, 'lengthFrames' => 2, 'tracks' => [ + ['id' => 'spark', 'type' => 'glyph', 'keyframes' => [ + ['frame' => 0, 'duration' => 2, 'content' => $glyph], + ]], + ]], + 'graphical' => ['fps' => 4, 'lengthFrames' => 2, 'tracks' => [ + ['id' => 'spark', 'type' => 'image', 'asset' => 'Graphics/Effects/dusk-slash.png', + 'sheet' => ['columns' => 2, 'rows' => 1], + 'keyframes' => [['frame' => 0, 'sourceFrame' => 0], ['frame' => 1, 'sourceFrame' => 1]], + ], + ]], +]]; +PHP); + + return [$root, $file]; +} + +function openFieldPivotSession(string $root): array +{ + $session = EditorSession::open($root); + $index = array_search('field-pivot', $session->listDatabaseRecords('cutscenes/effect')['records'], true); + $session->selectCutscenePresentation('cutscenes/effect', $index, 'graphical'); + + return [$session, $index]; +} + +it('sends consumer defaults through the actual timeline request without authoring them', function () { + [$root, $file] = makeFieldPivotProject(); + $source = file_get_contents($file); + $input = fopen('php://memory', 'r'); + $output = fopen('php://memory', 'w+'); + $diagnostics = fopen('php://memory', 'w+'); + $host = new SessionHost($input, $output, $diagnostics); + $request = static fn(string $method, array $params): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + try { + expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result'); + $category = 'cutscenes/effect'; + $index = array_search('field-pivot', $request('database.records', ['category' => $category])['result']['records'], true); + expect($request('cutscenes.presentation', ['category' => $category, 'index' => $index, 'presentation' => 'graphical']))->toHaveKey('result'); + $art = $request('cutscenes.timeline', ['category' => $category, 'index' => $index])['result']['tracks'][0]['art']; + expect($art['pivot'])->toBe('') + ->and($art['pivotKey']['field'])->toBe('track0Pivot') + ->and($art['pivotDefaults'])->toBe(['field' => ['x' => 0.5, 'y' => 1.0], 'battle' => ['x' => 0.5, 'y' => 0.5]]) + ->and(file_get_contents($file))->toBe($source); + } finally { + $host->run(); + fclose($input); + fclose($output); + fclose($diagnostics); + } +}); + +it('describes consumer-owned pivot defaults without changing the field shape or summon defaults', function () { + $effect = CutsceneSchemas::effectTrackList()->fieldsFor(['type' => 'image']); + $pivot = array_find($effect, static fn(RecordField $field): bool => $field->key === 'pivot'); + $attachment = array_find($effect, static fn(RecordField $field): bool => $field->key === 'attachment'); + $summon = array_find(CutsceneSchemas::imageTrackFields(), static fn(RecordField $field): bool => $field->key === 'pivot'); + + expect($pivot->codec)->toBe(RecordFieldCodec::NORMALIZED_POINT) + ->and($pivot->removeWhenEmpty)->toBeTrue() + ->and($pivot->label)->toContain('field 0.5, 1', 'battle 0.5, 0.5') + ->and($attachment->label)->toContain('battle only') + ->and($summon->displayDefault)->toBe('0.5, 0.5') + ->and(array_column(CutsceneSchemas::effectTrackList()->fieldsFor(['type' => 'glyph']), 'key'))->not->toContain('pivot'); +}); + +it('uses the existing GUI pivot key for source-preserving field edits, undo, clear and reopen', function () { + [$root, $file] = makeFieldPivotProject(); + $source = file_get_contents($file); + $original = require $file; + [$session, $index] = openFieldPivotSession($root); + $art = static fn(): array => $session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']; + $key = $art()['pivotKey']; + + expect($art()['pivot'])->toBe('') + ->and($art()['pivotDefaults'])->toBe(['field' => ['x' => 0.5, 'y' => 1.0], 'battle' => ['x' => 0.5, 'y' => 0.5]]) + ->and($art())->toMatchArray(['asset' => 'Graphics/Effects/dusk-slash.png', 'columns' => 2, 'rows' => 1]); + $session->saveDatabase('cutscenes/effect'); + expect(file_get_contents($file))->toBe($source); + $session->applyDatabaseRecord('cutscenes/effect', $index, $key, '0.25, 1'); + expect($art()['pivot'])->toBe('0.25, 1')->and(file_get_contents($file))->toBe($source); + expect($art()['pivotDefaults']['field'])->toBe(['x' => 0.5, 'y' => 1.0]); + $session->undo(); + expect($art()['pivot'])->toBe('')->and($session->listUnsavedChanges())->toBe([]); + $session->redo(); + $session->saveDatabase('cutscenes/effect'); + $saved = require $file; + expect($saved['presentations']['graphical']['tracks'][0]['pivot'])->toBe(['x' => 0.25, 'y' => 1.0]) + ->and($saved['presentations']['graphical']['tracks'][0])->not->toHaveKey('attachment') + ->and($saved['presentations']['terminal'])->toBe($original['presentations']['terminal']) + ->and(file_get_contents($file))->toContain('// Keep the terminal presentation', '$glyph =', "'content' => $" . 'glyph'); + [$reopened, $reopenedIndex] = openFieldPivotSession($root); + expect($reopened->describeCutsceneTimeline('cutscenes/effect', $reopenedIndex)['tracks'][0]['art']['pivot'])->toBe('0.25, 1'); + + $session->applyDatabaseRecord('cutscenes/effect', $index, $key, ''); + expect($art()['pivot'])->toBe('')->and($art()['pivotDefaults']['battle'])->toBe(['x' => 0.5, 'y' => 0.5]); + $session->saveDatabase('cutscenes/effect'); + expect(require $file)->toBe($original); + $session->undo(); + expect($art()['pivot'])->toBe('0.25, 1'); + $session->selectCutscenePresentation('cutscenes/effect', $index, 'terminal'); + expect($session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0])->not->toHaveKey('art'); +}); + +it('refuses malformed, nonfinite and out-of-range pivot edits without mutation or history', function () { + [$root, $file] = makeFieldPivotProject(); + [$session, $index] = openFieldPivotSession($root); + $source = file_get_contents($file); + $key = $session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']['pivotKey']; + $session->applyDatabaseRecord('cutscenes/effect', $index, $key, '0, 1'); + foreach (['-0.01, 1', '0.5, 1.01', '0.5', '0, 1, 0', 'left, top', 'NaN, 0', '0, INF', '1e999, 0'] as $value) { + expect(fn() => $session->applyDatabaseRecord('cutscenes/effect', $index, $key, $value)) + ->toThrow(SessionRefusal::class, 'two numbers from 0 to 1'); + } + expect($session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']['pivot'])->toBe('0, 1') + ->and(file_get_contents($file))->toBe($source); + $session->undo(); + expect($session->listUnsavedChanges())->toBe([]); +}); + +it('refuses an expression-backed pivot before writing or recording an undo step', function () { + [$root, $file] = makeFieldPivotProject(); + file_put_contents($file, str_replace("'sheet' =>", "'pivot' => ['x' => 1 / 2, 'y' => 1], 'sheet' =>", file_get_contents($file))); + $source = file_get_contents($file); + [$session, $index] = openFieldPivotSession($root); + $key = $session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']['pivotKey']; + + expect(fn() => $session->applyDatabaseRecord('cutscenes/effect', $index, $key, '0.25, 1')) + ->toThrow(SessionRefusal::class, 'cannot be rewritten safely') + ->and($session->listUnsavedChanges())->toBe([]) + ->and(file_get_contents($file))->toBe($source) + ->and($session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']['pivot'])->toBe('0.5, 1'); +}); + +it('keeps graphical pivots while terminal authoring edits only its selected sequence', function () { + [$root, $file] = makeFieldPivotProject(); + [$session, $index] = openFieldPivotSession($root); + $key = $session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']['pivotKey']; + $session->applyDatabaseRecord('cutscenes/effect', $index, $key, '0.5, 1'); + $session->saveDatabase('cutscenes/effect'); + $graphical = (require $file)['presentations']['graphical']; + + $editor = cutscenesEditor($root, 160, 50); + callEditorMethod($editor, 'switchCutsceneType', CutsceneType::EFFECT); + callEditorMethod($editor, 'selectCutsceneById', 'field-pivot'); + setCutsceneField($editor, 'fps', '8'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'field-pivot'); + $asset->save(); + expect((require $file)['presentations']['graphical'])->toBe($graphical) + ->and((require $file)['presentations']['terminal']['fps'])->toBe(8); +}); + +it('passes explicit and omitted pivots to the shared compiler without inventing field battler attachments', function () { + [$root, $file] = makeFieldPivotProject(); + $data = require $file; + $library = new EffectTimelineLibrary($root . '/assets'); + foreach ([false, true] as $battle) { + $compiled = $library->compile('field-pivot', $data, $battle, EffectPresentation::GRAPHICAL); + expect($compiled->playbackSegments[0]['drawCommands'][0]['payload'])->not->toHaveKey('pivot'); + } + $data['presentations']['graphical']['tracks'][0]['pivot'] = ['x' => 0.25, 'y' => 1]; + $compiled = $library->compile('field-pivot', $data, false, EffectPresentation::GRAPHICAL); + expect($compiled->playbackSegments[0]['drawCommands'][0]['payload']['pivot'])->toBe(['x' => 0.25, 'y' => 1]); + $data['presentations']['graphical']['tracks'][0]['attachment'] = 'ground'; + expect(fn() => $library->compile('field-pivot', $data, false, EffectPresentation::GRAPHICAL))->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Unit/Cutscenes/OwnedStageEffectTest.php b/tests/Unit/Cutscenes/OwnedStageEffectTest.php new file mode 100644 index 0000000..9c690a4 --- /dev/null +++ b/tests/Unit/Cutscenes/OwnedStageEffectTest.php @@ -0,0 +1,366 @@ + 8, 'lengthFrames' => 8, 'restFrame' => 3, + 'stage' => [ + 'canvas' => ['width' => 80, 'height' => 40], 'startFrame' => 1, 'restoreFrame' => 6, + 'subjects' => [['id' => 'visitor', 'position' => ['x' => 40, 'y' => 30], 'size' => ['width' => 16, 'height' => 16], + 'attachments' => [['id' => 'crown', 'x' => 0.5, 'y' => 0]]]], + 'camera' => [ + ['id' => 'wide', 'frame' => 0, 'focus' => ['x' => 40, 'y' => 20], 'zoom' => 1], + ['id' => 'close', 'frame' => 4, 'focus' => ['x' => 44, 'y' => 20], 'zoom' => 2], + ], + 'covers' => [ + ['id' => 'entry', 'frame' => 0, 'color' => 'black', 'opacity' => 1, 'easing' => 'hold'], + ['id' => 'clear', 'frame' => 1, 'color' => 'black', 'opacity' => 0], + ['id' => 'exit', 'frame' => 7, 'color' => 'black', 'opacity' => 0], + ], + ], + 'tracks' => [['id' => 'portrait', 'type' => 'image', 'anchor' => 'stage', + 'asset' => 'Graphics/Effects/visitor.png', 'sheet' => ['columns' => 1, 'rows' => 1], + 'placement' => ['subject' => 'visitor', 'attachment' => 'crown'], + 'keyframes' => [['frame' => 1, 'duration' => 5, 'sourceFrame' => 0]]]], + 'cues' => [], + ]; +} + +function ownedStageEffectProject(bool $paired = false, bool $readOnly = false): string +{ + $root = makeTemporaryProject('owned-stage-effect-'); + $timeline = ownedStageEffectSequence(); + if ($paired) { + $timeline = ['presentations' => [ + 'terminal' => ['fps' => 8, 'lengthFrames' => 8, 'tracks' => [['id' => 'caption', 'type' => 'text', 'anchor' => 'screen', + 'keyframes' => [['frame' => 0, 'duration' => 8, 'content' => 'A quiet interval', 'position' => ['x' => 0, 'y' => 0]]]]], 'cues' => []], + 'graphical' => $timeline, + ]]; + } + mkdir($root . '/assets/Animations/quiet-interval', 0o777, true); + $literal = var_export($timeline, true); + $source = $readOnly ? "\$timeline = {$literal};\nreturn \$timeline;" : "return {$literal};"; + file_put_contents($root . '/assets/Animations/quiet-interval/quiet-interval.timeline.php', "find(CutsceneType::EFFECT, 'quiet-interval'); + $asset->hydrate(); + + expect($asset->isEditable())->toBeTrue() + ->and($asset->isOwnedStageEffect())->toBeTrue() + ->and($asset->compileEffect(EffectPresentation::GRAPHICAL, false, forStage: true)->defaults['stage']['subjects'][0]['id'])->toBe('visitor') + ->and(fn() => $asset->compiledEffect(EffectPresentation::GRAPHICAL, false))->toThrow(RuntimeException::class) + ->and(fn() => $asset->compiledEffect(EffectPresentation::GRAPHICAL, true))->toThrow(RuntimeException::class) + ->and(fn() => $asset->compileEffect(EffectPresentation::GRAPHICAL, true, forStage: true))->toThrow(RuntimeException::class) + ->and(fn() => $asset->compileEffect(EffectPresentation::TERMINAL, true, forStage: true))->toThrow(RuntimeException::class); +})->with([false, true]); + +it('refuses invalid owned-stage context data before installing any source', function () { + $root = ownedStageEffectProject(); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'quiet-interval'); + $before = file_get_contents(ownedStageEffectPath($root)); + foreach ([ + static function (array &$payload): void { $payload['tracks'][0]['placement']['subject'] = 'unknown'; }, + static function (array &$payload): void { $payload['cues'] = [['id' => 'sound', 'frame' => 1, 'type' => 'playSound', 'payload' => ['sound' => 'chime']]]; }, + static function (array &$payload): void { $payload['playback'] = 'loop'; }, + static function (array &$payload): void { $payload['stage']['camera'][0]['frame'] = 1; }, + ] as $break) { + $payload = $asset->payload(); + $break($payload); + $state = $asset->captureEditState(); + $asset->apply($payload); + expect(fn() => $asset->save())->toThrow(RuntimeException::class, 'Effect quiet-interval cannot be played') + ->and(file_get_contents(ownedStageEffectPath($root)))->toBe($before); + $asset->restoreEditState($state); + } +}); + +it('keeps terminal stage playback independent of graphical decoding and draws no invented subjects', function () { + $root = ownedStageEffectProject(true); + unlink($root . '/assets/Graphics/Effects/visitor.png'); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'quiet-interval'); + $terminal = $asset->compileEffect(EffectPresentation::TERMINAL, false, forStage: true); + $preview = new TimelinePreviewSession($terminal); + $stage = new EffectPreviewStage(EffectPresentation::TERMINAL, false, forStage: true); + + expect($stage->drawFrame($preview->activeSegments(), 24, 3))->toBe(['A quiet interval ', str_repeat(' ', 24), str_repeat(' ', 24)]) + ->and($stage->renderCanvas($terminal, $root . '/assets', 2, 80, 40))->toBeNull() + ->and(fn() => $asset->compileEffect(EffectPresentation::GRAPHICAL, false, forStage: true))->toThrow(RuntimeException::class); +}); + +it('leaves editable and readonly owned-stage source byte-identical on no-op roundtrips and previews', function (bool $readOnly) { + $root = ownedStageEffectProject(true, $readOnly); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'quiet-interval'); + $before = sourceHashTree($root); + foreach (EffectPresentation::cases() as $presentation) { + $asset->selectPresentation($presentation); + $asset->apply($asset->payload()); + $asset->compileEffect($presentation, false, forStage: true); + } + expect($asset->isEditable())->toBe(! $readOnly) + ->and($asset->save())->toBeFalse() + ->and(sourceHashTree($root))->toBe($before); +})->with([false, true]); + +it('authors stage effects through shared rows and selectors with source-preserving save and undo', function (bool $paired) { + $root = ownedStageEffectProject($paired); + $before = require ownedStageEffectPath($root); + $source = file_get_contents(ownedStageEffectPath($root)); + $session = EditorSession::open($root); + if ($paired) { + $session->selectCutscenePresentation('cutscenes/effect', 0, 'graphical'); + } + $rows = $session->readDatabaseRecord('cutscenes/effect', 0, ['tracks'])['rows']; + $row = array_find($rows, static fn(array $row): bool => str_ends_with($row['label'], 'Subject')); + $opacity = array_find($rows, static fn(array $row): bool => str_ends_with($row['label'], 'Opacity')); + expect($row)->not->toBeNull() + ->and($opacity)->not->toBeNull() + ->and(array_column($rows, 'label'))->not->toContain('Track 1 Cell Width', 'Track 1 Facing', 'Track 1 Attachment'); + $context = ['category' => 'cutscenes/effect', 'index' => 0]; + expect(array_column($session->listReferences(null, 'stage_subjects', $context), 'value'))->toBe(['visitor']) + ->and(array_column($session->listReferences(null, 'stage_attachments', $context), 'value'))->toBe(['crown']) + ->and(array_column($session->listReferences(null, 'png_assets', $context), 'value'))->toContain('Graphics/Effects/visitor.png'); + $session->applyDatabaseRecord('cutscenes/effect', 0, $opacity['key'], '0.5'); + $session->undo(); + expect($session->saveAll()['failures'])->toBe([]) + ->and(require ownedStageEffectPath($root))->toBe($before) + ->and(file_get_contents(ownedStageEffectPath($root)))->toBe($source); + $session->redo(); + expect($session->saveAll()['failures'])->toBe([]); + $saved = require ownedStageEffectPath($root); + $sequence = $paired ? $saved['presentations']['graphical'] : $saved; + expect($sequence['tracks'][0]['keyframes'][0]['opacity'])->toBe(0.5) + ->and(file_get_contents(ownedStageEffectPath($root)))->toContain('// Keep the authored stage comment.'); + if ($paired) { + expect($saved['presentations']['terminal'])->toBe($before['presentations']['terminal']); + } +})->with([false, true]); + +it('projects seek camera covers and reduced motion with the shared Engine stage compositor and no retained scene', function () { + $root = ownedStageEffectProject(); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'quiet-interval'); + $compiled = $asset->compileEffect(EffectPresentation::GRAPHICAL, false, forStage: true); + $preview = new TimelinePreviewSession($compiled); + $stage = new EffectPreviewStage(EffectPresentation::GRAPHICAL, false, forStage: true); + $definition = CinematicStage::fromArray($compiled->defaults['stage'], 8, 3); + foreach ([0, 2, 5, 1, 7, 3] as $frame) { + $preview->seek($frame); + foreach ([false, true] as $reduced) { + expect($stage->renderCanvas($compiled, $root . '/assets', $preview->currentFrame(), 160, 80, $reduced)->toArray()) + ->toBe(CinematicStagePresentation::compose($definition->getFrame($frame, $reduced), $compiled->playbackSegments, $root . '/assets', 160, 80)->toArray()); + } + } + expect($stage->renderCanvas($compiled, $root . '/assets', 2, 160, 80)->images[0]->destination) + ->not->toEqual($stage->renderCanvas($compiled, $root . '/assets', 5, 160, 80)->images[0]->destination) + ->and($stage->renderCanvas($compiled, $root . '/assets', 7, 160, 80)->images)->toBe([]) + ->and($stage->renderCanvas($compiled, $root . '/assets', 7, 160, 80)->composites)->toBe([]); + $preview->restart(); + expect($preview->currentFrame())->toBe(0)->and($preview->cueLog())->toBe([]); + $preview->play(); + $preview->tick(2); + expect($preview->isCompleted())->toBeTrue(); +}); + +it('chooses owned stage in the Terminal Editor even when no project consumer references the effect', function () { + $root = ownedStageEffectProject(); + $editor = cutscenesEditor($root, 160, 50); + callEditorMethod($editor, 'switchCutsceneType', CutsceneType::EFFECT); + callEditorMethod($editor, 'selectCutsceneById', 'quiet-interval'); + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_PREVIEW); + callEditorMethod($editor, 'startTimelinePreview', false); + $preview = getEditorProperty($editor, 'timelinePreview'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'quiet-interval'); + expect($preview)->toBeInstanceOf(TimelinePreviewSession::class) + ->and(callEditorMethod($editor, 'createEffectPreviewStage', $asset)->forStage)->toBeTrue() + ->and(cutsceneFieldIds($editor))->not->toContain('@stage', 'stage.canvas', 'commandListStage.subjects'); + pressKeys($editor, 'b', 'd'); + expect(getEditorProperty($editor, 'timelinePreview'))->toBe($preview) + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('owns its stage'); + pressKeys($editor, '.', 'r', 'x'); + expect(getEditorProperty($editor, 'timelinePreview'))->toBeNull(); +}); + +it('reads replacement stage artwork from the current file rather than freezing its bytes or dimensions', function () { + $root = ownedStageEffectProject(); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'quiet-interval'); + $stage = new EffectPreviewStage(EffectPresentation::GRAPHICAL, false, forStage: true); + $first = $stage->renderCanvas($asset->compileEffect(EffectPresentation::GRAPHICAL, false, forStage: true), $root . '/assets', 1, 160, 80); + writeTilesetTestPng($root . '/assets/Graphics/Effects/visitor.png', 16, 12); + $replacement = $stage->renderCanvas($asset->compileEffect(EffectPresentation::GRAPHICAL, false, forStage: true), $root . '/assets', 1, 160, 80); + + expect($replacement->images[0]->sourceRect->width)->toBe(16) + ->and($replacement->images[0]->sourceRect->height)->toBe(12) + ->and($replacement->images[0]->destination)->toEqual($first->images[0]->destination) + ->and($asset->isDirty())->toBeFalse(); +}); + +it('validates a referenced stage with independent Engine admission for both presentations', function () { + $root = ownedStageEffectProject(true); + file_put_contents($root . '/config.php', " ['inn' => ['presentation' => 'quiet-interval']]];\n"); + $workspace = ProjectWorkspace::fromProject($root); + expect(EffectValidator::findUses($workspace)['quiet-interval'])->toHaveKey('stage') + ->and(new EffectValidator()->validate($workspace))->toBe([]); + $timeline = require ownedStageEffectPath($root); + $timeline['presentations']['terminal']['cues'] = [['id' => 'sound', 'frame' => 1, 'type' => 'playSound', 'payload' => ['sound' => 'chime']]]; + file_put_contents(ownedStageEffectPath($root), 'validate(ProjectWorkspace::fromProject($root)))->toBe([]); + $timeline['presentations']['terminal']['cues'][0]['frame'] = 100; + file_put_contents(ownedStageEffectPath($root), 'validate(ProjectWorkspace::fromProject($root)); + expect($issues)->toHaveCount(1) + ->and($issues[0]->message)->toContain('quiet-interval cannot be played as a stage', 'invalid field presentation cue'); +}); + +it('creates a graphical effect stage through the source-preserving session with no-op undo and save', function (bool $paired) { + $root = ownedStageEffectProject($paired); + $timeline = require ownedStageEffectPath($root); + $sequence = &$timeline; + if ($paired) { $sequence = &$timeline['presentations']['graphical']; } + unset($sequence['stage'], $sequence['tracks'][0]['placement']); + $sequence['tracks'][0]['anchor'] = 'screen'; + unset($sequence); + file_put_contents(ownedStageEffectPath($root), "selectCutscenePresentation('cutscenes/effect', 0, 'graphical'); } + expect($session->describeCutsceneTimeline('cutscenes/effect', 0))->toMatchArray(['canHaveStage' => true, 'ownedStage' => false, 'stage' => null]); + expect($session->setCutsceneStage('cutscenes/effect', 0, true))->toBe(['changed' => true, 'stage' => true]) + ->and($session->setCutsceneStage('cutscenes/effect', 0, true))->toBe(['changed' => false, 'stage' => true]); + $session->undo(); + expect($session->saveAll()['failures'])->toBe([]) + ->and(file_get_contents(ownedStageEffectPath($root)))->toBe($source); + $session->redo(); + // Adding a descriptor does not silently convert an existing track's geometry. + $preview = $session->readCutscenePreview('cutscenes/effect', 0, 3, 24, 3); + expect($preview['stageCanvas'])->toBeNull()->and($preview['stageCanvasError'])->toContain('only stage image tracks'); + $rows = $session->readDatabaseRecord('cutscenes/effect', 0, ['tracks'])['rows']; + $anchor = array_find($rows, static fn(array $row): bool => $row['label'] === 'Track 1 Anchor'); + $session->applyDatabaseRecord('cutscenes/effect', 0, $anchor['key'], 'stage'); + $rows = $session->readDatabaseRecord('cutscenes/effect', 0, ['tracks'])['rows']; + $size = array_find($rows, static fn(array $row): bool => $row['label'] === 'Track 1 Size'); + $session->applyDatabaseRecord('cutscenes/effect', 0, $size['key'], '16, 16'); + $workspace = new ReflectionProperty(EditorSession::class, 'workspace')->getValue($session); + $state = $workspace->cutscenes->find(CutsceneType::EFFECT, 'quiet-interval')->captureEditState(); + expect(eval('?>' . $state['sourceTemplates']['timeline']['source']))->toBe($state['sourceTemplates']['timeline']['values']); + expect($session->saveAll()['failures'])->toBe([]); + $saved = require ownedStageEffectPath($root); + $graphical = $paired ? $saved['presentations']['graphical'] : $saved; + expect($graphical['stage']['subjects'] ?? [])->toBe([]) + ->and($graphical['stage']['camera'][0]['frame'])->toBe(0) + ->and(file_get_contents(ownedStageEffectPath($root)))->toContain('// Keep the authored stage comment.'); + if ($paired) { expect($saved['presentations']['terminal'])->toBe($timeline['presentations']['terminal']); } + CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'quiet-interval')->hydrate(); +})->with([false, true]); + +it('removes an effect stage without erasing tracks and restores it across save and undo', function () { + $root = ownedStageEffectProject(); + $timeline = require ownedStageEffectPath($root); + $timeline['tracks'][] = ['id' => 'caption', 'type' => 'text', 'presentation' => 'terminal', 'anchor' => 'screen', + 'keyframes' => [['frame' => 0, 'duration' => 8, 'content' => 'Independent Terminal']]]; + file_put_contents(ownedStageEffectPath($root), 'setCutsceneStage('cutscenes/effect', 0, false); + expect($session->saveAll()['failures'])->not->toBe([]) + ->and(file_get_contents(ownedStageEffectPath($root)))->toBe($source); + $session->undo(); + expect($session->saveAll()['failures'])->toBe([]) + ->and(file_get_contents(ownedStageEffectPath($root)))->toBe($source); + $row = array_find($session->readDatabaseRecord('cutscenes/effect', 0, ['tracks'])['rows'], + static fn(array $row): bool => $row['label'] === 'Track 1 Id'); + $session->removeDatabaseItem('cutscenes/effect', 0, $row['key']); + $session->setCutsceneStage('cutscenes/effect', 0, false); + expect($session->saveAll()['failures'])->toBe([]); + $saved = require ownedStageEffectPath($root); + expect($saved)->not->toHaveKey('stage')->and($saved['tracks'])->toBe([$timeline['tracks'][1]]); + $session->undo(); + $session->undo(); + expect($session->saveAll()['failures'])->toBe([]) + ->and(file_get_contents(ownedStageEffectPath($root)))->toBe($source); +}); + +it('serves shared stage canvases on the graphical clock and independent Terminal cues without graphical dependencies', function () { + $root = ownedStageEffectProject(true); + $timeline = require ownedStageEffectPath($root); + $timeline['presentations']['terminal']['fps'] = 4; + $timeline['presentations']['terminal']['lengthFrames'] = 12; + $timeline['presentations']['terminal']['tracks'][0]['keyframes'][0]['duration'] = 12; + $timeline['presentations']['terminal']['cues'] = [['id' => 'chime', 'frame' => 10, 'type' => 'playSound', 'payload' => ['sound' => 'chime']]]; + file_put_contents(ownedStageEffectPath($root), 'selectCutscenePresentation('cutscenes/effect', 0, 'graphical'); + expect($session->describeCutsceneTimeline('cutscenes/effect', 0))->toMatchArray(['ownedStage' => true, 'canHaveStage' => true]); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'quiet-interval'); + $compiled = $asset->compileEffect(EffectPresentation::GRAPHICAL, false, forStage: true); + $stage = new EffectPreviewStage(EffectPresentation::GRAPHICAL, false, forStage: true); + foreach ([5, 1, 7, 3, 0, 30] as $frame) { + $preview = $session->readCutscenePreview('cutscenes/effect', 0, $frame, 24, 3); + expect($preview['stageCanvas'])->toBe($stage->renderCanvas($compiled, $root . '/assets', $frame, 80, 40)->toArray()) + ->and($preview['stageCanvasError'])->toBeNull() + ->and($preview['totalFrames'])->toBe(8)->and($preview['fps'])->toBe(8)->and($preview['cues'])->toBe([]); + } + $session->selectCutscenePresentation('cutscenes/effect', 0, 'terminal'); + expect($session->describeCutsceneTimeline('cutscenes/effect', 0))->toMatchArray(['ownedStage' => true, 'canHaveStage' => false, 'stage' => null]); + expect($session->saveAll()['failures'])->toBe([])->and(sourceHashTree($root))->toBe($before); + unlink($root . '/assets/Graphics/Effects/visitor.png'); + $preview = $session->readCutscenePreview('cutscenes/effect', 0, 10, 24, 3); + expect($preview)->toMatchArray(['frame' => 10, 'totalFrames' => 12, 'fps' => 4, 'stageCanvas' => null, 'stageCanvasError' => null]) + ->and($preview['lines'][0])->toBe('A quiet interval ')->and($preview['cues'][0]['id'])->toBe('chime'); +}); + +it('refuses source edits on read-only owned stages while still projecting their graphical preview', function () { + $root = ownedStageEffectProject(true, true); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + $session->selectCutscenePresentation('cutscenes/effect', 0, 'graphical'); + expect($session->readCutscenePreview('cutscenes/effect', 0, 3, 24, 3)['stageCanvas']['images'])->not->toBe([]) + ->and(fn() => $session->setCutsceneStage('cutscenes/effect', 0, false))->toThrow(SessionRefusal::class, 'read-only') + ->and(sourceHashTree($root))->toBe($before); +}); + +it('preserves independent authored Terminal tracks and cues without applying graphical stage consumer policy', function () { + $root = ownedStageEffectProject(true); + $timeline = require ownedStageEffectPath($root); + $timeline['presentations']['terminal']['cues'] = [['id' => 'authored-cue', 'frame' => 1, 'type' => 'showMessage', 'payload' => ['text' => 'An authored cue']]]; + file_put_contents(ownedStageEffectPath($root), 'find(CutsceneType::EFFECT, 'quiet-interval'); + $asset->hydrate(); + $compiled = $asset->compileEffect(EffectPresentation::TERMINAL, false, forStage: true); + expect($compiled->cueSchedule)->toBe($timeline['presentations']['terminal']['cues']); + $preview = new TimelinePreviewSession($compiled); + $preview->play(); + $preview->tick(0.25); + expect($preview->cueLog()[0])->toBe(['frame' => 1, 'id' => 'authored-cue', 'type' => 'showMessage']); + $session = EditorSession::open($root); + $session->selectCutscenePresentation('cutscenes/effect', 0, 'terminal'); + expect($session->readCutscenePreview('cutscenes/effect', 0, 1, 24, 3)['cues'])->toBe($compiled->cueSchedule) + ->and($session->describeCutsceneTimeline('cutscenes/effect', 0)['stage'])->toBeNull(); +}); diff --git a/tests/Unit/Cutscenes/PreviewLifetimeDiagnosticsTest.php b/tests/Unit/Cutscenes/PreviewLifetimeDiagnosticsTest.php new file mode 100644 index 0000000..cc916ae --- /dev/null +++ b/tests/Unit/Cutscenes/PreviewLifetimeDiagnosticsTest.php @@ -0,0 +1,375 @@ + 'missing-preview-map', + 'none' => '', + default => 'harbour', + }; + $file = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.data.php'; + $data = require $file; + $data['startMap'] = $mapId === '' ? null : $mapId; + $data['cast'] = []; + file_put_contents($file, ' 'wait', 'seconds' => 1.0]], true) . ';'); + if ($effect) { + file_put_contents($root . '/assets/Data/system.php', ' ['player' => ['destinationMap' => $mapId, 'spawnPoint' => ['x' => 2, 'y' => 3]]], + ], true) . ';'); + } + + return $root; +} + +function readPreviewContractField(EditorSession $session, bool $effect = false): PreviewField +{ + if ($effect) { + return new ReflectionProperty($session, 'effectField')->getValue($session); + } + $preview = new ReflectionProperty($session, 'cinematicPreview')->getValue($session); + + return new ReflectionProperty($preview, 'field')->getValue($preview); +} + +it('forwards the graphical epoch over both wire routes and refuses retired feedback', function (bool $effect) { + $root = createPreviewContractProject(effect: $effect); + $before = sourceHashTree($root); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params])); + $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + $session = new ReflectionProperty($host, 'session')->getValue($host); + $index = $effect ? array_search('ember-spark', $session->listDatabaseRecords('cutscenes/effect')['records'], true) : 0; + $start = $effect ? 'cutscenes.effectField' : 'cutscenes.cinematicPreview'; + $exchange = $effect ? 'cutscenes.effectFieldScene' : 'cutscenes.cinematicScene'; + try { + $first = $request($start, ['index' => $index, 'width' => 40, 'height' => 12])['result']; + $picture = $request($exchange, ['sessionId' => $first['sessionId'], 'events' => [previewWindowReady()]])['result']; + expect($picture['sessionId'])->toBe($first['sessionId']) + ->and(previewFrameGenerations($picture['messages']))->not->toBe([]); + if ($effect) { + $request('cutscenes.effectFieldClose'); + $current = $request($start, ['index' => $index, 'width' => 40, 'height' => 12])['result']; + } else { + $current = $request('cutscenes.cinematicControl', ['action' => 'restart'])['result']; + } + expect($current['sessionId'])->not->toBe($first['sessionId']) + ->and($request($exchange, ['sessionId' => $first['sessionId'], 'events' => [previewWindowReady()]])['result']) + ->toMatchArray(['sessionId' => $current['sessionId'], 'messages' => [], 'diagnostics' => []]); + expect(previewFrameGenerations($request($exchange, ['sessionId' => $current['sessionId'], 'events' => [previewWindowReady()]])['result']['messages']))->not->toBe([]); + expect($request($exchange, ['sessionId' => 42, 'events' => []])['error']['kind'])->toBe('request'); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +})->with(['cinematic' => [false], 'effect' => [true]]); + +it('describes a stable cinematic epoch without opening a graphical host and uses it for the exchange', function () { + $root = createPreviewContractProject(); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + try { + $started = $session->startCinematicPreview(0, 40, 12); + $field = readPreviewContractField($session); + expect($started['sessionId'])->toBeString()->not->toBe('') + ->and($started['diagnostics'])->toBe([]) + ->and(new ReflectionProperty($field, 'sceneHost')->getValue($field))->toBeNull(); + foreach (['play', 'tick', 'pause'] as $action) { + expect($session->controlCinematicPreview($action, 0.1)['sessionId'])->toBe($started['sessionId']); + } + expect($session->exchangeCinematicScene([]))->toMatchArray(['sessionId' => $started['sessionId'], 'diagnostics' => [], 'messages' => []]) + ->and(new ReflectionProperty($field, 'sceneHost')->getValue($field))->toBeNull(); + $answer = $session->exchangeCinematicScene([previewWindowReady()], $started['sessionId']); + expect($answer['sessionId'])->toBe($started['sessionId']) + ->and(previewFrameGenerations($answer['messages']))->not->toBe([]) + ->and(new ReflectionProperty($field, 'sceneHost')->getValue($field)->sessionId)->toBe($started['sessionId']); + expect(fn() => $session->exchangeCinematicScene(['not json'], $started['sessionId']))->toThrow(SessionRefusal::class); + expect(ConfigStore::get(ProjectConfig::class)->get('audio.music'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('audio.sfx'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('audio.voice'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('save.autosave'))->toBeFalse(); + $backends = new ReflectionProperty($field->scene->previewSceneManager->game->audioManager, 'backends') + ->getValue($field->scene->previewSceneManager->game->audioManager); + expect($backends)->toHaveCount(1)->and($backends[0])->toBeInstanceOf(SilentAudioBackend::class); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +}); + +it('retires the same-grid cinematic epoch at restart before exchange and requires fresh READY', function () { + $root = createPreviewContractProject(); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + try { + $started = $session->startCinematicPreview(0, 40, 12); + $old = $session->exchangeCinematicScene([previewWindowReady()], $started['sessionId']); + $generation = max(previewFrameGenerations($old['messages'])); + $session->controlCinematicPreview('play'); + $session->controlCinematicPreview('tick', 0.1); + $restart = $session->controlCinematicPreview('restart'); + expect($restart['sessionId'])->toBeString()->not->toBe('')->not->toBe($started['sessionId']) + ->and($restart['elapsed'])->toBe(0.0) + ->and(new ReflectionProperty(readPreviewContractField($session), 'sceneHost') + ->getValue(readPreviewContractField($session)))->toBeNull(); + $pending = $session->exchangeCinematicScene([json_encode(['protocol' => 2, 'type' => 'frame_ack', + 'generation' => $generation, 'frame' => 1, 'presented' => true])], $started['sessionId']); + expect($pending)->toMatchArray(['sessionId' => $restart['sessionId'], 'grid' => $old['grid'], 'messages' => []]); + $fresh = $session->exchangeCinematicScene([previewWindowReady()], $restart['sessionId']); + $frames = array_values(array_filter($fresh['messages'], static fn(array $message): bool => $message['type'] === 'frame')); + expect($fresh['sessionId'])->toBe($restart['sessionId'])->and($frames)->not->toBe([]) + ->and($frames[0]['payload']['reset'])->toBeTrue() + ->and($session->startCinematicPreview(0, 40, 12, keep: true)['sessionId'])->toBe($restart['sessionId']); + $replacement = $session->startCinematicPreview(0, 40, 12); + expect($replacement['sessionId'])->not->toBe($restart['sessionId']); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +}); + +it('rotates the shared field epoch on actual detach and resize but not a repeated detach without a host', function () { + $root = createPreviewContractProject(); + $before = sourceHashTree($root); + $field = PreviewField::open($root, 'harbour', new Vector2(2, 3), 40, 12, static fn(): float => 0.0); + try { + $initial = $field->sessionId; + $field->detachScene(); + expect($field->sessionId)->toBe($initial); + $field->exchangeScene([previewWindowReady()], $field->sessionId); + $field->detachScene(); + $detached = $field->sessionId; + expect($detached)->not->toBe($initial) + ->and($field->scene->getPresentationContext())->toBeNull() + ->and(count($field->frame()))->toBe(12); + $field->detachScene(); + expect($field->sessionId)->toBe($detached); + $field->exchangeScene([], $field->sessionId); + expect($field->sessionId)->toBe($detached); + $field->exchangeScene([previewWindowReady()], $field->sessionId); + $field->resize(40, 12); + $resized = $field->sessionId; + expect($resized)->not->toBe($detached)->and($field->getSceneGrid()->columns)->toBe(40); + $field->resize(50, 16); + expect($field->sessionId)->not->toBe($resized)->and(count($field->frame()))->toBe(16); + } finally { + $field->dispose(); + } + expect(sourceHashTree($root))->toBe($before); +}); + +it('reports the detached cinematic epoch in controls before reopening the same-grid host', function () { + $session = EditorSession::open(createPreviewContractProject()); + try { + $first = $session->startCinematicPreview(0, 40, 12); + $session->exchangeCinematicScene([previewWindowReady()], $first['sessionId']); + $session->detachCinematicScene(); + $detached = $session->controlCinematicPreview('pause'); + expect($detached['sessionId'])->not->toBe($first['sessionId']) + ->and($session->exchangeCinematicScene([]))->toMatchArray(['sessionId' => $detached['sessionId'], 'messages' => []]) + ->and(previewFrameGenerations($session->exchangeCinematicScene([previewWindowReady()], $detached['sessionId'])['messages']))->not->toBe([]); + } finally { + $session->close(); + } +}); + +it('shares effect controls and exchanges identity across seeks and replaces it on same-grid reopen or resize', function () { + $root = createPreviewContractProject(effect: true); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + $index = array_search('ember-spark', $session->listDatabaseRecords('cutscenes/effect')['records'], true); + try { + $first = $session->showEffectOnField($index, 0, 40, 12); + $field = readPreviewContractField($session, true); + expect($first['sessionId'])->toBeString()->not->toBe('') + ->and(new ReflectionProperty($field, 'sceneHost')->getValue($field))->toBeNull() + ->and($session->exchangeEffectFieldScene([]))->toMatchArray(['sessionId' => $first['sessionId'], 'messages' => []]); + $session->exchangeEffectFieldScene([previewWindowReady()], $first['sessionId']); + expect($session->showEffectOnField($index, 4, 40, 12)['sessionId'])->toBe($first['sessionId']); + $session->closeEffectField(); + $reopened = $session->showEffectOnField($index, 0, 40, 12); + expect($reopened['sessionId'])->not->toBe($first['sessionId']) + ->and($session->exchangeEffectFieldScene([]))->toMatchArray(['sessionId' => $reopened['sessionId'], 'messages' => []]); + $session->exchangeEffectFieldScene([previewWindowReady()], $reopened['sessionId']); + $resized = $session->showEffectOnField($index, 0, 50, 16); + expect($resized['sessionId'])->not->toBe($reopened['sessionId']) + ->and($session->exchangeEffectFieldScene([]))->toMatchArray(['sessionId' => $resized['sessionId'], 'messages' => []]); + expect(fn() => $session->exchangeEffectFieldScene(['not json'], $resized['sessionId']))->toThrow(SessionRefusal::class); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +}); + +it('describes current map diagnostics without hiding useful cinematic or effect terrain', function (string $mapKind, bool $effect) { + $root = createPreviewContractProject($mapKind, $effect); + $session = EditorSession::open($root); + if ($mapKind === 'malformed') { + // The workspace is already open: exercise the runtime reader, not the Editor's import refusal. + file_put_contents($root . '/assets/Maps/harbour/harbour.data.php', 'showEffectOnField(array_search('ember-spark', $session->listDatabaseRecords('cutscenes/effect')['records'], true), 0, 40, 12) + : $session->startCinematicPreview(0, 40, 12); + expect($answer['diagnostics'])->toBeArray()->and(array_is_list($answer['diagnostics']))->toBeTrue() + ->and($answer['lines'])->toHaveCount(12)->and($answer['sessionId'])->toBeString()->not->toBe(''); + if ($mapKind === 'valid') { + expect($answer['diagnostics'])->toBe([]); + } else { + expect($answer['diagnostics'])->not->toBe([]); + foreach ($answer['diagnostics'] as $diagnostic) { + expect($diagnostic)->toBeString()->not->toBe(''); + } + $diagnostic = implode('\n', $answer['diagnostics']); + expect($diagnostic)->toContain('undefined terrain'); + if ($mapKind !== 'none') { + expect($diagnostic)->toContain($mapKind === 'missing' ? 'missing-preview-map' : 'harbour'); + } else { + expect($diagnostic)->toContain('No map'); + } + } + $field = readPreviewContractField($session, $effect); + if ($effect) { + expect($answer['issue'])->toBe($field->mapFailure); + } else { + expect($answer['failure'])->toBeNull()->and($answer['finished'])->toBeFalse(); + } + $exchange = $effect ? $session->exchangeEffectFieldScene([previewWindowReady()], $answer['sessionId']) + : $session->exchangeCinematicScene([previewWindowReady()], $answer['sessionId']); + expect($exchange['sessionId'])->toBe($answer['sessionId']) + ->and($exchange['diagnostics'])->toBe($answer['diagnostics']) + ->and(previewFrameGenerations($exchange['messages']))->not->toBe([]); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +})->with([ + 'cinematic valid' => ['valid', false], 'cinematic missing' => ['missing', false], + 'cinematic malformed' => ['malformed', false], 'cinematic no map' => ['none', false], + 'effect valid' => ['valid', true], 'effect missing' => ['missing', true], + 'effect malformed' => ['malformed', true], 'effect no map' => ['none', true], +]); + +it('replaces boot map failures through actual cinematic transfers and clears them on recovery without changing host identity', function () { + $root = createPreviewContractProject('missing'); + $script = [ + ['type' => 'wait', 'seconds' => 0.1], + ['type' => 'transfer', 'map' => 'second-missing-map', 'x' => 2, 'y' => 3], + ['type' => 'wait', 'seconds' => 0.1], + ['type' => 'transfer', 'map' => 'harbour', 'x' => 2, 'y' => 3], + ['type' => 'wait', 'seconds' => 1.0], + ]; + file_put_contents($root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php', + 'startCinematicPreview(0, 40, 12); + expect(implode('\n', $started['diagnostics']))->toContain('missing-preview-map'); + $session->exchangeCinematicScene([previewWindowReady()], $started['sessionId']); + $session->controlCinematicPreview('play'); + $transferred = $session->controlCinematicPreview('tick', 0.1); + expect(implode('\n', $transferred['diagnostics']))->toContain('second-missing-map')->not->toContain('missing-preview-map') + ->and($transferred['sessionId'])->toBe($started['sessionId']); + $field = readPreviewContractField($session); + $recovered = $transferred; + for ($tick = 0; $tick < 8 && count($field->scene->transfers) < 2; $tick++) { + $recovered = $session->controlCinematicPreview('tick', 0.1); + } + expect(array_column($field->scene->transfers, 'map'))->toBe(['second-missing-map', 'harbour']); + expect($recovered['diagnostics'])->toBe([])->and($recovered['sessionId'])->toBe($started['sessionId']) + ->and(readPreviewContractField($session)->mapFailure)->toBeNull(); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +}); + +it('discards same-grid retired and unqualified feedback before READY can reach a replacement host', function (bool $effect) { + $root = createPreviewContractProject(effect: $effect); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + $index = $effect ? array_search('ember-spark', $session->listDatabaseRecords('cutscenes/effect')['records'], true) : 0; + $exchange = $effect ? $session->exchangeEffectFieldScene(...) : $session->exchangeCinematicScene(...); + try { + $old = $effect ? $session->showEffectOnField($index, 0, 40, 12) : $session->startCinematicPreview(0, 40, 12); + $oldFrame = $exchange([previewWindowReady()], $old['sessionId']); + if ($effect) { + $session->closeEffectField(); + $current = $session->showEffectOnField($index, 0, 40, 12); + } else { + $current = $session->controlCinematicPreview('restart'); + } + $field = readPreviewContractField($session, $effect); + $feedback = [ + previewWindowReady(), + json_encode(['protocol' => 2, 'type' => 'frame_ack', 'generation' => max(previewFrameGenerations($oldFrame['messages'])), + 'frame' => 1, 'presented' => true]), + json_encode(['protocol' => 2, 'type' => 'frame_rejected', 'generation' => 1, 'expectedGeneration' => 777, 'message' => 'retired rejection']), + json_encode(['protocol' => 2, 'type' => 'error', 'message' => 'retired view error']), + ]; + foreach ([null, '', $old['sessionId']] as $retiredId) { + expect($exchange([previewWindowReady()], $retiredId)['messages'])->toBe([]) + ->and(new ReflectionProperty($field, 'sceneHost')->getValue($field))->toBeNull(); + $reply = $exchange($feedback, $retiredId); + expect($reply)->toMatchArray(['sessionId' => $current['sessionId'], 'diagnostics' => $current['diagnostics'], + 'grid' => $oldFrame['grid'], 'messages' => []]) + ->and(new ReflectionProperty($field, 'sceneHost')->getValue($field))->toBeNull(); + } + expect($exchange([], null))->toMatchArray(['sessionId' => $current['sessionId'], 'messages' => []]); + $exchange([], $current['sessionId']); + expect($exchange(['not json', ...$feedback], $old['sessionId'])['messages'])->toBe([]); + $fresh = $exchange([previewWindowReady()], $current['sessionId']); + $frames = array_values(array_filter($fresh['messages'], static fn(array $message): bool => $message['type'] === 'frame')); + expect($fresh['sessionId'])->toBe($current['sessionId'])->and($frames)->not->toBe([]) + ->and($frames[0]['payload']['generation'])->toBe(1)->and($frames[0]['payload']['reset'])->toBeTrue(); + expect(fn() => $exchange(['not json'], $current['sessionId']))->toThrow(SessionRefusal::class); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +})->with(['cinematic restart' => [false], 'effect reopen' => [true]]); + +it('resizes a kept cinematic through its control description while preserving playback and retiring only the host epoch', function () { + $root = createPreviewContractProject(); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + try { + $first = $session->startCinematicPreview(0, 40, 12, play: true); + $session->exchangeCinematicScene([previewWindowReady()], $first['sessionId']); + $advanced = $session->controlCinematicPreview('tick', 0.1); + $field = readPreviewContractField($session); + $resized = $session->startCinematicPreview(0, 50, 16, keep: true); + expect($resized['sessionId'])->not->toBe($first['sessionId']) + ->and($resized['elapsed'])->toBe($advanced['elapsed'])->and($resized['playing'])->toBeTrue() + ->and($resized['lines'])->toHaveCount(16)->and(readPreviewContractField($session))->toBe($field) + ->and(new ReflectionProperty($field, 'sceneHost')->getValue($field))->toBeNull() + ->and($session->exchangeCinematicScene([], $first['sessionId']))->toMatchArray([ + 'sessionId' => $resized['sessionId'], 'diagnostics' => [], 'messages' => [], + 'grid' => ['columns' => 50, 'rows' => 16, 'cellWidth' => 10, 'cellHeight' => 20], + ]); + expect($session->startCinematicPreview(0, 50, 16, keep: true)['sessionId'])->toBe($resized['sessionId']); + $fresh = $session->exchangeCinematicScene([previewWindowReady()], $resized['sessionId']); + expect(previewFrameGenerations($fresh['messages']))->toBe([1]); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +}); diff --git a/tests/Unit/Cutscenes/PreviewPlayerMovementTest.php b/tests/Unit/Cutscenes/PreviewPlayerMovementTest.php new file mode 100644 index 0000000..145ea2e --- /dev/null +++ b/tests/Unit/Cutscenes/PreviewPlayerMovementTest.php @@ -0,0 +1,158 @@ + CollisionType::NONE, '#' => CollisionType::SOLID, '=' => CollisionType::SOLID]; +PHP_SOURCE); + writeTilesetTestPng($root . '/assets/Graphics/Characters/$Walker.png', 6, 8); + file_put_contents($root . '/assets/Data/Entities/player.php', ' ['north' => ['N'], 'east' => ['E'], 'south' => ['S'], 'west' => ['W']], + 'sprites2d' => ['sheet' => 'Graphics/Characters/$Walker.png'], + ], true) . ';'); + + return $root; +} + +it('uses the project directional sprites and shared heading for both preview presentations', + function (Vector2 $direction, MovementHeading $heading, string $glyph, string $facing, int $row): void { + $field = PreviewField::open(createPreviewWalkingProject(), 'harbour', new Vector2(4, 4), 20, 8, fn(): float => 0.0); + + try { + expect($field->mapFailure)->toBeNull(); + $field->run(function () use ($field, $direction, $heading, $glyph, $facing, $row): void { + $player = $field->scene->player; + $player->face($direction, $field->scene->camera); + expect($player->heading)->toBe($heading) + ->and($player->facing)->toBe($facing) + ->and($player->sprite)->toBe([$glyph]) + ->and([$player->position->x, $player->position->y])->toBe([4.0, 4.0]) + ->and($player->getGraphicalSpriteDefinition()->sourceRect->toArray()) + ->toBe(['x' => 2, 'y' => $row * 2, 'width' => 2, 'height' => 2]); + }); + expect(implode("\n", array_map(TerminalText::stripAnsi(...), $field->frame())))->toContain($glyph); + } finally { + $field->dispose(); + } + })->with([ + [Vector2::up(), MovementHeading::NORTH, 'N', 'up', 3], + [Vector2::right(), MovementHeading::EAST, 'E', 'right', 2], + [Vector2::down(), MovementHeading::SOUTH, 'S', 'down', 0], + [Vector2::left(), MovementHeading::WEST, 'W', 'left', 1], + ]); + +it('uses the runtime step at the callers pace and ends the walking presentation without moving again', function (): void { + $field = PreviewField::open(createPreviewWalkingProject(), 'harbour', new Vector2(4, 4), 20, 8, fn(): float => 0.0); + + try { + expect($field->mapFailure)->toBeNull(); + $field->run(function () use ($field): void { + $player = $field->scene->player; + expect($field->scene->moveAtPace(0.4, fn(): bool => $player->tryMove(Vector2::right(), $field->scene->camera))) + ->toBeTrue() + ->and([$player->position->x, $player->position->y])->toBe([5.0, 4.0]) + ->and($player->heading)->toBe(MovementHeading::EAST) + ->and($player->getGraphicalSpriteMotion()?->seconds)->toBe(0.4) + ->and($player->getGraphicalSpriteDefinition()->sourceRect->x)->toBe(4); + }); + $field->advance(0.7); + $field->run(function () use ($field): void { + $player = $field->scene->player; + expect([$player->position->x, $player->position->y])->toBe([5.0, 4.0]) + ->and($player->getGraphicalSpriteDefinition()->sourceRect->x)->toBe(2); + $player->face(Vector2::up(), $field->scene->camera); + expect($player->getGraphicalSpriteMotion())->toBeNull() + ->and($player->heading)->toBe(MovementHeading::NORTH); + }); + } finally { + $field->dispose(); + } +}); + +it('faces a blocked cell without moving or leaving a stale walking slide', function (): void { + $field = PreviewField::open(createPreviewWalkingProject(), 'harbour', new Vector2(1, 1), 20, 8, fn(): float => 0.0); + + try { + expect($field->mapFailure)->toBeNull(); + $field->run(function () use ($field): void { + $player = $field->scene->player; + expect($player->tryMove(Vector2::left(), $field->scene->camera))->toBeFalse() + ->and([$player->position->x, $player->position->y])->toBe([1.0, 1.0]) + ->and($player->heading)->toBe(MovementHeading::WEST) + ->and($player->sprite)->toBe(['W']) + ->and($player->getGraphicalSpriteMotion())->toBeNull(); + }); + } finally { + $field->dispose(); + } +}); + +it('scrolls following previews through the shared map camera and leaves detached cameras alone', function (): void { + $root = createPreviewWalkingProject(); + writeOpeningMaps($root); + $field = PreviewField::open($root, 'skyfield-night', new Vector2(30, 8), 20, 8, fn(): float => 0.0); + + try { + expect($field->mapFailure)->toBeNull(); + $field->run(function () use ($field): void { + $player = $field->scene->player; + $camera = $field->scene->camera; + $before = clone $camera->position; + expect($player->tryMove(Vector2::right(), $camera))->toBeTrue() + ->and($camera->position->x)->toBe($before->x + 1); + $camera->detach(); + $detached = clone $camera->position; + expect($player->tryMove(Vector2::down(), $camera))->toBeTrue() + ->and([$camera->position->x, $camera->position->y])->toBe([$detached->x, $detached->y]); + }); + } finally { + $field->dispose(); + } +}); + +it('keeps ordinary map events and encounter bookkeeping out of shared preview movement', function (): void { + $root = createPreviewWalkingProject(); + $directory = $root . '/assets/Maps/harbour'; + $map = require $directory . '/harbour.data.php'; + $map['events']['g'] = ['class' => CinematicEventTrigger::class, + 'data' => ['cinematicId' => 'harbour-lanterns', 'mode' => 'auto', 'reusable' => false]]; + file_put_contents($directory . '/harbour.data.php', ' 0.0); + + try { + expect($field->mapFailure)->toBeNull(); + $field->run(function () use ($field): void { + expect($field->scene->player->findEventMarkerPosition('g'))->toBeNull() + ->and($field->scene->encounterManager)->toBeNull() + ->and($field->scene->player->tryMove(Vector2::right(), $field->scene->camera))->toBeTrue(); + }); + $field->advance(1.0); + expect($field->scene->finishedSessions)->toBe([]) + ->and($field->scene->cinematicController->active())->toBeNull() + ->and(ConfigStore::get(ProjectConfig::class)->get('save.autosave'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('audio.music'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('audio.sfx'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('audio.voice'))->toBeFalse() + ->and(sourceHashTree($root))->toBe($before); + } finally { + $field->dispose(); + } +}); diff --git a/tests/Unit/Cutscenes/PreviewSceneHostTest.php b/tests/Unit/Cutscenes/PreviewSceneHostTest.php index 8acef7b..ba89f41 100644 --- a/tests/Unit/Cutscenes/PreviewSceneHostTest.php +++ b/tests/Unit/Cutscenes/PreviewSceneHostTest.php @@ -17,7 +17,7 @@ try { expect($preview->exchangeScene([]))->toBe([]); - $sent = $preview->exchangeScene([previewWindowReady()]); + $sent = $preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId()); expect(array_column($sent, 'type'))->toContain('frame') ->and(previewFrameGenerations($sent))->not->toBe([]); } finally { @@ -30,16 +30,16 @@ $preview = CinematicPreviewSession::start($root, harbourDefinition($root), ['x' => 2, 'y' => 3, 'width' => 40, 'height' => 12]); try { - $first = $preview->exchangeScene([previewWindowReady()]); + $first = $preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId()); $generation = max(previewFrameGenerations($first)); expect($preview->exchangeScene([json_encode(['protocol' => 2, 'type' => 'frame_ack', - 'generation' => $generation, 'frame' => 1, 'presented' => true])]))->toBe([]); + 'generation' => $generation, 'frame' => 1, 'presented' => true])], $preview->getSceneSessionId()))->toBe([]); $preview->detachScene(); // Detached, the Terminal picture has the preview's own size again. expect(count($preview->frame()))->toBe(12) ->and(mb_strlen(Ichiloto\Engine\IO\Console\TerminalText::stripAnsi($preview->frame()[0])))->toBe(40); - $again = array_values(array_filter($preview->exchangeScene([previewWindowReady()]), + $again = array_values(array_filter($preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId()), static fn(array $message): bool => $message['type'] === 'frame')); expect($again)->not->toBe([])->and($again[0]['payload']['reset'] ?? false)->toBeTrue(); } finally { @@ -52,14 +52,14 @@ $preview = CinematicPreviewSession::start($root, harbourDefinition($root), ['x' => 2, 'y' => 3, 'width' => 40, 'height' => 12]); try { - $generation = max(previewFrameGenerations($preview->exchangeScene([previewWindowReady()]))); + $generation = max(previewFrameGenerations($preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId()))); $preview->play(); for ($tick = 0; $tick < 5; $tick++) { $preview->tick(CinematicPreviewSession::TICK_SECONDS); } // The harbour's narration is on screen by now; the graphical view receives it as the next generation. $next = previewFrameGenerations($preview->exchangeScene([json_encode(['protocol' => 2, 'type' => 'frame_ack', - 'generation' => $generation, 'frame' => 1, 'presented' => true])])); + 'generation' => $generation, 'frame' => 1, 'presented' => true])], $preview->getSceneSessionId())); expect($next)->not->toBe([])->and(min($next))->toBeGreaterThan($generation); } finally { $preview->dispose(); diff --git a/tests/Unit/Cutscenes/StagedActorAuthoringTest.php b/tests/Unit/Cutscenes/StagedActorAuthoringTest.php new file mode 100644 index 0000000..5e4224d --- /dev/null +++ b/tests/Unit/Cutscenes/StagedActorAuthoringTest.php @@ -0,0 +1,248 @@ + 'harbour-lanterns', 'name' => 'Pose fixture', 'startMap' => 'harbour', + 'cast' => [ + ['kind' => 'staged_actor', 'id' => 'actor', 'sprite' => [$terminal], 'x' => 1, 'y' => 2], + ], + 'skip' => ['policy' => 'forbidden'], +]; +PHP); + file_put_contents($root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php', <<<'PHP' + 'sequence', 'commands' => [ + ['type' => 'stage_actor', 'id' => 'actor', 'sprite' => ['@'], 'x' => 1, 'y' => 2], + ['type' => 'wait', 'seconds' => 0.5], + ]], +]; +PHP); + + return $root; +} + +function getStagedAuthoringRow(EditorSession $session, string $field, array $frame = []): array +{ + return array_find($session->readDatabaseRecord('cutscenes/cinematic', 0, $frame)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? '') === $field) + ?? throw new RuntimeException('Missing staged authoring row ' . $field); +} + +function setStagedAuthoringRow(EditorSession $session, string $field, string $value, array $frame = []): array +{ + return $session->applyDatabaseRecord('cutscenes/cinematic', 0, getStagedAuthoringRow($session, $field, $frame)['key'], $value); +} + +it('offers graphical controls only to GUI records with engine-owned animation fields and existing pickers', function () { + $root = makeStagedAuthoringProject(); + $session = EditorSession::open($root); + $image = getStagedAuthoringRow($session, 'cast0Sprites2dasset'); + expect($image)->toMatchArray(['kind' => 'reference', 'reference' => 'png_assets', 'media' => ['kind' => 'image', 'root' => 'assets']]) + ->and(array_column($session->listReferences(null, 'png_assets'), 'value'))->toContain('Graphics/Poses/Actor.png'); + setStagedAuthoringRow($session, 'cast0Sprites2dasset', 'Graphics/Poses/Actor.png'); + $graphical = CutsceneSchemas::stagedActorFields(true, ['sprites2d' => ['asset' => 'Graphics/Poses/Actor.png']]); + $keys = array_map(static fn($field): string => $field->key, $graphical); + foreach (CinematicCommandSchema::export()['stagedPoseAnimation']['fields'] as $key) { + expect($keys)->toContain('sprites2d.animation.' . $key); + } + $terminal = CutsceneSchemas::stagedActorFields(); + expect(array_any($terminal, static fn($field): bool => str_starts_with($field->key, 'sprites2d') || str_starts_with($field->key, 'subject')))->toBeFalse(); +}); + +it('authors a looping pose and crop through GUI history and saves source-preserving runtime data', function () { + $root = makeStagedAuthoringProject(); + $session = EditorSession::open($root); + $file = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.data.php'; + $before = file_get_contents($file); + foreach ([ + 'asset' => 'Graphics/Poses/Actor.png', 'cells' => '2, 1', 'sourceRect' => '0, 0, 32, 16', + 'animation.columns' => '2', 'animation.frames' => '0, 1, 1, 0', 'animation.fps' => '4', + 'animation.loop' => 'false', 'animation.restFrame' => '1', + ] as $key => $value) { + expect(setStagedAuthoringRow($session, 'cast0Sprites2d' . str_replace('.', '', $key), $value)['changed'])->toBeTrue(); + } + $session->undo(); + expect(getStagedAuthoringRow($session, 'cast0Sprites2danimationrestFrame')['value'])->toBe('0'); + $session->redo(); + expect(file_get_contents($file))->toBe($before); + $session->saveDatabase('cutscenes/cinematic'); + $data = require $file; + $pose = CinematicStageManager::getGraphicalSprites($data['cast'][0]['sprites2d']); + expect($pose)->toBeInstanceOf(FieldPoseAnimation::class) + ->and($pose->frames)->toBe([0, 1, 1, 0])->and($pose->loop)->toBeFalse() + ->and($pose->fps)->toBe(4)->and($pose->restFrame)->toBe(1) + ->and($pose->getFrame($root . '/assets', 0.25)->sourceRect->x)->toBe(16) + ->and(file_get_contents($file))->toContain('// Keep this authored comment and terminal art.', '$terminal =', "'sprite' => [$" . 'terminal]'); + $reopened = EditorSession::open($root); + expect(getStagedAuthoringRow($reopened, 'cast0Sprites2danimationloop')['value'])->toBe('false'); +}); + +it('switches image forms and clears pose animation as atomic undoable edits without changing terminal sprites', function () { + $root = makeStagedAuthoringProject(); + $session = EditorSession::open($root); + setStagedAuthoringRow($session, 'cast0Sprites2dasset', 'Graphics/Poses/Actor.png'); + setStagedAuthoringRow($session, 'cast0Sprites2danimationcolumns', '2'); + setStagedAuthoringRow($session, 'cast0Sprites2danimationframes', '0, 1'); + $sprite = getStagedAuthoringRow($session, 'cast0Sprite'); + setStagedAuthoringRow($session, 'cast0Sprites2dsheet', 'Graphics/Poses/$Walker.png'); + expect(getStagedAuthoringRow($session, 'cast0Sprite'))->toBe($sprite) + ->and(getStagedAuthoringRow($session, 'cast0Sprites2dindex')['kind'])->toBe('integer'); + $session->undo(); + expect(getStagedAuthoringRow($session, 'cast0Sprites2danimationframes')['value'])->toBe('0, 1'); + setStagedAuthoringRow($session, 'cast0Sprites2danimationframes', ''); + $session->saveDatabase('cutscenes/cinematic'); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::CINEMATIC, 'harbour-lanterns'); + expect($asset->data()['cast'][0]['sprites2d'])->toBe(['asset' => 'Graphics/Poses/Actor.png']); + setStagedAuthoringRow($session, 'cast0Sprites2dasset', ''); + $session->saveDatabase('cutscenes/cinematic'); + expect(CutsceneLibrary::fromProject($root)->find(CutsceneType::CINEMATIC, 'harbour-lanterns')->data()['cast'][0])->not->toHaveKey('sprites2d'); +}); + +it('edits nested stage commands and bindings through the same record paths', function () { + $root = makeStagedAuthoringProject(); + $session = EditorSession::open($root); + $frame = [CutsceneAsset::COMMANDS_KEY, 0, 'commands']; + setStagedAuthoringRow($session, 'command0Sprites2dasset', 'Graphics/Poses/Actor.png', $frame); + setStagedAuthoringRow($session, 'command0Sprites2danimationfps', '6', $frame); + setStagedAuthoringRow($session, 'command0Subjectkind', 'player', $frame); + setStagedAuthoringRow($session, 'command0Replace', 'true', $frame); + $row = getStagedAuthoringRow($session, 'command0Id', $frame); + expect($row['childNoun'])->toBe('suppressed subject'); + $session->addDatabaseItem('cutscenes/cinematic', 0, $row['key'], child: true); + setStagedAuthoringRow($session, 'command0Suppress0Kind', 'npc', $frame); + $choices = $session->listReferences(null, 'map_npcs', ['category' => 'cutscenes/cinematic', 'index' => 0]); + expect(array_column($choices, 'value'))->toContain('keeper'); + setStagedAuthoringRow($session, 'command0Suppress0Id', 'keeper', $frame); + $session->saveDatabase('cutscenes/cinematic'); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::CINEMATIC, 'harbour-lanterns'); + $actor = $asset->commands()[0]['commands'][0]; + CinematicStageManager::validateBinding($actor); + expect($actor)->toMatchArray(['subject' => ['kind' => 'player'], 'suppress' => [['kind' => 'npc', 'id' => 'keeper']], 'replace' => true]) + ->not->toHaveKeys(['x', 'y', 'collision', 'facing']) + ->and($actor['sprites2d']['animation']['fps'])->toBe(6); + setStagedAuthoringRow($session, 'command0Subjectkind', '', $frame); + $session->undo(); + expect(getStagedAuthoringRow($session, 'command0Suppress0Id', $frame)['value'])->toBe('keeper'); +}); + +it('authors actor-envelope commands without flattening their source or writing ignored inline fields', function () { + $root = makeStagedAuthoringProject(); + $file = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($file, <<<'PHP' + 'stage_actor', 'actor' => ['id' => 'actor', 'sprite' => ['@'], 'x' => 1, 'y' => 2]], +]; +PHP); + $session = EditorSession::open($root); + $frame = [CutsceneAsset::COMMANDS_KEY]; + setStagedAuthoringRow($session, 'command0Actorsprites2dasset', 'Graphics/Poses/Actor.png', $frame); + setStagedAuthoringRow($session, 'command0Actorsprites2danimationcolumns', '2', $frame); + setStagedAuthoringRow($session, 'command0Actorsprites2danimationframes', '0, 1', $frame); + setStagedAuthoringRow($session, 'command0Actorsubjectkind', 'npc', $frame); + setStagedAuthoringRow($session, 'command0Actorsubjectid', 'keeper', $frame); + $session->addDatabaseItem('cutscenes/cinematic', 0, getStagedAuthoringRow($session, 'command0Actorid', $frame)['key'], child: true); + setStagedAuthoringRow($session, 'command0Suppress0Kind', 'npc', $frame); + setStagedAuthoringRow($session, 'command0Suppress0Id', 'keeper', $frame); + setStagedAuthoringRow($session, 'command0Suppress0Kind', 'player', $frame); + $session->saveDatabase('cutscenes/cinematic'); + $commands = require $file; + expect(array_keys($commands[0]))->not->toContain('sprites2d', 'subject', 'suppress', 'actor.suppress') + ->and($commands[0]['actor']['suppress'])->toBe([['kind' => 'player']]) + ->and($commands[0]['actor']['subject'])->toBe(['kind' => 'npc', 'id' => 'keeper']) + ->and(file_get_contents($file))->toContain('// This command owns its actor envelope.'); + $pose = CinematicStageManager::getGraphicalSprites($commands[0]['actor']['sprites2d']); + expect($pose->loop)->toBeTrue()->and($pose->getFrame($root . '/assets', 0.25)->sourceRect->x)->toBe(0); + $session->removeDatabaseItem('cutscenes/cinematic', 0, getStagedAuthoringRow($session, 'command0Suppress0Kind', $frame)['key']); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $file)[0]['actor'])->not->toHaveKey('suppress'); +}); + +it('refuses unsupported source expressions before mutation and still edits independent literal fields', function () { + $root = makeStagedAuthoringProject(); + $file = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.data.php'; + file_put_contents($file, str_replace("'x' => 1, 'y' => 2", "'x' => 1, 'y' => 2, 'sprites2d' => ['asset' => 'Graphics/Poses/Actor.png', 'animation' => ['frames' => [0], 'fps' => 4 * 2]]", file_get_contents($file))); + $before = file_get_contents($file); + $session = EditorSession::open($root); + expect(fn() => setStagedAuthoringRow($session, 'cast0Sprites2danimationfps', '12'))->toThrow(SessionRefusal::class, 'cannot be rewritten safely') + ->and(file_get_contents($file))->toBe($before) + ->and($session->listUnsavedChanges())->toBe([]) + ->and(getStagedAuthoringRow($session, 'cast0Sprites2danimationfps')['value'])->toBe('8'); + setStagedAuthoringRow($session, 'cast0Sprites2danimationloop', 'false'); + $session->saveDatabase('cutscenes/cinematic'); + expect(file_get_contents($file))->toContain("'fps' => 4 * 2", "'loop' => false"); +}); + +it('refuses invalid runtime animation values without changing data or history', function () { + $session = EditorSession::open(makeStagedAuthoringProject()); + setStagedAuthoringRow($session, 'cast0Sprites2dasset', 'Graphics/Poses/Actor.png'); + foreach (['animation.fps' => '0', 'animation.columns' => '65', 'animation.frames' => '9', 'cells' => '1.5, 1', 'sourceRect' => '0, 0, 0, 4'] as $field => $value) { + expect(fn() => setStagedAuthoringRow($session, 'cast0Sprites2d' . str_replace('.', '', $field), $value))->toThrow(SessionRefusal::class); + } + $session->undo(); + expect($session->listUnsavedChanges())->toBe([]); +}); + +it('keeps graphical data when the terminal workflow edits a terminal field', function () { + $root = makeStagedAuthoringProject(); + $session = EditorSession::open($root); + setStagedAuthoringRow($session, 'cast0Sprites2dasset', 'Graphics/Poses/Actor.png'); + setStagedAuthoringRow($session, 'cast0Sprites2danimationfps', '5'); + $session->saveDatabase('cutscenes/cinematic'); + $editor = cutscenesEditor($root, 160, 50); + $asset = libraryOf($editor)->find(CutsceneType::CINEMATIC, 'harbour-lanterns'); + $graphics = $asset->data()['cast'][0]['sprites2d']; + expect(cutsceneFieldIds($editor))->not->toContain('cast0Sprites2dasset', 'cast0Subjectkind'); + setCutsceneField($editor, 'cast0X', '3'); + $asset->save(); + expect($asset->data()['cast'][0]['sprites2d'])->toBe($graphics); +}); + +it('binds cast artwork to a selected NPC and preserves visibility and transforms through undo', function () { + $root = makeStagedAuthoringProject(); + $session = EditorSession::open($root); + setStagedAuthoringRow($session, 'cast0Subjectkind', 'npc'); + $session->undo(); + expect(getStagedAuthoringRow($session, 'cast0X')['value'])->toBe('1') + ->and(getStagedAuthoringRow($session, 'cast0Y')['value'])->toBe('2'); + $session->redo(); + $npc = getStagedAuthoringRow($session, 'cast0Subjectid'); + expect($npc['reference'])->toBe('map_npcs'); + setStagedAuthoringRow($session, 'cast0Subjectid', 'keeper'); + setStagedAuthoringRow($session, 'cast0Visible', 'false'); + $row = getStagedAuthoringRow($session, 'cast0Id'); + $session->addDatabaseItem('cutscenes/cinematic', 0, $row['key'], child: true); + $session->removeDatabaseItem('cutscenes/cinematic', 0, getStagedAuthoringRow($session, 'cast0Suppress0Kind')['key']); + $session->saveDatabase('cutscenes/cinematic'); + $actor = CutsceneLibrary::fromProject($root)->find(CutsceneType::CINEMATIC, 'harbour-lanterns')->data()['cast'][0]; + CinematicStageManager::validateBinding($actor); + expect($actor)->toMatchArray(['subject' => ['kind' => 'npc', 'id' => 'keeper'], 'visible' => false]) + ->not->toHaveKeys(['x', 'y', 'facing', 'collision', 'suppress']); +}); diff --git a/tests/Unit/EventAuthoringTest.php b/tests/Unit/EventAuthoringTest.php index c5270d0..1c080c7 100644 --- a/tests/Unit/EventAuthoringTest.php +++ b/tests/Unit/EventAuthoringTest.php @@ -112,8 +112,9 @@ function freeEventCells(ProjectMap $map, int $count = 2): array $current = EventTypeCatalog::at(EventTypeCatalog::indexOfClass($original['class'])); $same = EventAuthoring::setEventType($map, $marker, $current); - expect($map->getEventDefinition($marker))->toBe($current->buildDefinition($original)) - ->and(array_intersect_key($map->getEventDefinition($marker)['data'], $original['data']))->toEqual($original['data']); + // Defaults follow the type, while authored keys retain their values and relative order. + expect($map->getEventDefinition($marker))->toEqual($current->buildDefinition($original)) + ->and(array_intersect_key($map->getEventDefinition($marker)['data'], $original['data']))->toBe($original['data']); $same?->undo(); expect($map->getEventDefinition($marker))->toBe($original); diff --git a/tests/Unit/EventCueFrontendTest.php b/tests/Unit/EventCueFrontendTest.php new file mode 100644 index 0000000..d811ec9 --- /dev/null +++ b/tests/Unit/EventCueFrontendTest.php @@ -0,0 +1,111 @@ + 'Cue frontend fixture', + 'description' => strtoupper('kept'), + 'events' => [ + 'E' => [ + 'class' => 'Ichiloto\Engine\Events\Triggers\ChestEventTrigger', + 'data' => ['lootType' => 'gold', 'loot' => 2], + 'cue' => ['symbol' => '!', 'color' => 'bright-blue', 'conditions' => []], + ], + ], + ]; + PHP); + + return $root; +} + +it('cycles and clears cue kind through the real Terminal inspector without typed or graphical controls', function () { + $root = createCueFrontendProject(); + $editor = deletionEditor($root); + $map = callEditorMethod($editor, 'getSelectedMap'); + $bounds = $map->getEventBounds('E'); + setEditorProperty($editor, 'cursorX', $bounds['x']); + setEditorProperty($editor, 'cursorY', $bounds['y']); + setEditorProperty($editor, 'editingMode', 'event'); + setEditorProperty($editor, 'focusedPane', 'inspector'); + $fields = callEditorMethod($editor, 'getInspectorFields'); + $index = array_find_key($fields, static fn(array $field): bool => ($field['path'] ?? null) === ['cue', 'kind']); + expect($index)->not->toBeNull(); + setEditorProperty($editor, 'selectedInspectorFieldIndex', $index); + $before = $map->getEventDefinition('E'); + $hashes = sourceHashTree($root); + + expect(implode("\n", callEditorMethod($editor, 'inspectorPaneLayout')->lines))->toContain('Unclassified') + ->and($fields[$index])->not->toHaveKey('control'); + callEditorMethod($editor, 'activateInspectorField'); + expect($map->getEventField('E', ['cue', 'kind']))->toBe('story') + ->and(getEditorProperty($editor, 'isInspectorEditing'))->toBeFalse(); + callEditorMethod($editor, 'adjustInspectorOptionField', 1); + expect($map->getEventField('E', ['cue', 'kind']))->toBe('route'); + callEditorMethod($editor, 'adjustInspectorOptionField', -1); + callEditorMethod($editor, 'adjustInspectorOptionField', -1); + expect($map->getEventDefinition('E'))->toBe($before) + ->and(sourceHashTree($root))->toBe($hashes); + callEditorMethod($editor, 'performUndo'); + expect($map->getEventField('E', ['cue', 'kind']))->toBe('story'); + $map->save(); + expect(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getEventField('E', ['cue', 'kind']))->toBe('story') + ->and(file_get_contents($map->dataPath))->toContain("strtoupper('kept')"); +}); + +it('transports cue choices and clear through the GUI session protocol with undo save and reload', function () { + $root = createCueFrontendProject(); + $hashes = sourceHashTree($root); + $input = fopen('php://memory', 'r'); + $output = fopen('php://memory', 'w+'); + $diagnostics = fopen('php://memory', 'w+'); + $host = new SessionHost($input, $output, $diagnostics); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + $read = static fn(): array => $request('inspector.read', ['map' => 'test-map', 'event' => 'E'])['result']; + $findRow = static fn(array $inspector): array => array_find($inspector['rows'], + static fn(array $row): bool => ($row['key']['path'] ?? null) === ['cue', 'kind']); + + try { + expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result'); + $inspector = $read(); + $row = $findRow($inspector); + expect($row)->toMatchArray(['kind' => 'options', 'value' => '', 'options' => ['', 'story', 'route']]) + ->and($row['optionLabels'])->toBe(['Unclassified (clear kind)', 'Story', 'Route']) + ->and(sourceHashTree($root))->toBe($hashes); + + $params = ['map' => 'test-map', 'revision' => $inspector['revision'], 'key' => $row['key']]; + expect($request('inspector.apply', $params + ['value' => 'unknown'])['error']['kind'])->toBe('refusal') + ->and($findRow($read())['value'])->toBe('') + ->and(sourceHashTree($root))->toBe($hashes); + expect($request('inspector.apply', $params + ['value' => 'route'])['result']['changed'])->toBeTrue() + ->and($findRow($read())['value'])->toBe('route'); + expect($request('project.saveAll')['result']['failures'])->toBe([]) + ->and(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getEventField('E', ['cue', 'kind']))->toBe('route'); + $inspector = $read(); + expect($request('inspector.apply', ['map' => 'test-map', 'revision' => $inspector['revision'], + 'key' => $findRow($inspector)['key'], 'value' => ''])['result']['changed'])->toBeTrue() + ->and($findRow($read())['value'])->toBe(''); + expect($request('project.saveAll')['result']['failures'])->toBe([]) + ->and(sourceHashTree($root))->toBe($hashes); + expect($request('history.undo')['result']['label'])->toBe('Cue kind edit') + ->and($findRow($read())['value'])->toBe('route'); + expect($request('history.redo')['result']['label'])->toBe('Cue kind edit') + ->and($findRow($read())['value'])->toBe(''); + rewind($diagnostics); + expect(stream_get_contents($diagnostics))->toBe(''); + } finally { + $host->run(); + fclose($input); + fclose($output); + fclose($diagnostics); + } +}); diff --git a/tests/Unit/EventCueKindAuthoringTest.php b/tests/Unit/EventCueKindAuthoringTest.php new file mode 100644 index 0000000..470ff6f --- /dev/null +++ b/tests/Unit/EventCueKindAuthoringTest.php @@ -0,0 +1,267 @@ + $cueSource,"; + file_put_contents($root . '/assets/Maps/test-map/test-map.data.php', << 'Cue fixture', + 'region' => '', + 'description' => strtoupper('kept'), + 'triggers' => [], + 'events' => [ + 'E' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ChestEventTrigger', + 'data' => ['lootType' => 'gold', 'loot' => 2], + 'conditions' => [], + $cueField + ], + ], + ]; + PHP); + + return $root; +} + +function getCueKindFixtureSource(array $extra = []): string +{ + return "[\n // Independently authored cue presentation.\n 'symbol' => '!',\n 'color' => 'bright-blue',\n 'conditions' => [['type' => 'event', 'name' => 'cue_ready']],\n" + . implode('', array_map(static fn(string $key, mixed $value): string => ' ' . var_export($key, true) . ' => ' . var_export($value, true) . ",\n", array_keys($extra), array_values($extra))) + . ' ]'; +} + +function findCueKindMap(ProjectWorkspace $workspace): ProjectMap +{ + return array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map') + ?? throw new RuntimeException('The synthetic cue map is missing.'); +} + +function getCueKindRow(MapInspector $inspector, ProjectMap $map): array +{ + return array_find($inspector->getEventFields($map, 'E'), static fn(array $row): bool => ($row['path'] ?? null) === ['cue', 'kind']) + ?? throw new RuntimeException('The cue kind picker is missing.'); +} + +it('describes an optional constrained cue kind without classifying legacy cues or writing defaults', function (bool $graphical, ?string $kind, bool $hasCue) { + $root = createCueKindProject($hasCue ? getCueKindFixtureSource($kind === null ? [] : ['kind' => $kind]) : null); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $before = $map->getEventDefinition('E'); + $hashes = sourceHashTree($root); + $row = getCueKindRow(new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical), $map); + + expect(array_column($row['choices'], 'value'))->toBe(['', EventCueKind::STORY->value, EventCueKind::ROUTE->value]) + ->and($row['options'])->toBe(['', 'story', 'route']) + ->and($row['optionLabels'])->toBe(['' => 'Unclassified (clear kind)', 'story' => 'Story', 'route' => 'Route']) + ->and($row['selectedValue'])->toBe($kind ?? '') + ->and($row['optional'])->toBeTrue() + ->and($row)->not->toHaveKey('control') + ->and($map->getEventDefinition('E'))->toBe($before) + ->and($map->isDirty())->toBeFalse(); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes); +})->with([false, true])->with([ + 'no cue' => [null, false], + 'legacy blue cue' => [null, true], + 'story' => ['story', true], + 'route' => ['route', true], +]); + +it('authors each engine cue kind with source-preserving save undo redo and reload', function (bool $graphical, string $kind) { + $root = createCueKindProject(getCueKindFixtureSource()); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $before = $map->getEventDefinition('E'); + $hashes = sourceHashTree($root); + $change = $inspector->apply($map, getCueKindRow($inspector, $map), $kind); + $expected = $before; + $expected['cue']['kind'] = $kind; + + expect($change)->not->toBeNull() + ->and($map->getEventDefinition('E'))->toBe($expected) + ->and(sourceHashTree($root))->toBe($hashes); + $map->save(); + expect(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($expected) + ->and(file_get_contents($map->dataPath))->toContain('// Independently authored cue presentation.', "strtoupper('kept')") + ->and(array_keys(array_diff_assoc(sourceHashTree($root), $hashes)))->toBe(['assets/Maps/test-map/test-map.data.php']); + $change->undo(); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes) + ->and(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($before); + $change->execute(); + $map->save(); + expect(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($expected); +})->with([false, true])->with(['story', 'route']); + +it('clears only cue kind while retaining symbol color conditions and unrelated source', function (bool $graphical, string $kind) { + $root = createCueKindProject(getCueKindFixtureSource(['kind' => $kind])); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $before = $map->getEventDefinition('E'); + $hashes = sourceHashTree($root); + $expected = $before; + unset($expected['cue']['kind']); + $change = $inspector->apply($map, getCueKindRow($inspector, $map), ''); + + expect($change)->not->toBeNull() + ->and($map->getEventDefinition('E'))->toBe($expected) + ->and(EventCue::fromArray($expected['cue'])->kind)->toBeNull(); + $map->save(); + expect(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($expected) + ->and(file_get_contents($map->dataPath))->toContain('// Independently authored cue presentation.', "strtoupper('kept')"); + $change->undo(); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes); + $change->execute(); + $map->save(); + expect(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($expected); +})->with([false, true])->with(['story', 'route']); + +it('keeps clearing omitted kind a no-op and does not materialize cue defaults on authoring', function (bool $graphical) { + $root = createCueKindProject(); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $hashes = sourceHashTree($root); + $row = getCueKindRow($inspector, $map); + + expect($inspector->apply($map, $row, ''))->toBeNull() + ->and($map->isDirty())->toBeFalse() + ->and(sourceHashTree($root))->toBe($hashes); + $change = $inspector->apply($map, $row, 'route'); + expect($map->getEventField('E', ['cue']))->toBe(['kind' => 'route']); + $map->save(); + expect(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventField('E', ['cue']))->toBe(['kind' => 'route']); + $change->undo(); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes); +})->with([false, true]); + +it('refuses unknown picker values before changing memory source or dirty state', function (bool $graphical, string $kind) { + $root = createCueKindProject(getCueKindFixtureSource()); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $before = $map->getEventDefinition('E'); + $hashes = sourceHashTree($root); + + expect(fn() => $inspector->apply($map, getCueKindRow($inspector, $map), $kind))->toThrow(InspectorRefusal::class) + ->and($map->getEventDefinition('E'))->toBe($before) + ->and($map->isDirty())->toBeFalse() + ->and(sourceHashTree($root))->toBe($hashes); +})->with([false, true])->with(['unknown', 'STORY', ' route ', 'null', '[]']); + +it('refuses changing expression-authored cue kind before mutation', function (bool $graphical) { + $root = createCueKindProject("['symbol' => '!', 'kind' => strtolower('STORY')]"); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $before = $map->getEventDefinition('E'); + $hashes = sourceHashTree($root); + + expect(fn() => $inspector->apply($map, getCueKindRow($inspector, $map), 'route'))->toThrow(MapSourceRefusal::class) + ->and($map->getEventDefinition('E'))->toBe($before) + ->and($map->isDirty())->toBeFalse() + ->and(sourceHashTree($root))->toBe($hashes); +})->with([false, true]); + +it('clears expression-authored cue kind with byte-restoring undo after save reload and redo', function (bool $graphical) { + $root = createCueKindProject("['symbol' => '!', 'kind' => strtolower('STORY')]"); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $before = $map->getEventDefinition('E'); + $source = file_get_contents($map->dataPath); + $hashes = sourceHashTree($root); + $expected = $before; + unset($expected['cue']['kind']); + $change = $inspector->apply($map, getCueKindRow($inspector, $map), ''); + + expect($change)->not->toBeNull() + ->and($map->getEventDefinition('E'))->toBe($expected) + ->and(sourceHashTree($root))->toBe($hashes); + $map->save(); + $clearedSource = file_get_contents($map->dataPath); + expect($clearedSource)->not->toContain("strtolower('STORY')") + ->and(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($expected); + $change->undo(); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe($source) + ->and(sourceHashTree($root))->toBe($hashes) + ->and(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($before); + $change->execute(); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe($clearedSource) + ->and(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($expected); +})->with([false, true]); + +it('offers one constrained repair row for malformed authored kinds without flattening them into writable subfields', function (bool $graphical, mixed $kind) { + $root = createCueKindProject(getCueKindFixtureSource(['kind' => $kind])); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $hashes = sourceHashTree($root); + $before = $map->getEventDefinition('E'); + $rows = array_values(array_filter($inspector->getEventFields($map, 'E'), static fn(array $row): bool => array_slice($row['path'] ?? [], 0, 2) === ['cue', 'kind'])); + + expect($rows)->toHaveCount(1) + ->and($rows[0]['path'])->toBe(['cue', 'kind']) + ->and($rows[0]['value'])->not->toBe('') + ->and($rows[0])->not->toHaveKey('control') + ->and($map->getEventDefinition('E'))->toBe($before) + ->and($map->isDirty())->toBeFalse() + ->and(sourceHashTree($root))->toBe($hashes); + $clear = $inspector->apply($map, $rows[0], ''); + $expected = $before; + unset($expected['cue']['kind']); + expect($map->getEventDefinition('E'))->toBe($expected); + $clear->undo(); + expect($map->getEventDefinition('E'))->toBe($before); +})->with([false, true])->with([ + 'null' => [null], 'array' => [['story']], 'boolean' => [false], 'integer' => [1], +]); + +it('accepts omitted story and route kinds through global project validation', function (?string $kind, string $symbol) { + $cue = ['symbol' => $symbol, 'color' => 'bright-blue']; + if ($kind !== null) { $cue['kind'] = $kind; } + $root = createCueKindProject(var_export($cue, true)); + $issues = new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)); + + expect(issuesMentioning($issues, 'event cue'))->toBe([]); + if ($symbol !== '') { + expect(EventCue::fromArray($cue)->kind?->value)->toBe($kind); + } +})->with([null, 'story', 'route'])->with(['!', '']); + +it('rejects every invalid authored kind globally even when the cue symbol is blank', function (mixed $kind, string $symbol) { + $cue = ['symbol' => $symbol, 'kind' => $kind]; + $root = createCueKindProject(var_export($cue, true)); + $hashes = sourceHashTree($root); + $issues = issuesMentioning(new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), 'event cue kind'); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->where)->toBe('test-map event E') + ->and(fn() => EventCue::fromArray($cue))->toThrow(InvalidArgumentException::class) + ->and(sourceHashTree($root))->toBe($hashes); +})->with([ + 'unknown' => ['unknown'], 'empty' => [''], 'whitespace' => [' story '], + 'null' => [null], 'integer' => [1], 'float' => [1.5], 'boolean' => [false], + 'array' => [['story']], 'object' => [(object) ['kind' => 'story']], +])->with(['!', '']); diff --git a/tests/Unit/FileSetTransactionTest.php b/tests/Unit/FileSetTransactionTest.php index 1fe93df..c3cd960 100644 --- a/tests/Unit/FileSetTransactionTest.php +++ b/tests/Unit/FileSetTransactionTest.php @@ -59,6 +59,69 @@ function folderState(string $folder): array return $state; } +it('refuses a changed read dependency before creating or writing the destination', function () { + [$source, $dataPath] = transactionFolder(); + $destination = dirname($source) . '/copy'; + $loaded = (string) file_get_contents($dataPath); + $transaction = new FileSetTransaction($destination); + $transaction->expectSource($dataPath, $loaded); + $transaction->write($destination . '/copy.data.php', $loaded); + file_put_contents($dataPath, $loaded . "\n// Another author changed the source.\n"); + $before = folderState($source); + + expect(fn() => $transaction->commit())->toThrow(FileSetTransactionFailure::class, 'changed after opening') + ->and(is_dir($destination))->toBeFalse() + ->and(folderState($source))->toBe($before); +}); + +it('only recovers a missing source when the caller explicitly permits it', function (bool $allowMissing) { + [$folder, $dataPath] = transactionFolder(); + $loaded = (string) file_get_contents($dataPath); + unlink($dataPath); + $transaction = new FileSetTransaction($folder); + $transaction->expectSource($dataPath, $loaded, $allowMissing); + $transaction->write($dataPath, $loaded); + if ($allowMissing) { + $transaction->commit(); + expect(file_get_contents($dataPath))->toBe($loaded); + } else { + expect(fn() => $transaction->commit())->toThrow(FileSetTransactionFailure::class, 'changed after opening') + ->and(is_file($dataPath))->toBeFalse(); + } +})->with([false, true]); + +it('refuses source changes made after staging without leaving temporary files', function () { + [$source, $dataPath] = transactionFolder(); + $destination = dirname($source) . '/copy'; + $loaded = (string) file_get_contents($dataPath); + $transaction = new FileSetTransaction($destination); + $transaction->expectSource($dataPath, $loaded); + $transaction->write($destination . '/copy.data.php', $loaded); + $transaction->stage(); + file_put_contents($dataPath, $loaded . '// Concurrent external edit'); + $before = folderState($source); + expect(fn() => $transaction->commit())->toThrow(FileSetTransactionFailure::class, 'changed after opening') + ->and(is_dir($destination))->toBeFalse() + ->and(folderState($source))->toBe($before); +}); + +it('reserves read dependencies until the proposal has finished', function () { + [$source, $dataPath] = transactionFolder(); + $destination = dirname($source) . '/copy'; + $loaded = (string) file_get_contents($dataPath); + $transaction = new FileSetTransaction($destination); + $transaction->expectSource($dataPath, $loaded); + $transaction->write($destination . '/copy.data.php', $loaded); + $transaction->stage(); + $competing = new FileSetTransaction($source); + $competing->write($dataPath, $loaded . '// Concurrent proposal'); + expect(fn() => $competing->commit())->toThrow(FileSetTransactionFailure::class, 'Another file transaction'); + $transaction->rollBack(); + $competing->commit(); + expect(file_get_contents($dataPath))->toBe($loaded . '// Concurrent proposal') + ->and(is_dir($destination))->toBeFalse(); +}); + it('installs a new pair only when both files land, and leaves no folder when the second cannot', function () { [$folder, $dataPath, $partnerPath] = transactionFolder(withPair: false); $files = new FailingFileSetOperations(failures: ['move' => [$partnerPath]]); diff --git a/tests/Unit/InnDescriptorAuthoringTest.php b/tests/Unit/InnDescriptorAuthoringTest.php new file mode 100644 index 0000000..a580828 --- /dev/null +++ b/tests/Unit/InnDescriptorAuthoringTest.php @@ -0,0 +1,515 @@ + 10, 'lengthFrames' => 4, + 'stage' => ['canvas' => ['width' => 320, 'height' => 180], 'startFrame' => 0, 'restoreFrame' => 3, + 'camera' => [['id' => 'hold', 'frame' => 0, 'focus' => ['x' => 160, 'y' => 90], 'zoom' => 1, 'easing' => 'hold']], + 'subjects' => [['id' => 'guest', 'position' => ['x' => 160, 'y' => 150], 'size' => ['width' => 32, 'height' => 32]]]], + 'tracks' => [['id' => 'guest', 'type' => 'image', 'asset' => 'Graphics/Rest/guest.png', 'anchor' => 'stage', + 'placement' => ['subject' => 'guest'], 'pivot' => ['x' => .5, 'y' => 1], + 'keyframes' => [['frame' => 0, 'duration' => 4]]]], + ], true) . ';'); + } + $actor = (string) file_get_contents($root . '/assets/Data/Actors/Kaelion.php'); + file_put_contents($root . '/assets/Data/Actors/Guest.php', str_replace("'Kaelion'", "'guest-b'", $actor)); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace(" 'events' => [\n", " 'events' => [\n 'S' => [\n 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\SleepEventTrigger',\n 'data' => ['cost' => 5, 'confirmDialogue' => ['text' => 'Rest?']],\n ],\n", (string) file_get_contents($data))); + return $root; +} + +function innDescriptorConfig(string $root, string $descriptor): string +{ + $source = " ['title' => 'Original'],\n 'untouched' => strtoupper('kept'),\n 'opaque' => new stdClass(),\n 'graphics' => ['inn' => ['presentation' => $descriptor]],\n];\n"; + file_put_contents($root . '/config.php', $source); + return $source; +} + +function innDescriptorMap(ProjectWorkspace $workspace): ProjectMap +{ + return array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); +} + +function innDescriptorRow(MapInspector $inspector, ProjectMap $map, callable $matches): array +{ + return array_find($inspector->buildEventDataFields('S', $map->getEventDefinition('S')), $matches) ?? []; +} + +function innDescriptorSessionRow(EditorSession $session, string $category, int $index, string $field, array $frame = []): array +{ + return array_find($session->readDatabaseRecord($category, $index, $frame)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === $field) + ?? throw new RuntimeException('Missing Inn row ' . $field); +} + +function innDescriptorCinematic(string $root, string $presentation = ''): string +{ + $folder = $root . '/assets/Cutscenes/Cinematics/rest-scene'; + mkdir($folder, 0777, true); + file_put_contents($folder . '/rest-scene.data.php', " 'rest-scene', 'name' => 'Rest', 'startMap' => 'test-map', 'skip' => ['policy' => 'forbidden']];\n"); + $file = $folder . '/rest-scene.script.php'; + file_put_contents($file, " 'sequence', 'commands' => [['type' => 'inn', 'confirmDialogue' => ['text' => 'Rest?'], 'cost' => 5$presentation]]]];\n"); + return $file; +} + +it('keeps each descriptor one config record and preserves source through edit save reopen and byte-restoring undo', function (string $descriptor, string $fieldId, array $expected) { + $root = innDescriptorProject(); + $source = innDescriptorConfig($root, $descriptor); + $hashes = sourceHashTree($root); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $db = $workspace->getRecordDatabase('configuration'); + $index = array_search(ProjectConfig::INN_PRESENTATION, $db->getEntryLabels(), true); + expect(array_filter($db->getEntryLabels(), static fn(string $path): bool => str_starts_with($path, ProjectConfig::INN_PRESENTATION)))->toHaveCount(1); + $row = array_find($db->getSettingsFields($index), static fn(array $row): bool => ($row['field'] ?? null) === $fieldId); + expect($row['reference'])->toBe('stage_timelines')->and($row)->not->toHaveKey('control'); + $change = new RecordAuthoring()->applyField($db, $index, [], $fieldId, 'rest-b', 'Stage'); + $change->command->undo(); + expect($workspace->config->getSource())->toBe($source)->and(sourceHashTree($root))->toBe($hashes); + $change->command->execute(); + $workspace->config->save(); + expect(file_get_contents($root . '/config.php'))->toContain('// selected shot', "strtoupper('kept')", 'new stdClass()'); + $reopened = ProjectWorkspace::fromProject($root, graphical: true); + expect($reopened->config->getRecord(ProjectConfig::INN_PRESENTATION)->get('value'))->toBe($expected); + $change->command->undo(); + $workspace->config->save(); + expect(file_get_contents($root . '/config.php'))->toBe($source)->and(sourceHashTree($root))->toBe($hashes); +})->with([ + 'leader' => ["['treatment' => 'leader', 'leaders' => [\n // selected shot\n 'Kaelion' => 'rest-a',\n ]]", 'innLeader0Timeline', ['treatment' => 'leader', 'leaders' => ['Kaelion' => 'rest-b']]], + 'party' => ["['treatment' => 'party', 'parties' => [\n // selected shot\n ['actors' => ['Kaelion', 'guest-b'], 'timeline' => 'rest-a'],\n ]]", 'innParty0Timeline', ['treatment' => 'party', 'parties' => [['actors' => ['Kaelion', 'guest-b'], 'timeline' => 'rest-b']]]], +]); + +it('creates bindings with stable constrained pickers not invented ids and refuses incomplete saves or mixed treatment before mutation', function (string $treatment) { + $root = innDescriptorProject(); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $db = $workspace->getRecordDatabase('configuration'); + $index = array_search(ProjectConfig::INN_PRESENTATION, $db->getEntryLabels(), true); + $before = sourceHashTree($root); + $author = new RecordAuthoring(); + $author->applyField($db, $index, [], 'value.treatment', $treatment, 'Treatment'); + $prefix = $treatment === 'leader' ? 'innLeader' : 'innParty'; + $add = $author->addItem($db, $index, [], $prefix . 'List'); + expect($add->command)->not->toBeNull() + ->and(fn() => $workspace->config->save())->toThrow(InvalidArgumentException::class) + ->and(sourceHashTree($root))->toBe($before); + $actorField = $treatment === 'leader' ? $prefix . '0Actor' : $prefix . '0Actors'; + $actorRow = array_find($db->getSettingsFields($index), static fn(array $row): bool => ($row['field'] ?? null) === $actorField); + expect($actorRow['reference'])->toBe('actor_ids')->and($actorRow)->not->toHaveKey('control'); + $snapshot = $db->getRecordByIndex($index)->toArray(); + expect(fn() => $author->applyField($db, $index, [], $actorField, 'missing-actor', 'Guest'))->toThrow(RecordRefusal::class) + ->and($db->getRecordByIndex($index)->toArray())->toBe($snapshot); + $author->applyField($db, $index, [], $actorField, $treatment === 'leader' ? 'Kaelion' : 'Kaelion, guest-b', 'Guests'); + $stage = $author->applyField($db, $index, [], $prefix . '0Timeline', 'rest-a', 'Stage'); + $snapshot = $db->getRecordByIndex($index)->toArray(); + expect(fn() => $author->applyField($db, $index, [], 'value.treatment', $treatment === 'leader' ? 'party' : 'leader', 'Treatment'))->toThrow(RecordRefusal::class) + ->and($db->getRecordByIndex($index)->toArray())->toBe($snapshot); + $workspace->config->save(); + expect(ProjectWorkspace::fromProject($root)->config->getRecord(ProjectConfig::INN_PRESENTATION)->get('value')['treatment'])->toBe($treatment); + $stage->command->undo(); + expect(fn() => $workspace->config->save())->toThrow(InvalidArgumentException::class); + $stage->command->execute(); + expect($db->getRecordByIndex($index)->toArray())->toBe($snapshot); +})->with(['leader', 'party']); + +it('refuses expression edits and unknown stages before record mutation or history creation', function () { + $root = innDescriptorProject(); + $source = innDescriptorConfig($root, "['treatment' => 'leader', 'leaders' => ['Kaelion' => strtolower('REST-A')]]"); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $db = $workspace->getRecordDatabase('configuration'); + $index = array_search(ProjectConfig::INN_PRESENTATION, $db->getEntryLabels(), true); + $before = $db->getRecordByIndex($index)->toArray(); + $version = $db->getContentVersion(); + expect(fn() => new RecordAuthoring()->applyField($db, $index, [], 'innLeader0Timeline', 'rest-b', 'Stage'))->toThrow(RecordRefusal::class) + ->and(fn() => $db->setField($index, 'innLeader0Timeline', 'missing-stage'))->toThrow(InvalidArgumentException::class) + ->and($db->getRecordByIndex($index)->toArray())->toBe($before) + ->and($db->getContentVersion())->toBe($version) + ->and($workspace->config->isDirty())->toBeFalse() + ->and(file_get_contents($root . '/config.php'))->toBe($source); +}); + +it('preserves graphical descriptors in Terminal while retaining scalar config and Sleep workflow', function () { + $root = innDescriptorProject(); + innDescriptorConfig($root, "['treatment' => 'leader', 'leaders' => ['Kaelion' => 'rest-a']]"); + $workspace = ProjectWorkspace::fromProject($root); + $db = $workspace->getRecordDatabase('configuration'); + $index = array_search(ProjectConfig::INN_PRESENTATION, $db->getEntryLabels(), true); + $fields = $db->getSettingsFields($index); + expect($fields)->toHaveCount(2)->and($fields[1]['editable'])->toBeFalse(); + $old = $db->getRecordByIndex($index)->toArray(); + $db->setField($index, 'value', 'rest-b'); + expect($db->getRecordByIndex($index)->toArray())->toBe($old); + $map = innDescriptorMap($workspace); + $map->setEventField('S', ['data', 'presentation'], $old['value']); + $tui = new MapInspector(new ReferenceCatalog($workspace)); + $row = innDescriptorRow($tui, $map, static fn(array $row): bool => ($row['innPresentation'] ?? false) === true); + expect($row['editable'])->toBeFalse() + ->and(fn() => $tui->apply($map, $row, 'rest-b'))->toThrow(InspectorRefusal::class); + $map->setEventField('S', ['data', 'cost'], 7); + $map->save(); + expect(ProjectWorkspace::fromProject($root)->maps[0]->data)->not->toBeNull(); +}); + +it('authors Sleep leader and exact-party bindings with undo redo save reopen and refusal without mutation', function (string $treatment) { + $root = innDescriptorProject(); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $map = innDescriptorMap($workspace); + $before = sourceHashTree($root); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: true); + $treatmentRow = innDescriptorRow($inspector, $map, static fn(array $row): bool => ($row['path'] ?? null) === ['data', 'presentation', 'treatment']); + $mode = $inspector->apply($map, $treatmentRow, $treatment); + $heading = innDescriptorRow($inspector, $map, static fn(array $row): bool => isset($row['innPresentationList']) && ($row['innPresentationList']['index'] ?? null) === -1); + $added = $inspector->addListEntry($map, $heading); + expect(fn() => $map->save())->toThrow(InvalidArgumentException::class)->and(sourceHashTree($root))->toBe($before); + $actorRow = innDescriptorRow($inspector, $map, static fn(array $row): bool => ($row['reference'] ?? null) === 'actor_ids'); + $inspector->apply($map, $actorRow, $treatment === 'leader' ? 'Kaelion' : 'Kaelion, guest-b'); + $stageRow = innDescriptorRow($inspector, $map, static fn(array $row): bool => isset($row['innEntryField']) && ($row['reference'] ?? null) === 'stage_timelines'); + $stage = $inspector->apply($map, $stageRow, 'rest-a'); + $snapshot = $map->getEventDefinition('S'); + expect(fn() => $inspector->apply($map, $stageRow, 'absent-stage'))->toThrow(InspectorRefusal::class) + ->and($map->getEventDefinition('S'))->toBe($snapshot); + $stage->undo(); + $stage->execute(); + $map->save(); + expect(innDescriptorMap(ProjectWorkspace::fromProject($root))->getEventDefinition('S'))->toBe($snapshot); + $removed = $inspector->removeListEntry($map, $stageRow); + $removed->command->undo(); + expect($map->getEventDefinition('S'))->toBe($snapshot); + $removed->command->execute(); + $added->command->undo(); + $mode->undo(); + $map->save(); + expect(sourceHashTree($root))->toBe($before); +})->with(['leader', 'party']); + +it('authors nested registered inn descriptors and retains Terminal command scalar compatibility', function (string $treatment) { + $root = innDescriptorProject(); + file_put_contents($root . '/assets/Events/rest.php', " 'if', 'condition' => ['switch' => 'late'], 'then' => [['type' => 'inn', 'cost' => 5]]]];\n"); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $db = $workspace->getRecordDatabase('common_events'); + $index = array_search('rest', $db->getEntryLabels(), true); + $author = new RecordAuthoring(); + $frame = [0, 'then']; + $author->applyField($db, $index, $frame, 'command0Presentationtreatment', $treatment, 'Treatment'); + $beforeAdd = $db->getFrameCommands($index, $frame); + $added = $author->addNestedItem($db, $index, $frame, 0); + expect($added->command)->not->toBeNull()->and(fn() => $db->save())->toThrow(InvalidArgumentException::class); + $draft = $db->getFrameCommands($index, $frame); + $added->command->undo(); + expect($db->getFrameCommands($index, $frame))->toBe($beforeAdd); + $added->command->execute(); + expect($db->getFrameCommands($index, $frame))->toBe($draft); + $prefix = $treatment === 'leader' ? 'command0InnLeader0' : 'command0InnParty0'; + $author->applyField($db, $index, $frame, $prefix . ($treatment === 'leader' ? 'Actor' : 'Actors'), $treatment === 'leader' ? 'Kaelion' : 'Kaelion, guest-b', 'Guests'); + $change = $author->applyField($db, $index, $frame, $prefix . 'Timeline', 'rest-a', 'Stage'); + $expected = $db->getFrameCommands($index, $frame)[0]['presentation']; + $db->save(); + $change->command->undo(); + $change->command->execute(); + expect(ProjectWorkspace::fromProject($root)->getRecordDatabase('common_events')->getFrameCommands($index, $frame)[0]['presentation'])->toBe($expected) + ->and(file_get_contents($root . '/assets/Events/rest.php'))->toContain('// keep script annotation'); + $tui = ProjectWorkspace::fromProject($root)->getRecordDatabase('common_events'); + $row = array_find($tui->getFrameSettingsFields($index, $frame), static fn(array $row): bool => ($row['field'] ?? null) === 'command0Presentation'); + expect($row['editable'])->toBeFalse(); +})->with(['leader', 'party']); + +it('traverses every bound stage independent of the live party and reports malformed descriptors without guessing fallback', function () { + $root = innDescriptorProject(); + innDescriptorConfig($root, "['treatment' => 'leader', 'leaders' => ['Kaelion' => 'rest-a', 'guest-b' => 'missing-rest']]"); + file_put_contents($root . '/assets/Events/rest.php', ' 'if', 'then' => [['type' => 'inn', 'presentation' => ['treatment' => 'party', 'parties' => [ + ['actors' => ['Kaelion', 'guest-b'], 'timeline' => 'rest-b'], + ]]]]], + ], true) . ';'); + $workspace = ProjectWorkspace::fromProject($root); + $issues = []; + $uses = EffectValidator::findUses($workspace, $issues); + expect($uses['rest-a']['stage'])->toBe(['config.php: ' . ProjectConfig::INN_PRESENTATION]) + ->and($uses['missing-rest']['stage'])->toBe(['config.php: ' . ProjectConfig::INN_PRESENTATION]) + ->and($uses['rest-b']['stage'])->toBe(['event script rest']) + ->and(array_filter(new EffectValidator()->validate($workspace), static fn($issue): bool => str_contains($issue->message, 'missing-rest')))->toHaveCount(1); + $workspace->config->getRecord(ProjectConfig::INN_PRESENTATION)->set('value', ['leaders' => ['Kaelion' => 'rest-a']]); + $issues = []; + $uses = EffectValidator::findUses($workspace, $issues); + expect($uses)->not->toHaveKey('rest-a') + ->and(array_filter($issues, static fn($issue): bool => str_contains($issue->message, 'rest presentation is invalid')))->toHaveCount(1); +}); + +it('rejects mixed bindings duplicate parties and unknown keys through the authoritative descriptor', function (array $value) { + expect(fn() => InnPresentationFields::assertValid($value, complete: false))->toThrow(InvalidArgumentException::class); +})->with([ + [['treatment' => 'leader', 'leaders' => ['Kaelion' => 'rest-a'], 'parties' => [['actors' => ['guest-b'], 'timeline' => 'rest-b']]]], + [['treatment' => 'party', 'parties' => [['actors' => ['Kaelion', 'guest-b'], 'timeline' => 'rest-a'], ['actors' => ['guest-b', 'Kaelion'], 'timeline' => 'rest-b']]]], + [['treatment' => 'leader', 'leaders' => [], 'fallback' => 'rest-a']], +]); + +it('renames a selected leader identity in place without dropping its source annotation or expression value', function () { + $root = innDescriptorProject(); + $source = innDescriptorConfig($root, "['treatment' => 'leader', 'leaders' => [\n // selected shot\n 'Kaelion' => strtolower('REST-A'),\n ]]"); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $db = $workspace->getRecordDatabase('configuration'); + $index = array_search(ProjectConfig::INN_PRESENTATION, $db->getEntryLabels(), true); + $change = new RecordAuthoring()->applyField($db, $index, [], 'innLeader0Actor', 'guest-b', 'Leader'); + $workspace->config->save(); + expect(file_get_contents($root . '/config.php'))->toContain('// selected shot', "'guest-b' => strtolower('REST-A')") + ->and(ProjectWorkspace::fromProject($root)->config->getRecord(ProjectConfig::INN_PRESENTATION)->get('value')['leaders'])->toBe(['guest-b' => 'rest-a']); + $change->command->undo(); + $workspace->config->save(); + expect(file_get_contents($root . '/config.php'))->toBe($source); +}); + +it('exposes constrained Inn controls through the graphical session with history and disk round trips', function () { + $root = innDescriptorProject(); + $session = EditorSession::open($root); + $index = array_search(ProjectConfig::INN_PRESENTATION, ProjectWorkspace::fromProject($root)->getRecordDatabase('configuration')->getEntryLabels(), true); + $row = innDescriptorSessionRow($session, 'configuration', $index, 'value.treatment'); + expect($row['kind'])->toBe('options'); + $session->applyDatabaseRecord('configuration', $index, $row['key'], 'party'); + $session->addDatabaseItem('configuration', $index, innDescriptorSessionRow($session, 'configuration', $index, 'innPartyList')['key']); + $actors = innDescriptorSessionRow($session, 'configuration', $index, 'innParty0Actors'); + expect($actors)->toMatchArray(['kind' => 'reference', 'reference' => 'actor_ids', 'multi' => true]) + ->and(array_column($session->listReferences(null, 'actor_ids'), 'value'))->toContain('Kaelion', 'guest-b'); + $session->applyDatabaseRecord('configuration', $index, $actors['key'], 'Kaelion, guest-b'); + $stage = innDescriptorSessionRow($session, 'configuration', $index, 'innParty0Timeline'); + expect($stage)->toMatchArray(['kind' => 'reference', 'reference' => 'stage_timelines']); + $session->applyDatabaseRecord('configuration', $index, $stage['key'], 'rest-b'); + $session->undo(); + expect(innDescriptorSessionRow($session, 'configuration', $index, 'innParty0Timeline')['value'])->toBe(''); + $session->redo(); + expect(fn() => $session->applyDatabaseRecord('configuration', $index, $stage['key'], 'missing-stage'))->toThrow(SessionRefusal::class); + $session->saveDatabase('configuration'); + $reopenedIndex = array_search(ProjectConfig::INN_PRESENTATION, ProjectWorkspace::fromProject($root)->getRecordDatabase('configuration')->getEntryLabels(), true); + expect(innDescriptorSessionRow(EditorSession::open($root), 'configuration', $reopenedIndex, 'innParty0Timeline')['value'])->toBe('rest-b'); +}); + +it('keeps cinematic owned-list drafts editable and reference-constrained across refresh then saves reopens and undoes', function () { + $root = innDescriptorProject(); + $file = innDescriptorCinematic($root); + $before = file_get_contents($file); + $session = EditorSession::open($root); + $frame = [CutsceneAsset::COMMANDS_KEY, 0, 'commands']; + $row = static fn(string $field): array => innDescriptorSessionRow($session, 'cutscenes/cinematic', 0, $field, $frame); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row('command0Presentationtreatment')['key'], 'party'); + expect($session->addDatabaseItem('cutscenes/cinematic', 0, $row('command0Cost')['key'], child: true)['changed'])->toBeTrue(); + expect(fn() => $session->saveDatabase('cutscenes/cinematic'))->toThrow(InvalidArgumentException::class) + ->and(file_get_contents($file))->toBe($before); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row('command0InnParty0Actors')['key'], 'Kaelion, guest-b'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row('command0InnParty0Timeline')['key'], 'rest-a'); + $snapshot = $session->readDatabaseRecord('cutscenes/cinematic', 0, $frame); + expect(fn() => $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row('command0InnParty0Actors')['key'], 'unknown-actor'))->toThrow(SessionRefusal::class) + ->and($session->readDatabaseRecord('cutscenes/cinematic', 0, $frame))->toBe($snapshot); + $session->saveDatabase('cutscenes/cinematic'); + expect(innDescriptorSessionRow(EditorSession::open($root), 'cutscenes/cinematic', 0, 'command0InnParty0Timeline', $frame)['value'])->toBe('rest-a') + ->and(file_get_contents($file))->toContain('// preserve cinematic annotation'); + $session->undo(); + $session->redo(); + expect($row('command0InnParty0Timeline')['value'])->toBe('rest-a'); +}); + +it('keeps NPC commands graphical only while their sole map owner supplies save and history', function () { + $root = innDescriptorProject(); + $file = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $file; + $data['npcs'] = [['id' => 'keeper', 'name' => 'Keeper', 'x' => 1, 'y' => 1, 'sprite' => 'K', + 'script' => [['type' => 'inn', 'cost' => 5, 'presentation' => ['treatment' => 'leader', 'leaders' => ['Kaelion' => 'rest-a']]]]]]; + file_put_contents($file, 'getFields(0, $frame), static fn(array $row): bool => ($row['field'] ?? '') === 'command0Presentation'); + expect($tuiRow['editable'])->toBeFalse(); + $session = EditorSession::open($root); + $read = $session->readNpc('test-map', 0, $frame); + $row = array_find($read['rows'], static fn(array $row): bool => ($row['key']['field'] ?? '') === 'command0InnLeader0Timeline'); + expect($row['reference'])->toBe('stage_timelines'); + $session->applyNpc('test-map', $read['revision'], 0, $row['key'], 'rest-b'); + $session->undo(); + $session->redo(); + $session->saveMap('test-map'); + $reopened = $session->readNpc('test-map', 0, $frame); + expect(array_find(EditorSession::open($root)->readNpc('test-map', 0, $frame)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? '') === 'command0InnLeader0Timeline')['value'])->toBe('rest-b'); + expect(fn() => $session->applyNpc('test-map', $reopened['revision'], 0, $row['key'], 'missing-stage'))->toThrow(SessionRefusal::class); +}); + +it('preserves untouched graphical references while a Terminal cinematic edits gameplay cost', function (bool $saveAll) { + $root = innDescriptorProject(); + $file = innDescriptorCinematic($root, ", 'presentation' => ['treatment' => 'leader', 'leaders' => ['unknown-actor' => 'rest-a']]"); + $before = sourceHashTree($root); + $workspace = ProjectWorkspace::fromProject($root); + $library = $workspace->cutscenes; + $frame = [CutsceneAsset::COMMANDS_KEY, 0, 'commands']; + $change = $library->changeAsset(CutsceneType::CINEMATIC, 0, 'Cost', static fn($db, $index) => new RecordAuthoring()->applyField($db, $index, $frame, 'command0Cost', '7', 'Cost')); + if ($saveAll) { + expect($library->saveAll())->toBe(['saved' => ['cinematic rest-scene'], 'failed' => []]); + } else { + expect($library->save(CutsceneType::CINEMATIC, 'rest-scene'))->toBeTrue(); + } + expect((require $file)[0]['commands'][0])->toMatchArray(['cost' => 7, 'presentation' => ['treatment' => 'leader', 'leaders' => ['unknown-actor' => 'rest-a']]]) + ->and(ProjectWorkspace::fromProject($root)->cutscenes->find(CutsceneType::CINEMATIC, 'rest-scene')->payload()['commands'][0]['commands'][0]['cost'])->toBe(7) + ->and(file_get_contents($file))->toContain('// preserve cinematic annotation'); + $change['command']->undo(); + $library->save(CutsceneType::CINEMATIC, 'rest-scene'); + expect(sourceHashTree($root))->toBe($before); +})->with(['single save' => [false], 'save all' => [true]]); + +it('refuses incomplete or invalid newly authored bindings at the direct cutscene owner before staging or backup', function (array $presentation, bool $withReferences) { + $root = innDescriptorProject(); + innDescriptorCinematic($root); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $asset = $workspace->cutscenes->find(CutsceneType::CINEMATIC, 'rest-scene'); + $before = sourceHashTree($root); + $state = $asset->captureEditState(); + $payload = $asset->payload(); + $payload['commands'][0]['commands'][0]['presentation'] = $presentation; + $asset->apply($payload); + $edited = $asset->captureEditState(); + $backedUp = false; + $backup = static function () use (&$backedUp): void { $backedUp = true; }; + $references = $withReferences ? new ReferenceCatalog($workspace) : null; + expect(fn() => $asset->save($backup, $references))->toThrow(InvalidArgumentException::class) + ->and($backedUp)->toBeFalse() + ->and($asset->captureEditState())->toBe($edited) + ->and($asset->isDirty())->toBeTrue() + ->and(sourceHashTree($root))->toBe($before); + $asset->restoreEditState($state); + expect($asset->save($backup, $references))->toBeFalse()->and(sourceHashTree($root))->toBe($before); +})->with([ + 'missing treatment without catalog' => [['leaders' => ['Kaelion' => 'rest-a']], false], + 'blank leader without catalog' => [['treatment' => 'leader', 'leaders' => ['' => '']], false], + 'blank party without catalog' => [['treatment' => 'party', 'parties' => [['actors' => [], 'timeline' => '']]], false], + 'unknown actor with live catalog' => [['treatment' => 'leader', 'leaders' => ['unknown-actor' => 'rest-a']], true], + 'unknown stage with live catalog' => [['treatment' => 'party', 'parties' => [['actors' => ['Kaelion'], 'timeline' => 'unknown-stage']]], true], +]); + +it('composes route draft refusal with unchanged Inn references at the paired owner and restores authored source after save', function (array $draft) { + $root = innDescriptorProject(); + $file = innDescriptorCinematic($root, ", 'presentation' => ['treatment' => 'leader', 'leaders' => ['unknown-actor' => strtolower('REST-A')]]"); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $asset = $workspace->cutscenes->find(CutsceneType::CINEMATIC, 'rest-scene'); + $state = $asset->captureEditState(); + $before = sourceHashTree($root); + $payload = $asset->payload(); + $route = ['type' => 'move_route', 'subject' => 'player', 'secondsPerStep' => 0.3]; + $payload['commands'][0]['commands'][] = [...$route, ...$draft]; + $asset->apply($payload); + $pending = $asset->captureEditState(); + $backedUp = false; + $backup = static function () use (&$backedUp): void { $backedUp = true; }; + $references = new ReferenceCatalog($workspace); + expect(fn() => $asset->save($backup, $references))->toThrow(InvalidArgumentException::class) + ->and($backedUp)->toBeFalse() + ->and($asset->captureEditState())->toBe($pending) + ->and(sourceHashTree($root))->toBe($before); + $complete = [...$route, 'waypoints' => [['x' => 0], ['y' => 0]]]; + $payload['commands'][0]['commands'][1] = $complete; + $asset->apply($payload); + expect($asset->save(references: $references))->toBeTrue() + ->and((require $file)[0]['commands'][1])->toBe($complete) + ->and(file_get_contents($file))->toContain('// preserve cinematic annotation', "strtolower('REST-A')") + ->and(ProjectWorkspace::fromProject($root)->cutscenes->find(CutsceneType::CINEMATIC, 'rest-scene')->payload()['commands'][0]['commands'][1])->toBe($complete); + $asset->restoreEditState($state); + $asset->save(references: $references); + expect(sourceHashTree($root))->toBe($before); +})->with([ + 'no waypoints' => [['waypoints' => []]], + 'no authored axes' => [['waypoints' => [[]]]], + 'no retrace reference' => [['retrace' => '']], +]); + +it('refuses unsupported nested binding source changes before mutation while preserving independent authored expressions', function () { + $root = innDescriptorProject(); + $file = $root . '/assets/Events/rest.php'; + $source = " 'inn', 'confirmDialogue' => ['text' => 'Rest?'], 'cost' => 5, 'presentation' => ['treatment' => 'leader', 'leaders' => ['Kaelion' => strtolower('REST-A')]]]];\n"; + file_put_contents($file, $source); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $db = $workspace->getRecordDatabase('common_events'); + $index = array_search('rest', $db->getEntryLabels(), true); + $before = $db->getRecordByIndex($index)->toArray(); + $version = $db->getContentVersion(); + expect(fn() => new RecordAuthoring()->applyField($db, $index, [], 'command0InnLeader0Timeline', 'rest-b', 'Stage'))->toThrow(RecordRefusal::class) + ->and($db->getRecordByIndex($index)->toArray())->toBe($before) + ->and($db->getContentVersion())->toBe($version) + ->and(file_get_contents($file))->toBe($source); + new RecordAuthoring()->applyField($db, $index, [], 'command0Cost', '7', 'Cost'); + $db->save(); + expect(file_get_contents($file))->toContain('// binding comment', "strtolower('REST-A')") + ->and((require $file)[0]['cost'])->toBe(7); +}); + +it('validates Sleep cinematic and NPC bindings including actors and all timelines without party-dependent selection', function () { + $root = innDescriptorProject(); + innDescriptorCinematic($root, ", 'presentation' => ['treatment' => 'party', 'parties' => [['actors' => ['Kaelion', 'guest-b'], 'timeline' => 'rest-b']]]"); + $file = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $file; + $data['events']['S']['data']['presentation'] = ['treatment' => 'leader', 'leaders' => ['Kaelion' => 'rest-a']]; + $data['npcs'] = [['id' => 'keeper', 'name' => 'Keeper', 'x' => 1, 'y' => 1, 'sprite' => 'K', + 'script' => [['type' => 'if', 'then' => [['type' => 'inn', 'presentation' => ['treatment' => 'leader', 'leaders' => ['unknown-actor' => 'missing-rest']]]]]]]]; + file_put_contents($file, 'toBe(['map test-map event S']) + ->and($uses['rest-b']['stage'])->toBe(['cinematic rest-scene']) + ->and($uses['missing-rest']['stage'])->toBe(['map test-map NPC keeper']) + ->and(array_filter($issues, static fn($issue): bool => str_contains($issue->message, 'unknown actor identity unknown-actor')))->toHaveCount(1); +}); + +it('authors inline map command bindings through the same controls while Terminal preserves them read-only', function (string $treatment) { + $root = innDescriptorProject(); + $file = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $file; + $data['events']['I'] = ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptedEventTrigger', 'data' => [ + 'commands' => [['type' => 'if', 'then' => [['type' => 'inn', 'confirmDialogue' => ['text' => 'Rest?'], 'cost' => 5]]]], + ]]; + file_put_contents($file, " $inspector->buildEventDataFields('I', $map->getEventDefinition('I')); + $path = ['data', 'commands', '0', 'then', '0', 'presentation']; + $mode = $inspector->apply($map, array_find($rows(), static fn(array $row): bool => ($row['path'] ?? []) === [...$path, 'treatment']), $treatment); + $heading = array_find($rows(), static fn(array $row): bool => ($row['innPresentationList']['index'] ?? null) === -1); + $added = $inspector->addListEntry($map, $heading); + expect(fn() => $map->save())->toThrow(InvalidArgumentException::class)->and(file_get_contents($file))->toBe($before); + $actors = array_find($rows(), static fn(array $row): bool => ($row['reference'] ?? null) === 'actor_ids'); + $inspector->apply($map, $actors, $treatment === 'leader' ? 'Kaelion' : 'Kaelion, guest-b'); + $stageRow = array_find($rows(), static fn(array $row): bool => isset($row['innEntryField']) && ($row['reference'] ?? null) === 'stage_timelines'); + $stage = $inspector->apply($map, $stageRow, 'rest-a'); + $snapshot = $map->getEventDefinition('I'); + expect(fn() => $inspector->apply($map, $stageRow, 'missing-stage'))->toThrow(InspectorRefusal::class) + ->and($map->getEventDefinition('I'))->toBe($snapshot); + $stage->undo(); + $stage->execute(); + $map->save(); + $reopened = ProjectWorkspace::fromProject($root); + $tui = new MapInspector(new ReferenceCatalog($reopened)); + $reopenedMap = innDescriptorMap($reopened); + $terminalRows = $tui->buildEventDataFields('I', $reopenedMap->getEventDefinition('I')); + $descriptorRow = array_find($terminalRows, static fn(array $row): bool => ($row['path'] ?? []) === $path); + expect($descriptorRow['editable'])->toBeFalse() + ->and(array_any($terminalRows, static fn(array $row): bool => ($row['reference'] ?? '') === 'actor_ids'))->toBeFalse() + ->and($reopenedMap->getEventDefinition('I'))->toBe($snapshot) + ->and(file_get_contents($file))->toContain('// preserve inline event'); +})->with(['leader', 'party']); diff --git a/tests/Unit/LayeredMapTest.php b/tests/Unit/LayeredMapTest.php index 669cfbc..9d11d90 100644 --- a/tests/Unit/LayeredMapTest.php +++ b/tests/Unit/LayeredMapTest.php @@ -150,11 +150,15 @@ it('rejects externally changed or newly added layers without overwriting author work', function (bool $added) { $map = loadLayeredMap(layeredMapProject()); + $map->setLayerCell('map:1', 0, 0, 'X'); + $pending = $map->captureLayerSnapshot(); $path = $map->directory . '/layers/' . ($added ? '09.other.map.php' : '04.buildings.map.php'); file_put_contents($path, MapGridSource::buildSource("new \n ", 'OTHER')); - $map->setLayerCell('map:1', 0, 0, 'X'); $before = sourceHashTree($map->directory); - expect(fn() => $map->save())->toThrow(MapSourceRefusal::class) + expect(fn() => $map->setLayerCell('map:1', 1, 0, 'Y'))->toThrow(MapSourceRefusal::class) + ->and(fn() => $map->save())->toThrow(MapSourceRefusal::class) + ->and($map->captureLayerSnapshot())->toBe($pending) + ->and($map->isDirty())->toBeTrue() ->and(sourceHashTree($map->directory))->toBe($before); })->with([false, true]); diff --git a/tests/Unit/LayeredPreviewTest.php b/tests/Unit/LayeredPreviewTest.php index 6089699..9c43bdf 100644 --- a/tests/Unit/LayeredPreviewTest.php +++ b/tests/Unit/LayeredPreviewTest.php @@ -69,7 +69,7 @@ expect($glow())->not->toBe($before); // The graphical view's field has the same effect, drawn its own way. $preview->exchangeScene([json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => ['sprite_source_rect', - 'graphical_canvas', 'canvas_overlay', 'frame_viewport', 'field_motion']])]); + 'graphical_canvas', 'canvas_overlay', 'frame_viewport', 'field_motion']])], $preview->getSceneSessionId()); expect($scene->fieldEffects->count)->toBe(1); $preview->detachScene(); expect($scene->fieldEffects->count)->toBe(1); diff --git a/tests/Unit/MapGraphicsTest.php b/tests/Unit/MapGraphicsTest.php index c049b4a..5950c93 100644 --- a/tests/Unit/MapGraphicsTest.php +++ b/tests/Unit/MapGraphicsTest.php @@ -154,11 +154,15 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array it('refuses to save over tile layers changed or added after opening', function (bool $added) { $root = mapGraphicsProject(); $map = loadLayeredMap($root); - writeTileLayer($map->directory, $added ? '03.shadows.tiles.php' : '02.decor.tiles.php', "0 0 0 0\n0 0 0 0"); $map->resize(6, 3); + $pending = $map->captureLayerSnapshot(); + writeTileLayer($map->directory, $added ? '03.shadows.tiles.php' : '02.decor.tiles.php', "0 0 0 0\n0 0 0 0"); $before = sourceHashTree($map->directory); - expect(fn() => $map->save())->toThrow(MapSourceRefusal::class, 'after opening') + expect(fn() => $map->resize(7, 3))->toThrow(MapSourceRefusal::class, 'after opening') + ->and(fn() => $map->save())->toThrow(MapSourceRefusal::class, 'after opening') + ->and($map->captureLayerSnapshot())->toBe($pending) + ->and($map->isDirty())->toBeTrue() ->and(sourceHashTree($map->directory))->toBe($before); })->with(['changed' => false, 'added' => true]); diff --git a/tests/Unit/MapSourceHistoryTest.php b/tests/Unit/MapSourceHistoryTest.php new file mode 100644 index 0000000..3f5b032 --- /dev/null +++ b/tests/Unit/MapSourceHistoryTest.php @@ -0,0 +1,93 @@ + 'Source history fixture', + 'description' => strtoupper('kept'), + 'note' => $note, + 'region' => 'original', + 'events' => [ + 'E' => [ + 'class' => 'Ichiloto\Engine\Events\Triggers\ChestEventTrigger', + 'data' => ['lootType' => 'gold', 'loot' => 2], + 'cue' => [ + // Preserve the independently authored condition and expression. + 'symbol' => '!', 'color' => 'bright-blue', + 'kind' => strtolower('STORY'), + ], + ], + ], + ]; + PHP; + file_put_contents($file, $source); + $originalData = (static fn(string $path): array => require $path)($file); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + $old = $map->getMapDataField($path); + $map->setMapDataField($path, null); + $map->save(); + expect($map->isDirty())->toBeFalse() + ->and($map->hasMapDataField($path))->toBeFalse(); + $removed = file_get_contents($file); + + if ($layerHistory) { + $before = $map->captureLayerSnapshot(); + $map->setMapField('region', 'temporary layer edit'); + $after = $map->captureLayerSnapshot(); + $map->restoreLayerSnapshot($before); + $map->restoreLayerSnapshot($after); + $map->restoreLayerSnapshot($before); + $map->save(); + expect(file_get_contents($file))->toBe($removed); + } + + $map->setMapDataField($path, $old); + expect($map->getMapDataField([]))->toBe($originalData); + $map->save(); + expect(file_get_contents($file))->toBe($source) + ->and($map->isDirty())->toBeFalse() + ->and(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getMapDataField($path))->toBe($old); + $map->setMapDataField($path, null); + $map->save(); + expect(file_get_contents($file))->toBe($removed); + $map->setMapDataField($path, $old); + $map->setMapField('region', 'later unrelated edit'); + $map->save(); + expect(file_get_contents($file))->toBe(str_replace("'region' => 'original'", "'region' => 'later unrelated edit'", $source)); +})->with([ + 'computed map field' => [['description']], + 'variable map field' => [['note']], + 'computed event field' => [['events', 'E', 'cue', 'kind']], + 'parent containing expression' => [['events', 'E', 'cue']], +])->with([false, true]); + +it('keeps the saved validation checkpoint when replaying layer history with incomplete command drafts', function (array $command) { + $root = makeTemporaryProject('editor-source-checkpoint-'); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + $saved = $map->captureLayerSnapshot(); + $hashes = sourceHashTree($root); + $map->setMapDataField(['events', 'D'], [ + 'class' => \Ichiloto\Engine\Events\Triggers\ScriptEventTrigger::class, + 'data' => ['script' => [$command]], + ]); + $draft = $map->captureLayerSnapshot(); + $map->restoreLayerSnapshot($saved); + $map->restoreLayerSnapshot($draft); + expect(fn() => $map->save())->toThrow(InvalidArgumentException::class) + ->and(sourceHashTree($root))->toBe($hashes) + ->and($map->isDirty())->toBeTrue(); + $map->restoreLayerSnapshot($saved); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes)->and($map->isDirty())->toBeFalse(); +})->with([ + 'route draft' => [['type' => 'move_route', 'subject' => 'player', 'waypoints' => []]], + 'inn draft' => [['type' => 'inn', 'presentation' => ['treatment' => 'leader', 'leaders' => ['' => '']]]], +]); diff --git a/tests/Unit/MovementRouteAuthoringTest.php b/tests/Unit/MovementRouteAuthoringTest.php new file mode 100644 index 0000000..d2a9a6f --- /dev/null +++ b/tests/Unit/MovementRouteAuthoringTest.php @@ -0,0 +1,821 @@ + ($row['entry'] ?? []) === [$command] + && ($row['name'] ?? '') === $name) + ?? throw new RuntimeException('Missing route field ' . $name); +} + +function inlineRouteRow(EditorSession $session, array $path): array +{ + return array_find($session->readInspector('test-map', 'S')['rows'], static fn(array $row): bool => ($row['key']['path'] ?? []) === $path) + ?? throw new RuntimeException('Missing inline route field ' . implode('.', $path)); +} + +function makeRouteOwnerProject(): array +{ + $root = cutsceneProject(); + $remember = static fn(string $id): array => ['type' => 'move_route', 'remember' => $id, 'steps' => [['direction' => 'right']]]; + $commands = [ + [...$remember('root-recorded'), 'annotation' => $remember('not-a-command')], + ['type' => 'common_event', 'id' => 'route-shared'], + ['type' => 'sequence', 'commands' => [['type' => 'move_route', 'retrace' => 'root-recorded']]], + ['type' => 'wait', 'seconds' => 1], + ]; + foreach ([ + 'route-owner' => $commands, + 'route-shared' => [$remember('common-recorded'), ['type' => 'common_event', 'id' => 'route-cycle']], + 'route-cycle' => [$remember('cycle-recorded'), ['type' => 'common_event', 'id' => 'route-shared']], + 'route-other' => [$remember('unrelated-common')], + ] as $id => $script) { + file_put_contents($root . '/assets/Events/' . $id . '.php', " [$remember('variant-a'), ['type' => 'move_route', 'retrace' => 'variant-a'], $remember('variant-a-second')]], + ['conditions' => ['flag=on'], 'script' => [$remember('variant-b'), ['type' => 'move_route', 'retrace' => 'variant-b']]], + ]; + $data['npcs'][] = ['id' => 'other', 'name' => 'Other', 'sprite' => 'O', 'x' => 6, 'y' => 1, 'script' => [$remember('other-npc')]]; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\ScriptEventTrigger::class, 'data' => ['script' => $commands]]; + $data['events']['T'] = ['class' => \Ichiloto\Engine\Events\Triggers\ScriptEventTrigger::class, 'data' => ['script' => [$remember('other-event')]]]; + $source = " 1,", "'seconds' => abs(1),", $source); + file_put_contents($mapPath, $source); + return [$root, $mapPath]; +} + +it('transports exact route row owners through the real GUI host boundary without changing source', function () { + [$root] = makeRouteOwnerProject(); + $before = sourceHashTree($root); + $input = fopen('php://memory', 'r'); + $output = fopen('php://memory', 'w+'); + $diagnostics = fopen('php://memory', 'w+'); + $host = new \Ichiloto\Editor\Session\SessionHost($input, $output, $diagnostics); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + $list = static fn(?string $map, array $owner): array => + $request('references.list', ['map' => $map, 'category' => 'cinematic_movement_routes', 'record' => $owner]); + try { + expect($request('hello', ['protocol' => \Ichiloto\Editor\Session\SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result'); + foreach ([['script', 2, 'commands'], [0, 'script'], [1, 'script']] as $frame) { + $view = $request('npc.read', ['map' => 'harbour', 'index' => 0, 'frame' => $frame])['result']; + $command = $frame[0] === 'script' ? 0 : 1; + $row = routeAuthoringRow($view, 'Recorded Route', $command); + $reply = $list($view['map'], ['kind' => 'npc', 'index' => 0, 'frame' => $row['key']['frame']]); + $expected = match ($frame[0]) { + 'script' => ['root-recorded', 'common-recorded', 'cycle-recorded'], + 0 => ['variant-a', 'variant-a-second'], + 1 => ['variant-b'], + }; + expect($reply)->toHaveKey('result')->and(array_column($reply['result'], 'value'))->toBe($expected); + } + $event = $request('inspector.read', ['map' => 'harbour', 'event' => 'S'])['result']; + $row = array_find($event['rows'], static fn(array $row): bool => ($row['reference'] ?? '') === 'cinematic_movement_routes'); + expect($row)->not->toBeNull(); + $reply = $list($event['map'], ['kind' => 'event', 'marker' => $row['key']['marker'], 'path' => $row['key']['path']]); + expect($reply)->toHaveKey('result') + ->and(array_column($reply['result'], 'value'))->toBe(['root-recorded', 'common-recorded', 'cycle-recorded']); + + $index = array_search('route-owner', $request('database.records', ['category' => 'common_events'])['result']['records'], true); + expect($index)->toBeInt(); + $view = $request('database.record', ['category' => 'common_events', 'index' => $index, 'frame' => [2, 'commands']])['result']; + $row = routeAuthoringRow($view, 'Recorded Route'); + $reply = $list(null, ['category' => 'common_events', 'index' => $index, 'frame' => $row['key']['frame']]); + expect($reply)->toHaveKey('result') + ->and(array_column($reply['result'], 'value'))->toBe(['root-recorded', 'common-recorded', 'cycle-recorded']) + ->and($request('references.list', ['map' => 'harbour', 'category' => 'cinematic_movement_routes'])['result'])->toBe([]) + ->and($list(null, ['kind' => 'npc', 'index' => 0, 'frame' => ['script']]))->toHaveKey('error') + ->and(sourceHashTree($root))->toBe($before); + rewind($diagnostics); + expect(stream_get_contents($diagnostics))->toBe(''); + } finally { + $host->run(); + fclose($input); + fclose($output); + fclose($diagnostics); + } +}); + +it('lists only declarations reachable from the explicit live command owner including cyclic common-event references', function () { + [$root] = makeRouteOwnerProject(); + $session = EditorSession::open($root); + foreach ([['kind' => 'npc', 'index' => 0, 'frame' => ['script', 2, 'commands']], + ['kind' => 'event', 'marker' => 'S', 'path' => ['data', 'script', '2', 'commands', '0', 'retrace']]] as $owner) { + expect(array_column($session->listReferences('harbour', 'cinematic_movement_routes', $owner), 'value')) + ->toBe(['root-recorded', 'common-recorded', 'cycle-recorded']); + } + foreach ([0 => 'variant-a', 1 => 'variant-b'] as $index => $expected) { + expect(array_column($session->listReferences('harbour', 'cinematic_movement_routes', + ['kind' => 'npc', 'index' => 0, 'frame' => [$index, 'script']]), 'value'))->toBe($index === 0 ? [$expected, 'variant-a-second'] : [$expected]); + } + expect($session->listReferences('harbour', 'cinematic_movement_routes'))->toBe([]) + ->and(fn() => $session->listReferences(null, 'cinematic_movement_routes', ['kind' => 'npc', 'index' => 0, 'frame' => ['script']])) + ->toThrow(SessionRefusal::class, 'requires its map') + ->and(fn() => $session->listReferences('harbour', 'cinematic_movement_routes', ['kind' => 'event', 'marker' => 'missing', 'path' => ['data', 'script']])) + ->toThrow(SessionRefusal::class, 'no longer present') + ->and(fn() => $session->listReferences('harbour', 'cinematic_movement_routes', ['kind' => 'npc', 'index' => 0, 'frame' => [99, 'script']])) + ->toThrow(SessionRefusal::class, 'no longer present'); +}); + +it('writes and refuses NPC route references against the same invocation while preserving undo expressions and variants', function () { + [$root, $mapPath] = makeRouteOwnerProject(); + $session = EditorSession::open($root); + $original = file_get_contents($mapPath); + $row = routeAuthoringRow($session->readNpc('harbour', 0, ['script', 2, 'commands']), 'Recorded Route'); + $revision = $session->readNpc('harbour', 0, ['script', 2, 'commands'])['revision']; + foreach (['variant-a', 'other-npc', 'unrelated-common', 'not-a-command'] as $bad) { + expect(fn() => $session->applyNpc('harbour', $revision, 0, $row['key'], $bad))->toThrow(SessionRefusal::class, 'Unknown cinematic_movement_routes'); + } + expect($session->undo()['label'])->toBeNull()->and(file_get_contents($mapPath))->toBe($original); + $session->applyNpc('harbour', $revision, 0, $row['key'], 'common-recorded'); + expect(routeAuthoringRow($session->readNpc('harbour', 0, ['script', 2, 'commands']), 'Recorded Route')['value'])->toBe('common-recorded'); + $session->undo(); + expect(routeAuthoringRow($session->readNpc('harbour', 0, ['script', 2, 'commands']), 'Recorded Route')['value'])->toBe('root-recorded'); + $session->saveMap('harbour'); + expect(file_get_contents($mapPath))->toBe($original); + $session->redo(); + $session->saveMap('harbour'); + expect(file_get_contents($mapPath))->toContain('// Preserve map source.', "'seconds' => abs(1)"); + $reopened = EditorSession::open($root); + expect(routeAuthoringRow($reopened->readNpc('harbour', 0, ['script', 2, 'commands']), 'Recorded Route')['value'])->toBe('common-recorded'); + $variant = $reopened->readNpc('harbour', 0, [0, 'script']); + $row = routeAuthoringRow($variant, 'Recorded Route', 1); + expect(fn() => $reopened->applyNpc('harbour', $variant['revision'], 0, $row['key'], 'variant-b'))->toThrow(SessionRefusal::class, 'Unknown cinematic_movement_routes'); + expect(fn() => $reopened->applyNpc('harbour', $variant['revision'], 0, $row['key'], 'root-recorded'))->toThrow(SessionRefusal::class, 'Unknown cinematic_movement_routes'); + $reopened->applyNpc('harbour', $variant['revision'], 0, $row['key'], 'variant-a-second'); + $reopened->saveMap('harbour'); + expect((require $mapPath)['npcs'][0]['dialogue'][0]['script'][1]['retrace'])->toBe('variant-a-second') + ->and((require $mapPath)['npcs'][0]['dialogue'][1]['script'][1]['retrace'])->toBe('variant-b'); + $reopened->undo(); + $reopened->saveMap('harbour'); + expect((require $mapPath)['npcs'][0]['dialogue'][0]['script'][1]['retrace'])->toBe('variant-a'); +}); + +it('writes inline route references from the actual event script and refuses siblings without changing source or history', function () { + [$root, $mapPath] = makeRouteOwnerProject(); + $session = EditorSession::open($root); + $view = $session->readInspector('harbour', 'S'); + $path = ['data', 'script', '2', 'commands', '0', 'retrace']; + $row = array_find($view['rows'], static fn(array $row): bool => ($row['key']['path'] ?? []) === $path); + expect($row)->not->toBeNull(); + $original = file_get_contents($mapPath); + expect(fn() => $session->applyInspector('harbour', $view['revision'], $row['key'], 'other-event')) + ->toThrow(SessionRefusal::class, 'Unknown cinematic_movement_routes'); + expect($session->undo()['label'])->toBeNull()->and(file_get_contents($mapPath))->toBe($original); + $session->applyInspector('harbour', $view['revision'], $row['key'], 'common-recorded'); + $session->saveMap('harbour'); + expect((require $mapPath)['events']['S']['data']['script'][2]['commands'][0]['retrace'])->toBe('common-recorded') + ->and(file_get_contents($mapPath))->toContain('// Preserve map source.', "'seconds' => abs(1)"); + $session->undo(); + $session->saveMap('harbour'); + expect(file_get_contents($mapPath))->toBe($original); + $session->redo(); + $session->saveMap('harbour'); + expect(EditorSession::open($root)->listReferences('harbour', 'cinematic_movement_routes', ['kind' => 'event', 'marker' => 'S', 'path' => $path])) + ->toHaveCount(3); +}); + +it('uses a standalone common-event record for list and apply rather than unrelated cinematic or event records', function () { + [$root] = makeRouteOwnerProject(); + $session = EditorSession::open($root); + $index = array_search('route-owner', $session->listDatabaseRecords('common_events')['records'], true); + expect($index)->toBeInt(); + $owner = ['category' => 'common_events', 'index' => $index, 'frame' => [2, 'commands']]; + expect(array_column($session->listReferences(null, 'cinematic_movement_routes', $owner), 'value')) + ->toBe(['root-recorded', 'common-recorded', 'cycle-recorded']); + $row = routeAuthoringRow($session->readDatabaseRecord('common_events', $index, [2, 'commands']), 'Recorded Route'); + $path = $root . '/assets/Events/route-owner.php'; + $original = file_get_contents($path); + expect(fn() => $session->applyDatabaseRecord('common_events', $index, $row['key'], 'unrelated-common')) + ->toThrow(SessionRefusal::class, 'Unknown cinematic_movement_routes'); + $session->applyDatabaseRecord('common_events', $index, $row['key'], 'common-recorded'); + $session->saveDatabase('common_events'); + expect((require $path)[2]['commands'][0]['retrace'])->toBe('common-recorded')->and(file_get_contents($path))->toContain('// Preserve common source.'); + $session->undo(); + $session->saveDatabase('common_events'); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $session->saveDatabase('common_events'); + $reopened = EditorSession::open($root); + expect(routeAuthoringRow($reopened->readDatabaseRecord('common_events', $index, [2, 'commands']), 'Recorded Route')['value'])->toBe('common-recorded'); +}); + +it('shares all three runtime route shapes between event and cinematic schemas without projecting one into another', function () { + foreach ([RecordSchemaCatalog::eventCommandList('script'), CutsceneSchemas::cinematicCommandList('commands')] as $schema) { + foreach (['steps' => [['direction' => 'right', 'count' => 1]], 'waypoints' => [['x' => 0], ['y' => 2]], 'retrace' => 'outbound'] as $mode => $value) { + $route = ['type' => 'move_route', 'subject' => 'player', $mode => $value, 'secondsPerStep' => 0.2, 'wait' => true]; + $fields = $schema->fieldsFor($route); + $modeField = array_find($fields, static fn($f): bool => $f->key === MovementRouteFields::MODE_FIELD); + expect($modeField->displayDefault)->toBe($mode) + ->and($schema->removeConflictingFields($route, 'secondsPerStep'))->toBe($route) + ->and($schema->nestedListFor($route)?->key)->toBe($mode === 'retrace' ? null : $mode); + if ($mode === 'retrace') { + expect(array_find($fields, static fn($f): bool => $f->key === 'retrace')->reference)->toBe('cinematic_movement_routes'); + } + } + } + $route = ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'guide', 'remember' => 'outbound', 'speed' => 2.0, + 'steps' => [['direction' => 'left', 'faceOnly' => true, 'seconds' => 0.4]], 'annotation' => 'keep']; + expect(MovementRouteFields::prepareEdit([...$route, MovementRouteFields::MODE_FIELD => 'steps'], MovementRouteFields::MODE_FIELD))->toBe($route); + $waypoints = MovementRouteFields::prepareEdit([...$route, MovementRouteFields::MODE_FIELD => 'waypoints'], MovementRouteFields::MODE_FIELD); + expect($waypoints)->toBe([...array_diff_key($route, ['steps' => true]), 'waypoints' => []]); + $retrace = MovementRouteFields::prepareEdit([...$waypoints, MovementRouteFields::MODE_FIELD => 'retrace'], MovementRouteFields::MODE_FIELD); + expect($retrace)->toBe([...array_diff_key($waypoints, ['waypoints' => true, 'remember' => true]), 'retrace' => '']) + ->and(fn() => MovementRouteFields::prepareEdit([...$route, MovementRouteFields::MODE_FIELD => 'bogus'], MovementRouteFields::MODE_FIELD))->toThrow(InvalidArgumentException::class) + ->and(fn() => MovementRouteFields::prepareEdit([...$route, 'remember' => '../unsafe'], 'remember'))->toThrow(InvalidArgumentException::class) + ->and(fn() => MovementRouteFields::prepareEdit([...$waypoints, 'subject' => 'staged_actor'], 'subject'))->toThrow(InvalidArgumentException::class); +}); + +it('keeps sparse waypoint axes and zero through source preserving edits undo save and reopen', function () { + $root = makeTemporaryProject('ichiloto-route-authoring-'); + $path = $root . '/assets/Events/waypoint-route.php'; + file_put_contents($path, <<<'PHP' + 'move_route', 'subject' => 'player', 'wait' => true, + 'secondsPerStep' => 0.25, 'remember' => 'outbound', 'annotation' => 'keep', + 'waypoints' => [['x' => 2], ['y' => 1], ['x' => 4, 'y' => 2]], +]]; +PHP); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $index = array_search('waypoint-route', $session->listDatabaseRecords('common_events')['records'], true); + $view = $session->readDatabaseRecord('common_events', $index); + $placement = array_find($view['rows'], static fn(array $row): bool => ($row['mapPlacement']['kind'] ?? '') === 'waypoints'); + expect($placement['mapPlacement']['points'][0]['point'])->toBe([2, null]) + ->and($placement['mapPlacement']['points'][1]['point'])->toBe([2, 1]); + $context = ['kind' => 'database', 'category' => 'common_events', 'index' => $index]; + $revision = $session->readMap('test-map')['revision']; + $session->applyMapPlacement($context, $placement['key'], $placement['mapPlacement'], 'test-map', 0, 0, 3, [1, 2], $revision); + $changed = array_find($session->readDatabaseRecord('common_events', $index)['rows'], static fn(array $row): bool => isset($row['mapPlacement'])); + expect($changed['mapPlacement']['points'][0]['point'])->toBe([0, null]); + $session->undo(); + $session->saveDatabase('common_events'); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $session->saveDatabase('common_events'); + expect((require $path)[0])->toMatchArray(['secondsPerStep' => 0.25, 'remember' => 'outbound', 'annotation' => 'keep', + 'waypoints' => [['x' => 0], ['y' => 1], ['x' => 4, 'y' => 2]]]) + ->and(file_get_contents($path))->toContain('// Authored route, not a generated path.'); + $reopened = EditorSession::open($root); + $view = $reopened->readDatabaseRecord('common_events', $index); + $y = array_find($view['rows'], static fn(array $row): bool => ($row['entry'] ?? []) === [0, 2] && ($row['name'] ?? '') === 'Y'); + expect($y)->not->toBeNull(); + $reopened->applyDatabaseRecord('common_events', $index, $y['key'], ''); + $reopened->saveDatabase('common_events'); + expect((require $path)[0]['waypoints'][2])->toBe(['x' => 4]); +}); + +it('refuses expression owned waypoint placement without flattening source or creating history', function () { + $root = makeTemporaryProject('ichiloto-route-expression-'); + $path = $root . '/assets/Events/waypoint-expression.php'; + file_put_contents($path, <<<'PHP' + 'move_route', 'subject' => 'player', 'waypoints' => [['x' => abs($destination)]]]]; +PHP); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $index = array_search('waypoint-expression', $session->listDatabaseRecords('common_events')['records'], true); + $row = array_find($session->readDatabaseRecord('common_events', $index)['rows'], static fn(array $r): bool => isset($r['mapPlacement'])); + expect($row['mapPlacement']['issue'])->toContain('literal source fields') + ->and(fn() => $session->applyMapPlacement(['kind' => 'database', 'category' => 'common_events', 'index' => $index], + $row['key'], $row['mapPlacement'], 'test-map', 0, 3, 1, null, $session->readMap('test-map')['revision']))->toThrow(SessionRefusal::class) + ->and($session->undo()['label'])->toBeNull(); + $session->saveDatabase('common_events'); + expect(file_get_contents($path))->toBe($original); +}); + +it('selects live cinematic remembered route identities at nested command depths and round trips retrace', function () { + $root = cutsceneProject(); + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, <<<'PHP' + 'sequence', 'commands' => [ + ['type' => 'move_route', 'subject' => 'player', 'remember' => 'outbound', 'waypoints' => [['x' => 2]], 'secondsPerStep' => 0.3], + ['type' => 'move_route', 'subject' => 'player', 'retrace' => 'outbound', 'speed' => 2.0], +]]]; +PHP); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $record = ['category' => 'cutscenes/cinematic', 'index' => 0]; + expect(array_column($session->listReferences(null, 'cinematic_movement_routes', $record), 'value'))->toBe(['outbound']) + ->and($session->listReferences(null, 'cinematic_movement_routes'))->toBe([]); + $frame = ['commands', 0, 'commands']; + $view = $session->readDatabaseRecord('cutscenes/cinematic', 0, $frame); + $remember = routeAuthoringRow($view, 'Remember As'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $remember['key'], 'return-path'); + expect(array_column($session->listReferences(null, 'cinematic_movement_routes', $record), 'value'))->toBe(['return-path']); + $view = $session->readDatabaseRecord('cutscenes/cinematic', 0, $frame); + $retrace = routeAuthoringRow($view, 'Recorded Route', 1); + expect($retrace['reference'])->toBe('cinematic_movement_routes'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $retrace['key'], 'return-path'); + $session->undo(); + $session->undo(); + $session->saveDatabase('cutscenes/cinematic'); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $session->redo(); + $session->saveDatabase('cutscenes/cinematic'); + $saved = require $path; + expect($saved[0]['commands'][0])->toMatchArray(['remember' => 'return-path', 'waypoints' => [['x' => 2]], 'secondsPerStep' => 0.3]) + ->and($saved[0]['commands'][1])->toBe(['type' => 'move_route', 'subject' => 'player', 'retrace' => 'return-path', 'speed' => 2.0]) + ->and(array_column(EditorSession::open($root)->listReferences(null, 'cinematic_movement_routes', $record), 'value'))->toBe(['return-path']); +}); + +it('authors a new waypoint mode and child axes without saving the derived selector or coordinate defaults', function () { + $root = makeTemporaryProject('ichiloto-route-mode-'); + $path = $root . '/assets/Events/mode-route.php'; + file_put_contents($path, <<<'PHP' + 'move_route', 'subject' => 'player', 'remember' => 'outbound', + 'wait' => true, 'speed' => 3.0, 'annotation' => 'keep', + 'steps' => [['direction' => 'left', 'faceOnly' => true, 'seconds' => 0.4]], +]]; +PHP); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $index = array_search('mode-route', $session->listDatabaseRecords('common_events')['records'], true); + $mode = routeAuthoringRow($session->readDatabaseRecord('common_events', $index), 'Route Mode'); + $session->applyDatabaseRecord('common_events', $index, $mode['key'], 'waypoints'); + expect(fn() => $session->saveDatabase('common_events'))->toThrow(InvalidArgumentException::class, 'non-empty list') + ->and(file_get_contents($path))->toBe($original); + $mode = routeAuthoringRow($session->readDatabaseRecord('common_events', $index), 'Route Mode'); + $session->addDatabaseItem('common_events', $index, $mode['key'], child: true); + expect(fn() => $session->saveDatabase('common_events'))->toThrow(InvalidArgumentException::class, 'x and/or y') + ->and(file_get_contents($path))->toBe($original); + $view = $session->readDatabaseRecord('common_events', $index); + $x = array_find($view['rows'], static fn(array $row): bool => ($row['entry'] ?? []) === [0, 0] && ($row['name'] ?? '') === 'X'); + expect($x['value'])->toBe('(Keep current X)'); + $session->applyDatabaseRecord('common_events', $index, $x['key'], '0'); + $session->saveDatabase('common_events'); + expect((require $path)[0])->toBe(['type' => 'move_route', 'subject' => 'player', 'remember' => 'outbound', + 'wait' => true, 'speed' => 3.0, 'annotation' => 'keep', 'waypoints' => [['x' => 0]]]) + ->and(file_get_contents($path))->toContain('// Preserve the command envelope.') + ->and(file_get_contents($path))->not->toContain('_routeMode'); + $session->undo(); + $session->undo(); + $session->undo(); + $session->saveDatabase('common_events'); + expect(require $path)->toBe([['type' => 'move_route', 'subject' => 'player', 'remember' => 'outbound', + 'wait' => true, 'speed' => 3.0, 'annotation' => 'keep', + 'steps' => [['direction' => 'left', 'faceOnly' => true, 'seconds' => 0.4]]]]) + ->and(file_get_contents($path))->toContain('// Preserve the command envelope.'); +}); + +it('places sparse inline map-event waypoints through existing grouped path edits', function () { + $root = makeTemporaryProject('ichiloto-event-waypoints-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, 'data' => ['script' => [ + ['type' => 'move_route', 'subject' => 'player', 'secondsPerStep' => 0.4, 'wait' => true, + 'waypoints' => [['x' => 2], ['y' => 1]]], + ]]]; + file_put_contents($path, "readInspector('test-map', 'S'); + $row = array_find($read['rows'], static fn(array $r): bool => ($r['mapPlacement']['kind'] ?? '') === 'waypoints'); + expect($row['mapPlacement']['points'][0]['point'])->toBe([2, null]); + $session->applyMapPlacement(['kind' => 'event', 'map' => 'test-map', 'revision' => $read['revision']], + $row['key'], $row['mapPlacement'], 'test-map', 0, 0, 2, null, $read['revision']); + expect($session->undo()['label'])->toBe('Place on map'); + $session->saveMap('test-map'); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $session->saveMap('test-map'); + expect((require $path)['events']['S']['data']['script'][0])->toBe(['type' => 'move_route', 'subject' => 'player', + 'secondsPerStep' => 0.4, 'wait' => true, 'waypoints' => [['x' => 0], ['y' => 1]]]) + ->and(file_get_contents($path))->toContain('// Preserve inline event source.'); + $reopened = EditorSession::open($root)->readInspector('test-map', 'S'); + expect(array_find($reopened['rows'], static fn(array $r): bool => isset($r['mapPlacement']))['mapPlacement']['points'][0]['point'])->toBe([0, null]); +}); + +it('refuses clearing the last waypoint axis or negative coordinates without a partial edit', function () { + $root = makeTemporaryProject('ichiloto-route-invalid-'); + $path = $root . '/assets/Events/waypoint-invalid.php'; + file_put_contents($path, " 'move_route', 'subject' => 'player', 'waypoints' => [['y' => 1]]]];\n"); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $index = array_search('waypoint-invalid', $session->listDatabaseRecords('common_events')['records'], true); + $row = array_find($session->readDatabaseRecord('common_events', $index)['rows'], static fn(array $r): bool => ($r['entry'] ?? []) === [0, 0] && ($r['name'] ?? '') === 'Y'); + expect(fn() => $session->applyDatabaseRecord('common_events', $index, $row['key'], ''))->toThrow(SessionRefusal::class, 'x and/or y') + ->and(fn() => $session->applyDatabaseRecord('common_events', $index, $row['key'], '-1'))->toThrow(SessionRefusal::class, 'non-negative') + ->and($session->undo()['label'])->toBeNull(); + $session->saveDatabase('common_events'); + expect(file_get_contents($path))->toBe($original); +}); + +it('offers remembered identities in every reachable cinematic arm and referenced common event without following cycles forever', function () { + $root = cutsceneProject(); + file_put_contents($root . '/assets/Events/outbound.php', " 'move_route', 'subject' => 'player', 'remember' => 'common-route', 'steps' => [['direction' => 'right']]], + ['type' => 'common_event', 'id' => 'outbound'], + ];\n"); + file_put_contents($root . '/assets/Events/unrelated.php', " 'move_route', 'subject' => 'player', 'remember' => 'unreachable-route', 'steps' => [['direction' => 'left']]]];\n"); + $route = static fn(string $id): array => ['type' => 'move_route', 'subject' => 'player', 'remember' => $id, 'waypoints' => [['x' => 1]]]; + $commands = [ + ['type' => 'branch', 'conditions' => [], 'then' => [$route('then-route')], 'else' => [$route('else-route')]], + ['type' => 'choice', 'prompt' => 'Choose', 'options' => [['text' => 'Continue', 'then' => [$route('choice-route')]]], 'cancel' => [$route('cancel-route')]], + ['type' => 'parallel', 'lanes' => [['id' => 'first', 'commands' => [$route('lane-route')]], [$route('legacy-lane-route')]]], + ['type' => 'common_event', 'id' => 'outbound'], + ]; + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, "listReferences(null, 'cinematic_movement_routes', ['category' => 'cutscenes/cinematic', 'index' => 0]), 'value'); + expect($values)->toBe(['then-route', 'else-route', 'choice-route', 'cancel-route', 'lane-route', 'legacy-lane-route', 'common-route']); +}); + +it('shares sparse inline route controls and validation with command records through undo save and reopen', function () { + $root = makeTemporaryProject('ichiloto-inline-route-fields-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $route = ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'guide', 'wait' => true, + 'speed' => 2.0, 'remember' => 'outbound', 'annotation' => 'keep', 'waypoints' => [['x' => 2]]]; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, + 'data' => ['script' => [['type' => 'sequence', 'commands' => [$route]]]]]; + file_put_contents($path, "toBe('2')->and($y['value'])->toContain('Keep current Y') + ->and($y['kind'])->toBe('integer'); + $read = $session->readInspector('test-map', 'S'); + expect(fn() => $session->applyInspector('test-map', $read['revision'], $x['key'], '-1'))->toThrow(SessionRefusal::class, 'non-negative') + ->and(fn() => $session->applyInspector('test-map', $read['revision'], $x['key'], ''))->toThrow(SessionRefusal::class, 'x and/or y') + ->and($session->undo()['label'])->toBeNull(); + $session->applyInspector('test-map', $read['revision'], $x['key'], '0'); + $session->undo(); + $session->saveMap('test-map'); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $session->saveMap('test-map'); + expect((require $path)['events']['S']['data']['script'][0]['commands'][0])->toBe([...$route, 'waypoints' => [['x' => 0]]]) + ->and(file_get_contents($path))->toContain('// Inline route ownership.'); + $reopened = EditorSession::open($root); + $mode = inlineRouteRow($reopened, [...$base, MovementRouteFields::MODE_FIELD]); + expect($mode['value'])->toBe('waypoints')->and($mode['options'])->toBe(['steps', 'waypoints', 'retrace']); + $read = $reopened->readInspector('test-map', 'S'); + $reopened->applyInspector('test-map', $read['revision'], $mode['key'], 'steps'); + $beforeDraft = file_get_contents($path); + expect(fn() => $reopened->saveMap('test-map'))->toThrow(InvalidArgumentException::class, 'at least one step') + ->and(file_get_contents($path))->toBe($beforeDraft) + ->and(inlineRouteRow($reopened, [...$base, MovementRouteFields::MODE_FIELD])['value'])->toBe('steps'); + $reopened->undo(); + expect(inlineRouteRow($reopened, [...$base, MovementRouteFields::MODE_FIELD])['value'])->toBe('waypoints'); + $reopened->redo(); + $steps = inlineRouteRow($reopened, [...$base, 'steps']); + $reopened->addInspectorListEntry('test-map', $reopened->readInspector('test-map', 'S')['revision'], $steps['key']); + $reopened->saveMap('test-map'); + expect((require $path)['events']['S']['data']['script'][0]['commands'][0])->toBe([...array_diff_key($route, ['waypoints' => true]), 'steps' => [RecordSchemaCatalog::routeStepList()->blank]]) + ->and(inlineRouteRow(EditorSession::open($root), [...$base, MovementRouteFields::MODE_FIELD])['value'])->toBe('steps') + ->and(file_get_contents($path))->not->toContain('_routeMode'); +}); + +it('refuses expression owned inline waypoint fields without source normalization or history', function () { + $root = makeTemporaryProject('ichiloto-inline-route-expression-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, + 'data' => ['script' => [['type' => 'move_route', 'waypoints' => [['x' => 2]]]]]]; + $source = " 2,", "'x' => abs(-2),", $source); + file_put_contents($path, $source); + $session = EditorSession::open($root); + $row = inlineRouteRow($session, ['data', 'script', '0', 'waypoints', '0', 'x']); + $read = $session->readInspector('test-map', 'S'); + expect(fn() => $session->applyInspector('test-map', $read['revision'], $row['key'], '3'))->toThrow(SessionRefusal::class) + ->and($session->undo()['label'])->toBeNull(); + $session->saveMap('test-map'); + expect(file_get_contents($path))->toBe($source); +}); + +it('authors inline route modes and nullable children without fabricating axes or clearing cardinal defaults', function () { + $root = makeTemporaryProject('ichiloto-inline-route-children-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $route = ['type' => 'move_route', 'subject' => 'player', 'wait' => true, 'secondsPerStep' => 0.25, + 'steps' => [['direction' => 'left', 'faceOnly' => true, 'seconds' => 0.5]]]; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, 'data' => ['script' => [$route]]]; + file_put_contents($path, "applyInspector('test-map', $session->readInspector('test-map', 'S')['revision'], $field['key'], $value); + }; + $apply(inlineRouteRow($session, [...$base, MovementRouteFields::MODE_FIELD]), 'waypoints'); + expect(fn() => $session->saveMap('test-map'))->toThrow(InvalidArgumentException::class, 'non-empty list') + ->and(file_get_contents($path))->toBe($original); + $heading = inlineRouteRow($session, [...$base, 'waypoints']); + $session->addInspectorListEntry('test-map', $session->readInspector('test-map', 'S')['revision'], $heading['key']); + expect(fn() => $session->saveMap('test-map'))->toThrow(InvalidArgumentException::class, 'x and/or y') + ->and(file_get_contents($path))->toBe($original); + $x = inlineRouteRow($session, [...$base, 'waypoints', '0', 'x']); + expect($x['value'])->toContain('Keep current X'); + $apply($x, '0'); + $session->addInspectorListEntry('test-map', $session->readInspector('test-map', 'S')['revision'], $x['key']); + $y = inlineRouteRow($session, [...$base, 'waypoints', '1', 'y']); + expect(inlineRouteRow($session, [...$base, 'waypoints', '1', 'x'])['value'])->toContain('Keep current X'); + $apply($y, '2'); + $session->saveMap('test-map'); + $expected = [...array_diff_key($route, ['steps' => true]), 'waypoints' => [['x' => 0], ['y' => 2]]]; + expect((require $path)['events']['S']['data']['script'][0])->toBe($expected) + ->and(file_get_contents($path))->not->toContain('_routeMode'); + $reopened = EditorSession::open($root); + $y = inlineRouteRow($reopened, [...$base, 'waypoints', '1', 'y']); + $reopened->removeInspectorListEntry('test-map', $reopened->readInspector('test-map', 'S')['revision'], $y['key']); + $reopened->undo(); + $reopened->saveMap('test-map'); + expect((require $path)['events']['S']['data']['script'][0])->toBe($expected); + for ($i = 0; $i < 5; ++$i) { $session->undo(); } + $session->saveMap('test-map'); + expect((require $path)['events']['S']['data']['script'][0])->toBe($route) + ->and(file_get_contents($path))->toContain('// Mode conversion is deliberate.'); + $steps = inlineRouteRow($session, [...$base, 'steps']); + $session->addInspectorListEntry('test-map', $session->readInspector('test-map', 'S')['revision'], $steps['key']); + $session->saveMap('test-map'); + expect((require $path)['events']['S']['data']['script'][0]['steps'][1])->toBe(RecordSchemaCatalog::routeStepList()->blank); +}); + +it('refuses cross-scene and unknown retrace references using the live owning record at every frame depth', function () { + $root = cutsceneProject(); + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + $route = ['type' => 'move_route', 'subject' => 'player', 'retrace' => 'outbound']; + $commands = [ + ['type' => 'move_route', 'subject' => 'player', 'remember' => 'outbound', 'steps' => [['direction' => 'right']]], + $route, + ['type' => 'sequence', 'commands' => [$route]], + ['type' => 'branch', 'conditions' => [], 'then' => [$route], 'else' => [$route]], + ['type' => 'choice', 'prompt' => 'Choose', 'options' => [['text' => 'Go', 'then' => [$route]]], 'cancel' => [$route]], + ['type' => 'parallel', 'lanes' => [['id' => 'route', 'commands' => [$route]]]], + ]; + file_put_contents($path, " 'other-scene', 'name' => 'Other', 'startMap' => 'harbour', 'skip' => ['policy' => 'forbidden']];\n"); + file_put_contents($other . '/other-scene.script.php', " 'move_route', 'subject' => 'player', 'remember' => 'other-route', 'waypoints' => [['y' => 1]]]];\n"); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $frames = [[['commands'], 1], [['commands', 2, 'commands'], 0], [['commands', 3, 'then'], 0], + [['commands', 3, 'else'], 0], [['commands', 4, 'options', 0, 'then'], 0], [['commands', 4, 'cancel'], 0], + [['commands', 5, 'lanes', 0, 'commands'], 0]]; + foreach ($frames as [$frame, $command]) { + $row = routeAuthoringRow($session->readDatabaseRecord('cutscenes/cinematic', 0, $frame), 'Recorded Route', $command); + foreach (['other-route', 'unknown-route'] as $bad) { + expect(fn() => $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row['key'], $bad))->toThrow(SessionRefusal::class, 'Unknown cinematic_movement_routes'); + } + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row['key'], 'outbound'); + } + expect($session->undo()['label'])->toBeNull() + ->and(array_column($session->listReferences(null, 'cinematic_movement_routes', ['category' => 'cutscenes/cinematic', 'index' => 0]), 'value'))->toBe(['outbound']); + $session->saveDatabase('cutscenes/cinematic'); + expect(file_get_contents($path))->toBe($original); +}); + +it('validates changed route paths through every authored arm while preserving reordered legacy paths', function () { + $invalid = ['type' => 'move_route', 'waypoints' => []]; + $old = ['type' => 'sequence', 'commands' => [$invalid]]; + MovementRouteFields::assertChangedCommandsValid($old, [...$old, 'annotation' => 'keep']); + MovementRouteFields::assertChangedCommandsValid($old, ['type' => 'sequence', 'commands' => [[...$invalid, 'secondsPerStep' => 0.2]]]); + MovementRouteFields::assertChangedCommandsValid($old, ['type' => 'sequence', 'commands' => [['type' => 'text', 'text' => 'New'], $invalid]]); + foreach (['then', 'else', 'commands', 'cancel', 'options', 'lanes', 'script', 'finalizer'] as $arm) { + expect(fn() => MovementRouteFields::assertChangedCommandsValid([], [$arm => [$invalid]]))->toThrow(InvalidArgumentException::class, 'non-empty list'); + } + expect(fn() => MovementRouteFields::assertChangedCommandsValid([], ['type' => 'move_route', 'retrace' => '']))->toThrow(InvalidArgumentException::class, 'safe stable id') + ->and(fn() => MovementRouteFields::assertChangedCommandsValid([], ['type' => 'move_route', 'steps' => [], 'waypoints' => [['x' => 0]]]))->toThrow(InvalidArgumentException::class, 'exactly one'); + expect(fn() => MovementRouteFields::assertChangedCommandsValid($old, ['type' => 'sequence', 'commands' => [$invalid, $invalid]]))->toThrow(InvalidArgumentException::class, 'non-empty list'); + foreach ([[], [[]], [['direction' => '']], [['direction' => 'diagonal']], [['direction' => 'left', 'count' => -1]], [['direction' => 'left', 'faceOnly' => 'true']]] as $steps) { + expect(fn() => MovementRouteFields::assertChangedCommandsValid([], ['type' => 'move_route', 'steps' => $steps]))->toThrow(InvalidArgumentException::class); + } + MovementRouteFields::assertChangedCommandsValid([], ['type' => 'move_route', 'steps' => [['direction' => ' RIGHT ', 'count' => '0', 'faceOnly' => true], ['direction' => 'up']]]); + MovementRouteFields::assertChangedCommandsValid([], ['type' => 'move_route', 'waypoints' => [['x' => 0], ['y' => 0]]]); +}); + +it('refuses unfinished cinematic routes at the actual paired source owner then saves completed sparse axes', function () { + $root = cutsceneProject(); + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + $route = ['type' => 'move_route', 'subject' => 'player', 'secondsPerStep' => 0.3, + 'steps' => [['direction' => 'left', 'faceOnly' => true]]]; + file_put_contents($path, "readDatabaseRecord('cutscenes/cinematic', 0, $frame), 'Route Mode'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $mode['key'], 'waypoints'); + expect(fn() => $session->saveDatabase('cutscenes/cinematic'))->toThrow(InvalidArgumentException::class, 'non-empty list') + ->and(sourceHashTree($root))->toBe($original); + $mode = routeAuthoringRow($session->readDatabaseRecord('cutscenes/cinematic', 0, $frame), 'Route Mode'); + $session->addDatabaseItem('cutscenes/cinematic', 0, $mode['key'], child: true); + expect(fn() => $session->saveDatabase('cutscenes/cinematic'))->toThrow(InvalidArgumentException::class, 'x and/or y') + ->and(sourceHashTree($root))->toBe($original); + $view = $session->readDatabaseRecord('cutscenes/cinematic', 0, $frame); + $x = array_find($view['rows'], static fn(array $row): bool => ($row['entry'] ?? []) === [0, 0] && ($row['name'] ?? '') === 'X'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $x['key'], '0'); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0])->toBe([...array_diff_key($route, ['steps' => true]), 'waypoints' => [['x' => 0]]]) + ->and(file_get_contents($path))->toContain('// Owner-validated route.'); + $reopened = EditorSession::open($root); + $mode = routeAuthoringRow($reopened->readDatabaseRecord('cutscenes/cinematic', 0, $frame), 'Route Mode'); + $reopened->applyDatabaseRecord('cutscenes/cinematic', 0, $mode['key'], 'retrace'); + $saved = sourceHashTree($root); + expect(fn() => $reopened->saveDatabase('cutscenes/cinematic'))->toThrow(InvalidArgumentException::class, 'safe stable id') + ->and(sourceHashTree($root))->toBe($saved); + $reopened->undo(); + $reopened->saveDatabase('cutscenes/cinematic'); + expect(sourceHashTree($root))->toBe($saved); +}); + +it('keeps incomplete cardinal record drafts and their history on refusal then saves a corrected route', function () { + $root = makeTemporaryProject('ichiloto-route-cardinal-save-'); + $path = $root . '/assets/Events/cardinal-draft.php'; + $route = ['type' => 'move_route', 'subject' => 'player', 'secondsPerStep' => 0.2, + 'annotation' => 'keep', 'waypoints' => [['y' => 0]]]; + file_put_contents($path, "listDatabaseRecords('common_events')['records'], true); + $mode = routeAuthoringRow($session->readDatabaseRecord('common_events', $index), 'Route Mode'); + $session->applyDatabaseRecord('common_events', $index, $mode['key'], 'steps'); + expect(fn() => $session->saveDatabase('common_events'))->toThrow(InvalidArgumentException::class, 'at least one step') + ->and(sourceHashTree($root))->toBe($original) + ->and(routeAuthoringRow($session->readDatabaseRecord('common_events', $index), 'Route Mode')['value'])->toBe('steps'); + $session->undo(); + expect(routeAuthoringRow($session->readDatabaseRecord('common_events', $index), 'Route Mode')['value'])->toBe('waypoints'); + $session->redo(); + $session->addDatabaseItem('common_events', $index, $mode['key'], child: true); + $count = array_find($session->readDatabaseRecord('common_events', $index)['rows'], static fn(array $row): bool => ($row['entry'] ?? []) === [0, 0] && $row['name'] === 'Count'); + $session->applyDatabaseRecord('common_events', $index, $count['key'], '-1'); + expect(fn() => $session->saveDatabase('common_events'))->toThrow(InvalidArgumentException::class, 'non-negative integer') + ->and(sourceHashTree($root))->toBe($original); + $session->undo(); + $session->saveDatabase('common_events'); + $expected = [...array_diff_key($route, ['waypoints' => true]), 'steps' => [RecordSchemaCatalog::routeStepList()->blank]]; + expect((require $path)[0])->toBe($expected) + ->and(file_get_contents($path))->toContain('// Cardinal correction, not a fabricated path.') + ->and(routeAuthoringRow(EditorSession::open($root)->readDatabaseRecord('common_events', $index), 'Route Mode')['value'])->toBe('steps'); +}); + +it('preserves untouched loaded invalid route paths in Terminal and direct map saves but refuses newly incomplete paths', function () { + $root = makeTemporaryProject('ichiloto-route-loaded-invalid-'); + $path = $root . '/assets/Events/invalid-existing.php'; + file_put_contents($path, " 'move_route', 'waypoints' => []], ['type' => 'text', 'text' => 'Old']];\n"); + $mapPath = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $mapPath; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, + 'data' => ['annotation' => 'Old', 'script' => [['type' => 'move_route', 'steps' => []]]]]; + $mapSource = " 'move_route'", "'type' => strtolower('MOVE_ROUTE')", $mapSource); + file_put_contents($mapPath, $mapSource); + $workspace = \Ichiloto\Editor\ProjectWorkspace::fromProject($root); + $database = $workspace->getRecordDatabase('common_events'); + $index = array_search('invalid-existing', $database->getEntryLabels(), true); + $database->setField($index, 'command1Text', 'Changed'); + $database->save(); + expect((require $path)[0])->toBe(['type' => 'move_route', 'waypoints' => []]) + ->and(file_get_contents($path))->toContain('// Legacy path preserved.', "[['type' => 'move_route', 'waypoints' => []],"); + $map = array_find($workspace->maps, static fn($map): bool => $map->mapId === 'test-map'); + $map->setEventField('S', ['data', 'annotation'], 'Changed'); + $map->save(); + expect((require $mapPath)['events']['S']['data']['script'])->toBe([['type' => 'move_route', 'steps' => []]]) + ->and(file_get_contents($mapPath))->toContain("'type' => strtolower('MOVE_ROUTE')", '// Legacy route source is not normalized.'); + $original = sourceHashTree($root); + $map->setEventField('E', ['data', 'script'], [['type' => 'move_route', 'waypoints' => []]]); + $backedUp = false; + expect(fn() => $map->save(static function () use (&$backedUp): void { $backedUp = true; }))->toThrow(InvalidArgumentException::class, 'non-empty list') + ->and($backedUp)->toBeFalse()->and(sourceHashTree($root))->toBe($original) + ->and($map->getEventField('E', ['data', 'script']))->toBe([['type' => 'move_route', 'waypoints' => []]]); +}); + +it('protects direct cutscene saves before backup or staging and preserves loaded route data during metadata edits', function () { + $root = cutsceneProject(); + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, " 'move_route', 'waypoints' => [['x' => 0]]]];\n"); + $workspace = \Ichiloto\Editor\ProjectWorkspace::fromProject($root); + $asset = $workspace->cutscenes->find(\Ichiloto\Editor\Cutscenes\CutsceneType::CINEMATIC, 'harbour-lanterns'); + $state = $asset->captureEditState(); + $state['data']['name'] = 'Changed metadata'; + $asset->restoreEditState($state); + $asset->save(); + expect(require $path)->toBe([['type' => 'move_route', 'waypoints' => [['x' => 0]]]]) + ->and(file_get_contents($path))->toContain('// Loaded route remains authored.'); + $original = sourceHashTree($root); + $state = $asset->captureEditState(); + $state['partner'] = [['type' => 'sequence', 'commands' => [['type' => 'move_route', 'retrace' => '']]]]; + $asset->restoreEditState($state); + $backedUp = false; + expect(fn() => $asset->save(static function () use (&$backedUp): void { $backedUp = true; }))->toThrow(InvalidArgumentException::class, 'safe stable id') + ->and($backedUp)->toBeFalse()->and(sourceHashTree($root))->toBe($original) + ->and(glob(dirname($path) . '/.*.tmp'))->toBe([]); +}); + +it('records and retraces within ordinary NPC map and standalone common-event execution sessions', function () { + $root = cutsceneProject(); + file_put_contents($root . '/assets/Maps/collisions.php', " \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::NONE, '#' => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::SOLID, '=' => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::SOLID];"); + $commands = [ + ['type' => 'move_route', 'remember' => 'outbound', 'secondsPerStep' => 0, 'steps' => [['direction' => 'right']]], + ['type' => 'move_route', 'retrace' => 'outbound', 'secondsPerStep' => 0], + ]; + $path = $root . '/assets/Events/standalone-route.php'; + file_put_contents($path, ' 0.0); + try { + expect($field->mapFailure)->toBeNull(); + $field->run(function () use ($field, $commands): void { + foreach ([ + 'npc:harbour:keeper' => [$commands, ['npc' => 'keeper']], + 'harbour:event' => [$commands, ['marker' => 'event']], + 'standalone-common' => [[['type' => 'common_event', 'id' => 'standalone-route']], ['source' => 'common_event']], + ] as $identity => [$commands, $origin]) { + $interpreter = new EventInterpreter($field->scene, $field->presentation); + $session = $interpreter->run($commands, $identity, origin: $origin); + for ($tick = 0; $tick < 20 && $session->status !== EventExecutionStatus::COMPLETED; ++$tick) { + $interpreter->update(0.0); + } + expect($session?->cinematic)->toBeNull() + ->and($session?->status)->toBe(EventExecutionStatus::COMPLETED) + ->and($session?->failureMessage)->toBeNull() + ->and([$field->scene->player->position->x, $field->scene->player->position->y])->toBe([4.0, 4.0]) + ->and(fn() => $session->movementRoute('outbound'))->toThrow(RuntimeException::class, 'was not recorded in this session'); + } + }); + expect(require $path)->toBe($commands); + } finally { + $field->dispose(); + } +}); + +it('shares actual recorded movement with common events only within the owning execution lifetime', function (bool $cinematic) { + $root = cutsceneProject(); + file_put_contents($root . '/assets/Maps/collisions.php', " \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::NONE, '#' => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::SOLID, '=' => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::SOLID];"); + file_put_contents($root . '/assets/Events/record-route.php', " 'move_route', 'remember' => 'common-recorded', 'secondsPerStep' => 0, 'steps' => [['direction' => 'right']]]];"); + file_put_contents($root . '/assets/Events/return-route.php', " 'move_route', 'retrace' => 'root-recorded', 'secondsPerStep' => 0]];"); + $commands = [ + ['type' => 'common_event', 'id' => 'record-route'], + ['type' => 'move_route', 'retrace' => 'common-recorded', 'secondsPerStep' => 0], + ['type' => 'move_route', 'remember' => 'root-recorded', 'secondsPerStep' => 0, 'steps' => [['direction' => 'right']]], + ['type' => 'common_event', 'id' => 'return-route'], + ['type' => 'wait', 'seconds' => 1], + ]; + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, 'listReferences(null, 'cinematic_movement_routes', ['category' => 'cutscenes/cinematic', 'index' => 0]), 'value')) + ->toBe(['common-recorded', 'root-recorded']) + ->and($session->listReferences('harbour', 'cinematic_movement_routes'))->toBe([]); + $definition = CinematicDefinition::fromArrays(['id' => 'route-lifetime', 'name' => 'Route Lifetime', 'startMap' => 'harbour', 'skip' => ['policy' => 'forbidden']], $commands); + $field = PreviewField::open($root, 'harbour', new Vector2(4, 4), 20, 8, fn(): float => 0.0); + try { + expect($field->mapFailure)->toBeNull(); + $field->run(function () use ($field, $definition, $commands, $cinematic): void { + $interpreter = new EventInterpreter($field->scene, $field->presentation); + $execution = $cinematic ? $interpreter->runCinematic($definition) : $interpreter->run($commands, 'ordinary-root'); + for ($tick = 0; $tick < 20 && ($execution?->pendingCommand['type'] ?? '') !== 'wait'; ++$tick) { + $interpreter->update(0.0); + } + expect($execution?->failureMessage)->toBeNull() + ->and($execution?->status)->toBe(EventExecutionStatus::YIELDED) + ->and($execution?->pendingCommand['type'])->toBe('wait') + ->and($execution->movementRoute('common-recorded')->complete)->toBeTrue() + ->and($execution->movementRoute('root-recorded')->complete)->toBeTrue() + ->and([$field->scene->player->position->x, $field->scene->player->position->y])->toBe([4.0, 4.0]) + ->and(fn() => new MovementRouteRunner($field->scene, ['retrace' => 'common-recorded'], $execution)) + ->toThrow(MovementRouteException::class, 'only be retraced once'); + $interpreter->update(2.0); + expect($execution->status)->toBe(EventExecutionStatus::COMPLETED) + ->and(fn() => $execution->movementRoute('common-recorded'))->toThrow(RuntimeException::class, 'was not recorded in this session'); + $next = $cinematic ? $interpreter->runCinematic($definition) : $interpreter->run($commands, 'ordinary-root'); + expect($next->id)->not->toBe($execution->id); + for ($tick = 0; $tick < 20 && ($next->pendingCommand['type'] ?? '') !== 'wait'; ++$tick) { + $interpreter->update(0.0); + } + expect($next->pendingCommand['type'])->toBe('wait'); + $next->cancelLanes(); + expect(fn() => $next->movementRoute('root-recorded'))->toThrow(RuntimeException::class, 'was not recorded in this session'); + }); + } finally { + $field->dispose(); + } +})->with([false, true]); diff --git a/tests/Unit/ScriptCommandAuthoringTest.php b/tests/Unit/ScriptCommandAuthoringTest.php index ead9a6f..e7386dd 100644 --- a/tests/Unit/ScriptCommandAuthoringTest.php +++ b/tests/Unit/ScriptCommandAuthoringTest.php @@ -7,6 +7,7 @@ use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Validation\ProjectValidator; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandReference; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; /** Declares a project command whose handler class the Editor never loads. */ @@ -46,8 +47,9 @@ function writeScriptCommandScript(string $path, array $payload): void function registeredVariantFields(string $type): array { $fields = []; - - foreach (RecordSchemaCatalog::forKey('common_events')?->subList?->variants[$type] ?? [] as $field) { + $variant = RecordSchemaCatalog::forKey('common_events')?->subList?->variants[$type] ?? []; + if ($variant instanceof Closure) { $variant = $variant(['type' => $type]); } + foreach ($variant as $field) { $fields[$field->key] = $field; } @@ -58,6 +60,15 @@ function registeredVariantFields(string $type): array ScriptCommandRegistry::reset(); }); +it('maps every Engine command reference to an Editor resource category', function (): void { + foreach (ScriptCommandReference::cases() as $reference) { + expect(ProjectScriptCommands::getReferenceCategory($reference))->toBeString()->not->toBeEmpty(); + } + + expect(ProjectScriptCommands::getReferenceCategory(ScriptCommandReference::STAGE_TIMELINE)) + ->toBe('stage_timelines'); +}); + it('reads a project\'s declared commands without loading their handlers', function (): void { $root = makeTemporaryProject(); @@ -84,9 +95,11 @@ function registeredVariantFields(string $type): array expect($list->fields[0]->options)->toBe([...RecordSchemaCatalog::EVENT_COMMAND_TYPES, 'shop', 'inn', 'hire_carriage']) ->and(array_keys($shop))->toBe(['buyRate', 'sellRate']) ->and($shop['sellRate']->type)->toBe(InputControlType::FLOAT) - ->and(array_keys($inn))->toBe(['confirmDialogue.text', 'confirmDialogue.name', 'cost', 'spawnPoint.x', 'spawnPoint.y', 'bgm', 'resultVariable']) + ->and(array_keys($inn))->toBe(['confirmDialogue.text', 'confirmDialogue.name', 'cost', 'spawnPoint.x', 'spawnPoint.y', 'bgm', 'presentation', 'resultVariable']) ->and($inn['bgm']->reference)->toBe('bgm') ->and($inn['bgm']->allowsNone)->toBeTrue() + ->and($inn['presentation']->reference)->toBe('stage_timelines') + ->and($inn['presentation']->allowsNone)->toBeTrue() ->and($inn['cost']->type)->toBe(InputControlType::INTEGER) ->and($carriage['destination']->reference)->toBe('maps') ->and($carriage['destination']->allowsNone)->toBeFalse() diff --git a/tests/Unit/Session/CinematicPreviewControlTest.php b/tests/Unit/Session/CinematicPreviewControlTest.php new file mode 100644 index 0000000..734379a --- /dev/null +++ b/tests/Unit/Session/CinematicPreviewControlTest.php @@ -0,0 +1,187 @@ + 'authored'", "'policy' => '{$policy}'", (string) file_get_contents($file)); + $source = str_replace(" 'finalizer' => [", " 'finalizer' => [\n ['type' => 'cinematic_music', 'track' => 'preview-theme', 'loop' => false, 'fadeIn' => 0.2, 'completionBehavior' => 'stop'],", $source); + file_put_contents($file, $source); + mkdir($root . '/assets/Audio/BGM', 0o777, true); + file_put_contents($root . '/assets/Audio/BGM/preview-theme.ogg', ''); + + return $root; +} + +/** Inspect the owned isolated preview without adding a runtime state-mutation protocol. */ +function readControlledCinematic(EditorSession $session): CinematicPreviewSession +{ + return new ReflectionProperty($session, 'cinematicPreview')->getValue($session); +} + +it('skips a cinematic through its timed finalizer with renderer-neutral cleanup and no project writes', function (bool $graphical, bool $playing) { + $root = createCinematicControlProject(); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + $map = $session->readMap('harbour'); + try { + $started = $session->startCinematicPreview(0, 60, 16); + if ($graphical) { + $session->exchangeCinematicScene([json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => [ + 'sprite_source_rect', 'graphical_canvas', 'canvas_overlay', 'canvas_clip_opacity', 'frame_viewport', 'field_motion', + ]])], $started['sessionId']); + } + $preview = readControlledCinematic($session); + if ($playing) { + $session->controlCinematicPreview('play'); + $session->controlCinematicPreview('tick', 0.1); + } + expect($preview->snapshot()->values['staged actors'])->not->toBe([]) + ->and($preview->snapshot()->values['field input'])->toBe('cinematic'); + + $skipped = $session->controlCinematicPreview('skip'); + expect($skipped)->toMatchArray(['status' => 'finalizing', 'playing' => true, 'finished' => false, 'failure' => null]) + ->and($skipped['terminalCanvas']['textLayers'][0]['grid'])->toMatchArray(['columns' => 60, 'rows' => 16]); + $finalizing = $preview->snapshot()->values; + expect(fn() => $session->controlCinematicPreview('skip'))->toThrow(SessionRefusal::class, 'the finalizer is already running') + ->and($preview->snapshot()->values)->toBe($finalizing); + + $finished = $session->controlCinematicPreview('tick', 0.5); + expect($finished)->toMatchArray(['status' => 'completed', 'playing' => false, 'finished' => true, 'failure' => null]); + $values = $preview->snapshot()->values; + expect($values['switch harbour_lanterns_seen'])->toBeTrue() + ->and($values['story events'])->toContain('cinematic:harbour-lanterns:completed') + ->and($values['staged actors'])->toBe([]) + ->and($values['camera']['followsPlayer'])->toBeTrue() + ->and($values['field input'])->toBe('player') + ->and($values['save available'])->toBeTrue() + ->and($values['checkpoints'])->toBe([]) + ->and(fn() => $session->controlCinematicPreview('skip'))->toThrow(SessionRefusal::class, 'no cinematic is active') + ->and($session->readMap('harbour'))->toBe($map) + ->and($session->listUnsavedChanges())->toBe([]) + ->and(sourceHashTree($root))->toBe($before); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +})->with([ + 'terminal paused' => [false, false], 'terminal playing' => [false, true], + 'graphical paused' => [true, false], 'graphical playing' => [true, true], +]); + +it('completes an immediate finalizer without leaving playback running or changing authored source', function () { + $root = cutsceneProject(); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + try { + $session->startCinematicPreview(0, 40, 12); + expect($session->controlCinematicPreview('skip'))->toMatchArray(['status' => 'completed', 'finished' => true, 'playing' => false]) + ->and(readControlledCinematic($session)->snapshot()->values['switch harbour_lanterns_seen'])->toBeTrue() + ->and($session->listUnsavedChanges())->toBe([]) + ->and(sourceHashTree($root))->toBe($before); + } finally { + $session->close(); + } +}); + +it('returns skip policy refusals over the existing protocol without replacing or advancing the isolated preview', function () { + $root = createCinematicControlProject('forbidden'); + $before = sourceHashTree($root); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params])); + $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + $session = new ReflectionProperty($host, 'session')->getValue($host); + try { + $started = $request('cutscenes.cinematicPreview', ['index' => 0, 'width' => 40, 'height' => 12])['result']; + $preview = readControlledCinematic($session); + $state = $preview->snapshot()->values; + $reply = $request('cutscenes.cinematicControl', ['action' => 'skip']); + + expect($reply['error']['kind'])->toBe('refusal') + ->and($reply['error']['message'])->toBe('Skip refused: ' . $preview->skipRefusalReason()) + ->and($reply['error']['message'])->toContain('skip policy is "forbidden"') + ->and(readControlledCinematic($session))->toBe($preview) + ->and($preview->snapshot()->values)->toBe($state) + ->and($request('cutscenes.cinematicPreview', ['index' => 0, 'width' => 40, 'height' => 12, 'keep' => true])['result'])->toBe($started) + ->and($session->listUnsavedChanges())->toBe([]) + ->and(sourceHashTree($root))->toBe($before); + } finally { + $session->close(); + } +}); + +it('reports the Engine failure when skip is requested after a failed preview instead of hiding it behind an inactive-cinematic refusal', function () { + $root = cutsceneProject(); + $file = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($file, " 'wait', 'seconds' => 0.1], + ['type' => 'move_route', 'subject' => 'staged_actor', 'actorId' => 'nobody', 'steps' => [['direction' => 'up', 'count' => 1]]], + ], true) . ";\n"); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + try { + $session->startCinematicPreview(0, 40, 12); + $session->controlCinematicPreview('step'); + $result = $session->controlCinematicPreview('skip'); + expect($result)->toMatchArray(['status' => 'failed', 'finished' => true, 'playing' => false]) + ->and($result['failure'])->toBe(readControlledCinematic($session)->failure()['message']) + ->and($result['failure'])->toContain('nobody') + ->and($session->listUnsavedChanges())->toBe([]) + ->and(sourceHashTree($root))->toBe($before); + } finally { + $session->close(); + } +}); + +it('refuses an unsafe authored finalizer before preview launch and leaves no cinematic to skip', function (array $finalizer, string $reason) { + $root = cutsceneProject(); + $file = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.data.php'; + $data = require $file; + $data['finalizer'] = $finalizer; + file_put_contents($file, " $session->startCinematicPreview(0, 40, 12))->toThrow(SessionRefusal::class, $reason) + ->and(fn() => $session->controlCinematicPreview('skip'))->toThrow(SessionRefusal::class, 'No cinematic is being previewed.') + ->and($session->listUnsavedChanges())->toBe([]) + ->and(sourceHashTree($root))->toBe($before); + } finally { + $session->close(); + } +})->with([ + 'empty finalizer' => [[], 'requires an authored finalizer'], + 'unsafe command' => [[['type' => 'wait', 'seconds' => 0.2]], 'unsafe type "wait"'], +]); + +it('serves semantic skip and finalization over the protocol and refuses an absent preview', function () { + $root = createCinematicControlProject(); + $before = sourceHashTree($root); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 7, 'method' => $method, 'params' => $params])); + $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + $session = new ReflectionProperty($host, 'session')->getValue($host); + try { + $absent = ['kind' => 'refusal', 'message' => 'No cinematic is being previewed.']; + expect($request('cutscenes.cinematicControl', ['action' => 'skip'])['error'])->toBe($absent); + $request('cutscenes.cinematicPreview', ['index' => 0, 'width' => 40, 'height' => 12]); + expect($request('cutscenes.cinematicControl', ['action' => 'skip'])['result'])->toMatchArray(['status' => 'finalizing', 'playing' => true]) + ->and($request('cutscenes.cinematicControl', ['action' => 'tick', 'seconds' => 0.5])['result']) + ->toMatchArray(['status' => 'completed', 'finished' => true, 'failure' => null]); + $request('cutscenes.cinematicStop'); + expect($request('cutscenes.cinematicControl', ['action' => 'skip'])['error'])->toBe($absent) + ->and(sourceHashTree($root))->toBe($before); + } finally { + $session->close(); + } +}); diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php index 3feb136..4f45a27 100644 --- a/tests/Unit/Session/CutsceneRecordSessionTest.php +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -269,17 +269,17 @@ function expectTerminalCanvas(array $canvas, int $columns, int $rows): void $root = cutsceneProject(); $before = sourceHashTree($root); $session = EditorSession::open($root); - $session->startCinematicPreview(0, 60, 16); + $started = $session->startCinematicPreview(0, 60, 16); $ready = json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => ['sprite_source_rect', 'graphical_canvas', 'canvas_overlay', 'canvas_clip_opacity', 'frame_viewport', 'field_motion']]); - $exchange = $session->exchangeCinematicScene([$ready]); + $exchange = $session->exchangeCinematicScene([$ready], $started['sessionId']); // The window builds its scene with the grid the session names: the preview's screen in the game's graphical text cells. expect($exchange['grid'])->toBe(['columns' => 60, 'rows' => 16, 'cellWidth' => 10, 'cellHeight' => 20]); $sent = $exchange['messages']; expect(array_column($sent, 'type'))->toContain('frame') ->and(array_key_exists('protocol', $sent[0]['payload']))->toBeFalse() - ->and(fn() => $session->exchangeCinematicScene(['not a renderer event']))->toThrow(SessionRefusal::class, 'graphical preview stopped') + ->and(fn() => $session->exchangeCinematicScene(['not a renderer event'], $started['sessionId']))->toThrow(SessionRefusal::class, 'graphical preview stopped') ->and($session->detachCinematicScene())->toBe(['detached' => true]); $session->stopCinematicPreview(); expect(fn() => $session->exchangeCinematicScene([$ready]))->toThrow(SessionRefusal::class, 'No cinematic is being previewed') diff --git a/tests/Unit/Session/MapLifecycleSessionTest.php b/tests/Unit/Session/MapLifecycleSessionTest.php new file mode 100644 index 0000000..3909497 --- /dev/null +++ b/tests/Unit/Session/MapLifecycleSessionTest.php @@ -0,0 +1,199 @@ +readInspector('test-map'); + $session->applyInspector('test-map', $inspector['revision'], ['target' => 'map', 'field' => 'name'], $name); +} + +function readLifecycleGlyph(array $map, string $layer, int $x, int $y): string +{ + return array_find($map['layers'], static fn(array $entry): bool => $entry['id'] === $layer)['rows'][$y][$x]; +} + +it('duplicates all current layers and unsaved tiles without changing the original or its undo history', function () { + $root = mapGraphicsProject(); + $original = $root . '/assets/Maps/test-map'; + $before = sourceHashTree($original); + $session = EditorSession::open($root); + $map = $session->readMap('test-map'); + $session->paint('test-map', $map['revision'], $map['baseLayer'], [[0, 0]], '#'); + $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[1, 1]], 5); + $session->createMap('Cellar', null, 3, 3); + $other = $session->readMap('cellar'); + $session->paint('cellar', $other['revision'], $other['baseLayer'], [[0, 0]], '%'); + + $created = $session->duplicateMap('test-map', $session->readMap('test-map')['revision']); + $copy = $session->readMap($created['map']); + expect($created['map'])->toBe('test-map-copy') + ->and(array_column($created['maps'], 'id'))->toContain('test-map', 'test-map-copy', 'cellar') + ->and($copy['dirty'])->toBeFalse() + ->and(readLifecycleGlyph($copy, $copy['baseLayer'], 0, 0))->toBe('#') + ->and(readTileRows($root . '/assets/Maps/test-map-copy/graphics/01.floor.tiles.php')[1][1])->toBe(5) + ->and(array_column($copy['layers'], 'id'))->toBe(array_column($map['layers'], 'id')) + ->and(file_get_contents($root . '/assets/Maps/test-map-copy/test-map-copy.data.php'))->toContain('// Authored metadata remains authored.') + ->and(file_get_contents($root . '/assets/Maps/test-map-copy/graphics/01.floor.tiles.php'))->toContain('// Painted in the GUI editor.') + ->and(sourceHashTree($original))->toBe($before) + ->and($session->readMap('test-map')['dirty'])->toBeTrue() + ->and($session->readMap('cellar')['dirty'])->toBeTrue() + ->and($session->undo()['maps'])->toBe(['cellar']) + ->and($session->undo()['label'])->toBe('Place tiles') + ->and(readTileRows($root . '/assets/Maps/test-map-copy/graphics/01.floor.tiles.php')[1][1])->toBe(5) + ->and($session->duplicateMap('test-map', $session->readMap('test-map')['revision'])['map'])->toBe('test-map-copy-2'); +}); + +it('refuses stale copy requests without writing any files', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + $map = $session->readMap('test-map'); + $session->paint('test-map', $map['revision'], $map['baseLayer'], [[0, 0]], '#'); + $before = sourceHashTree($root); + expect(fn() => $session->duplicateMap('test-map', $map['revision']))->toThrow(SessionRefusal::class, 'changed since revision') + ->and(fn() => $session->duplicateMap('missing', 0))->toThrow(SessionRefusal::class, 'There is no map') + ->and(sourceHashTree($root))->toBe($before); +}); + +it('asks for an explicit derived-path move and does not move on an ordinary save', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + renameLifecycleMap($session, 'Harbour Workshop'); + $map = $session->readMap('test-map'); + $before = sourceHashTree($root); + $question = $session->moveMap('test-map', $map['revision']); + expect($question)->toMatchArray(['status' => 'question', 'map' => 'test-map', 'revision' => $map['revision'], 'destination' => 'harbour-workshop']) + ->and($question['warning'])->toContain('References are not migrated', 'Moving clears the undo history') + ->and(sourceHashTree($root))->toBe($before) + ->and(fn() => $session->moveMap('test-map', $map['revision'], confirmed: true))->toThrow(SessionRefusal::class, 'Review the proposed destination') + ->and(fn() => $session->moveMap('test-map', $map['revision'], '../outside', true))->toThrow(SessionRefusal::class, 'derived destination changed') + ->and($session->saveMap('test-map')['saved'])->toBe('test-map') + ->and(is_dir($root . '/assets/Maps/harbour-workshop'))->toBeFalse(); +}); + +it('rejects a previously reviewed move when the map or destination changed', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + renameLifecycleMap($session, 'Workshop'); + $question = $session->moveMap('test-map', $session->readMap('test-map')['revision']); + renameLifecycleMap($session, 'New Workshop'); + $before = sourceHashTree($root); + expect(fn() => $session->moveMap('test-map', $question['revision'], $question['destination'], true)) + ->toThrow(SessionRefusal::class, 'changed since revision') + ->and(fn() => $session->moveMap('test-map', $session->readMap('test-map')['revision'], $question['destination'], true)) + ->toThrow(SessionRefusal::class, 'derived destination changed') + ->and(sourceHashTree($root))->toBe($before); +}); + +it('moves the authored map transactionally, keeps unrelated work and notes, and explicitly leaves references unchanged', function () { + $root = mapGraphicsProject(); + $original = $root . '/assets/Maps/test-map'; + file_put_contents($original . '/notes.txt', 'Author-owned notes'); + $session = EditorSession::open($root); + $session->createMap('Cellar', null, 3, 3); + $data = $root . '/assets/Maps/cellar/cellar.data.php'; + file_put_contents($data, str_replace("'events' => []", "'events' => ['D' => ['class' => 'Door', 'data' => ['destinationMap' => 'test-map']]]", (string) file_get_contents($data))); + $session = EditorSession::open($root); + $other = $session->readMap('cellar'); + $session->paint('cellar', $other['revision'], $other['baseLayer'], [[0, 0]], '%'); + $map = $session->readMap('test-map'); + $session->paint('test-map', $map['revision'], $map['baseLayer'], [[0, 0]], '#'); + $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[1, 1]], 5); + renameLifecycleMap($session, 'Harbour Workshop'); + $question = $session->moveMap('test-map', $session->readMap('test-map')['revision']); + expect($question['references'])->toBe(['event D on cellar']); + + $moved = $session->moveMap('test-map', $question['revision'], $question['destination'], true); + $destination = $root . '/assets/Maps/harbour-workshop'; + $current = $session->readMap('harbour-workshop'); + expect($moved)->toMatchArray(['status' => 'moved', 'previousMap' => 'test-map', 'map' => 'harbour-workshop']) + ->and(array_column($moved['maps'], 'id'))->not->toContain('test-map') + ->and($current['dirty'])->toBeFalse() + ->and(readLifecycleGlyph($current, $current['baseLayer'], 0, 0))->toBe('#') + ->and(readTileRows($destination . '/graphics/01.floor.tiles.php')[1][1])->toBe(5) + ->and(file_get_contents($destination . '/harbour-workshop.data.php'))->toContain('// Authored metadata remains authored.') + ->and(file_get_contents($original . '/notes.txt'))->toBe('Author-owned notes') + ->and(is_file($original . '/test-map.data.php'))->toBeFalse() + ->and($session->readMap('cellar')['dirty'])->toBeTrue() + ->and($session->undo())->toMatchArray(['label' => null, 'maps' => []]) + ->and(fn() => $session->readMap('test-map'))->toThrow(SessionRefusal::class, 'There is no map'); + $loaded = ProjectWorkspace::fromProject($root); + $cellar = array_find($loaded->maps, static fn($map): bool => $map->mapId === 'cellar'); + expect($cellar->getEventDefinitions()['D']['data']['destinationMap'])->toBe('test-map'); +}); + +it('refuses a colliding destination and keeps undo and all sources intact', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + $session->createMap('Workshop', null, 3, 3); + renameLifecycleMap($session, 'Workshop'); + $map = $session->readMap('test-map'); + $before = sourceHashTree($root); + expect(fn() => $session->moveMap('test-map', $map['revision'], 'workshop', true)) + ->toThrow(SessionRefusal::class, 'already exists') + ->and(sourceHashTree($root))->toBe($before) + ->and($session->undo()['maps'])->toBe(['test-map']); +}); + +it('keeps an unchanged derived path as a no-op without clearing undo', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + $map = $session->readMap('test-map'); + $session->paint('test-map', $map['revision'], $map['baseLayer'], [[0, 0]], '#'); + $before = sourceHashTree($root); + expect($session->moveMap('test-map', $session->readMap('test-map')['revision'])['status'])->toBe('unchanged') + ->and(sourceHashTree($root))->toBe($before) + ->and($session->undo()['label'])->toBe('Paint'); +}); + +it('refuses external source edits across save, copy and move without overwriting them', function (string $member, string $operation) { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + renameLifecycleMap($session, 'Workshop'); + $path = $root . '/assets/Maps/test-map/' . $member; + file_put_contents($path, (string) file_get_contents($path) . "\n// Changed outside the editor.\n"); + $before = sourceHashTree($root); + $revision = $session->readMap('test-map')['revision']; + $execute = match ($operation) { + 'save' => fn() => $session->saveMap('test-map'), + 'copy' => fn() => $session->duplicateMap('test-map', $revision), + 'move' => fn() => $session->moveMap('test-map', $revision, 'workshop', true), + }; + expect($execute)->toThrow(SessionRefusal::class, 'changed') + ->and(sourceHashTree($root))->toBe($before) + ->and($session->readMap('test-map')['dirty'])->toBeTrue(); +})->with(['test-map.data.php', 'layers/01.terrain.map.php', 'graphics/01.floor.tiles.php'])->with(['save', 'copy', 'move']); + +it('checks source freshness even when an otherwise clean map would not need writing', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + $session->readMap('test-map'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($path, (string) file_get_contents($path) . "\n// Changed outside the editor.\n"); + $before = sourceHashTree($root); + expect(fn() => $session->saveMap('test-map'))->toThrow(SessionRefusal::class, 'changed after opening') + ->and(sourceHashTree($root))->toBe($before) + ->and($session->readMap('test-map')['dirty'])->toBeFalse(); +}); + +it('exposes duplication and reviewed relocation through the session protocol', function () { + $root = mapGraphicsProject(); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result'); + $map = $request('map.read', ['map' => 'test-map'])['result']; + expect($request('map.duplicate', ['map' => 'test-map', 'revision' => 'bad'])['error']['kind'])->toBe('request') + ->and($request('map.duplicate', ['map' => 'test-map', 'revision' => $map['revision']])['result']['map'])->toBe('test-map-copy'); + $changed = $request('inspector.apply', ['map' => 'test-map', 'revision' => $map['revision'], + 'key' => ['target' => 'map', 'field' => 'name'], 'value' => 'Workshop'])['result']; + $question = $request('map.move', ['map' => 'test-map', 'revision' => $changed['revision']])['result']; + expect($question['status'])->toBe('question') + ->and($request('map.move', ['map' => 'test-map', 'revision' => $question['revision'], 'destination' => 4])['error']['kind'])->toBe('request') + ->and($request('map.move', ['map' => 'test-map', 'revision' => $question['revision'], 'destination' => $question['destination'], 'confirm' => true])['result']['status'])->toBe('moved'); +}); diff --git a/tests/Unit/Session/MapLineInsertionSessionTest.php b/tests/Unit/Session/MapLineInsertionSessionTest.php new file mode 100644 index 0000000..c4ec861 --- /dev/null +++ b/tests/Unit/Session/MapLineInsertionSessionTest.php @@ -0,0 +1,263 @@ + [],", <<<'PHP' +'station' => ['x' => 2, 'y' => 1], + 'tileLayers' => ['floor' => ['offset' => [0, 1]]], + 'npcs' => [['id' => 'visitor', 'name' => 'Visitor', 'sprite' => 'v', 'x' => 2, 'y' => 1, + 'movement' => 'wander', 'wanderArea' => ['x' => 1, 'y' => 0, 'width' => 2, 'height' => 2]]], + 'events' => ['D' => ['class' => 'Ichiloto\Engine\Events\Triggers\SleepEventTrigger', 'data' => ['spawnPoint' => ['x' => 2, 'y' => 1]]]], +PHP, (string) file_get_contents($path))); + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource(" \n D ", 'EVENTS', "// Authored event notes.\n")); + $other = $root . '/assets/Maps/approach'; + mkdir($other, 0o777, true); + file_put_contents($other . '/approach.map.php', MapGridSource::buildSource("....\n....", 'MAP')); + file_put_contents($other . '/approach.event.php', MapGridSource::buildSource(" D \n ", 'EVENTS')); + file_put_contents($other . '/approach.data.php', <<<'PHP' + 'Approach', 'region' => '', 'events' => ['D' => [ + 'class' => 'Ichiloto\Engine\Events\Triggers\TransferPlayerTrigger', + 'data' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 2, 'y' => 1]], +]]]; +PHP); + file_put_contents($root . '/assets/Events/arrival.php', <<<'PHP' + 'transfer', 'map' => 'test-map', 'x' => 2, 'y' => 1], + ['type' => 'move_player', 'x' => 3, 'y' => 1], +]; +PHP); + $cinematic = $root . '/assets/Cutscenes/Cinematics/arrival'; + mkdir($cinematic, 0o777, true); + file_put_contents($cinematic . '/arrival.data.php', <<<'PHP' + 'arrival', 'name' => 'Arrival', 'startMap' => 'test-map', + 'cast' => [['id' => 'visitor', 'sprite' => 'v', 'x' => 2, 'y' => 1]]]; +PHP); + file_put_contents($cinematic . '/arrival.script.php', <<<'PHP' + 'move_player', 'x' => 3, 'y' => 1]]; +PHP); + $system = $root . '/assets/Data/system.php'; + $data = ['startingPositions' => ['player' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 2, 'y' => 1]]]]; + file_put_contents($system, " 0, 'migrations' => [], 'aliases' => [], 'tombstones' => []];\n"); + + return $root; +} + +function writeReviewedMapInsertion(EditorSession $session, array $question): array +{ + return $session->insertMapLines($question['map'], $question['revision'], $question['axis'], $question['at'], $question['count'], 'write', $question['confirm']); +} + +it('reviews the shared complete insertion without writing and cancels without losing undo or copied content', function () { + $root = mapInsertionSessionProject(); + $session = EditorSession::open($root); + $map = $session->readMap('test-map'); + $clipboard = $session->copySelection('test-map', $map['baseLayer'], 0, 0, 1, 1); + $before = sourceHashTree($root); + $question = $session->insertMapLines('test-map', $map['revision'], 'y', 1, 2); + expect($question)->toMatchArray(['status' => 'question', 'map' => 'test-map', 'revision' => $map['revision'], + 'axis' => 'y', 'at' => 1, 'count' => 2, 'width' => 4, 'height' => 4]) + ->and(array_column($question['answers'], 'key'))->toBe(['cancel', 'write']) + ->and($question['answers'][1]['description'])->toContain('now, not on Save', 'Undo restores') + ->and($question['paths'])->toContain('assets/Maps/test-map/test-map.event.php', 'assets/Maps/approach/approach.data.php', + 'assets/Events/arrival.php', 'assets/Data/system.php', 'assets/Data/save-compatibility.php', + 'assets/Cutscenes/Cinematics/arrival/arrival.data.php', 'assets/Cutscenes/Cinematics/arrival/arrival.script.php') + ->and($question['notes'][0])->toContain('Saves move with the map') + ->and(sourceHashTree($root))->toBe($before) + ->and($session->insertMapLines('test-map', $map['revision'], 'y', 1, 2, 'cancel')['status'])->toBe('cancelled') + ->and(sourceHashTree($root))->toBe($before) + ->and($session->describeClipboard())->toBe($clipboard) + ->and($session->undo()['label'])->toBeNull(); +}); + +it('writes all coordinate owners together, reloads every document and reverses the source set through undo and redo', function (string $axis) { + $root = mapInsertionSessionProject(); + $session = EditorSession::open($root); + $map = $session->readMap('test-map'); + $other = $session->readMap('approach'); + $before = sourceHashTree($root); + $question = $session->insertMapLines('test-map', $map['revision'], $axis, 1, 2); + $result = writeReviewedMapInsertion($session, $question); + $after = sourceHashTree($root); + $data = require $root . '/assets/Maps/test-map/test-map.data.php'; + $point = $axis === 'x' ? ['x' => 4, 'y' => 1] : ['x' => 2, 'y' => 3]; + $loaded = $session->readMap('test-map'); + $eventLayer = array_find($loaded['layers'], static fn(array $layer): bool => $layer['event']); + expect($result)->toMatchArray(['status' => 'inserted', 'map' => 'test-map', 'changed' => true]) + ->and($result['revision'])->toBeGreaterThan($map['revision']) + ->and($session->readMap('approach')['revision'])->toBeGreaterThan($other['revision']) + ->and([$loaded['width'], $loaded['height']])->toBe([$question['width'], $question['height']]) + ->and($loaded['dirty'])->toBeFalse() + ->and($data['npcs'][0])->toMatchArray($point) + ->and($data['npcs'][0]['wanderArea'])->toBe($axis === 'x' + ? ['x' => 3, 'y' => 0, 'width' => 2, 'height' => 2] + : ['x' => 1, 'y' => 0, 'width' => 2, 'height' => 4]) + ->and($data['events']['D']['data']['spawnPoint'])->toBe($point) + ->and($data['station'])->toBe(['x' => 2, 'y' => 1]) + ->and($data['tileLayers'])->toBe(['floor' => ['offset' => [0, 1]]]) + ->and((require $root . '/assets/Maps/approach/approach.data.php')['events']['D']['data']['spawnPoint'])->toBe($point) + ->and((require $root . '/assets/Data/system.php')['startingPositions']['player']['spawnPoint'])->toBe($point) + ->and((require $root . '/assets/Cutscenes/Cinematics/arrival/arrival.data.php')['cast'][0])->toMatchArray($point) + ->and((require $root . '/assets/Events/arrival.php')[0])->toMatchArray($point) + ->and((require $root . '/assets/Data/save-compatibility.php')['migrations'][0]['mapShifts'][0]) + ->toBe(['map' => 'test-map', 'axis' => $axis, 'at' => 1, 'by' => 2]) + ->and($eventLayer)->not->toBeNull() + ->and($eventLayer['rows'][$axis === 'x' ? 1 : 3][$axis === 'x' ? 3 : 1])->toBe('D') + ->and(readTileRows($root . '/assets/Maps/test-map/graphics/01.floor.tiles.php')[$axis === 'x' ? 0 : 1][$axis === 'x' ? 1 : 0])->toBe(0) + ->and(file_get_contents($root . '/assets/Maps/approach/approach.data.php'))->toContain("// Keep this author's comment.") + ->and(fn() => writeReviewedMapInsertion($session, $question))->toThrow(SessionRefusal::class, 'changed since revision'); + $undo = $session->undo(); + expect($undo['maps'])->toContain('test-map', 'approach') + ->and($undo['databases'])->not->toBeEmpty() + ->and(sourceHashTree($root))->toBe($before) + ->and($session->readMap('test-map')['revision'])->toBeGreaterThan($result['revision']); + expect($session->redo()['label'])->toBe('Insert ' . ($axis === 'x' ? 'columns' : 'rows')) + ->and(sourceHashTree($root))->toBe($after) + ->and($session->readMap('test-map')['dirty'])->toBeFalse(); +})->with(['x', 'y']); + +it('keeps copied glyphs, styles and relative tiles usable after insertion, undo and redo', function (string $axis, string $state) { + $root = mapGraphicsProject(); + writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "5 6 0 0\n0 0 0 0"); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($path, str_replace("'events' => [],", "'events' => [], 'tileLayers' => ['floor' => ['movesWith' => 'terrain']],", (string) file_get_contents($path))); + $session = EditorSession::open($root); + $map = $session->readMap('test-map'); + $layerId = $map['baseLayer']; + $sourceLayer = array_find($map['layers'], static fn(array $layer): bool => $layer['id'] === $layerId); + $sourceColors = array_column(array_filter($sourceLayer['colors'], static fn(array $cell): bool => $cell[1] === 0 && $cell[0] < 2), 2); + $clipboard = $session->copySelection('test-map', $layerId, 0, 0, 2, 1); + $before = sourceHashTree($root); + $question = $session->insertMapLines('test-map', $map['revision'], $axis, 1, 2); + writeReviewedMapInsertion($session, $question); + $after = sourceHashTree($root); + + if ($state !== 'inserted') { + $session->undo(); + expect(sourceHashTree($root))->toBe($before); + } + if ($state === 'redone') { + $session->redo(); + expect(sourceHashTree($root))->toBe($after); + } + $x = $state !== 'undone' && $axis === 'x' ? 1 : 0; + $y = $state !== 'undone' && $axis === 'x' ? 0 : 1; + expect($session->describeClipboard())->toBe($clipboard); + $pasted = $session->pasteSelection('test-map', $session->readMap('test-map')['revision'], $layerId, $x, $y); + $layer = array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] === $layerId); + $colors = array_column(array_filter($layer['colors'], static fn(array $cell): bool => $cell[1] === $y && $cell[0] >= $x && $cell[0] < $x + 2), 2); + $tiles = array_column($session->readTiles('test-map')['layers'], null, 'name'); + expect($pasted)->toMatchArray(['status' => 'applied', 'changed' => 2]) + ->and(array_slice($layer['rows'][$y], $x, 2))->toBe(array_slice($sourceLayer['rows'][0], 0, 2)) + ->and($colors)->toBe($sourceColors) + ->and(array_slice($tiles['floor']['rows'][$y], $x, 2))->toBe([5, 6]) + ->and($session->undo()['label'])->toBe('Paste selection') + ->and($session->describeClipboard())->toBe($clipboard) + ->and(sourceHashTree($root))->toBe($state === 'undone' ? $before : $after); +})->with(['x', 'y'])->with(['inserted', 'undone', 'redone']); + +it('refuses changed choices and unreviewed writes without changing a file', function () { + $root = mapInsertionSessionProject(); + $session = EditorSession::open($root); + $map = $session->readMap('test-map'); + $question = $session->insertMapLines('test-map', $map['revision'], 'y', 1, 1); + $before = sourceHashTree($root); + expect(fn() => $session->insertMapLines('test-map', $map['revision'], 'y', 1, 1, 'write'))->toThrow(SessionRefusal::class, 'files or choices changed') + ->and(fn() => $session->insertMapLines('test-map', $map['revision'], 'x', 1, 1, 'write', $question['confirm']))->toThrow(SessionRefusal::class, 'files or choices changed') + ->and(fn() => $session->insertMapLines('test-map', $map['revision'], 'y', 1, 2, 'write', $question['confirm']))->toThrow(SessionRefusal::class, 'files or choices changed') + ->and(fn() => $session->insertMapLines('test-map', $map['revision'], 'y', 1, 1, 'yes', $question['confirm']))->toThrow(SessionRefusal::class, 'Answer write or cancel') + ->and(sourceHashTree($root))->toBe($before) + ->and($session->undo()['label'])->toBeNull(); +}); + +it('refuses unsaved work in any map before both review and confirmed writes', function () { + $root = mapInsertionSessionProject(); + $session = EditorSession::open($root); + $map = $session->readMap('test-map'); + $question = $session->insertMapLines('test-map', $map['revision'], 'y', 1, 1); + $other = $session->readMap('approach'); + $session->paint('approach', $other['revision'], $other['baseLayer'], [[0, 0]], '#'); + $before = sourceHashTree($root); + expect(fn() => $session->insertMapLines('test-map', $map['revision'], 'y', 1, 1))->toThrow(SessionRefusal::class, 'Save or undo pending') + ->and(fn() => writeReviewedMapInsertion($session, $question))->toThrow(SessionRefusal::class, 'Save or undo pending') + ->and($session->readMap('approach')['dirty'])->toBeTrue() + ->and(sourceHashTree($root))->toBe($before) + ->and($session->undo()['label'])->toBe('Paint'); +}); + +it('refuses external changes after review, including newly discovered references', function (bool $newFile) { + $root = mapInsertionSessionProject(); + $session = EditorSession::open($root); + $question = $session->insertMapLines('test-map', $session->readMap('test-map')['revision'], 'y', 1, 1); + $path = $root . '/assets/Events/' . ($newFile ? 'new-reference' : 'arrival') . '.php'; + if ($newFile) { + file_put_contents($path, " 'transfer', 'map' => 'test-map', 'x' => 2, 'y' => 1]];\n"); + } else { + file_put_contents($path, (string) file_get_contents($path) . "\n// Another writer's change.\n"); + } + $before = sourceHashTree($root); + expect(fn() => writeReviewedMapInsertion($session, $question))->toThrow(SessionRefusal::class, 'files or choices changed') + ->and(sourceHashTree($root))->toBe($before) + ->and($session->undo()['label'])->toBeNull(); +})->with([false, true]); + +it('preserves external edits when undoing an applied insertion is no longer safe', function () { + $root = mapInsertionSessionProject(); + $session = EditorSession::open($root); + $question = $session->insertMapLines('test-map', $session->readMap('test-map')['revision'], 'y', 1, 1); + writeReviewedMapInsertion($session, $question); + $path = $root . '/assets/Events/arrival.php'; + file_put_contents($path, (string) file_get_contents($path) . "\n// Another writer's change.\n"); + $before = sourceHashTree($root); + expect(fn() => $session->undo())->toThrow(RuntimeException::class, 'changed outside') + ->and(sourceHashTree($root))->toBe($before); +}); + +it('reports expressions needing hand edits and never flattens their source', function () { + $root = mapInsertionSessionProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($path, str_replace("'y' => 1,", "'y' => 0 + 1,", (string) file_get_contents($path))); + $session = EditorSession::open($root); + $question = $session->insertMapLines('test-map', $session->readMap('test-map')['revision'], 'y', 1, 1); + expect(array_column($question['handEdits'], 'where'))->toContain('npcs.0.y'); + writeReviewedMapInsertion($session, $question); + expect(file_get_contents($path))->toContain("'y' => 0 + 1,"); +}); + +it('exposes strict request validation, review, cancel, apply and undo through the actual host', function () { + $root = mapInsertionSessionProject(); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params = []): array => $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + $map = $request('map.read', ['map' => 'test-map'])['result']; + $params = ['map' => 'test-map', 'revision' => $map['revision'], 'axis' => 'y', 'at' => 1, 'count' => 1]; + $before = sourceHashTree($root); + foreach (['revision', 'at', 'count', 'axis', 'confirm', 'answer'] as $field) { + expect($request('map.insertLines', [...$params, $field => []])['error']['kind'])->toBe('request'); + } + expect($request('map.insertLines', [...$params, 'axis' => 'z'])['error']['kind'])->toBe('refusal') + ->and($request('map.insertLines', [...$params, 'at' => 99])['error']['kind'])->toBe('refusal') + ->and($request('map.insertLines', [...$params, 'count' => 0])['error']['kind'])->toBe('refusal'); + $question = $request('map.insertLines', $params)['result']; + expect($question['status'])->toBe('question') + ->and($request('map.insertLines', [...$params, 'answer' => 'cancel'])['result']['status'])->toBe('cancelled') + ->and(sourceHashTree($root))->toBe($before) + ->and($request('map.insertLines', [...$params, 'answer' => 'write', 'confirm' => $question['confirm']])['result']['status'])->toBe('inserted') + ->and($request('history.undo')['result']['label'])->toBe('Insert row') + ->and(sourceHashTree($root))->toBe($before); +}); diff --git a/tests/Unit/Session/MapPlacementSessionTest.php b/tests/Unit/Session/MapPlacementSessionTest.php new file mode 100644 index 0000000..9107f6b --- /dev/null +++ b/tests/Unit/Session/MapPlacementSessionTest.php @@ -0,0 +1,492 @@ + 'guide', 'name' => 'Guide', 'x' => 1, 'y' => 1, 'script' => [ + ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'guide', 'wait' => true, 'steps' => [ + ['direction' => 'right', 'count' => 2, 'faceOnly' => false, 'seconds' => 0.25, 'custom' => 'preserved'], + ['direction' => 'up', 'count' => 3, 'faceOnly' => true], + ]], + ['type' => 'transfer', 'map' => 'test-map', 'x' => 2, 'y' => 1], + ]]]; + file_put_contents($path, " ($row['mapPlacement']['kind'] ?? null) === $kind) + ?? throw new RuntimeException('Missing placement ' . $kind); +} + +it('edits NPC route endpoints as one source preserving undo step without changing timing facing or identity', function () { + $root = mapPlacementProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $original = file_get_contents($path); + $session = EditorSession::open($root); + $read = $session->readNpc('test-map', 0, ['script']); + $row = findMapPlacementRow($read, 'route'); + expect($row['mapPlacement']['points'][0]['point'])->toBe([2, 0]) + ->and($row['mapPlacement']['points'][1]['point'])->toBe([2, 0]); + $context = ['kind' => 'npc', 'map' => 'test-map', 'revision' => $read['revision'], 'index' => 0]; + $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'test-map', 0, 1, 3, [1, 1], $read['revision']); + $changed = findMapPlacementRow($session->readNpc('test-map', 0, ['script']), 'route'); + expect($changed['mapPlacement']['points'][0]['point'])->toBe([0, 2]) + ->and($session->undo()['label'])->toBe('Place on map'); + $session->saveMap('test-map'); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $session->saveMap('test-map'); + $saved = require $path; + expect(file_get_contents($path))->toContain('// Authored map note.') + ->and($saved['npcs'][0]['id'])->toBe('guide') + ->and($saved['npcs'][0]['script'][0]['steps'][0])->toMatchArray(['direction' => 'down', 'count' => 2, 'faceOnly' => false, 'seconds' => 0.25, 'custom' => 'preserved']) + ->and(findMapPlacementRow(EditorSession::open($root)->readNpc('test-map', 0, ['script']), 'route')['mapPlacement']['points'][0]['point'])->toBe([0, 2]); +}); + +it('refuses diagonal outside and stale route gestures without changing history', function () { + $session = EditorSession::open(mapPlacementProject()); + $read = $session->readNpc('test-map', 0, ['script']); + $row = findMapPlacementRow($read, 'route'); + $context = ['kind' => 'npc', 'map' => 'test-map', 'revision' => $read['revision'], 'index' => 0]; + expect(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'test-map', 0, 3, 3, [1, 1], $read['revision']))->toThrow(SessionRefusal::class, 'cardinal') + ->and(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'test-map', 0, 99, 1, [1, 1], $read['revision']))->toThrow(SessionRefusal::class, 'inside') + ->and(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'test-map', 0, 2, 1, null, $read['revision']))->toThrow(SessionRefusal::class, 'origin') + ->and($session->undo()['label'])->toBeNull(); + $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'test-map', 1, 5, 1, [1, 1], $read['revision']); + $next = findMapPlacementRow($session->readNpc('test-map', 0, ['script']), 'route'); + expect($next['mapPlacement']['points'][1]['point'])->toBe([2, 0]); + expect(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'test-map', 0, 2, 1, [1, 1], $read['revision']))->toThrow(SessionRefusal::class); +}); + +it('places transfer command coordinates together through shared schema rows', function () { + $session = EditorSession::open(mapPlacementProject()); + $read = $session->readNpc('test-map', 0, ['script']); + $row = findMapPlacementRow($read, 'transfer'); + $session->applyMapPlacement(['kind' => 'npc', 'map' => 'test-map', 'revision' => $read['revision'], 'index' => 0], + $row['key'], $row['mapPlacement'], 'test-map', 0, 4, 3, null, $read['revision']); + expect(findMapPlacementRow($session->readNpc('test-map', 0, ['script']), 'transfer')['mapPlacement']['points'][0]['point'])->toBe([4, 3]); + $session->undo(); + expect(findMapPlacementRow($session->readNpc('test-map', 0, ['script']), 'transfer')['mapPlacement']['points'][0]['point'])->toBe([2, 1]); +}); + +it('places transfer events through the atomic destination service and preserves unknown fields', function () { + $root = mapPlacementProject(); + $session = EditorSession::open($root); + $revision = $session->readMap('test-map')['revision']; + $created = $session->createEvent('test-map', $revision, [[4, 1]], 'Transfer Player'); + $session->saveMap('test-map'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['events'][$created['marker']]['data']['spawnPoint']['annotation'] = 'keep arrival metadata'; + file_put_contents($path, "readInspector('test-map', $created['marker']); + $row = findMapPlacementRow($read, 'event-transfer'); + $session->applyMapPlacement(['kind' => 'event', 'map' => 'test-map', 'revision' => $read['revision']], + $row['key'], $row['mapPlacement'], 'test-map', 0, 5, 2, null, $read['revision']); + expect(findMapPlacementRow($session->readInspector('test-map', $created['marker']), 'event-transfer')['mapPlacement']['points'][0]['point'])->toBe([5, 2]); + $session->undo(); + expect(findMapPlacementRow($session->readInspector('test-map', $created['marker']), 'event-transfer')['mapPlacement'])->toBe($row['mapPlacement']); + $session->saveMap('test-map'); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $session->saveMap('test-map'); + expect(findMapPlacementRow(EditorSession::open($root)->readInspector('test-map', $created['marker']), 'event-transfer')['mapPlacement']['points'][0]['point'])->toBe([5, 2]) + ->and(file_get_contents($path))->toContain('// Keep transfer source notes.', 'keep arrival metadata'); +}); + +it('exposes cinematic map targets but never screen or actor relative coordinates and preserves nested source', function () { + $root = cutsceneProject(); + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, <<<'PHP' + 'sequence', 'commands' => [ + ['type' => 'camera', 'operation' => 'pan', 'target' => ['kind' => 'position', 'x' => 1, 'y' => 1], 'seconds' => 0.5], + ['type' => 'field_animation', 'effect' => 'spark', 'target' => ['kind' => 'position', 'x' => 2, 'y' => 1]], + ['type' => 'field_animation', 'effect' => 'spark', 'target' => ['kind' => 'screen_position', 'x' => 2, 'y' => 1]], + ['type' => 'camera', 'operation' => 'focus', 'target' => ['kind' => 'npc', 'id' => 'guide']], + ['type' => 'camera', 'operation' => 'route', 'points' => [['kind' => 'position', 'x' => 1, 'y' => 1, 'seconds' => 0.25]]], +]]]; +PHP); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $read = $session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands', 0, 'commands']); + $placements = array_values(array_filter($read['rows'], static fn(array $r): bool => isset($r['mapPlacement']))); + expect(array_column(array_column($placements, 'mapPlacement'), 'kind'))->toBe(['camera', 'effect', 'camera']); + $row = $placements[0]; + // Transport object member order is not a source revision. + $key = array_reverse($row['key'], true); + $context = ['kind' => 'database', 'category' => 'cutscenes/cinematic', 'index' => 0]; + $previewRevision = $session->readMap('harbour')['revision']; + $session->applyMapPlacement($context, $key, array_reverse($row['mapPlacement'], true), 'harbour', 0, 3, 2, null, $previewRevision); + expect(findMapPlacementRow($session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands', 0, 'commands']), 'camera')['mapPlacement']['points'][0]['point'])->toBe([3, 2]); + expect(fn() => $session->applyMapPlacement($context, $key, $row['mapPlacement'], 'harbour', 0, 4, 2, null, $previewRevision))->toThrow(SessionRefusal::class, 'changed'); + $session->undo(); + $session->saveDatabase('cutscenes/cinematic'); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $session->saveDatabase('cutscenes/cinematic'); + expect(file_get_contents($path))->toContain('// Keep this script comment.', "'seconds' => 0.5") + ->and(findMapPlacementRow(EditorSession::open($root)->readDatabaseRecord('cutscenes/cinematic', 0, ['commands', 0, 'commands']), 'camera')['mapPlacement']['points'][0]['point'])->toBe([3, 2]); +}); + +it('edits inline event script paths using the same map placement and grouped inspector service', function () { + $root = mapPlacementProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['events'] = ['S' => ['class' => \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, 'data' => [ + 'script' => [['type' => 'move_route', 'subject' => 'player', 'steps' => [['direction' => 'right', 'count' => 1, 'faceOnly' => false, 'seconds' => 0.4]]]], + ]]]; + file_put_contents($path, "readInspector('test-map', 'S'); + $row = findMapPlacementRow($read, 'route'); + $session->applyMapPlacement(['kind' => 'event', 'map' => 'test-map', 'revision' => $read['revision']], + $row['key'], $row['mapPlacement'], 'test-map', 0, 1, 3, [1, 1], $read['revision']); + expect(findMapPlacementRow($session->readInspector('test-map', 'S'), 'route')['mapPlacement']['points'][0]['point'])->toBe([0, 2]); + $session->undo(); + expect(findMapPlacementRow($session->readInspector('test-map', 'S'), 'route')['mapPlacement'])->toBe($row['mapPlacement']); +}); + +it('keeps the existing resize inspector safe for NPCs with undo and redo', function () { + $session = EditorSession::open(mapPlacementProject()); + $read = $session->readInspector('test-map'); + $row = array_find($read['rows'], static fn(array $r): bool => ($r['key']['target'] ?? '') === 'map-size' && ($r['key']['field'] ?? '') === 'width'); + expect($row['kind'])->toBe('integer'); + expect(fn() => $session->applyInspector('test-map', $read['revision'], $row['key'], '1'))->toThrow(SessionRefusal::class, 'stranded'); + $session->applyInspector('test-map', $read['revision'], $row['key'], '15'); + expect($session->readMap('test-map')['width'])->toBe(15); + $session->undo(); + expect($session->readMap('test-map')['width'])->toBe(12); + $session->redo(); + expect($session->readMap('test-map')['width'])->toBe(15); +}); + +it('does not flatten expression owned coordinates or retain half of a grouped edit', function () { + $root = makeTemporaryProject('ichiloto-map-placement-expression-'); + $path = $root . '/assets/Events/placement-expression.php'; + file_put_contents($path, <<<'PHP' + 'transfer', 'map' => 'test-map', 'x' => 2, 'y' => abs($arrival)]]; +PHP); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $labels = $session->listDatabaseRecords('common_events')['records']; + $index = array_search('placement-expression', $labels, true); + expect($index)->not->toBeFalse(); + $row = findMapPlacementRow($session->readDatabaseRecord('common_events', $index), 'transfer'); + expect(fn() => $session->applyMapPlacement(['kind' => 'database', 'category' => 'common_events', 'index' => $index], + $row['key'], $row['mapPlacement'], 'test-map', 0, 4, 3, null, $session->readMap('test-map')['revision']))->toThrow(SessionRefusal::class); + expect(findMapPlacementRow($session->readDatabaseRecord('common_events', $index), 'transfer')['mapPlacement'])->toBe($row['mapPlacement']) + ->and($session->undo()['label'])->toBeNull(); + $session->saveDatabase('common_events'); + expect(file_get_contents($path))->toBe($original); +}); + +it('refuses a route edit that would move later authored points outside the preview map', function () { + $root = mapPlacementProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['npcs'][0]['script'][0]['steps'][1] = ['direction' => 'down', 'count' => 2, 'faceOnly' => false]; + file_put_contents($path, "readNpc('test-map', 0, ['script']); + $row = findMapPlacementRow($read, 'route'); + expect(fn() => $session->applyMapPlacement(['kind' => 'npc', 'map' => 'test-map', 'revision' => $read['revision'], 'index' => 0], + $row['key'], $row['mapPlacement'], 'test-map', 0, 1, 4, [1, 1], $read['revision']))->toThrow(SessionRefusal::class, 'Step 2 would leave') + ->and($session->undo()['label'])->toBeNull(); +}); + +it('round trips common event route placement over the host protocol', function () { + $root = makeTemporaryProject('ichiloto-map-placement-protocol-'); + $path = $root . '/assets/Events/placement-route.php'; + file_put_contents($path, <<<'PHP' + 'move_route', 'subject' => 'player', 'steps' => [ + ['direction' => 'right', 'count' => 1, 'faceOnly' => false, 'seconds' => 0.3, 'annotation' => 'keep'], +]]]; +PHP); + $original = file_get_contents($path); + $host = new \Ichiloto\Editor\Session\SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params = []): array => $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params])); + $request('hello', ['protocol' => \Ichiloto\Editor\Session\SessionHost::PROTOCOL, 'project' => $root]); + $index = array_search('placement-route', $request('database.records', ['category' => 'common_events'])['result']['records'], true); + expect($index)->not->toBeFalse(); + $record = ['category' => 'common_events', 'index' => $index]; + $row = findMapPlacementRow($request('database.record', $record)['result'], 'route'); + $gesture = ['context' => ['kind' => 'database', ...$record], 'key' => $row['key'], 'expected' => $row['mapPlacement'], + 'previewMap' => 'test-map', 'previewRevision' => $request('map.read', ['map' => 'test-map'])['result']['revision'], + 'point' => 0, 'x' => 1, 'y' => 3, 'origin' => [1, 1]]; + expect($request('mapPlacement.apply', [...$gesture, 'origin' => ['1', 1]])['error']['kind'])->toBe('request') + ->and($request('mapPlacement.apply', [...$gesture, 'previewRevision' => '0'])['error']['kind'])->toBe('request') + ->and($request('mapPlacement.apply', array_diff_key($gesture, ['previewRevision' => true]))['error']['kind'])->toBe('request') + ->and($request('mapPlacement.apply', $gesture)['result']['changed'])->toBeTrue() + ->and($request('history.undo')['result']['label'])->toBe('Place on map'); + $request('database.save', ['category' => 'common_events']); + expect(file_get_contents($path))->toBe($original); + $request('history.redo'); + $request('database.save', ['category' => 'common_events']); + $saved = require $path; + expect($saved[0]['steps'][0])->toMatchArray(['direction' => 'down', 'count' => 2, 'seconds' => 0.3, 'annotation' => 'keep']) + ->and(file_get_contents($path))->toContain('// Keep route timing and annotations.') + ->and(findMapPlacementRow(EditorSession::open($root)->readDatabaseRecord('common_events', $index), 'route')['mapPlacement']['points'][0]['point'])->toBe([0, 2]); +}); + +it('edits one camera route point without shifting other absolute targets or inventing actor relative geometry', function () { + $root = cutsceneProject(); + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, <<<'PHP' +'camera', 'operation'=>'route', 'points'=>[ + ['kind'=>'position', 'x'=>1, 'y'=>1, 'seconds'=>0.2], + ['kind'=>'position', 'x'=>2, 'y'=>1, 'seconds'=>0.3], + ['kind'=>'npc', 'id'=>'guide', 'seconds'=>0.4], + ['kind'=>'position', 'x'=>4, 'y'=>2, 'seconds'=>0.5], +]]]; +PHP); + $session = EditorSession::open($root); + $rows = array_values(array_filter($session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands'])['rows'], + static fn(array $row): bool => isset($row['mapPlacement']))); + expect($rows)->toHaveCount(3) + ->and($rows[1]['mapPlacement']['selectedPoint'])->toBe(1) + ->and(array_column($rows[1]['mapPlacement']['points'], 'point'))->toBe([[1, 1], [2, 1], [4, 2]]) + ->and($rows[1]['mapPlacement']['points'][1]['from'])->toBe([1, 1]) + ->and($rows[1]['mapPlacement']['points'][2]['from'])->toBeNull(); + $session->applyMapPlacement(['kind'=>'database', 'category'=>'cutscenes/cinematic', 'index'=>0], + $rows[1]['key'], $rows[1]['mapPlacement'], 'harbour', 1, 3, 2, null, $session->readMap('harbour')['revision']); + $session->saveDatabase('cutscenes/cinematic'); + $saved = require $path; + expect($saved[0]['points'])->toBe([ + ['kind'=>'position', 'x'=>1, 'y'=>1, 'seconds'=>0.2], + ['kind'=>'position', 'x'=>3, 'y'=>2, 'seconds'=>0.3], + ['kind'=>'npc', 'id'=>'guide', 'seconds'=>0.4], + ['kind'=>'position', 'x'=>4, 'y'=>2, 'seconds'=>0.5], + ]); +}); + +it('refuses incomplete or wrongly typed map axes without defaulting them to another cell', function (mixed $x, mixed $y) { + $fields = [ + ['entry'=>[0], 'sourceKey'=>'target.x', 'field'=>'x', 'dataPath'=>['target', 'x']], + ['entry'=>[0], 'sourceKey'=>'target.y', 'field'=>'y', 'dataPath'=>['target', 'y']], + ]; + $fields = \Ichiloto\Editor\Maps\MapPlacement::describeFields(['type'=>'field_animation', 'target'=>['kind'=>'position', 'x'=>$x, 'y'=>$y]], $fields); + $placement = $fields[0]['mapPlacement']; + expect($placement['issue'])->toContain('explicit non-negative integer'); + expect(fn() => \Ichiloto\Editor\Maps\MapPlacement::getChanges($placement, 0, 2, 1, null, 'synthetic')) + ->toThrow(InvalidArgumentException::class, 'explicit non-negative integer'); +})->with([[null, 1], [1, null], [1.5, 1], ['1', 1], [-1, 1]]); + +it('refuses current cinematic target expressions even when the evaluated coordinate or kind would not change', function (string $command, string $kind) { + $root = cutsceneProject(); + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, "readDatabaseRecord('cutscenes/cinematic', 0, ['commands']), $kind); + expect($row['mapPlacement']['issue'])->toContain('literal source'); + expect(fn() => $session->applyMapPlacement(['kind'=>'database', 'category'=>'cutscenes/cinematic', 'index'=>0], + $row['key'], $row['mapPlacement'], 'harbour', 0, 3, 1, null, $session->readMap('harbour')['revision']))->toThrow(SessionRefusal::class, 'literal source'); + expect($session->undo()['label'])->toBeNull(); + $session->saveDatabase('cutscenes/cinematic'); + expect(file_get_contents($path))->toBe($original); +})->with([ + 'camera unchanged axis' => ["['type'=>'camera','operation'=>'pan','target'=>['kind'=>'position','x'=>2,'y'=>abs(1)],'seconds'=>0.5]", 'camera'], + 'camera route axis' => ["['type'=>'camera','operation'=>'route','points'=>[['kind'=>'position','x'=>2,'y'=>abs(1),'seconds'=>0.5]]]", 'camera'], + 'field effect unchanged axis' => ["['type'=>'field_animation','effect'=>'spark','target'=>['kind'=>'position','x'=>2,'y'=>abs(1)]]", 'effect'], + 'field effect expression kind' => ["['type'=>'field_animation','effect'=>'spark','target'=>['kind'=>strtolower('POSITION'),'x'=>2,'y'=>1]]", 'effect'], +]); + +it('does not record unchanged inline placement values as a new undo step', function () { + $root = mapPlacementProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['events']['S'] = ['class'=>\Ichiloto\Engine\Events\Triggers\StoryTrigger::class, + 'data'=>['script'=>[['type'=>'transfer', 'map'=>'test-map', 'x'=>2, 'y'=>1]]]]; + file_put_contents($path, "readInspector('test-map', 'S'); + $row = findMapPlacementRow($read, 'transfer'); + $result = $session->applyMapPlacement(['kind'=>'event', 'map'=>'test-map', 'revision'=>$read['revision']], + $row['key'], $row['mapPlacement'], 'test-map', 0, 2, 1, null, $read['revision']); + expect($result['changed'])->toBeFalse()->and($result['revision'])->toBe($read['revision']) + ->and($session->undo()['label'])->toBeNull(); +}); + +it('places authored waypoints and retains runtime owned retrace geometry', function () { + foreach ([['waypoints' => [['x' => 2, 'y' => 1]]], ['retrace' => 'remembered-route']] as $mode) { + $root = mapPlacementProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['npcs'][0]['script'][0] = ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'guide', ...$mode]; + file_put_contents($path, "readNpc('test-map', 0, ['script']); + $row = findMapPlacementRow($read, isset($mode['waypoints']) ? 'waypoints' : 'route'); + if (isset($mode['waypoints'])) { + expect($row['mapPlacement']['issue'])->toBeNull(); + $session->applyMapPlacement(['kind' => 'npc', 'map' => 'test-map', 'revision' => $read['revision'], 'index' => 0], + $row['key'], $row['mapPlacement'], 'test-map', 0, 3, 2, null, $read['revision']); + expect(findMapPlacementRow($session->readNpc('test-map', 0, ['script']), 'waypoints')['mapPlacement']['points'][0]['point'])->toBe([3, 2]); + $session->undo(); + } else { + expect($row['mapPlacement']['issue'])->toContain('completed runtime route record') + ->and($row['mapPlacement']['points'])->toBe([]) + ->and(array_find($read['rows'], static fn(array $r): bool => ($r['reference'] ?? '') === 'cinematic_movement_routes'))->not->toBeNull() + ->and(fn() => $session->applyMapPlacement(['kind' => 'npc', 'map' => 'test-map', 'revision' => $read['revision'], 'index' => 0], + $row['key'], $row['mapPlacement'], 'test-map', 0, 2, 1, [1, 1], $read['revision']))->toThrow(SessionRefusal::class, 'completed runtime route record') + ->and($session->undo()['label'])->toBeNull(); + } + $session->saveMap('test-map'); + expect(file_get_contents($path))->toBe($original); + } +}); + +it('refuses type changed descriptors and keys but accepts reordered object members', function () { + $session = EditorSession::open(mapPlacementProject()); + $read = $session->readNpc('test-map', 0, ['script']); + $row = findMapPlacementRow($read, 'transfer'); + $context = ['kind' => 'npc', 'map' => 'test-map', 'revision' => $read['revision'], 'index' => 0]; + $expected = $row['mapPlacement']; + $expected['points'][0]['point'][0] = '2'; + expect(fn() => $session->applyMapPlacement($context, $row['key'], $expected, 'test-map', 0, 4, 3, null, $read['revision'])) + ->toThrow(SessionRefusal::class, 'changed'); + $key = $row['key']; + $key['frame'] = ['script', '0']; + expect(fn() => $session->applyMapPlacement($context, $key, $row['mapPlacement'], 'test-map', 0, 4, 3, null, $read['revision'])) + ->toThrow(SessionRefusal::class); + expect($session->undo()['label'])->toBeNull(); + $session->applyMapPlacement($context, array_reverse($row['key'], true), array_reverse($row['mapPlacement'], true), + 'test-map', 0, 4, 3, null, $read['revision']); + expect(findMapPlacementRow($session->readNpc('test-map', 0, ['script']), 'transfer')['mapPlacement']['points'][0]['point'])->toBe([4, 3]); +}); + +it('refuses stale preview map revisions independently of the database owner', function () { + $root = cutsceneProject(); + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, "'move_route','subject'=>'player','steps'=>[['direction'=>'right','count'=>1]]]];\n"); + $session = EditorSession::open($root); + $read = $session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands']); + $row = findMapPlacementRow($read, 'route'); + $context = ['kind' => 'database', 'category' => 'cutscenes/cinematic', 'index' => 0]; + $map = $session->readMap('harbour'); + $inspector = $session->readInspector('harbour'); + $width = array_find($inspector['rows'], static fn(array $row): bool => ($row['key']['target'] ?? '') === 'map-size' && ($row['key']['field'] ?? '') === 'width'); + $session->applyInspector('harbour', $map['revision'], $width['key'], (string) ($map['width'] + 1)); + expect(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'harbour', 0, 3, 2, [1, 1], $map['revision'])) + ->toThrow(SessionRefusal::class, 'changed since revision'); + expect(findMapPlacementRow($session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands']), 'route')['mapPlacement'])->toBe($row['mapPlacement']); + expect($session->undo()['label'])->not->toBe('Place on map'); +}); + +it('refuses an old database review after a timing edit and undo even when geometry matches again', function () { + $root = cutsceneProject(); + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, "'move_route','subject'=>'player','steps'=>[['direction'=>'right','count'=>1,'seconds'=>0.4]]]];\n"); + $session = EditorSession::open($root); + $read = $session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands']); + $row = findMapPlacementRow($read, 'route'); + $timing = array_find($read['rows'], static fn(array $row): bool => ($row['name'] ?? '') === 'Seconds'); + expect($timing)->not->toBeNull(); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $timing['key'], '0.8'); + $session->undo(); + expect(fn() => $session->applyMapPlacement(['kind'=>'database', 'category'=>'cutscenes/cinematic', 'index'=>0], + $row['key'], $row['mapPlacement'], 'harbour', 0, 3, 1, [1, 1], $session->readMap('harbour')['revision'])) + ->toThrow(SessionRefusal::class, 'changed'); + $session->saveDatabase('cutscenes/cinematic'); + expect(file_get_contents($path))->toContain("'seconds'=>0.4"); +}); + +it('refuses unchanged expression coordinates before any route or target mutation', function (string $owner, string $command, string $kind) { + $root = mapPlacementProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + if ($owner === 'database') { + $path = $root . '/assets/Events/placement-source.php'; + file_put_contents($path, " \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, 'data' => ['script' => ['COMMAND_SOURCE']]]; + } + file_put_contents($path, " $owner, 'map' => 'test-map', 'revision' => $session->readMap('test-map')['revision'], 'index' => 0]; + if ($owner === 'database') { + $index = array_search('placement-source', $session->listDatabaseRecords('common_events')['records'], true); + $context = ['kind' => 'database', 'category' => 'common_events', 'index' => $index]; + $read = $session->readDatabaseRecord('common_events', $index); + } else { + $read = $owner === 'npc' ? $session->readNpc('test-map', 0, ['script']) : $session->readInspector('test-map', 'S'); + } + $row = findMapPlacementRow($read, $kind); + expect($row['mapPlacement']['issue'])->toContain('literal source'); + expect(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'test-map', 0, 4, 1, + $kind === 'route' ? [1, 1] : null, $session->readMap('test-map')['revision']))->toThrow(SessionRefusal::class, 'literal source'); + expect($session->undo()['label'])->toBeNull(); + $owner === 'database' ? $session->saveDatabase('common_events') : $session->saveMap('test-map'); + expect(file_get_contents($path))->toBe($original); +})->with([ + 'NPC transfer unchanged y' => ['npc', "['type'=>'transfer','map'=>'test-map','x'=>2,'y'=>abs(1)]", 'transfer'], + 'inline transfer unchanged y' => ['event', "['type'=>'transfer','map'=>'test-map','x'=>2,'y'=>abs(1)]", 'transfer'], + 'common transfer unchanged y' => ['database', "['type'=>'transfer','map'=>'test-map','x'=>2,'y'=>abs(1)]", 'transfer'], + 'NPC route count' => ['npc', "['type'=>'move_route','subject'=>'player','steps'=>[['direction'=>'right','count'=>abs(1)]]]", 'route'], +]); + +it('refuses an external literal or expression rewrite rather than authoring over a stale source review', function (bool $expression) { + $root = mapPlacementProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $session = EditorSession::open($root); + $read = $session->readNpc('test-map', 0, ['script']); + $row = findMapPlacementRow($read, 'transfer'); + file_put_contents($path, str_replace("'x' => 2", $expression ? "'x' => abs(2)" : "'x' => 3", file_get_contents($path))); + $external = file_get_contents($path); + expect(fn() => $session->applyMapPlacement(['kind'=>'npc', 'map'=>'test-map', 'revision'=>$read['revision'], 'index'=>0], + $row['key'], $row['mapPlacement'], 'test-map', 0, 4, 3, null, $read['revision']))->toThrow(SessionRefusal::class, 'changed'); + expect($session->undo()['label'])->toBeNull()->and(file_get_contents($path))->toBe($external); +})->with([false, true]); + +it('round trips every cinematic map target with grouped undo and unchanged terminal map geometry', function (array $command, string $kind, array $frame) { + $root = cutsceneProject(); + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, "readDatabaseRecord('cutscenes/cinematic', 0, $frame), $kind); + $session->applyMapPlacement(['kind'=>'database', 'category'=>'cutscenes/cinematic', 'index'=>0], + $row['key'], $row['mapPlacement'], 'harbour', 0, 3, 2, null, $session->readMap('harbour')['revision']); + expect($session->undo()['label'])->toBe('Place on map'); + $session->saveDatabase('cutscenes/cinematic'); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $session->saveDatabase('cutscenes/cinematic'); + $reopened = EditorSession::open($root); + expect(findMapPlacementRow($reopened->readDatabaseRecord('cutscenes/cinematic', 0, $frame), $kind)['mapPlacement']['points'][0]['point'])->toBe([3, 2]) + ->and(file_get_contents($path))->toContain('// Preserve this author note.', "'annotation' => 'keep'") + ->and(sourceHashTree($root . '/assets/Maps'))->toBe($mapSources); +})->with([ + 'camera pan' => [['type'=>'camera', 'operation'=>'pan', 'target'=>['kind'=>'position', 'x'=>1, 'y'=>1], 'seconds'=>0.5, 'annotation'=>'keep'], 'camera', ['commands']], + 'camera route point' => [['type'=>'camera', 'operation'=>'route', 'points'=>[['kind'=>'position', 'x'=>1, 'y'=>1, 'seconds'=>0.25, 'annotation'=>'keep']]], 'camera', ['commands']], + 'field effect map target' => [['type'=>'field_animation', 'effect'=>'spark', 'target'=>['kind'=>'position', 'x'=>1, 'y'=>1], 'annotation'=>'keep'], 'effect', ['commands']], + 'nested effect target' => [['type'=>'branch', 'conditions'=>[], 'then'=>[['type'=>'field_animation', 'effect'=>'spark', 'target'=>['kind'=>'position', 'x'=>1, 'y'=>1], 'annotation'=>'keep']]], 'effect', ['commands', 0, 'then']], + 'transfer target' => [['type'=>'transfer', 'map'=>'harbour', 'x'=>1, 'y'=>1, 'annotation'=>'keep'], 'transfer', ['commands']], + 'move player target' => [['type'=>'move_player', 'x'=>1, 'y'=>1, 'annotation'=>'keep'], 'position', ['commands']], +]); diff --git a/tests/Unit/Session/NpcFieldSheetSessionTest.php b/tests/Unit/Session/NpcFieldSheetSessionTest.php index aa12910..e734bbd 100644 --- a/tests/Unit/Session/NpcFieldSheetSessionTest.php +++ b/tests/Unit/Session/NpcFieldSheetSessionTest.php @@ -4,6 +4,8 @@ use Ichiloto\Editor\Session\EditorSession; use Ichiloto\Editor\Session\SessionRefusal; +use Ichiloto\Editor\Field\NpcInspector; +use Ichiloto\Editor\ProjectWorkspace; /** * An NPC's graphical character sheet, set from the graphical editor's NPC @@ -28,6 +30,47 @@ function findFieldSheetRow(EditorSession $session, int $index): ?array return array_find($session->readNpc('test-map', $index)['rows'], static fn(array $row): bool => $row['label'] === 'Field Sheet'); } +/** A literal sheet alongside authored expressions, terminal headings and an independent neighbor. */ +function createNpcSheetAuthoringProject(string $asset = 'People.png', int $index = 2): array +{ + $root = mapGraphicsProject(); + writeTilesetTestPng($root . '/assets/Graphics/Characters/People.png', 48, 32); + writeTilesetTestPng($root . '/assets/Graphics/Characters/$Guard.png', 12, 16); + file_put_contents($root . '/assets/Maps/collisions.php', <<<'PHP' + CollisionType::NONE, 'x' => CollisionType::PASS_THROUGH, + 'buildings' => ['/' => CollisionType::NONE]]; +PHP); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $source = " 'Test Map', 'events' => [], 'tileset' => 'home', + 'futureValue' => abs(7), + 'npcs' => [ + ['id' => 'guide', 'name' => 'Guide', 'x' => 2, 'y' => 0, + 'sprite' => 'G', 'sprites' => ['north' => '^', 'south' => 'v', 'east' => '>', 'west' => '<'], + 'sprites2d' => ['sheet' => 'Graphics/Characters/$asset', /* chosen character */ 'index' => $index, 'layer' => 100]], + ['id' => 'neighbor', 'name' => 'Neighbor', 'sprite' => 'N', 'x' => 3, 'y' => 1], + ], + ];\n"; + file_put_contents($path, $source); + + return [$root, $path, EditorSession::open($root)]; +} + +function getNpcSheetSettingRow(EditorSession $session, string $field): ?array +{ + return array_find($session->readNpc('test-map', 0)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === 'sprites2d.' . $field); +} + +function setNpcSheetSetting(EditorSession $session, string $field, string $value): array +{ + $row = getNpcSheetSettingRow($session, $field) ?? throw new RuntimeException('Missing sheet setting ' . $field); + + return $session->applyNpc('test-map', $session->readMap('test-map')['revision'], 0, $row['key'], $value); +} + it('shows the field sheet as a picture to pick, after the terminal appearance', function () { [$session, $index] = npcFieldSheetSession(); $row = findFieldSheetRow($session, $index); @@ -62,3 +105,151 @@ function findFieldSheetRow(EditorSession $session, int $index): ?array ->toThrow(SessionRefusal::class) ->and(findFieldSheetRow($session, $index)['value'])->toBe(''); }); + +it('authors sheet index and graphical layer through actual session history and source-preserving save reopen', function () { + [$root, $path, $session] = createNpcSheetAuthoringProject(); + $source = file_get_contents($path); + $before = require $path; + $map = loadLayeredMap($root); + $terminal = $map->renderPreview(20, 10); + $dictionary = require $root . '/assets/Maps/collisions.php'; + $collision = \Ichiloto\Engine\Field\MapCollisionResolver::resolveLayers($map->getLayerSet(), $dictionary); + $grids = sourceHashTree($root . '/assets/Maps/test-map/layers'); + $row = getNpcSheetSettingRow($session, 'index'); + expect($row)->toMatchArray(['kind' => 'options', 'options' => ['0', '1', '2', '3', '4', '5', '6', '7'], 'value' => '2']) + ->and(getNpcSheetSettingRow($session, 'layer'))->toMatchArray(['kind' => 'integer', 'value' => '100']); + setNpcSheetSetting($session, 'index', '6'); + expect(getNpcSheetSettingRow($session, 'sheet')['imagePreview']['frames'][0]['sourceRect']) + ->toBe(['x' => 28, 'y' => 16, 'width' => 4, 'height' => 4]); + setNpcSheetSetting($session, 'layer', '999'); + expect(file_get_contents($path))->toBe($source); + $session->undo(); + expect(getNpcSheetSettingRow($session, 'layer')['value'])->toBe('100'); + $session->undo(); + expect(getNpcSheetSettingRow($session, 'index')['value'])->toBe('2'); + $session->redo(); + $session->redo(); + expect(setNpcSheetSetting($session, 'index', '6')['changed'])->toBeFalse(); + $session->saveMap('test-map'); + expect(file_get_contents($path))->toBe(str_replace(["'index' => 2", "'layer' => 100"], ["'index' => 6", "'layer' => 999"], $source)); + $saved = require $path; + expect($saved['npcs'][0]['sprites2d'])->toBe(['sheet' => 'Graphics/Characters/People.png', 'index' => 6, 'layer' => 999]); + unset($saved['npcs'][0]['sprites2d'], $before['npcs'][0]['sprites2d']); + expect($saved)->toBe($before) + ->and(sourceHashTree($root . '/assets/Maps/test-map/layers'))->toBe($grids); + $map = loadLayeredMap($root); + expect($map->renderPreview(20, 10))->toBe($terminal) + ->and(\Ichiloto\Engine\Field\MapCollisionResolver::resolveLayers($map->getLayerSet(), $dictionary))->toBe($collision); + $reopened = EditorSession::open($root); + expect(getNpcSheetSettingRow($reopened, 'index')['value'])->toBe('6') + ->and(getNpcSheetSettingRow($reopened, 'layer')['value'])->toBe('999') + ->and(getNpcSheetSettingRow($reopened, 'sheet')['imagePreview'])->toBe(getNpcSheetSettingRow($session, 'sheet')['imagePreview']); + $inspector = new NpcInspector(ProjectWorkspace::fromProject($root)->getMapByIndex(0)); + expect(json_encode($inspector->getFields(0)))->not->toContain('sprites2d', 'imagePreview'); +}); + +it('removes preview and dependent controls on clear and restores them with their exact settings on undo', function () { + [$root, $path, $session] = createNpcSheetAuthoringProject(); + $sheet = getNpcSheetSettingRow($session, 'sheet'); + $indexKey = getNpcSheetSettingRow($session, 'index')['key']; + $layerKey = getNpcSheetSettingRow($session, 'layer')['key']; + setNpcSheetSetting($session, 'sheet', ''); + expect(getNpcSheetSettingRow($session, 'sheet'))->not->toHaveKey('imagePreview') + ->and(getNpcSheetSettingRow($session, 'index'))->toBeNull() + ->and(getNpcSheetSettingRow($session, 'layer'))->toBeNull(); + $source = sourceHashTree($root); + foreach ([$indexKey, $layerKey] as $key) { + expect(fn() => $session->applyNpc('test-map', $session->readMap('test-map')['revision'], 0, $key, '0')) + ->toThrow(SessionRefusal::class); + } + expect(sourceHashTree($root))->toBe($source); + $session->undo(); + expect(getNpcSheetSettingRow($session, 'sheet'))->toBe($sheet) + ->and(getNpcSheetSettingRow($session, 'index')['value'])->toBe('2') + ->and(getNpcSheetSettingRow($session, 'layer')['value'])->toBe('100'); + $session->redo(); + $session->saveMap('test-map'); + expect((require $path)['npcs'][0])->not->toHaveKey('sprites2d'); + $session->undo(); + $session->saveMap('test-map'); + expect(getNpcSheetSettingRow(EditorSession::open($root), 'sheet'))->toBe($sheet); +}); + +it('constrains single-character selection and refuses invalid values without touching source state or history', function (string $asset, int $index, string $field, string $value) { + [$root, $path, $session] = createNpcSheetAuthoringProject($asset, $index); + $before = $session->readNpc('test-map', 0); + $files = sourceHashTree($root); + expect(getNpcSheetSettingRow($session, 'index')['options'])->toBe($asset === '$Guard.png' ? ['0'] : ['0', '1', '2', '3', '4', '5', '6', '7']) + ->and(fn() => setNpcSheetSetting($session, $field, $value))->toThrow(SessionRefusal::class) + ->and($session->readNpc('test-map', 0))->toBe($before) + ->and($session->listUnsavedChanges())->toBe([]) + ->and($session->undo()['label'])->toBeNull() + ->and(sourceHashTree($root))->toBe($files); +})->with([ + ['$Guard.png', 0, 'index', '1'], ['People.png', 2, 'index', '8'], ['People.png', 2, 'index', '-1'], + ['People.png', 2, 'index', '1.5'], ['People.png', 2, 'index', ''], + ['People.png', 2, 'layer', '-1'], ['People.png', 2, 'layer', '1000'], + ['People.png', 2, 'layer', '2147483648'], ['People.png', 2, 'layer', 'no'], +]); + +it('never silently resets the current index or layer when replacing a sheet', function () { + [$root, $path, $session] = createNpcSheetAuthoringProject(); + $before = $session->readNpc('test-map', 0); + expect(fn() => setNpcSheetSetting($session, 'sheet', 'Graphics/Characters/$Guard.png'))->toThrow(SessionRefusal::class) + ->and($session->readNpc('test-map', 0))->toBe($before); + setNpcSheetSetting($session, 'index', '0'); + setNpcSheetSetting($session, 'sheet', 'Graphics/Characters/$Guard.png'); + expect(getNpcSheetSettingRow($session, 'index')['options'])->toBe(['0']) + ->and(getNpcSheetSettingRow($session, 'layer')['value'])->toBe('100'); +}); + +it('reads fresh current sheet dimensions and reports unavailable previews without mutating NPC data', function (string $replacement) { + [$root, $path, $session] = createNpcSheetAuthoringProject(); + $original = getNpcSheetSettingRow($session, 'sheet'); + $npc = $session->readNpc('test-map', 0)['npc']; + $png = $root . '/assets/Graphics/Characters/People.png'; + if ($replacement === 'valid') { + writeTilesetTestPng($png, 96, 64); + } elseif ($replacement === 'uneven') { + writeTilesetTestPng($png, 50, 50); + } elseif ($replacement === 'malformed') { + file_put_contents($png, 'not a PNG'); + } else { + unlink($png); + } + $files = sourceHashTree($root); + $preview = getNpcSheetSettingRow($session, 'sheet')['imagePreview']; + if ($replacement === 'valid') { + expect($preview)->not->toHaveKey('issue') + ->and($preview['frames'][0]['sourceRect'])->toBe(['x' => 56, 'y' => 0, 'width' => 8, 'height' => 8]); + } else { + expect($preview['frames'])->toBe([])->and($preview['issue'])->not->toBeEmpty(); + expect(fn() => setNpcSheetSetting($session, 'layer', '101'))->toThrow(SessionRefusal::class); + } + expect($session->readNpc('test-map', 0)['npc'])->toBe($npc) + ->and(getNpcSheetSettingRow($session, 'index')['value'])->toBe('2') + ->and(getNpcSheetSettingRow($session, 'sheet')['value'])->toBe($original['value']) + ->and($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($files); +})->with(['valid', 'uneven', 'malformed', 'missing']); + +it('refuses stale revisions stale external source and uneditable sheet source before mutation', function (string $mode) { + [$root, $path, $session] = createNpcSheetAuthoringProject(); + $key = getNpcSheetSettingRow($session, 'index')['key']; + $revision = $session->readMap('test-map')['revision']; + if ($mode === 'revision') { + setNpcSheetSetting($session, 'layer', '101'); + } elseif ($mode === 'external') { + file_put_contents($path, file_get_contents($path) . "\n// Other author.\n"); + } else { + file_put_contents($path, str_replace("'index' => 2", "'index' => abs(2)", file_get_contents($path))); + $session = EditorSession::open($root); + $revision = $session->readMap('test-map')['revision']; + } + $files = sourceHashTree($root); + expect(fn() => $session->applyNpc('test-map', $revision, 0, $key, '6'))->toThrow(SessionRefusal::class) + ->and(sourceHashTree($root))->toBe($files); + if ($mode !== 'external') { + expect(getNpcSheetSettingRow($session, 'index')['value'])->toBe('2'); + } + expect($session->undo()['label'])->toBe($mode === 'revision' ? 'NPC field sheet layer' : null); +})->with(['revision', 'external', 'expression']); diff --git a/tests/Unit/Session/SummonStageSessionTest.php b/tests/Unit/Session/SummonStageSessionTest.php index 7e77c4b..683b4cd 100644 --- a/tests/Unit/Session/SummonStageSessionTest.php +++ b/tests/Unit/Session/SummonStageSessionTest.php @@ -126,6 +126,7 @@ function stageRows(EditorSession $session, array $frame = []): array $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical'); $timeline = $session->describeCutsceneTimeline('cutscenes/summon', 0); expect($timeline['canHaveStage'])->toBeTrue() + ->and($timeline['tracks'][0]['art']['pivotDefaults'])->toBe(['battle' => ['x' => 0.5, 'y' => 0.5]]) ->and($timeline['stage'])->toBeNull(); $session->setCutsceneStage('cutscenes/summon', 0, true); @@ -148,6 +149,7 @@ function stageRows(EditorSession $session, array $frame = []): array ->and($timeline['tracks'][0]['art'])->toMatchArray(['anchor' => 'stage', 'subject' => 'subject', 'placement' => ['position' => ['x' => 0.0, 'y' => 0.0], 'size' => null, 'pivot' => ['x' => 0.5, 'y' => 0.5]]]) ->and($timeline['tracks'][0]['art'])->not->toHaveKey('attachmentOptions') + ->and($timeline['tracks'][0]['art']['pivotDefaults'])->toBe(['stage' => ['x' => 0.5, 'y' => 0.5]]) ->and($timeline['tracks'][0]['keyframes'][0]['offset'])->toBe(['x' => 4.0, 'y' => -2.5]); // The terminal sequence never has one. diff --git a/tests/Unit/Session/TilesetAuthoringSessionTest.php b/tests/Unit/Session/TilesetAuthoringSessionTest.php new file mode 100644 index 0000000..cf48fd3 --- /dev/null +++ b/tests/Unit/Session/TilesetAuthoringSessionTest.php @@ -0,0 +1,140 @@ + 'Fence', 'layer' => 'buildings', 'connects' => TilesetPiece::LINES, + 'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'], + 'tiles' => ['walls' => '2816', 'trim' => ['horizontal' => '5', 'vertical' => '6', 'corner' => '7']]]; +} + +it('previews every cardinal neighbourhood using shared piece shaping and Engine autotile resolution', function () { + $piece = TilesetPiece::fromArray('fence', connectedAuthoringPiece(), 'Test'); + $samples = ConnectedPiecePreview::buildSamples($piece); + expect(array_column($samples, 'mask'))->toBe(range(0, 15)); + foreach ($samples as $sample) { + $mask = $sample['mask']; + $shape = $piece->getLineShape((bool) ($mask & 1), (bool) ($mask & 2), (bool) ($mask & 4), (bool) ($mask & 8)); + $drawn = $sample['piece']; + expect($sample['shape'])->toBe($shape) + ->and($drawn->getSourceGrid()[2][2])->toBe($piece->getSourceShapeGrid()[$shape]) + ->and($drawn->tiles['trim'][2][2])->toBe($piece->shapeTiles['trim'][$shape]) + ->and($drawn->glyphs[0])->toBe(array_fill(0, 5, ' ')) + ->and($drawn->glyphs[4])->toBe(array_fill(0, 5, ' ')); + $raw = array_map(static fn(array $row): array => array_map(static fn(string $glyph): int => $glyph === ' ' ? 0 : 2816, $row), $drawn->glyphs); + expect(array_map(static fn(array $row): array => array_map(intval(...), $row), $drawn->tiles['walls'])) + ->toBe(AutotileShape::resolveLayer($raw)); + } + expect($samples[0]['piece']->tiles['walls'][2][2])->not->toBe($samples[15]['piece']->tiles['walls'][2][2]); +}); + +it('exports canonical shapes and live record keys for sheet authoring including incomplete tile rows', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: ['fence' => connectedAuthoringPiece(), + 'rug' => ['name' => 'Rug', 'layer' => 'fixtures', 'glyphs' => [' ', ' '], 'tiles' => ['rugs' => ['0']]]]); + $session = EditorSession::open($root); + $preview = $session->readTilesetPreview(0); + expect($preview['issue'])->toContain('must match its glyphs') + ->and($preview['connectionShapes'])->toBe(TilesetPiece::LINE_SHAPES) + ->and($preview['authoring'][1])->toMatchArray(['id' => 'rug', 'width' => 2, 'height' => 2, 'issue' => null]); + $layer = $preview['authoring'][1]['layers'][0]; + $session->applyDatabaseRecord('tilesets', 0, $layer['key'], "0 5\n8 9"); + $repaired = $session->readTilesetPreview(0); + expect($repaired['issue'])->toBeNull() + ->and($repaired['pieces'][0]['connections'])->toHaveCount(16) + ->and($repaired['pieces'][0]['connections'][0]['operations'])->not->toBeEmpty() + ->and($repaired['pieces'][1]['connections'])->toBe([]); +}); + +it('assigns sheet identities through generic record edits with undo redo and source preserving save reload', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: ['fence' => connectedAuthoringPiece(), + 'rug' => ['name' => 'Rug', 'layer' => 'fixtures', 'glyphs' => [' ', ' '], + 'tiles' => ['rugs' => ['1 2', '3 4'], 'details' => ['0 0', '0 5']]]]); + $path = $root . '/assets/Data/Tilesets/home.php'; + $original = str_replace("readTilesetPreview(0)['authoring']; + $session->applyDatabaseRecord('tilesets', 0, $authors[1]['layers'][0]['key'], "0 17\n2816 2864"); + $session->applyDatabaseRecord('tilesets', 0, $authors[0]['layers'][1]['key'], 'horizontal: 5, vertical: 42, corner: 7'); + $session->undo(); + expect((array) $session->readTilesetPreview(0)['authoring'][0]['layers'][1]['shapes']) + ->toBe(['horizontal' => '5', 'vertical' => '6', 'corner' => '7']); + $session->undo(); + $session->saveDatabase('tilesets'); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $session->redo(); + $session->saveDatabase('tilesets'); + $saved = (string) file_get_contents($path); + $tileset = Tileset::load($root . '/assets', 'home'); + expect($saved)->toContain('// Preserve authored notes.', '-') + ->and($tileset->pieces['rug']->tiles)->toBe(['rugs' => [['0', '17'], ['2816', '2864']], 'details' => [['0', '0'], ['0', '5']]]) + ->and($tileset->pieces['rug']->glyphs)->toBe([[' ', ' '], [' ', ' ']]) + ->and($tileset->pieces['fence']->shapeTiles['trim'])->toBe(['horizontal' => '5', 'vertical' => '42', 'corner' => '7']) + ->and($tileset->pieces['fence']->shapeTiles['walls'])->toBe(array_fill_keys(TilesetPiece::LINE_SHAPES, '2816')) + ->and(sourceHashTree($root . '/assets/Maps'))->toBe($mapBefore); + $reloaded = EditorSession::open($root)->readTilesetPreview(0); + expect($reloaded['issue'])->toBeNull() + ->and($reloaded['authoring'][0]['id'])->toBe('fence') + ->and($reloaded['pieces'][0]['connections'][5]['shape'])->toBe('vertical'); +}); + +it('creates a piece and graphical layer through the existing add paths without deriving gameplay from artwork', function () { + $root = mapGraphicsProject(); + writeTestTileset($root); + $session = EditorSession::open($root); + $session->addDatabaseItem('tilesets', 0, ['frame' => []]); + $piece = $session->readTilesetPreview(0)['authoring'][0]; + $session->applyDatabaseRecord('tilesets', 0, ['frame' => [], 'field' => 'piece0Id'], 'mat'); + $session->applyDatabaseRecord('tilesets', 0, ['frame' => [], 'field' => 'piece0Glyphs'], " \n "); + $session->addDatabaseItem('tilesets', 0, $piece['key'], true); + $layer = $session->readTilesetPreview(0)['authoring'][0]['layers'][0]; + $session->applyDatabaseRecord('tilesets', 0, $layer['key'], "1 2\n9 10"); + $session->saveDatabase('tilesets'); + $piece = Tileset::load($root . '/assets', 'home')->pieces['mat']; + expect($piece->glyphs)->toBe([[' ', ' '], [' ', ' ']]) + ->and($piece->tiles)->toBe(['tiles' => [['1', '2'], ['9', '10']]]) + ->and($piece->layer)->toBe('fixtures'); +}); + +it('refuses unsupported tileset source edits without rewriting authored code', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: ['fence' => connectedAuthoringPiece()]); + $path = $root . '/assets/Data/Tilesets/home.php'; + $source = (string) file_get_contents($path); + $source = str_replace("'walls' => '2816'", "'walls' => (string) (2800 + 16)", $source); + file_put_contents($path, $source); + $session = EditorSession::open($root); + $key = $session->readTilesetPreview(0)['authoring'][0]['layers'][0]['key']; + expect($key)->toBeArray()->and($session->readTilesetPreview(0)['issue'])->toBeNull(); + expect(fn() => $session->applyDatabaseRecord('tilesets', 0, $key, '2864'))->toThrow(SessionRefusal::class) + ->and(file_get_contents($path))->toBe($source) + ->and($session->listDatabaseRecords('tilesets')['dirty'])->toBeFalse(); +}); + +it('reports unreadable connected artwork while preserving every glyph topology and authoring target', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: ['fence' => connectedAuthoringPiece()]); + unlink($root . '/assets/Graphics/Tilesets/Home_A2.png'); + $preview = EditorSession::open($root)->readTilesetPreview(0); + expect($preview['unreadable'])->not->toBeEmpty() + ->and($preview['pieces'][0]['connections'])->toHaveCount(16) + ->and($preview['authoring'][0]['layers'][0]['key'])->toBeArray(); + foreach ($preview['pieces'][0]['connections'] as $connection) { + expect($connection['issue'])->not->toBeNull() + // The usable B-sheet trim remains visible; a missing A2 sheet must not hide it. + ->and($connection['operations'])->not->toBeEmpty() + ->and($connection['picture'][2][2]['symbol'])->not->toBe(' '); + } +}); diff --git a/tests/Unit/SessionHostTest.php b/tests/Unit/SessionHostTest.php new file mode 100644 index 0000000..956fe8a --- /dev/null +++ b/tests/Unit/SessionHostTest.php @@ -0,0 +1,74 @@ + ['player' => ['destinationMap' => 'harbour', + 'spawnPoint' => ['x' => 4, 'y' => 4], 'spawnSprite' => ['^']]], + ], true) . ';'); + $before = sourceHashTree($root); + $input = fopen('php://memory', 'r'); + $output = fopen('php://memory', 'w+'); + $diagnostics = fopen('php://memory', 'w+'); + $host = new SessionHost($input, $output, $diagnostics); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + $exchange = 'cutscenes.' . $source . 'Scene'; + $start = $source === 'cinematic' ? 'cutscenes.cinematicPreview' : 'cutscenes.effectField'; + $detach = $source === 'cinematic' ? 'cutscenes.cinematicSceneDetach' : 'cutscenes.effectFieldClose'; + + try { + expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result'); + $category = $source === 'cinematic' ? 'cutscenes/cinematic' : 'cutscenes/effect'; + $identity = $source === 'cinematic' ? 'Harbour Lanterns' : 'ember-spark'; + $index = array_search($identity, $request('database.records', ['category' => $category])['result']['records'], true); + expect($index)->toBeInt(); + $startParams = ['index' => $index, 'frame' => 0, 'width' => 40, 'height' => 12]; + expect($request($start, $startParams))->toHaveKey('result'); + $discovery = $request($exchange)['result']; + $sessionId = $discovery['sessionId']; + expect($discovery['messages'])->toBe([]) + ->and($sessionId)->toBeString()->not->toBe(''); + + $ready = $request($exchange, ['sessionId' => $sessionId, 'events' => [previewWindowReady()]]); + expect($ready)->toHaveKey('result') + ->and($ready['result']['grid'])->toBe(['columns' => 40, 'rows' => 12, 'cellWidth' => 10, 'cellHeight' => 20]); + $messages = $ready['result']['messages']; + $generation = max(previewFrameGenerations($messages)); + expect(array_column($messages, 'type'))->toContain('frame'); + $ack = json_encode(['protocol' => 2, 'type' => 'frame_ack', + 'generation' => $generation, 'frame' => 1, 'presented' => true], JSON_THROW_ON_ERROR); + expect($request($exchange, ['sessionId' => $sessionId, 'events' => [$ack]])['result']['messages'])->toBe([]); + + // Malformed requests must be refused before touching the active relay. + foreach ([null, 'not a list', [0 => previewWindowReady(), 2 => $ack], [previewWindowReady(), 7], [false]] as $events) { + expect($request($exchange, ['sessionId' => $sessionId, 'events' => $events])['error'])->toBe([ + 'kind' => 'request', 'message' => '"events" must be a list of strings.', + ]); + } + expect($request($exchange, ['sessionId' => $sessionId, 'events' => []])['result']['messages'])->toBe([]); + expect($request($detach))->toHaveKey('result'); + if ($source !== 'cinematic') { + expect($request($start, $startParams))->toHaveKey('result'); + } + $nextSessionId = $request($exchange)['result']['sessionId']; + expect($nextSessionId)->not->toBe($sessionId); + $again = $request($exchange, ['sessionId' => $nextSessionId, 'events' => [previewWindowReady()]]); + expect($again)->toHaveKey('result'); + $frames = array_values(array_filter($again['result']['messages'], static fn(array $message): bool => $message['type'] === 'frame')); + expect($frames)->not->toBe([])->and($frames[0]['payload']['reset'] ?? false)->toBeTrue(); + rewind($diagnostics); + expect(stream_get_contents($diagnostics))->toBe('') + ->and(sourceHashTree($root))->toBe($before); + } finally { + // EOF uses the production host teardown, including preview disposal. + $host->run(); + fclose($input); + fclose($output); + fclose($diagnostics); + } +})->with(['cinematic', 'effectField']); diff --git a/tests/Unit/SourceFreshnessTest.php b/tests/Unit/SourceFreshnessTest.php new file mode 100644 index 0000000..8f728b5 --- /dev/null +++ b/tests/Unit/SourceFreshnessTest.php @@ -0,0 +1,439 @@ + 'guide', 'name' => 'Guide', 'x' => 1, 'y' => 1, + 'script' => [['type' => 'transfer', 'map' => 'test-map', 'x' => 2, 'y' => 1]]]]; + $source = str_replace("'futureValue' => 7", "'futureValue' => abs(7)", var_export($data, true)); + file_put_contents($path, " 'camera', 'operation' => 'pan', 'target' => ['kind' => 'position', 'x' => 2, 'y' => 1], 'seconds' => 0.5], + ['type' => 'wait', 'seconds' => abs(1)], + ];\n"); + return [$root, $path, 'harbour']; +} + +function readSourceFreshnessPlacement(EditorSession $session, string $kind, string $map): array +{ + $view = $kind === 'map' ? $session->readNpc($map, 0, ['script']) + : $session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands']); + $row = array_find($view['rows'], static fn(array $row): bool => isset($row['mapPlacement'])) + ?? throw new RuntimeException('The synthetic placement is missing.'); + $context = $kind === 'map' ? ['kind' => 'npc', 'map' => $map, 'revision' => $view['revision'], 'index' => 0] + : ['kind' => 'database', 'category' => 'cutscenes/cinematic', 'index' => 0]; + return [$row, $context]; +} + +/** A sparse route shares its owner with untouched legacy Inn references. */ +function createSourceFreshnessRouteProject(string $kind): array +{ + [$root, $path, $map] = createSourceFreshnessProject($kind); + $commands = [ + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 2], ['y' => 1]], 'secondsPerStep' => 0.3], + ['type' => 'inn', 'cost' => 5, 'confirmDialogue' => ['text' => 'Synthetic rest?'], + 'presentation' => ['treatment' => 'leader', 'leaders' => ['retired-actor' => 'retired-rest']]], + ['type' => 'wait', 'seconds' => 1], + ]; + $data = $kind === 'map' ? require $path : $commands; + if ($kind === 'map') { $data['npcs'][0]['script'] = $commands; } + else { + $metadataPath = str_replace('.script.php', '.data.php', $path); + $metadata = require $metadataPath; + $metadata['skip'] = ['policy' => 'forbidden']; + file_put_contents($metadataPath, " 7", "'seconds' => 1"], ["'futureValue' => abs(7)", "'seconds' => abs(1)"], var_export($data, true)); + file_put_contents($path, "setMapField('description', $name); + } else { + $owner->apply([...$owner->payload(), 'name' => $name]); + } +} + +function rewriteSourceFreshnessFile(string $path, string $mode): void +{ + $source = (string) file_get_contents($path); + $changed = match ($mode) { + 'literal' => str_replace("'x' => 2", "'x' => 3", $source), + 'expression' => str_replace("'x' => 2", "'x' => (1 + 1)", $source), + 'comment' => $source . "\n// Written by another author.\n", + }; + if ($changed === $source) { + throw new RuntimeException('The synthetic rewrite did not change the source.'); + } + file_put_contents($path, $changed); +} + +it('refuses external source changes before first placement review and after review without mutating cached data', function (string $kind, string $mode, bool $reviewed) { + [$root, $path, $map] = createSourceFreshnessProject($kind); + $session = EditorSession::open($root); + $revision = $session->readMap($map)['revision']; + if ($reviewed) { + [$row, $context] = readSourceFreshnessPlacement($session, $kind, $map); + expect($row['mapPlacement']['issue'])->toBeNull(); + } + rewriteSourceFreshnessFile($path, $mode); + $before = sourceHashTree($root); + if (! $reviewed) { + [$row, $context] = readSourceFreshnessPlacement($session, $kind, $map); + expect($row['mapPlacement']['issue'])->toContain('changed after opening'); + } + expect(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], $map, 0, 4, 2, null, $revision)) + ->toThrow(SessionRefusal::class); + [$current] = readSourceFreshnessPlacement($session, $kind, $map); + expect($current['mapPlacement']['points'][0]['point'])->toBe([2, 1]) + ->and($session->undo()['label'])->toBeNull() + ->and(sourceHashTree($root))->toBe($before); +})->with(['map', 'cinematic'])->with(['literal', 'comment', 'expression'])->with([false, true]); + +it('refuses stale ordinary field descriptors before first review and after review', function (string $kind, string $mode, bool $reviewed) { + [$root, $path, $map] = createSourceFreshnessProject($kind); + $session = EditorSession::open($root); + $session->readMap($map); + $read = static fn() => $kind === 'map' ? $session->readInspector($map) + : $session->readDatabaseRecord('cutscenes/cinematic', 0); + if ($reviewed) { + expect(array_any($read()['rows'], static fn(array $row): bool => isset($row['mapPlacement'])))->toBeFalse(); + } + rewriteSourceFreshnessFile($path, $mode); + $before = sourceHashTree($root); + expect($read)->toThrow(SessionRefusal::class, 'changed after opening'); + if ($kind === 'map') { + expect(fn() => $session->readNpc($map, 0))->toThrow(SessionRefusal::class, 'changed after opening'); + } + expect($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($before); +})->with(['map', 'cinematic'])->with(['literal', 'comment', 'expression'])->with([false, true]); + +it('keeps ordinary field review available for dirty edits and missing source recovery', function (string $kind) { + [$root, $path, $map] = createSourceFreshnessProject($kind); + $session = EditorSession::open($root); + $session->readMap($map); + $read = static fn() => $kind === 'map' ? $session->readInspector($map) + : $session->readDatabaseRecord('cutscenes/cinematic', 0); + $view = $read(); + $field = $kind === 'map' ? 'description' : 'name'; + $row = array_find($view['rows'], static fn(array $row): bool => ($row['key']['field'] ?? '') === $field) + ?? throw new RuntimeException('The synthetic ordinary field is missing.'); + if ($kind === 'map') { + $session->applyInspector($map, $view['revision'], $row['key'], 'Pending internal edit'); + } else { + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row['key'], 'Pending internal edit'); + } + expect(array_find($read()['rows'], static fn(array $row): bool => ($row['key']['field'] ?? '') === $field)['value']) + ->toBe('Pending internal edit'); + unlink($path); + expect($read()['rows'])->not->toBeEmpty(); + if ($kind === 'map') { $session->saveMap($map); } + else { $session->saveDatabase('cutscenes/cinematic'); } + $reopened = loadSourceFreshnessOwner($root, $kind); + expect($reopened instanceof ProjectMap ? $reopened->getEditableData()['description'] : $reopened->payload()['name']) + ->toBe('Pending internal edit')->and(file_get_contents($path))->toContain('Authored', 'abs('); + $session->undo(); + expect($read()['rows'])->not->toBeEmpty(); +})->with(['map', 'cinematic']); + +it('refuses stale direct authoring and saves while keeping dirty internal edits intact', function (string $kind, string $mode, bool $dirty) { + [$root, $path] = createSourceFreshnessProject($kind); + $owner = loadSourceFreshnessOwner($root, $kind); + if ($dirty) { + editSourceFreshnessOwner($owner, 'Pending internal edit'); + } + $owner->assertSourcesUnchanged(); + $state = $owner instanceof ProjectMap ? $owner->getEditableData() : $owner->captureEditState(); + $version = $owner->stateVersion(); + rewriteSourceFreshnessFile($path, $mode); + $before = sourceHashTree($root); + $refusal = $kind === 'map' ? MapSourceRefusal::class : FileSetTransactionFailure::class; + expect(fn() => $owner->assertSourcesUnchanged())->toThrow($refusal, 'changed after opening') + ->and(fn() => editSourceFreshnessOwner($owner, 'Must not apply'))->toThrow($refusal, 'changed after opening') + ->and(fn() => $owner->save())->toThrow($refusal, 'changed after opening'); + expect($owner instanceof ProjectMap ? $owner->getEditableData() : $owner->captureEditState())->toBe($state) + ->and($owner->stateVersion())->toBe($version) + ->and($owner->isDirty())->toBe($dirty) + ->and(sourceHashTree($root))->toBe($before) + ->and(glob(dirname($path) . '/*.tmp-*'))->toBe([]); +})->with(['map', 'cinematic'])->with(['literal', 'comment', 'expression'])->with([false, true]); + +it('refuses a stale displayed map independently of cinematic placement source', function (string $member) { + [$root, $path, $map] = createSourceFreshnessProject('cinematic'); + $session = EditorSession::open($root); + [$row, $context] = readSourceFreshnessPlacement($session, 'cinematic', $map); + $revision = $session->readMap($map)['revision']; + $mapPath = $root . '/assets/Maps/harbour/harbour.' . $member . '.php'; + file_put_contents($mapPath, file_get_contents($mapPath) . "\n// External preview-map edit.\n"); + $before = sourceHashTree($root); + expect(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], $map, 0, 4, 2, null, $revision)) + ->toThrow(SessionRefusal::class, 'changed after opening'); + expect($session->undo()['label'])->toBeNull() + ->and(sourceHashTree($root))->toBe($before) + ->and(file_get_contents($path))->toContain("'x' => 2"); +})->with(['data', 'map', 'event']); + +it('keeps the owner baseline correct across dirty placement undo save redo and reopen', function (string $kind) { + [$root, $path, $map] = createSourceFreshnessProject($kind); + $original = file_get_contents($path); + $session = EditorSession::open($root); + [$row, $context] = readSourceFreshnessPlacement($session, $kind, $map); + $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], $map, 0, 4, 2, null, $session->readMap($map)['revision']); + $save = static fn() => $kind === 'map' ? $session->saveMap($map) : $session->saveDatabase('cutscenes/cinematic'); + $save(); + expect(file_get_contents($path))->toContain("'x' => 4", 'abs('); + $session->undo(); + $save(); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $save(); + [$reopened] = readSourceFreshnessPlacement(EditorSession::open($root), $kind, $map); + expect($reopened['mapPlacement']['points'][0]['point'])->toBe([4, 2]) + ->and($reopened['mapPlacement']['issue'])->toBeNull() + ->and(file_get_contents($path))->toContain('Authored', 'abs('); +})->with(['map', 'cinematic']); + +it('keeps nullable waypoint placement compatible with unchanged Inn semantics and owner baselines', function (string $kind) { + [$root, $path, $map] = createSourceFreshnessRouteProject($kind); + $original = file_get_contents($path); + $session = EditorSession::open($root); + [$row, $context] = readSourceFreshnessPlacement($session, $kind, $map); + expect($row['mapPlacement']['kind'])->toBe('waypoints') + ->and($row['mapPlacement']['points'][0]['point'])->toBe([2, null]) + ->and(array_keys($row['mapPlacement']['points'][0]['fields']))->toBe(['x']) + ->and($row['mapPlacement']['points'][1]['point'])->toBe([2, 1]) + ->and(array_keys($row['mapPlacement']['points'][1]['fields']))->toBe(['y']); + $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], $map, 0, 4, 3, [1, 1], $session->readMap($map)['revision']); + $save = static fn() => $kind === 'map' ? $session->saveMap($map) : $session->saveDatabase('cutscenes/cinematic'); + $save(); + $saved = require $path; + $commands = $kind === 'map' ? $saved['npcs'][0]['script'] : $saved; + expect($commands[0]['waypoints'])->toBe([['x' => 4], ['y' => 1]]) + ->and($commands[0]['secondsPerStep'])->toBe(0.3) + ->and($commands[1]['presentation'])->toBe(['treatment' => 'leader', 'leaders' => ['retired-actor' => 'retired-rest']]) + ->and(file_get_contents($path))->toContain('Authored mixed route and Inn source', 'abs(1)'); + $session->undo(); + $save(); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $save(); + [$reopened] = readSourceFreshnessPlacement(EditorSession::open($root), $kind, $map); + expect($reopened['mapPlacement']['points'][0]['point'])->toBe([4, null]) + ->and($reopened['mapPlacement']['points'][1]['point'])->toBe([4, 1]) + ->and($reopened['mapPlacement']['issue'])->toBeNull(); +})->with(['map', 'cinematic']); + +it('refuses stale sparse waypoint placement beside untouched Inn references', function (string $kind, bool $reviewed) { + [$root, $path, $map] = createSourceFreshnessRouteProject($kind); + $session = EditorSession::open($root); + $revision = $session->readMap($map)['revision']; + if ($reviewed) { [$row, $context] = readSourceFreshnessPlacement($session, $kind, $map); } + rewriteSourceFreshnessFile($path, 'comment'); + $before = sourceHashTree($root); + if (!$reviewed) { [$row, $context] = readSourceFreshnessPlacement($session, $kind, $map); } + expect(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], $map, 0, 4, 3, [1, 1], $revision)) + ->toThrow(SessionRefusal::class); + [$current] = readSourceFreshnessPlacement($session, $kind, $map); + expect($current['mapPlacement']['issue'])->toContain('changed after opening') + ->and($current['mapPlacement']['points'][0]['point'])->toBe([2, null]) + ->and($session->undo()['label'])->toBeNull() + ->and(sourceHashTree($root))->toBe($before); +})->with(['map', 'cinematic'])->with([false, true]); + +it('recovers missing map data or the complete map folder without losing internal edits', function (bool $wholeFolder, bool $dirty) { + [$root, $path] = createSourceFreshnessProject('map'); + $owner = loadSourceFreshnessOwner($root, 'map'); + $original = (string) file_get_contents($path); + if ($wholeFolder) { + removeDirectoryRecursively(dirname($path)); + } else { + unlink($path); + } + $owner->assertSourcesUnchanged(); + if ($dirty) { + editSourceFreshnessOwner($owner, 'Recovered internal edit'); + } + $owner->save(); + $reopened = loadSourceFreshnessOwner($root, 'map'); + expect($reopened->getDescription())->toBe($dirty ? 'Recovered internal edit' : 'Loaded map') + ->and($owner->isDirty())->toBeFalse() + ->and(file_get_contents($path))->toContain('Authored map source.', 'abs(7)'); + if (! $dirty) { + expect(file_get_contents($path))->toBe($original); + } +})->with([false, true])->with([false, true]); + +it('recovers missing cinematic members exactly and adopts their new saved baseline', function (string $member, bool $dirty) { + [$root, $path] = createSourceFreshnessProject('cinematic'); + $owner = loadSourceFreshnessOwner($root, 'cinematic'); + $original = sourceHashTree($owner->folder); + foreach ($owner->paths() as $owned) { + if ($member === 'both' || str_ends_with($owned, '.' . $member . '.php')) { + unlink($owned); + } + } + $owner->assertSourcesUnchanged(); + if ($dirty) { + editSourceFreshnessOwner($owner, 'Recovered cinematic'); + } + expect($owner->save())->toBeTrue(); + $owner->assertSourcesUnchanged(); + $reopened = loadSourceFreshnessOwner($root, 'cinematic'); + expect($owner->isDirty())->toBeFalse() + ->and($reopened->name())->toBe($dirty ? 'Recovered cinematic' : $owner->name()) + ->and(file_get_contents($path))->toContain('Authored cinematic source.', 'abs(1)'); + if (! $dirty) { + expect(sourceHashTree($owner->folder))->toBe($original); + } + editSourceFreshnessOwner($owner, 'Another internal edit'); + $owner->save(); + expect(loadSourceFreshnessOwner($root, 'cinematic')->name())->toBe('Another internal edit'); +})->with(['data', 'script', 'both'])->with([false, true]); + +it('refuses directory replacements rather than treating them as recoverable missing files', function (string $kind) { + [$root, $path] = createSourceFreshnessProject($kind); + $owner = loadSourceFreshnessOwner($root, $kind); + unlink($path); + mkdir($path); + $before = sourceHashTree($root); + expect(fn() => $owner->save())->toThrow(RuntimeException::class, 'changed after opening') + ->and(is_dir($path))->toBeTrue() + ->and(sourceHashTree($root))->toBe($before); +})->with(['map', 'cinematic']); + +it('refuses new cinematic target collisions rather than overwriting another author', function () { + $root = cutsceneProject(); + $owner = CutsceneAsset::create(CutsceneType::CINEMATIC, 'new-scene', $root . '/assets/Cutscenes/Cinematics', + ['id' => 'new-scene', 'name' => 'New Scene', 'commands' => []], $root); + mkdir($owner->folder); + file_put_contents($owner->partnerPath(), " $owner->assertSourcesUnchanged())->toThrow(FileSetTransactionFailure::class, 'changed after opening') + ->and(fn() => $owner->save())->toThrow(FileSetTransactionFailure::class, 'changed after opening') + ->and($owner->isNew())->toBeTrue() + ->and($owner->isDirty())->toBeTrue() + ->and(is_file($owner->dataPath()))->toBeFalse() + ->and(sourceHashTree($root))->toBe($before); +}); + +it('refuses stale deletion and recovers the saved deletion through its existing undo state', function () { + [$root, $path] = createSourceFreshnessProject('cinematic'); + $owner = loadSourceFreshnessOwner($root, 'cinematic'); + $state = $owner->captureEditState(); + $original = sourceHashTree($owner->folder); + $source = (string) file_get_contents($path); + $owner->markDeleted(true); + rewriteSourceFreshnessFile($path, 'comment'); + $before = sourceHashTree($root); + expect(fn() => $owner->save())->toThrow(FileSetTransactionFailure::class, 'changed after opening') + ->and(sourceHashTree($root))->toBe($before); + file_put_contents($path, $source); + $owner->save(); + expect(array_filter($owner->paths(), is_file(...)))->toBe([]); + $owner->restoreEditState($state); + $owner->save(); + expect(sourceHashTree($owner->folder))->toBe($original) + ->and(loadSourceFreshnessOwner($root, 'cinematic')->isEditable())->toBeTrue(); +}); + +it('guards unchanged members if another author writes during the save backup callback', function (string $kind) { + [$root, $path] = createSourceFreshnessProject($kind); + $owner = loadSourceFreshnessOwner($root, $kind); + editSourceFreshnessOwner($owner, 'Pending internal edit'); + $target = $kind === 'map' ? $owner->eventPath : $owner->partnerPath(); + $source = (string) file_get_contents($target); + $before = sourceHashTree($root); + expect(fn() => $owner->save(static function () use ($target, $source): void { + file_put_contents($target, $source . "\n// External edit during backup.\n"); + }))->toThrow(FileSetTransactionFailure::class, 'changed after opening'); + $before[substr($target, strlen($root) + 1)] = hash_file('sha256', $target); + expect(sourceHashTree($root))->toBe($before) + ->and($owner->isDirty())->toBeTrue() + ->and(glob(dirname($path) . '/*.tmp-*'))->toBe([]); +})->with(['map', 'cinematic']); + +it('reviews expected sources without staging and preserves their access metadata', function () { + [$root, $path] = createSourceFreshnessProject('map'); + $transaction = new FileSetTransaction($root . '/not-created'); + $source = (string) file_get_contents($path); + $transaction->expectSource($path, $source); + touch($path, time() - 3600, time() - 7200); + clearstatcache(true, $path); + $before = stat($path); + $transaction->assertSourcesUnchanged(); + clearstatcache(true, $path); + $after = stat($path); + expect(is_dir($root . '/not-created'))->toBeFalse() + ->and($after['atime'])->toBe($before['atime']) + ->and($after['mtime'])->toBe($before['mtime']) + ->and($after['mode'])->toBe($before['mode']); + rewriteSourceFreshnessFile($path, 'comment'); + expect(fn() => $transaction->assertSourcesUnchanged())->toThrow(FileSetTransactionFailure::class, 'changed after opening'); +}); + +it('refuses stale cinematic field edits before changing record state or history', function () { + [$root, $path] = createSourceFreshnessProject('cinematic'); + $session = EditorSession::open($root); + $view = $session->readDatabaseRecord('cutscenes/cinematic', 0); + $name = array_find($view['rows'], static fn(array $row): bool => ($row['key']['field'] ?? '') === 'name'); + $library = Ichiloto\Editor\Cutscenes\CutsceneLibrary::fromProject($root); + $asset = $library->find(CutsceneType::CINEMATIC, 'harbour-lanterns'); + $version = $asset->stateVersion(); + $state = $asset->captureEditState(); + rewriteSourceFreshnessFile($path, 'comment'); + expect(fn() => $session->applyDatabaseRecord('cutscenes/cinematic', 0, $name['key'], 'Must not apply')) + ->toThrow(SessionRefusal::class, 'changed after opening'); + expect(fn() => $library->changeAsset(CutsceneType::CINEMATIC, 0, 'Must not apply', + static fn() => throw new LogicException('The stale authoring callback must not run.'))) + ->toThrow(FileSetTransactionFailure::class, 'changed after opening'); + expect($asset->captureEditState())->toBe($state) + ->and($asset->stateVersion())->toBe($version) + ->and($session->undo()['label'])->toBeNull(); +}); + +it('refuses external creation of a pending map layer during save', function () { + $root = makeTemporaryProject('source-freshness-layers-'); + $directory = $root . '/assets/Maps/fresh-map'; + ProjectMap::createBlank($directory, 'fresh-map', 'Fresh Map', 12, 5); + $owner = ProjectMap::fromDirectory($root . '/assets/Maps', $directory); + $layer = $owner->createLayer('canopy', true); + $target = array_find($owner->getLayers(), static fn(array $entry): bool => $entry['id'] === $layer)['path']; + $before = sourceHashTree($root); + expect(fn() => $owner->save(static function () use ($target): void { + file_put_contents($target, "toThrow(FileSetTransactionFailure::class, 'changed after opening'); + $before[substr($target, strlen($root) + 1)] = hash_file('sha256', $target); + ksort($before); + expect(sourceHashTree($root))->toBe($before) + ->and(file_get_contents($target))->toContain('Another author') + ->and($owner->isDirty())->toBeTrue(); +}); diff --git a/tests/Unit/StagedCharacterSheetPreviewTest.php b/tests/Unit/StagedCharacterSheetPreviewTest.php new file mode 100644 index 0000000..a5ce3a3 --- /dev/null +++ b/tests/Unit/StagedCharacterSheetPreviewTest.php @@ -0,0 +1,84 @@ + 'Graphics/Characters/People.png', 'index' => 5, 'layer' => 120]; + $actor = ['id' => 'walker', 'sprite' => ['@'], 'x' => 1, 'y' => 2, 'sprites2d' => $sprites]; + $wrapped = $owner === 'envelope'; + $stage = $wrapped ? ['type' => 'stage_actor', 'actor' => $actor] : ['type' => 'stage_actor', ...$actor]; + $command = ['type' => 'sequence', 'commands' => [$stage]]; + $metadata = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.data.php'; + $data = require $metadata; + $data['cast'] = [['kind' => 'staged_actor', ...$actor]]; + $data['skip'] = ['policy' => 'forbidden']; + file_put_contents($metadata, "readDatabaseRecord('cutscenes/cinematic', 0, $frame); + + return array_find($view['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $field) + ?? throw new RuntimeException('Missing staged sheet preview row ' . $field); +} + +it('shares standing preview crops with staged cast nested commands and actor envelopes', function (string $owner) { + [$root, $session, $frame, $stem, $sprites] = createStagedSheetPreviewProject($owner); + $row = readStagedSheetPreviewRow($session, $frame, $stem . 'sheet'); + expect($row)->toMatchArray(['kind' => 'reference', 'reference' => 'png_assets', + 'value' => $sprites['sheet'], 'media' => ['kind' => 'image', 'root' => 'assets']]) + ->and($row['imagePreview'])->toBe(CharacterSheetPreview::describe($sprites, $root . '/assets')) + ->and($row['imagePreview']['frames'][0]['sourceRect'])->toBe(['x' => 16, 'y' => 16, 'width' => 4, 'height' => 4]); + $index = readStagedSheetPreviewRow($session, $frame, $stem . 'index'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $index['key'], '7'); + expect(readStagedSheetPreviewRow($session, $frame, $stem . 'sheet')['imagePreview']['frames'][0]['sourceRect']['x'])->toBe(40); + $session->undo(); + expect(readStagedSheetPreviewRow($session, $frame, $stem . 'sheet')['imagePreview'])->toBe($row['imagePreview']); + $session->redo(); + $session->saveDatabase('cutscenes/cinematic'); + $reopened = EditorSession::open($root); + expect(readStagedSheetPreviewRow($reopened, $frame, $stem . 'index')['value'])->toBe('7') + ->and(readStagedSheetPreviewRow($reopened, $frame, $stem . 'sheet')['imagePreview']) + ->toBe(readStagedSheetPreviewRow($session, $frame, $stem . 'sheet')['imagePreview']); +})->with(['cast', 'inline', 'envelope']); + +it('refreshes staged preview dimensions from the correct current workspace and reports file issues', function (string $owner) { + [$root, $session, $frame, $stem] = createStagedSheetPreviewProject($owner); + [$otherRoot, $other, $otherFrame, $otherStem] = createStagedSheetPreviewProject($owner, 96, 64); + $png = $root . '/assets/Graphics/Characters/People.png'; + expect(readStagedSheetPreviewRow($session, $frame, $stem . 'sheet')['imagePreview']['frames'][0]['sourceRect']['width'])->toBe(4) + ->and(readStagedSheetPreviewRow($other, $otherFrame, $otherStem . 'sheet')['imagePreview']['frames'][0]['sourceRect']['width'])->toBe(8); + writeTilesetTestPng($png, 144, 96); + expect(readStagedSheetPreviewRow($session, $frame, $stem . 'sheet')['imagePreview']['frames'][0]['sourceRect']['width'])->toBe(12); + file_put_contents($png, 'malformed replacement'); + $files = sourceHashTree($root); + $preview = readStagedSheetPreviewRow($session, $frame, $stem . 'sheet')['imagePreview']; + expect($preview['frames'])->toBe([])->and($preview['issue'])->not->toBeEmpty() + ->and($session->listUnsavedChanges())->toBe([])->and(sourceHashTree($root))->toBe($files) + ->and(readStagedSheetPreviewRow($other, $otherFrame, $otherStem . 'sheet')['imagePreview']['frames'][0]['sourceRect']['width'])->toBe(8); +})->with(['cast', 'envelope']); + +it('reports an absent explicit staged asset root instead of inventing one from an owner path', function () { + $entry = ['type' => 'stage_actor', 'actor' => ['sprites2d' => ['sheet' => 'People.png']]]; + $fields = [['sourceKey' => 'actor.sprites2d.sheet', 'value' => 'People.png'], + ['sourceKey' => 'actor.sprites2d.asset', 'value' => '']]; + $described = StagedActorFields::describeFields($entry, $fields, null); + expect($described[0]['imagePreview']['frames'])->toBe([]) + ->and($described[0]['imagePreview']['issue'])->toContain('no project asset root') + ->and($described[1])->not->toHaveKey('imagePreview'); +}); diff --git a/tests/Unit/TilesetPieceKeepsTest.php b/tests/Unit/TilesetPieceKeepsTest.php new file mode 100644 index 0000000..57a6604 --- /dev/null +++ b/tests/Unit/TilesetPieceKeepsTest.php @@ -0,0 +1,271 @@ + $id, 'layer' => 'buildings'] + $data, 'Synthetic keeps'); +} + +function planKeepsTestChanges(array $pieces, array $changes, array $glyphs, array $tiles, array $choices = [], array $assigned = []): array +{ + return GlyphTilePlanner::fromPieces($pieces, 'buildings')->plan($changes, + static fn(int $x, int $y): ?string => $glyphs[$y][$x] ?? null, + static fn(string $layer, int $x, int $y): string => $tiles[$layer][$y][$x] ?? '0', + $choices, assigned: $assigned); +} + +/** Reports only stale piece tiles, apart from unrelated synthetic fixture coverage. */ +function getKeepsTestStaleIssues(string $root): array +{ + return array_values(array_map(static fn($issue): string => $issue->message, + array_filter(MapGraphicsValidator::validateCoverage(loadLayeredMap($root)), + static fn($issue): bool => str_contains($issue->message, 'no longer there')))); +} + +it('gives kept cells to the same glyph roles as blank attachments, even for zero tiles', function () { + $piece = createKeepsTestPiece('mounted', ['glyphs' => ['a ', ' b'], + 'tiles' => ['decor' => ['5 0', '0 6']], 'keeps' => ['walls', 'floor']]); + $roles = PieceRole::readPiece($piece); + $attached = [['dx' => 0, 'dy' => 0], ['dx' => 1, 'dy' => 0], ['dx' => 0, 'dy' => 1]]; + + expect($roles['a'][0]->keeps)->toBe(['walls' => $attached, 'floor' => $attached]) + ->and($roles['b'][0]->keeps)->toBe(['walls' => [['dx' => 0, 'dy' => 0]], 'floor' => [['dx' => 0, 'dy' => 0]]]) + ->and($roles['a'][0]->tiles)->toBe(['decor' => [['dx' => 0, 'dy' => 0, 'entry' => '5']]]) + ->and(PieceRole::readPiece($piece, ['walls', 'decor'])['a'][0]->keeps)->toBe(['floor' => $attached]) + ->and(PieceRole::readPiece($piece, ['walls', 'decor'])['a'][0]->tiles)->toBe([]); + + $middle = createKeepsTestPiece('middle', ['glyphs' => [' w '], 'keeps' => ['walls']]); + expect(PieceRole::readPiece($middle)['w'][0]->keeps['walls'])->toBe([ + ['dx' => 0, 'dy' => 0], ['dx' => -1, 'dy' => 0], ['dx' => 1, 'dy' => 0], + ]); +}); + +it('deduplicates equivalent roles but not distinct kept layers or footprints', function () { + $data = ['glyphs' => ['w'], 'tiles' => ['decor' => ['5']]]; + $planner = GlyphTilePlanner::fromPieces([ + createKeepsTestPiece('plain', $data), + createKeepsTestPiece('mounted', $data + ['keeps' => ['walls']]), + createKeepsTestPiece('both', $data + ['keeps' => ['walls', 'floor']]), + createKeepsTestPiece('both-reordered', $data + ['keeps' => ['floor', 'walls']]), + createKeepsTestPiece('wide', array_replace($data, ['glyphs' => ['w '], 'tiles' => ['decor' => ['5 0']], 'keeps' => ['walls']])), + ], 'buildings'); + + expect(array_map(static fn(PieceRole $role): string => $role->key, $planner->getRoles('w'))) + ->toBe(['plain:0:0', 'mounted:0:0', 'both:0:0', 'wide:0:0']) + ->and($planner->findRole('w', 'both-reordered:0:0'))->toBe($planner->findRole('w', 'both:0:0')) + ->and($planner->findRole('w', 'mounted:0:0'))->not->toBe($planner->findRole('w', 'plain:0:0')); +}); + +it('preserves the actual underlay at the arriving role and its attached blank', function (array $keeps, array $expectedWalls) { + $pieces = [ + createKeepsTestPiece('wall', ['glyphs' => ['# '], 'tiles' => ['walls' => ['10 11']]]), + createKeepsTestPiece('window', ['glyphs' => ['w '], 'tiles' => ['decor' => ['5 6']], 'keeps' => $keeps]), + ]; + $plan = planKeepsTestChanges($pieces, [['x' => 1, 'y' => 0, 'old' => '#', 'new' => 'w']], + [[' ', '#', ' ', ' ']], ['walls' => [['0', '10', '11', '0']]]); + + expect($plan['unresolved'])->toBe([]) + ->and($plan['tiles']['walls'] ?? [])->toBe($expectedWalls) + ->and($plan['tiles']['decor'])->toBe([ + ['x' => 1, 'y' => 0, 'entry' => '5'], ['x' => 2, 'y' => 0, 'entry' => '6'], + ]); +})->with([ + 'kept layer' => [['walls'], []], + 'existing non-keeping behaviour' => [[], [['x' => 1, 'y' => 0, 'entry' => '0'], ['x' => 2, 'y' => 0, 'entry' => '0']]], +]); + +it('protects an arriving kept cell from a different leaving role but not cells outside it', function () { + $pieces = [ + createKeepsTestPiece('wall', ['glyphs' => ['# '], 'tiles' => ['walls' => ['10 11']]]), + createKeepsTestPiece('window', ['glyphs' => ['w'], 'tiles' => ['decor' => ['5']], 'keeps' => ['walls']]), + ]; + $plan = planKeepsTestChanges($pieces, [ + ['x' => 1, 'y' => 0, 'old' => '#', 'new' => ' '], ['x' => 2, 'y' => 0, 'old' => ' ', 'new' => 'w'], + ], [[' ', '#', ' ', ' ']], ['walls' => [['0', '10', '11', '0']]]); + + expect($plan['tiles'])->toBe([ + 'walls' => [['x' => 1, 'y' => 0, 'entry' => '0']], + 'decor' => [['x' => 2, 'y' => 0, 'entry' => '5']], + ]); +}); + +it('erases and moves only a keeping role\'s own tiles, never independent underlay', function () { + $pieces = [ + createKeepsTestPiece('wall', ['glyphs' => ['#'], 'tiles' => ['walls' => ['10']]]), + createKeepsTestPiece('window', ['glyphs' => ['w'], 'tiles' => ['decor' => ['5']], 'keeps' => ['walls']]), + ]; + $plan = planKeepsTestChanges($pieces, [ + ['x' => 1, 'y' => 0, 'old' => 'w', 'new' => ' '], ['x' => 3, 'y' => 0, 'old' => '#', 'new' => 'w'], + ], [[' ', 'w', ' ', '#']], ['walls' => [['0', '13', '0', '10']], 'decor' => [['0', '5', '0', '0']]]); + + expect($plan['tiles'])->toBe(['decor' => [['x' => 1, 'y' => 0, 'entry' => '0'], ['x' => 3, 'y' => 0, 'entry' => '5']]]); +}); + +it('requires a real role choice when identical artwork has different keeping behaviour', function () { + $wall = createKeepsTestPiece('wall', ['glyphs' => ['#'], 'tiles' => ['walls' => ['10']]]); + $data = ['glyphs' => ['w'], 'tiles' => ['decor' => ['5']]]; + $pieces = [$wall, createKeepsTestPiece('plain', $data), createKeepsTestPiece('mounted', $data + ['keeps' => ['walls']])]; + $changes = [['x' => 0, 'y' => 0, 'old' => '#', 'new' => 'w']]; + $glyphs = [['#']]; + $tiles = ['walls' => [['10']]]; + + expect(planKeepsTestChanges($pieces, $changes, $glyphs, $tiles)['unresolved']['w'])->toHaveCount(2) + ->and(planKeepsTestChanges($pieces, $changes, $glyphs, $tiles, ['w' => 'mounted:0:0'])['tiles'])->not->toHaveKey('walls') + ->and(planKeepsTestChanges($pieces, $changes, $glyphs, $tiles, ['w' => 'plain:0:0'])['tiles']['walls']) + ->toBe([['x' => 0, 'y' => 0, 'entry' => '0']]) + ->and(planKeepsTestChanges($pieces, $changes, $glyphs, $tiles, ['w' => 'plain:0:0'], ['0,0' => 'mounted:0:0'])['tiles']) + ->not->toHaveKey('walls'); +}); + +it('does not invent missing kept layers or block independently authored arriving tiles', function () { + $pieces = [ + createKeepsTestPiece('window', ['glyphs' => ['w '], 'tiles' => ['decor' => ['5 6']], 'keeps' => ['walls']]), + createKeepsTestPiece('wall', ['glyphs' => ['#'], 'tiles' => ['walls' => ['12']]]), + ]; + $plan = planKeepsTestChanges($pieces, [['x' => 0, 'y' => 0, 'old' => ' ', 'new' => 'w']], [[' ', ' ']], []); + expect($plan['tiles'])->toBe(['decor' => [['x' => 0, 'y' => 0, 'entry' => '5'], ['x' => 1, 'y' => 0, 'entry' => '6']]]); + + $plan = planKeepsTestChanges($pieces, [ + ['x' => 0, 'y' => 0, 'old' => ' ', 'new' => 'w'], ['x' => 1, 'y' => 0, 'old' => ' ', 'new' => '#'], + ], [[' ', ' ']], ['walls' => [['10', '11']]]); + expect($plan['tiles']['walls'])->toBe([['x' => 1, 'y' => 0, 'entry' => '12']]); +}); + +it('stamps multi-cell keeps through PiecePlacer with byte-preserving undo and save round trips', function () { + $root = mapGraphicsProject(); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/layers/04.buildings.map.php', MapGridSource::buildSource("####\n####", 'AUTHORED', "// Preserve this authored layer.\n")); + writeTileLayer($directory, '03.walls.tiles.php', "10 11 10 11\n11 10 11 10", "// Independent underlay.\n"); + writeTileLayer($directory, '02.decor.tiles.php', "0 0 0 0\n0 0 0 0"); + writeTestTileset($root, pieces: [ + 'wall-one' => ['name' => 'Wall one', 'layer' => 'buildings', 'glyphs' => ['#'], 'tiles' => ['walls' => ['10']]], + 'wall-two' => ['name' => 'Wall two', 'layer' => 'buildings', 'glyphs' => ['#'], 'tiles' => ['walls' => ['11']]], + 'mounted' => ['name' => 'Mounted', 'layer' => 'buildings', 'glyphs' => ['w ', ' b'], + 'tiles' => ['decor' => ['5 6', '0 7']], 'keeps' => ['walls']], + ]); + $map = loadLayeredMap($root); + $before = $map->captureLayerSnapshot(); + $tilesBefore = $map->getTileLayerSources(); + $disk = sourceHashTree($directory); + $piece = PiecePlacer::loadPieces($map)['mounted']; + $result = PiecePlacer::stampArea($map, $piece, ['x' => 1, 'y' => 0], ['x' => 1, 'y' => 0], null); + $after = $map->captureLayerSnapshot(); + $glyphSourcesAfter = $map->getGridSources(); + $tilesAfter = $map->getTileLayerSources(); + $walls = $directory . '/graphics/03.walls.tiles.php'; + + expect($result['command'])->not->toBeNull() + ->and($tilesAfter[$walls])->toBe($tilesBefore[$walls]) + ->and($map->getLayerSymbol('map:4', 2, 0))->toBe('#') + ->and($map->getLayerSymbol('map:4', 1, 1))->toBe('#') + ->and(sourceHashTree($directory))->toBe($disk); + $result['command']->undo(); + expect($map->captureLayerSnapshot())->toBe($before)->and($map->getTileLayerSources())->toBe($tilesBefore); + $result['command']->execute(); + expect($map->captureLayerSnapshot())->toBe($after)->and($map->getTileLayerSources())->toBe($tilesAfter); + $map->save(); + expect(array_keys(array_diff_assoc(sourceHashTree($directory), $disk))) + ->toBe(['graphics/02.decor.tiles.php', 'layers/04.buildings.map.php']) + ->and(file_get_contents($walls))->toBe($tilesBefore[$walls]); + + $map = loadLayeredMap($root); + expect($map->getGridSources())->toBe($glyphSourcesAfter)->and($map->getTileLayerSources())->toBe($tilesAfter) + ->and($map->isDirty())->toBeFalse(); + $writes = [['x' => 1, 'y' => 0, 'symbol' => ' '], ['x' => 2, 'y' => 1, 'symbol' => ' ']]; + $plan = CanvasEditor::plan($map, 'map:4', $writes); + expect($plan['unresolved'])->toBe([])->and($plan['tiles'])->not->toHaveKey('walls'); + CanvasEditor::apply($map, 'map:4', $writes, 'Erase mounted glyphs', $plan['tiles']); + expect($map->getTileLayerSources()[$walls])->toBe($tilesBefore[$walls]); +}); + +it('explains arbitrary kept underlay only at matching role cells, not stale tiles elsewhere', function () { + $root = mapGraphicsProject(); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/layers/04.buildings.map.php', MapGridSource::buildSource("w \n ", 'AUTHORED')); + writeTestTileset($root, pieces: [ + 'wall-one' => ['name' => 'Wall one', 'layer' => 'buildings', 'glyphs' => ['#'], 'tiles' => ['walls' => ['10']]], + 'wall-two' => ['name' => 'Wall two', 'layer' => 'buildings', 'glyphs' => ['@'], 'tiles' => ['walls' => ['11']]], + 'window' => ['name' => 'Window', 'layer' => 'buildings', 'glyphs' => ['w '], 'tiles' => ['decor' => ['5 6']], 'keeps' => ['walls']], + ]); + writeTileLayer($directory, '02.decor.tiles.php', "5 6 0 0\n0 0 0 0"); + writeTileLayer($directory, '03.walls.tiles.php', "10 11 10 0\n0 0 11 10"); + expect(getKeepsTestStaleIssues($root))->toBe([ + '3 tiles on the walls tile layer are drawn by a tileset piece whose glyph is no longer there: (2, 0), (2, 1), (3, 1).', + ]); + + // The glyph alone must not excuse another role's underlay when its identifying art contradicts it. + writeTileLayer($directory, '02.decor.tiles.php', "9 0 0 0\n0 0 0 0"); + expect(getKeepsTestStaleIssues($root))->toBe([ + '5 tiles on the walls tile layer are drawn by a tileset piece whose glyph is no longer there: (0, 0), (1, 0), (2, 0) and 2 more.', + ]); +}); + +it('does not extend a surviving role\'s keeps into a missing sibling glyph\'s cells', function () { + $root = mapGraphicsProject(); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/layers/04.buildings.map.php', MapGridSource::buildSource("a \n ", 'AUTHORED')); + writeTestTileset($root, pieces: [ + 'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'glyphs' => ['#'], 'tiles' => ['walls' => ['10']]], + 'mounted' => ['name' => 'Mounted', 'layer' => 'buildings', 'glyphs' => ['a b'], + 'tiles' => ['decor' => ['5 0 6']], 'keeps' => ['walls']], + ]); + writeTileLayer($directory, '02.decor.tiles.php', "5 0 0 0\n0 0 0 0"); + writeTileLayer($directory, '03.walls.tiles.php', "10 10 10 0\n0 0 0 0"); + expect(getKeepsTestStaleIssues($root))->toBe([ + '1 tile on the walls tile layer is drawn by a tileset piece whose glyph is no longer there: (2, 0).', + ]); +}); + +it('allows glyph-only keeping roles without claiming an entire layer', function () { + $root = mapGraphicsProject(); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/layers/04.buildings.map.php', MapGridSource::buildSource("g \n ", 'AUTHORED')); + writeTestTileset($root, pieces: [ + 'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'glyphs' => ['#'], 'tiles' => ['walls' => ['10']]], + 'mounted' => ['name' => 'Mounted', 'layer' => 'buildings', 'glyphs' => ['g '], 'keeps' => ['walls']], + ]); + writeTileLayer($directory, '03.walls.tiles.php', "10 10 10 0\n0 0 0 0"); + expect(getKeepsTestStaleIssues($root))->toBe([ + '1 tile on the walls tile layer is drawn by a tileset piece whose glyph is no longer there: (2, 0).', + ]); +}); + +it('keeps underlay for an assigned same-glyph repaint and clips attached cells off the map', function () { + $pieces = [ + createKeepsTestPiece('wall', ['glyphs' => ['#'], 'tiles' => ['walls' => ['10']]]), + createKeepsTestPiece('mounted', ['glyphs' => ['# '], 'tiles' => ['decor' => ['5 6']], 'keeps' => ['walls']]), + ]; + $plan = planKeepsTestChanges($pieces, [['x' => 0, 'y' => 0, 'old' => '#', 'new' => '#']], + [['#']], ['walls' => [['10']]], assigned: ['0,0' => 'mounted:0:0']); + expect($plan)->toBe(['tiles' => ['decor' => [['x' => 0, 'y' => 0, 'entry' => '5']]], 'unresolved' => []]); +}); + +it('leaves underlay intact when PiecePlacer stamps a piece containing only blank glyphs', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: [ + 'blank' => ['name' => 'Blank', 'layer' => 'buildings', 'glyphs' => [' '], + 'tiles' => ['decor' => ['8 9']], 'keeps' => ['floor']], + ]); + $map = loadLayeredMap($root); + $before = $map->getTileLayerSources(); + $glyphs = array_diff_key($map->getGridSources(), $before); + $result = PiecePlacer::stampArea($map, PiecePlacer::loadPieces($map)['blank'], + ['x' => 1, 'y' => 0], ['x' => 1, 'y' => 0], null); + + expect(array_diff_key($map->getGridSources(), $map->getTileLayerSources()))->toBe($glyphs) + ->and($map->getTileLayerSources()[$map->directory . '/graphics/01.floor.tiles.php']) + ->toBe($before[$map->directory . '/graphics/01.floor.tiles.php']) + ->and($result['command'])->not->toBeNull(); + $result['command']->undo(); + expect($map->getTileLayerSources())->toBe($before); +}); From 49f7384dc9e3ddd1cb3cb311fef0ed97d31d070b Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 10 Oct 2026 06:09:44 +0200 Subject: [PATCH 235/344] feat(maps): preserve independent occupancy through authoring Add explicit revisioned source-preserving migration with shared undo and preview consumption. Remove glyph-derived collision changes for explicitly declared maps while retaining legacy behavior. Carry occupancy through resize, row and column insertion, atomic source plans and save/undo/redo. Refuse malformed, stale or unpreservable edits before mutation; full Unit suite 2195 passed with 15 optional real-Game skips. --- docs/manual.md | 16 +- src/Cutscenes/Preview/PreviewMapManager.php | 16 +- src/Maps/LayerEditor.php | 10 + src/Maps/LineInsertionPlanner.php | 18 +- src/ProjectMap.php | 139 +++++- src/Session/EditorSession.php | 9 + src/Session/SessionHost.php | 4 + .../Unit/MapPhysicalOccupancyGeometryTest.php | 449 ++++++++++++++++++ .../Session/PhysicalOccupancySessionTest.php | 143 ++++++ 9 files changed, 779 insertions(+), 25 deletions(-) create mode 100644 tests/Unit/MapPhysicalOccupancyGeometryTest.php create mode 100644 tests/Unit/Session/PhysicalOccupancySessionTest.php diff --git a/docs/manual.md b/docs/manual.md index 8b02705..4e1ae6d 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -216,6 +216,17 @@ Creating a layer on a legacy map explicitly moves its terrain into `layers/00.terrain.map.php` in the same save transaction; ordinary legacy saves keep the original layout. +The GUI offers **Separate collision** for an undeclared map. After explicit +confirmation, the shared session captures its current resolved physical cells +without changing glyphs, artwork or passage. This is one undo step, written only +on save; opening or saving a legacy map never converts it automatically. Once +converted, terminal appearance and layer edits no longer choose collision. +Resize and row/column insertion preserve the declaration with geometry, adding +`SOLID` physical cells independently of blank glyphs. Malformed or unsupported +source edits refuse before mutation. The TUI receives no graphical conversion +control; it preserves the declaration through its existing edit/save/history +workflow. Dedicated physical brushes remain a GUI authoring gap. + Gameplay and event layers use the same Paint/Normal modes, tools, mouse strokes, selection, colour, clipboard and undo. The layer inspector lists terminal layer visibility only. Ordinary glyph edits do not rewrite graphical decoration. @@ -415,11 +426,12 @@ intact: a tileset piece draws stands for that piece's glyph on its gameplay layer, such as a bed's tiles for `O` and `U` on fixtures or a wall's tile for `-`, `|` and `+` on buildings. Placing such a tile in the GUI editor writes its - glyph, and so its collision, where the piece puts it, with the rest of that + glyph where the piece puts it, with the rest of that glyph's tiles and the piece's glyph cells that draw no tile (the lower half of a window); erasing or covering one removes the glyph it stood for, with its other tiles; a wall's cells take the shapes their neighbours give them. - Glyphs, collision and tiles change in one undo step, and picked-up tiles + On undeclared maps, glyph-derived collision follows in that same undo step; + converted maps retain their independently authored physical cells. Picked-up tiles move in one step too. A tile two pieces draw is asked about. A tile whose glyph would fall off the map is refused. Tiles no piece draws, such as a rug or a picture, stand for nothing in the terminal and are placed freely. diff --git a/src/Cutscenes/Preview/PreviewMapManager.php b/src/Cutscenes/Preview/PreviewMapManager.php index f39b37f..37917a7 100644 --- a/src/Cutscenes/Preview/PreviewMapManager.php +++ b/src/Cutscenes/Preview/PreviewMapManager.php @@ -7,6 +7,8 @@ use Ichiloto\Engine\Events\Triggers\EventTriggerFactory; use Ichiloto\Engine\Core\Game; use Ichiloto\Engine\Field\MapManager; +use Ichiloto\Engine\Field\MapCollisionResolver; +use Ichiloto\Engine\Field\MapPhysicalOccupancy; use Ichiloto\Engine\Field\Player; use Ichiloto\Engine\Scenes\Game\GameScene; use Throwable; @@ -51,11 +53,15 @@ public function loadForPreview(string $mapId): array $map = $this->readMapDataFromFile($mapId); $this->mapData = $map; $this->calculateMapDimensions(); - $dictionaryPath = getcwd() . '/assets/Maps/collisions.php'; - $dictionary = is_file($dictionaryPath) ? $this->loadCollisionDictionary($dictionaryPath) : []; - $this->collisionMap = $this->layers === null - ? $this->generateCollisionMap($this->tileMap, $dictionary) - : $this->generateLayerCollisionMap($this->layers, $dictionary); + if (array_key_exists(MapPhysicalOccupancy::DATA_KEY, $map)) { + $this->collisionMap = MapCollisionResolver::resolveMap($this->layers, $map)->collisionGrid; + } else { + $dictionaryPath = getcwd() . '/assets/Maps/collisions.php'; + $dictionary = is_file($dictionaryPath) ? $this->loadCollisionDictionary($dictionaryPath) : []; + $this->collisionMap = $this->layers === null + ? $this->generateCollisionMap($this->tileMap, $dictionary) + : $this->generateLayerCollisionMap($this->layers, $dictionary); + } $this->gameScene->npcManager?->configure(is_array($map['npcs'] ?? null) ? $map['npcs'] : []); $this->installFieldEffects(); } catch (Throwable $failure) { diff --git a/src/Maps/LayerEditor.php b/src/Maps/LayerEditor.php index 876c84c..5e06c70 100644 --- a/src/Maps/LayerEditor.php +++ b/src/Maps/LayerEditor.php @@ -37,6 +37,16 @@ private function __construct() { } + /** + * Captures existing occupancy independently, without changing any rendered layer. + * @return LayerEdit + */ + public static function migratePhysicalOccupancy(ProjectMap $map): array + { + return self::describeEdit(self::record($map, 'Physical occupancy migration', + static fn() => $map->migratePhysicalOccupancy()), null); + } + /** * Adds an empty glyph layer, gameplay or decoration, at the next order. * diff --git a/src/Maps/LineInsertionPlanner.php b/src/Maps/LineInsertionPlanner.php index e5f03d8..34cc2d0 100644 --- a/src/Maps/LineInsertionPlanner.php +++ b/src/Maps/LineInsertionPlanner.php @@ -15,6 +15,7 @@ use Ichiloto\Editor\Storage\SourceSetPlan; use Ichiloto\Engine\Field\MapGridSource; use Ichiloto\Engine\Field\MapLayerSource; +use Ichiloto\Engine\Field\MapPhysicalOccupancy; use RuntimeException; use Throwable; @@ -80,6 +81,20 @@ private static function buildPlan(string $root, LineInsertion $insertion): LineI $watched[$path] = (string) file_get_contents($path); } + // Physical geometry is edited by ProjectMap, not the coordinate inventory. + // Keep its source-preserved proposal in this same reversible source set. + $map->assertSourcesUnchanged(); + $mapData = $payloads[$map->dataPath]; + if ($map->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + $mapData[MapPhysicalOccupancy::DATA_KEY] = $map->getMapDataField([MapPhysicalOccupancy::DATA_KEY]); + } + if ($map->getMapDataField([]) !== $map->data) { + $originals[$map->dataPath] = $watched[$map->dataPath]; + $proposals[$map->dataPath] = $map->captureLayerSnapshot()['source']; + $before[$map->dataPath] = PhpDataFile::getComparableValue($payloads[$map->dataPath]); + $after[$map->dataPath] = PhpDataFile::getComparableValue($mapData); + } + $commonEvents = []; foreach ($dataPaths as $path) { if (dirname($path) === $root . '/assets/Events') { @@ -112,7 +127,8 @@ private static function buildPlan(string $root, LineInsertion $insertion): LineI } } foreach ($inventory->getShifts() as $path => $shifts) { - $next = self::rewriteShifts($watched[$path], $payloads[$path], $shifts, $relative($path), $handEdits); + $next = self::rewriteShifts($proposals[$path] ?? $watched[$path], + $path === $map->dataPath ? $mapData : $payloads[$path], $shifts, $relative($path), $handEdits); if ($next !== null) { $originals[$path] = $watched[$path]; $proposals[$path] = $next['source']; diff --git a/src/ProjectMap.php b/src/ProjectMap.php index ac3372c..f898040 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -24,6 +24,8 @@ use Ichiloto\Engine\Field\MapLayerSource; use Ichiloto\Engine\Field\MapLayerSet; use Ichiloto\Engine\Field\MapCollisionResolver; +use Ichiloto\Engine\Field\MapPhysicalOccupancy; +use Ichiloto\Engine\Events\Enumerations\CollisionType; use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\IO\Console\SgrStyleState; use Ichiloto\Engine\Rendering\Tilesets\Tileset; @@ -860,7 +862,34 @@ public function getAssetRoot(): string public function validateLayerContracts(): void { $this->assertGridsAgree(); - MapCollisionResolver::resolveLayers($this->layers->getLayerSet(), $this->loadCollisionDictionary()); + $this->getResolvedCollisionMap(); + } + + /** @return int[][] The same physical cells the Engine installs at map load. */ + public function getResolvedCollisionMap(): array + { + $dictionary = $this->hasMapDataField([MapPhysicalOccupancy::DATA_KEY]) ? [] : $this->loadCollisionDictionary(); + + return MapCollisionResolver::resolveMap($this->layers->getLayerSet(), $this->editableData, $dictionary)->collisionGrid; + } + + /** Explicitly preserves current collisions independently of both presentations. */ + public function migratePhysicalOccupancy(): bool + { + $this->assertEditable(); + try { + if ($this->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + $this->getResolvedCollisionMap(); + + return false; + } + $occupancy = MapCollisionResolver::resolveLegacyOccupancy($this->layers->getLayerSet(), $this->loadCollisionDictionary()); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal(rtrim($error->getMessage(), '.') . '. Nothing was changed.', previous: $error); + } + $this->setMapDataField([MapPhysicalOccupancy::DATA_KEY], $occupancy->exportDeclaration()); + + return true; } /** @@ -918,10 +947,10 @@ private function countCollisionChanges(Closure $change): int $this->assertEditable(); $trial = clone $this->layers; $change($trial); - $dictionary = $this->loadCollisionDictionary(); + $dictionary = $this->hasMapDataField([MapPhysicalOccupancy::DATA_KEY]) ? [] : $this->loadCollisionDictionary(); try { - $before = MapCollisionResolver::resolveLayers($this->layers->getLayerSet(), $dictionary); - $after = MapCollisionResolver::resolveLayers($trial->getLayerSet(), $dictionary); + $before = MapCollisionResolver::resolveMap($this->layers->getLayerSet(), $this->editableData, $dictionary)->collisionGrid; + $after = MapCollisionResolver::resolveMap($trial->getLayerSet(), $this->editableData, $dictionary)->collisionGrid; } catch (InvalidArgumentException $error) { throw new MapSourceRefusal(rtrim($error->getMessage(), '.') . '. Nothing was changed.', previous: $error); } @@ -1368,7 +1397,7 @@ public function getPlacedEventMarkers(): array * Captures the editable grid state for undoable whole-grid mutations * (resize, event bounds rewrites). * - * @return array{tiles: array, events: array, layers: array} + * @return array{tiles: array, events: array, layers: array, occupancy: ?array} */ public function captureGridSnapshot(): array { @@ -1376,21 +1405,28 @@ public function captureGridSnapshot(): array 'tiles' => $this->layers->getBaseGrid()->cells, 'events' => $this->layers->getEventGrid()->getSymbols(), 'layers' => $this->layers->captureSnapshot(), + 'occupancy' => $this->getDeclaredPhysicalOccupancy(), ]; } /** * Restores a previously captured grid snapshot. * - * @param array{tiles: array, events: array, layers: array} $snapshot The captured state. + * Occupancy is restored with geometry, without reverting unrelated metadata. + * Older snapshots remain usable on maps without declared occupancy only. + * + * @param array{tiles: array, events: array, layers: array, occupancy?: ?array} $snapshot The captured state. * @return void */ public function restoreGridSnapshot(array $snapshot): void { $this->assertEditable(); - $this->layers->restoreSnapshot($snapshot['layers']); - $this->cachedWidth = null; - $this->touchState(); + if (! array_key_exists('occupancy', $snapshot) && $this->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + throw new MapSourceRefusal('This grid snapshot has no physical occupancy state; nothing was changed.'); + } + $layers = clone $this->layers; + $layers->restoreSnapshot($snapshot['layers']); + $this->applyGridState($layers, $snapshot['occupancy'] ?? null); } /** @@ -1900,6 +1936,9 @@ public function setMapField(string $field, mixed $value): void * Resizes the map, its event overlay and its graphical tile layers while * preserving existing content. A tile layer the Engine cannot read * refuses the resize before anything changes. + * Declared physical cells are cropped or padded with SOLID, independently + * of blank terminal glyphs and empty graphical tiles. Absent occupancy + * stays absent; this operation never migrates a legacy map. * * @param int $width The new map width. * @param int $height The new map height. @@ -1910,23 +1949,30 @@ public function resize(int $width, int $height): void $this->assertEditable(); $width = max(1, $width); $height = max(1, $height); + $occupancy = $this->getDeclaredPhysicalOccupancy(); if ($width === $this->getWidth() && $height === $this->getHeight()) { return; } - $this->layers->resize($width, $height); - - $this->cachedWidth = null; - $this->touchState(); + $layers = $this->copyEditableLayers(); + $layers->resize($width, $height); + if ($occupancy !== null) { + $occupancy = array_map(static fn(array $row): array => array_pad(array_slice($row, 0, $width), $width, CollisionType::SOLID), + array_slice($occupancy, 0, $height)); + $occupancy = array_pad($occupancy, $height, array_fill(0, $width, CollisionType::SOLID)); + } + $this->applyGridState($layers, $occupancy); } /** * Inserts `$count` blank rows before row `$at` (axis `y`) or blank * columns before column `$at` (axis `x`) into every grid the map has: - * its terminal layers, its event layer and its tile layers. Only the - * grids change; the coordinates stored in data files move through the - * project-wide insertion plan. + * its terminal layers, its event layer and its tile layers. Declared + * physical occupancy moves with these grids; new physical cells are SOLID. + * Ragged rows follow terminal geometry: columns affect only rows reaching + * `$at`, and new rows take the adjacent row's width. Absent occupancy stays + * absent. Other data coordinates move through the project-wide insertion plan. * * @throws MapSourceRefusal When the line is outside the map or a tile layer cannot take it; nothing is changed. */ @@ -1943,8 +1989,67 @@ public function insertLines(string $axis, int $at, int $count): void $this->mapId, $count, $axis === 'y' ? 'rows' : 'columns', $at)); } - $this->layers->insertLines($axis, $at, $count); + $occupancy = $this->getDeclaredPhysicalOccupancy(); + $layers = $this->copyEditableLayers(); + $layers->insertLines($axis, $at, $count); + if ($occupancy !== null) { + if ($axis === 'y') { + $beside = $occupancy[$at] ?? $occupancy[$at - 1] ?? []; + array_splice($occupancy, $at, 0, array_fill(0, $count, array_fill(0, count($beside), CollisionType::SOLID))); + } else { + foreach ($occupancy as &$row) { + if (count($row) >= $at) { + array_splice($row, $at, 0, array_fill(0, $count, CollisionType::SOLID)); + } + } + unset($row); + } + } + $this->applyGridState($layers, $occupancy); + } + /** @return list>|null Null means undeclared, never invalid. */ + private function getDeclaredPhysicalOccupancy(): ?array + { + if (! $this->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + return null; + } + try { + return new MapPhysicalOccupancy($this->editableData[MapPhysicalOccupancy::DATA_KEY], $this->layers->getLayerSet(), $this->mapId . ' occupancy') + ->exportDeclaration(); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage() . ' Nothing was changed.', previous: $error); + } + } + + /** A shallow clone alone would still share the mutable EditableGrid objects. */ + private function copyEditableLayers(): MapLayers + { + $layers = clone $this->layers; + $layers->restoreSnapshot($this->layers->captureSnapshot()); + return $layers; + } + + /** Installs geometry and its declaration only after validation and source preflight. */ + private function applyGridState(MapLayers $layers, mixed $occupancy): void + { + $next = $this->editableData; + if ($occupancy === null) { + unset($next[MapPhysicalOccupancy::DATA_KEY]); + } else { + try { + $next[MapPhysicalOccupancy::DATA_KEY] = new MapPhysicalOccupancy($occupancy, $layers->getLayerSet(), $this->mapId . ' occupancy') + ->exportDeclaration(); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage() . ' Nothing was changed.', previous: $error); + } + } + if ($next !== $this->editableData) { + $document = $this->assertDataPreservable($next); + $next = $this->alignDataWithSource($document->root(), $next); + } + $this->layers = $layers; + $this->editableData = $next; $this->cachedWidth = null; $this->touchState(); } diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 5520557..88fa14f 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -22,6 +22,7 @@ use Ichiloto\Editor\Canvas\CanvasTool; use Ichiloto\Editor\Canvas\ToolGeometry; use Ichiloto\Engine\Rendering\Tilesets\TileId; +use Ichiloto\Engine\Field\MapPhysicalOccupancy; use Ichiloto\Engine\Rendering\Tilesets\TilesetPiece; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererProtocolException; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererTransportException; @@ -582,6 +583,7 @@ public function readMap(string $mapId): array 'events' => $events, 'npcs' => $npcs, 'tileLayers' => $map->describeTileLayers(), + 'physicalOccupancy' => $map->hasMapDataField([MapPhysicalOccupancy::DATA_KEY]), ]; } @@ -1302,6 +1304,13 @@ public function createLayer(string $mapId, int $revision, string $name, bool $de return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => LayerEditor::createLayer($map, $name, $decoration)); } + /** Explicit source-preserving conversion, recorded as one shared undo step. */ + public function migratePhysicalOccupancy(string $mapId, int $revision): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => + LayerEditor::migratePhysicalOccupancy($map)); + } + /** * Renames a glyph layer as one undo step. A rename that changes the * map's collisions is asked about; it is made when asked again confirmed. diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 15b5fc4..02515dd 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -225,6 +225,10 @@ private function dispatch(string $method, array $params): array self::readChoices($params), is_string($params['label'] ?? null) ? $params['label'] : 'Paint', ), + 'map.migrateOccupancy' => $session->migratePhysicalOccupancy( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + ), 'layer.create' => $session->createLayer( self::requireString($params, 'map'), self::requireInt($params, 'revision'), diff --git a/tests/Unit/MapPhysicalOccupancyGeometryTest.php b/tests/Unit/MapPhysicalOccupancyGeometryTest.php new file mode 100644 index 0000000..15e71b7 --- /dev/null +++ b/tests/Unit/MapPhysicalOccupancyGeometryTest.php @@ -0,0 +1,449 @@ + 'home',\n"; + file_put_contents($directory . '/test-map.data.php', " 'Synthetic geometry',\n 'region' => '',\n" + . " 'description' => strtoupper('authored'),\n 'note' => \$note,\n" + . $tileset . " 'events' => [],\n // Physical ground, not terminal art.\n" + . " 'occupancy' => {$declaration},\n];\n"); +} + +function loadOccupancyGeometryMap(string $root): ProjectMap +{ + return ProjectMap::fromDirectory($root . '/assets/Maps', $root . '/assets/Maps/test-map'); +} + +it('resizes physical cells with terminal event and graphical geometry and pads only with solid cells', function (bool $legacy, bool $ragged) { + $root = createOccupancyGeometryProject($ragged, $legacy); + $map = loadOccupancyGeometryMap($root); + $original = $map->getResolvedCollisionMap(); + $source = (string) file_get_contents($map->dataPath); + $map->resize(6, 3); + $expected = array_map(static fn(array $row): array => array_pad($row, 6, CollisionType::SOLID->value), $original); + $expected[] = array_fill(0, 6, CollisionType::SOLID->value); + expect($map->getResolvedCollisionMap())->toBe($expected) + ->and($map->getTileSymbol(5, 2))->toBe(' ') + ->and($map->getEventMarkerAt(5, 2))->toBeNull() + ->and(file_get_contents($map->dataPath))->toBe($source); + $map->save(); + expect(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($expected) + ->and(file_get_contents($map->dataPath))->toContain("strtoupper('authored')", "'note' => \$note", '// Physical ground, not terminal art.'); + if (! $legacy) { + expect(readTileRows($map->directory . '/graphics/01.floor.tiles.php')[2])->toBe(array_fill(0, 6, 0)); + } + $map->resize(2, 1); + $map->save(); + expect(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe([array_slice($original[0], 0, 2)]); +})->with([false, true])->with([false, true]); + +it('inserts physical cells at the same line as presentation cells including ragged row and column edges', function (bool $legacy, bool $ragged, string $axis, int $at) { + $root = createOccupancyGeometryProject($ragged, $legacy); + $map = loadOccupancyGeometryMap($root); + $expected = $map->getResolvedCollisionMap(); + if ($axis === 'y') { + $beside = $expected[$at] ?? $expected[$at - 1]; + array_splice($expected, $at, 0, array_fill(0, 2, array_fill(0, count($beside), CollisionType::SOLID->value))); + } else { + foreach ($expected as &$row) { + if (count($row) >= $at) { + array_splice($row, $at, 0, array_fill(0, 2, CollisionType::SOLID->value)); + } + } + unset($row); + } + $before = $map->captureGridSnapshot(); + $map->insertLines($axis, $at, 2); + $after = $map->captureGridSnapshot(); + expect($map->getResolvedCollisionMap())->toBe($expected) + ->and(array_map(count(...), $map->getLayerSet()->getComposedGrid()))->toBe(array_map(count(...), $expected)); + $map->save(); + expect(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($expected); + $map->restoreGridSnapshot($before); + $map->save(); + expect($map->getMapDataField(['occupancy']))->toBe($before['occupancy']); + $map->restoreGridSnapshot($after); + $map->save(); + expect(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($expected); +})->with([false, true])->with([false, true])->with([ + 'first column' => ['x', 0], 'middle column' => ['x', 1], 'ragged boundary' => ['x', 2], 'last column' => ['x', 4], + 'first row' => ['y', 0], 'middle row' => ['y', 1], 'last row' => ['y', 2], +]); + +it('keeps resize history meaningful across save undo redo and unrelated metadata edits', function () { + $root = createOccupancyGeometryProject(); + $map = loadOccupancyGeometryMap($root); + $originals = sourceHashTree($map->directory); + $before = $map->captureGridSnapshot(); + $map->resize(6, 3); + $after = $map->captureGridSnapshot(); + $history = new CommandHistory(); + $history->record(new GenericCommand('Resize', fn() => $map->restoreGridSnapshot($after), fn() => $map->restoreGridSnapshot($before))); + $map->save(); + $resized = sourceHashTree($map->directory); + expect($map->isDirty())->toBeFalse(); + $history->undo(); + expect($map->isDirty())->toBeTrue()->and($map->getMapDataField(['occupancy']))->toBe($before['occupancy']); + $map->save(); + expect(sourceHashTree($map->directory))->toBe($originals); + $history->redo(); + $map->save(); + expect(sourceHashTree($map->directory))->toBe($resized)->and($map->isDirty())->toBeFalse(); + $map->setMapField('name', 'Later metadata'); + $history->undo(); + expect($map->getDisplayName())->toBe('Later metadata')->and($map->getMapDataField(['occupancy']))->toBe($before['occupancy']); +}); + +it('carries physical geometry through the existing inspector resize command', function () { + $root = createOccupancyGeometryProject(); + [$editor, $map] = layeredCanvasEditor($root); + $before = sourceHashTree($map->directory); + $physical = $map->getResolvedCollisionMap(); + callEditorMethod($editor, 'setEditingMode', 'map'); + callEditorMethod($editor, 'applyInspectorFieldValue', ['label' => ' X', 'value' => '4', 'target' => 'map-size', 'field' => 'width'], '6'); + expect($map->getResolvedCollisionMap()[0])->toBe([...$physical[0], CollisionType::SOLID->value, CollisionType::SOLID->value]); + $map->save(); + callEditorMethod($editor, 'performUndo'); + $map->save(); + expect(sourceHashTree($map->directory))->toBe($before)->and($map->getResolvedCollisionMap())->toBe($physical); + callEditorMethod($editor, 'performRedo'); + expect($map->getResolvedCollisionMap()[0])->toBe([...$physical[0], CollisionType::SOLID->value, CollisionType::SOLID->value]); +}); + +it('does not add physical declarations during legacy geometry edits or snapshot restoration', function (string $operation) { + $root = mapGraphicsProject(ragged: true); + $map = loadLayeredMap($root); + $source = file_get_contents($map->dataPath); + $before = $map->captureGridSnapshot(); + if ($operation === 'resize') { + $map->resize(5, 3); + } else { + $map->insertLines($operation, 1, 1); + } + $map->save(); + expect($map->hasMapDataField(['occupancy']))->toBeFalse()->and(file_get_contents($map->dataPath))->toBe($source); + $map->restoreGridSnapshot($before); + $map->save(); + expect($map->hasMapDataField(['occupancy']))->toBeFalse()->and(file_get_contents($map->dataPath))->toBe($source); +})->with(['resize', 'x', 'y']); + +it('refuses malformed declared physical data before grid state or history changes', function (mixed $rows, string $operation) { + $root = createOccupancyGeometryProject(); + $directory = $root . '/assets/Maps/test-map'; + writeGeometryOccupancyData($directory, $rows); + $map = loadOccupancyGeometryMap($root); + $data = $map->getMapDataField([]); + $grids = $map->getGridSources(); + $version = $map->getContentVersion(); + $hashes = sourceHashTree($root); + $history = new CommandHistory(); + $apply = match ($operation) { + 'resize' => fn() => $map->resize(6, 3), + 'no-op resize' => fn() => $map->resize(4, 2), + 'snapshot' => fn() => $map->captureGridSnapshot(), + default => fn() => $map->insertLines($operation, 1, 1), + }; + expect(function () use ($apply, $history): void { + $apply(); + $history->record(new GenericCommand('Geometry', fn() => null, fn() => null)); + })->toThrow(MapSourceRefusal::class, 'occupancy'); + expect($map->getGridSources())->toBe($grids)->and($map->getMapDataField([]))->toBe($data) + ->and($map->getContentVersion())->toBe($version)->and($history->count())->toBe(0) + ->and($map->isDirty())->toBeFalse()->and(sourceHashTree($root))->toBe($hashes); +})->with([ + 'declared null' => [null], 'non-list' => [['one' => []]], 'missing rows' => [[]], + 'short row' => [[[CollisionType::NONE], array_fill(0, 4, CollisionType::NONE)]], + 'integer cells' => [array_fill(0, 2, array_fill(0, 4, 0))], + 'unresolved passage' => [array_fill(0, 2, array_fill(0, 4, CollisionType::PASS_THROUGH))], +])->with(['resize', 'no-op resize', 'x', 'y', 'snapshot']); + +it('refuses computed physical source edits before mutation rather than flattening them', function (string $operation, bool $wholeSource) { + $root = createOccupancyGeometryProject(); + $directory = $root . '/assets/Maps/test-map'; + $rows = loadOccupancyGeometryMap($root)->getMapDataField(['occupancy']); + writeGeometryOccupancyData($directory, $rows, expression: 'array_values(' . PhpValueExporter::export($rows, 1) . ')'); + if ($wholeSource) { + $path = $directory . '/test-map.data.php'; + file_put_contents($path, str_replace('return [', 'return array_merge([', str_replace("];\n", "]);\n", (string) file_get_contents($path)))); + } + $map = loadOccupancyGeometryMap($root); + $before = $map->captureGridSnapshot(); + $version = $map->getContentVersion(); + $hashes = sourceHashTree($root); + expect(fn() => $operation === 'resize' ? $map->resize(6, 3) : $map->insertLines($operation, 1, 1)) + ->toThrow(MapSourceRefusal::class); + expect($map->captureGridSnapshot())->toBe($before)->and($map->getContentVersion())->toBe($version) + ->and($map->isDirty())->toBeFalse()->and(sourceHashTree($root))->toBe($hashes); +})->with(['resize', 'x', 'y'])->with([false, true]); + +it('refuses unsafe occupancy snapshots before changing grids or undo stacks', function (string $failure) { + $root = createOccupancyGeometryProject(); + $map = loadOccupancyGeometryMap($root); + $before = $map->captureGridSnapshot(); + $map->resize(6, 3); + $after = $map->captureGridSnapshot(); + $bad = $before; + if ($failure === 'old snapshot') { + unset($bad['occupancy']); + } elseif ($failure === 'non-array snapshot') { + $bad['occupancy'] = 'not physical rows'; + } elseif ($failure === 'wrong geometry') { + $bad['occupancy'] = $after['occupancy']; + } else { + $bad['occupancy'][0][0] = CollisionType::PASS_THROUGH; + } + $history = new CommandHistory(); + $history->record(new GenericCommand('Resize', fn() => $map->restoreGridSnapshot($after), fn() => $map->restoreGridSnapshot($bad))); + $version = $map->getContentVersion(); + $hashes = sourceHashTree($root); + expect(fn() => $history->undo())->toThrow(MapSourceRefusal::class); + expect($map->captureGridSnapshot())->toBe($after)->and($map->getContentVersion())->toBe($version) + ->and($history->count())->toBe(1)->and($history->canRedo())->toBeFalse()->and(sourceHashTree($root))->toBe($hashes); +})->with(['old snapshot', 'malformed snapshot', 'non-array snapshot', 'wrong geometry']); + +it('retains no-op and older snapshot compatibility when physical state is already valid', function (bool $declared) { + $root = $declared ? createOccupancyGeometryProject() : mapGraphicsProject(); + $map = loadOccupancyGeometryMap($root); + $snapshot = $map->captureGridSnapshot(); + $version = $map->getContentVersion(); + $hashes = sourceHashTree($root); + $map->resize(4, 2); + expect($map->captureGridSnapshot())->toBe($snapshot)->and($map->getContentVersion())->toBe($version) + ->and($map->isDirty())->toBeFalse()->and(sourceHashTree($root))->toBe($hashes); + if (! $declared) { + unset($snapshot['occupancy']); + $map->resize(6, 3); + $map->restoreGridSnapshot($snapshot); + expect($map->isDirty())->toBeFalse()->and($map->hasMapDataField(['occupancy']))->toBeFalse(); + } +})->with([false, true]); + +it('refuses external source changes before geometry or history mutation', function (string $operation) { + $root = createOccupancyGeometryProject(); + $map = loadOccupancyGeometryMap($root); + $before = $map->captureGridSnapshot(); + $version = $map->getContentVersion(); + file_put_contents($map->dataPath, (string) file_get_contents($map->dataPath) . "// External author edit.\n"); + $hashes = sourceHashTree($root); + $history = new CommandHistory(); + $apply = match ($operation) { + 'resize' => fn() => $map->resize(6, 3), + 'restore' => fn() => $map->restoreGridSnapshot($before), + default => fn() => $map->insertLines($operation, 1, 1), + }; + $history->record(new GenericCommand('Geometry', fn() => null, $apply)); + expect(fn() => $history->undo())->toThrow(MapSourceRefusal::class, 'changed after opening'); + expect($map->captureGridSnapshot())->toBe($before)->and($map->getContentVersion())->toBe($version) + ->and($history->count())->toBe(1)->and($history->canRedo())->toBeFalse()->and(sourceHashTree($root))->toBe($hashes); +})->with(['resize', 'x', 'y', 'restore']); + +it('keeps declared physical data untouched when graphical geometry preflight refuses', function (string $operation) { + $root = createOccupancyGeometryProject(); + writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "0 bad 0 0\n0 0 0 5"); + $map = loadOccupancyGeometryMap($root); + $before = $map->captureGridSnapshot(); + $version = $map->getContentVersion(); + $hashes = sourceHashTree($root); + expect(fn() => $operation === 'resize' ? $map->resize(6, 3) : $map->insertLines($operation, 1, 1)) + ->toThrow(MapSourceRefusal::class, '02.decor.tiles.php'); + expect($map->captureGridSnapshot())->toBe($before)->and($map->getContentVersion())->toBe($version) + ->and($map->isDirty())->toBeFalse()->and(sourceHashTree($root))->toBe($hashes); +})->with(['resize', 'x', 'y']); + +it('rolls back physical data and every grid together when geometry save installation fails', function (string $operation) { + $root = createOccupancyGeometryProject(); + $map = loadOccupancyGeometryMap($root); + $hashes = sourceHashTree($map->directory); + if ($operation === 'resize') { + $map->resize(6, 3); + } else { + $map->insertLines($operation, 1, 1); + } + $physical = $map->getResolvedCollisionMap(); + $failure = null; + try { + $map->save(files: new FailingFileSetOperations(failures: ['move' => [$map->eventPath]])); + } catch (FileSetTransactionFailure $thrown) { + $failure = $thrown; + } + expect($failure)->not->toBeNull()->and($failure->wasRolledBack)->toBeTrue() + ->and(sourceHashTree($map->directory))->toBe($hashes)->and($map->getResolvedCollisionMap())->toBe($physical) + ->and($map->isDirty())->toBeTrue(); + $map->save(); + expect(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($physical)->and($map->isDirty())->toBeFalse(); +})->with(['resize', 'x', 'y']); + +it('includes physical geometry in the project insertion source set with coordinate edits and exact undo', function (string $axis, bool $withCoordinates) { + $root = createOccupancyGeometryProject(); + $directory = $root . '/assets/Maps/test-map'; + $path = $directory . '/test-map.data.php'; + if ($withCoordinates) { + file_put_contents($path, str_replace("'events' => [],", "'npcs' => [['id' => 'visitor', 'name' => 'Synthetic visitor', 'sprite' => 'v', 'x' => 2, 'y' => 1]],\n" + . " 'events' => ['D' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\SleepEventTrigger',\n" + . " 'data' => ['spawnPoint' => ['x' => 2, 'y' => 1]]]],", (string) file_get_contents($path))); + } + $original = loadOccupancyGeometryMap($root); + $before = sourceHashTree($root); + $originalPhysical = $original->getResolvedCollisionMap(); + $expected = $originalPhysical; + if ($axis === 'x') { + foreach ($expected as &$row) { + array_splice($row, 1, 0, [CollisionType::SOLID->value, CollisionType::SOLID->value]); + } + unset($row); + } else { + array_splice($expected, 1, 0, array_fill(0, 2, array_fill(0, 4, CollisionType::SOLID->value))); + } + $plan = LineInsertionPlanner::planProject($root, 'test-map', $axis, 1, 2); + expect(sourceHashTree($root))->toBe($before)->and($plan->getChangedPaths())->toContain($path); + $plan->apply(); + $after = sourceHashTree($root); + $map = loadOccupancyGeometryMap($root); + expect($map->getResolvedCollisionMap())->toBe($expected) + ->and(file_get_contents($path))->toContain("strtoupper('authored')", "'note' => \$note", '// Physical ground, not terminal art.'); + if ($withCoordinates) { + $point = $axis === 'x' ? ['x' => 4, 'y' => 1] : ['x' => 2, 'y' => 3]; + expect(['x' => $map->getMapDataField(['npcs', 0, 'x']), 'y' => $map->getMapDataField(['npcs', 0, 'y'])])->toBe($point) + ->and($map->getEventField('D', ['data', 'spawnPoint']))->toBe($point); + } + $plan->revert(); + expect(sourceHashTree($root))->toBe($before)->and(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($originalPhysical); + $plan->apply(); + expect(sourceHashTree($root))->toBe($after)->and(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($expected); +})->with(['x', 'y'])->with([false, true]); + +it('refuses a project insertion with malformed or unrewritable occupancy before proposing any writes', function (bool $malformed) { + $root = createOccupancyGeometryProject(); + $map = loadOccupancyGeometryMap($root); + $rows = $map->getMapDataField(['occupancy']); + writeGeometryOccupancyData($map->directory, $malformed ? null : $rows, + expression: $malformed ? null : 'array_values(' . PhpValueExporter::export($rows, 1) . ')'); + $before = sourceHashTree($root); + expect(fn() => LineInsertionPlanner::planProject($root, 'test-map', 'y', 1, 1))->toThrow(MapSourceRefusal::class) + ->and(sourceHashTree($root))->toBe($before); +})->with([false, true]); + +it('preserves unrelated object expressions in isolated insertion readback with and without declared occupancy', function (bool $declared) { + $root = $declared ? createOccupancyGeometryProject() : mapGraphicsProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($path, str_replace("'events' => [],", "'events' => [], 'futureObject' => new \\DateTimeImmutable('2001-02-03'),", + (string) file_get_contents($path))); + $before = sourceHashTree($root); + $plan = LineInsertionPlanner::planProject($root, 'test-map', 'x', 1, 1); + $plan->apply(); + $map = loadOccupancyGeometryMap($root); + expect($map->hasMapDataField(['occupancy']))->toBe($declared) + ->and($map->getMapDataField(['futureObject'])->format('Y-m-d'))->toBe('2001-02-03') + ->and(file_get_contents($path))->toContain("new \\DateTimeImmutable('2001-02-03')"); + if ($declared) { + expect(array_map(count(...), $map->getResolvedCollisionMap()))->toBe([5, 5]); + } + $plan->revert(); + expect(sourceHashTree($root))->toBe($before); +})->with([false, true]); + +it('retains physical geometry in the existing revisioned insertion command and its undo redo', function (string $axis) { + $root = createOccupancyGeometryProject(); + $session = EditorSession::open($root); + $read = $session->readMap('test-map'); + $before = sourceHashTree($root); + $physical = loadOccupancyGeometryMap($root)->getResolvedCollisionMap(); + $question = $session->insertMapLines('test-map', $read['revision'], $axis, 1, 2); + expect($question['paths'])->toContain('assets/Maps/test-map/test-map.data.php') + ->and(sourceHashTree($root))->toBe($before); + $session->insertMapLines('test-map', $read['revision'], $axis, 1, 2, 'write', $question['confirm']); + $after = sourceHashTree($root); + $inserted = loadOccupancyGeometryMap($root)->getResolvedCollisionMap(); + expect($inserted)->not->toBe($physical)->and($session->readMap('test-map')['physicalOccupancy'])->toBeTrue(); + $session->undo(); + expect(sourceHashTree($root))->toBe($before)->and(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($physical); + $session->redo(); + expect(sourceHashTree($root))->toBe($after)->and(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($inserted); +})->with(['x', 'y']); + +it('follows legacy geometry exactly when a ragged map contains an empty interior row', function (string $operation, int $at) { + $root = createOccupancyGeometryProject(legacy: true); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/test-map.map.php', MapGridSource::buildSource("...\n\n..", 'GROUND')); + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource(" E \n\n ", 'EVENTS')); + $rows = [[CollisionType::EXIT, CollisionType::NONE, CollisionType::COUNTER], [], [CollisionType::ENCOUNTER, CollisionType::ITEM]]; + writeGeometryOccupancyData($directory, $rows, legacy: true); + $map = loadOccupancyGeometryMap($root); + $expected = $rows; + if ($operation === 'resize') { + $expected = array_map(static fn(array $row): array => array_pad($row, 4, CollisionType::SOLID), $expected); + $expected[] = array_fill(0, 4, CollisionType::SOLID); + $map->resize(4, 4); + } elseif ($operation === 'y') { + $beside = $expected[$at] ?? $expected[$at - 1]; + array_splice($expected, $at, 0, [array_fill(0, count($beside), CollisionType::SOLID)]); + $map->insertLines('y', $at, 1); + } else { + foreach ($expected as &$row) { + if (count($row) >= $at) { + array_splice($row, $at, 0, [CollisionType::SOLID]); + } + } + unset($row); + $map->insertLines('x', $at, 1); + } + expect($map->getMapDataField(['occupancy']))->toBe($expected) + ->and(array_map(count(...), $map->getLayerSet()->getComposedGrid()))->toBe(array_map(count(...), $expected)); + $map->save(); + expect(loadOccupancyGeometryMap($root)->getMapDataField(['occupancy']))->toBe($expected); +})->with(['resize' => ['resize', 0], 'empty column' => ['x', 0], 'short columns' => ['x', 3], + 'empty adjacent row' => ['y', 1], 'append row' => ['y', 3]]); + +it('grows an empty legacy physical row deliberately and refuses unrepresentable empty row insertions without mutation', function () { + $root = createOccupancyGeometryProject(legacy: true); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/test-map.map.php', MapGridSource::buildSource('', 'GROUND')); + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource('', 'EVENTS')); + writeGeometryOccupancyData($directory, [[]], legacy: true); + $map = loadOccupancyGeometryMap($root); + $before = $map->captureGridSnapshot(); + $version = $map->getContentVersion(); + $hashes = sourceHashTree($root); + expect(fn() => $map->insertLines('y', 0, 1))->toThrow(MapSourceRefusal::class, 'Nothing was changed.'); + expect($map->captureGridSnapshot())->toBe($before)->and($map->getContentVersion())->toBe($version) + ->and($map->isDirty())->toBeFalse()->and(sourceHashTree($root))->toBe($hashes); + $map->resize(2, 2); + $map->save(); + expect(loadOccupancyGeometryMap($root)->getMapDataField(['occupancy']))->toBe(array_fill(0, 2, array_fill(0, 2, CollisionType::SOLID))); +}); diff --git a/tests/Unit/Session/PhysicalOccupancySessionTest.php b/tests/Unit/Session/PhysicalOccupancySessionTest.php new file mode 100644 index 0000000..632f911 --- /dev/null +++ b/tests/Unit/Session/PhysicalOccupancySessionTest.php @@ -0,0 +1,143 @@ + CollisionType::NONE, "/" => CollisionType::SOLID, "x" => CollisionType::COUNTER];'); + $session = EditorSession::open($root); + $map = new ReflectionProperty(EditorSession::class, 'workspace')->getValue($session)->getMapByIndex(0); + $original = sourceHashTree($root); + $originalData = $map->getMapDataField([]); + $terminal = $map->getLayerSet()->getComposedGrid(); + $collision = $map->getResolvedCollisionMap(); + $revision = $session->readMap('test-map')['revision']; + expect($session->readMap('test-map')['physicalOccupancy'])->toBeFalse(); + + expect($session->migratePhysicalOccupancy('test-map', $revision)) + ->toMatchArray(['status' => 'applied', 'changed' => true]) + ->and($map->getLayerSet()->getComposedGrid())->toBe($terminal) + ->and($map->getResolvedCollisionMap())->toBe($collision) + ->and($session->readMap('test-map')['physicalOccupancy'])->toBeTrue() + ->and(sourceHashTree($root))->toBe($original) + ->and(fn() => $session->migratePhysicalOccupancy('test-map', $revision)) + ->toThrow(SessionRefusal::class, 'changed since revision'); + $session->saveMap('test-map'); + $migrated = sourceHashTree($root); + expect($migrated)->not->toBe($original) + ->and(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getResolvedCollisionMap())->toBe($collision); + expect($session->undo())->toMatchArray(['label' => 'Physical occupancy migration', 'maps' => ['test-map']]); + $session->saveMap('test-map'); + expect(sourceHashTree($root))->toBe($original) + ->and($map->getMapDataField([]))->toBe($originalData); + $session->redo(); + $session->saveMap('test-map'); + expect(sourceHashTree($root))->toBe($migrated) + ->and($map->getResolvedCollisionMap())->toBe($collision); + $current = $session->readMap('test-map')['revision']; + expect($session->migratePhysicalOccupancy('test-map', $current)) + ->toMatchArray(['status' => 'applied', 'changed' => false]) + ->and($session->readMap('test-map')['revision'])->toBe($current); +}); + +it('serves explicit occupancy migration through the existing revisioned session protocol', function () { + $root = layeredMapProject(); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(int $id, string $method, array $params): array => + $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params])); + $request(0, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + $revision = $request(1, 'map.read', ['map' => 'test-map'])['result']['revision']; + expect($request(2, 'map.migrateOccupancy', ['map' => 'test-map', 'revision' => $revision])['result']) + ->toMatchArray(['status' => 'applied', 'changed' => true]) + ->and($request(3, 'map.migrateOccupancy', ['map' => 'test-map', 'revision' => $revision])['error']['kind']) + ->toBe('refusal') + ->and($request(4, 'map.migrateOccupancy', ['map' => 'test-map', 'revision' => 'old'])['error']['kind']) + ->toBe('request'); +}); + +it('keeps physical cells unchanged across later presentation and layer edits', function () { + $root = layeredMapProject(); + file_put_contents($root . '/assets/Maps/collisions.php', ' CollisionType::NONE, "/" => CollisionType::SOLID, "x" => CollisionType::COUNTER];'); + $map = loadLayeredMap($root); + $collision = $map->getResolvedCollisionMap(); + $map->migratePhysicalOccupancy(); + $map->setLayerCell('map:1', 0, 0, '/'); + expect($map->getResolvedCollisionMap())->toBe($collision) + ->and($map->countDecorationCollisionChanges('map:4', true))->toBe(0); + $map->setLayerDecoration('map:4', true); + $map->renameLayer('map:4', 'appearance'); + $map->save(); + expect(loadLayeredMap($root)->getResolvedCollisionMap())->toBe($collision); +}); + +it('refuses malformed existing occupancy without source edits revision changes or history entries', function () { + $root = layeredMapProject(); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, ' [], "occupancy" => null];'); + $session = EditorSession::open($root); + $map = new ReflectionProperty(EditorSession::class, 'workspace')->getValue($session)->getMapByIndex(0); + $source = sourceHashTree($root); + $revision = $session->readMap('test-map')['revision']; + expect(fn() => $map->migratePhysicalOccupancy())->toThrow(MapSourceRefusal::class, 'occupancy') + ->and(fn() => $session->migratePhysicalOccupancy('test-map', $revision)) + ->toThrow(SessionRefusal::class, 'occupancy') + ->and($session->readMap('test-map')['revision'])->toBe($revision) + ->and($session->readMap('test-map')['dirty'])->toBeFalse() + ->and($session->undo())->toMatchArray(['label' => null, 'maps' => [], 'revisions' => [], 'databases' => []]) + ->and(sourceHashTree($root))->toBe($source); +}); + +it('refuses explicit migration when source cannot preserve the added declaration', function () { + $root = layeredMapProject(); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, ' []]; return $data;'); + $map = loadLayeredMap($root); + $before = sourceHashTree($root); + $snapshot = $map->captureLayerSnapshot(); + expect(fn() => $map->migratePhysicalOccupancy())->toThrow(MapSourceRefusal::class) + ->and(sourceHashTree($root))->toBe($before) + ->and($map->captureLayerSnapshot())->toBe($snapshot) + ->and($map->getMapDataField(['occupancy']))->toBeNull(); +}); + +it('uses declared occupancy in the shared field preview and refuses malformed replacement', function () { + $root = makeTemporaryProject(); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/test-map.map.php', MapGridSource::buildSource('###', 'MAP')); + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource(' ', 'EVENT')); + $path = $directory . '/test-map.data.php'; + $data = ['events' => [], 'occupancy' => [[CollisionType::NONE, CollisionType::COUNTER, CollisionType::SOLID]]]; + file_put_contents($path, ' 0.0); + try { + expect($field->mapFailure)->toBeNull() + ->and($field->scene->previewMap->getCollision(0, 0))->toBe(CollisionType::NONE) + ->and($field->scene->previewMap->getCollision(1, 0))->toBe(CollisionType::COUNTER) + ->and($field->scene->previewMap->getCollision(2, 0))->toBe(CollisionType::SOLID) + ->and(sourceHashTree($root))->toBe($source); + file_put_contents($path, ' [], "occupancy" => null];'); + $field->run(function () use ($field): void { + expect(fn() => $field->scene->previewMap->loadForPreview('test-map')) + ->toThrow(InvalidArgumentException::class, 'Map occupancy must be a zero-based list'); + }); + expect($field->mapFailure)->toContain('Map occupancy must be a zero-based list') + ->and($field->scene->previewMap->mapData)->toBe([]); + } finally { + $field->dispose(); + } +}); From 4aab439358218515cb61d1c3164df1330e1d074e Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 10 Oct 2026 06:58:24 +0200 Subject: [PATCH 236/344] fix(source): preserve enum case edits and reject fake imports Replace only a verified enum case token while preserving aliases, spacing and comments. Restrict class-import discovery to real simple imports before the returned array; strings and nested uses no longer count as imports. Unsupported enum expressions remain refused. --- src/Cutscenes/Source/ArraySourceWriter.php | 102 +++++++++++- tests/Fixtures/SourceWriterEnums.php | 27 +++ .../Unit/Cutscenes/ArraySourceWriterTest.php | 154 ++++++++++++++++++ 3 files changed, 274 insertions(+), 9 deletions(-) create mode 100644 tests/Fixtures/SourceWriterEnums.php diff --git a/src/Cutscenes/Source/ArraySourceWriter.php b/src/Cutscenes/Source/ArraySourceWriter.php index 406a037..85254d6 100644 --- a/src/Cutscenes/Source/ArraySourceWriter.php +++ b/src/Cutscenes/Source/ArraySourceWriter.php @@ -79,6 +79,9 @@ final class ArraySourceWriter /** @var array Variable names claimed by this plan. */ private array $claimedVariables = []; + /** @var array|null Class imports in the returned array's scope. */ + private ?array $classImports = null; + private function __construct(private readonly PhpArraySourceDocument $document) { } @@ -140,6 +143,12 @@ private function diff(SourceNode $node, mixed $old, mixed $new, array $path): vo return; } + if ($node->kind === SourceNode::EXPRESSION && ($edit = $this->findEnumCaseEdit($node, $old, $new)) !== null) { + $this->edits[] = $edit; + + return; + } + // An enum case's value, written as `Enum::CASE->value`, stays written // that way: the new value is the matching case of the same enum. if ($node->kind === SourceNode::EXPRESSION && ($literal = $this->findEnumValueExpression($node, $new)) !== null) { @@ -781,11 +790,35 @@ private static function identityOf(mixed $entry): ?string } /** - * Plans replacing a node with the literal for a value. + * Literal enum cases retain class spelling, spacing and comments; only + * the case-name token changes. Aliases to cases are not case literals. * - * @param array $path - * @return array{0: int, 1: int, 2: string} + * @return array{0: int, 1: int, 2: string}|null */ + private function findEnumCaseEdit(SourceNode $node, mixed $old, mixed $new): ?array + { + if (! $old instanceof \UnitEnum || ! $new instanceof \UnitEnum || $old::class !== $new::class) { + return null; + } + $prefix = 'document->source, $node->start, $node->end - $node->start)), + static fn(\PhpToken $token): bool => ! $token->is([T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT]))); + if (count($tokens) !== 3 + || ! $tokens[0]->is([T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED]) + || ! $tokens[1]->is(T_DOUBLE_COLON) || ! $tokens[2]->is(T_STRING) + || $tokens[2]->text !== $old->name) { + return null; + } + $class = $this->resolveClassName($tokens[0]->text); + if ($class === null || strcasecmp($class, $old::class) !== 0) { + return null; + } + $start = $node->start + $tokens[2]->pos - strlen($prefix); + + return [$start, $start + strlen($tokens[2]->text), $new->name]; + } + /** * The expression for a new value where the file writes an enum case's * value as `Enum::CASE->value`: the same enum, the case holding the new @@ -819,19 +852,70 @@ private function resolveClassName(string $name): ?string } $first = explode('\\', $name)[0]; - preg_match_all('/^\s*use\s+([A-Za-z0-9_\\\\]+)(?:\s+as\s+([A-Za-z0-9_]+))?\s*;/m', $this->document->source, $uses, PREG_SET_ORDER); + $class = $this->getClassImports()[strtolower($first)] ?? null; - foreach ($uses as $use) { - $alias = ($use[2] ?? '') !== '' ? $use[2] : substr((string) strrchr('\\' . $use[1], '\\'), 1); + return $class === null ? null : $class . substr($name, strlen($first)); + } - if ($alias === $first) { - return $use[1] . substr($name, strlen($first)); + /** @return array Simple class imports, never text inside strings or nested uses. */ + private function getClassImports(): array + { + if ($this->classImports !== null) { + return $this->classImports; + } + $imports = []; + $tokens = array_values(array_filter(\PhpToken::tokenize($this->document->source), + static fn(\PhpToken $token): bool => ! $token->is([T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT]))); + $depth = 0; + + foreach ($tokens as $index => $token) { + if ($token->pos >= $this->document->root()->start) { + break; + } + if ($token->is(['(', '[', '{', T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES])) { + $depth++; + } elseif ($token->is([')', ']', '}'])) { + $depth--; + } + if ($depth !== 0) { + continue; + } + if ($token->is(T_NAMESPACE)) { + $imports = []; + continue; + } + if (! $token->is(T_USE)) { + continue; + } + $class = $tokens[$index + 1] ?? null; + if ($class === null || ! $class->is([T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED])) { + continue; + } + $end = $index + 2; + $name = ltrim($class->text, '\\'); + $alias = substr((string) strrchr('\\' . $name, '\\'), 1); + if (($tokens[$end] ?? null)?->is(T_AS)) { + $explicitAlias = $tokens[$end + 1] ?? null; + if ($explicitAlias === null || ! $explicitAlias->is(T_STRING)) { + continue; + } + $alias = $explicitAlias->text; + $end += 2; + } + if (($tokens[$end] ?? null)?->is(';')) { + $imports[strtolower($alias)] = $name; } } - return null; + return $this->classImports = $imports; } + /** + * Plans replacing a node with the literal for a value. + * + * @param array $path + * @return array{0: int, 1: int, 2: string} + */ private function replacementFor(SourceNode $node, array $path, mixed $value): array { return $this->document->replaceValueEdit($path, $this->literalFor($value, $path)); diff --git a/tests/Fixtures/SourceWriterEnums.php b/tests/Fixtures/SourceWriterEnums.php new file mode 100644 index 0000000..d1228f5 --- /dev/null +++ b/tests/Fixtures/SourceWriterEnums.php @@ -0,0 +1,27 @@ + [['name', 'text'], "['type' => 'text']", true], 'every one' => [['type', 'name', 'text'], '[]', false], ]); + +it('rewrites backed and unit enum literals without changing class spelling comments or key order', function ( + string $import, string $expression, string $replacement, UnitEnum $first, UnitEnum $second, +) { + $source = " 'kept',\n // Authored case heading.\n" + . " 'case' => {$expression}, // Authored tail.\n" + . " 'after' => strtoupper('kept'),\n);\n"; + $document = PhpArraySourceDocument::parse($source); + $old = evaluateSource($source); + $new = $old; + $new['case'] = $second; + expect($old['case'])->toBe($first); + $rewritten = ArraySourceWriter::rewrite($document, $old, $new); + expect($rewritten->source)->toBe(str_replace($expression, $replacement, $source)) + ->and(evaluateSource($rewritten->source))->toBe($new) + ->and(array_keys(evaluateSource($rewritten->source)))->toBe(['before', 'case', 'after']) + ->and($document->source)->toBe($source) + ->and(ArraySourceWriter::rewrite($rewritten, $new, $old)->source)->toBe($source); +})->with([ + 'backed import' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterBacked;', + 'SourceWriterBacked::FIRST', 'SourceWriterBacked::SECOND', SourceWriterBacked::FIRST, SourceWriterBacked::SECOND], + 'unit import' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterUnit;', + 'SourceWriterUnit::FIRST', 'SourceWriterUnit::SECOND', SourceWriterUnit::FIRST, SourceWriterUnit::SECOND], + 'backed alias' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterBacked as Choice;', + 'Choice::FIRST', 'Choice::SECOND', SourceWriterBacked::FIRST, SourceWriterBacked::SECOND], + 'case-insensitive alias' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterBacked as Choice;', + 'choice::FIRST', 'choice::SECOND', SourceWriterBacked::FIRST, SourceWriterBacked::SECOND], + 'inline import' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterBacked as Choice; /* kept */', + 'Choice::FIRST', 'Choice::SECOND', SourceWriterBacked::FIRST, SourceWriterBacked::SECOND], + 'commented import' => ['use /* class */ Ichiloto\Editor\Tests\Fixtures\SourceWriterUnit /* alias */ as Choice;', + 'Choice::FIRST', 'Choice::SECOND', SourceWriterUnit::FIRST, SourceWriterUnit::SECOND], + 'unit alias' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterUnit as Choice;', + 'Choice::FIRST', 'Choice::SECOND', SourceWriterUnit::FIRST, SourceWriterUnit::SECOND], + 'namespace alias' => ['use Ichiloto\Editor\Tests\Fixtures as Fixtures;', + 'Fixtures\SourceWriterUnit::FIRST', 'Fixtures\SourceWriterUnit::SECOND', SourceWriterUnit::FIRST, SourceWriterUnit::SECOND], + 'backed fully qualified' => ['', '\Ichiloto\Editor\Tests\Fixtures\SourceWriterBacked::FIRST', + '\Ichiloto\Editor\Tests\Fixtures\SourceWriterBacked::SECOND', SourceWriterBacked::FIRST, SourceWriterBacked::SECOND], + 'unit fully qualified' => ['', '\Ichiloto\Editor\Tests\Fixtures\SourceWriterUnit::FIRST', + '\Ichiloto\Editor\Tests\Fixtures\SourceWriterUnit::SECOND', SourceWriterUnit::FIRST, SourceWriterUnit::SECOND], + 'inter-token comments' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterBacked as Choice;', + "Choice /* class */ :: /* case */ FIRST", "Choice /* class */ :: /* case */ SECOND", SourceWriterBacked::FIRST, SourceWriterBacked::SECOND], + 'inter-token newlines' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterUnit as Choice;', + "Choice\n ::\n FIRST", "Choice\n ::\n SECOND", SourceWriterUnit::FIRST, SourceWriterUnit::SECOND], +]); + +it('preserves byte-exact enum source on no-op and refuses replacement by a different enum type or scalar', function (mixed $replacement) { + $source = " Choice::FIRST, 'kept' => 1];\n"; + $document = PhpArraySourceDocument::parse($source); + $old = evaluateSource($source); + expect(ArraySourceWriter::rewrite($document, $old, $old))->toBe($document); + $new = $old; + $new['case'] = $replacement; + expect(fn() => ArraySourceWriter::rewrite($document, $old, $new))->toThrow(SourcePreservationRefusal::class, 'case') + ->and($document->source)->toBe($source); +})->with([ + 'another enum' => [SourceWriterUnit::SECOND], + 'backing value' => ['second'], + 'integer' => [1], + 'null' => [null], +]); + +it('refuses computed enum values and noncase constants rather than flattening their expressions', function (string $expression) { + $source = " {$expression}, 'plain' => 1];\n"; + $document = PhpArraySourceDocument::parse($source); + $old = evaluateSource($source); + $new = $old; + $new['case'] = SourceWriterBacked::SECOND; + expect($old['case'])->toBe(SourceWriterBacked::FIRST) + ->and(fn() => ArraySourceWriter::rewrite($document, $old, $new))->toThrow(SourcePreservationRefusal::class, 'case') + ->and($document->source)->toBe($source); + $new = $old; + $new['plain'] = 2; + expect(ArraySourceWriter::rewrite($document, $old, $new)->source)->toBe(str_replace("'plain' => 1", "'plain' => 2", $source)); +})->with([ + "Choice::from('first')", + "Choice::tryFrom('first')", + 'Choice::ALIAS', + 'Constants::CASE_ALIAS', + 'Constants::FIRST', + "constant(Choice::class . '::FIRST')", + 'true ? Choice::FIRST : Choice::SECOND', + '(Choice::FIRST)', +]); + +it('refuses unresolved class names and unknown constants without evaluating them', function (string $expression) { + $source = " {$expression}];\n"; + $document = PhpArraySourceDocument::parse($source); + expect(fn() => ArraySourceWriter::rewrite($document, ['case' => SourceWriterBacked::FIRST], ['case' => SourceWriterBacked::SECOND])) + ->toThrow(SourcePreservationRefusal::class, 'case') + ->and($document->source)->toBe($source); +})->with(['Choice::UNKNOWN_CASE', 'UnknownChoice::FIRST', '\UnloadedUnknownChoice::FIRST']); + +it('requires the loaded enum case to match the authored literal before rewriting it', function () { + $source = " Choice::FIRST];\n"; + $document = PhpArraySourceDocument::parse($source); + expect(fn() => ArraySourceWriter::rewrite($document, ['case' => SourceWriterBacked::SECOND], ['case' => SourceWriterBacked::FIRST])) + ->toThrow(SourcePreservationRefusal::class, 'case') + ->and($document->source)->toBe($source); +}); + +it('does not mistake imports inside authored text or another namespace for actual enum imports', function (string $source) { + $document = PhpArraySourceDocument::parse($source); + $old = evaluateSource($source); + $new = $old; + $new['case'] = SourceWriterBacked::SECOND; + expect(fn() => ArraySourceWriter::rewrite($document, $old, $new))->toThrow(SourcePreservationRefusal::class, 'case') + ->and($document->source)->toBe($source); +})->with([ + 'nowdoc' => <<<'PHP' + Choice::FIRST, 'documentation' => $documentation]; + PHP, + 'comment' => <<<'PHP' + Choice::FIRST]; + PHP, + 'previous namespace' => <<<'PHP' + Choice::FIRST]; + PHP, +]); + +it('keeps existing backed enum value expressions editable without switching them to enum objects', function () { + $source = " Choice::FIRST->value];\n"; + $document = PhpArraySourceDocument::parse($source); + $old = evaluateSource($source); + $new = ['case' => 'second']; + $rewritten = ArraySourceWriter::rewrite($document, $old, $new)->source; + expect($rewritten)->toBe(str_replace('Choice::FIRST->value', 'Choice::SECOND->value', $source)) + ->and(evaluateSource($rewritten))->toBe($new) + ->and(fn() => ArraySourceWriter::rewrite($document, $old, ['case' => SourceWriterBacked::SECOND])) + ->toThrow(SourcePreservationRefusal::class, 'case'); +}); From 1e9040b5700812652eed5026d8628b2d02d273db Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 10 Oct 2026 06:58:24 +0200 Subject: [PATCH 237/344] feat(maps): add source-preserving physical collision painting Expose the Engine-owned collision choices and physical cells through the shared session. Paint and fill independently of presentation with one undo entry, ragged-cell validation, freshness and revision refusal. Keep legacy conversion explicit and preserve TUI authoring. --- docs/manual.md | 13 +- src/Canvas/PhysicalOccupancyEditor.php | 77 ++++ src/ProjectMap.php | 46 +++ src/Session/EditorSession.php | 34 ++ src/Session/SessionHost.php | 13 + .../Session/PhysicalOccupancyPaintingTest.php | 343 ++++++++++++++++++ 6 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 src/Canvas/PhysicalOccupancyEditor.php create mode 100644 tests/Unit/Session/PhysicalOccupancyPaintingTest.php diff --git a/docs/manual.md b/docs/manual.md index 4e1ae6d..aee9567 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -225,7 +225,18 @@ Resize and row/column insertion preserve the declaration with geometry, adding `SOLID` physical cells independently of blank glyphs. Malformed or unsupported source edits refuse before mutation. The TUI receives no graphical conversion control; it preserves the declaration through its existing edit/save/history -workflow. Dedicated physical brushes remain a GUI authoring gap. +workflow. + +The GUI **Collision** tool displays the shared physical cells over either +presentation. On converted maps its constrained collision-type picker and the +existing pencil, line, outline, filled-rectangle and fill brushes edit passage +as one undo step without changing glyphs or tiles. The overlay's numbers match +the picker; right-click picks the physical cell's type. Fill follows equal +physical cells and does not bridge missing cells in ragged rows. Unsupported +types, stale revisions, malformed declarations and source-preservation failures +refuse before any change. Escape, map/tool changes and brush changes retire +pending region answers. Conversion remains explicit on legacy maps. Dedicated +object-footprint authoring and native acceptance of this tool remain open. Gameplay and event layers use the same Paint/Normal modes, tools, mouse strokes, selection, colour, clipboard and undo. The layer inspector lists terminal layer diff --git a/src/Canvas/PhysicalOccupancyEditor.php b/src/Canvas/PhysicalOccupancyEditor.php new file mode 100644 index 0000000..fe554b4 --- /dev/null +++ b/src/Canvas/PhysicalOccupancyEditor.php @@ -0,0 +1,77 @@ +, issue: ?string} */ + public static function readOccupancy(ProjectMap $map): array + { + $types = array_values(array_map(static fn(CollisionType $type): array => [ + 'value' => $type->value, + 'label' => ucwords(strtolower(str_replace('_', ' ', $type->name))), + ], array_filter(CollisionType::cases(), static fn(CollisionType $type): bool => $type !== CollisionType::PASS_THROUGH))); + try { + if ($map->getGridSourceIssue() !== null) { + throw new MapSourceRefusal($map->getGridSourceIssue()); + } + return ['rows' => $map->getResolvedCollisionMap(), 'types' => $types, 'issue' => null]; + } catch (InvalidArgumentException | MapSourceRefusal $error) { + return ['rows' => [], 'types' => $types, 'issue' => $error->getMessage()]; + } + } + + /** + * Applies one already-validated stroke and returns its unrecorded history entry. + * History restores only occupancy, through the same source-preserving writer. + * + * @param list $cells + * @return array{changed: int, command: ?Command} + */ + public static function applyPaint(ProjectMap $map, array $cells, int $collision, string $label = 'Paint collision'): array + { + $type = CollisionType::tryFrom($collision); + if ($type === null || $type === CollisionType::PASS_THROUGH) { + throw new MapSourceRefusal('Choose a final CollisionType value. Nothing was changed.'); + } + $path = [MapPhysicalOccupancy::DATA_KEY]; + $before = $map->getMapDataField($path); + $changed = $map->paintPhysicalOccupancy($cells, $type); + $after = $map->getMapDataField($path); + + return ['changed' => $changed, 'command' => $changed === 0 ? null : new GenericCommand($label, + static fn() => $map->setMapDataField($path, $after), + static fn() => $map->setMapDataField($path, $before), + )]; + } + + /** @return list */ + public static function getFillRegion(ProjectMap $map, int $x, int $y): array + { + $map->assertSourcesUnchanged(); + $occupancy = self::readOccupancy($map); + if ($occupancy['issue'] !== null) { + throw new MapSourceRefusal($occupancy['issue']); + } + $rows = $occupancy['rows']; + if (! isset($rows[$y][$x])) { + throw new MapSourceRefusal("Physical cell {$x}, {$y} is outside the map."); + } + // A missing ragged cell has no collision identity, so fill cannot cross it. + $identityAt = static fn(int $x, int $y): string => isset($rows[$y][$x]) ? (string) $rows[$y][$x] : 'missing'; + + return array_map(static fn(array $cell): array => [$cell['x'], $cell['y']], + ToolGeometry::floodFill($identityAt, $map->getWidth(), $map->getHeight(), $x, $y)); + } +} diff --git a/src/ProjectMap.php b/src/ProjectMap.php index f898040..b4780f1 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -873,6 +873,52 @@ public function getResolvedCollisionMap(): array return MapCollisionResolver::resolveMap($this->layers->getLayerSet(), $this->editableData, $dictionary)->collisionGrid; } + /** + * Paints declared physical cells only, independently of either presentation. + * Every cell is validated before the single source-preserving data mutation. + * + * @param list $cells + * @return int The number of distinct cells whose collision changed. + */ + public function paintPhysicalOccupancy(array $cells, CollisionType $collision): int + { + $this->assertEditable(); + if ($collision === CollisionType::PASS_THROUGH) { + throw new MapSourceRefusal('PASS_THROUGH is not a final physical collision. Nothing was changed.'); + } + $rows = $this->getDeclaredPhysicalOccupancy(); + if ($rows === null) { + throw new MapSourceRefusal('Physical occupancy must be explicitly migrated before painting. Nothing was changed.'); + } + if (! array_is_list($cells)) { + throw new MapSourceRefusal('Physical cells must be a list of [x, y] integer pairs. Nothing was changed.'); + } + $unique = []; + foreach ($cells as $cell) { + if (! is_array($cell) || ! array_is_list($cell) || count($cell) !== 2 + || ! is_int($cell[0]) || ! is_int($cell[1])) { + throw new MapSourceRefusal('Physical cells must be a list of [x, y] integer pairs. Nothing was changed.'); + } + [$x, $y] = $cell; + if (! isset($rows[$y][$x])) { + throw new MapSourceRefusal("Physical cell {$x}, {$y} is outside the map. Nothing was changed."); + } + $unique["{$x},{$y}"] = [$x, $y]; + } + $changed = 0; + foreach ($unique as [$x, $y]) { + if ($rows[$y][$x] !== $collision) { + $rows[$y][$x] = $collision; + $changed++; + } + } + if ($changed > 0) { + $this->setMapDataField([MapPhysicalOccupancy::DATA_KEY], $rows); + } + + return $changed; + } + /** Explicitly preserves current collisions independently of both presentations. */ public function migratePhysicalOccupancy(): bool { diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 88fa14f..5ad6d56 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -8,6 +8,7 @@ use Ichiloto\Editor\Backup\BackupWriter; use Ichiloto\Editor\Canvas\CanvasClipboard; use Ichiloto\Editor\Canvas\CanvasEditor; +use Ichiloto\Editor\Canvas\PhysicalOccupancyEditor; use Ichiloto\Editor\Canvas\Clipboard; use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Cutscenes\CutsceneRecordCategory; @@ -584,6 +585,7 @@ public function readMap(string $mapId): array 'npcs' => $npcs, 'tileLayers' => $map->describeTileLayers(), 'physicalOccupancy' => $map->hasMapDataField([MapPhysicalOccupancy::DATA_KEY]), + 'occupancy' => PhysicalOccupancyEditor::readOccupancy($map), ]; } @@ -1311,6 +1313,38 @@ public function migratePhysicalOccupancy(string $mapId, int $revision): array LayerEditor::migratePhysicalOccupancy($map)); } + /** + * @param list $cells + * @return array{status: 'applied', changed: int, revision: int} + */ + public function paintOccupancy(string $mapId, int $revision, array $cells, int $collision, string $label = 'Paint collision'): array + { + $map = $this->requireCurrentMap($mapId, $revision); + try { + $applied = PhysicalOccupancyEditor::applyPaint($map, $cells, $collision, $label); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($applied['command'] !== null) { + $this->history->record($applied['command']); + } + + return ['status' => 'applied', 'changed' => $applied['changed'], 'revision' => $this->getMapRevision($map)]; + } + + /** @return array{cells: list, map: string, revision: int} */ + public function getOccupancyFillRegion(string $mapId, int $x, int $y, int $revision): array + { + $map = $this->requireCurrentMap($mapId, $revision); + try { + $cells = PhysicalOccupancyEditor::getFillRegion($map, $x, $y); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + + return ['cells' => $cells, 'map' => $mapId, 'revision' => $this->getMapRevision($map)]; + } + /** * Renames a glyph layer as one undo step. A rename that changes the * map's collisions is asked about; it is made when asked again confirmed. diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 02515dd..06f0022 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -182,6 +182,12 @@ private function dispatch(string $method, array $params): array self::readOptionalString($params, 'layer'), self::readOptionalString($params, 'tileLayer'), ), + 'canvas.fillOccupancy' => $session->getOccupancyFillRegion( + self::requireString($params, 'map'), + self::requireInt($params, 'x'), + self::requireInt($params, 'y'), + self::requireInt($params, 'revision'), + ), 'pieces.list' => $session->listPieces(self::requireString($params, 'map')), 'pieces.preview' => $session->previewPiece( self::requireString($params, 'map'), @@ -229,6 +235,13 @@ private function dispatch(string $method, array $params): array self::requireString($params, 'map'), self::requireInt($params, 'revision'), ), + 'map.paintOccupancy' => $session->paintOccupancy( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireCells($params), + self::requireInt($params, 'collision'), + array_key_exists('label', $params) ? self::requireString($params, 'label') : 'Paint collision', + ), 'layer.create' => $session->createLayer( self::requireString($params, 'map'), self::requireInt($params, 'revision'), diff --git a/tests/Unit/Session/PhysicalOccupancyPaintingTest.php b/tests/Unit/Session/PhysicalOccupancyPaintingTest.php new file mode 100644 index 0000000..085c38d --- /dev/null +++ b/tests/Unit/Session/PhysicalOccupancyPaintingTest.php @@ -0,0 +1,343 @@ + str_repeat(' ', strlen($row)), explode("\n", $glyphs))); + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource($events, 'EVENT')); + $rows = array_map(static fn(string $row): array => array_fill(0, strlen($row), CollisionType::NONE), explode("\n", $glyphs)); + file_put_contents($directory . '/test-map.data.php', $source ?? ' 'Physical fixture', 'events' => [], 'occupancy' => $rows], true) . ';'); + + return $root; +} + +function getPhysicalOccupancyMap(EditorSession $session): \Ichiloto\Editor\ProjectMap +{ + return new ReflectionProperty(EditorSession::class, 'workspace')->getValue($session)->getMapByIndex(0); +} + +function createPhysicalOccupancyRpc(string $root): Closure +{ + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result'); + + return $request; +} + +it('reads the resolved physical grid and final collision choices without presentation padding', function () { + $root = createPhysicalOccupancyProject(); + $session = EditorSession::open($root); + $read = $session->readMap('test-map'); + $map = getPhysicalOccupancyMap($session); + expect($read['physicalOccupancy'])->toBeTrue() + ->and($read['occupancy']['rows'])->toBe([[0, 0, 0], [0, 0]]) + ->and($read['occupancy']['issue'])->toBeNull() + ->and(array_column($read['occupancy']['types'], 'value'))->toBe(array_values(array_map( + static fn(CollisionType $type): int => $type->value, + array_filter(CollisionType::cases(), static fn(CollisionType $type): bool => $type !== CollisionType::PASS_THROUGH)))) + ->and(array_column($read['occupancy']['types'], 'label'))->not->toContain('Pass Through') + ->and($map->getMapDataField(['occupancy', 0, 0]))->toBe(CollisionType::NONE); +}); + +it('reads legacy collision truth but refuses painting without explicit conversion', function () { + $root = createPhysicalOccupancyProject(' []];', "##\n#"); + file_put_contents($root . '/assets/Maps/collisions.php', ' ' + . var_export(CollisionType::COUNTER, true) . '];'); + $session = EditorSession::open($root); + $before = $session->readMap('test-map'); + $hashes = sourceHashTree($root); + expect($before['occupancy']['rows'])->toBe([[10, 10], [10]]) + ->and($before['occupancy']['issue'])->toBeNull() + ->and($before['physicalOccupancy'])->toBeFalse() + ->and($session->getOccupancyFillRegion('test-map', 0, 0, $before['revision'])['cells'])->toHaveCount(3) + ->and(fn() => $session->paintOccupancy('test-map', $before['revision'], [[0, 0]], 0)) + ->toThrow(SessionRefusal::class, 'explicitly migrated') + ->and($session->readMap('test-map'))->toBe($before) + ->and($session->undo()['label'])->toBeNull() + ->and(sourceHashTree($root))->toBe($hashes); +}); + +it('reports unresolved legacy collisions rather than inventing physical passage', function () { + $root = createPhysicalOccupancyProject(' []];'); + file_put_contents($root . '/assets/Maps/collisions.php', ' 0];'); + $session = EditorSession::open($root); + $read = $session->readMap('test-map'); + expect($read['occupancy']['rows'])->toBe([]) + ->and($read['occupancy']['issue'])->toContain('Invalid dictionary entry') + ->and(fn() => $session->getOccupancyFillRegion('test-map', 0, 0, $read['revision'])) + ->toThrow(SessionRefusal::class, 'Invalid dictionary entry'); +}); + +it('reports malformed declared occupancy and atomically refuses painting and fill', function (mixed $rows) { + $root = createPhysicalOccupancyProject(' [], 'occupancy' => $rows], true) . ';'); + $session = EditorSession::open($root); + $before = $session->readMap('test-map'); + $hashes = sourceHashTree($root); + expect($before['physicalOccupancy'])->toBeTrue() + ->and($before['occupancy']['rows'])->toBe([]) + ->and($before['occupancy']['issue'])->toBeString() + ->and(fn() => $session->paintOccupancy('test-map', $before['revision'], [[0, 0]], 1)) + ->toThrow(SessionRefusal::class, 'occupancy') + ->and(fn() => $session->getOccupancyFillRegion('test-map', 0, 0, $before['revision'])) + ->toThrow(SessionRefusal::class, 'occupancy') + ->and($session->readMap('test-map'))->toBe($before) + ->and($session->undo()['label'])->toBeNull() + ->and(sourceHashTree($root))->toBe($hashes); +})->with([ + 'null' => [null], + 'missing rows' => [[]], + 'integer cells' => [[[0, 0, 0], [0, 0]]], + 'wrong row width' => [[[CollisionType::NONE], [CollisionType::NONE, CollisionType::NONE]]], + 'unresolved cell' => [[[CollisionType::PASS_THROUGH, CollisionType::NONE, CollisionType::NONE], [CollisionType::NONE, CollisionType::NONE]]], +]); + +it('strictly validates occupancy RPC arguments before changing any state', function (string $method, array $override) { + $root = createPhysicalOccupancyProject(); + $request = createPhysicalOccupancyRpc($root); + $before = $request('map.read', ['map' => 'test-map'])['result']; + $params = ['map' => 'test-map', 'revision' => $before['revision'], 'cells' => [[0, 0]], 'collision' => 1, 'x' => 0, 'y' => 0]; + expect($request($method, array_replace($params, $override))['error']['kind'])->toBe('request') + ->and($request('map.read', ['map' => 'test-map'])['result'])->toBe($before) + ->and($request('history.undo', [])['result']['label'])->toBeNull(); +})->with([ + 'map integer' => ['map.paintOccupancy', ['map' => 1]], + 'revision string' => ['map.paintOccupancy', ['revision' => '0']], + 'collision string' => ['map.paintOccupancy', ['collision' => '1']], + 'collision float' => ['map.paintOccupancy', ['collision' => 1.5]], + 'collision boolean' => ['map.paintOccupancy', ['collision' => true]], + 'label null' => ['map.paintOccupancy', ['label' => null]], + 'cells null' => ['map.paintOccupancy', ['cells' => null]], + 'cell fractional' => ['map.paintOccupancy', ['cells' => [[0.5, 0]]]], + 'cell extra value' => ['map.paintOccupancy', ['cells' => [[0, 0, 1]]]], + 'cell text' => ['map.paintOccupancy', ['cells' => [[0, '0']]]], + 'fill x string' => ['canvas.fillOccupancy', ['x' => '0']], + 'fill revision null' => ['canvas.fillOccupancy', ['revision' => null]], +]); + +it('rejects malformed or outside physical cells atomically even after a valid cell', function (array $cells) { + $root = createPhysicalOccupancyProject(); + $session = EditorSession::open($root); + $before = $session->readMap('test-map'); + $hashes = sourceHashTree($root); + expect(fn() => $session->paintOccupancy('test-map', $before['revision'], $cells, 1))->toThrow(SessionRefusal::class) + ->and($session->readMap('test-map'))->toBe($before) + ->and($session->undo()['label'])->toBeNull() + ->and(sourceHashTree($root))->toBe($hashes); +})->with([ + 'ragged gap' => [[[0, 0], [2, 1]]], + 'negative x' => [[[0, 0], [-1, 0]]], + 'negative y' => [[[0, 0], [0, -1]]], + 'outside width' => [[[0, 0], [3, 0]]], + 'outside height' => [[[0, 0], [0, 2]]], + 'short pair' => [[[0, 0], [1]]], + 'noninteger' => [[[0, 0], [1, '0']]], + 'not pair' => [[[0, 0], null]], + 'nonlist' => [[1 => [0, 0]]], + 'reordered pair' => [[[1 => 0, 0 => 0]]], +]); + +it('rejects nonfinal collision values without dirty state or history', function (int $collision) { + $session = EditorSession::open(createPhysicalOccupancyProject()); + $before = $session->readMap('test-map'); + expect(fn() => $session->paintOccupancy('test-map', $before['revision'], [[0, 0]], $collision)) + ->toThrow(SessionRefusal::class, 'final CollisionType') + ->and($session->readMap('test-map'))->toBe($before) + ->and($session->undo()['label'])->toBeNull(); +})->with([9, -1, 999]); + +it('paints every final enum type after explicit migration and saves enums rather than integers', function (CollisionType $collision) { + $root = createPhysicalOccupancyProject(' []];'); + $session = EditorSession::open($root); + $session->migratePhysicalOccupancy('test-map', $session->readMap('test-map')['revision']); + $before = $session->readMap('test-map'); + $changed = $before['occupancy']['rows'][0][0] === $collision->value ? 0 : 1; + expect($session->paintOccupancy('test-map', $before['revision'], [[0, 0]], $collision->value)['changed'])->toBe($changed) + ->and($session->readMap('test-map')['occupancy']['rows'][0][0])->toBe($collision->value); + $session->saveMap('test-map'); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + expect($map->getMapDataField(['occupancy', 0, 0]))->toBe($collision); +})->with(array_values(array_filter(CollisionType::cases(), static fn(CollisionType $type): bool => $type !== CollisionType::PASS_THROUGH))); + +it('deduplicates a physical stroke and rejects stale painting and fill over RPC', function () { + $request = createPhysicalOccupancyRpc(createPhysicalOccupancyProject()); + $revision = $request('map.read', ['map' => 'test-map'])['result']['revision']; + $params = ['map' => 'test-map', 'revision' => $revision, 'cells' => [[0, 0], [0, 0], [1, 0]], 'collision' => 10]; + $applied = $request('map.paintOccupancy', $params)['result']; + expect($applied)->toBe(['status' => 'applied', 'changed' => 2, 'revision' => $revision + 1]) + ->and($request('map.paintOccupancy', $params)['error']['kind'])->toBe('refusal') + ->and($request('canvas.fillOccupancy', ['map' => 'test-map', 'revision' => $revision, 'x' => 0, 'y' => 0])['error']['kind']) + ->toBe('refusal') + ->and($request('canvas.fillOccupancy', ['map' => 'test-map', 'revision' => $applied['revision'], 'x' => 0, 'y' => 0])['result']) + ->toBe(['cells' => [[0, 0], [1, 0]], 'map' => 'test-map', 'revision' => $applied['revision']]) + ->and($request('history.undo', [])['result']['label'])->toBe('Paint collision'); +}); + +it('leaves no-op physical strokes clean and preserves the redo stack', function () { + $session = EditorSession::open(createPhysicalOccupancyProject()); + $before = $session->readMap('test-map'); + foreach ([[], [[0, 0], [0, 0]]] as $cells) { + expect($session->paintOccupancy('test-map', $before['revision'], $cells, 0)) + ->toBe(['status' => 'applied', 'changed' => 0, 'revision' => $before['revision']]); + } + expect($session->readMap('test-map'))->toBe($before)->and($session->undo()['label'])->toBeNull(); + $session->paintOccupancy('test-map', $before['revision'], [[0, 0]], 1, 'Physical test stroke'); + expect($session->undo()['label'])->toBe('Physical test stroke'); + $revision = $session->readMap('test-map')['revision']; + $session->paintOccupancy('test-map', $revision, [[0, 0]], 0); + expect($session->redo()['label'])->toBe('Physical test stroke') + ->and($session->readMap('test-map')['occupancy']['rows'][0][0])->toBe(1); +}); + +it('does not flood across missing ragged cells or accept a missing fill seed', function () { + $rows = [[CollisionType::SOLID, CollisionType::NONE, CollisionType::NONE], [CollisionType::SOLID], + [CollisionType::SOLID, CollisionType::NONE, CollisionType::NONE]]; + $root = createPhysicalOccupancyProject(' [], 'occupancy' => $rows], true) . ';', "###\n#\n###"); + $session = EditorSession::open($root); + $before = $session->readMap('test-map'); + expect($session->getOccupancyFillRegion('test-map', 1, 0, $before['revision'])['cells'])->toBe([[1, 0], [2, 0]]) + ->and($session->getOccupancyFillRegion('test-map', 1, 2, $before['revision'])['cells'])->toBe([[1, 2], [2, 2]]) + ->and(fn() => $session->getOccupancyFillRegion('test-map', 1, 1, $before['revision'])) + ->toThrow(SessionRefusal::class, 'outside the map') + ->and($session->readMap('test-map'))->toBe($before); +}); + +it('preserves authored comments and unrelated expressions through shared undo save and redo', function () { + $source = <<<'PHP' + 'Physical fixture', + 'description' => strtoupper('kept'), // Keep this authored expression. + 'note' => $kept, + 'events' => [], + 'occupancy' => array( + array(CollisionType::NONE, /* ground contact */ CollisionType::NONE, CollisionType::NONE), + array(CollisionType::NONE, CollisionType::NONE), + ), + ); + PHP; + $root = createPhysicalOccupancyProject($source); + $file = $root . '/assets/Maps/test-map/test-map.data.php'; + $session = EditorSession::open($root); + $map = getPhysicalOccupancyMap($session); + $hashes = sourceHashTree($root); + $session->paintOccupancy('test-map', $session->readMap('test-map')['revision'], [[1, 0]], 10); + expect(sourceHashTree($root))->toBe($hashes) + ->and($map->getMapDataField(['occupancy', 0, 1]))->toBe(CollisionType::COUNTER); + $session->saveMap('test-map'); + $painted = file_get_contents($file); + expect($painted)->toContain("'description' => strtoupper('kept'), // Keep this authored expression.", + "'note' => \$kept", '/* ground contact */', "'occupancy' => array(") + ->and(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getResolvedCollisionMap())->toBe([[0, 10, 0], [0, 0]]); + expect($session->undo()['label'])->toBe('Paint collision'); + $session->saveMap('test-map'); + expect(file_get_contents($file))->toBe($source); + $session->redo(); + $session->saveMap('test-map'); + expect(file_get_contents($file))->toBe($painted); +}); + +it('refuses computed occupancy writes without flattening authored PHP', function (string $declaration) { + $root = createPhysicalOccupancyProject($declaration); + $session = EditorSession::open($root); + $before = $session->readMap('test-map'); + $hashes = sourceHashTree($root); + expect($before['occupancy']['issue'])->toBeNull() + ->and(fn() => $session->paintOccupancy('test-map', $before['revision'], [[0, 0]], 1)) + ->toThrow(SessionRefusal::class) + ->and($session->readMap('test-map'))->toBe($before) + ->and($session->undo()['label'])->toBeNull() + ->and(sourceHashTree($root))->toBe($hashes); +})->with([ + 'computed root' => [' [], "occupancy" => [[\Ichiloto\Engine\Events\Enumerations\CollisionType::NONE, \Ichiloto\Engine\Events\Enumerations\CollisionType::NONE, \Ichiloto\Engine\Events\Enumerations\CollisionType::NONE], [\Ichiloto\Engine\Events\Enumerations\CollisionType::NONE, \Ichiloto\Engine\Events\Enumerations\CollisionType::NONE]]]; return $data;'], + 'computed rows' => [' [], "occupancy" => array_map(static fn($n) => array_fill(0, $n, CollisionType::NONE), [3, 2])];'], + 'computed cell' => [' [], "occupancy" => [[CollisionType::from(0), CollisionType::NONE, CollisionType::NONE], [CollisionType::NONE, CollisionType::NONE]]];'], +]); + +it('refuses external source replacement on painting fill and saved history without losing history', function (string $member) { + $root = createPhysicalOccupancyProject(); + $session = EditorSession::open($root); + $session->paintOccupancy('test-map', $session->readMap('test-map')['revision'], [[0, 0]], 1); + $session->saveMap('test-map'); + $path = $root . '/assets/Maps/test-map/test-map.' . $member . '.php'; + $source = file_get_contents($path); + file_put_contents($path, $source . "\n// External author replacement.\n"); + $before = $session->readMap('test-map'); + $hashes = sourceHashTree($root); + expect(fn() => $session->paintOccupancy('test-map', $before['revision'], [[1, 0]], 1))->toThrow(SessionRefusal::class) + ->and(fn() => $session->getOccupancyFillRegion('test-map', 0, 0, $before['revision']))->toThrow(SessionRefusal::class) + ->and(fn() => $session->undo())->toThrow(MapSourceRefusal::class) + ->and($session->readMap('test-map'))->toBe($before) + ->and(sourceHashTree($root))->toBe($hashes); + file_put_contents($path, $source); + expect($session->undo()['label'])->toBe('Paint collision') + ->and($session->readMap('test-map')['occupancy']['rows'][0][0])->toBe(0); +})->with(['data', 'map']); + +it('keeps occupancy painting independent from glyph and bound tile edits and their history', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: [ + 'chest' => ['name' => 'Chest', 'layer' => 'buildings', 'glyphs' => ['m'], 'tiles' => ['furniture' => ['60']]], + ]); + $session = EditorSession::open($root); + $session->migratePhysicalOccupancy('test-map', $session->readMap('test-map')['revision']); + $map = getPhysicalOccupancyMap($session); + $glyphs = $map->getLayerSet()->getComposedGrid(); + $tiles = $map->getTileLayerSources(); + $physical = $session->readMap('test-map')['occupancy']['rows']; + $target = $physical[0][0] === 10 ? 1 : 10; + $session->paintOccupancy('test-map', $session->readMap('test-map')['revision'], [[0, 0]], $target); + $painted = $session->readMap('test-map')['occupancy']['rows']; + expect($map->getLayerSet()->getComposedGrid())->toBe($glyphs)->and($map->getTileLayerSources())->toBe($tiles); + $session->paint('test-map', $session->readMap('test-map')['revision'], 'map:4', [[0, 0]], 'm'); + expect($session->readMap('test-map')['occupancy']['rows'])->toBe($painted); + $session->stampTiles('test-map', $session->readMap('test-map')['revision'], 'furniture', [[1, 1, 60]]); + expect($session->readMap('test-map')['occupancy']['rows'])->toBe($painted); + $session->undo(); + $session->undo(); + expect($map->getLayerSet()->getComposedGrid())->toBe($glyphs) + ->and($map->getTileLayerSources())->toBe($tiles) + ->and($session->readMap('test-map')['occupancy']['rows'])->toBe($painted); + $session->undo(); + expect($session->readMap('test-map')['occupancy']['rows'])->toBe($physical); + $session->redo(); + $session->redo(); + $session->redo(); + expect($session->readMap('test-map')['occupancy']['rows'])->toBe($painted); + $session->saveMap('test-map'); + expect(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getResolvedCollisionMap())->toBe($painted); +}); + +it('restores only physical data rather than unrelated metadata or presentations', function () { + $root = createPhysicalOccupancyProject(); + $session = EditorSession::open($root); + $map = getPhysicalOccupancyMap($session); + $edit = PhysicalOccupancyEditor::applyPaint($map, [[0, 0]], 1); + $map->setMapField('name', 'Later authored name'); + $map->setLayerCell($map->getBaseLayerId(), 1, 0, '.'); + $edit['command']->undo(); + expect($map->getDisplayName())->toBe('Later authored name') + ->and($map->getLayerSymbol($map->getBaseLayerId(), 1, 0))->toBe('.') + ->and($map->getResolvedCollisionMap())->toBe([[0, 0, 0], [0, 0]]); + $edit['command']->execute(); + expect($map->getDisplayName())->toBe('Later authored name') + ->and($map->getLayerSymbol($map->getBaseLayerId(), 1, 0))->toBe('.') + ->and($map->getResolvedCollisionMap())->toBe([[1, 0, 0], [0, 0]]); +}); From f4ab120c402ba5b527011800762ca65de14f6b62 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 10 Oct 2026 07:56:46 +0200 Subject: [PATCH 238/344] fix(source): remove enum read-only and literal replacement restrictions Route enum-bearing arrays through source-preserving edits instead of regeneration. Permit explicit nullable and cross-enum literal edits without dropping comments; retain scalar coercion and computed-expression refusals. --- src/Cutscenes/Source/ArraySourceWriter.php | 30 +++++++++++++------ src/Database/PhpDataFile.php | 18 +++++------ .../Unit/Cutscenes/ArraySourceWriterTest.php | 17 +++++++++-- tests/Unit/RecordDatabaseTest.php | 25 ++++++++++++++-- 4 files changed, 67 insertions(+), 23 deletions(-) diff --git a/src/Cutscenes/Source/ArraySourceWriter.php b/src/Cutscenes/Source/ArraySourceWriter.php index 85254d6..1abc54d 100644 --- a/src/Cutscenes/Source/ArraySourceWriter.php +++ b/src/Cutscenes/Source/ArraySourceWriter.php @@ -143,8 +143,8 @@ private function diff(SourceNode $node, mixed $old, mixed $new, array $path): vo return; } - if ($node->kind === SourceNode::EXPRESSION && ($edit = $this->findEnumCaseEdit($node, $old, $new)) !== null) { - $this->edits[] = $edit; + if ($node->kind === SourceNode::EXPRESSION && ($edits = $this->findEnumCaseEdits($node, $old, $new)) !== null) { + array_push($this->edits, ...$edits); return; } @@ -790,14 +790,15 @@ private static function identityOf(mixed $entry): ?string } /** - * Literal enum cases retain class spelling, spacing and comments; only - * the case-name token changes. Aliases to cases are not case literals. + * Verified enum literals retain spacing and comments. Nullable and cross-enum + * edits are explicit data replacements; record schemas own admissible types. + * Aliases to cases and computed values are not case literals. * - * @return array{0: int, 1: int, 2: string}|null + * @return list|null */ - private function findEnumCaseEdit(SourceNode $node, mixed $old, mixed $new): ?array + private function findEnumCaseEdits(SourceNode $node, mixed $old, mixed $new): ?array { - if (! $old instanceof \UnitEnum || ! $new instanceof \UnitEnum || $old::class !== $new::class) { + if (! $old instanceof \UnitEnum || ($new !== null && ! $new instanceof \UnitEnum)) { return null; } $prefix = 'start + $tokens[2]->pos - strlen($prefix); + $edit = static function (\PhpToken $token, string $replacement) use ($node, $prefix): array { + $start = $node->start + $token->pos - strlen($prefix); - return [$start, $start + strlen($tokens[2]->text), $new->name]; + return [$start, $start + strlen($token->text), $replacement]; + }; + if ($new === null) { + return [$edit($tokens[0], 'null'), $edit($tokens[1], ''), $edit($tokens[2], '')]; + } + $edits = [$edit($tokens[2], $new->name)]; + if ($old::class !== $new::class) { + $edits[] = $edit($tokens[0], '\\' . $new::class); + } + + return $edits; } /** diff --git a/src/Database/PhpDataFile.php b/src/Database/PhpDataFile.php index a21ccbe..b567482 100644 --- a/src/Database/PhpDataFile.php +++ b/src/Database/PhpDataFile.php @@ -15,8 +15,8 @@ /** * One authored PHP data file, loaded with its source header preserved. * - * A file that returns an array literal of plain data (scalars and arrays, - * no objects or enum cases) is saved by editing its own source: + * A file that returns an array literal of data (scalars, arrays and enum + * cases, not arbitrary objects) is saved by editing its own source: * only the values that changed are rewritten where they sit, so comments, * nowdocs, variables and layout inside the data survive, and a change that * cannot be expressed there is refused rather than flattened. A save also @@ -514,21 +514,21 @@ public static function load(string $path, ?string $workingDirectory = null): sel */ private static function isEditedInPlace(string $source, mixed $payload): bool { - if (! self::holdsObject($payload) && self::parseArraySource($source) !== null) { + if (! self::holdsNonEnumObject($payload) && self::parseArraySource($source) !== null) { return true; } return array_filter(PhpSourceDocument::parse($source)->entryClasses(), static fn(string $class): bool => trim($class) !== '') !== []; } - /** Whether a value holds an object anywhere, an enum case included. */ - private static function holdsObject(mixed $value): bool + /** Enum cases use the same source-preserving literal path as other data. */ + private static function holdsNonEnumObject(mixed $value): bool { if (is_object($value)) { - return true; + return ! $value instanceof \UnitEnum; } - return is_array($value) && array_any($value, self::holdsObject(...)); + return is_array($value) && array_any($value, self::holdsNonEnumObject(...)); } /** The file as an editable array literal, or null when it is not one. */ @@ -596,10 +596,10 @@ public function composeContents(mixed $payload, bool $checkDisk = false): string $document = $this->source === null ? null : self::parseArraySource($this->source); - // Plain data is edited in its own source; objects keep the regeneration + // Data, including enum cases, is edited in its own source; objects keep the regeneration // (or the constructor-argument edits) their files were written for. if ($document !== null && is_array($this->payload) && is_array($payload) - && ! self::holdsObject($this->payload) && ! self::holdsObject($payload)) { + && ! self::holdsNonEnumObject($this->payload) && ! self::holdsNonEnumObject($payload)) { if ($checkDisk && @file_get_contents($this->path) !== $this->source) { throw new RuntimeException(sprintf( 'Refusing to overwrite %s: it changed outside the editor since it was read. Reload it first.', diff --git a/tests/Unit/Cutscenes/ArraySourceWriterTest.php b/tests/Unit/Cutscenes/ArraySourceWriterTest.php index 941819b..aefbbc2 100644 --- a/tests/Unit/Cutscenes/ArraySourceWriterTest.php +++ b/tests/Unit/Cutscenes/ArraySourceWriterTest.php @@ -480,7 +480,7 @@ function rewriteAuthored(callable $change): string "Choice\n ::\n FIRST", "Choice\n ::\n SECOND", SourceWriterUnit::FIRST, SourceWriterUnit::SECOND], ]); -it('preserves byte-exact enum source on no-op and refuses replacement by a different enum type or scalar', function (mixed $replacement) { +it('preserves byte-exact enum source on no-op and refuses scalar backing-value replacements', function (mixed $replacement) { $source = " Choice::FIRST, 'kept' => 1];\n"; $document = PhpArraySourceDocument::parse($source); @@ -491,12 +491,23 @@ function rewriteAuthored(callable $change): string expect(fn() => ArraySourceWriter::rewrite($document, $old, $new))->toThrow(SourcePreservationRefusal::class, 'case') ->and($document->source)->toBe($source); })->with([ - 'another enum' => [SourceWriterUnit::SECOND], 'backing value' => ['second'], 'integer' => [1], - 'null' => [null], ]); +it('preserves enum literal comments during explicit nullable and cross-enum data replacements', function (?UnitEnum $replacement) { + $source = " Choice /* class */ :: /* case */ FIRST, 'kept' => 1];\n"; + $old = evaluateSource($source); + $new = $old; + $new['case'] = $replacement; + $document = PhpArraySourceDocument::parse($source); + $rewritten = ArraySourceWriter::rewrite($document, $old, $new); + expect(evaluateSource($rewritten->source))->toBe($new) + ->and($rewritten->source)->toContain('/* class */', '/* case */', "'kept' => 1") + ->and($document->source)->toBe($source); +})->with(['nullable' => [null], 'another enum' => [SourceWriterUnit::SECOND]]); + it('refuses computed enum values and noncase constants rather than flattening their expressions', function (string $expression) { $source = " \\" . Color::class . "::RED],\n];\n"; + file_put_contents($path, $source); + $file = PhpDataFile::load($path); + expect($file->isEditable())->toBeTrue(); + $file->save([['color' => Color::YELLOW]]); + expect(file_get_contents($path))->toBe(str_replace('::RED', '::YELLOW', $source)); + $file->save([['color' => null]]); + expect(require $path)->toBe([['color' => null]]) + ->and(file_get_contents($path))->toContain('// keep me'); + + $computed = str_replace('\\' . Color::class . '::RED', '(static fn() => \\' . Color::class . '::RED)()', $source); + file_put_contents($path, $computed); + $file = PhpDataFile::load($path); + expect($file->payload)->toBe([['color' => Color::RED]]) + ->and(fn() => $file->composeContents([['color' => Color::YELLOW]]))->toThrow(RuntimeException::class) + ->and(file_get_contents($path))->toBe($computed); + removeDirectoryRecursively($root); +}); + +it('still refuses to regenerate commented data that is not an array literal', function (): void { $root = makeTemporaryProject(); $path = $root . '/assets/Data/states.php'; foreach ([ - " \\" . Color::class . "::RED],\n];\n", " 'poison']],\n);\n", ] as $source) { file_put_contents($path, $source); From 8ccd4027e827d2697aa9ab3b710dbc740b46dbf8 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 10 Oct 2026 07:57:24 +0200 Subject: [PATCH 239/344] feat(maps): author and stamp explicit physical footprints Use shared source-preserving record edits and atomic heterogeneous map writes with undo. Preview and commit share whole-mask bounds and stale recipe/revision checks. Glyphs, artwork and terminal authoring controls remain independent. --- docs/manual.md | 16 +- src/Canvas/PhysicalOccupancyEditor.php | 69 +++- src/Database/ProjectRecordDatabase.php | 5 + src/Database/RecordFieldCodec.php | 3 + src/Database/RecordSchemaCatalog.php | 6 +- src/Maps/PhysicalFootprintCodec.php | 88 ++++++ src/Maps/TilesetAuthoring.php | 21 +- src/ProjectMap.php | 42 ++- src/Session/EditorSession.php | 104 +++++- src/Session/SessionHost.php | 56 ++++ tests/Unit/PhysicalFootprintCodecTest.php | 37 +++ .../Session/PhysicalFootprintStampingTest.php | 139 ++++++++ .../Session/TilesetOccupancySessionTest.php | 297 ++++++++++++++++++ 13 files changed, 864 insertions(+), 19 deletions(-) create mode 100644 src/Maps/PhysicalFootprintCodec.php create mode 100644 tests/Unit/PhysicalFootprintCodecTest.php create mode 100644 tests/Unit/Session/PhysicalFootprintStampingTest.php create mode 100644 tests/Unit/Session/TilesetOccupancySessionTest.php diff --git a/docs/manual.md b/docs/manual.md index aee9567..673c906 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -235,8 +235,20 @@ the picker; right-click picks the physical cell's type. Fill follows equal physical cells and does not bridge missing cells in ragged rows. Unsupported types, stale revisions, malformed declarations and source-preservation failures refuse before any change. Escape, map/tool changes and brush changes retire -pending region answers. Conversion remains explicit on legacy maps. Dedicated -object-footprint authoring and native acceptance of this tool remain open. +pending region answers. Conversion remains explicit on legacy maps. + +In the GUI tileset inspector, **Physical footprint** is an optional reusable +recipe, not an automatic consequence of placing a picture. Its cells use final +collision types or **Leave unchanged**; adding a recipe initially leaves all +cells unchanged. Edit it through the constrained grid or Physical Footprint +record row, then save the tileset. The Collision tool's **Object footprint** mode +selects a saved recipe and stamps it with one click as one undo step. Only map +occupancy changes, never glyphs or tiles. Every non-null cell must fit the actual +map, including ragged rows, or the entire stamp refuses. Stale map/recipe values +and unsupported source expressions refuse before mutation. Removing a recipe +does not erase collision already stamped into a map. The TUI preserves recipes +through existing record edits without adding graphical authoring controls. +Native acceptance of the physical brushes and footprint controls remains open. Gameplay and event layers use the same Paint/Normal modes, tools, mouse strokes, selection, colour, clipboard and undo. The layer inspector lists terminal layer diff --git a/src/Canvas/PhysicalOccupancyEditor.php b/src/Canvas/PhysicalOccupancyEditor.php index fe554b4..30e305e 100644 --- a/src/Canvas/PhysicalOccupancyEditor.php +++ b/src/Canvas/PhysicalOccupancyEditor.php @@ -10,18 +10,25 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Engine\Events\Enumerations\CollisionType; use Ichiloto\Engine\Field\MapPhysicalOccupancy; +use Ichiloto\Engine\Rendering\Tilesets\TilesetPiece; use InvalidArgumentException; /** Renderer-neutral physical painting; glyph and graphical tool behavior stays separate. */ final class PhysicalOccupancyEditor { - /** @return array{rows: int[][], types: list, issue: ?string} */ - public static function readOccupancy(ProjectMap $map): array + /** @return list */ + public static function getCollisionChoices(): array { - $types = array_values(array_map(static fn(CollisionType $type): array => [ + return array_values(array_map(static fn(CollisionType $type): array => [ 'value' => $type->value, 'label' => ucwords(strtolower(str_replace('_', ' ', $type->name))), ], array_filter(CollisionType::cases(), static fn(CollisionType $type): bool => $type !== CollisionType::PASS_THROUGH))); + } + + /** @return array{rows: int[][], types: list, issue: ?string} */ + public static function readOccupancy(ProjectMap $map): array + { + $types = self::getCollisionChoices(); try { if ($map->getGridSourceIssue() !== null) { throw new MapSourceRefusal($map->getGridSourceIssue()); @@ -56,6 +63,62 @@ public static function applyPaint(ProjectMap $map, array $cells, int $collision, )]; } + /** @return list>|null */ + public static function exportRecipe(TilesetPiece $piece): ?array + { + return $piece->occupancy === null ? null : array_map(static fn(array $row): array => + array_map(static fn(?CollisionType $type): ?int => $type?->value, $row), $piece->occupancy); + } + + /** + * Preview and commit use exactly the same authored mask. Transparent mask + * cells never acquire collision from artwork or terminal glyphs. + * @return list + */ + public static function getPieceWrites(ProjectMap $map, TilesetPiece $piece, int $x, int $y): array + { + $map->assertSourcesUnchanged(); + if ($piece->occupancy === null) { + throw new MapSourceRefusal('This piece has no physical footprint. Define and save one in its tileset first. Nothing was changed.'); + } + if (! $map->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + throw new MapSourceRefusal('Physical occupancy must be explicitly migrated before stamping. Nothing was changed.'); + } + $rows = $map->getResolvedCollisionMap(); + if (! isset($rows[$y][$x])) { + throw new MapSourceRefusal("Physical origin {$x}, {$y} is outside the map. Nothing was changed."); + } + $writes = []; + foreach ($piece->occupancy as $dy => $row) { + foreach ($row as $dx => $type) { + if ($type === null) { + continue; + } + if (! isset($rows[$y + $dy][$x + $dx])) { + throw new MapSourceRefusal('The whole physical footprint must fit the map. Nothing was changed.'); + } + $writes[] = [$x + $dx, $y + $dy, $type]; + } + } + + return $writes; + } + + /** @return array{changed: int, command: ?Command} */ + public static function applyPiece(ProjectMap $map, TilesetPiece $piece, int $x, int $y): array + { + $writes = self::getPieceWrites($map, $piece, $x, $y); + $path = [MapPhysicalOccupancy::DATA_KEY]; + $before = $map->getMapDataField($path); + $changed = $map->writePhysicalOccupancy($writes); + $after = $map->getMapDataField($path); + + return ['changed' => $changed, 'command' => $changed === 0 ? null : new GenericCommand('Stamp physical footprint', + static fn() => $map->setMapDataField($path, $after), + static fn() => $map->setMapDataField($path, $before), + )]; + } + /** @return list */ public static function getFillRegion(ProjectMap $map, int $x, int $y): array { diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 2d4c502..54e7e81 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -3366,6 +3366,10 @@ public static function summarizeLines(string $value): string private static function coerce(RecordField $field, string $rawValue): mixed { + if ($field->codec === RecordFieldCodec::PHYSICAL_FOOTPRINT) { + return trim($rawValue) === '' && $field->removeWhenEmpty ? null + : \Ichiloto\Editor\Maps\PhysicalFootprintCodec::decode($rawValue); + } if ($field->codec === RecordFieldCodec::SOURCE_RECT) { if (trim($rawValue) === '' && $field->removeWhenEmpty) { return null; @@ -3627,6 +3631,7 @@ private static function displayValue(RecordField $field, mixed $value): string } return match ($field->codec) { + RecordFieldCodec::PHYSICAL_FOOTPRINT => \Ichiloto\Editor\Maps\PhysicalFootprintCodec::encode($value), RecordFieldCodec::CONDITIONS => ConditionCodec::encodeAll(is_array($value) ? $value : []), RecordFieldCodec::AFFINITIES => ElementAffinityCodec::encodeAll(is_array($value) ? $value : []), RecordFieldCodec::WORLD_WRITES => WorldWriteCodec::encodeAll(is_array($value) ? $value : []), diff --git a/src/Database/RecordFieldCodec.php b/src/Database/RecordFieldCodec.php index d5f2f2c..40d209a 100644 --- a/src/Database/RecordFieldCodec.php +++ b/src/Database/RecordFieldCodec.php @@ -39,6 +39,9 @@ enum RecordFieldCodec: string */ case SHAPE_TILES = 'shape_tiles'; + /** Optional physical stamping rows, encoded as JSON integers or null and stored as final CollisionType cases or null. */ + case PHYSICAL_FOOTPRINT = 'physical_footprint'; + /** An element => multiplier map, edited a row at a time. */ case AFFINITIES = 'affinities'; diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 7c04e2d..8ace818 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -126,7 +126,7 @@ public static function all(bool $graphical = false): array self::terms(), self::configuration($graphical), self::types(), - self::tilesets(), + self::tilesets($graphical), self::battlerArt('actors'), self::battlerArt('enemies'), self::battleScaleReference(), @@ -1571,7 +1571,7 @@ private static function types(): RecordSchema * * @return RecordSchema */ - private static function tilesets(): RecordSchema + private static function tilesets(bool $graphical = false): RecordSchema { $layer = new RecordField('layer', 'Tile Layer'); $tilesFor = static fn(?string $valueField, RecordField $value, array $blank): RecordSubList => new RecordSubList( @@ -1621,6 +1621,8 @@ private static function tilesets(): RecordSchema new RecordField('id', 'Id'), new RecordField('name', 'Name'), new RecordField('layer', 'Glyph Layer'), + ...($graphical ? [new RecordField('occupancy', 'Physical Footprint', codec: RecordFieldCodec::PHYSICAL_FOOTPRINT, + removeWhenEmpty: true)] : []), new RecordField( 'connects', 'Connects', diff --git a/src/Maps/PhysicalFootprintCodec.php b/src/Maps/PhysicalFootprintCodec.php new file mode 100644 index 0000000..4cfad20 --- /dev/null +++ b/src/Maps/PhysicalFootprintCodec.php @@ -0,0 +1,88 @@ +> */ + public static function decode(string $encoded): array + { + try { + // Objects with numeric keys are not JSON lists. + $rows = json_decode($encoded, false, 64, JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + throw new InvalidArgumentException('Physical footprint must be JSON rows of final collision integers or null: ' . $error->getMessage(), previous: $error); + } + + return self::decodeRows($rows); + } + + /** @return list> Geometry is validated by TilesetPiece, never guessed here. */ + public static function decodeRows(mixed $rows): array + { + self::assertRows($rows); + $decoded = []; + foreach ($rows as $y => $row) { + $decoded[$y] = []; + foreach ($row as $x => $cell) { + $type = is_int($cell) ? CollisionType::tryFrom($cell) : null; + if ($cell !== null && (! is_int($cell) || $type === null || $type === CollisionType::PASS_THROUGH)) { + throw new InvalidArgumentException("Physical footprint cell {$x}, {$y} must be a final CollisionType integer or null; PASS_THROUGH is not a physical cell."); + } + $decoded[$y][$x] = $type; + } + } + + return $decoded; + } + + /** @return list> Only valid authored cells are projected, without repairing or padding them. */ + public static function exportRows(mixed $rows): array + { + self::assertRows($rows); + $exported = []; + foreach ($rows as $y => $row) { + $exported[$y] = []; + foreach ($row as $x => $type) { + if ($type !== null && (! $type instanceof CollisionType || $type === CollisionType::PASS_THROUGH)) { + throw new InvalidArgumentException("Physical footprint cell {$x}, {$y} must be a final CollisionType case or null."); + } + $exported[$y][$x] = $type?->value; + } + } + + return $exported; + } + + /** Display invalid declarations as authored so the record row can repair them, not as an invented valid mask. */ + public static function encode(mixed $rows): string + { + if ($rows === null) { return ''; } + try { + return json_encode($rows, JSON_THROW_ON_ERROR); + } catch (JsonException) { + // Unsupported enum declarations remain visible for explicit row repair. + return PhpValueExporter::export($rows); + } + } + + private static function assertRows(mixed $rows): void + { + if (! is_array($rows) || ! array_is_list($rows)) { + throw new InvalidArgumentException('Physical footprint must be a zero-based list of rows.'); + } + foreach ($rows as $y => $row) { + if (! is_array($row) || ! array_is_list($row)) { + throw new InvalidArgumentException("Physical footprint row {$y} must be a zero-based list of cells."); + } + } + } +} diff --git a/src/Maps/TilesetAuthoring.php b/src/Maps/TilesetAuthoring.php index 6fa3d47..25cced4 100644 --- a/src/Maps/TilesetAuthoring.php +++ b/src/Maps/TilesetAuthoring.php @@ -45,11 +45,26 @@ public static function describePieces(array $data, array $rows): array try { // The glyph footprint remains author-owned even while tile rows are being repaired. $glyphData = $dataPiece; - unset($glyphData['tiles'], $glyphData['effect']); + unset($glyphData['tiles'], $glyphData['effect'], $glyphData['occupancy']); $blueprint = TilesetPiece::fromArray((string) $id, $glyphData, 'Tileset authoring'); } catch (InvalidArgumentException $error) { $issue = $error->getMessage(); } + $occupancy = null; + $occupancyIssue = null; + $occupancyDeclared = array_key_exists('occupancy', $dataPiece); + if ($occupancyDeclared) { + try { + $occupancy = PhysicalFootprintCodec::exportRows($dataPiece['occupancy']); + } catch (InvalidArgumentException $error) { + $occupancyIssue = $error->getMessage(); + } + try { + TilesetPiece::fromArray((string) $id, $glyphData + ['occupancy' => $dataPiece['occupancy']], 'Tileset authoring'); + } catch (InvalidArgumentException $error) { + $occupancyIssue = $error->getMessage(); + } + } $layers = []; foreach (array_keys(is_array($dataPiece['tiles'] ?? null) ? $dataPiece['tiles'] : []) as $layerIndex => $name) { $field = $prefix . 'Tiles' . $layerIndex . ($connected ? 'Tile' : 'Rows'); @@ -61,7 +76,9 @@ public static function describePieces(array $data, array $rows): array $pieces[] = ['id' => (string) $id, 'name' => (string) ($dataPiece['name'] ?? $id), 'layer' => (string) ($dataPiece['layer'] ?? ''), 'connected' => $connected, 'width' => $blueprint?->width, 'height' => $blueprint?->height, - 'key' => $keys[$prefix . 'Name'] ?? null, 'layers' => $layers, 'issue' => $issue]; + 'key' => $keys[$prefix . 'Name'] ?? null, 'layers' => $layers, 'issue' => $issue, + 'occupancy' => $occupancy, 'occupancyKey' => $keys[$prefix . 'Occupancy'] ?? null, + 'occupancyDeclared' => $occupancyDeclared, 'occupancyIssue' => $occupancyIssue]; } return $pieces; diff --git a/src/ProjectMap.php b/src/ProjectMap.php index b4780f1..8f7d203 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -882,31 +882,55 @@ public function getResolvedCollisionMap(): array */ public function paintPhysicalOccupancy(array $cells, CollisionType $collision): int { - $this->assertEditable(); if ($collision === CollisionType::PASS_THROUGH) { throw new MapSourceRefusal('PASS_THROUGH is not a final physical collision. Nothing was changed.'); } - $rows = $this->getDeclaredPhysicalOccupancy(); - if ($rows === null) { - throw new MapSourceRefusal('Physical occupancy must be explicitly migrated before painting. Nothing was changed.'); - } if (! array_is_list($cells)) { throw new MapSourceRefusal('Physical cells must be a list of [x, y] integer pairs. Nothing was changed.'); } - $unique = []; + $writes = []; foreach ($cells as $cell) { if (! is_array($cell) || ! array_is_list($cell) || count($cell) !== 2 || ! is_int($cell[0]) || ! is_int($cell[1])) { throw new MapSourceRefusal('Physical cells must be a list of [x, y] integer pairs. Nothing was changed.'); } - [$x, $y] = $cell; + $writes[] = [$cell[0], $cell[1], $collision]; + } + + return $this->writePhysicalOccupancy($writes); + } + + /** @param list $writes Atomic heterogeneous physical writes. */ + public function writePhysicalOccupancy(array $writes): int + { + $this->assertEditable(); + $rows = $this->getDeclaredPhysicalOccupancy(); + if ($rows === null) { + throw new MapSourceRefusal('Physical occupancy must be explicitly migrated before painting. Nothing was changed.'); + } + if (! array_is_list($writes)) { + throw new MapSourceRefusal('Physical writes must be a list of [x, y, CollisionType] entries. Nothing was changed.'); + } + $unique = []; + foreach ($writes as $write) { + if (! is_array($write) || ! array_is_list($write) || count($write) !== 3 + || ! is_int($write[0]) || ! is_int($write[1]) || ! $write[2] instanceof CollisionType) { + throw new MapSourceRefusal('Physical writes must be a list of [x, y, CollisionType] entries. Nothing was changed.'); + } + [$x, $y, $collision] = $write; + if ($collision === CollisionType::PASS_THROUGH) { + throw new MapSourceRefusal('PASS_THROUGH is not a final physical collision. Nothing was changed.'); + } if (! isset($rows[$y][$x])) { throw new MapSourceRefusal("Physical cell {$x}, {$y} is outside the map. Nothing was changed."); } - $unique["{$x},{$y}"] = [$x, $y]; + if (isset($unique["{$x},{$y}"]) && $unique["{$x},{$y}"][2] !== $collision) { + throw new MapSourceRefusal("Physical cell {$x}, {$y} has conflicting writes. Nothing was changed."); + } + $unique["{$x},{$y}"] = $write; } $changed = 0; - foreach ($unique as [$x, $y]) { + foreach ($unique as [$x, $y, $collision]) { if ($rows[$y][$x] !== $collision) { $rows[$y][$x] = $collision; $changed++; diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 5ad6d56..04afc5c 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -730,11 +730,68 @@ public function readTilesetPreview(int $index): array 'pieces' => $pieces, 'authoring' => \Ichiloto\Editor\Maps\TilesetAuthoring::describePieces($data, $this->readDatabaseRecord('tilesets', $index)['rows']), 'connectionShapes' => TilesetPiece::LINE_SHAPES, + 'collisionTypes' => PhysicalOccupancyEditor::getCollisionChoices(), 'issue' => $issue, 'unreadable' => $unreadable, ]; } + /** + * Sets or removes one explicitly selected piece recipe through its current + * record field, preserving source and shared undo. Wire cells are integers + * or null; a null value removes the recipe, never authors an explicit null. + * + * @param list>|null $expected The recipe the caller last read. + * @param list>|null $value The new recipe, or null to remove it. + */ + public function setTilesetOccupancy(int $index, string $tilesetId, string $pieceId, ?array $expected, ?array $value): array + { + $record = $this->requireRecordDatabase('tilesets')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('tilesets has no record %d.', $index)); + if ($record->recordId !== $tilesetId) { + throw new SessionRefusal('That tileset record changed; read the tileset list again.'); + } + $data = (array) $record->toArray(); + $piece = is_array($data['pieces'] ?? null) ? ($data['pieces'][$pieceId] ?? null) : null; + if (! is_array($piece)) { + throw new SessionRefusal(sprintf('%s is no longer in tileset %s; choose a piece again.', $pieceId, $tilesetId)); + } + try { + if ($expected !== null) { \Ichiloto\Editor\Maps\PhysicalFootprintCodec::decodeRows($expected); } + $current = array_key_exists('occupancy', $piece) + ? \Ichiloto\Editor\Maps\PhysicalFootprintCodec::exportRows($piece['occupancy']) : null; + } catch (InvalidArgumentException $error) { + throw new SessionRefusal('The current or expected recipe cannot be compared safely. Repair its Physical Footprint record row first: ' + . $error->getMessage(), previous: $error); + } + if ($current !== $expected) { + throw new SessionRefusal('That physical footprint changed; read the piece again before editing it.'); + } + try { + $declaration = $value === null ? null : \Ichiloto\Editor\Maps\PhysicalFootprintCodec::decodeRows($value); + // Graphical rows and effect admission do not own logical footprint geometry. + unset($piece['tiles'], $piece['effect'], $piece['occupancy']); + if ($declaration !== null) { $piece['occupancy'] = $declaration; } + TilesetPiece::fromArray($pieceId, $piece, 'Tileset occupancy authoring'); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + $described = array_find(\Ichiloto\Editor\Maps\TilesetAuthoring::describePieces($data, + $this->readDatabaseRecord('tilesets', $index)['rows']), static fn(array $entry): bool => $entry['id'] === $pieceId); + $key = $described['occupancyKey'] ?? null; + if (! is_array($key)) { + throw new SessionRefusal('That physical footprint row cannot be edited; read the record again.'); + } + $result = $this->applyDatabaseRecord('tilesets', $index, $key, + \Ichiloto\Editor\Maps\PhysicalFootprintCodec::encode($declaration)); + if (($result['status'] ?? null) === 'question' + || (($result['changed'] ?? false) !== true && $current !== $value)) { + throw new SessionRefusal('The physical footprint was not applied. Resolve its source edit through the Physical Footprint record row first.'); + } + + return [...$result, 'occupancy' => $value]; + } + /** * Marks one tile of a tileset record as drawn above characters or as a * table, or takes the mark away when it has it, as one undo step through @@ -974,10 +1031,11 @@ public function listPieces(string $mapId): array 'height' => $piece->height, 'tileLayers' => array_keys($piece->connects === null ? $piece->tiles : $piece->shapeTiles), 'picture' => PiecePlacer::buildPicture($piece), + 'occupancy' => PhysicalOccupancyEditor::exportRecipe($piece), ]; } - return ['map' => $map->mapId, 'pieces' => $described, 'issue' => null]; + return ['map' => $map->mapId, 'tileset' => $map->getMapDataField(['tileset']), 'pieces' => $described, 'issue' => null]; } /** @@ -1345,6 +1403,50 @@ public function getOccupancyFillRegion(string $mapId, int $x, int $y, int $revis return ['cells' => $cells, 'map' => $mapId, 'revision' => $this->getMapRevision($map)]; } + /** @return array{cells: list} */ + public function previewOccupancyPiece(string $mapId, int $revision, string $tilesetId, string $pieceId, array $expected, int $x, int $y): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $piece = $this->requireOccupancyPiece($map, $tilesetId, $pieceId, $expected); + try { + $writes = PhysicalOccupancyEditor::getPieceWrites($map, $piece, $x, $y); + } catch (MapSourceRefusal | InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + + return ['cells' => array_map(static fn(array $write): array => [$write[0], $write[1], $write[2]->value], $writes)]; + } + + /** @return array{status: 'applied', changed: int, revision: int} */ + public function stampOccupancyPiece(string $mapId, int $revision, string $tilesetId, string $pieceId, array $expected, int $x, int $y): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $piece = $this->requireOccupancyPiece($map, $tilesetId, $pieceId, $expected); + try { + $applied = PhysicalOccupancyEditor::applyPiece($map, $piece, $x, $y); + } catch (MapSourceRefusal | InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + if ($applied['command'] !== null) { + $this->history->record($applied['command']); + } + + return ['status' => 'applied', 'changed' => $applied['changed'], 'revision' => $this->getMapRevision($map)]; + } + + private function requireOccupancyPiece(ProjectMap $map, string $tilesetId, string $pieceId, array $expected): TilesetPiece + { + if ($map->getMapDataField(['tileset']) !== $tilesetId) { + throw new SessionRefusal('The map tileset changed. Choose a physical footprint again. Nothing was changed.'); + } + $piece = $this->requirePiece($map, $pieceId); + if (PhysicalOccupancyEditor::exportRecipe($piece) !== $expected) { + throw new SessionRefusal('The physical footprint changed. Reload the pieces before stamping. Nothing was changed.'); + } + + return $piece; + } + /** * Renames a glyph layer as one undo step. A rename that changes the * map's collisions is asked about; it is made when asked again confirmed. diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 06f0022..5ab693b 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -69,6 +69,15 @@ public function handle(string $line): array $params = is_array($request) && is_array($request['params'] ?? null) ? $request['params'] : []; try { + if (in_array($method, ['tileset.setOccupancy', 'occupancy.previewPiece', 'occupancy.stampPiece'], true)) { + // Preserve JSON object/list identity for this strict mask contract. + $wire = json_decode($line, false, 64, JSON_THROW_ON_ERROR); + foreach (['expected', 'value'] as $key) { + if (($wire->params ?? null) instanceof \stdClass && property_exists($wire->params, $key)) { + $params[$key] = $wire->params->{$key}; + } + } + } return ['id' => $id, 'result' => $this->dispatch($method, $params)]; } catch (SessionRefusal $refusal) { return ['id' => $id, 'error' => ['kind' => 'refusal', 'message' => $refusal->getMessage()]]; @@ -141,6 +150,10 @@ private function dispatch(string $method, array $params): array is_bool($params['tileShadows'] ?? false) ? ($params['tileShadows'] ?? false) : throw new InvalidRequest('"tileShadows" must be a boolean.')), 'tiles.palette' => $session->readTilePalette(self::requireString($params, 'map')), 'tilesets.preview' => $session->readTilesetPreview(self::requireInt($params, 'index')), + 'tileset.setOccupancy' => $session->setTilesetOccupancy( + self::requireInt($params, 'index'), self::requireFootprintId($params, 'tileset'), self::requireFootprintId($params, 'piece'), + self::requireFootprint($params, 'expected'), self::requireFootprint($params, 'value'), + ), 'tilesets.mark' => $session->toggleTilesetMark( self::requireInt($params, 'index'), self::requireString($params, 'mark'), @@ -242,6 +255,16 @@ private function dispatch(string $method, array $params): array self::requireInt($params, 'collision'), array_key_exists('label', $params) ? self::requireString($params, 'label') : 'Paint collision', ), + 'occupancy.previewPiece' => $session->previewOccupancyPiece( + self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'tileset'), self::requireString($params, 'piece'), + self::requireRecipe($params), self::requireInt($params, 'x'), self::requireInt($params, 'y'), + ), + 'occupancy.stampPiece' => $session->stampOccupancyPiece( + self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'tileset'), self::requireString($params, 'piece'), + self::requireRecipe($params), self::requireInt($params, 'x'), self::requireInt($params, 'y'), + ), 'layer.create' => $session->createLayer( self::requireString($params, 'map'), self::requireInt($params, 'revision'), @@ -551,6 +574,33 @@ private function write(array $response): void fflush($this->output); } + /** @param array $params */ + private static function requireFootprintId(array $params, string $key): string + { + $id = self::requireString($params, $key); + if (preg_match('/\A[a-z0-9][a-z0-9_-]*\z/', $id) !== 1) { + throw new InvalidRequest(sprintf('"%s" must be a stable lowercase tileset or piece id.', $key)); + } + + return $id; + } + + /** @param array $params @return list>|null */ + private static function requireFootprint(array $params, string $key): ?array + { + if (! array_key_exists($key, $params)) { + throw new InvalidRequest(sprintf('"%s" must be supplied as footprint rows or null.', $key)); + } + if ($params[$key] === null) { return null; } + try { + $rows = \Ichiloto\Editor\Maps\PhysicalFootprintCodec::decodeRows($params[$key]); + + return \Ichiloto\Editor\Maps\PhysicalFootprintCodec::exportRows($rows); + } catch (\InvalidArgumentException $error) { + throw new InvalidRequest(sprintf('"%s": %s', $key, $error->getMessage()), previous: $error); + } + } + /** @param array $params */ private static function requireString(array $params, string $key): string { @@ -563,6 +613,12 @@ private static function requireInt(array $params, string $key): int return is_int($params[$key] ?? null) ? $params[$key] : throw new InvalidRequest(sprintf('"%s" must be an integer.', $key)); } + private static function requireRecipe(array $params): array + { + return self::requireFootprint($params, 'expected') + ?? throw new InvalidRequest('"expected" must be the non-null physical footprint from pieces.list.'); + } + /** @param array $params */ private static function readOptionalString(array $params, string $key): ?string { diff --git a/tests/Unit/PhysicalFootprintCodecTest.php b/tests/Unit/PhysicalFootprintCodecTest.php new file mode 100644 index 0000000..5a92d2f --- /dev/null +++ b/tests/Unit/PhysicalFootprintCodecTest.php @@ -0,0 +1,37 @@ + $type !== CollisionType::PASS_THROUGH)); + $rows = [[...$types, null]]; + $wire = [array_map(static fn(?CollisionType $type): ?int => $type?->value, [...$types, null])]; + expect(PhysicalFootprintCodec::exportRows($rows))->toBe($wire) + ->and(PhysicalFootprintCodec::decode(PhysicalFootprintCodec::encode($rows)))->toBe($rows) + ->and(PhysicalFootprintCodec::decodeRows($wire))->toBe($rows) + ->and(PhysicalFootprintCodec::decode('[[null,null]]'))->toBe([[null, null]]) + ->and(PhysicalFootprintCodec::encode(null))->toBe(''); +}); + +it('keeps unsupported enum declarations visible for explicit raw row repair without normalizing them', function () { + $rows = [[SourceWriterUnit::FIRST]]; + expect(PhysicalFootprintCodec::encode($rows))->toContain('SourceWriterUnit::FIRST') + ->and(fn() => PhysicalFootprintCodec::decode(PhysicalFootprintCodec::encode($rows)))->toThrow(InvalidArgumentException::class) + ->and(fn() => PhysicalFootprintCodec::exportRows($rows))->toThrow(InvalidArgumentException::class); +}); + +it('rejects JSON null non lists and cells that are not final integer values before enum conversion', function (string $wire) { + expect(fn() => PhysicalFootprintCodec::decode($wire))->toThrow(InvalidArgumentException::class); +})->with(['null', 'false', '1', '"rows"', '{}', '{"0":[1]}', '[{"0":1}]', '[null]', '[1]', + '[[true]]', '[["1"]]', '[[1.0]]', '[["SOLID"]]', '[[9]]', '[[999]]', '[[[1]]]', '[[1]] trailing']); + +it('does not normalize invalid authored scalar cells into valid wire recipes', function (mixed $rows) { + expect(fn() => PhysicalFootprintCodec::exportRows($rows))->toThrow(InvalidArgumentException::class); +})->with(['null' => [null], 'scalar' => ['rows'], 'keyed' => [['rows' => []]], + 'integer' => [[[1]]], 'name' => [[['SOLID']]], 'pass through' => [[[CollisionType::PASS_THROUGH]]]]); diff --git a/tests/Unit/Session/PhysicalFootprintStampingTest.php b/tests/Unit/Session/PhysicalFootprintStampingTest.php new file mode 100644 index 0000000..ea7c227 --- /dev/null +++ b/tests/Unit/Session/PhysicalFootprintStampingTest.php @@ -0,0 +1,139 @@ + 'home',", $body)); + writeTestTileset($root, pieces: ['desk' => ['name' => 'Desk', 'layer' => 'not-required-for-collision', + 'glyphs' => ['##', '##'], 'occupancy' => $recipe ?? [[null, CollisionType::COUNTER], [CollisionType::SOLID, CollisionType::NONE]]]]); + + return $root; +} + +it('previews and stamps a heterogeneous footprint independently of either presentation with shared undo and saved source', function () { + $root = createPhysicalFootprintProject(); + $session = EditorSession::open($root); + $before = $session->readMap('test-map'); + $hashes = sourceHashTree($root); + $pieces = $session->listPieces('test-map'); + $expected = [[null, 10], [1, 0]]; + expect($pieces['tileset'])->toBe('home') + ->and($pieces['pieces'][0]['layerIssue'])->toBeString() + ->and($pieces['pieces'][0]['occupancy'])->toBe($expected) + ->and($session->previewOccupancyPiece('test-map', $before['revision'], 'home', 'desk', $expected, 0, 0)['cells']) + ->toBe([[1, 0, 10], [0, 1, 1], [1, 1, 0]]) + ->and($session->readMap('test-map'))->toBe($before) + ->and(sourceHashTree($root))->toBe($hashes); + $applied = $session->stampOccupancyPiece('test-map', $before['revision'], 'home', 'desk', $expected, 0, 0); + $after = $session->readMap('test-map'); + expect($applied['changed'])->toBe(2) + ->and($after['occupancy']['rows'])->toBe([[0, 10, 0, 0], [1, 0, 0]]) + ->and($after['layers'])->toBe($before['layers']); + $session->saveMap('test-map'); + expect(file_get_contents($root . '/assets/Maps/test-map/test-map.data.php'))->toContain('// Physical source notes.'); + $map = getPhysicalOccupancyMap($session); + expect($map->getMapDataField(['occupancy', 0, 1]))->toBe(CollisionType::COUNTER); + $session->undo(); + expect($session->readMap('test-map')['occupancy']['rows'])->toBe($before['occupancy']['rows']); + $session->redo(); + $session->saveMap('test-map'); + expect(EditorSession::open($root)->readMap('test-map')['occupancy']['rows'])->toBe($after['occupancy']['rows']); + foreach ($hashes as $path => $hash) { + if ($path !== 'assets/Maps/test-map/test-map.data.php') { + expect(sourceHashTree($root)[$path])->toBe($hash); + } + } +}); + +it('refuses a partial ragged footprint, stale map revision and changed recipe without touching source or history', function () { + $root = createPhysicalFootprintProject(); + $session = EditorSession::open($root); + $before = $session->readMap('test-map'); + $hashes = sourceHashTree($root); + $expected = [[null, 10], [1, 0]]; + expect(fn() => $session->previewOccupancyPiece('test-map', $before['revision'], 'home', 'desk', $expected, 2, 0)) + ->toThrow(SessionRefusal::class, 'whole physical footprint') + ->and(fn() => $session->stampOccupancyPiece('test-map', $before['revision'], 'home', 'desk', $expected, 2, 0)) + ->toThrow(SessionRefusal::class, 'whole physical footprint') + ->and(fn() => $session->stampOccupancyPiece('test-map', $before['revision'], 'wrong', 'desk', $expected, 0, 0)) + ->toThrow(SessionRefusal::class, 'tileset changed') + ->and(fn() => $session->stampOccupancyPiece('test-map', $before['revision'], 'home', 'desk', [[1, 10], [1, 0]], 0, 0)) + ->toThrow(SessionRefusal::class, 'footprint changed') + ->and($session->undo()['label'])->toBeNull() + ->and($session->readMap('test-map'))->toBe($before) + ->and(sourceHashTree($root))->toBe($hashes); + $session->paintOccupancy('test-map', $before['revision'], [[3, 0]], 1); + expect(fn() => $session->stampOccupancyPiece('test-map', $before['revision'], 'home', 'desk', $expected, 0, 0)) + ->toThrow(SessionRefusal::class); + $new = $session->readMap('test-map'); + writeTestTileset($root, pieces: ['desk' => ['name' => 'Desk', 'layer' => 'not-required-for-collision', 'glyphs' => ['##', '##'], + 'occupancy' => [[null, CollisionType::COUNTER], [CollisionType::NONE, CollisionType::NONE]]]]); + expect(fn() => $session->stampOccupancyPiece('test-map', $new['revision'], 'home', 'desk', $expected, 0, 0)) + ->toThrow(SessionRefusal::class, 'footprint changed') + ->and($session->readMap('test-map'))->toBe($new); +}); + +it('keeps all-null recipes as no-op masks and rejects legacy maps without implicit migration', function () { + $root = createPhysicalFootprintProject([[null, null], [null, null]]); + $session = EditorSession::open($root); + $before = $session->readMap('test-map'); + expect($session->stampOccupancyPiece('test-map', $before['revision'], 'home', 'desk', [[null, null], [null, null]], 0, 0)['changed'])->toBe(0) + ->and($session->undo()['label'])->toBeNull() + ->and($session->readMap('test-map'))->toBe($before); + $map = getPhysicalOccupancyMap($session); + $map->setMapDataField(['occupancy'], null); + file_put_contents($root . '/assets/Maps/collisions.php', ' \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::NONE];'); + $read = $session->readMap('test-map'); + expect(fn() => $session->stampOccupancyPiece('test-map', $read['revision'], 'home', 'desk', [[null, null], [null, null]], 0, 0)) + ->toThrow(SessionRefusal::class, 'explicitly migrated'); +}); + +it('never applies a physical recipe as a side effect of graphical or glyph piece placement', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: ['post' => ['name' => 'Post', 'layer' => 'buildings', 'glyphs' => ['#'], + 'tiles' => ['decor' => ['5']], 'occupancy' => [[CollisionType::COUNTER]]]]); + $session = EditorSession::open($root); + $before = $session->readMap('test-map'); + $session->migratePhysicalOccupancy('test-map', $before['revision']); + $separated = $session->readMap('test-map'); + $session->placePiece('test-map', $separated['revision'], 'post', [0, 0], [0, 0]); + expect($session->readMap('test-map')['occupancy'])->toBe($separated['occupancy']); +}); + +it('refuses conflicting or invalid heterogeneous writes atomically through the shared source gate', function () { + $root = createPhysicalFootprintProject(); + $session = EditorSession::open($root); + $map = getPhysicalOccupancyMap($session); + $before = $session->readMap('test-map'); + foreach ([[[0, 0, CollisionType::NONE], [0, 0, CollisionType::COUNTER]], + [[0, 0, CollisionType::COUNTER], [3, 1, CollisionType::SOLID]], + [[0, 0, CollisionType::COUNTER], [1, 0, CollisionType::PASS_THROUGH]], [[0, 0, 1]]] as $writes) { + expect(fn() => $map->writePhysicalOccupancy($writes))->toThrow(\Ichiloto\Editor\MapSourceRefusal::class) + ->and($session->readMap('test-map'))->toBe($before); + } + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($path, str_replace('// Physical source notes.', '// External edit.', file_get_contents($path))); + expect(fn() => PhysicalOccupancyEditor::applyPiece($map, $map->loadTileset()->pieces['desk'], 0, 0)) + ->toThrow(\Ichiloto\Editor\MapSourceRefusal::class); +}); + +it('validates physical footprint RPC identity and values before mutation', function (array $override) { + $root = createPhysicalFootprintProject(); + $request = createPhysicalOccupancyRpc($root); + $before = $request('map.read', ['map' => 'test-map'])['result']; + $params = ['map' => 'test-map', 'revision' => $before['revision'], 'tileset' => 'home', 'piece' => 'desk', 'expected' => [[null, 10], [1, 0]], 'x' => 0, 'y' => 0]; + expect($request('occupancy.stampPiece', array_replace($params, $override))['error']['kind'])->toBe('request') + ->and($request('map.read', ['map' => 'test-map'])['result'])->toBe($before); +})->with([ + 'missing expected' => [['expected' => null]], 'bad row' => [['expected' => [1]]], 'boolean cell' => [['expected' => [[true, 10], [1, 0]]]], + 'fractional origin' => [['x' => 1.5]], 'tileset integer' => [['tileset' => 1]], +]); diff --git a/tests/Unit/Session/TilesetOccupancySessionTest.php b/tests/Unit/Session/TilesetOccupancySessionTest.php new file mode 100644 index 0000000..bb6b122 --- /dev/null +++ b/tests/Unit/Session/TilesetOccupancySessionTest.php @@ -0,0 +1,297 @@ + 'Synthetic object', 'layer' => 'fixtures', 'glyphs' => ['xx', ' x'], + 'tiles' => ['objects' => ['1 0', '0 2']]]; + writeTestTileset($root, pieces: $pieces ?? ['object' => $piece]); + $path = $root . '/assets/Data/Tilesets/home.php'; + file_put_contents($path, str_replace("readTilesetPreview($index)['authoring'], static fn(array $piece): bool => $piece['id'] === $id) + ?? throw new RuntimeException("No synthetic piece {$id}."); +} + +it('edits recipes through shared record history and source preserving save undo redo', function () { + $old = [[CollisionType::SOLID, null], [CollisionType::NONE, CollisionType::COUNTER]]; + $root = createFootprintAuthoringProject(['occupancy' => $old]); + $path = $root . '/assets/Data/Tilesets/home.php'; + file_put_contents($path, str_replace("'occupancy' =>", "// Preserve this interior footprint note.\n 'occupancy' =>", (string) file_get_contents($path))); + $mapBefore = sourceHashTree($root . '/assets/Maps'); + $session = EditorSession::open($root); + $record = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::all(true)['tilesets'], graphical: true)->getRecordByIndex(0); + expect($record->isEditable())->toBeTrue($record->getReadOnlyReason() ?? 'The synthetic source must remain editable.'); + $piece = readFootprintAuthoringPiece($session); + expect($piece)->toMatchArray(['width' => 2, 'height' => 2, 'occupancy' => [[1, null], [0, 10]], + 'occupancyDeclared' => true, 'occupancyIssue' => null]) + ->and($piece['occupancyKey'])->toBeArray(); + $row = array_find($session->readDatabaseRecord('tilesets', 0)['rows'], static fn(array $row): bool => + ($row['key']['field'] ?? null) === 'piece0Occupancy'); + expect($piece['occupancyKey'])->toBe($row['key']); + $mask = [[null, 0], [10, 1]]; + expect($session->setTilesetOccupancy(0, 'home', 'object', $piece['occupancy'], $mask)) + ->toMatchArray(['changed' => true, 'occupancy' => $mask]) + ->and($session->setTilesetOccupancy(0, 'home', 'object', $mask, $mask)['changed'])->toBeFalse(); + $session->saveDatabase('tilesets'); + expect(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy) + ->toBe([[null, CollisionType::NONE], [CollisionType::COUNTER, CollisionType::SOLID]]) + ->and(file_get_contents($path))->toContain('// Keep the author', '// Preserve this interior footprint note.', 'CollisionType::COUNTER', 'xx'); + expect($session->undo())->toMatchArray(['databases' => ['tilesets'], 'maps' => []]); + $session->saveDatabase('tilesets'); + expect(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy)->toBe($old) + ->and(file_get_contents($path))->toContain('// Keep the author', '// Preserve this interior footprint note.'); + $session->redo(); + $session->saveDatabase('tilesets'); + expect(EditorSession::open($root)->readTilesetPreview(0)['authoring'][0]['occupancy'])->toBe($mask) + ->and(sourceHashTree($root . '/assets/Maps'))->toBe($mapBefore); +}); + +it('adds an all null recipe and removes the field rather than persisting explicit null', function () { + $root = createFootprintAuthoringProject(); + $path = $root . '/assets/Data/Tilesets/home.php'; + $session = EditorSession::open($root); + $mask = [[null, null], [null, null]]; + expect(readFootprintAuthoringPiece($session))->toMatchArray(['occupancy' => null, 'occupancyDeclared' => false]); + $session->setTilesetOccupancy(0, 'home', 'object', null, $mask); + $session->saveDatabase('tilesets'); + expect(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy)->toBe($mask) + ->and(file_get_contents($path))->toContain("'occupancy'"); + $session->setTilesetOccupancy(0, 'home', 'object', $mask, null); + $session->saveDatabase('tilesets'); + expect(file_get_contents($path))->not->toContain("'occupancy'") + ->and(readFootprintAuthoringPiece($session))->toMatchArray(['occupancy' => null, 'occupancyDeclared' => false]); + $session->undo(); + $session->saveDatabase('tilesets'); + expect(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy)->toBe($mask); + $session->redo(); + $session->saveDatabase('tilesets'); + expect(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy)->toBeNull(); +}); + +it('retains glyph geometry and a repair key for a wrong size recipe independently of invalid graphics', function () { + $root = createFootprintAuthoringProject(['occupancy' => [[CollisionType::SOLID]], 'tiles' => ['objects' => ['invalid']], 'effect' => '../invalid']); + $session = EditorSession::open($root); + $piece = readFootprintAuthoringPiece($session); + expect($piece)->toMatchArray(['width' => 2, 'height' => 2, 'issue' => null, 'occupancy' => [[1]]]) + ->and($piece['occupancyIssue'])->toContain('occupancy must have 2 rows') + ->and($piece['occupancyKey'])->toBeArray(); + expect($session->setTilesetOccupancy(0, 'home', 'object', [[1]], [[null, 0], [1, 10]])['changed'])->toBeTrue() + ->and(readFootprintAuthoringPiece($session)['occupancyIssue'])->toBeNull(); +}); + +it('exposes invalid recipe diagnostics without inventing a mask and permits explicit record row repair', function (mixed $bad) { + $root = createFootprintAuthoringProject(['occupancy' => $bad]); + $session = EditorSession::open($root); + $piece = readFootprintAuthoringPiece($session); + expect($piece)->toMatchArray(['width' => 2, 'height' => 2, 'issue' => null, 'occupancy' => null, 'occupancyDeclared' => true]) + ->and($piece['occupancyIssue'])->toContain('occupancy') + ->and($piece['occupancyKey'])->toBeArray() + ->and(fn() => $session->setTilesetOccupancy(0, 'home', 'object', null, [[1, null], [0, 10]])) + ->toThrow(SessionRefusal::class, 'cannot be compared safely'); + expect($session->listDatabaseRecords('tilesets')['dirty'])->toBeFalse(); + $session->applyDatabaseRecord('tilesets', 0, $piece['occupancyKey'], '[[1,null],[0,10]]'); + $session->saveDatabase('tilesets'); + expect(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy) + ->toBe([[CollisionType::SOLID, null], [CollisionType::NONE, CollisionType::COUNTER]]) + ->and(readFootprintAuthoringPiece($session)['occupancyIssue'])->toBeNull(); +})->with([ + 'explicit null' => [null], 'scalar' => ['bad'], 'non list' => [['row' => [CollisionType::SOLID]]], + 'integer instead of enum' => [[[1, null], [null, null]]], + 'pass through' => [[[CollisionType::PASS_THROUGH, null], [null, null]]], + 'unsupported enum' => [[[SourceWriterUnit::FIRST, null], [null, null]]], +]); + +it('uses one shared connected recipe and preserves connected shapes when editing it', function () { + $root = createFootprintAuthoringProject(['connects' => 'lines', 'glyphs' => [ + 'horizontal' => '-', 'vertical' => '|', 'corner' => '+'], 'tiles' => ['objects' => '12']]); + $session = EditorSession::open($root); + $session->setTilesetOccupancy(0, 'home', 'object', null, [[10]]); + $session->saveDatabase('tilesets'); + $piece = Tileset::load($root . '/assets', 'home')->pieces['object']; + expect($piece->occupancy)->toBe([[CollisionType::COUNTER]]) + ->and([$piece->width, $piece->height])->toBe([1, 1]) + ->and($piece->getSourceShapeGrid())->toBe(['horizontal' => '-', 'vertical' => '|', 'corner' => '+']) + ->and(fn() => $session->setTilesetOccupancy(0, 'home', 'object', [[10]], [[10, 10]])) + ->toThrow(SessionRefusal::class, 'must be 1 cells wide'); +}); + +it('refuses stale record identity piece identity and expected recipe without any mutation', function () { + $root = createFootprintAuthoringProject(['occupancy' => [[CollisionType::NONE, null], [null, null]]]); + $session = EditorSession::open($root); + $before = sourceHashTree($root); + foreach ([ + [0, 'other', 'object', [[0, null], [null, null]], [[1, null], [null, null]]], + [0, 'home', 'missing', [[0, null], [null, null]], [[1, null], [null, null]]], + [0, 'home', 'object', null, [[1, null], [null, null]]], + [0, 'home', 'object', [[1, null], [null, null]], [[1, null], [null, null]]], + ] as $args) { + expect(fn() => $session->setTilesetOccupancy(...$args))->toThrow(SessionRefusal::class) + ->and($session->listDatabaseRecords('tilesets')['dirty'])->toBeFalse() + ->and(sourceHashTree($root))->toBe($before); + } + $session->setTilesetOccupancy(0, 'home', 'object', [[0, null], [null, null]], [[1, null], [null, null]]); + expect(fn() => $session->setTilesetOccupancy(0, 'home', 'object', [[0, null], [null, null]], [[10, null], [null, null]])) + ->toThrow(SessionRefusal::class, 'changed') + ->and(readFootprintAuthoringPiece($session)['occupancy'])->toBe([[1, null], [null, null]]); +}); + +it('resolves the fresh occupancy row by stable piece id after another piece is removed', function () { + $root = createFootprintAuthoringProject(pieces: [ + 'first' => ['name' => 'First', 'layer' => 'fixtures', 'glyphs' => ['x']], + 'object' => ['name' => 'Target', 'layer' => 'fixtures', 'glyphs' => ['x']], + ]); + $session = EditorSession::open($root); + expect(readFootprintAuthoringPiece($session)['occupancyKey']['field'])->toBe('piece1Occupancy'); + $session->removeDatabaseItem('tilesets', 0, ['field' => 'piece0Name', 'frame' => []]); + expect($session->setTilesetOccupancy(0, 'home', 'object', null, [[1]])['changed'])->toBeTrue() + ->and(readFootprintAuthoringPiece($session)['occupancyKey']['field'])->toBe('piece0Occupancy'); + $session->saveDatabase('tilesets'); + expect(array_keys(Tileset::load($root . '/assets', 'home')->pieces))->toBe(['object']) + ->and(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy)->toBe([[CollisionType::SOLID]]); +}); + +it('refuses unsupported computed recipe source before any record or history mutation', function () { + $root = createFootprintAuthoringProject(); + $path = $root . '/assets/Data/Tilesets/home.php'; + $source = <<<'PHP' + 'Home', 'sheets' => ['B' => 'Graphics/Tilesets/Home_B.png'], 'pieces' => [ + 'object' => ['name' => 'Computed object', 'layer' => 'fixtures', 'glyphs' => ['xx', 'xx'], + 'occupancy' => (static fn(): array => [[CollisionType::NONE, null], [null, null]])()], + ]]; + PHP; + file_put_contents($path, $source); + $session = EditorSession::open($root); + $before = sourceHashTree($root); + expect(fn() => $session->setTilesetOccupancy(0, 'home', 'object', [[0, null], [null, null]], [[1, null], [null, null]])) + ->toThrow(SessionRefusal::class) + ->and($session->listDatabaseRecords('tilesets')['dirty'])->toBeFalse() + ->and(readFootprintAuthoringPiece($session)['occupancy'])->toBe([[0, null], [null, null]]) + ->and(sourceHashTree($root))->toBe($before) + ->and($session->undo())->toMatchArray(['label' => null, 'maps' => [], 'databases' => []]); +}); + +it('omits the recipe field from TUI schemas while preserving authored recipes during unrelated terminal edits', function () { + $mask = [[CollisionType::SOLID, null], [CollisionType::NONE, CollisionType::COUNTER]]; + $root = createFootprintAuthoringProject(['occupancy' => $mask]); + $terminal = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::all(false)['tilesets']); + $graphical = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::all(true)['tilesets'], graphical: true); + expect(array_column($terminal->getSettingsFields(0), 'field'))->not->toContain('piece0Occupancy') + ->and(array_column($graphical->getSettingsFields(0), 'field'))->toContain('piece0Occupancy'); + $terminal->setField(0, 'name', 'Updated name'); + $terminal->save(); + expect(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy)->toBe($mask); +}); + +it('refuses invalid recipe geometry at the dedicated Engine validation boundary without partial edits', function (array $mask) { + $root = createFootprintAuthoringProject(); + $session = EditorSession::open($root); + $before = sourceHashTree($root); + expect(fn() => $session->setTilesetOccupancy(0, 'home', 'object', null, $mask))->toThrow(SessionRefusal::class) + ->and($session->listDatabaseRecords('tilesets')['dirty'])->toBeFalse() + ->and(sourceHashTree($root))->toBe($before); +})->with(['empty' => [[]], 'short height' => [[[1, null]]], 'ragged' => [[[1], [null, null]]], + 'wide' => [[[1, null, null], [null, null, null]]]]); + +it('validates recipe RPC identities expected baseline and JSON list types before editing', function () { + $root = createFootprintAuthoringProject(); + $input = fopen('php://memory', 'r'); + $output = fopen('php://memory', 'w+'); + $diagnostics = fopen('php://memory', 'w+'); + $host = new SessionHost($input, $output, $diagnostics); + $request = static fn(string $method, array $params): array => $host->handle(json_encode( + ['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + try { + expect($request('hello', ['project' => $root, 'protocol' => SessionHost::PROTOCOL]))->toHaveKey('result'); + $params = ['index' => 0, 'tileset' => 'home', 'piece' => 'object', 'expected' => null, 'value' => [[1, null], [0, 10]]]; + foreach ([['index' => '0'], ['tileset' => 'Home'], ['piece' => ''], ['value' => 'bad'], ['value' => 1], + ['value' => [[true, null], [0, 10]]], ['value' => [['1', null], [0, 10]]], ['value' => [[1.0, null], [0, 10]]], + ['value' => [[9, null], [0, 10]]], ['value' => [[999, null], [0, 10]]], ['value' => (object) ['0' => [1, null], '1' => [0, 10]]], + ['value' => [(object) ['0' => 1, '1' => null], [0, 10]]], ['expected' => [[9, null], [0, 10]]]] as $invalid) { + $wire = json_encode(['id' => 1, 'method' => 'tileset.setOccupancy', 'params' => $invalid + $params], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); + expect($host->handle($wire)['error']['kind'])->toBe('request'); + } + foreach (['index', 'tileset', 'piece', 'expected', 'value'] as $missing) { + $invalid = $params; + unset($invalid[$missing]); + expect($request('tileset.setOccupancy', $invalid)['error']['kind'])->toBe('request'); + } + expect($request('database.records', ['category' => 'tilesets'])['result']['dirty'])->toBeFalse() + ->and($request('tileset.setOccupancy', $params)['result'])->toMatchArray(['changed' => true, 'occupancy' => $params['value']]) + ->and($request('tileset.setOccupancy', ['expected' => $params['value']] + $params)['result']) + ->toMatchArray(['changed' => false, 'occupancy' => $params['value']]) + ->and($request('tileset.setOccupancy', $params)['error']['kind'])->toBe('refusal'); + $params['expected'] = $params['value']; + $params['value'] = null; + expect($request('tileset.setOccupancy', $params)['result']['occupancy'])->toBeNull(); + rewind($diagnostics); + expect(stream_get_contents($diagnostics))->toBe(''); + } finally { + $host->run(); + fclose($input); + fclose($output); + fclose($diagnostics); + } +}); + +it('returns a recipe RPC refusal without successful occupancy metadata when nonliteral authored source cannot be edited', function () { + $root = createFootprintAuthoringProject(); + $path = $root . '/assets/Data/Tilesets/home.php'; + file_put_contents($path, <<<'PHP' + [ + // Preserve this nonliteral, author-owned source computation. + 'name' => 'Home', 'sheets' => ['B' => 'Graphics/Tilesets/Home_B.png'], 'pieces' => [ + 'object' => ['name' => 'Computed object', 'layer' => 'fixtures', 'glyphs' => ['xx', 'xx'], + 'occupancy' => [[CollisionType::NONE, null], [null, null]]], + ], + ])(); + PHP); + $input = fopen('php://memory', 'r'); + $output = fopen('php://memory', 'w+'); + $diagnostics = fopen('php://memory', 'w+'); + $host = new SessionHost($input, $output, $diagnostics); + $request = static fn(string $method, array $params): array => $host->handle(json_encode( + ['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + $before = sourceHashTree($root); + try { + $request('hello', ['project' => $root, 'protocol' => SessionHost::PROTOCOL]); + $reply = $request('tileset.setOccupancy', ['index' => 0, 'tileset' => 'home', 'piece' => 'object', + 'expected' => [[0, null], [null, null]], 'value' => [[1, null], [null, null]]]); + expect($reply)->not->toHaveKey('result') + ->and($reply['error']['kind'])->toBe('refusal') + ->and($request('database.records', ['category' => 'tilesets'])['result']['dirty'])->toBeFalse() + ->and($request('history.undo', [])['result'])->toMatchArray(['label' => null, 'maps' => [], 'databases' => []]) + ->and(sourceHashTree($root))->toBe($before); + rewind($diagnostics); + expect(stream_get_contents($diagnostics))->toBe(''); + } finally { + $host->run(); + fclose($input); + fclose($output); + fclose($diagnostics); + } +}); From 95ac81cd70ec0d74270c79ac55b67a53f5e65b53 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 10 Oct 2026 13:49:29 +0200 Subject: [PATCH 240/344] feat(canvas): give an erased mounted piece's cell back to the face it kept Erasing a piece that keeps the tiles beneath it, such as a window on a wall face, now restores the role those kept tiles alone prove: the face's glyph returns with the tiles that never left, so the terminal and collision role is whole again. Several proven roles wait for the author's choice under the existing piece question, and none leaves the cell blank. GlyphTilePlanner decides it once and CanvasEditor::plan returns the glyphs the edit writes, painted as the restored piece authors them. Every canvas path writes them: TUI edits and strokes, GUI painting, cut and paste, and piece placement. --- docs/manual.md | 7 +- src/Canvas/CanvasClipboard.php | 2 +- src/Canvas/CanvasEditor.php | 17 +- src/Canvas/GlyphTileCanvas.php | 22 +-- src/Canvas/GlyphTilePlanner.php | 75 ++++++++- src/Canvas/PiecePlacer.php | 11 +- src/Canvas/PieceRole.php | 10 +- src/Editor.php | 17 +- src/Session/EditorSession.php | 2 +- tests/Unit/Canvas/KeptUnderlayErasureTest.php | 146 ++++++++++++++++++ tests/Unit/TilesetPieceKeepsTest.php | 2 +- 11 files changed, 277 insertions(+), 34 deletions(-) create mode 100644 tests/Unit/Canvas/KeptUnderlayErasureTest.php diff --git a/docs/manual.md b/docs/manual.md index 673c906..2ccec22 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -443,7 +443,12 @@ intact: otherwise the editor asks which piece it is, or No tiles, and Esc leaves the map as it was. The brush remembers the answer for its glyph until a glyph is typed again, and the eyedropper picks up the piece a glyph draws. Tiles no - piece accounts for, such as a floor under a wall, stay where they are. + piece accounts for, such as a floor under a wall, stay where they are. A + piece that keeps the tiles beneath it, such as a window mounted on a wall + face, leaves them when it arrives; erasing it gives the cell back to the + piece those kept tiles stand for, such as the face's `#`, so the wall is + whole again. When they stand for several pieces, the editor asks which, or + none to leave the cell blank. - Glyphs follow the tiles that stand for them, the other way round. Any tile a tileset piece draws stands for that piece's glyph on its gameplay layer, diff --git a/src/Canvas/CanvasClipboard.php b/src/Canvas/CanvasClipboard.php index ef1de36..d229f98 100644 --- a/src/Canvas/CanvasClipboard.php +++ b/src/Canvas/CanvasClipboard.php @@ -104,7 +104,7 @@ private static function applyWithTiles(ProjectMap $map, string $layerId, array $ if ($plan !== null && $plan['unresolved'] !== []) { return ['command' => null, 'changed' => 0, 'unresolved' => $plan['unresolved']]; } - $applied = CanvasEditor::apply($map, $layerId, $writes, $label, [...($plan['tiles'] ?? []), ...$tiles]); + $applied = CanvasEditor::apply($map, $layerId, $plan['writes'] ?? $writes, $label, [...($plan['tiles'] ?? []), ...$tiles]); return ['command' => $applied['command'], 'changed' => $applied['changed'], 'unresolved' => []]; } diff --git a/src/Canvas/CanvasEditor.php b/src/Canvas/CanvasEditor.php index 6657e6e..77c90e6 100644 --- a/src/Canvas/CanvasEditor.php +++ b/src/Canvas/CanvasEditor.php @@ -37,8 +37,10 @@ final class CanvasEditor * @param bool $repaint Whether a glyph painted over itself may take another role. * @param list $excludedLayers Tile layers the edit sets itself. * @param array $assigned The role key a written cell's glyph plays, by `x,y`, when the edit knows it. - * @return array{tiles: array>, unresolved: array>}|null - * The tiles and the glyphs still waiting on a choice, or null when the layer has no pieces to follow. + * @return array{tiles: array>, unresolved: array>, writes: array}|null + * The tiles, the glyphs still waiting on a choice, and the glyphs the edit writes: `$writes`, except where + * an erase gives a cell back to the role its kept underlay proves, painted as its piece authors it. Null + * when the layer has no pieces to follow, and the edit writes `$writes` as they are. * @throws MapSourceRefusal When a tile layer the pieces draw on cannot be read. */ public static function plan(ProjectMap $map, string $layerId, array $writes, array $choices = [], bool $repaint = false, @@ -56,7 +58,7 @@ public static function plan(ProjectMap $map, string $layerId, array $writes, arr } } - return $planner->plan( + $plan = $planner->plan( $changes, static fn(int $x, int $y): ?string => $map->hasLayerCell($layerId, $x, $y) ? $map->getLayerSymbol($layerId, $x, $y) : null, self::createTileReader($map), @@ -64,6 +66,15 @@ public static function plan(ProjectMap $map, string $layerId, array $writes, arr $repaint, $assigned, ); + $restored = []; + foreach ($plan['glyphs'] as $glyph) { + // The face comes back as its piece paints it, never in the colour of the piece that left. + $restored["{$glyph['x']},{$glyph['y']}"] = ['x' => $glyph['x'], 'y' => $glyph['y'], 'symbol' => $glyph['symbol']] + + PiecePlacer::resolveCellPaint($glyph['role']->source, ''); + } + $plan['writes'] = array_map(static fn(array $write): array => $restored["{$write['x']},{$write['y']}"] ?? $write, $writes); + + return $plan; } /** diff --git a/src/Canvas/GlyphTileCanvas.php b/src/Canvas/GlyphTileCanvas.php index 22aee42..821db2e 100644 --- a/src/Canvas/GlyphTileCanvas.php +++ b/src/Canvas/GlyphTileCanvas.php @@ -62,6 +62,7 @@ private function commitCanvasWrites(ProjectMap $map, array $writes, string $labe return null; } $tiles = [...($plan['tiles'] ?? []), ...$tiles]; + $writes = $plan['writes'] ?? $writes; $this->finalizeActiveStroke(); try { @@ -82,30 +83,31 @@ private function commitCanvasWrites(ProjectMap $map, array $writes, string $labe * glyphs are written, so the stroke records them with its glyphs when it * ends ({@see finalizeActiveStroke()}). * - * @param array $writes + * @param array $writes * @param Closure(array): void $retry Paints this step again with the author's choices. - * @return bool Whether the glyphs may be written: false when refused or waiting on a choice. + * @return array|null + * The glyphs the step writes ({@see CanvasEditor::plan()}), or null when refused or waiting on a choice. */ - private function followStrokeWithTiles(ProjectMap $map, array $writes, Closure $retry): bool + private function followStrokeWithTiles(ProjectMap $map, array $writes, Closure $retry): ?array { try { $plan = $this->planGlyphTiles($map, $this->getActiveCanvasLayer(), $writes, $this->getPaintPieceChoices((string) ($writes[0]['symbol'] ?? '')), true); } catch (MapSourceRefusal $refusal) { $this->setStatus($refusal->getMessage(), StatusLevel::WARN); - return false; + return null; } if ($plan === null) { - return true; + return $writes; } if ($plan['unresolved'] !== []) { $this->finalizeActiveStroke(); $glyph = (string) array_key_first($plan['unresolved']); $this->askForGlyphPiece($glyph, $plan['unresolved'][$glyph], [], $retry); - return false; + return null; } if ($plan['tiles'] === []) { - return true; + return $plan['writes']; } if ($this->activeStrokeTiles === null || $this->activeStrokeTiles['map'] !== $map) { $this->activeStrokeTiles = ['map' => $map, 'sources' => $map->getTileLayerSources()]; @@ -114,10 +116,10 @@ private function followStrokeWithTiles(ProjectMap $map, array $writes, Closure $ $map->writeTileCells($plan['tiles']); } catch (MapSourceRefusal $refusal) { $this->setStatus($refusal->getMessage(), StatusLevel::WARN); - return false; + return null; } - return true; + return $plan['writes']; } /** @@ -129,7 +131,7 @@ private function followStrokeWithTiles(ProjectMap $map, array $writes, Closure $ * @param array $writes * @param array $choices * @param list $excludedLayers Tile layers the edit sets itself. - * @return array{tiles: array>, unresolved: array>}|null + * @return array{tiles: array>, unresolved: array>, writes: array>}|null * @throws MapSourceRefusal When a tile layer the pieces draw on cannot be read. */ private function planGlyphTiles(ProjectMap $map, string $layerId, array $writes, array $choices, bool $repaint, diff --git a/src/Canvas/GlyphTilePlanner.php b/src/Canvas/GlyphTilePlanner.php index 750575b..9c4a0df 100644 --- a/src/Canvas/GlyphTilePlanner.php +++ b/src/Canvas/GlyphTilePlanner.php @@ -121,26 +121,37 @@ public function findPlayedRole(string $glyph, int $x, int $y, Closure $tileAt): * @param array $assigned The role key a cell's glyph plays, by `x,y`, when the edit already knows * it, such as a tile edit that placed that role's tile. It outranks proof and choice, and plans the cell even * when its glyph stays the same, so a role whose tiles had gone missing gets them back. - * @return array{tiles: array>, unresolved: array>} - * The tile cells to write, keyed by tile layer name, and the roles of each glyph that still needs a choice. + * @return array{tiles: array>, unresolved: array>, glyphs: list} + * The tile cells to write, keyed by tile layer name; the roles of each glyph that still needs a choice; and + * the cells an erase gives back to the role their kept underlay proves ({@see findUnderlayRole()}), which + * the edit writes instead of a blank. */ public function plan(array $changes, Closure $glyphAt, Closure $tileAt, array $choices = [], bool $repaint = false, array $assigned = []): array { $changes = array_values(array_filter($changes, static fn(array $change): bool => $repaint || $change['old'] !== $change['new'] || isset($assigned["{$change['x']},{$change['y']}"]))); + $unresolved = []; + $glyphs = []; + $underlay = []; + foreach ($changes as $index => $change) { + if ($change['new'] === ' ' && ($found = $this->findUnderlayRole($change, $tileAt, $choices, $unresolved)) !== null) { + [$changes[$index]['new'], $underlay[$index]] = $found; + $glyphs[] = ['x' => $change['x'], 'y' => $change['y'], 'symbol' => $found[0], 'role' => $found[1]]; + } + } $changed = []; foreach ($changes as $change) { $changed["{$change['x']},{$change['y']}"] = $change['new']; } $overlay = []; $resolved = []; - $unresolved = []; $kept = []; foreach ($changes as $index => $change) { $key = $assigned["{$change['x']},{$change['y']}"] ?? null; $resolved[$index] = match (true) { + isset($underlay[$index]) => $underlay[$index], $change['new'] === ' ' => null, $key !== null => $this->findRole($change['new'], $key), default => $this->resolveRole($change, $glyphAt, $tileAt, $changed, $choices, $unresolved), @@ -192,7 +203,63 @@ public function plan(array $changes, Closure $glyphAt, Closure $tileAt, array $c } } - return ['tiles' => $tiles, 'unresolved' => $unresolved]; + return ['tiles' => $tiles, 'unresolved' => $unresolved, 'glyphs' => $glyphs]; + } + + /** + * The role an erased cell goes back to: when the role leaving it keeps a + * layer's existing tiles there, the role those kept tiles alone prove, + * one whose own tiles at the cell are all present and all on kept layers + * (the wall face a window was mounted on). Erasing gives the cell that + * role's glyph, so its terminal and collision role returns with the tiles + * that never left. Roles of another glyph proven the same way make the + * erase wait for the author's choice, keyed by the blank glyph; choosing + * none leaves the cell blank. Null when nothing is kept or nothing proven. + * + * @param array{x: int, y: int, old: string, new: string} $change + * @param array $choices + * @param array> $unresolved + * @return array{0: string, 1: PieceRole}|null The glyph and role the cell takes. + */ + private function findUnderlayRole(array $change, Closure $tileAt, array $choices, array &$unresolved): ?array + { + [$x, $y] = [$change['x'], $change['y']]; + $leaving = $this->findPlayedRole($change['old'], $x, $y, $tileAt); + $keptLayers = []; + foreach ($leaving?->keeps ?? [] as $layer => $cells) { + if (array_any($cells, static fn(array $cell): bool => $cell['dx'] === 0 && $cell['dy'] === 0)) { + $keptLayers[$layer] = true; + } + } + if ($leaving === null || $keptLayers === []) { + return null; + } + $proven = []; + foreach (array_keys($this->roles) as $glyph) { + $role = $this->findPlayedRole((string) $glyph, $x, $y, $tileAt); + if ($role !== null && $role->pieceId !== $leaving->pieceId && array_diff_key($role->getOwnEntries(), $keptLayers) === []) { + $proven[(string) $glyph] = $role; + } + } + if (count($proven) < 2) { + return $proven === [] ? null : [(string) array_key_first($proven), reset($proven)]; + } + if (array_key_exists(' ', $choices)) { + foreach ($proven as $glyph => $role) { + if ($choices[' '] !== null && $this->findRole($glyph, $choices[' ']) === $role) { + return [$glyph, $role]; + } + } + + return null; + } + $waiting = []; + foreach ([...($unresolved[' '] ?? []), ...array_values($proven)] as $role) { + $waiting[$role->key] = $role; + } + $unresolved[' '] = array_values($waiting); + + return null; } /** diff --git a/src/Canvas/PiecePlacer.php b/src/Canvas/PiecePlacer.php index 129743d..183cfbc 100644 --- a/src/Canvas/PiecePlacer.php +++ b/src/Canvas/PiecePlacer.php @@ -239,7 +239,9 @@ public static function stampArea(ProjectMap $map, TilesetPiece $piece, array $fr $map->writeTileEntries($piece->tiles, $x, $y); } } else { - $map->writeTileCells(CanvasEditor::plan($map, $layer, $writes, assigned: $assigned)['tiles'] ?? []); + $plan = CanvasEditor::plan($map, $layer, $writes, assigned: $assigned); + $map->writeTileCells($plan['tiles'] ?? []); + $writes = $plan['writes'] ?? $writes; } $tilesAfter = $map->getTileLayerSources(); [$stroke] = CanvasEditor::writeCells($map, $layer, $writes, $label); @@ -288,9 +290,10 @@ public static function drawConnected(ProjectMap $map, TilesetPiece $piece, array isset($drawnKeys["{$cell['x']},{$cell['y']}"]) ? $fallbackColor : null)), $cells); // Each shape draws its tiles, and a glyph the wall covers takes its own ({@see GlyphTilePlanner}). $tilesBefore = $map->getTileLayerSources(); - $map->writeTileCells(CanvasEditor::plan($map, $layer, $writes)['tiles'] ?? []); + $plan = CanvasEditor::plan($map, $layer, $writes); + $map->writeTileCells($plan['tiles'] ?? []); $tilesAfter = $map->getTileLayerSources(); - [$stroke] = CanvasEditor::writeCells($map, $layer, $writes, $label); + [$stroke] = CanvasEditor::writeCells($map, $layer, $plan['writes'] ?? $writes, $label); return $stroke->hasChanges() || $tilesAfter !== $tilesBefore ? CanvasEditor::combineStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter) @@ -315,7 +318,7 @@ public static function resolveMemberLookup(ProjectMap $map, string $layer, Tiles * * @return array{style: array{prefix: string, suffix: string}}|array{color: string|null} */ - private static function resolveCellPaint(string $source, ?string $fallbackColor): array + public static function resolveCellPaint(string $source, ?string $fallbackColor): array { $style = self::readCellStyle($source); diff --git a/src/Canvas/PieceRole.php b/src/Canvas/PieceRole.php index 34ba2ae..a667868 100644 --- a/src/Canvas/PieceRole.php +++ b/src/Canvas/PieceRole.php @@ -24,9 +24,10 @@ * @param array> $tiles Tile entries relative to the glyph cell, keyed by tile layer name; never `0`. * @param array{int, int}|null $cell An item piece's cell, as row and column; null for a connected piece's shape. * @param array> $keeps Cells whose existing tiles stay, keyed by tile layer; these are not owned tiles to remove. + * @param string $source The glyph cell as the tileset authors it, with its style markup, if any. */ public function __construct(public string $pieceId, string $part, public string $label, public array $tiles, public ?array $cell = null, - public array $keeps = []) + public array $keeps = [], public string $source = '') { $this->key = "{$pieceId}:{$part}"; } @@ -45,6 +46,7 @@ public static function readPiece(TilesetPiece $piece, array $excludedLayers = [] $excluded = array_flip($excludedLayers); if ($piece->connects !== null) { $roles = []; + $sources = $piece->getSourceShapeGrid(); foreach ($piece->shapes as $shape => $glyph) { $tiles = []; foreach (array_diff_key($piece->shapeTiles, $excluded) as $layer => $entries) { @@ -52,7 +54,7 @@ public static function readPiece(TilesetPiece $piece, array $excludedLayers = [] $tiles[$layer] = [['dx' => 0, 'dy' => 0, 'entry' => $entries[$shape]]]; } } - $roles[$glyph][] = new self($piece->id, $shape, $piece->name, $tiles); + $roles[$glyph][] = new self($piece->id, $shape, $piece->name, $tiles, source: $sources[$shape] ?? $glyph); } return $roles; @@ -82,6 +84,7 @@ public static function readPiece(TilesetPiece $piece, array $excludedLayers = [] $attached[$nearest[0]][] = [$row, $column]; } $roles = []; + $sources = $piece->getSourceGrid(); foreach ($glyphCells as $index => [$row, $column]) { $tiles = []; $ownedCells = [[$row, $column], ...($attached[$index] ?? [])]; @@ -99,7 +102,8 @@ public static function readPiece(TilesetPiece $piece, array $excludedLayers = [] } } $roles[$piece->glyphs[$row][$column]][] = new self($piece->id, "{$row}:{$column}", - self::describeCell($piece->name, $glyphCells, $row, $column), $tiles, [$row, $column], $keeps); + self::describeCell($piece->name, $glyphCells, $row, $column), $tiles, [$row, $column], $keeps, + $sources[$row][$column] ?? $piece->glyphs[$row][$column]); } return $roles; diff --git a/src/Editor.php b/src/Editor.php index e7ca1bf..2f747cb 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -4236,24 +4236,29 @@ private function paintCanvasStroke( $this->renderCanvasArea(); }; - if (! $this->followStrokeWithTiles($selectedMap, array_map( - static fn(array $point): array => ['x' => $point['x'], 'y' => $point['y'], 'symbol' => $symbol], + $color = $this->selectedPaintColor; + $writes = $this->followStrokeWithTiles($selectedMap, array_map( + static fn(array $point): array => ['x' => $point['x'], 'y' => $point['y'], 'symbol' => $symbol, 'color' => $color], $points, - ), $retry)) { + ), $retry); + if ($writes === null) { $this->activeMousePaintButton = null; $this->lastMousePaintPoint = null; return; } - foreach ($points as $point) { + // The planned glyphs: an erase may give a cell back to the wall face its window was mounted on. + foreach ($writes as $point) { if (! $selectedMap->hasLayerCell($this->getActiveCanvasLayer(), $point['x'], $point['y'])) { continue; } $oldSymbol = $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']); $oldStyle = $selectedMap->getLayerCellStyle($this->getActiveCanvasLayer(), $point['x'], $point['y']); - [$newPrefix, $newSuffix] = CanvasEditor::resolvePaintStyle($symbol, $this->selectedPaintColor, $oldStyle); - $selectedMap->setLayerCell($this->getActiveCanvasLayer(), $point['x'], $point['y'], $symbol, $newPrefix, $newSuffix); + [$newPrefix, $newSuffix] = isset($point['style']) + ? [$point['style']['prefix'], $point['style']['suffix']] + : CanvasEditor::resolvePaintStyle($point['symbol'], $point['color'] ?? null, $oldStyle); + $selectedMap->setLayerCell($this->getActiveCanvasLayer(), $point['x'], $point['y'], $point['symbol'], $newPrefix, $newSuffix); $newSymbol = $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']); $this->activeStrokeCommand->appendCell( $point['x'], diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 04afc5c..6642bfa 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1341,7 +1341,7 @@ public function paint(string $mapId, int $revision, string $layerId, array $cell $plan['unresolved'][$glyph], )]; } - $applied = CanvasEditor::apply($map, $layerId, $writes, $label, $plan['tiles'] ?? []); + $applied = CanvasEditor::apply($map, $layerId, $plan['writes'] ?? $writes, $label, $plan['tiles'] ?? []); } catch (MapSourceRefusal $refusal) { throw new SessionRefusal($refusal->getMessage(), previous: $refusal); } diff --git a/tests/Unit/Canvas/KeptUnderlayErasureTest.php b/tests/Unit/Canvas/KeptUnderlayErasureTest.php new file mode 100644 index 0000000..dcab7c3 --- /dev/null +++ b/tests/Unit/Canvas/KeptUnderlayErasureTest.php @@ -0,0 +1,146 @@ + $id, 'layer' => 'buildings'] + $data, 'Synthetic underlay'); +} + +/** @param list $pieces */ +function planUnderlayErase(array $pieces, string $old, array $tiles, array $choices = []): array +{ + return GlyphTilePlanner::fromPieces($pieces, 'buildings')->plan([['x' => 0, 'y' => 0, 'old' => $old, 'new' => ' ']], + static fn(int $x, int $y): ?string => $x === 0 && $y === 0 ? $old : null, + static fn(string $layer, int $x, int $y): string => $x === 0 && $y === 0 ? ($tiles[$layer] ?? '0') : '0', + $choices); +} + +/** @return array The face, window and an unrelated piece. */ +function underlayPieces(): array +{ + return [ + createUnderlayPiece('plaster', ['glyphs' => ['#'], 'tiles' => ['walls' => ['10']]]), + createUnderlayPiece('stone', ['glyphs' => ['%'], 'tiles' => ['walls' => ['11']]]), + createUnderlayPiece('window', ['glyphs' => ['w'], 'tiles' => ['decor' => ['5']], 'keeps' => ['walls']]), + createUnderlayPiece('sign', ['glyphs' => ['s'], 'tiles' => ['decor' => ['6']]]), + ]; +} + +it('gives an erased mounted window back to the wall face its kept tiles prove', function () { + $plan = planUnderlayErase(underlayPieces(), 'w', ['walls' => '10', 'decor' => '5']); + + expect(array_map(static fn(array $glyph): array => [$glyph['symbol'], $glyph['role']->key], $plan['glyphs'])) + ->toBe([['#', 'plaster:0:0']]) + // The window's own tile goes; the face it kept stays exactly as it was. + ->and($plan['tiles'])->toBe(['decor' => [['x' => 0, 'y' => 0, 'entry' => '0']]]) + ->and($plan['unresolved'])->toBe([]); +}); + +it('leaves an erased cell blank when nothing kept proves a role', function (string $old, array $tiles) { + $plan = planUnderlayErase(underlayPieces(), $old, $tiles); + + expect($plan['glyphs'])->toBe([])->and($plan['unresolved'])->toBe([]); +})->with([ + 'a piece that keeps nothing' => ['s', ['walls' => '10', 'decor' => '6']], + 'a mounted window with no face behind it' => ['w', ['decor' => '5']], + 'a kept tile no piece draws' => ['w', ['walls' => '42', 'decor' => '5']], +]); + +it('asks which face an erase restores when the kept tiles prove several, and keeps the answer', function () { + $pieces = [...underlayPieces(), createUnderlayPiece('panel', ['glyphs' => ['P'], 'tiles' => ['walls' => ['10']]])]; + $tiles = ['walls' => '10', 'decor' => '5']; + + expect(array_map(static fn($role): string => $role->key, planUnderlayErase($pieces, 'w', $tiles)['unresolved'][' '])) + ->toBe(['plaster:0:0', 'panel:0:0']) + ->and(planUnderlayErase($pieces, 'w', $tiles)['glyphs'])->toBe([]) + ->and(planUnderlayErase($pieces, 'w', $tiles, [' ' => 'panel:0:0'])['glyphs'][0]['symbol'])->toBe('P') + // Choosing none leaves the cell blank, as an author may want. + ->and(planUnderlayErase($pieces, 'w', $tiles, [' ' => null])['glyphs'])->toBe([]) + ->and(planUnderlayErase($pieces, 'w', $tiles, [' ' => null])['unresolved'])->toBe([]); +}); + +/** A synthetic map with a plaster face at (1,0) and (2,0), a window mounted on (1,0), and the editor's buildings layer id. */ +function mountedWindowMap(): array +{ + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: [ + 'plaster' => ['name' => 'Plaster', 'layer' => 'buildings', 'glyphs' => ['#'], 'tiles' => ['walls' => ['10']]], + 'window' => ['name' => 'Window', 'layer' => 'buildings', 'glyphs' => ['w'], 'tiles' => ['decor' => ['5']], 'keeps' => ['walls']], + ]); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/layers/04.buildings.map.php', + Ichiloto\Engine\Field\MapGridSource::buildSource(" w# \n ", 'AUTHORED', "// keep 04.buildings.map.php\n")); + writeTileLayer($directory, '02.decor.tiles.php', "0 5 0 0\n0 0 0 0"); + writeTileLayer($directory, '03.walls.tiles.php', "0 10 10 0\n0 0 0 0"); + $map = loadLayeredMap($root); + $layerId = array_find($map->getLayers(), static fn(array $layer): bool => $layer['name'] === 'buildings')['id']; + + return [$root, $map, $layerId]; +} + +/** @return list Validation's warnings about piece tiles whose glyph has gone, for the map as it stands in memory. */ +function readStaleUnderlayIssues(ProjectMap $map): array +{ + return array_values(array_map(static fn($issue): string => $issue->message, array_filter(MapGraphicsValidator::validateCoverage($map), + static fn($issue): bool => str_contains($issue->message, 'no longer there')))); +} + +/** @return array{string, string, string} The buildings glyph at (1,0) and the decor and walls tiles there. */ +function readMountedCell(ProjectMap $map, string $layerId): array +{ + $tile = CanvasEditor::createTileReader($map); + + return [$map->getLayerSymbol($layerId, 1, 0), $tile('decor', 1, 0), $tile('walls', 1, 0)]; +} + +it('restores the face when a window is erased, as one undoable step that saves as authored', function () { + [$root, $map, $layerId] = mountedWindowMap(); + $writes = [['x' => 1, 'y' => 0, 'symbol' => ' ', 'color' => null]]; + $plan = CanvasEditor::plan($map, $layerId, $writes); + $applied = CanvasEditor::apply($map, $layerId, $plan['writes'], 'Erase', $plan['tiles']); + + expect(readMountedCell($map, $layerId))->toBe(['#', '0', '10']) + ->and(readStaleUnderlayIssues($map))->toBe([]); + $applied['command']->undo(); + expect(readMountedCell($map, $layerId))->toBe(['w', '5', '10']); + $applied['command']->execute(); + expect(readMountedCell($map, $layerId))->toBe(['#', '0', '10']); + + $map->save(); + $reloaded = loadLayeredMap($root); + expect(readMountedCell($reloaded, $layerId))->toBe(['#', '0', '10']) + ->and((string) file_get_contents($root . '/assets/Maps/test-map/layers/04.buildings.map.php')) + ->toContain("// keep 04.buildings.map.php\n")->toContain(" ## \n"); +}); + +it('moves a mounted window with the face it sits on, leaving nothing stale behind', function () { + [$root, $map, $layerId] = mountedWindowMap(); + $clipboard = new Clipboard(); + CanvasClipboard::cut($map, $layerId, 1, 0, 1, 1, $clipboard); + + // A cut takes the whole block, face included: the cell it leaves holds nothing. + expect(readMountedCell($map, $layerId))->toBe([' ', '0', '0']); + CanvasClipboard::paste($map, $layerId, $clipboard, 1, 1); + $tile = CanvasEditor::createTileReader($map); + expect([$map->getLayerSymbol($layerId, 1, 1), $tile('decor', 1, 1), $tile('walls', 1, 1)])->toBe(['w', '5', '10']) + // The face beside it, and its other layers, are untouched. + ->and([$map->getLayerSymbol($layerId, 2, 0), $tile('walls', 2, 0)])->toBe(['#', '10']) + ->and(readStaleUnderlayIssues($map))->toBe([]); +}); diff --git a/tests/Unit/TilesetPieceKeepsTest.php b/tests/Unit/TilesetPieceKeepsTest.php index 57a6604..1046127 100644 --- a/tests/Unit/TilesetPieceKeepsTest.php +++ b/tests/Unit/TilesetPieceKeepsTest.php @@ -247,7 +247,7 @@ function getKeepsTestStaleIssues(string $root): array ]; $plan = planKeepsTestChanges($pieces, [['x' => 0, 'y' => 0, 'old' => '#', 'new' => '#']], [['#']], ['walls' => [['10']]], assigned: ['0,0' => 'mounted:0:0']); - expect($plan)->toBe(['tiles' => ['decor' => [['x' => 0, 'y' => 0, 'entry' => '5']]], 'unresolved' => []]); + expect($plan)->toBe(['tiles' => ['decor' => [['x' => 0, 'y' => 0, 'entry' => '5']]], 'unresolved' => [], 'glyphs' => []]); }); it('leaves underlay intact when PiecePlacer stamps a piece containing only blank glyphs', function () { From 1e7fdf019875c2d26cde35acde0b8a0f62298dde Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 10 Oct 2026 18:09:00 +0200 Subject: [PATCH 241/344] feat(editor): author player ownership and actor field art Reuse shared image pickers and source-preserving history for player graphical ownership and actor field-sheet roles. Preserve inactive fixed art and Terminal workflows; refuse external source conflicts. Focused checks pass; native inspection remains pending. --- docs/manual.md | 13 +- src/Actors/ActorAuthoring.php | 55 ++- src/Database/DatabaseCatalog.php | 11 +- src/Database/ProjectRecordDatabase.php | 28 +- src/Database/RecordSchema.php | 2 + src/Database/RecordSchemaCatalog.php | 3 + src/Database/SharedFileTransaction.php | 4 + src/Field/CharacterSheetFields.php | 87 +++++ src/Field/CharacterSheetPreview.php | 4 +- src/Field/PlayerPresentationFields.php | 105 ++++++ src/Session/EditorSession.php | 6 +- tests/Unit/Session/FieldRoleAuthoringTest.php | 350 ++++++++++++++++++ 12 files changed, 658 insertions(+), 10 deletions(-) create mode 100644 src/Field/CharacterSheetFields.php create mode 100644 src/Field/PlayerPresentationFields.php create mode 100644 tests/Unit/Session/FieldRoleAuthoringTest.php diff --git a/docs/manual.md b/docs/manual.md index 2ccec22..29a8513 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1085,7 +1085,7 @@ skill's effects, a quest's rewards) listed under them. | Category | Backing file | Terminal editor | GUI editor | | --- | --- | --- | --- | -| Actors | `assets/Data/Actors/*.php` | Editable | Same | +| Actors | `assets/Data/Actors/*.php` | Editable | Same, plus `images.field2d` artwork selected for the established actor id | | Classes | `assets/Data/classes.php` | Editable - levels, the experience curve, every stat curve, equipment types and skills learned | Same | | Skills | `assets/Data/Skills/*.php` | Editable - one data record per numbered file, attacks, abilities and spells alike, with their effects; `skills.php` loads them | Same | | Items | `assets/Data/Items/*.php` | Editable - one data record per numbered file, with scope, occasion, animation and effects; `items.php` loads Items, Weapons and Armors | Same | @@ -1112,8 +1112,19 @@ skill's effects, a quest's rewards) listed under them. | Configuration | `config.php` (`save`, `accessibility`, `ui`, `graphics`, `audio`, `inn`) | Editable - one row per setting, typed by what it holds; field zoom is kept from 1 to 8 | Same | | Types | `assets/Data/system.php` | Editable - the project's elements, one per row; an empty list means the Engine's defaults. Weapon, armor and equipment types are the Engine's own | Same | | Terms | `config.php` (`vocab`, `messages`) | Literal terms editable; comments and unrelated expressions preserved | Same | +| Player Field Appearance | `assets/Data/Entities/player.php` | Not shown: graphical ownership and art | Choose fixed-player, selected party leader, or absent legacy selector; fixed sheet picker with index/layer controls | | Actor Battle Art, Enemy Battle Art, Battle Scale | `assets/Data/Presentation/battlers.php` | Not shown: graphical battle art | Editable from the actor's or enemy's own page (see Battle Art) | +Player Field Appearance edits only `graphicalSubject` and `sprites2d`, preserving +the player's Terminal glyphs and unrelated authored source. Selecting +`party-leader` retains inactive fixed art read-only and uses the selected actor's +`images.field2d` role; missing leader art is diagnosed, never replaced with +another actor. Actor and fixed-player sheet pickers share current-file +four-direction previews. Clearing removes only the optional binding; undo/redo +and save/reopen restore its settings. Unsupported expressions and external +source conflicts are refused before overwriting files. These controls have +automated coverage; native visual inspection remains pending. + Why a category can still turn out read-only: a file the editor cannot evaluate, a value it could not write back out, or a comment inside returned data that holds objects or is not an array literal are each a reason, and the status line names it. You can diff --git a/src/Actors/ActorAuthoring.php b/src/Actors/ActorAuthoring.php index 9455e05..1e2e926 100644 --- a/src/Actors/ActorAuthoring.php +++ b/src/Actors/ActorAuthoring.php @@ -9,6 +9,7 @@ use Ichiloto\Editor\Database\RecordRefusal; use Ichiloto\Editor\Database\SummonAssignmentDiagnostics; use Ichiloto\Editor\EquipmentOptimizationPolicy; +use Ichiloto\Editor\Field\CharacterSheetFields; use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\History\SourceSetRequired; use Ichiloto\Editor\Inspector\InputControl; @@ -17,6 +18,7 @@ use Ichiloto\Editor\ProjectActor; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Entities\Enumerations\WeaponType; +use InvalidArgumentException; use RuntimeException; use Throwable; @@ -73,6 +75,8 @@ final class ActorAuthoring /** @var array Which slot each actor's Optimize preview fills. */ private array $optimizeSlots = []; + public function __construct(private readonly bool $graphical = false) {} + /** * Applies one actor row: a choice of what the panes show, which records * nothing, or a value of the actor, as one undo step. A value the actor's @@ -113,8 +117,28 @@ public function applyField(ProjectWorkspace $workspace, int $index, string $fiel $before = $actor->getData(); try { - $workspace->actorDatabase->setField($index, $field, self::coerceActorFieldValue($field, $rawValue)); - } catch (RuntimeException $refused) { + if (str_starts_with($field, 'images.field2d.')) { + if (! $this->graphical || ! $actor->hasDefinitionId()) { + throw new RuntimeException('Field art requires the graphical editor and an established actor identity.'); + } + if (array_key_exists('images', $before) && ! is_array($before['images'])) { + throw new RuntimeException('Actor images is not an editable role map; correct its source before binding field art.'); + } + $after = $before; + $binding = CharacterSheetFields::applyField($before['images']['field2d'] ?? null, + substr($field, strlen('images.field2d.')), $rawValue, $workspace->projectRoot . '/assets'); + if ($binding === null) { + unset($after['images']['field2d']); + } else { + $after['images']['field2d'] = $binding; + } + // Prove literal source and external-file freshness before making an undo step. + $actor->getProposedSource($after); + $actor->restoreData($after); + } else { + $workspace->actorDatabase->setField($index, $field, self::coerceActorFieldValue($field, $rawValue)); + } + } catch (RuntimeException|InvalidArgumentException $refused) { $actor->restoreData($before); throw new RecordRefusal($refused->getMessage(), previous: $refused); @@ -593,6 +617,7 @@ public function describeFields(ProjectWorkspace $workspace, ProjectActor $actor) 'field' => 'counterAttack', ], ...$this->actorSummonFields($workspace, $actor), + ...($this->graphical ? $this->getFieldAppearanceFields($workspace, $actor) : []), [ 'label' => 'Level', 'value' => (string) $actor->getLevel(), @@ -628,6 +653,32 @@ public function describeFields(ProjectWorkspace $workspace, ProjectActor $actor) ]; } + private function getFieldAppearanceFields(ProjectWorkspace $workspace, ProjectActor $actor): array + { + $binding = $actor->getImages()['field2d'] ?? null; + $rows = [['label' => 'Field Appearance', 'value' => 'Actor role: images.field2d. Independent of terminal and battle art.', 'editable' => false]]; + foreach (CharacterSheetFields::getFields($binding, 'images.field2d', 'Actor Field Sheet') as $field) { + $key = substr($field->key, strlen('images.field2d.')); + $value = is_array($binding) ? ($binding[$key] ?? null) : null; + $shown = $value ?? ($field->reference === null ? $field->displayDefault : ''); + $rows[] = [ + 'label' => $field->label, 'field' => $field->key, + 'value' => is_string($shown) || is_int($shown) ? (string) $shown : '(malformed)', + 'editable' => $actor->hasDefinitionId(), + ...($field->reference !== null ? ['reference' => $field->reference, 'allowsNone' => true, 'noneLabel' => $field->noneLabel()] : []), + ...($field->options !== [] ? ['options' => $field->options] : []), + ...($field->reference === null && $field->options === [] ? ['control' => new InputControl($field->type, + is_string($shown) || is_int($shown) ? (string) $shown : '')] : []), + 'displayDefault' => $field->displayDefault, + ]; + } + if (! $actor->hasDefinitionId()) { + $rows[] = ['label' => 'Field Art Identity', 'value' => 'Establish the actor\'s permanent id before binding artwork.', 'editable' => false]; + } + + return CharacterSheetFields::describeFields($rows, $binding, 'images.field2d', $workspace->projectRoot . '/assets'); + } + /** * Returns the actor's starting summon assignments: a multi-pick over the * project's summons, and one verdict row per assignment judged by the diff --git a/src/Database/DatabaseCatalog.php b/src/Database/DatabaseCatalog.php index 9ed04dd..bdb9f31 100644 --- a/src/Database/DatabaseCatalog.php +++ b/src/Database/DatabaseCatalog.php @@ -4,6 +4,8 @@ namespace Ichiloto\Editor\Database; +use Ichiloto\Editor\Field\PlayerPresentationFields; + /** * Provides the editor's Database category registry. */ @@ -47,6 +49,13 @@ public static function all(): array ]; } + /** The GUI's categories; the Terminal registry and its list positions stay unchanged. */ + public static function getGraphicalCategories(): array + { + return [...self::all(), new DatabaseCategoryDefinition(PlayerPresentationFields::CATEGORY, + 'Player Field Appearance', 'Choose fixed player art or the selected party leader\'s field role.', true)]; + } + /** * Returns the categories edited from another category's records rather * than listed on their own: battle art is set on the actor's or enemy's @@ -78,7 +87,7 @@ public static function knows(string $key): bool */ public static function findByKey(string $key): ?DatabaseCategoryDefinition { - return array_find([...self::all(), ...self::getEmbedded()], static fn(DatabaseCategoryDefinition $category): bool => $category->key === $key); + return array_find([...self::getGraphicalCategories(), ...self::getEmbedded()], static fn(DatabaseCategoryDefinition $category): bool => $category->key === $key); } /** diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 54e7e81..19c5bf4 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -4,6 +4,8 @@ namespace Ichiloto\Editor\Database; +use Ichiloto\Editor\Field\PlayerPresentationFields; + use Ichiloto\Editor\IO\AtomicFile; use BackedEnum; @@ -539,6 +541,9 @@ public function getSettingsFields(int $index): array foreach ($this->schema->fieldsFor($record->toArray()) as $field) { $fields[] = self::describeField($field, self::displayValue($field, $record->get($field->key)), $field->key, $isEditable); } + if ($this->schema->key === PlayerPresentationFields::CATEGORY) { + $fields = PlayerPresentationFields::describeFields($fields, (array) $record->toArray(), $this->authoringReferences?->getAssetRoot()); + } if ($configIssue !== null) { $fields[] = ['label' => 'Read-only', 'value' => $configIssue, 'editable' => false]; } foreach ($this->schema->commandListsFor($record->toArray()) as $listKey => $commandList) { @@ -815,7 +820,12 @@ public function setField(int $index, string $fieldId, string $rawValue): void $previousIdentity = $this->schema->identityKey === null ? null : $record->get($this->schema->identityKey); $before = $record->toArray(); $draft = clone $record; - $draft->set($field->key, $value); + if ($this->schema->key === PlayerPresentationFields::CATEGORY) { + $draft->restorePayload(PlayerPresentationFields::applyField((array) $before, $fieldId, $rawValue, + $this->authoringReferences?->getAssetRoot() ?? throw new \InvalidArgumentException('The player presentation has no project asset root.'))); + } else { + $draft->set($field->key, $value); + } $this->dropStaleShapeFields($draft, $before); $this->assertProposedRecord($record, $draft); $record->restorePayload($draft->toArray()); @@ -2785,6 +2795,22 @@ public function assertSourceAccepts(int $index): void }; } + /** Reuse the source writer's conflict gate for categories that exclusively own a file. */ + public function assertUnchangedSource(): void + { + if (! $this->schema->requireUnchangedSource || $this->file === null) { + return; + } + try { + if (! $this->file->exists && is_file($this->file->path)) { + throw new RuntimeException('The source was created outside the editor; reload before saving.'); + } + $this->file->composeContents($this->mergeIntoFilePayload(), checkDisk: true); + } catch (RuntimeException $refused) { + throw new \Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal($refused->getMessage(), previous: $refused); + } + } + private function saveDirectory(): void { if (! is_dir($this->path) && ! mkdir($this->path, 0777, true) && ! is_dir($this->path)) { diff --git a/src/Database/RecordSchema.php b/src/Database/RecordSchema.php index 5355516..1739013 100644 --- a/src/Database/RecordSchema.php +++ b/src/Database/RecordSchema.php @@ -92,6 +92,8 @@ public function __construct( public ?Closure $commandListsFor = null, /** @var Closure(array): list|null Selects the inline lists offered by this record's shape. */ public ?Closure $subListsFor = null, + /** Exclusive source owners refuse external edits rather than merging a stale snapshot. */ + public bool $requireUnchangedSource = false, ) { if ($projection instanceof WholeFileProjection) { // A field outside the keys the category owns would read blank and diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 8ace818..d3ef9d6 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -4,6 +4,8 @@ namespace Ichiloto\Editor\Database; +use Ichiloto\Editor\Field\PlayerPresentationFields; + use Closure; use LogicException; @@ -130,6 +132,7 @@ public static function all(bool $graphical = false): array self::battlerArt('actors'), self::battlerArt('enemies'), self::battleScaleReference(), + ...($graphical ? [PlayerPresentationFields::getSchema()] : []), ]; $keyed = []; diff --git a/src/Database/SharedFileTransaction.php b/src/Database/SharedFileTransaction.php index 55062eb..5efa001 100644 --- a/src/Database/SharedFileTransaction.php +++ b/src/Database/SharedFileTransaction.php @@ -107,6 +107,10 @@ private static function compose(array $databases, bool $write): bool return false; } + foreach ($dirty as $database) { + $database->assertUnchangedSource(); + } + $first = $dirty[0]; $path = $first->backingFilePath(); $file = PhpDataFile::load($path, $first->projectRoot()); diff --git a/src/Field/CharacterSheetFields.php b/src/Field/CharacterSheetFields.php new file mode 100644 index 0000000..f2eefc3 --- /dev/null +++ b/src/Field/CharacterSheetFields.php @@ -0,0 +1,87 @@ + */ + public static function getFields(mixed $binding, string $prefix, string $label): array + { + $fields = [RecordField::reference($prefix . '.sheet', $label, 'png_assets', + allowsNone: true, noneLabel: 'None: no field sheet')]; + if ($binding === null) { + return $fields; + } + $asset = is_array($binding) && is_string($binding['sheet'] ?? null) ? $binding['sheet'] : ''; + $fields[] = new RecordField($prefix . '.index', 'Sheet Character Index', InputControlType::INTEGER, + options: CharacterSheetPreview::getIndexOptions($asset), displayDefault: '0'); + $fields[] = new RecordField($prefix . '.layer', 'Graphical Layer', InputControlType::INTEGER, + displayDefault: (string) CharacterSheet::DEFAULT_LAYER); + + return $fields; + } + + /** Attach the same current-file four-direction preview used for NPCs. */ + public static function describeFields(array $fields, mixed $binding, string $prefix, ?string $assetRoot, bool $worldLayer = true): array + { + if ($binding !== null) { + foreach ($fields as &$field) { + if (($field['field'] ?? null) === $prefix . '.sheet' && ($field['editable'] ?? true) !== false) { + $field['imagePreview'] = CharacterSheetPreview::describe($binding, $assetRoot, $worldLayer); + } + } + unset($field); + } + + return $fields; + } + + /** Refuse invalid selections before changing their owner or its history. */ + public static function applyField(mixed $binding, string $field, string $value, string $assetRoot, bool $worldLayer = true): ?array + { + if ($field === 'sheet' && $value === '') { + return null; + } + if (! in_array($field, ['sheet', 'index', 'layer'], true)) { + throw new InvalidArgumentException('Unknown character sheet field.'); + } + if ($binding !== null && ! is_array($binding)) { + throw new InvalidArgumentException('Clear the malformed character sheet before choosing a new one.'); + } + $draft = $binding ?? []; + if ($field === 'sheet') { + $draft['sheet'] = $value; + } else { + if (! is_string($draft['sheet'] ?? null) || $draft['sheet'] === '') { + throw new InvalidArgumentException('Select a field sheet before editing its character index or graphical layer.'); + } + if (preg_match('/^-?\d+$/D', $value) !== 1 || filter_var($value, FILTER_VALIDATE_INT) === false) { + throw new InvalidArgumentException('A sheet character index or graphical layer must be an integer.'); + } + $draft[$field] = (int) $value; + } + try { + if ($worldLayer) { + NpcCharacterSheet::validate($draft, $assetRoot); + } else { + $sheet = CharacterSheet::fromArray($draft); + $image = PngAssetPreflight::inspect($assetRoot, $sheet->asset); + $sheet->getFrameSize($image['width'], $image['height']); + } + } catch (RuntimeException $refused) { + throw new InvalidArgumentException($refused->getMessage(), previous: $refused); + } + + return $draft; + } +} diff --git a/src/Field/CharacterSheetPreview.php b/src/Field/CharacterSheetPreview.php index 042bd54..4bd8aa2 100644 --- a/src/Field/CharacterSheetPreview.php +++ b/src/Field/CharacterSheetPreview.php @@ -14,7 +14,7 @@ final class CharacterSheetPreview { /** @return array{frames: list, issue?: string} */ - public static function describe(mixed $data, ?string $assetRoot): array + public static function describe(mixed $data, ?string $assetRoot, bool $worldLayer = true): array { try { if (! is_array($data)) { @@ -23,7 +23,7 @@ public static function describe(mixed $data, ?string $assetRoot): array if ($assetRoot === null) { throw new RuntimeException('The current owner has no project asset root for character-sheet preview.'); } - $sheet = NpcCharacterSheet::validate($data); + $sheet = $worldLayer ? NpcCharacterSheet::validate($data) : CharacterSheet::fromArray($data); $image = PngAssetPreflight::inspect($assetRoot, $sheet->asset); $size = $sheet->getFrameSize($image['width'], $image['height']); $frames = []; diff --git a/src/Field/PlayerPresentationFields.php b/src/Field/PlayerPresentationFields.php new file mode 100644 index 0000000..70ced00 --- /dev/null +++ b/src/Field/PlayerPresentationFields.php @@ -0,0 +1,105 @@ + 'Player Field Appearance', + fieldsFor: self::getFields(...), + requireUnchangedSource: true, + ); + } + + /** @return list */ + public static function getFields(array $data): array + { + $fields = [new RecordField('graphicalSubject', 'Graphical Subject', + options: [self::LEGACY, ...array_map(static fn(PlayerGraphicalSubject $mode): string => $mode->value, PlayerGraphicalSubject::cases())], + displayDefault: self::LEGACY)]; + $mode = array_key_exists('graphicalSubject', $data) ? $data['graphicalSubject'] : PlayerGraphicalSubject::FIXED_PLAYER->value; + if (! array_key_exists('graphicalSubject', $data) || $mode === PlayerGraphicalSubject::FIXED_PLAYER->value) { + $fields = [...$fields, ...CharacterSheetFields::getFields($data['sprites2d'] ?? null, 'sprites2d', 'Fixed Player Field Sheet')]; + } else { + // Inactive is not unowned: shape changes must retain legacy art. + $fields = [...$fields, ...array_map(static fn(RecordField $field): RecordField => $field->asReadOnly(), + CharacterSheetFields::getFields($data['sprites2d'] ?? null, 'sprites2d', 'Inactive Fixed Player Field Sheet'))]; + } + + return $fields; + } + + public static function describeFields(array $fields, array $data, ?string $assetRoot): array + { + $mode = array_key_exists('graphicalSubject', $data) ? $data['graphicalSubject'] : PlayerGraphicalSubject::FIXED_PLAYER->value; + if (array_key_exists('graphicalSubject', $data) && ! is_string($mode)) { + foreach ($fields as &$field) { + if (($field['field'] ?? null) === 'graphicalSubject') { + $field['value'] = '(invalid ' . get_debug_type($mode) . ' selector)'; + } + } + unset($field); + } + if ($mode === PlayerGraphicalSubject::FIXED_PLAYER->value) { + $fields = CharacterSheetFields::describeFields($fields, $data['sprites2d'] ?? null, 'sprites2d', $assetRoot, worldLayer: false); + } + $note = match ($mode) { + PlayerGraphicalSubject::PARTY_LEADER->value => 'Uses the selected party leader\'s actor images.field2d role. Missing leader art stays missing; no fixed-player substitute. Inactive fixed art is retained.', + PlayerGraphicalSubject::FIXED_PLAYER->value => 'Uses this player\'s fixed sheet. Actor roles and party order do not change it. Terminal appearance is independent.', + default => 'Invalid graphicalSubject: optional graphical presentation is refused. Choose a valid mode; no identity is substituted.', + }; + $fields[] = ['label' => 'Field Appearance Ownership', 'value' => $note, 'editable' => false]; + + return $fields; + } + + public static function applyField(array $data, string $field, string $value, string $assetRoot): array + { + if ($field === 'graphicalSubject') { + if ($value === self::LEGACY) { + unset($data[$field]); + } elseif (PlayerGraphicalSubject::tryFrom($value) === null) { + throw new InvalidArgumentException('Choose fixed-player or party-leader, or leave the selector absent for legacy fixed-player.'); + } else { + $data[$field] = $value; + } + } elseif (str_starts_with($field, 'sprites2d.')) { + $mode = array_key_exists('graphicalSubject', $data) ? $data['graphicalSubject'] : PlayerGraphicalSubject::FIXED_PLAYER->value; + if ($mode !== PlayerGraphicalSubject::FIXED_PLAYER->value) { + throw new InvalidArgumentException('Fixed player art is inactive; choose fixed-player before editing it.'); + } + $binding = CharacterSheetFields::applyField($data['sprites2d'] ?? null, substr($field, strlen('sprites2d.')), $value, $assetRoot, worldLayer: false); + if ($binding === null) { + unset($data['sprites2d']); + } else { + $data['sprites2d'] = $binding; + } + } else { + throw new InvalidArgumentException('Unknown player presentation field.'); + } + + return $data; + } +} diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 6642bfa..b0bdd93 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -206,7 +206,7 @@ private function __construct( private readonly BackupWriter $backups, ) { $this->workspace = $workspace; - $this->actorAuthoring = new ActorAuthoring(); + $this->actorAuthoring = new ActorAuthoring(graphical: true); } /** Opens the project at a root, as the terminal editor opens it. */ @@ -235,7 +235,7 @@ public function describeProject(): array 'description' => $category->description, 'implemented' => $category->isImplemented, 'group' => 'Database', - ], DatabaseCatalog::all()), + ], DatabaseCatalog::getGraphicalCategories()), // Cutscene types, edited through the same record RPCs under their record category keys. 'cutscenes' => array_map(static fn(CutsceneType $type): array => [ 'key' => $type->getRecordCategory(), @@ -3764,7 +3764,7 @@ private function isCategoryUnsaved(string $category): bool private function listUnsavedCategories(): array { $keys = [ - ...array_map(static fn($category): string => $category->key, DatabaseCatalog::all()), + ...array_map(static fn($category): string => $category->key, DatabaseCatalog::getGraphicalCategories()), ...array_map(static fn(CutsceneType $type): string => $type->getRecordCategory(), CutsceneType::cases()), ]; diff --git a/tests/Unit/Session/FieldRoleAuthoringTest.php b/tests/Unit/Session/FieldRoleAuthoringTest.php new file mode 100644 index 0000000..7a18f8e --- /dev/null +++ b/tests/Unit/Session/FieldRoleAuthoringTest.php @@ -0,0 +1,350 @@ + Character::class, 'data' => [ + 'id' => 'actor-stable', 'name' => 'Different Display Name', 'level' => 1, + 'future' => abs(7), 'images' => ['face' => 'unrelated-face', + 'field2d' => ['sheet' => 'Graphics/Characters/Cast.png', /* chosen cell */ 'index' => 2, 'layer' => 100]], +]]; +PHP); + $player = $root . '/assets/Data/Entities/player.php'; + file_put_contents($player, <<<'PHP' + '@', 'north' => '^', 'south' => 'v', 'east' => '>', 'west' => '<', + 'future' => abs(9), + 'sprites2d' => ['sheet' => 'Graphics/Characters/Cast.png', /* fixed cell */ 'index' => 3, 'layer' => 50]]; +PHP); + writeTilesetTestPng($root . '/assets/Graphics/Characters/Cast.png', 48, 32); + writeTilesetTestPng($root . '/assets/Graphics/Characters/$Solo.png', 12, 16); + writeTilesetTestPng($root . '/assets/Graphics/Characters/Uneven.png', 50, 50); + + return [$root, $actor, $player, EditorSession::open($root)]; +} + +function getFieldRoleRow(EditorSession $session, string $category, string $field): ?array +{ + return array_find($session->readDatabaseRecord($category, 0)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === $field); +} + +function applyFieldRole(EditorSession $session, string $category, string $field, string $value): array +{ + $row = getFieldRoleRow($session, $category, $field) ?? throw new RuntimeException('Missing row ' . $field); + + return $session->applyDatabaseRecord($category, 0, $row['key'], $value); +} + +it('exposes graphical ownership and actor-role pickers only through the graphical session', function () { + [$root, , , $session] = createFieldRoleProject(); + $terminal = ProjectWorkspace::fromProject($root); + $actor = $terminal->actorDatabase->getActorByIndex(0); + expect(array_column(DatabaseCatalog::all(), 'key'))->not->toContain(PlayerPresentationFields::CATEGORY) + ->and($terminal->getRecordDatabase(PlayerPresentationFields::CATEGORY))->toBeNull() + ->and(json_encode((new ActorAuthoring())->describeFields($terminal, $actor)))->not->toContain('field2d', 'imagePreview') + ->and(array_column($session->describeProject()['databases'], 'key'))->toContain(PlayerPresentationFields::CATEGORY); + foreach (['actors' => 'images.field2d.sheet', PlayerPresentationFields::CATEGORY => 'sprites2d.sheet'] as $category => $field) { + $row = getFieldRoleRow($session, $category, $field); + expect($row)->toMatchArray(['kind' => 'reference', 'reference' => 'png_assets', 'media' => ['kind' => 'image', 'root' => 'assets']]) + ->and(array_column($row['imagePreview']['frames'], 'label'))->toBe(['South', 'West', 'East', 'North']); + } +}); + +it('preserves legacy fixed art and terminal data across explicit mode changes save reopen undo and redo', function () { + [$root, , $path, $session] = createFieldRoleProject(); + $source = file_get_contents($path); + $before = require $path; + $category = PlayerPresentationFields::CATEGORY; + expect(getFieldRoleRow($session, $category, 'graphicalSubject')['value'])->toBe(PlayerPresentationFields::LEGACY) + ->and(PlayerPresentationConfig::fromArray($before)->graphicalSubject)->toBe(PlayerGraphicalSubject::FIXED_PLAYER); + $sheetKey = getFieldRoleRow($session, $category, 'sprites2d.sheet')['key']; + applyFieldRole($session, $category, 'graphicalSubject', 'party-leader'); + expect(getFieldRoleRow($session, $category, 'sprites2d.sheet')['kind'])->toBe('info') + ->and(fn() => $session->applyDatabaseRecord($category, 0, $sheetKey, ''))->toThrow(SessionRefusal::class) + ->and($session->listDatabaseRecords($category)['unsavedCategories'])->toContain($category); + $session->saveDatabase($category); + $after = require $path; + expect($after['sprites2d'])->toBe($before['sprites2d']) + ->and(PlayerPresentationConfig::fromArray($after)->graphicalSubject)->toBe(PlayerGraphicalSubject::PARTY_LEADER) + ->and(PlayerPresentationConfig::fromArray($after)->graphical)->toBeNull(); + unset($after['graphicalSubject']); + expect($after)->toBe($before)->and(file_get_contents($path))->toContain('// Terminal art', "'future' => abs(9)"); + $session = EditorSession::open($root); + applyFieldRole($session, $category, 'graphicalSubject', 'fixed-player'); + expect(getFieldRoleRow($session, $category, 'sprites2d.index')['value'])->toBe('3'); + $session->undo(); + expect(getFieldRoleRow($session, $category, 'sprites2d.sheet')['kind'])->toBe('info'); + $session->redo(); + applyFieldRole($session, $category, 'graphicalSubject', PlayerPresentationFields::LEGACY); + $session->saveDatabase($category); + expect(require $path)->toBe($before) + ->and(file_get_contents($path))->toContain('// Terminal art', "'future' => abs(9)", '/* fixed cell */'); +}); + +it('authors the stable actor field role without changing other roles or identity and roundtrips exact literal edits', function () { + [$root, $path, , $session] = createFieldRoleProject(); + $source = file_get_contents($path); + $before = (require $path)['data']; + applyFieldRole($session, 'actors', 'images.field2d.index', '6'); + applyFieldRole($session, 'actors', 'images.field2d.layer', '999'); + expect(file_get_contents($path))->toBe($source); + $session->undo(); + expect(getFieldRoleRow($session, 'actors', 'images.field2d.layer')['value'])->toBe('100'); + $session->redo(); + $session->saveDatabase('actors'); + expect(file_get_contents($path))->toBe(str_replace(["'index' => 2", "'layer' => 100"], ["'index' => 6", "'layer' => 999"], $source)); + $after = (require $path)['data']; + unset($after['images']['field2d'], $before['images']['field2d']); + expect($after)->toBe($before) + ->and(getFieldRoleRow(EditorSession::open($root), 'actors', 'images.field2d.index')['value'])->toBe('6'); +}); + +it('clears the whole optional sheet and restores its settings through history for each owner', function (string $category, string $prefix) { + [$root, $actor, $player, $session] = createFieldRoleProject(); + $original = getFieldRoleRow($session, $category, $prefix . '.sheet'); + $key = getFieldRoleRow($session, $category, $prefix . '.index')['key']; + applyFieldRole($session, $category, $prefix . '.sheet', ''); + expect(getFieldRoleRow($session, $category, $prefix . '.sheet'))->not->toHaveKey('imagePreview') + ->and(getFieldRoleRow($session, $category, $prefix . '.index'))->toBeNull() + ->and(fn() => $session->applyDatabaseRecord($category, 0, $key, '0'))->toThrow(SessionRefusal::class); + $session->undo(); + expect(getFieldRoleRow($session, $category, $prefix . '.sheet'))->toBe($original); + $session->redo(); + $session->saveDatabase($category); + expect($category === 'actors' ? (require $actor)['data']['images'] : require $player) + ->not->toHaveKey($category === 'actors' ? 'field2d' : 'sprites2d'); + $session->undo(); + $session->saveDatabase($category); + expect(getFieldRoleRow(EditorSession::open($root), $category, $prefix . '.sheet'))->toBe($original); +})->with([['actors', 'images.field2d'], [PlayerPresentationFields::CATEGORY, 'sprites2d']]); + +it('refuses invalid mode choices without altering data source or history', function (string $value) { + [$root, , , $session] = createFieldRoleProject(); + $files = sourceHashTree($root); + $before = $session->readDatabaseRecord(PlayerPresentationFields::CATEGORY, 0); + expect(fn() => applyFieldRole($session, PlayerPresentationFields::CATEGORY, 'graphicalSubject', $value))->toThrow(SessionRefusal::class) + ->and($session->readDatabaseRecord(PlayerPresentationFields::CATEGORY, 0))->toBe($before) + ->and($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($files); +})->with(['', 'Party-Leader', ' party-leader', 'protagonist']); + +it('refuses invalid sheets indices layers and identity substitutions before mutation', function (string $field, string $value) { + [$root, , , $session] = createFieldRoleProject(); + $files = sourceHashTree($root); + $before = $session->readDatabaseRecord('actors', 0); + expect(fn() => applyFieldRole($session, 'actors', 'images.field2d.' . $field, $value))->toThrow(SessionRefusal::class) + ->and($session->readDatabaseRecord('actors', 0))->toBe($before) + ->and($session->listUnsavedChanges())->toBe([]) + ->and($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($files); +})->with([['sheet', '../Outside.png'], ['sheet', 'Graphics/Characters/Missing.png'], + ['sheet', 'Graphics/Characters/Uneven.png'], ['sheet', 'Graphics/Characters/$Solo.png'], + ['index', '8'], ['index', '-1'], ['index', '1.5'], ['index', ''], ['layer', '-1'], ['layer', '1000'], ['layer', '2147483648']]); + +it('does not reset sheet selection metadata when choosing another layout', function () { + [, , , $session] = createFieldRoleProject(); + applyFieldRole($session, 'actors', 'images.field2d.index', '0'); + applyFieldRole($session, 'actors', 'images.field2d.sheet', 'Graphics/Characters/$Solo.png'); + expect(getFieldRoleRow($session, 'actors', 'images.field2d.index')['options'])->toBe(['0']) + ->and(getFieldRoleRow($session, 'actors', 'images.field2d.layer')['value'])->toBe('100'); +}); + +it('reads replaceable current assets and diagnoses missing actor art without a substitute or source mutation', function (string $replacement) { + [$root, , , $session] = createFieldRoleProject(); + $path = $root . '/assets/Graphics/Characters/Cast.png'; + $original = getFieldRoleRow($session, 'actors', 'images.field2d.sheet')['imagePreview']; + if ($replacement === 'valid') { + writeTilesetTestPng($path, 96, 64); + } elseif ($replacement === 'uneven') { + writeTilesetTestPng($path, 50, 50); + } elseif ($replacement === 'malformed') { + file_put_contents($path, 'not PNG'); + } else { + unlink($path); + } + $files = sourceHashTree($root); + $row = getFieldRoleRow($session, 'actors', 'images.field2d.sheet'); + if ($replacement === 'valid') { + expect($row['imagePreview']['frames'][0]['sourceRect']['width'])->toBe(2 * $original['frames'][0]['sourceRect']['width']); + } else { + expect($row['imagePreview']['frames'])->toBe([])->and($row['imagePreview']['issue'])->not->toBeEmpty(); + } + expect($row['value'])->toBe('Graphics/Characters/Cast.png')->and($session->undo()['label'])->toBeNull() + ->and(sourceHashTree($root))->toBe($files); +})->with(['valid', 'uneven', 'malformed', 'missing']); + +it('refuses opaque and externally conflicting actor sources before an undo step', function (string $mode) { + [$root, $path, , $session] = createFieldRoleProject(); + if ($mode === 'external') { + file_put_contents($path, file_get_contents($path) . "\n// Other author.\n"); + } else { + file_put_contents($path, str_replace("'index' => 2", "'index' => abs(2)", file_get_contents($path))); + $session = EditorSession::open($root); + } + $files = sourceHashTree($root); + expect(fn() => applyFieldRole($session, 'actors', 'images.field2d.index', '6'))->toThrow(SessionRefusal::class) + ->and(getFieldRoleRow($session, 'actors', 'images.field2d.index')['value'])->toBe('2') + ->and($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($files); +})->with(['expression', 'external']); + +it('refuses opaque player selector source and can recover by reopening externally edited source', function () { + [$root, , $path] = createFieldRoleProject(); + file_put_contents($path, str_replace("'future' => abs(9)", "'graphicalSubject' => strtolower('FIXED-PLAYER'), 'future' => abs(9)", file_get_contents($path))); + $session = EditorSession::open($root); + $files = sourceHashTree($root); + expect(fn() => applyFieldRole($session, PlayerPresentationFields::CATEGORY, 'graphicalSubject', 'party-leader'))->toThrow(SessionRefusal::class) + ->and($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($files); + file_put_contents($path, str_replace("strtolower('FIXED-PLAYER')", "'party-leader'", file_get_contents($path))); + expect(getFieldRoleRow(EditorSession::open($root), PlayerPresentationFields::CATEGORY, 'sprites2d.sheet')['kind'])->toBe('info'); +}); + +it('serves the player mode and actor role through the existing GUI record and picker protocol', function () { + [$root] = createFieldRoleProject(); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(int $id, string $method, array $params = []): array => $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params])); + $hello = $request(1, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + expect($hello)->not->toHaveKey('error'); + $read = $request(2, 'database.record', ['category' => 'actors', 'index' => 0])['result']; + $row = array_find($read['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'images.field2d.sheet'); + expect($row['reference'])->toBe('png_assets') + ->and(array_column(EditorSession::open($root)->listReferences(null, 'png_assets'), 'value'))->toContain('Graphics/Characters/Cast.png') + ->and($request(3, 'database.apply', ['category' => 'actors', 'index' => 0, 'key' => $row['key'], 'value' => ''])['result']['changed'])->toBeTrue() + ->and($request(4, 'history.undo')['result']['databases'])->toContain('actors'); +}); + +it('refuses externally changed player source during edits and Save All without overwriting it', function (bool $editFirst) { + [$root, , $path, $session] = createFieldRoleProject(); + $category = PlayerPresentationFields::CATEGORY; + if ($editFirst) { + applyFieldRole($session, $category, 'graphicalSubject', 'party-leader'); + } + file_put_contents($path, str_replace("'index' => 3", "'index' => 4", file_get_contents($path))); + $source = file_get_contents($path); + if ($editFirst) { + expect(fn() => $session->saveDatabase($category))->toThrow(SessionRefusal::class) + ->and($session->saveAll()['failures'])->not->toBeEmpty() + ->and($session->hasUnsavedChanges())->toBeTrue(); + } else { + expect(fn() => applyFieldRole($session, $category, 'graphicalSubject', 'party-leader'))->toThrow(SessionRefusal::class) + ->and($session->undo()['label'])->toBeNull(); + } + expect(file_get_contents($path))->toBe($source) + ->and(getFieldRoleRow(EditorSession::open($root), $category, 'sprites2d.index')['value'])->toBe('4'); +})->with([false, true]); + +it('binds a previously absent actor role without storing runtime sheet facts or inferred identities', function () { + [$root, $path] = createFieldRoleProject(); + $before = require $path; + unset($before['data']['images']['field2d']); + file_put_contents($path, 'not->toHaveKey('imagePreview') + ->and(getFieldRoleRow($session, 'actors', 'images.field2d.index'))->toBeNull(); + applyFieldRole($session, 'actors', 'images.field2d.sheet', 'Graphics/Characters/$Solo.png'); + $session->saveAll(); + $after = (require $path)['data']; + expect($after['images']['field2d'])->toBe(['sheet' => 'Graphics/Characters/$Solo.png']) + ->and($after['id'])->toBe('actor-stable')->and($after['images']['face'])->toBe('unrelated-face') + ->and(getFieldRoleRow(EditorSession::open($root), 'actors', 'images.field2d.index')['value'])->toBe('0'); +}); + +it('diagnoses invalid authored selectors without presenting inactive fixed art as a substitute', function (mixed $mode) { + [$root, , $path] = createFieldRoleProject(); + $data = require $path; + $data['graphicalSubject'] = $mode; + file_put_contents($path, 'readDatabaseRecord(PlayerPresentationFields::CATEGORY, 0); + expect(getFieldRoleRow($session, PlayerPresentationFields::CATEGORY, 'sprites2d.sheet'))->toMatchArray(['kind' => 'info', 'label' => 'Inactive Fixed Player Field Sheet']) + ->and(getFieldRoleRow($session, PlayerPresentationFields::CATEGORY, 'sprites2d.sheet'))->not->toHaveKey('imagePreview') + ->and(json_encode($read))->toContain('Invalid graphicalSubject'); + expect(getFieldRoleRow($session, PlayerPresentationFields::CATEGORY, 'graphicalSubject')['value'])->not->toBe(PlayerPresentationFields::LEGACY); + applyFieldRole($session, PlayerPresentationFields::CATEGORY, 'graphicalSubject', 'fixed-player'); + expect(getFieldRoleRow($session, PlayerPresentationFields::CATEGORY, 'sprites2d.sheet')['kind'])->toBe('reference'); +})->with([null, 'unknown', 42]); + +it('refuses invalid fixed-player art through the normal optional-presentation authoring refusal', function (string $field, string $value) { + [$root, , , $session] = createFieldRoleProject(); + $files = sourceHashTree($root); + expect(fn() => applyFieldRole($session, PlayerPresentationFields::CATEGORY, 'sprites2d.' . $field, $value))->toThrow(SessionRefusal::class) + ->and($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($files); +})->with([['sheet', 'Graphics/Characters/Missing.png'], ['sheet', 'Graphics/Characters/Uneven.png'], ['index', '8'], ['layer', '1.5']]); + +it('allows an absent player presentation file to be authored without manufacturing terminal data', function () { + [$root, , $path] = createFieldRoleProject(); + unlink($path); + $session = EditorSession::open($root); + applyFieldRole($session, PlayerPresentationFields::CATEGORY, 'graphicalSubject', 'party-leader'); + $saved = $session->saveAll(); + expect($saved['failures'])->toBe([])->and(require $path)->toBe(['graphicalSubject' => 'party-leader']); +}); + +it('preserves actor roles and the player presentation file when the TUI edits ordinary actor data', function () { + [$root, $path, $player] = createFieldRoleProject(); + $workspace = ProjectWorkspace::fromProject($root); + $before = (require $path)['data']; + $source = file_get_contents($player); + $authoring = new ActorAuthoring(); + $authoring->applyField($workspace, 0, 'description', 'Terminal-only ordinary edit', 'Description'); + $workspace->actorDatabase->save(); + expect((require $path)['data']['images'])->toBe($before['images'])->and(file_get_contents($player))->toBe($source) + ->and(fn() => $authoring->applyField($workspace, 0, 'images.field2d.sheet', '', 'Field Sheet')) + ->toThrow(\Ichiloto\Editor\Database\RecordRefusal::class); +}); + +it('requires a stable actor identity before field art authoring and never infers it from a sheet or filename', function () { + [$root, $path] = createFieldRoleProject(); + file_put_contents($path, str_replace("'id' => 'actor-stable', ", '', file_get_contents($path))); + $session = EditorSession::open($root); + $row = getFieldRoleRow($session, 'actors', 'images.field2d.sheet'); + $files = sourceHashTree($root); + expect($row['kind'])->toBe('info') + ->and($row)->not->toHaveKey('imagePreview') + ->and(fn() => $session->applyDatabaseRecord('actors', 0, $row['key'], 'Graphics/Characters/$Solo.png'))->toThrow(SessionRefusal::class) + ->and($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($files); +}); + +it('reads the saved role with the current Engine actor contract', function () { + [$root, , , $session] = createFieldRoleProject(); + applyFieldRole($session, 'actors', 'images.field2d.index', '0'); + applyFieldRole($session, 'actors', 'images.field2d.sheet', 'Graphics/Characters/$Solo.png'); + $session->saveDatabase('actors'); + $actor = ProjectWorkspace::fromProject($root)->actorDatabase->createActorStore()->get('actor-stable'); + expect($actor->getGraphicalCharacterSheet()->asset)->toBe('Graphics/Characters/$Solo.png') + ->and($actor->getGraphicalCharacterSheet()->index)->toBe(0) + ->and($actor->id)->toBe('actor-stable'); +}); + +it('diagnoses malformed actor roles and clears only that optional role without substituting artwork', function () { + [$root, $path] = createFieldRoleProject(); + $data = require $path; + $data['data']['images']['field2d'] = 'not a sheet definition'; + file_put_contents($path, 'toBe([])->and($row['imagePreview']['issue'])->not->toBeEmpty() + ->and(fn() => applyFieldRole($session, 'actors', 'images.field2d.sheet', 'Graphics/Characters/$Solo.png'))->toThrow(SessionRefusal::class); + applyFieldRole($session, 'actors', 'images.field2d.sheet', ''); + $session->saveDatabase('actors'); + expect((require $path)['data']['images'])->toBe(['face' => 'unrelated-face']); + $session->undo(); + expect(getFieldRoleRow($session, 'actors', 'images.field2d.sheet')['imagePreview']['issue'])->toBe($row['imagePreview']['issue']); +}); From 4c4af43e88aba3c7c1b80ebe21c973fdebb230a6 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 10 Oct 2026 19:36:09 +0200 Subject: [PATCH 242/344] feat(editor): author map-owned world objects safely Reuse map-owned declarations, shared role and condition fields, source-preserving history and Engine-composed current-file previews. Refuse dangling layer ownership, referenced deletion and opaque source edits before mutation; recheck deletion references on redo. Preserve Terminal authoring, physical occupancy and save identity. Verify 181 focused and adjacent tests with live Engine source; native interaction and Game integration remain pending. --- src/Database/CutsceneSchemas.php | 28 +- src/Database/ProjectRecord.php | 2 + src/Database/ProjectRecordDatabase.php | 5 +- src/Database/RecordSchema.php | 2 + src/Database/RecordSubList.php | 3 + src/Database/ReferenceCatalog.php | 10 + src/Database/StagedActorFields.php | 72 +---- src/Field/FieldSpriteFields.php | 78 +++++ src/Field/WorldObjectAuthoring.php | 287 ++++++++++++++++++ src/Field/WorldObjectPreview.php | 71 +++++ src/Field/WorldObjectReferences.php | 108 +++++++ src/Maps/LineInsertionInventory.php | 14 + src/ProjectMap.php | 64 +++- src/Session/EditorSession.php | 98 +++++- src/Session/SessionHost.php | 27 ++ .../Unit/Session/WorldObjectAuthoringTest.php | 260 ++++++++++++++++ 16 files changed, 1037 insertions(+), 92 deletions(-) create mode 100644 src/Field/FieldSpriteFields.php create mode 100644 src/Field/WorldObjectAuthoring.php create mode 100644 src/Field/WorldObjectPreview.php create mode 100644 src/Field/WorldObjectReferences.php create mode 100644 tests/Unit/Session/WorldObjectAuthoringTest.php diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index 964a5c1..9f99f7c 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -323,13 +323,14 @@ public static function castList(bool $graphical = false): RecordSubList prefix: 'cast', singular: 'cast member', fields: [ - new RecordField('kind', 'Kind', options: ['player', 'party_actor', 'npc', 'staged_actor']), + new RecordField('kind', 'Kind', options: ['player', 'party_actor', 'npc', 'staged_actor', ...($graphical ? ['world_object'] : [])]), ], blank: ['kind' => 'staged_actor', 'id' => 'actor', 'sprite' => ['@'], 'x' => 0, 'y' => 0], variants: [ 'player' => [new RecordField('id', 'Id')], 'party_actor' => [RecordField::reference('id', 'Actor', 'actors')], 'npc' => [new RecordField('id', 'NPC Id', reference: 'map_npcs')], + 'world_object' => [RecordField::reference('id', 'World Object', 'map_world_objects')], 'staged_actor' => static fn(array $entry): array => self::stagedActorFields($graphical, $entry), ], variantKey: 'kind', @@ -393,7 +394,7 @@ public static function cinematicCommandList(string $key, bool $graphical = false nestedLists: [ 'move_route' => MovementRouteFields::getPointList(...), 'parallel' => self::laneList(), - 'camera' => static fn(array $entry): ?RecordSubList => strtolower(strval($entry['operation'] ?? '')) === 'route' ? self::cameraPointList() : null, + 'camera' => static fn(array $entry): ?RecordSubList => strtolower(strval($entry['operation'] ?? '')) === 'route' ? self::cameraPointList($graphical) : null, ...RecordSchemaCatalog::getRegisteredCommandLists($graphical), ...($graphical ? ['stage_actor' => StagedActorFields::getSuppressionList(...)] : []), ], @@ -461,15 +462,15 @@ public static function finalizerCommandList(): RecordSubList public static function cinematicCommandVariants(bool $graphical = false): array { $base = RecordSchemaCatalog::eventCommandVariants($graphical); - $subject = static fn(string $prefix, string $label, bool $screen = false): array => [ + $subject = static fn(string $prefix, string $label, bool $screen = false, array $entry = []): array => [ new RecordField( $prefix . '.kind', $label . ' Kind', options: $screen - ? CinematicCommandSchema::SUBJECT_KINDS - : array_values(array_diff(CinematicCommandSchema::SUBJECT_KINDS, ['screen_position'])), + ? array_values(array_diff(CinematicCommandSchema::SUBJECT_KINDS, $graphical ? [] : ['world_object'])) + : array_values(array_diff(CinematicCommandSchema::SUBJECT_KINDS, $graphical ? ['screen_position'] : ['screen_position', 'world_object'])), ), - new RecordField($prefix . '.id', $label . ' Id', reference: 'cinematic_subjects', removeWhenEmpty: true, allowsNone: true), + new RecordField($prefix . '.id', $label . ' Id', reference: ($entry[$prefix]['kind'] ?? null) === 'world_object' ? 'map_world_objects' : 'cinematic_subjects', removeWhenEmpty: true, allowsNone: true), new RecordField($prefix . '.x', $label . ' X', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField($prefix . '.y', $label . ' Y', InputControlType::INTEGER, removeWhenEmpty: true), ]; @@ -489,7 +490,7 @@ public static function cinematicCommandVariants(bool $graphical = false): array 'checkpoint' => [new RecordField('name', 'Checkpoint', reference: 'cinematic_checkpoints')], 'camera' => static fn(array $entry): array => [ new RecordField('operation', 'Operation', options: CinematicCommandSchema::CAMERA_OPERATIONS), - ...(in_array($entry['operation'] ?? '', ['focus', 'pan', 'track'], true) ? $subject('target', 'Target') : []), + ...(in_array($entry['operation'] ?? '', ['focus', 'pan', 'track'], true) ? $subject('target', 'Target', entry: $entry) : []), ...(in_array($entry['operation'] ?? '', ['pan', 'track', 'shake'], true) ? [new RecordField('seconds', 'Seconds', InputControlType::FLOAT)] : []), @@ -509,12 +510,12 @@ public static function cinematicCommandVariants(bool $graphical = false): array 'field_animation' => static fn(array $entry): array => array_key_exists('effect', $entry) ? [ new RecordField('effect', 'Effect', reference: 'effects', removeWhenEmpty: true, allowsNone: true, displayDefault: '(legacy animation)'), - ...$subject('target', 'Target', true), + ...$subject('target', 'Target', true, $entry), ] : [ new RecordField('effect', 'Effect', reference: 'effects', removeWhenEmpty: true, allowsNone: true, displayDefault: '(legacy animation)'), RecordField::reference('animation', 'Animation', 'animations'), - ...$subject('target', 'Target', true), + ...$subject('target', 'Target', true, $entry), new RecordField('secondsPerFrame', 'Seconds Per Frame', InputControlType::FLOAT, removeWhenEmpty: true, displayDefault: '0.12'), ], 'title_card' => [ @@ -562,15 +563,16 @@ public static function laneList(): RecordSubList /** * Returns the points of a camera route: a target each, with a duration. */ - public static function cameraPointList(): RecordSubList + public static function cameraPointList(bool $graphical = false): RecordSubList { return new RecordSubList( key: 'points', prefix: 'point', singular: 'route point', - fields: [ - new RecordField('kind', 'Kind', options: array_values(array_diff(CinematicCommandSchema::SUBJECT_KINDS, ['screen_position']))), - new RecordField('id', 'Id', reference: 'cinematic_subjects', removeWhenEmpty: true, allowsNone: true), + fields: [], + getFields: static fn(array $entry): array => [ + new RecordField('kind', 'Kind', options: array_values(array_diff(CinematicCommandSchema::SUBJECT_KINDS, $graphical ? ['screen_position'] : ['screen_position', 'world_object']))), + new RecordField('id', 'Id', reference: ($entry['kind'] ?? null) === 'world_object' ? 'map_world_objects' : 'cinematic_subjects', removeWhenEmpty: true, allowsNone: true), new RecordField('x', 'X', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField('y', 'Y', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField('seconds', 'Seconds', InputControlType::FLOAT), diff --git a/src/Database/ProjectRecord.php b/src/Database/ProjectRecord.php index c15be99..e5898b1 100644 --- a/src/Database/ProjectRecord.php +++ b/src/Database/ProjectRecord.php @@ -201,6 +201,8 @@ private static function withPathValue(array $target, array $segments, mixed $val return $target; } + // Removing an absent nested field never changes its scalar/null owner. + if ($value === null && !is_array($target[$key] ?? null)) { return $target; } $child = is_array($target[$key] ?? null) ? $target[$key] : []; $child = self::withPathValue($child, $segments, $value); diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 19c5bf4..1634548 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -56,7 +56,7 @@ public function useAuthoringReferences(ReferenceCatalog $references): void private function coerceReferenceField(ProjectRecord $record, RecordField $field, string $rawValue, array $ownerPath = []): mixed { $value = self::coerce($field, $rawValue); - if ($this->authoringReferences !== null && in_array($field->reference, ['actor_ids', 'stage_timelines', 'cinematic_movement_routes'], true) && $value !== null) { + if ($this->authoringReferences !== null && in_array($field->reference, ['actor_ids', 'stage_timelines', 'cinematic_movement_routes', 'map_world_objects'], true) && $value !== null) { $choices = $field->reference === 'cinematic_movement_routes' ? $this->authoringReferences->getMovementRouteIds($record, $ownerPath) : $this->authoringReferences->valuesFor($field->reference); @@ -826,6 +826,9 @@ public function setField(int $index, string $fieldId, string $rawValue): void } else { $draft->set($field->key, $value); } + if ($this->schema->prepareEdit !== null) { + $draft->restorePayload(($this->schema->prepareEdit)((array) $draft->toArray(), $field->key)); + } $this->dropStaleShapeFields($draft, $before); $this->assertProposedRecord($record, $draft); $record->restorePayload($draft->toArray()); diff --git a/src/Database/RecordSchema.php b/src/Database/RecordSchema.php index 1739013..7a1aa08 100644 --- a/src/Database/RecordSchema.php +++ b/src/Database/RecordSchema.php @@ -94,6 +94,8 @@ public function __construct( public ?Closure $subListsFor = null, /** Exclusive source owners refuse external edits rather than merging a stale snapshot. */ public bool $requireUnchangedSource = false, + /** Normalize dependent fields on an edited record before accepting the draft. */ + public ?Closure $prepareEdit = null, ) { if ($projection instanceof WholeFileProjection) { // A field outside the keys the category owns would read blank and diff --git a/src/Database/RecordSubList.php b/src/Database/RecordSubList.php index e46079b..85b3fa0 100644 --- a/src/Database/RecordSubList.php +++ b/src/Database/RecordSubList.php @@ -67,6 +67,8 @@ public function __construct( public ?string $keyField = null, public ?string $valueField = null, public ?Closure $prepareEdit = null, + /** Dynamic entry fields independent of a persisted variant discriminator. */ + public ?Closure $getFields = null, ) { } @@ -198,6 +200,7 @@ public function armsFor(array $entry): array */ public function fieldsFor(array $entry): array { + if ($this->getFields !== null) { return ($this->getFields)($entry); } if ($this->variantKey === null) { return $this->fields; } diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index d18e9de..2d881bf 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -74,6 +74,8 @@ final class ReferenceCatalog 'png_assets', 'elements', 'map_npcs', + 'map_world_objects', + 'world_object_tile_layers', 'knowledge_subjects', 'knowledge_reports', 'knowledge_record_types', @@ -143,6 +145,11 @@ public function getAssetRoot(): string return rtrim($this->workspace->projectRoot, '/') . '/assets'; } + public function describeWorldObjectReferences(ProjectMap $map, string $id): array + { + return new \Ichiloto\Editor\Field\WorldObjectReferences($this->workspace)->describeReferences($map, $id); + } + /** * @param ProjectWorkspace $workspace The project. * @param ProjectMap|null $currentMap The map the author is working in, @@ -242,6 +249,8 @@ public function valuesFor(string $category): array // read live from the collection, a just-created NPC is offered // at once and a deleted one is gone. 'map_npcs' => $this->getSubjectMap()?->getNpcs()->ids() ?? [], + 'map_world_objects' => $this->getSubjectMap() === null ? [] : array_column(\Ichiloto\Editor\Field\WorldObjectAuthoring::readEntries($this->getSubjectMap()), 'id'), + 'world_object_tile_layers' => $this->currentMap === null ? [] : array_column($this->currentMap->describeTileLayers()['layers'], 'name'), // Older references name an animation; current ones store its id. 'animations' => array_map( static fn(ProjectRecord $animation): string => strval($animation->get('name')), @@ -457,6 +466,7 @@ private function subjectIds(): array return array_values(array_unique([ ...$this->castIds(['staged_actor', 'npc', 'party_actor', 'player']), ...($this->getSubjectMap()?->getNpcs()->ids() ?? []), + ...$this->valuesFor('map_world_objects'), ...$this->valuesFor('actors'), ...($this->currentMap?->getEventMarkers() ?? []), ])); diff --git a/src/Database/StagedActorFields.php b/src/Database/StagedActorFields.php index 7206cdf..7aa7a16 100644 --- a/src/Database/StagedActorFields.php +++ b/src/Database/StagedActorFields.php @@ -4,10 +4,9 @@ namespace Ichiloto\Editor\Database; -use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Editor\Field\CharacterSheetPreview; +use Ichiloto\Editor\Field\FieldSpriteFields; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; -use Ichiloto\Engine\Cutscenes\Cinematics\CinematicStageManager; /** Graphical authoring controls; Engine remains the authority for accepted data. */ final class StagedActorFields @@ -56,10 +55,7 @@ public static function getCommandFields(array $command): array public static function getFields(array $entry): array { $contract = CinematicCommandSchema::export(); - $animation = $contract['stagedPoseAnimation']; $fields = [ - RecordField::reference('sprites2d.asset', 'Graphical Image / Pose Sheet', 'png_assets', allowsNone: true, noneLabel: '(inherit / no graphical override)'), - RecordField::reference('sprites2d.sheet', 'Walking Character Sheet', 'png_assets', allowsNone: true, noneLabel: '(not a walking sheet)'), new RecordField('subject.kind', 'Bind Visual To', options: ['', ...explode('|', $contract['stagedActorBinding']['subject']['kind'])], removeWhenEmpty: true, displayDefault: '(independent staged actor)'), RecordField::boolean('replace', 'Replace Existing Visual'), @@ -67,35 +63,10 @@ public static function getFields(array $entry): array if (($entry['subject']['kind'] ?? null) === 'npc') { $fields[] = RecordField::reference('subject.id', 'Bound NPC', 'map_npcs'); } - $sprites = $entry['sprites2d'] ?? []; - if (! is_array($sprites) || $sprites === []) { - return $fields; - } - $fields[] = new RecordField('sprites2d.layer', 'Graphical Layer', InputControlType::INTEGER, displayDefault: '0'); - if (isset($sprites['sheet'])) { - $fields[] = new RecordField('sprites2d.index', 'Sheet Character Index', InputControlType::INTEGER, displayDefault: '0'); - - return $fields; - } - $fields[] = new RecordField('sprites2d.cells', 'Visual Size (cells: width, height)', codec: RecordFieldCodec::SIZE, - removeWhenEmpty: true, displayDefault: '1, 1'); - $fields[] = new RecordField('sprites2d.sourceRect', 'Sheet Region (x, y, width, height)', codec: RecordFieldCodec::SOURCE_RECT, - removeWhenEmpty: true, displayDefault: '(whole image)'); - $controls = [ - 'columns' => new RecordField('sprites2d.animation.columns', 'Pose Grid Columns', InputControlType::INTEGER, displayDefault: (string) $animation['defaults']['columns']), - 'rows' => new RecordField('sprites2d.animation.rows', 'Pose Grid Rows', InputControlType::INTEGER, displayDefault: (string) $animation['defaults']['rows']), - 'frames' => new RecordField('sprites2d.animation.frames', 'Pose Frame Order (empty: static)', codec: RecordFieldCodec::CSV_INTEGERS, removeWhenEmpty: true), - 'fps' => new RecordField('sprites2d.animation.fps', 'Pose Frames Per Second', InputControlType::INTEGER, displayDefault: (string) $animation['defaultFps']), - 'loop' => RecordField::boolean('sprites2d.animation.loop', 'Loop Pose', removeWhenEmpty: false, displayDefault: $animation['defaults']['loop'] ? 'true' : 'false'), - 'restFrame' => new RecordField('sprites2d.animation.restFrame', 'Reduced-Motion Rest Cell', InputControlType::INTEGER, displayDefault: (string) $animation['defaults']['restFrame']), - ]; - foreach ($animation['fields'] as $key) { - if (isset($controls[$key])) { - $fields[] = $controls[$key]; - } + if (($entry['subject']['kind'] ?? null) === 'world_object') { + $fields[] = RecordField::reference('subject.id', 'Bound World Object', 'map_world_objects'); } - - return $fields; + return [...FieldSpriteFields::getFields($entry, pickSheetIndex: false), ...$fields]; } public static function getSuppressionList(array $entry): ?RecordSubList @@ -111,7 +82,8 @@ public static function getSuppressionList(array $entry): ?RecordSubList key: $wrapped ? 'actor.suppress' : 'suppress', prefix: 'suppress', singular: 'suppressed subject', fields: [new RecordField('kind', 'Subject Kind', options: $kinds)], blank: ['kind' => 'player'], - variants: ['npc' => [RecordField::reference('id', 'NPC', 'map_npcs')]], + variants: ['npc' => [RecordField::reference('id', 'NPC', 'map_npcs')], + 'world_object' => [RecordField::reference('id', 'World Object', 'map_world_objects')]], variantKey: 'kind', removeWhenEmpty: true, ); } @@ -142,36 +114,6 @@ public static function prepareEdit(array $entry, string $field): array } } } - if (! str_starts_with($field, 'sprites2d.')) { - return $entry; - } - $sprites = $entry['sprites2d'] ?? []; - if ($field === 'sprites2d.sheet' || $field === 'sprites2d.asset') { - $key = substr($field, strlen('sprites2d.')); - if (! isset($sprites[$key])) { - // Clearing an already absent alternate picker must not erase the active form. - if (($key === 'sheet' && isset($sprites['asset'])) || ($key === 'asset' && isset($sprites['sheet']))) { - return $entry; - } - unset($entry['sprites2d']); - - return $entry; - } - if ($key === 'sheet') { - unset($sprites['asset'], $sprites['cells'], $sprites['sourceRect'], $sprites['animation']); - } else { - unset($sprites['sheet'], $sprites['index']); - } - } - if ($field === 'sprites2d.animation.frames' && ! isset($sprites['animation']['frames'])) { - unset($sprites['animation']); - } elseif (str_starts_with($field, 'sprites2d.animation.')) { - $sprites['animation']['frames'] ??= [0]; - } - // Use runtime validation rather than a second copy of its bounds and shape rules. - CinematicStageManager::getGraphicalSprites($sprites); - $entry['sprites2d'] = $sprites; - - return $entry; + return FieldSpriteFields::prepareEdit($entry, $field); } } diff --git a/src/Field/FieldSpriteFields.php b/src/Field/FieldSpriteFields.php new file mode 100644 index 0000000..ad14b7f --- /dev/null +++ b/src/Field/FieldSpriteFields.php @@ -0,0 +1,78 @@ + $field->key === 'sprites2d.index' + ? new RecordField('sprites2d.index', 'Sheet Character Index', InputControlType::INTEGER, displayDefault: '0') : $field, $walking); + } + return [$fields[0], ...$walking]; + } + return [...$fields, + new RecordField('sprites2d.layer', 'Graphical Layer', InputControlType::INTEGER, displayDefault: '0'), + new RecordField('sprites2d.cells', 'Visual Size (cells)', codec: RecordFieldCodec::SIZE, removeWhenEmpty: true, displayDefault: '1, 1'), + new RecordField('sprites2d.sourceRect', 'Image Region', codec: RecordFieldCodec::SOURCE_RECT, removeWhenEmpty: true, displayDefault: '(whole image)'), + new RecordField('sprites2d.animation.frames', 'Pose Frame Order (empty: static)', codec: RecordFieldCodec::CSV_INTEGERS, removeWhenEmpty: true), + new RecordField('sprites2d.animation.columns', 'Pose Grid Columns', InputControlType::INTEGER, displayDefault: '1'), + new RecordField('sprites2d.animation.rows', 'Pose Grid Rows', InputControlType::INTEGER, displayDefault: '1'), + new RecordField('sprites2d.animation.fps', 'Pose Frames Per Second', InputControlType::INTEGER, displayDefault: (string) FieldPoseAnimation::DEFAULT_FPS), + RecordField::boolean('sprites2d.animation.loop', 'Loop Pose', removeWhenEmpty: false, displayDefault: 'true'), + new RecordField('sprites2d.animation.restFrame', 'Reduced-Motion Rest Cell', InputControlType::INTEGER, displayDefault: '0'), + ]; + } + + public static function prepareEdit(array $entry, string $field, bool $explicitAbsence = false): array + { + if (!str_starts_with($field, 'sprites2d.')) { return $entry; } + $sprites = $entry['sprites2d'] ?? []; + if ($field === 'sprites2d.sheet' || $field === 'sprites2d.asset') { + $key = substr($field, strlen('sprites2d.')); + if (!isset($sprites[$key])) { + if (($key === 'sheet' && isset($sprites['asset'])) || ($key === 'asset' && isset($sprites['sheet']))) { return $entry; } + if ($explicitAbsence) { $entry['sprites2d'] = null; } else { unset($entry['sprites2d']); } + return $entry; + } + if ($key === 'sheet') { unset($sprites['asset'], $sprites['cells'], $sprites['sourceRect'], $sprites['animation']); } + else { unset($sprites['sheet'], $sprites['index']); } + } + if ($field === 'sprites2d.animation.frames' && !isset($sprites['animation']['frames'])) { unset($sprites['animation']); } + elseif (str_starts_with($field, 'sprites2d.animation.')) { $sprites['animation']['frames'] ??= [0]; } + FieldSpriteRole::readDefinition($sprites); + $entry['sprites2d'] = $sprites; + return $entry; + } + + public static function describeFields(array $fields, array $entry, ?string $assetRoot): array + { + foreach ($fields as &$field) { + if (($field['sourceKey'] ?? $field['field'] ?? null) === 'sprites2d.sheet' && is_array($entry['sprites2d'] ?? null)) { + $field['imagePreview'] = CharacterSheetPreview::describe($entry['sprites2d'], $assetRoot); + } + } + unset($field); + return $fields; + } +} diff --git a/src/Field/WorldObjectAuthoring.php b/src/Field/WorldObjectAuthoring.php new file mode 100644 index 0000000..4e59404 --- /dev/null +++ b/src/Field/WorldObjectAuthoring.php @@ -0,0 +1,287 @@ +getMapDataField([WorldObjectDefinition::DATA_KEY]) ?? []; + if (!is_array($entries) || !array_is_list($entries) || array_any($entries, static fn($entry): bool => !is_array($entry))) { + throw new InvalidArgumentException('worldObjects requires literal object records in a list; it cannot be flattened.'); + } + return $entries; + } + + public static function validateEntries(ProjectMap $map, array $entries, bool $complete = true): array + { + $owners = array_column($map->describeTileLayers()['layers'], 'owner', 'name'); + if (!$complete) { + // Empty conditions are an unfinished authoring draft, never a runtime default. + foreach ($entries as &$entry) { + $variants = $entry['variants'] ?? []; + if (!is_array($variants) || !array_is_list($variants) || count($variants) > WorldObjectDefinition::MAX_VARIANTS) { + throw new InvalidArgumentException('World-object variants exceed the Engine list contract.'); + } + $ids = []; + foreach ($variants as $variant) { + if (!is_array($variant) || array_diff(array_keys($variant), ['id', 'conditions', 'sprites2d']) !== [] + || !array_key_exists('sprites2d', $variant) || !array_key_exists('conditions', $variant)) { + throw new InvalidArgumentException('A variant requires id, conditions and explicit sprites2d.'); + } + $checked = WorldObjectDefinition::readMap(['worldObjects' => [[ + 'id' => $variant['id'] ?? null, 'anchor' => $entry['anchor'], 'pivot' => $entry['pivot'], 'sprites2d' => $variant['sprites2d'], + ]]], $map->getLayerSet()); + $id = $checked[0]->id; + if (isset($ids[$id])) { throw new InvalidArgumentException('Duplicate world-object variant id.'); } + $ids[$id] = true; + } + $entry['variants'] = array_values(array_filter($entry['variants'] ?? [], static fn($variant): bool => ($variant['conditions'] ?? null) !== [])); + } + unset($entry); + } + return WorldObjectDefinition::readMap([WorldObjectDefinition::DATA_KEY => $entries], $map->getLayerSet(), tileOwners: $owners); + } + + public function getSourceIssue(): ?string + { + return self::readSourceIssue($this->map); + } + + public static function readSourceIssue(ProjectMap $map): ?string + { + try { + $document = PhpArraySourceDocument::parse($map->captureLayerSnapshot()['source']); + $node = $document->root()->entryFor(WorldObjectDefinition::DATA_KEY)?->value; + $isLiteral = static function (SourceNode $node) use (&$isLiteral): bool { + if ($node->kind === SourceNode::SCALAR) { return true; } + return $node->kind === SourceNode::ARRAY && !$node->hasOpaqueKey + && array_all($node->entries, static fn($entry): bool => $isLiteral($entry->value)); + }; + return $node === null || $isLiteral($node) ? null : 'World-object edits require literal declarations; expressions and spread arrays are preserved, not rewritten.'; + } catch (\RuntimeException $error) { return $error->getMessage(); } + } + + public function getRecords(): ProjectRecordDatabase + { + $entries = self::readEntries($this->map); + $variants = new RecordSubList(key: 'variants', prefix: 'variant', singular: 'variant', fields: [], + blank: ['id' => $this->getUnusedId(array_merge(...array_map(static fn($entry): array => $entry['variants'] ?? [], $entries)), 'variant'), + 'conditions' => [], 'sprites2d' => null], heading: 'Ordered Variants', removeWhenEmpty: true, + getFields: static fn(array $entry): array => [new RecordField('id', 'Variant Id'), + new RecordField('conditions', 'When (all conditions)', codec: RecordFieldCodec::CONDITIONS), + ...FieldSpriteFields::getFields($entry, true)], + prepareEdit: static fn(array $entry, string $field): array => FieldSpriteFields::prepareEdit($entry, $field, true)); + $cells = new RecordSubList(key: 'covers.cells', prefix: 'cell', singular: 'covered cell', + fields: [new RecordField('x', 'X', InputControlType::INTEGER), new RecordField('y', 'Y', InputControlType::INTEGER)], + blank: ['x' => 0, 'y' => 0], heading: 'Covered Cells'); + $schema = new RecordSchema(key: 'world_objects', entryNoun: 'world object', storage: RecordStorage::MAP_OWNED, + relativePath: '', fields: [], labelKey: 'id', + fieldsFor: function (array $entry): array { + $fields = [new RecordField('id', 'Stable Map-local Id', isReadOnly: true), + new RecordField('anchor.x', 'Anchor X', InputControlType::INTEGER), + new RecordField('anchor.y', 'Anchor Y', InputControlType::INTEGER), + new RecordField('pivot.x', 'Image Pivot X (0..1)', InputControlType::FLOAT), + new RecordField('pivot.y', 'Image Pivot Y (0..1)', InputControlType::FLOAT), + ...FieldSpriteFields::getFields($entry, true), + new RecordField('covers.layer', 'Covered Gameplay Layer', options: ['', ...array_column(array_filter($this->map->getLayers(), + static fn($layer): bool => !$layer['decoration'] && $layer['id'] !== \Ichiloto\Editor\Maps\MapLayers::EVENT), 'name')], + removeWhenEmpty: true, displayDefault: '(no coverage)')]; + if (isset($entry['covers'])) { + $fields[] = new RecordField('covers.tileLayers', 'Covered Tile Layers', codec: RecordFieldCodec::CSV_LIST, + reference: 'world_object_tile_layers', allowsNone: true); + } + return $fields; + }, + subLists: [$variants, $cells], + subListsFor: static fn(array $entry): array => isset($entry['covers']) ? ['variants', 'covers.cells'] : ['variants'], + prepareEdit: static function (array $entry, string $field): array { + if ($field === 'covers.layer') { + if (!isset($entry['covers']['layer'])) { unset($entry['covers']); } + else { + $entry['covers']['cells'] ??= [['x' => $entry['anchor']['x'], 'y' => $entry['anchor']['y']]]; + $entry['covers']['tileLayers'] ??= []; + } + } + return FieldSpriteFields::prepareEdit($entry, $field, true); + }); + foreach ($entries as &$entry) { + if (isset($entry['covers'])) { + $entry['covers']['cells'] = array_map(static fn($cell): array => ['x' => $cell[0], 'y' => $cell[1]], $entry['covers']['cells']); + } + } + unset($entry); + $records = ProjectRecordDatabase::overOwnedList($schema, $this->map->dataPath, $entries, static function (): void {}, graphical: true); + $records->useAuthoringReferences($this->references); + return $records; + } + + public function createObject(string $id, int $x, int $y): ?Command + { + $entries = self::readEntries($this->map); + $entries[] = ['id' => $id, 'anchor' => ['x' => $x, 'y' => $y], 'pivot' => ['x' => .5, 'y' => 1.0], 'sprites2d' => null]; + return $this->writeEntries($entries, 'Create world object'); + } + + public function deleteObject(string $id): ?Command + { + $entries = self::readEntries($this->map); + $index = $this->getIndex($id); + $this->assertObjectUnreferenced($id); + array_splice($entries, $index, 1); + $command = $this->writeEntries($entries, 'Delete world object'); + if ($command === null) { return null; } + return new GenericCommand('Delete world object', function () use ($id, $command): void { + $this->assertObjectUnreferenced($id); + $command->execute(); + }, static fn() => $command->undo()); + } + + private function assertObjectUnreferenced(string $id): void + { + $references = $this->references->describeWorldObjectReferences($this->map, $id); + if ($references !== []) { + throw new MapSourceRefusal("Cannot delete world object {$id}; references or unproven authored sources remain:\n" . implode("\n", $references)); + } + } + + public function getIndex(string $id): int + { + foreach (self::readEntries($this->map) as $index => $entry) { if (($entry['id'] ?? null) === $id) { return $index; } } + throw new InvalidArgumentException('That map-local world object no longer exists.'); + } + + public function applyField(string $id, string $field, string $value): ?Command + { + $records = $this->getRecords(); + $index = $this->getIndex($id); + $fields = $records->getSettingsFields($index); + $row = array_find($fields, static fn($row): bool => ($row['field'] ?? null) === $field); + if ($row === null || ($row['editable'] ?? true) === false) { throw new InvalidArgumentException('Read the current editable world-object row before changing it.'); } + if (isset($row['options']) && !in_array($value, $row['options'], true)) { throw new InvalidArgumentException('Choose a listed value.'); } + if (($row['reference'] ?? null) === 'png_assets' && $value !== '') { + if (!in_array($value, $this->references->valuesFor('png_assets'), true)) { throw new InvalidArgumentException('Choose a current project PNG.'); } + \Ichiloto\Engine\Rendering\Sprites\PngAssetPreflight::inspect($this->map->getAssetRoot(), $value); + } + if (($row['reference'] ?? null) === 'world_object_tile_layers') { + $owner = self::readEntries($this->map)[$index]['covers']['layer']; + $choices = array_column(array_filter($this->map->describeTileLayers()['layers'], static fn($layer): bool => $layer['owner'] === $owner), 'name'); + foreach (array_filter(array_map(trim(...), explode(',', $value))) as $selected) { + if (!in_array($selected, $choices, true)) { throw new InvalidArgumentException('Choose a tile layer owned by the covered gameplay layer.'); } + } + } + (new RecordAuthoring())->applyField($records, $index, [], $field, $value, $row['label']); + return $this->writeRecords($records, 'Edit world object'); + } + + public function changeItem(string $id, string $field, bool $remove): ?Command + { + $records = $this->getRecords(); + $index = $this->getIndex($id); + $item = $records->locateItem($index, [], $field); + if (!$remove && $item?->listKey === 'covers.cells') { + $entries = self::readEntries($this->map); + $object = $entries[$index]; + $claimed = []; + foreach ($entries as $entry) { + if (($entry['covers']['layer'] ?? null) === $object['covers']['layer']) { + array_push($claimed, ...$entry['covers']['cells']); + } + } + for ($y = 0; $y < $this->map->getHeight(); $y++) { + for ($x = 0; $x < $this->map->getWidth(); $x++) { + if (!in_array([$x, $y], $claimed, true)) { + $entries[$index]['covers']['cells'][] = [$x, $y]; + return $this->writeEntries($entries, 'Add covered cell'); + } + } + } + throw new InvalidArgumentException('No unclaimed cell remains on the covered gameplay layer.'); + } + $authoring = new RecordAuthoring(); + $remove ? $authoring->removeItem($records, $index, [], $field) : $authoring->addItem($records, $index, [], $field); + return $this->writeRecords($records, $remove ? 'Remove world-object item' : 'Add world-object item'); + } + + public function moveVariant(string $id, string $variantId, int $offset): ?Command + { + if (!in_array($offset, [-1, 1], true)) { throw new InvalidArgumentException('Move one variant up or down.'); } + $entries = self::readEntries($this->map); + $index = $this->getIndex($id); + $variants = $entries[$index]['variants'] ?? []; + $from = array_search($variantId, array_column($variants, 'id'), true); + if ($from === false) { throw new InvalidArgumentException('Choose a known variant to reorder.'); } + $to = $from + $offset; + if ($to < 0 || $to >= count($variants)) { return null; } + [$variants[$from], $variants[$to]] = [$variants[$to], $variants[$from]]; + $entries[$index]['variants'] = $variants; + return $this->writeEntries($entries, 'Reorder world-object variants'); + } + + /** Canvas operations use the same complete declaration and history boundary as rows. */ + public function placeObject(string $id, int $x, int $y, bool $coverage = false): ?Command + { + $entries = self::readEntries($this->map); + $index = $this->getIndex($id); + if ($coverage) { + if (!isset($entries[$index]['covers'])) { throw new InvalidArgumentException('Select a covered gameplay layer first.'); } + $cells = $entries[$index]['covers']['cells']; + $at = array_search([$x, $y], $cells, true); + if ($at === false) { $cells[] = [$x, $y]; } else { array_splice($cells, $at, 1); } + $entries[$index]['covers']['cells'] = $cells; + } else { $entries[$index]['anchor'] = ['x' => $x, 'y' => $y]; } + return $this->writeEntries($entries, $coverage ? 'Toggle world-object coverage' : 'Place world-object anchor'); + } + + private function writeRecords(ProjectRecordDatabase $records, string $label): ?Command + { + $entries = array_map(static fn($record): array => (array) $record->toArray(), $records->getRecords()); + foreach ($entries as &$entry) { + if (isset($entry['covers'])) { $entry['covers']['cells'] = array_map(static fn($cell): array => [$cell['x'], $cell['y']], $entry['covers']['cells']); } + } + unset($entry); + return $this->writeEntries($entries, $label); + } + + private function writeEntries(array $entries, string $label): ?Command + { + $before = $this->map->getMapDataField([WorldObjectDefinition::DATA_KEY]); + if ($before === $entries) { return null; } + self::validateEntries($this->map, $entries, false); + if (($issue = $this->getSourceIssue()) !== null) { throw new MapSourceRefusal($issue); } + $this->map->setMapDataField([WorldObjectDefinition::DATA_KEY], $entries); + $map = $this->map; + return new GenericCommand($label, + static fn() => $map->setMapDataField([WorldObjectDefinition::DATA_KEY], $entries), + static fn() => $map->setMapDataField([WorldObjectDefinition::DATA_KEY], $before)); + } + + private function getUnusedId(array $entries, string $stem): string + { + $ids = array_column($entries, 'id'); + for ($number = 1; ; $number++) { if (!in_array($stem . '-' . $number, $ids, true)) { return $stem . '-' . $number; } } + } +} diff --git a/src/Field/WorldObjectPreview.php b/src/Field/WorldObjectPreview.php new file mode 100644 index 0000000..0885e11 --- /dev/null +++ b/src/Field/WorldObjectPreview.php @@ -0,0 +1,71 @@ + 3600) { throw new InvalidArgumentException('Preview time requires finite seconds in 0..3600.'); } + $issue = null; + try { $graphics = $map->loadGraphics(); } + catch (InvalidArgumentException|\RuntimeException $error) { $graphics = null; $issue = $error->getMessage(); } + $entries = WorldObjectAuthoring::readEntries($map); + $definitions = WorldObjectAuthoring::validateEntries($map, $entries); + if (array_diff(array_keys($variants), array_column($definitions, 'id')) !== []) { throw new InvalidArgumentException('Preview selection names an unknown map-local object.'); } + if (array_any($variants, static fn($id): bool => !is_string($id))) { throw new InvalidArgumentException('Preview variants must name known variant ids.'); } + $coverage = $sprites = $diagnostics = []; + foreach ($definitions as $object) { + $selected = $variants[$object->id] ?? ''; + $definition = $object->sprites; + if ($selected !== '') { + $variant = array_find($object->variants, static fn($variant): bool => $variant['id'] === $selected) + ?? throw new InvalidArgumentException('Choose a known world-object variant to preview.'); + $definition = $variant['sprites']; + } + $role = $definition === null ? null : new FieldSpriteRole($definition, $map->getAssetRoot(), 'World-object authoring ' . $object->id); + try { + $role?->advance($seconds); + $frame = $role?->getFrame($object->pivot); + if ($definition === null || $frame !== null) { + $coverage = array_replace_recursive($coverage, $object->coverage); + } else { $diagnostics[] = $object->id . ': the current selected role cannot be drawn; covered glyphs and tiles remain.'; } + if ($frame !== null) { + $sprites[] = new PresentationSprite('world-object:' . $map->mapId . ':' . $object->id, + $frame->asset, $object->x, $object->y, $frame->width, $frame->height, + $frame->anchor, $frame->layer, $frame->sourceRect, lift: $frame->lift, + quarterTurns: $frame->quarterTurns, pivot: $frame->pivot); + } + } finally { $role?->release(); } + } + $world = PresentationWorld::getFromLayers($map->getLayerSet(), 'map', $graphics, $map->getAssetRoot(), $coverage); + $operations = $world->getOperations(true, true); + $ordered = PresentationSprite::orderedList($sprites); + foreach ($ordered as $order => $sprite) { + $operations[] = ['op' => 'put', 'kind' => 'sprite', 'id' => $sprite->id, + 'value' => [...$sprite->toArray(true, true, true), 'order' => $order]]; + } + $columns = min(512, $map->getWidth()); + $rows = min(256, $map->getHeight()); + return ['map' => $map->mapId, 'assetRoot' => $map->getAssetRoot(), + 'grid' => ['columns' => $columns, 'rows' => $rows, + 'cellWidth' => FieldViewport::TILE_SIZE, 'cellHeight' => FieldViewport::TILE_SIZE], + 'update' => ['frame' => 1, 'baseGeneration' => 0, 'generation' => 1, 'reset' => true, 'present' => true, + 'operations' => $operations, 'viewport' => ['scale' => min($columns / $map->getWidth(), $rows / $map->getHeight()), 'origin' => ['x' => 0, 'y' => 0], + 'clipRect' => ['x' => 0, 'y' => 0, 'width' => $columns * FieldViewport::TILE_SIZE, + 'height' => $rows * FieldViewport::TILE_SIZE], + 'textLayerIds' => [], 'spriteIds' => array_column($ordered, 'id'), 'worldId' => 'map', + 'worldOrigin' => ['column' => 0, 'row' => 0]]], + 'graphicsIssue' => $issue, 'diagnostics' => $diagnostics]; + } +} diff --git a/src/Field/WorldObjectReferences.php b/src/Field/WorldObjectReferences.php new file mode 100644 index 0000000..488b85c --- /dev/null +++ b/src/Field/WorldObjectReferences.php @@ -0,0 +1,108 @@ +workspace->cutscenes?->assets(CutsceneType::CINEMATIC) ?? []; + $knownFiles = array_merge([], ...array_map(static fn($asset): array => $asset->paths(), $cinematics)); + foreach (glob($this->workspace->projectRoot . '/' . CutsceneType::CINEMATIC->relativeRoot() . '/*/*.php') ?: [] as $path) { + if (!in_array($path, $knownFiles, true)) { $found[] = $path . ': authored source is unavailable in the current catalog; reload before deleting a subject'; } + } + $document = PhpArraySourceDocument::parse($map->captureLayerSnapshot()['source']); + foreach (['events', 'npcs'] as $key) { + self::describeOpaque($document->root()->entryFor($key)?->value, $map->dataPath . ':' . $key, $found); + } + foreach ($map->getEventDefinitions() as $marker => $event) { + self::describeTree($event, $id, $map->mapId . ' event ' . $marker, $found, $scripts); + } + foreach ($map->getNpcs()->all() as $npc) { + self::describeTree($npc->toArray(), $id, $map->mapId . ' NPC ' . ($npc->getId() ?? $npc->getName()), $found, $scripts); + } + foreach ($cinematics as $asset) { + if ($asset->isDeleted()) { continue; } + $label = 'cinematic ' . $asset->id; + try { + $asset->assertSourcesUnchanged(); + $state = $asset->captureEditState(); + $dataSource = $state['sourceTemplates']['data']['source'] ?? file_get_contents($asset->dataPath()); + $data = PhpArraySourceDocument::parse((string) $dataSource); + $start = $data->root()->entryFor('startMap')?->value; + if ($start === null || $start->kind !== SourceNode::SCALAR) { + $found[] = $label . ': startMap ownership is not safely provable'; + continue; + } + $commands = $asset->commands(); + if (($asset->data()['startMap'] ?? null) !== $map->mapId && !self::hasTransferTo($commands, $map->mapId)) { continue; } + foreach (['cast', 'finalizer'] as $key) { + self::describeOpaque($data->root()->entryFor($key)?->value, $asset->dataPath() . ':' . $key, $found); + self::describeTree($asset->data()[$key] ?? [], $id, $label . '.' . $key, $found, $scripts); + } + $source = $state['sourceTemplates'][$asset->type->partnerNoun()]['source'] ?? file_get_contents($asset->partnerPath()); + self::describeOpaque(PhpArraySourceDocument::parse((string) $source)->root(), $asset->partnerPath(), $found); + self::describeTree($commands, $id, $label . '.commands', $found, $scripts); + } catch (Throwable $error) { + $found[] = $label . ': opaque authored source; references cannot be proved (' . $error->getMessage() . ')'; + } + } + $database = $this->workspace->getRecordDatabase('common_events'); + $visited = []; + while (($pending = array_diff_key($scripts, $visited)) !== []) { + foreach ($pending as $scriptId => $via) { + $visited[$scriptId] = true; + $record = array_find($database?->getRecords() ?? [], static fn($record): bool => ($record->toArray()['__scriptId'] ?? null) === $scriptId); + $label = 'event script ' . $scriptId . ' (via ' . $via . ')'; + if ($record === null) { $found[] = $label . ': references are unavailable'; continue; } + try { + $database->assertUnchangedSource(); + $source = file_get_contents($record->sourcePath ?? $database->backingFilePath()); + self::describeOpaque(PhpArraySourceDocument::parse((string) $source)->root(), $label, $found); + self::describeTree((array) $record->toArray(), $id, $label, $found, $scripts); + } catch (Throwable $error) { + $found[] = $label . ': authored references cannot be proved (' . $error->getMessage() . ')'; + } + } + } + return array_values(array_unique($found)); + } + + private static function describeTree(mixed $tree, string $id, string $path, array &$found, array &$scripts): void + { + if (!is_array($tree)) { return; } + if (($tree['kind'] ?? null) === 'world_object' && ($tree['id'] ?? null) === $id) { $found[] = $path; } + $script = ($tree['type'] ?? null) === 'common_event' ? ($tree['id'] ?? null) : ($tree['scriptId'] ?? null); + if (is_string($script) && $script !== '') { $scripts[$script] = $path; } + foreach ($tree as $key => $value) { self::describeTree($value, $id, $path . '.' . $key, $found, $scripts); } + } + + private static function describeOpaque(?SourceNode $node, string $path, array &$found): void + { + if ($node === null || $node->kind === SourceNode::SCALAR) { return; } + if ($node->kind !== SourceNode::ARRAY || $node->hasOpaqueKey) { + $found[] = $path . ': opaque authored source; subject references cannot be proved'; + return; + } + foreach ($node->entries as $index => $entry) { self::describeOpaque($entry->value, $path . '.' . ($entry->key ?? $index), $found); } + } + + private static function hasTransferTo(array $tree, string $mapId): bool + { + return (($tree['type'] ?? null) === 'transfer' && ($tree['map'] ?? null) === $mapId) + || array_any($tree, static fn($value): bool => is_array($value) && self::hasTransferTo($value, $mapId)); + } +} diff --git a/src/Maps/LineInsertionInventory.php b/src/Maps/LineInsertionInventory.php index 173397f..f61e47f 100644 --- a/src/Maps/LineInsertionInventory.php +++ b/src/Maps/LineInsertionInventory.php @@ -85,6 +85,20 @@ public function addMapData(string $file, string $mapId, array $data): void $isTarget = $mapId === $this->insertion->mapId; $here = [$mapId]; + if ($isTarget) { + foreach (($data['worldObjects'] ?? []) as $index => $object) { + $this->shiftPoint($file, ['worldObjects', $index, 'anchor'], $object['anchor']); + foreach (($object['covers']['cells'] ?? []) as $cellIndex => $cell) { + $axis = $this->insertion->axis === 'x' ? 0 : 1; + $coordinate = $cell[$axis]; + if (is_int($coordinate) && $coordinate >= $this->insertion->at) { + $path = ['worldObjects', $index, 'covers', 'cells', $cellIndex, $axis]; + $this->shifts[$file][self::formatKey($path)] = ['path' => $path, 'value' => $this->insertion->getShiftedCoordinate($coordinate)]; + } + } + } + } + foreach ((array) ($data['npcs'] ?? []) as $index => $npc) { if (! is_array($npc)) { continue; diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 8f7d203..8841529 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -474,7 +474,13 @@ public function renameLayer(string $id, string $name, bool $confirmCollisionChan } $layer = $this->layers->getLayer($id); $this->changeLayers(static fn(MapLayers $layers) => $layers->renameLayer($id, $name), - static fn(mixed $settings): mixed => $layer['decoration'] ? $settings : TileLayerSettings::renameOwner($settings, $layer['name'], $name)); + static fn(mixed $settings): mixed => $layer['decoration'] ? $settings : TileLayerSettings::renameOwner($settings, $layer['name'], $name), + static function (array $objects) use ($layer, $name): array { + foreach ($objects as &$object) { + if (($object['covers']['layer'] ?? null) === $layer['name']) { $object['covers']['layer'] = $name; } + } + return $objects; + }); } /** @@ -530,18 +536,29 @@ public function removeLayer(string $id): void * @param (Closure(mixed): mixed)|null $adjustSettings The tile layer settings the change leaves, given the current ones. * @throws MapSourceRefusal When the change or the settings are refused. */ - private function changeLayers(Closure $change, ?Closure $adjustSettings = null): void + private function changeLayers(Closure $change, ?Closure $adjustSettings = null, ?Closure $adjustObjects = null): void { $this->assertEditable(); $layers = clone $this->layers; $change($layers); + $data = $this->editableData; if ($adjustSettings !== null) { $settings = $this->getMapDataField([MapGraphics::SETTINGS_KEY]); $next = $adjustSettings($settings); if ($next !== $settings) { - $this->setMapDataField([MapGraphics::SETTINGS_KEY], $next); + if ($next === null) { unset($data[MapGraphics::SETTINGS_KEY]); } + else { $data[MapGraphics::SETTINGS_KEY] = $next; } + } + } + if ($adjustObjects !== null && isset($data['worldObjects'])) { + $data['worldObjects'] = $adjustObjects($data['worldObjects']); + if ($data['worldObjects'] !== $this->editableData['worldObjects']) { + $issue = \Ichiloto\Editor\Field\WorldObjectAuthoring::readSourceIssue($this); + if ($issue !== null) { throw new MapSourceRefusal($issue); } } } + $this->assertWorldObjectState($layers, $data); + $this->writeData($data); $this->layers = $layers; $this->cachedWidth = null; $this->touchState(); @@ -764,7 +781,15 @@ public function createTileLayer(string $name): void public function renameTileLayer(string $name, string $newName): void { $this->changeLayers(static fn(MapLayers $layers) => $layers->renameTileLayer($name, $newName), - static fn(mixed $settings): mixed => TileLayerSettings::renameLayer($settings, $name, $newName)); + static fn(mixed $settings): mixed => TileLayerSettings::renameLayer($settings, $name, $newName), + static function (array $objects) use ($name, $newName): array { + foreach ($objects as &$object) { + if (isset($object['covers'])) { + $object['covers']['tileLayers'] = array_map(static fn($layer): string => $layer === $name ? $newName : $layer, $object['covers']['tileLayers']); + } + } + return $objects; + }); } /** @@ -812,6 +837,9 @@ public function setTileLayerSettings(string $name, array $offset, ?string $moves throw new MapSourceRefusal(rtrim($error->getMessage(), '.') . '. Nothing was changed.', previous: $error); } if ($next !== $settings) { + $data = $this->editableData; + $data[MapGraphics::SETTINGS_KEY] = $next; + $this->assertWorldObjectState($this->layers, $data); $this->setMapDataField([MapGraphics::SETTINGS_KEY], $next); } } @@ -1067,10 +1095,15 @@ public function captureLayerSnapshot(): array public function restoreLayerSnapshot(array $snapshot): void { - $this->restoreGridSnapshot($snapshot['grid']); + // The snapshot owns both halves; validate them together, not old data against restored layer names. + $candidate = clone $this; + $candidate->editableData = $snapshot['data']; + $candidate->restoreGridSnapshot($snapshot['grid']); // History restores edits, never the last successful save checkpoint // or the original source needed by other outstanding undo entries. $this->editableData = $snapshot['data']; + $this->layers = $candidate->layers; + $this->cachedWidth = null; $this->touchState(); } @@ -2114,6 +2147,7 @@ private function applyGridState(MapLayers $layers, mixed $occupancy): void throw new MapSourceRefusal($error->getMessage() . ' Nothing was changed.', previous: $error); } } + $this->assertWorldObjectState($layers, $next); if ($next !== $this->editableData) { $document = $this->assertDataPreservable($next); $next = $this->alignDataWithSource($document->root(), $next); @@ -2124,6 +2158,18 @@ private function applyGridState(MapLayers $layers, mixed $occupancy): void $this->touchState(); } + /** Layer/geometry edits cannot leave map-owned anchors or coverage dangling. */ + private function assertWorldObjectState(MapLayers $layers, array $data): void + { + if (!isset($data['worldObjects'])) { return; } + $candidate = clone $this; + $candidate->layers = $layers; + $candidate->editableData = $data; + $candidate->cachedWidth = null; + try { \Ichiloto\Editor\Field\WorldObjectAuthoring::validateEntries($candidate, $data['worldObjects'], false); } + catch (InvalidArgumentException $error) { throw new MapSourceRefusal('World-object ownership: ' . $error->getMessage() . ' Nothing was changed.', previous: $error); } + } + /** * Every grid file's source as the map now holds it: terminal and event * layers, and tile layers, keyed by path. @@ -2342,6 +2388,14 @@ public function willMoveOnSave(): bool public function save(?callable $backup = null, ?FileSetOperations $files = null): string { $this->assertEditable(); + if (($this->loadedData['worldObjects'] ?? null) !== ($this->editableData['worldObjects'] ?? null)) { + try { + \Ichiloto\Editor\Field\WorldObjectAuthoring::validateEntries($this, + \Ichiloto\Editor\Field\WorldObjectAuthoring::readEntries($this)); + } catch (\InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage(), previous: $error); + } + } $this->assertChangedInnPresentations($this->loadedData, $this->editableData, complete: true); \Ichiloto\Editor\Database\MovementRouteFields::assertChangedCommandsValid($this->loadedData, $this->editableData); $target = $this->resolveSaveTarget(); diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index b0bdd93..04eb591 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -54,6 +54,9 @@ use Ichiloto\Editor\Events\EventTypeCatalog; use Ichiloto\Editor\Events\EventTypeDefinition; use Ichiloto\Editor\Field\NpcAuthoring; +use Ichiloto\Editor\Field\WorldObjectAuthoring; +use Ichiloto\Editor\Field\WorldObjectPreview; +use Ichiloto\Editor\Field\FieldSpriteFields; use Ichiloto\Editor\Field\CharacterSheetPreview; use Ichiloto\Editor\Field\NpcChange; use Ichiloto\Editor\Field\NpcInspector; @@ -583,6 +586,7 @@ public function readMap(string $mapId): array 'layers' => $layers, 'events' => $events, 'npcs' => $npcs, + 'worldObjects' => WorldObjectAuthoring::readEntries($map), 'tileLayers' => $map->describeTileLayers(), 'physicalOccupancy' => $map->hasMapDataField([MapPhysicalOccupancy::DATA_KEY]), 'occupancy' => PhysicalOccupancyEditor::readOccupancy($map), @@ -2908,6 +2912,14 @@ public function listReferences(?string $mapId, string $category, ?array $record ?? throw new SessionRefusal(sprintf('There is no %s %s.', $type->noun(), strval($record['index'] ?? ''))); } $catalog = new ReferenceCatalog($this->workspace, $mapId === null ? null : $this->requireMap($mapId), $cutscene); + if ($category === 'world_object_tile_layers') { + if ($mapId === null || !is_string($record['id'] ?? null)) { throw new SessionRefusal('A covered tile-layer picker needs its map-local object owner.'); } + $map = $this->requireMap($mapId); + $object = array_find(WorldObjectAuthoring::readEntries($map), static fn($entry): bool => $entry['id'] === $record['id']) + ?? throw new SessionRefusal('That world-object owner no longer exists.'); + return array_map(static fn($layer): array => ['value' => $layer['name'], 'label' => $layer['name']], + array_values(array_filter($map->describeTileLayers()['layers'], static fn($layer): bool => $layer['owner'] === ($object['covers']['layer'] ?? null)))); + } if ($category === 'cinematic_movement_routes' && $record !== null) { $catalog = $catalog->createMovementContext($this->getMovementOwnerCommands($mapId, $record)); } @@ -3873,13 +3885,82 @@ private static function requireFrame(mixed $frame, string $reader): array return $frame; } - /** - * Creates an NPC at a tile ({@see NpcAuthoring::create()}): fixed, its - * stable id derived from its name, a blank name taking the placeholder. - * - * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} - * @throws SessionRefusal When the map is unknown, stale or read-only, or the tile is outside it or taken. - */ + /** Reads a transient inspector for the map-owned declaration. */ + public function readWorldObject(string $mapId, string $id): array + { + $map = $this->requireMap($mapId); + $authoring = $this->createWorldObjectAuthoring($map); + $index = $authoring->getIndex($id); + $records = $authoring->getRecords(); + $object = WorldObjectAuthoring::readEntries($map)[$index]; + $issue = $authoring->getSourceIssue(); + $fields = $records->getSettingsFields($index); + foreach ($fields as &$field) { + $owner = isset($field['entry'][0]) && str_starts_with($field['field'] ?? '', 'variant') + ? $object['variants'][$field['entry'][0]] : $object; + $field = FieldSpriteFields::describeFields([$field], $owner, $map->getAssetRoot())[0]; + if ($issue !== null) { $field['editable'] = false; } + } + unset($field); + return ['map' => $mapId, 'revision' => $this->getMapRevision($map), 'event' => null, + 'worldObject' => ['id' => $id, 'variants' => array_column($object['variants'] ?? [], 'id'), + 'anchor' => $object['anchor'], 'covers' => $object['covers'] ?? null, 'issue' => $issue], + 'issue' => $issue, + 'rows' => array_map(static fn($field): array => self::describeRecordRow($records, $index, [], $field, 'world-object'), $fields)]; + } + + public function createWorldObject(string $mapId, int $revision, string $id, int $x, int $y): array + { + return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->createObject($id, $x, $y)); + } + + public function deleteWorldObject(string $mapId, int $revision, string $id): array + { + return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->deleteObject($id)); + } + + public function applyWorldObject(string $mapId, int $revision, string $id, array $key, string $value): array + { + $field = is_string($key['field'] ?? null) ? $key['field'] : throw new SessionRefusal('Name a current world-object field.'); + return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->applyField($id, $field, $value)); + } + + public function changeWorldObjectItem(string $mapId, int $revision, string $id, array $key, bool $remove): array + { + $field = is_string($key['field'] ?? null) ? $key['field'] : throw new SessionRefusal('Name a current world-object list row.'); + return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->changeItem($id, $field, $remove)); + } + + public function placeWorldObject(string $mapId, int $revision, string $id, int $x, int $y, bool $coverage = false): array + { + return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->placeObject($id, $x, $y, $coverage)); + } + + public function moveWorldObjectVariant(string $mapId, int $revision, string $id, string $variantId, int $offset): array + { + return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->moveVariant($id, $variantId, $offset)); + } + + public function readWorldObjectPreview(string $mapId, array $variants = [], float $seconds = 0): array + { + try { return WorldObjectPreview::describeWorld($this->requireMap($mapId), $variants, $seconds); } + catch (InvalidArgumentException|MapSourceRefusal $error) { throw new SessionRefusal($error->getMessage(), previous: $error); } + } + + private function createWorldObjectAuthoring(ProjectMap $map): WorldObjectAuthoring + { + return new WorldObjectAuthoring($map, new ReferenceCatalog($this->workspace, $map)); + } + + private function changeWorldObject(string $mapId, int $revision, callable $edit): array + { + $map = $this->requireCurrentMap($mapId, $revision); + try { $command = $edit($this->createWorldObjectAuthoring($map)); } + catch (InvalidArgumentException|\RuntimeException $error) { throw new SessionRefusal($error->getMessage(), previous: $error); } + if ($command !== null) { $this->history->record($command); } + return ['changed' => $command !== null, 'revision' => $this->getMapRevision($map)]; + } + public function createNpc(string $mapId, int $revision, int $x, int $y, string $name): array { $map = $this->requireCurrentMap($mapId, $revision); @@ -4653,7 +4734,8 @@ private function traverseHistory(callable $step): array $workspace = $this->workspace; $before = array_map($this->getMapRevision(...), $this->getMapsById()); $databases = $this->listDatabaseVersions(); - $command = $step(); + try { $command = $step(); } + catch (MapSourceRefusal $error) { throw new SessionRefusal($error->getMessage(), previous: $error); } // A step that put another workspace in place (a file set written at // once, or its undo) changed whatever it reloaded: everything. $reloaded = $this->workspace !== $workspace; diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 5ab693b..9ce3297 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -146,6 +146,21 @@ private function dispatch(string $method, array $params): array self::readOptionalString($params, 'answer'), self::readOptionalString($params, 'confirm'), ), 'map.read' => $session->readMap(self::requireString($params, 'map')), + 'worldObjects.read' => $session->readWorldObject(self::requireString($params, 'map'), self::requireString($params, 'id')), + 'worldObjects.create' => $session->createWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'id'), self::requireInt($params, 'x'), self::requireInt($params, 'y')), + 'worldObjects.delete' => $session->deleteWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireString($params, 'id')), + 'worldObjects.apply' => $session->applyWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'id'), self::requireArray($params, 'key'), self::requireString($params, 'value')), + 'worldObjects.add', 'worldObjects.remove' => $session->changeWorldObjectItem(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'id'), self::requireArray($params, 'key'), $method === 'worldObjects.remove'), + 'worldObjects.place' => $session->placeWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'id'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), + is_bool($params['coverage'] ?? false) ? ($params['coverage'] ?? false) : throw new InvalidRequest('"coverage" must be a boolean.')), + 'worldObjects.moveVariant' => $session->moveWorldObjectVariant(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'id'), self::requireString($params, 'variant'), self::requireInt($params, 'offset')), + 'worldObjects.preview' => $session->readWorldObjectPreview(self::requireString($params, 'map'), + isset($params['variants']) ? self::requireArray($params, 'variants') : [], isset($params['seconds']) ? self::requireNumber($params, 'seconds') : 0), 'map.world' => $session->readWorld(self::requireString($params, 'map'), is_bool($params['tileShadows'] ?? false) ? ($params['tileShadows'] ?? false) : throw new InvalidRequest('"tileShadows" must be a boolean.')), 'tiles.palette' => $session->readTilePalette(self::requireString($params, 'map')), @@ -613,6 +628,18 @@ private static function requireInt(array $params, string $key): int return is_int($params[$key] ?? null) ? $params[$key] : throw new InvalidRequest(sprintf('"%s" must be an integer.', $key)); } + private static function requireArray(array $params, string $key): array + { + return is_array($params[$key] ?? null) ? $params[$key] : throw new InvalidRequest(sprintf('"%s" must be structured data.', $key)); + } + + private static function requireNumber(array $params, string $key): float + { + $value = $params[$key] ?? null; + return (is_int($value) || is_float($value)) && is_finite((float) $value) + ? (float) $value : throw new InvalidRequest(sprintf('"%s" must be a finite number.', $key)); + } + private static function requireRecipe(array $params): array { return self::requireFootprint($params, 'expected') diff --git a/tests/Unit/Session/WorldObjectAuthoringTest.php b/tests/Unit/Session/WorldObjectAuthoringTest.php new file mode 100644 index 0000000..ea3247b --- /dev/null +++ b/tests/Unit/Session/WorldObjectAuthoringTest.php @@ -0,0 +1,260 @@ + [],", "'events' => [], /* map-owned identity */ 'worldObjects' => [ + ['id' => 'fixture-prop', 'anchor' => ['x' => 1, 'y' => 1], + 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null], + ], 'tileLayers' => ['floor' => ['movesWith' => 'terrain']], 'preserved' => abs(7),", $source); + file_put_contents($file, $source); + writeTilesetTestPng($root . '/assets/Graphics/Props/Replaceable.png', 12, 16); + writeTilesetTestPng($root . '/assets/Graphics/Props/$Walking.png', 12, 16); + return [$root, $file, EditorSession::open($root)]; +} + +function applyWorldObjectRow(EditorSession $session, string $field, string $value): array +{ + $view = $session->readWorldObject('test-map', 'fixture-prop'); + $row = array_find($view['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === $field) + ?? throw new RuntimeException('Missing world-object row ' . $field); + return $session->applyWorldObject('test-map', $view['revision'], 'fixture-prop', $row['key'], $value); +} + +it('owns objects only in map source, with source-safe history save and reopen', function () { + [$root, $file, $session] = createWorldObjectProject(); + $before = file_get_contents($file); + $map = $session->readMap('test-map'); + expect(array_column($map['worldObjects'], 'id'))->toBe(['fixture-prop']); + $session->createWorldObject('test-map', $map['revision'], 'new-prop', 2, 1); + expect(file_get_contents($file))->toBe($before) + ->and($session->undo()['maps'])->toBe(['test-map']); + expect(array_column($session->readMap('test-map')['worldObjects'], 'id'))->toBe(['fixture-prop']); + $session->redo(); + applyWorldObjectRow($session, 'anchor.x', '2'); + applyWorldObjectRow($session, 'sprites2d.asset', 'Graphics/Props/Replaceable.png'); + $session->saveMap('test-map'); + expect(file_get_contents($file))->toContain('/* map-owned identity */', "'preserved' => abs(7)") + ->and(EditorSession::open($root)->readMap('test-map')['worldObjects'][0]['sprites2d'])->toBe(['asset' => 'Graphics/Props/Replaceable.png']); + $revision = $session->readMap('test-map')['revision']; + $session->deleteWorldObject('test-map', $revision, 'new-prop'); + $session->undo(); + expect(array_column($session->readMap('test-map')['worldObjects'], 'id'))->toBe(['fixture-prop', 'new-prop']); +}); + +it('offers typed variants conditions coverage and known resource selections', function () { + [, , $session] = createWorldObjectProject(); + $view = $session->readWorldObject('test-map', 'fixture-prop'); + $heading = array_find($view['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === 'variantList'); + $session->changeWorldObjectItem('test-map', $view['revision'], 'fixture-prop', $heading['key'], false); + expect(fn() => $session->saveMap('test-map'))->toThrow(SessionRefusal::class, 'conditions'); + applyWorldObjectRow($session, 'variant0Conditions', 'switch:existing-condition'); + applyWorldObjectRow($session, 'variant0Sprites2dasset', 'Graphics/Props/Replaceable.png'); + $layer = array_find($session->readMap('test-map')['tileLayers']['layers'], static fn($layer): bool => $layer['name'] === 'floor')['owner']; + applyWorldObjectRow($session, 'covers.layer', $layer); + $session->placeWorldObject('test-map', $session->readMap('test-map')['revision'], 'fixture-prop', 2, 1, true); + $object = $session->readMap('test-map')['worldObjects'][0]; + expect($object['variants'][0]['conditions'])->toBe([['type' => 'switch', 'name' => 'existing-condition']]) + ->and($object['covers']['cells'])->toBe([[1, 1], [2, 1]]) + ->and($object['covers']['tileLayers'])->toBe([]); + $choices = $session->listReferences('test-map', 'world_object_tile_layers', ['id' => 'fixture-prop']); + expect(array_column($choices, 'value'))->toContain('floor'); + applyWorldObjectRow($session, 'covers.tileLayers', 'floor'); + expect(fn() => applyWorldObjectRow($session, 'covers.tileLayers', 'unknown'))->toThrow(SessionRefusal::class, 'Choose a tile layer'); + $session->saveMap('test-map'); +}); + +it('shares current-file walking roles and explicit absence without changing Terminal or occupancy', function () { + [$root, , $session] = createWorldObjectProject(); + $before = $session->readMap('test-map'); + $terminal = ProjectWorkspace::fromProject($root)->maps[0]->getLayerSet()->getComposedGrid(); + applyWorldObjectRow($session, 'sprites2d.sheet', 'Graphics/Props/$Walking.png'); + $row = array_find($session->readWorldObject('test-map', 'fixture-prop')['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === 'sprites2d.sheet'); + expect($row['imagePreview']['issue'] ?? null)->toBeNull() + ->and(count($row['imagePreview']['frames']))->toBe(4); + applyWorldObjectRow($session, 'sprites2d.sheet', ''); + expect($session->readMap('test-map')['worldObjects'][0]['sprites2d'])->toBeNull() + ->and($session->readMap('test-map')['occupancy'])->toBe($before['occupancy']) + ->and($session->readMap('test-map')['layers'])->toBe($before['layers']); + $session->saveMap('test-map'); + expect(ProjectWorkspace::fromProject($root)->maps[0]->getLayerSet()->getComposedGrid())->toBe($terminal); +}); + +it('refuses stale revisions ids assets expressions and external source edits before mutation', function () { + [$root, $file, $session] = createWorldObjectProject(); + $view = $session->readWorldObject('test-map', 'fixture-prop'); + expect(fn() => $session->createWorldObject('test-map', $view['revision'], 'fixture-prop', 1, 1))->toThrow(SessionRefusal::class, 'Duplicate') + ->and(fn() => $session->createWorldObject('test-map', $view['revision'], 'Invalid Id', 1, 1))->toThrow(SessionRefusal::class, 'stable lowercase') + ->and(fn() => applyWorldObjectRow($session, 'sprites2d.asset', '../foreign.png'))->toThrow(SessionRefusal::class, 'Choose a current'); + applyWorldObjectRow($session, 'anchor.x', '2'); + expect(fn() => $session->deleteWorldObject('test-map', $view['revision'], 'fixture-prop'))->toThrow(SessionRefusal::class, 'changed since revision'); + file_put_contents($file, str_replace("'preserved' => abs(7)", "'preserved' => abs(8)", (string) file_get_contents($file))); + expect(fn() => applyWorldObjectRow($session, 'anchor.x', '3'))->toThrow(SessionRefusal::class) + ->and($session->readMap('test-map')['worldObjects'][0]['anchor']['x'])->toBe(2); + $source = str_replace("'x' => 1", "'x' => abs(1)", (string) file_get_contents($file)); + file_put_contents($file, $source); + $reloaded = EditorSession::open($root); + expect($reloaded->readWorldObject('test-map', 'fixture-prop')['issue'])->toContain('literal') + ->and(fn() => applyWorldObjectRow($reloaded, 'anchor.x', '3'))->toThrow(SessionRefusal::class) + ->and(file_get_contents($file))->toBe($source); +}); + +it('uses Engine coverage and current-frame projection, retaining glyphs for missing roles', function () { + [$root, , $session] = createWorldObjectProject(); + $layer = $session->readMap('test-map')['layers'][0]['name']; + applyWorldObjectRow($session, 'covers.layer', $layer); + applyWorldObjectRow($session, 'sprites2d.asset', 'Graphics/Props/Replaceable.png'); + $preview = $session->readWorldObjectPreview('test-map'); + $sprite = array_find($preview['update']['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'); + expect($sprite['value']['id'])->toBe('world-object:test-map:fixture-prop') + ->and($sprite['value']['pivot'])->toBe(['x' => .5, 'y' => 1.0]); + unlink($root . '/assets/Graphics/Props/Replaceable.png'); + $missing = $session->readWorldObjectPreview('test-map'); + expect($missing['diagnostics'])->toHaveCount(1) + ->and(array_filter($missing['update']['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'))->toBe([]); + writeTilesetTestPng($root . '/assets/Graphics/Props/Replaceable.png', 24, 32); + expect($session->readWorldObjectPreview('test-map')['diagnostics'])->toBe([]); +}); + +it('routes real RPC ownership and cinematic object selectors without a global object database', function () { + [$root, , $session] = createWorldObjectProject(); + $host = new SessionHost(fopen('php://memory', 'r+'), fopen('php://memory', 'r+'), fopen('php://memory', 'r+')); + $request = static fn(string $method, array $params): array => $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params])); + $hello = $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + expect(array_column($hello['result']['project']['databases'], 'key'))->not->toContain('world_objects'); + $view = $request('worldObjects.read', ['map' => 'test-map', 'id' => 'fixture-prop'])['result']; + expect($request('worldObjects.place', ['map' => 'test-map', 'id' => 'fixture-prop', 'revision' => $view['revision'], 'x' => 2, 'y' => 1])['result']['changed'])->toBeTrue(); + expect($request('worldObjects.preview', ['map' => 'test-map'])['result']['update']['reset'])->toBeTrue() + ->and($request('worldObjects.preview', ['map' => 'test-map', 'seconds' => 'wrong'])['error']['kind'])->toBe('request'); + expect(array_column($session->listReferences('test-map', 'map_world_objects'), 'value'))->toBe(['fixture-prop']); + $camera = CutsceneSchemas::cinematicCommandVariants(true)['camera'](['operation' => 'focus', 'target' => ['kind' => 'world_object', 'id' => 'fixture-prop']]); + expect(array_find($camera, static fn($field): bool => $field->key === 'target.id')->reference)->toBe('map_world_objects'); +}); + +it('refuses referenced and opaque cinematic deletions before source history or revision mutation', function () { + [$root, $file] = createWorldObjectProject(); + $folder = $root . '/assets/Cutscenes/Cinematics/fixture-cutscene'; + mkdir($folder, 0777, true); + $dataFile = $folder . '/fixture-cutscene.data.php'; + $scriptFile = $folder . '/fixture-cutscene.script.php'; + file_put_contents($dataFile, "'fixture-cutscene', 'name'=>'Fixture', 'startMap'=>'test-map', 'cast'=>[ + ['kind'=>'staged_actor', 'id'=>'staged', 'subject'=>['kind'=>'world_object','id'=>'fixture-prop'], + 'suppress'=>[['kind'=>'world_object','id'=>'fixture-prop']]], + ]];"); + file_put_contents($scriptFile, "'camera', 'operation'=>'focus', 'target'=>['kind'=>'world_object','id'=>'fixture-prop']]];"); + $session = EditorSession::open($root); + $before = $session->readMap('test-map'); + $source = file_get_contents($file); + try { + $session->deleteWorldObject('test-map', $before['revision'], 'fixture-prop'); + $this->fail('Referenced object deletion was not refused.'); + } catch (SessionRefusal $error) { + expect($error->getMessage())->toContain('fixture-cutscene.cast.0.subject', 'fixture-cutscene.cast.0.suppress.0', 'fixture-cutscene.commands.0.target'); + } + expect($session->readMap('test-map'))->toBe($before) + ->and(file_get_contents($file))->toBe($source) + ->and($session->undo()['maps'])->toBe([]); + file_put_contents($dataFile, "'fixture-cutscene', 'name'=>'Fixture', 'startMap'=>'test-map', 'cast'=>[]];"); + file_put_contents($scriptFile, " $opaque->deleteWorldObject('test-map', $opaque->readMap('test-map')['revision'], 'fixture-prop')) + ->toThrow(SessionRefusal::class, 'opaque authored source'); + file_put_contents($scriptFile, "'wait','seconds'=>0.5]];"); + $safe = EditorSession::open($root); + $safe->deleteWorldObject('test-map', $safe->readMap('test-map')['revision'], 'fixture-prop'); + expect($safe->readMap('test-map')['worldObjects'])->toBe([]); + $safe->undo(); + expect(array_column($safe->readMap('test-map')['worldObjects'], 'id'))->toBe(['fixture-prop']); + $safe->redo(); + $safe->saveMap('test-map'); + expect(EditorSession::open($root)->readMap('test-map')['worldObjects'])->toBe([]); +}); + +it('reorders explicit variants durably with undo and refuses invalid preview selection', function () { + [$root, , $session] = createWorldObjectProject(); + foreach (['first', 'second'] as $id) { + $view = $session->readWorldObject('test-map', 'fixture-prop'); + $heading = array_find($view['rows'], static fn($row): bool => ($row['key']['field'] ?? '') === 'variantList'); + $session->changeWorldObjectItem('test-map', $view['revision'], 'fixture-prop', $heading['key'], false); + applyWorldObjectRow($session, 'variant0Id', $id); + applyWorldObjectRow($session, 'variant0Conditions', 'switch:existing-condition'); + } + $session->moveWorldObjectVariant('test-map', $session->readMap('test-map')['revision'], 'fixture-prop', 'first', -1); + expect($session->readWorldObject('test-map', 'fixture-prop')['worldObject']['variants'])->toBe(['first', 'second']); + $session->undo(); + expect($session->readWorldObject('test-map', 'fixture-prop')['worldObject']['variants'])->toBe(['second', 'first']); + $session->redo(); + $session->saveMap('test-map'); + expect(EditorSession::open($root)->readWorldObject('test-map', 'fixture-prop')['worldObject']['variants'])->toBe(['first', 'second']) + ->and(fn() => $session->readWorldObjectPreview('test-map', ['fixture-prop'=>true]))->toThrow(SessionRefusal::class, 'known variant ids') + ->and(fn() => $session->readWorldObjectPreview('test-map', ['fixture-prop'=>'unknown']))->toThrow(SessionRefusal::class, 'known world-object variant'); +}); + +it('preserves known layer references through renames and refuses dangling removal atomically', function () { + [$root, , $session] = createWorldObjectProject(); + applyWorldObjectRow($session, 'covers.layer', 'terrain'); + applyWorldObjectRow($session, 'covers.tileLayers', 'floor'); + $layer = array_find($session->readMap('test-map')['layers'], static fn($layer): bool => $layer['name'] === 'terrain')['id']; + $session->renameLayer('test-map', $session->readMap('test-map')['revision'], $layer, 'ground-renamed', true); + $session->renameTileLayer('test-map', $session->readMap('test-map')['revision'], 'floor', 'floor-renamed'); + $before = $session->readMap('test-map'); + expect($before['worldObjects'][0]['covers'])->toBe(['layer'=>'ground-renamed','cells'=>[[1,1]],'tileLayers'=>['floor-renamed']]) + ->and(fn() => $session->removeTileLayer('test-map', $before['revision'], 'floor-renamed'))->toThrow(SessionRefusal::class) + ->and(fn() => $session->removeLayer('test-map', $before['revision'], $layer, true))->toThrow(SessionRefusal::class) + ->and($session->readMap('test-map'))->toBe($before); + $session->undo(); + expect($session->readMap('test-map')['worldObjects'][0]['covers']['tileLayers'])->toBe(['floor']); + $session->redo(); + $session->saveMap('test-map'); + expect(EditorSession::open($root)->readMap('test-map')['worldObjects'][0]['covers'])->toBe($before['worldObjects'][0]['covers']); +}); + +it('moves only owned map coordinates in the existing insertion inventory and refuses clipped anchors', function () { + [$root] = createWorldObjectProject(); + $map = ProjectWorkspace::fromProject($root)->maps[0]; + expect(fn() => $map->resize(1,1))->toThrow(Ichiloto\Editor\MapSourceRefusal::class) + ->and($map->getWidth())->toBe(4); + $data = $map->getMapDataField([]); + $data['worldObjects'][0]['covers'] = ['layer'=>'terrain','cells'=>[[1,1],[3,1]],'tileLayers'=>['floor']]; + $inventory = new Ichiloto\Editor\Maps\LineInsertionInventory(new Ichiloto\Editor\Maps\LineInsertion('test-map','x',1,2), []); + $inventory->addMapData('fixture.data.php','test-map',$data); + $shifts = $inventory->getShifts()['fixture.data.php']; + expect(array_column($shifts,'path'))->toContain(['worldObjects',0,'anchor','x'], ['worldObjects',0,'covers','cells',0,0], ['worldObjects',0,'covers','cells',1,0]) + ->and(array_column($shifts,'value'))->toBe([3,3,5]); +}); + +it('keeps graphical subject controls out of Terminal schemas and uses typed camera route references', function () { + $camera = CutsceneSchemas::cinematicCommandVariants(false)['camera'](['operation'=>'focus','target'=>['kind'=>'npc']]); + expect(array_find($camera, static fn($field): bool => $field->key === 'target.kind')->options)->not->toContain('world_object'); + $points = CutsceneSchemas::cameraPointList(true)->fieldsFor(['kind'=>'world_object']); + expect(array_find($points, static fn($field): bool => $field->key === 'id')->reference)->toBe('map_world_objects'); + $effect = CutsceneSchemas::cinematicCommandVariants(true)['field_animation'](['target'=>['kind'=>'world_object']]); + expect(array_find($effect, static fn($field): bool => $field->key === 'target.id')->reference)->toBe('map_world_objects'); +}); + +it('rechecks reference proof on redo and preserves the redo entry when a new source appears', function () { + [$root, , $session] = createWorldObjectProject(); + $session->deleteWorldObject('test-map', $session->readMap('test-map')['revision'], 'fixture-prop'); + $session->undo(); + $before = $session->readMap('test-map'); + $folder = $root . '/assets/Cutscenes/Cinematics/external-fixture'; + mkdir($folder,0777,true); + $file = $folder . '/external-fixture.script.php'; + file_put_contents($file, "'camera','operation'=>'focus','target'=>['kind'=>'world_object','id'=>'fixture-prop']]];"); + expect(fn() => $session->redo())->toThrow(SessionRefusal::class, 'external-fixture.script.php') + ->and($session->readMap('test-map'))->toBe($before); + unlink($file); + $session->redo(); + expect($session->readMap('test-map')['worldObjects'])->toBe([]); +}); From 0877bddbdaf94b81fb6ebbc8c8133c589693ba14 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 10 Oct 2026 21:38:14 +0200 Subject: [PATCH 243/344] feat(editor): pick a chest's loot from the database its loot type names A chest's Loot row was a text field in the GUI editor. It is now a picker of the database that its loot type names: items, weapons or armors, or the whole inventory for an accessory. It stores the stable definition id the runtime's item store resolves. A chest with no loot type is read as an item chest, as the runtime reads it, and gold stays an amount to type. findEventReference takes the loot type as an optional argument. The terminal editor calls it without one, so it keeps its own loot dialog unchanged. --- src/Inspector/MapInspector.php | 17 +++++++++++++++-- tests/Unit/EventReferenceFieldTest.php | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index ca192ab..449a496 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -577,9 +577,11 @@ public function applyMapBgmVariantValue(ProjectMap $map, array $field, mixed $va * another resource. * * @param array $field + * @param LootType|null $lootType The event's loot type, for a chest's loot row; without it that row has no picker + * (the terminal editor chooses loot in its own dialog). * @return array{category: string, title: string}|null */ - public static function findEventReference(array $field): ?array + public static function findEventReference(array $field, ?LootType $lootType = null): ?array { if (($field['target'] ?? null) !== 'event') { return null; @@ -603,6 +605,14 @@ public static function findEventReference(array $field): ?array // A shop's stock is data.items.N.item. The leaf alone would also // match an unrelated event that happened to call a field "item". $leaf === 'item' && ($path[1] ?? null) === 'items' => ['category' => 'inventory', 'title' => 'Item'], + // A chest's loot: the runtime gives every loot but gold from the item store, by stable definition id. + $path === ['data', 'loot'] => match ($lootType) { + LootType::ITEM => ['category' => 'items', 'title' => 'Item'], + LootType::WEAPON => ['category' => 'weapons', 'title' => 'Weapon'], + LootType::ARMOR => ['category' => 'armors', 'title' => 'Armor'], + LootType::ACCESSORY => ['category' => 'inventory', 'title' => 'Accessory'], + default => null, + }, default => null, }; } @@ -1416,6 +1426,9 @@ private function writeInnPresentation(ProjectMap $map, string $marker, mixed $va private function decorateEventInspectorFields(string $marker, array $fields): array { $decorated = []; + // A chest's loot is chosen from the database its loot type names, as the runtime reads an unset type: an item. + $lootType = LootType::tryFrom(strval(array_find($fields, + static fn(array $field): bool => array_values((array) ($field['path'] ?? [])) === ['data', 'lootType'])['value'] ?? '')) ?? LootType::ITEM; foreach ($fields as $field) { $field['marker'] = $marker; @@ -1447,7 +1460,7 @@ private function decorateEventInspectorFields(string $marker, array $fields): ar $field['destination'] = true; } - $reference = self::findEventReference($field); + $reference = self::findEventReference($field, $lootType); if (is_array($reference)) { // A reference is chosen, never spelled: dropping the control diff --git a/tests/Unit/EventReferenceFieldTest.php b/tests/Unit/EventReferenceFieldTest.php index cc128fc..7d1de54 100644 --- a/tests/Unit/EventReferenceFieldTest.php +++ b/tests/Unit/EventReferenceFieldTest.php @@ -65,6 +65,28 @@ function eventDataFieldFor(array $data, array $path): array ->and($field)->toHaveKey('control'); }); +it('picks a chest\'s loot from the database its loot type names', function (array $data, ?string $category) { + $field = eventDataFieldFor($data, ['data', 'loot']); + + expect($field['reference'] ?? null)->toBe($category); + if ($category !== null) { + expect($field)->not->toHaveKey('control'); + } +})->with([ + 'an item' => [['loot' => '', 'lootType' => 'item'], 'items'], + // The runtime reads a chest with no loot type as holding an item. + 'no loot type' => [['loot' => ''], 'items'], + 'a weapon' => [['loot' => '', 'lootType' => 'weapon'], 'weapons'], + 'an armor' => [['loot' => '', 'lootType' => 'armor'], 'armors'], + 'an accessory' => [['loot' => '', 'lootType' => 'accessory'], 'inventory'], + // Gold is an amount, typed. + 'gold' => [['loot' => '50', 'lootType' => 'gold'], null], +]); + +it('leaves a chest\'s loot to the terminal editor\'s own dialog when no loot type is given', function () { + expect(Ichiloto\Editor\Inspector\MapInspector::findEventReference(['target' => 'event', 'path' => ['data', 'loot']]))->toBeNull(); +}); + it('offers a shop every kind of thing an item store holds', function () { $catalog = new ReferenceCatalog(ProjectWorkspace::fromProject(makeTemporaryProject())); $stock = $catalog->valuesFor('inventory'); From 47e7184aea24dd0b92848ce320c43e485b579e9d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 10 Oct 2026 21:40:55 +0200 Subject: [PATCH 244/344] test(session): follow the session's graphical categories and refusals The project description test expected only the terminal's database categories. The session has offered the graphical editor's categories since 1e7fdf0, so the test now expects those, with every terminal category first, in the terminal's order. The test for collision undo after an external source replacement expected a MapSourceRefusal. Since 4c4af43 the session reports that refusal as a SessionRefusal, as it reports every refusal to an interface, so the test now expects a SessionRefusal. The other assertions are unchanged: history, map and source are untouched, and undo works once the source is restored. --- tests/Unit/Session/EditorSessionTest.php | 7 +++++-- tests/Unit/Session/PhysicalOccupancyPaintingTest.php | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index 038760b..4db101c 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -20,13 +20,16 @@ function sessionCell(array $map, string $layer, int $x, int $y): string return $found['rows'][$y][$x] ?? throw new RuntimeException("No cell {$x},{$y} on {$layer}."); } -it('describes the project: its maps and every database category', function () { +it('describes the project: its maps and every database category the graphical editor offers', function () { $session = EditorSession::open(makeTemporaryProject()); $project = $session->describeProject(); + $keys = array_column($project['databases'], 'key'); expect(array_column($project['maps'], 'id'))->toContain('test-map') ->and($project['maps'][0])->toHaveKeys(['id', 'name', 'dirty', 'readOnly', 'revision']) - ->and(array_column($project['databases'], 'key'))->toBe(array_map(static fn($category): string => $category->key, DatabaseCatalog::all())); + ->and($keys)->toBe(array_map(static fn($category): string => $category->key, DatabaseCatalog::getGraphicalCategories())) + // Every terminal category is offered, in the terminal's order, before the graphical ones. + ->and(array_slice($keys, 0, count(DatabaseCatalog::all())))->toBe(array_map(static fn($category): string => $category->key, DatabaseCatalog::all())); }); it('reads a map as an interface draws it: layers, glyphs, events and NPCs', function () { diff --git a/tests/Unit/Session/PhysicalOccupancyPaintingTest.php b/tests/Unit/Session/PhysicalOccupancyPaintingTest.php index 085c38d..90e4f87 100644 --- a/tests/Unit/Session/PhysicalOccupancyPaintingTest.php +++ b/tests/Unit/Session/PhysicalOccupancyPaintingTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Ichiloto\Editor\Canvas\PhysicalOccupancyEditor; -use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Session\EditorSession; use Ichiloto\Editor\Session\SessionHost; @@ -283,7 +282,8 @@ function createPhysicalOccupancyRpc(string $root): Closure $hashes = sourceHashTree($root); expect(fn() => $session->paintOccupancy('test-map', $before['revision'], [[1, 0]], 1))->toThrow(SessionRefusal::class) ->and(fn() => $session->getOccupancyFillRegion('test-map', 0, 0, $before['revision']))->toThrow(SessionRefusal::class) - ->and(fn() => $session->undo())->toThrow(MapSourceRefusal::class) + // The session reports a map source refusal as its own, as every session refusal reaches an interface. + ->and(fn() => $session->undo())->toThrow(SessionRefusal::class) ->and($session->readMap('test-map'))->toBe($before) ->and(sourceHashTree($root))->toBe($hashes); file_put_contents($path, $source); From 6af1acbe287b0d6df5a4886f7a52a412316634ac Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 10 Oct 2026 22:40:09 +0200 Subject: [PATCH 245/344] feat(editor): give the canvas preview its revision, layer ids and artwork worldObjects.preview now returns the map revision it describes, each glyph layer's presentation layer id, and every asset path the composition depends on: the tileset's sheets and the selected world-object art. This lets the GUI draw the whole map from this composition, refuse a reply for an older revision, and redraw when a file is replaced, removed or restored. The layer-id mapping moves from EditorSession::readWorld into WorldObjectPreview::getLayerIds, and both use it. --- src/Field/WorldObjectPreview.php | 24 +++++++- src/Session/EditorSession.php | 12 ++-- .../Unit/Session/WorldObjectAuthoringTest.php | 61 +++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/src/Field/WorldObjectPreview.php b/src/Field/WorldObjectPreview.php index 0885e11..bab6050 100644 --- a/src/Field/WorldObjectPreview.php +++ b/src/Field/WorldObjectPreview.php @@ -7,13 +7,32 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Engine\Rendering\Presentation\PresentationSprite; use Ichiloto\Engine\Rendering\Presentation\PresentationWorld; +use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; +use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Engine\Rendering\Sprites\FieldSpriteRole; use Ichiloto\Engine\Rendering\FieldViewport; +use Ichiloto\Engine\Animations\Field\FieldPoseAnimation; use InvalidArgumentException; /** Explicit authoring variant/time selection, without writes or fabricated game state. */ final class WorldObjectPreview { + /** + * Each glyph layer's presentation layer id, keyed by the editor's layer id, so an interface can name the layers of + * the composed world as it names the map's own. + * + * @return array + */ + public static function getLayerIds(ProjectMap $map): array + { + $glyphLayers = array_values(array_filter($map->getLayers(), static fn(array $layer): bool => $layer['id'] !== MapLayers::EVENT)); + $ids = []; + foreach ($map->getLayerSet()->layers as $index => $layer) { + $ids[(string) $glyphLayers[$index]['id']] = PresentationLayerPolicy::getMapLayerId($layer); + } + return $ids; + } + public static function describeWorld(ProjectMap $map, array $variants = [], float $seconds = 0): array { if (!is_finite($seconds) || $seconds < 0 || $seconds > 3600) { throw new InvalidArgumentException('Preview time requires finite seconds in 0..3600.'); } @@ -25,6 +44,7 @@ public static function describeWorld(ProjectMap $map, array $variants = [], floa if (array_diff(array_keys($variants), array_column($definitions, 'id')) !== []) { throw new InvalidArgumentException('Preview selection names an unknown map-local object.'); } if (array_any($variants, static fn($id): bool => !is_string($id))) { throw new InvalidArgumentException('Preview variants must name known variant ids.'); } $coverage = $sprites = $diagnostics = []; + $assetPaths = array_values($graphics?->tileset->sheets ?? []); foreach ($definitions as $object) { $selected = $variants[$object->id] ?? ''; $definition = $object->sprites; @@ -34,6 +54,7 @@ public static function describeWorld(ProjectMap $map, array $variants = [], floa $definition = $variant['sprites']; } $role = $definition === null ? null : new FieldSpriteRole($definition, $map->getAssetRoot(), 'World-object authoring ' . $object->id); + if ($definition !== null) { $assetPaths[] = $definition instanceof FieldPoseAnimation ? $definition->image->asset : $definition->asset; } try { $role?->advance($seconds); $frame = $role?->getFrame($object->pivot); @@ -57,7 +78,8 @@ public static function describeWorld(ProjectMap $map, array $variants = [], floa } $columns = min(512, $map->getWidth()); $rows = min(256, $map->getHeight()); - return ['map' => $map->mapId, 'assetRoot' => $map->getAssetRoot(), + return ['map' => $map->mapId, 'assetRoot' => $map->getAssetRoot(), 'layerIds' => self::getLayerIds($map), + 'assetPaths' => array_values(array_unique($assetPaths)), 'grid' => ['columns' => $columns, 'rows' => $rows, 'cellWidth' => FieldViewport::TILE_SIZE, 'cellHeight' => FieldViewport::TILE_SIZE], 'update' => ['frame' => 1, 'baseGeneration' => 0, 'generation' => 1, 'reset' => true, 'present' => true, diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 04eb591..afc5cc3 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -89,7 +89,6 @@ use Ichiloto\Editor\Validation\MapValidator; use Ichiloto\Engine\IO\Console\TerminalPresentationComposer; use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; -use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; use Ichiloto\Engine\Rendering\Sprites\CharacterSheet; use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; use Ichiloto\Engine\Rendering\Presentation\PresentationWorld; @@ -622,11 +621,7 @@ public function readWorld(string $mapId, bool $tileShadows = false): array } catch (InvalidArgumentException|MapSourceRefusal $error) { throw new SessionRefusal(sprintf('%s cannot be drawn: %s', $mapId, $error->getMessage()), previous: $error); } - $glyphLayers = array_values(array_filter($map->getLayers(), static fn(array $layer): bool => $layer['id'] !== MapLayers::EVENT)); - $layerIds = []; - foreach ($layerSet->layers as $index => $layer) { - $layerIds[(string) $glyphLayers[$index]['id']] = PresentationLayerPolicy::getMapLayerId($layer); - } + $layerIds = WorldObjectPreview::getLayerIds($map); return [ 'map' => $map->mapId, @@ -3943,7 +3938,10 @@ public function moveWorldObjectVariant(string $mapId, int $revision, string $id, public function readWorldObjectPreview(string $mapId, array $variants = [], float $seconds = 0): array { - try { return WorldObjectPreview::describeWorld($this->requireMap($mapId), $variants, $seconds); } + try { + $map = $this->requireMap($mapId); + return [...WorldObjectPreview::describeWorld($map, $variants, $seconds), 'revision' => $this->getMapRevision($map)]; + } catch (InvalidArgumentException|MapSourceRefusal $error) { throw new SessionRefusal($error->getMessage(), previous: $error); } } diff --git a/tests/Unit/Session/WorldObjectAuthoringTest.php b/tests/Unit/Session/WorldObjectAuthoringTest.php index ea3247b..a583330 100644 --- a/tests/Unit/Session/WorldObjectAuthoringTest.php +++ b/tests/Unit/Session/WorldObjectAuthoringTest.php @@ -127,6 +127,67 @@ function applyWorldObjectRow(EditorSession $session, string $field, string $valu expect($session->readWorldObjectPreview('test-map')['diagnostics'])->toBe([]); }); +it('exports canvas owner revision and layer identities with transient variants and current asset paths', function () { + [$root, $file, $session] = createWorldObjectProject(); + applyWorldObjectRow($session, 'sprites2d.asset', 'Graphics/Props/Replaceable.png'); + applyWorldObjectRow($session, 'covers.layer', 'terrain'); + applyWorldObjectRow($session, 'covers.tileLayers', 'floor'); + applyWorldObjectRow($session, 'sprites2d.layer', '950'); + $view = $session->readWorldObject('test-map', 'fixture-prop'); + $heading = array_find($view['rows'], static fn($row): bool => ($row['key']['field'] ?? '') === 'variantList'); + $session->changeWorldObjectItem('test-map', $view['revision'], 'fixture-prop', $heading['key'], false); + applyWorldObjectRow($session, 'variant0Id', 'absent'); + applyWorldObjectRow($session, 'variant0Conditions', 'switch:existing-condition'); + $before = $session->readMap('test-map'); + $source = file_get_contents($file); + $base = $session->readWorldObjectPreview('test-map'); + $absent = $session->readWorldObjectPreview('test-map', ['fixture-prop' => 'absent'], .25); + $hasFloor = static function (array $preview): bool { + foreach ($preview['update']['operations'] as $op) { + if (($op['op'] ?? '') !== 'worldTiles' || $op['layerId'] !== 'tiles:floor') { continue; } + foreach ($op['rows'] as $row) { + if ($row['row'] === 1 && array_any($row['cells'], static fn(array $cell): bool => $cell['column'] === 1)) { return true; } + } + } + return false; + }; + $sprite = array_find($base['update']['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'); + expect($base['revision'])->toBe($before['revision']) + ->and($base['layerIds'])->toBe($session->readWorld('test-map')['layerIds']) + ->and($base['assetPaths'])->toContain('Graphics/Props/Replaceable.png') + ->and($sprite['value']['id'])->toBe('world-object:test-map:fixture-prop') + ->and($sprite['value']['layer'])->toBe(950) + ->and($sprite['value']['pivot'])->toBe(['x' => .5, 'y' => 1.0]) + ->and($hasFloor($base))->toBeFalse() + ->and($hasFloor($absent))->toBeFalse() + ->and(array_filter($absent['update']['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'))->toBe([]) + ->and($session->readMap('test-map'))->toBe($before) + ->and(file_get_contents($file))->toBe($source); + unlink($root . '/assets/Graphics/Props/Replaceable.png'); + $missing = $session->readWorldObjectPreview('test-map'); + expect($missing['assetPaths'])->toContain('Graphics/Props/Replaceable.png') + ->and($missing['diagnostics'])->toHaveCount(1)->and($hasFloor($missing))->toBeTrue(); + writeTilesetTestPng($root . '/assets/Graphics/Props/Replaceable.png', 24, 32); + $session->saveMap('test-map'); + $reopened = EditorSession::open($root); + expect($reopened->readWorldObjectPreview('test-map')['diagnostics'])->toBe([]) + ->and($reopened->readMap('test-map')['worldObjects'][0]['sprites2d']['asset'])->toBe('Graphics/Props/Replaceable.png') + ->and($reopened->readMap('test-map')['worldObjects'][0]['anchor'])->toBe($before['worldObjects'][0]['anchor']); +}); + +it('keeps unavailable sheet dependencies visible to the transient canvas asset watch', function () { + [$root, , $session] = createWorldObjectProject(); + $path = 'Graphics/Tilesets/Home_A2.png'; + unlink($root . '/assets/' . $path); + $missing = $session->readWorldObjectPreview('test-map'); + expect($missing['assetPaths'])->toContain($path) + ->and(array_filter($missing['update']['operations'], static fn(array $op): bool => ($op['layerId'] ?? '') === 'tiles:floor'))->toBe([]); + writeTilesetTestPng($root . '/assets/' . $path, 32, 24); + $restored = $session->readWorldObjectPreview('test-map'); + expect($restored['assetPaths'])->toContain($path) + ->and(array_filter($restored['update']['operations'], static fn(array $op): bool => ($op['layerId'] ?? '') === 'tiles:floor'))->not->toBe([]); +}); + it('routes real RPC ownership and cinematic object selectors without a global object database', function () { [$root, , $session] = createWorldObjectProject(); $host = new SessionHost(fopen('php://memory', 'r+'), fopen('php://memory', 'r+'), fopen('php://memory', 'r+')); From 15a4b6e4710bec60d5ff4a4ec9e855d2332d0a1c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 00:10:04 +0200 Subject: [PATCH 246/344] feat(editor): place named field resources as world objects A map's world object can name a field resource ({id, resource, anchor, covers}) instead of carrying its own pivot and art. The Editor validates placements with the project's field catalogue, read only when a map names a resource, as the Engine reads it. It previews them with the resource's art, and watches that art. A placement's inspector offers a Field Resource picker in place of the pivot, art and variants, which the resource owns. A resource the catalogue does not name is refused before anything is written. The new field_resources reference lists each resource as "Name (id)". --- src/Database/ReferenceCatalog.php | 30 +++++++ src/Field/WorldObjectAuthoring.php | 29 +++++-- .../Unit/Session/WorldObjectResourceTest.php | 86 +++++++++++++++++++ 3 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/Session/WorldObjectResourceTest.php diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 2d881bf..4f902eb 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -12,6 +12,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Animations\Field\FieldPresentationCatalog; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Battle\CounterAttackRule; use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; @@ -99,6 +100,7 @@ final class ReferenceCatalog 'tilesets', 'effects', 'stage_timelines', + 'field_resources', 'map_regions', 'animation_roles', 'battle_arenas', @@ -291,6 +293,7 @@ public function valuesFor(string $category): array // Effect timelines are folders the Engine lists by stable id. 'effects' => new EffectTimelineLibrary($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets')->findTimelineIds(), 'stage_timelines' => $this->stageTimelineIds(), + 'field_resources' => array_map(strval(...), array_keys($this->loadFieldResourceNames())), default => $this->recordValues($category), }; } @@ -303,6 +306,24 @@ public function valuesFor(string $category): array * * @return list */ + /** + * The project's named field resources (assets/Data/Presentation/field.php), by stable id, with their display + * names. A catalogue that cannot be read offers none; project validation reports why. + * + * @return array + */ + private function loadFieldResourceNames(): array + { + try { + $catalog = ProjectDirectoryContext::run($this->workspace->projectRoot, + fn(): FieldPresentationCatalog => FieldPresentationCatalog::load($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets')); + } catch (Throwable) { + return []; + } + + return array_map(static fn($resource): string => $resource->name, $catalog->resources); + } + private function stageTimelineIds(): array { $library = new EffectTimelineLibrary($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); @@ -639,6 +660,15 @@ public function labelsFor(string $category): array return $this->loadArenaNames(); } + if ($category === 'field_resources') { + $labels = []; + foreach ($this->loadFieldResourceNames() as $id => $name) { + $labels[(string) $id] = sprintf('%s (%s)', $name, $id); + } + + return $labels; + } + if ($category === 'animation_ids') { $labels = []; foreach ($this->animationRecords() as $animation) { diff --git a/src/Field/WorldObjectAuthoring.php b/src/Field/WorldObjectAuthoring.php index 4e59404..37ea0ed 100644 --- a/src/Field/WorldObjectAuthoring.php +++ b/src/Field/WorldObjectAuthoring.php @@ -19,6 +19,7 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; use Ichiloto\Editor\Cutscenes\Source\SourceNode; +use Ichiloto\Engine\Animations\Field\FieldPresentationCatalog; use Ichiloto\Engine\Field\WorldObjectDefinition; use InvalidArgumentException; @@ -42,6 +43,8 @@ public static function validateEntries(ProjectMap $map, array $entries, bool $co if (!$complete) { // Empty conditions are an unfinished authoring draft, never a runtime default. foreach ($entries as &$entry) { + // A placement of a named resource has no variants of its own to finish. + if (array_key_exists('resource', $entry)) { continue; } $variants = $entry['variants'] ?? []; if (!is_array($variants) || !array_is_list($variants) || count($variants) > WorldObjectDefinition::MAX_VARIANTS) { throw new InvalidArgumentException('World-object variants exceed the Engine list contract.'); @@ -63,7 +66,18 @@ public static function validateEntries(ProjectMap $map, array $entries, bool $co } unset($entry); } - return WorldObjectDefinition::readMap([WorldObjectDefinition::DATA_KEY => $entries], $map->getLayerSet(), tileOwners: $owners); + return WorldObjectDefinition::readMap([WorldObjectDefinition::DATA_KEY => $entries], $map->getLayerSet(), tileOwners: $owners, + catalog: self::loadCatalog($map, $entries)); + } + + /** + * The project's named field resources, read as the Engine reads them when a map names one: only then, so a map + * of inline objects never depends on the catalogue. + */ + private static function loadCatalog(ProjectMap $map, array $entries): ?FieldPresentationCatalog + { + return array_any($entries, static fn($entry): bool => is_array($entry) && array_key_exists('resource', $entry)) + ? FieldPresentationCatalog::load($map->getAssetRoot()) : null; } public function getSourceIssue(): ?string @@ -101,12 +115,16 @@ public function getRecords(): ProjectRecordDatabase $schema = new RecordSchema(key: 'world_objects', entryNoun: 'world object', storage: RecordStorage::MAP_OWNED, relativePath: '', fields: [], labelKey: 'id', fieldsFor: function (array $entry): array { + // A placement of a named resource takes its art and pivot from the resource; it chooses which one. + $presentation = array_key_exists('resource', $entry) + ? [new RecordField('resource', 'Field Resource', reference: 'field_resources')] + : [new RecordField('pivot.x', 'Image Pivot X (0..1)', InputControlType::FLOAT), + new RecordField('pivot.y', 'Image Pivot Y (0..1)', InputControlType::FLOAT), + ...FieldSpriteFields::getFields($entry, true)]; $fields = [new RecordField('id', 'Stable Map-local Id', isReadOnly: true), new RecordField('anchor.x', 'Anchor X', InputControlType::INTEGER), new RecordField('anchor.y', 'Anchor Y', InputControlType::INTEGER), - new RecordField('pivot.x', 'Image Pivot X (0..1)', InputControlType::FLOAT), - new RecordField('pivot.y', 'Image Pivot Y (0..1)', InputControlType::FLOAT), - ...FieldSpriteFields::getFields($entry, true), + ...$presentation, new RecordField('covers.layer', 'Covered Gameplay Layer', options: ['', ...array_column(array_filter($this->map->getLayers(), static fn($layer): bool => !$layer['decoration'] && $layer['id'] !== \Ichiloto\Editor\Maps\MapLayers::EVENT), 'name')], removeWhenEmpty: true, displayDefault: '(no coverage)')]; @@ -117,7 +135,8 @@ public function getRecords(): ProjectRecordDatabase return $fields; }, subLists: [$variants, $cells], - subListsFor: static fn(array $entry): array => isset($entry['covers']) ? ['variants', 'covers.cells'] : ['variants'], + subListsFor: static fn(array $entry): array => [...(array_key_exists('resource', $entry) ? [] : ['variants']), + ...(isset($entry['covers']) ? ['covers.cells'] : [])], prepareEdit: static function (array $entry, string $field): array { if ($field === 'covers.layer') { if (!isset($entry['covers']['layer'])) { unset($entry['covers']); } diff --git a/tests/Unit/Session/WorldObjectResourceTest.php b/tests/Unit/Session/WorldObjectResourceTest.php new file mode 100644 index 0000000..67294c5 --- /dev/null +++ b/tests/Unit/Session/WorldObjectResourceTest.php @@ -0,0 +1,86 @@ + [\n" + . " 'broadleaf' => ['name' => 'Broadleaf', 'pivot' => ['x' => 0.5, 'y' => 0.9], 'sprites2d' => ['asset' => 'Graphics/Props/Broadleaf.png']],\n" + . " 'conifer' => ['name' => 'Conifer', 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => ['asset' => 'Graphics/Props/Conifer.png']],\n" + . "]];\n"); + $file = $root . '/assets/Maps/test-map/test-map.data.php'; + $source = str_replace("'events' => [],", "'events' => [], 'worldObjects' => [ + ['id' => 'grove-001', 'resource' => 'broadleaf', 'anchor' => ['x' => 1, 'y' => 1]], + ], 'preserved' => abs(7),", (string) file_get_contents($file)); + file_put_contents($file, $source); + + return [$root, $file, EditorSession::open($root)]; +} + +/** @return array|null The placement's row for a field. */ +function findPlacementRow(EditorSession $session, string $field): ?array +{ + return array_find($session->readWorldObject('test-map', 'grove-001')['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === $field); +} + +it('previews a placed resource with the resource\'s own art, and watches that art', function () { + [, , $session] = createResourcePlacementProject(); + $preview = $session->readWorldObjectPreview('test-map'); + $sprite = array_find($preview['update']['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'); + + expect($preview['diagnostics'])->toBe([]) + ->and($sprite['value']['id'])->toBe('world-object:test-map:grove-001') + ->and($sprite['value']['pivot'])->toBe(['x' => .5, 'y' => .9]) + ->and($preview['assetPaths'])->toContain('Graphics/Props/Broadleaf.png'); +}); + +it('offers a placement the resource to choose, never the art and pivot the resource owns', function () { + [$root, , $session] = createResourcePlacementProject(); + $catalog = new ReferenceCatalog(ProjectWorkspace::fromProject($root)); + + expect(findPlacementRow($session, 'resource'))->toMatchArray(['reference' => 'field_resources']) + ->and(findPlacementRow($session, 'pivot.x'))->toBeNull() + ->and(findPlacementRow($session, 'sprites2d.asset'))->toBeNull() + ->and($catalog->valuesFor('field_resources'))->toBe(['broadleaf', 'conifer']) + ->and($catalog->labelsFor('field_resources'))->toBe(['broadleaf' => 'Broadleaf (broadleaf)', 'conifer' => 'Conifer (conifer)']); +}); + +it('changes a placement\'s resource as one undoable step that keeps the rest of the source as authored', function () { + [$root, $file, $session] = createResourcePlacementProject(); + $row = findPlacementRow($session, 'resource'); + $session->applyWorldObject('test-map', $session->readWorldObject('test-map', 'grove-001')['revision'], 'grove-001', $row['key'], 'conifer'); + + expect($session->readMap('test-map')['worldObjects'][0])->toBe(['id' => 'grove-001', 'resource' => 'conifer', 'anchor' => ['x' => 1, 'y' => 1]]); + $session->saveMap('test-map'); + expect((string) file_get_contents($file))->toContain("'resource' => 'conifer'")->toContain("'preserved' => abs(7)"); + $session->undo(); + expect($session->readMap('test-map')['worldObjects'][0]['resource'])->toBe('broadleaf') + ->and(EditorSession::open($root)->readMap('test-map')['worldObjects'][0]['resource'])->toBe('conifer'); +}); + +it('refuses a resource the catalogue does not name, before writing anything', function () { + [, $file, $session] = createResourcePlacementProject(); + $before = (string) file_get_contents($file); + $map = $session->readMap('test-map'); + $row = findPlacementRow($session, 'resource'); + + expect(fn() => $session->applyWorldObject('test-map', $session->readWorldObject('test-map', 'grove-001')['revision'], 'grove-001', $row['key'], 'birch')) + ->toThrow(SessionRefusal::class) + ->and($session->readMap('test-map'))->toBe($map) + ->and((string) file_get_contents($file))->toBe($before); +}); From 3e148a5c01392dcd9698e7beac1e488d0f0ef518 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 00:10:04 +0200 Subject: [PATCH 247/344] feat(database): author field resources in the graphical editor Field Resources is a graphical Database category over the resources dictionary of assets/Data/Presentation/field.php. Each record is one resource keyed by its stable id: name, ground contact, image or sheet, and an optional occupancy stamp of relative cells with typed physical cases. Only that dictionary is written; the catalogue's cues and action prompt are kept as authored. The Engine's own catalogue admission refuses a save before anything is written, and an invalid id is refused as it is typed. A new resource starts on the project's first image. KeyedDictionaryProjection edits one dictionary inside a shared file, keyed by each record's identity. makeBlank may now build an array blank for any data category, whether one file per record or a projection-backed row. --- docs/manual.md | 14 ++ src/Database/DatabaseCatalog.php | 4 +- src/Database/ProjectRecordDatabase.php | 6 +- .../Projections/KeyedDictionaryProjection.php | 103 +++++++++++++++ src/Database/RecordSchemaCatalog.php | 2 +- src/Field/FieldResourceFields.php | 120 ++++++++++++++++++ .../Session/DatabaseCoverageSessionTest.php | 2 + .../Session/FieldResourceAuthoringTest.php | 108 ++++++++++++++++ 8 files changed, 355 insertions(+), 4 deletions(-) create mode 100644 src/Database/Projections/KeyedDictionaryProjection.php create mode 100644 src/Field/FieldResourceFields.php create mode 100644 tests/Unit/Session/FieldResourceAuthoringTest.php diff --git a/docs/manual.md b/docs/manual.md index 29a8513..9ac745b 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1113,6 +1113,7 @@ skill's effects, a quest's rewards) listed under them. | Types | `assets/Data/system.php` | Editable - the project's elements, one per row; an empty list means the Engine's defaults. Weapon, armor and equipment types are the Engine's own | Same | | Terms | `config.php` (`vocab`, `messages`) | Literal terms editable; comments and unrelated expressions preserved | Same | | Player Field Appearance | `assets/Data/Entities/player.php` | Not shown: graphical ownership and art | Choose fixed-player, selected party leader, or absent legacy selector; fixed sheet picker with index/layer controls | +| Field Resources | `assets/Data/Presentation/field.php` (`resources`) | Not shown: graphical resources | One record per named resource, keyed by its stable id: name, ground contact, image or sheet, and an optional occupancy stamp | | Actor Battle Art, Enemy Battle Art, Battle Scale | `assets/Data/Presentation/battlers.php` | Not shown: graphical battle art | Editable from the actor's or enemy's own page (see Battle Art) | Player Field Appearance edits only `graphicalSubject` and `sprites2d`, preserving @@ -1125,6 +1126,19 @@ and save/reopen restore its settings. Unsupported expressions and external source conflicts are refused before overwriting files. These controls have automated coverage; native visual inspection remains pending. +Field Resources are reusable whole images, such as a tree, that a map places by +reference. A resource owns its art and its ground contact (the pivot, such as a +trunk base) and may carry an occupancy stamp: cells relative to a placement's +anchor and the physical type a brush gives each when it places the resource. +The stamp is applied only by an explicit authoring placement; a resource never +adds collision to a map by itself, and changing a stamp never changes cells an +earlier placement stamped. Only the `resources` dictionary is edited; the +catalogue's cues and action prompt are kept exactly as written. A new resource +starts on the project's first image for the author to replace, an id is refused +as it is typed if the Engine would refuse it, and a save the Engine would refuse +writes nothing. A map's world object that names a resource shows a Field +Resource picker in place of its own pivot and art, which the resource owns. + Why a category can still turn out read-only: a file the editor cannot evaluate, a value it could not write back out, or a comment inside returned data that holds objects or is not an array literal are each a reason, and the status line names it. You can diff --git a/src/Database/DatabaseCatalog.php b/src/Database/DatabaseCatalog.php index bdb9f31..45fbf27 100644 --- a/src/Database/DatabaseCatalog.php +++ b/src/Database/DatabaseCatalog.php @@ -53,7 +53,9 @@ public static function all(): array public static function getGraphicalCategories(): array { return [...self::all(), new DatabaseCategoryDefinition(PlayerPresentationFields::CATEGORY, - 'Player Field Appearance', 'Choose fixed player art or the selected party leader\'s field role.', true)]; + 'Player Field Appearance', 'Choose fixed player art or the selected party leader\'s field role.', true), + new DatabaseCategoryDefinition(\Ichiloto\Editor\Field\FieldResourceFields::CATEGORY, + 'Field Resources', 'Reusable whole images, such as trees, that maps place by reference.', true)]; } /** diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 1634548..654036d 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -1142,8 +1142,10 @@ public function requireNewRecord(?string $identity = null): int throw new RecordRefusal(sprintf('A new %s could not be made: %s', $this->schema->entryNoun, $failure->getMessage()), previous: $failure); } - // A data record's blank is its data; a constructor list's, the object. - if ($this->schema->recordClass !== null ? ! is_array($payload) : ! is_object($payload)) { + // A data record's blank is its data, whether it has a file of its own or is a row of a projected file; + // a constructor list's, the object. + $dataRecords = $this->schema->recordClass !== null || $this->schema->projection !== null; + if ($dataRecords ? ! is_array($payload) : ! is_object($payload)) { throw new RecordRefusal($cannot); } } diff --git a/src/Database/Projections/KeyedDictionaryProjection.php b/src/Database/Projections/KeyedDictionaryProjection.php new file mode 100644 index 0000000..65453ae --- /dev/null +++ b/src/Database/Projections/KeyedDictionaryProjection.php @@ -0,0 +1,103 @@ +key] ?? null; + if (! is_array($dictionary)) { + return []; + } + + $rows = []; + foreach ($dictionary as $id => $entry) { + $rows[] = [$this->identityKey => (string) $id, ...(is_array($entry) ? $entry : [])]; + } + + return $rows; + } + + /** + * @inheritDoc + */ + public function write(array $whole, array $rows): array + { + $dictionary = []; + foreach ($rows as $row) { + $id = (string) ($row[$this->identityKey] ?? ''); + unset($row[$this->identityKey]); + $dictionary[$id] = $row; + } + if ($dictionary === [] && ! array_key_exists($this->key, $whole)) { + return $whole; + } + $whole[$this->key] = $dictionary; + + return $whole; + } + + /** + * Explains why rewriting this projection would change authored shape. + */ + public function preservationIssue(mixed $whole): ?string + { + if (! is_array($whole)) { + return sprintf('returns %s, not an array', get_debug_type($whole)); + } + + if (! array_key_exists($this->key, $whole)) { + return null; + } + + $dictionary = $whole[$this->key]; + if (! is_array($dictionary) || ($dictionary !== [] && array_is_list($dictionary))) { + return sprintf('field "%s" is %s, not a dictionary keyed by id', $this->key, is_array($dictionary) ? 'an ordered list' : get_debug_type($dictionary)); + } + + foreach ($dictionary as $id => $entry) { + if (! is_string($id) || ! is_array($entry) || array_key_exists($this->identityKey, $entry)) { + return sprintf('field "%s" entry "%s" is not a record the editor can preserve under its key', $this->key, $id); + } + } + + return null; + } + + /** + * @inheritDoc + * + * The dictionary is written in record order, so record order is the file's. + */ + public function ordersRecords(): bool + { + return true; + } +} diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index d3ef9d6..0a9e1fe 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -132,7 +132,7 @@ public static function all(bool $graphical = false): array self::battlerArt('actors'), self::battlerArt('enemies'), self::battleScaleReference(), - ...($graphical ? [PlayerPresentationFields::getSchema()] : []), + ...($graphical ? [PlayerPresentationFields::getSchema(), \Ichiloto\Editor\Field\FieldResourceFields::getSchema()] : []), ]; $keyed = []; diff --git a/src/Field/FieldResourceFields.php b/src/Field/FieldResourceFields.php new file mode 100644 index 0000000..436cc86 --- /dev/null +++ b/src/Field/FieldResourceFields.php @@ -0,0 +1,120 @@ + preg_match('/^[a-z]/', $id) === 1 ? $id : 'resource-' . $id, 'name' => $name, + 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => ['asset' => $images[0]]]; + } + + /** @return list */ + public static function getFields(array $entry): array + { + return [ + new RecordField('id', 'Stable Id'), + new RecordField('name', 'Name'), + new RecordField('pivot.x', 'Ground Contact X (0..1)', InputControlType::FLOAT), + new RecordField('pivot.y', 'Ground Contact Y (0..1)', InputControlType::FLOAT), + ...FieldSpriteFields::getFields($entry), + ]; + } + + /** + * The optional authoring stamp: cells relative to a placement's anchor and the physical type each gets when a + * brush places the resource. Placing never infers collision from the art. + */ + private static function getStampList(): RecordSubList + { + $types = array_values(array_filter(CollisionType::cases(), static fn(CollisionType $type): bool => $type !== CollisionType::PASS_THROUGH)); + + return new RecordSubList(key: 'occupancyStamp', prefix: 'stamp', singular: 'stamped cell', + fields: [new RecordField('x', 'Offset X', InputControlType::INTEGER), new RecordField('y', 'Offset Y', InputControlType::INTEGER), + new RecordField('type', 'Physical Type', options: array_map(static fn(CollisionType $type): string => $type->name, $types), + enumClass: CollisionType::class)], + blank: ['x' => 0, 'y' => 0, 'type' => CollisionType::SOLID], heading: 'Occupancy Stamp', removeWhenEmpty: true); + } + + /** A stable id is refused as it is typed, by the Engine's own rule, rather than at the save. */ + public static function checkEdit(array $entry, string $field): array + { + if ($field === 'id') { + try { + WorldObjectResource::assertId($entry['id'] ?? null); + } catch (Throwable $error) { + throw new RecordRefusal($error->getMessage(), previous: $error); + } + } + + return $entry; + } + + /** The Engine's own admission of the whole catalogue, so a save never writes a file the game refuses. */ + public static function checkCatalog(array $whole, string $projectRoot): ?string + { + try { + FieldPresentationCatalog::fromArray($whole, $projectRoot . '/assets'); + } catch (Throwable $error) { + return $error->getMessage(); + } + + return null; + } +} diff --git a/tests/Unit/Session/DatabaseCoverageSessionTest.php b/tests/Unit/Session/DatabaseCoverageSessionTest.php index 2e8e386..9b8f684 100644 --- a/tests/Unit/Session/DatabaseCoverageSessionTest.php +++ b/tests/Unit/Session/DatabaseCoverageSessionTest.php @@ -71,6 +71,8 @@ function editRecordOnce(EditorSession $session, string $category, int $index): b // A new enemy starts on the project's first enemy sprite. mkdir($root . '/assets/Graphics/Enemies', 0o777, true); file_put_contents($root . '/assets/Graphics/Enemies/blob.txt', "(oo)\n"); + // A new field resource starts on the project's first image. + writeTilesetTestPng($root . '/assets/Graphics/Props/Coverage.png', 4, 4); $session = EditorSession::open($root); $writable = []; diff --git a/tests/Unit/Session/FieldResourceAuthoringTest.php b/tests/Unit/Session/FieldResourceAuthoringTest.php new file mode 100644 index 0000000..2a2b1a0 --- /dev/null +++ b/tests/Unit/Session/FieldResourceAuthoringTest.php @@ -0,0 +1,108 @@ + [], + 'resources' => [ + 'oak' => [ + 'name' => 'Oak', + 'pivot' => ['x' => 0.5, 'y' => 0.9], + 'sprites2d' => ['asset' => 'Graphics/Props/Oak.png'], + 'occupancyStamp' => [['x' => 0, 'y' => 0, 'type' => \Ichiloto\Engine\Events\Enumerations\CollisionType::SOLID]], + ], + ], +]; +PHP_SOURCE); + + return [$root, $path, EditorSession::open($root)]; +} + +function findResourceRow(EditorSession $session, int $index, string $field): ?array +{ + return array_find($session->readDatabaseRecord(FieldResourceFields::CATEGORY, $index)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === $field); +} + +function applyResourceRow(EditorSession $session, int $index, string $field, string $value): array +{ + $row = findResourceRow($session, $index, $field) ?? throw new RuntimeException('Missing row ' . $field); + + return $session->applyDatabaseRecord(FieldResourceFields::CATEGORY, $index, $row['key'], $value); +} + +it('offers field resources only to the graphical editor', function () { + [, , $session] = createFieldResourceProject(); + + expect(array_column(DatabaseCatalog::all(), 'key'))->not->toContain(FieldResourceFields::CATEGORY) + ->and(array_column($session->describeProject()['databases'], 'key'))->toContain(FieldResourceFields::CATEGORY) + ->and($session->listDatabaseRecords(FieldResourceFields::CATEGORY)['records'])->toBe(['Oak']); +}); + +it('reads a resource with its key as its id, its art, ground contact and stamp', function () { + [, , $session] = createFieldResourceProject(); + + expect(findResourceRow($session, 0, 'id')['value'])->toBe('oak') + ->and(findResourceRow($session, 0, 'pivot.y')['value'])->toBe('0.9') + ->and(findResourceRow($session, 0, 'sprites2d.asset')['value'])->toBe('Graphics/Props/Oak.png') + ->and(findResourceRow($session, 0, 'stamp0Type')['value'] ?? null)->toBe('SOLID'); +}); + +it('adds and edits a resource, saving the catalogue the Engine admits and keeping what it does not own', function () { + [$root, $path, $session] = createFieldResourceProject(); + $session->createDatabaseRecord(FieldResourceFields::CATEGORY); + $index = count($session->listDatabaseRecords(FieldResourceFields::CATEGORY)['records']) - 1; + applyResourceRow($session, $index, 'id', 'pine'); + applyResourceRow($session, $index, 'name', 'Pine'); + applyResourceRow($session, $index, 'sprites2d.asset', 'Graphics/Props/Pine.png'); + $session->saveDatabase(FieldResourceFields::CATEGORY); + + $catalog = FieldPresentationCatalog::load($root . '/assets'); + expect(array_keys($catalog->resources))->toBe(['oak', 'pine']) + ->and($catalog->getResource('pine')->name)->toBe('Pine') + ->and($catalog->getResource('oak')->occupancyStamp)->toBe([['x' => 0, 'y' => 0, 'type' => CollisionType::SOLID]]) + ->and((string) file_get_contents($path))->toContain('// Authored cue bindings stay as written.') + ->and((require $path)['cues'])->toBe([]); +}); + +it('refuses an id the Engine would refuse as it is typed', function () { + [, , $session] = createFieldResourceProject(); + + expect(fn() => applyResourceRow($session, 0, 'id', 'Oak Tree'))->toThrow(SessionRefusal::class) + ->and(findResourceRow($session, 0, 'id')['value'])->toBe('oak'); +}); + +it('refuses to save a resource the Engine would refuse, writing nothing', function () { + [, $path, $session] = createFieldResourceProject(); + $before = (string) file_get_contents($path); + applyResourceRow($session, 0, 'pivot.x', '2'); + + expect(fn() => $session->saveDatabase(FieldResourceFields::CATEGORY))->toThrow(SessionRefusal::class) + ->and((string) file_get_contents($path))->toBe($before); +}); From e83bf6baa79d348da923904e204a7d71522d3d12 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 00:40:28 +0200 Subject: [PATCH 248/344] feat(playtest): start from the title, and report what a playtest can launch A playtest from the graphical session can now start at the game's title with the project's own starting position (PlaytestStart::TITLE), as well as on the open map at a cell. Its overlay keeps system.php unchanged and, like every playtest, the author's saves untouched. A title playtest is refused while any map is unsaved, since the opening may visit any of them. playtest.options reports the starts and the renderers this session can launch, the default first; for now only GPUI, since a graphical editor owns no terminal to hand over. playtest.start takes an optional start and renderer, and refuses a renderer it does not offer. describePlaytest names the start. --- src/Playtest/PlaytestOverlay.php | 9 +++ src/Playtest/PlaytestStart.php | 25 +++++++++ src/Session/EditorSession.php | 71 ++++++++++++++++++------ src/Session/SessionHost.php | 25 ++++++++- tests/Unit/Session/EditorSessionTest.php | 33 +++++++++++ 5 files changed, 146 insertions(+), 17 deletions(-) create mode 100644 src/Playtest/PlaytestStart.php diff --git a/src/Playtest/PlaytestOverlay.php b/src/Playtest/PlaytestOverlay.php index d7f72b4..74f7b4d 100644 --- a/src/Playtest/PlaytestOverlay.php +++ b/src/Playtest/PlaytestOverlay.php @@ -75,6 +75,15 @@ public static function create(string $projectRoot, string $mapId, int $spawnX, i return new self($root, $mapId, $spawnX, $spawnY); } + /** + * Builds an overlay that plays the game from its title with the project's own starting position: the opening + * as a player first meets it, still without touching the author's saves. + */ + public static function createForTitle(string $projectRoot): self + { + return new self(self::build($projectRoot, static fn(array $system): array => $system, 'a playtest from the title')); + } + /** * Builds an overlay for a battle test: the author's project with its * system data's battle test replaced by the one given (unsaved edits diff --git a/src/Playtest/PlaytestStart.php b/src/Playtest/PlaytestStart.php new file mode 100644 index 0000000..0d74b6e --- /dev/null +++ b/src/Playtest/PlaytestStart.php @@ -0,0 +1,25 @@ + 'From the selected cell', + self::TITLE => 'From the title', + }; + } +} diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index afc5cc3..1821c4c 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -73,6 +73,7 @@ use Ichiloto\Editor\Playtest\PlaytestLauncher; use Ichiloto\Editor\Playtest\PlaytestOverlay; use Ichiloto\Editor\Playtest\PlaytestRun; +use Ichiloto\Editor\Playtest\PlaytestStart; use Ichiloto\Editor\Maps\LayerEditor; use Ichiloto\Editor\Maps\LineInsertionPlanner; use Ichiloto\Editor\Maps\MapLayers; @@ -153,6 +154,8 @@ final class EditorSession /** The playtest running in the background, or the last one, to report how it ended. */ private ?PlaytestRun $playtest = null; + /** Where that playtest began. */ + private PlaytestStart $playtestStart = PlaytestStart::CELL; /** The battle test running in its own window, and the troop it fights. */ private ?PlaytestRun $battleTestRun = null; @@ -4321,34 +4324,54 @@ public function saveMap(string $mapId): array } /** - * Plays the game from a map and cell in the background, in its own - * graphical window, as the terminal editor's playtest does: from a - * temporary overlay of the project that writes nothing into it, with - * the author's player settings (volume and mute included) and no saves. - * The game reads the map from disk, so a map with unsaved changes is - * refused until it is saved. + * Plays the game in the background, in its own graphical window, as the terminal editor's playtest does: from a + * temporary overlay of the project that writes nothing into it, with the author's player settings (volume and + * mute included) and no saves. It starts on the open map at a cell, or at the game's title with the project's own + * starting position. The game reads maps from disk, so a playtest is refused while a map it plays from is unsaved: + * the start map, or, from the title, any map. * + * @param PlaytestStart $start Where the playtest begins; from the title the map and cell are not used. + * @param string|null $renderer One of {@see describePlaytestOptions()}'s renderers; null is its default. * @return array The playtest, as {@see describePlaytest()}. - * @throws SessionRefusal When the map is unknown or unsaved, a playtest is running, or it cannot start. + * @throws SessionRefusal When the map is unknown or unsaved, a playtest is running, the renderer cannot be + * launched from here, or it cannot start. */ - public function startPlaytest(string $mapId, int $x, int $y): array + public function startPlaytest(?string $mapId, ?int $x, ?int $y, PlaytestStart $start = PlaytestStart::CELL, ?string $renderer = null): array { - $map = $this->requireMap($mapId); if ($this->playtest?->isRunning() === true) { throw new SessionRefusal('A playtest is already running; stop it or close its window first.'); } - if ($map->isDirty()) { - throw new SessionRefusal(sprintf('Save %s before playtesting it; the game reads the map on disk.', $mapId)); + $options = $this->describePlaytestOptions(); + $renderer ??= $options['defaultRenderer']; + if (! in_array($renderer, $options['renderers'], true)) { + throw new SessionRefusal(sprintf('A playtest cannot use the %s renderer from here; choose one of %s.', $renderer, implode(', ', $options['renderers']))); } - if ($x < 0 || $y < 0 || $x >= $map->getWidth() || $y >= $map->getHeight()) { - throw new SessionRefusal(sprintf('%d, %d is outside %s.', $x, $y, $mapId)); + if ($start === PlaytestStart::CELL) { + if ($mapId === null || $x === null || $y === null) { + throw new SessionRefusal('Choose the cell to start on, or play from the title.'); + } + $map = $this->requireMap($mapId); + if ($map->isDirty()) { + throw new SessionRefusal(sprintf('Save %s before playtesting it; the game reads the map on disk.', $mapId)); + } + if ($x < 0 || $y < 0 || $x >= $map->getWidth() || $y >= $map->getHeight()) { + throw new SessionRefusal(sprintf('%d, %d is outside %s.', $x, $y, $mapId)); + } + } else { + $unsaved = array_keys(array_filter($this->getMapsById(), static fn(ProjectMap $map): bool => $map->isDirty())); + if ($unsaved !== []) { + throw new SessionRefusal(sprintf('Save %s before playing from the title; the game reads every map on disk.', implode(', ', $unsaved))); + } } $overlay = null; try { - $overlay = PlaytestOverlay::create($this->workspace->projectRoot, $mapId, $x, $y); + $overlay = $start === PlaytestStart::CELL + ? PlaytestOverlay::create($this->workspace->projectRoot, (string) $mapId, (int) $x, (int) $y) + : PlaytestOverlay::createForTitle($this->workspace->projectRoot); $launcher = PlaytestLauncher::discover(projectRoot: $this->workspace->projectRoot); $log = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('ichiloto-playtest-', true) . '.log'; - $this->playtest = $launcher->start($overlay, self::PLAYTEST_RENDERER, $log); + $this->playtest = $launcher->start($overlay, $renderer, $log); + $this->playtestStart = $start; } catch (RuntimeException $error) { $overlay?->destroy(); throw new SessionRefusal(sprintf('The playtest could not start: %s', $error->getMessage()), previous: $error); @@ -4357,11 +4380,26 @@ public function startPlaytest(string $mapId, int $x, int $y): array return $this->describePlaytest(); } + /** + * Where a playtest from this editor can begin, and the renderers it can launch, the first being the default. A + * graphical editor owns no terminal to hand over, so it launches only renderers that open a window of their own. + * + * @return array{starts: list, renderers: list, defaultRenderer: string} + */ + public function describePlaytestOptions(): array + { + return [ + 'starts' => array_map(static fn(PlaytestStart $start): string => $start->value, PlaytestStart::cases()), + 'renderers' => [self::PLAYTEST_RENDERER], + 'defaultRenderer' => self::PLAYTEST_RENDERER, + ]; + } + /** * Whether a playtest is running, where it started, and how the last one * ended: its exit code and, when it failed, the end of its output. * - * @return array{running: bool, map: ?string, x: ?int, y: ?int, stopped: bool, exitCode: ?int, log: ?string} + * @return array{running: bool, start: ?string, map: ?string, x: ?int, y: ?int, stopped: bool, exitCode: ?int, log: ?string} */ public function describePlaytest(): array { @@ -4371,6 +4409,7 @@ public function describePlaytest(): array return [ 'running' => $running, + 'start' => $run === null ? null : $this->playtestStart->value, 'map' => $run?->overlay->mapId, 'x' => $run?->overlay->spawnX, 'y' => $run?->overlay->spawnY, diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 9ce3297..e00af46 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -7,6 +7,7 @@ use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Editor\Console\ConsoleBinary; use Ichiloto\Editor\Console\ProjectCreator; +use Ichiloto\Editor\Playtest\PlaytestStart; use JsonException; use RuntimeException; use Throwable; @@ -565,7 +566,8 @@ private function dispatch(string $method, array $params): array 'history.redo' => $session->redo(), 'project.dirty' => ['dirty' => $session->hasUnsavedChanges(), 'unsaved' => $session->listUnsavedChanges()], 'project.saveAll' => $session->saveAll(), - 'playtest.start' => $session->startPlaytest(self::requireString($params, 'map'), self::requireInt($params, 'x'), self::requireInt($params, 'y')), + 'playtest.start' => self::startPlaytest($session, $params), + 'playtest.options' => $session->describePlaytestOptions(), 'playtest.status' => $session->describePlaytest(), 'playtest.stop' => $session->stopPlaytest(), 'battleTest.describe' => $session->describeBattleTest(match (true) { @@ -622,6 +624,27 @@ private static function requireString(array $params, string $key): string return is_string($params[$key] ?? null) ? $params[$key] : throw new InvalidRequest(sprintf('"%s" must be a string.', $key)); } + /** + * A playtest from a cell names its map and cell; one from the title names neither. A renderer, when named, is + * one the session offers. + * + * @param array $params + */ + private static function startPlaytest(EditorSession $session, array $params): array + { + $start = PlaytestStart::tryFrom(is_string($params['start'] ?? null) ? $params['start'] : PlaytestStart::CELL->value) + ?? throw new InvalidRequest('"start" must be "cell" or "title".'); + $renderer = $params['renderer'] ?? null; + if ($renderer !== null && ! is_string($renderer)) { + throw new InvalidRequest('"renderer" must be a renderer id.'); + } + if ($start === PlaytestStart::TITLE) { + return $session->startPlaytest(null, null, null, $start, $renderer); + } + + return $session->startPlaytest(self::requireString($params, 'map'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), $start, $renderer); + } + /** @param array $params */ private static function requireInt(array $params, string $key): int { diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index 4db101c..f6ee39f 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Ichiloto\Editor\Database\DatabaseCatalog; +use Ichiloto\Editor\Playtest\PlaytestStart; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Session\EditorSession; use Ichiloto\Editor\Session\SessionHost; @@ -430,6 +431,38 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a } }); +it('plays from the title with the project\'s own start, and only with renderers it can launch', function () { + $root = makeTemporaryProject(); + file_put_contents($root . '/assets/Data/system.php', " ['player' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 2, 'y' => 1]]]];\n"); + // The fake game reports the start position its overlay gives it, then ends. + $console = $root . '/fake-console.php'; + file_put_contents($console, 'describePlaytestOptions())->toBe(['starts' => ['cell', 'title'], 'renderers' => ['gpui'], 'defaultRenderer' => 'gpui']) + ->and(fn() => $session->startPlaytest(null, null, null, PlaytestStart::TITLE, 'terminal')) + ->toThrow(SessionRefusal::class, 'cannot use the terminal renderer from here'); + $map = $session->readMap('test-map'); + $session->paint('test-map', $map['revision'], $map['baseLayer'], [[1, 1]], '%'); + expect(fn() => $session->startPlaytest(null, null, null, PlaytestStart::TITLE)) + ->toThrow(SessionRefusal::class, 'Save test-map before playing from the title'); + $session->saveMap('test-map'); + + expect($session->startPlaytest(null, null, null, PlaytestStart::TITLE))->toMatchArray(['start' => 'title', 'map' => null]); + $deadline = microtime(true) + 5; + while ($session->describePlaytest()['running'] && microtime(true) < $deadline) { + usleep(20000); + } + expect($session->describePlaytest()['log'])->toContain('{"destinationMap":"test-map","spawnPoint":{"x":2,"y":1}}') + ->toContain('--renderer=gpui'); + } finally { + putenv('ICHILOTO_CONSOLE_BIN'); + } +}); + it('stamps a block of tiles as one undo step, each cell its own tile', function () { $root = mapGraphicsProject(); $session = EditorSession::open($root); From ee3c739436bc149f19b09cea8845546132225cfd Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 01:58:46 +0200 Subject: [PATCH 249/344] fix(canvas): keep a connected piece's kept layers as it is drawn and erased A connected piece, such as a wall or border, can now keep tile layers, as the Engine admits since 1022193. Each shape's role keeps those layers at its own single cell. Drawing it over a piece that owned the layer, reshaping it as lines join, and erasing it leave that layer's tiles as they were, through the same planner that plain pieces with keeps already use. This lets the base beneath a stone border's see-through edges survive editing. --- src/Canvas/PieceRole.php | 7 +- .../Unit/Canvas/ConnectedKeptUnderlayTest.php | 76 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Canvas/ConnectedKeptUnderlayTest.php diff --git a/src/Canvas/PieceRole.php b/src/Canvas/PieceRole.php index a667868..5d1b6e3 100644 --- a/src/Canvas/PieceRole.php +++ b/src/Canvas/PieceRole.php @@ -54,7 +54,12 @@ public static function readPiece(TilesetPiece $piece, array $excludedLayers = [] $tiles[$layer] = [['dx' => 0, 'dy' => 0, 'entry' => $entries[$shape]]]; } } - $roles[$glyph][] = new self($piece->id, $shape, $piece->name, $tiles, source: $sources[$shape] ?? $glyph); + // A connected shape is one cell, so it keeps its piece's kept layers at that cell alone. + $keeps = []; + foreach (array_diff($piece->keeps, $excludedLayers) as $layer) { + $keeps[$layer] = [['dx' => 0, 'dy' => 0]]; + } + $roles[$glyph][] = new self($piece->id, $shape, $piece->name, $tiles, keeps: $keeps, source: $sources[$shape] ?? $glyph); } return $roles; diff --git a/tests/Unit/Canvas/ConnectedKeptUnderlayTest.php b/tests/Unit/Canvas/ConnectedKeptUnderlayTest.php new file mode 100644 index 0000000..e59247b --- /dev/null +++ b/tests/Unit/Canvas/ConnectedKeptUnderlayTest.php @@ -0,0 +1,76 @@ + ['name' => 'Dirt', 'layer' => 'terrain', 'glyphs' => ['.'], 'tiles' => ['substrate' => ['2864'], 'floor' => ['2816']]], + 'kept-border' => ['name' => 'Kept Border', 'layer' => 'terrain', 'connects' => 'lines', + 'glyphs' => ['horizontal' => '=', 'vertical' => '!', 'corner' => '#'], 'tiles' => ['decor' => '6'], 'keeps' => ['substrate']], + 'plain-border' => ['name' => 'Plain Border', 'layer' => 'terrain', 'connects' => 'lines', + 'glyphs' => ['horizontal' => '~', 'vertical' => ':', 'corner' => '%'], 'tiles' => ['decor' => '7']], + ]); + $directory = $root . '/assets/Maps/test-map'; + writeTileLayer($directory, '00.substrate.tiles.php', "2864 2864 2864 2864\n2864 2864 2864 2864"); + writeTileLayer($directory, '02.decor.tiles.php', "0 0 0 0\n0 0 0 0"); + + return [$root, EditorSession::open($root)]; +} + +/** @return list One tile layer's first row, as the session reads it. */ +function readUnderlayRow(EditorSession $session, string $layer): array +{ + return array_find($session->readTiles('test-map')['layers'], static fn(array $found): bool => $found['name'] === $layer)['rows'][0]; +} + +function drawUnderlayBorder(EditorSession $session, string $piece): void +{ + $session->placePiece('test-map', $session->readMap('test-map')['revision'], $piece, [0, 0], [3, 0]); +} + +it('keeps the base beneath a connected border drawn over a piece that owned it', function () { + [, $session] = createConnectedUnderlayProject(); + drawUnderlayBorder($session, 'kept-border'); + + expect(readUnderlayRow($session, 'substrate'))->toBe([2864, 2864, 2864, 2864]) + // The dirt's own ground leaves with it; the border draws its own tile. + ->and(readUnderlayRow($session, 'floor'))->toBe([0, 0, 0, 0]) + ->and(readUnderlayRow($session, 'decor'))->toBe([6, 6, 6, 6]); +}); + +it('takes the base with the dirt when a connected border does not keep it', function () { + [, $session] = createConnectedUnderlayProject(); + drawUnderlayBorder($session, 'plain-border'); + + expect(readUnderlayRow($session, 'substrate'))->toBe([0, 0, 0, 0]); +}); + +it('keeps the base when the border reshapes, is erased, undone and saved', function () { + [$root, $session] = createConnectedUnderlayProject(); + drawUnderlayBorder($session, 'kept-border'); + // A second line joining the first reshapes its end into a corner. + $session->placePiece('test-map', $session->readMap('test-map')['revision'], 'kept-border', [3, 0], [3, 1]); + expect(readUnderlayRow($session, 'substrate'))->toBe([2864, 2864, 2864, 2864]); + + $map = $session->readMap('test-map'); + $session->paint('test-map', $map['revision'], $map['baseLayer'], [[0, 0], [1, 0]], ' '); + expect(readUnderlayRow($session, 'substrate'))->toBe([2864, 2864, 2864, 2864]) + ->and(array_slice(readUnderlayRow($session, 'decor'), 0, 2))->toBe([0, 0]); + + $session->undo(); + expect(array_slice(readUnderlayRow($session, 'decor'), 0, 2))->toBe([6, 6]); + $session->saveMap('test-map'); + expect(readUnderlayRow(EditorSession::open($root), 'substrate'))->toBe([2864, 2864, 2864, 2864]); +}); From efd8dc1b2bf9dfabaee5d88d372a7f6f5ae4663a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 04:36:08 +0200 Subject: [PATCH 250/344] feat(database): author sign boards, text roles and translatable sign placements Field Resources can show a sign board instead of an image: a board image cut into fixed caps and a repeating centre, its label's text role, padding and width limits, as the Engine's WorldSignStyle admits them. Switching what a resource shows keeps the image chosen. Text Roles is a new graphical Database category over assets/Data/Presentation/text.php. Each role names the project's own font files in order, a size and a colour. A new role starts on the project's first font, and a new file gets its schema id. KeyedDictionaryProjection can now write keys like that, which a file needs and its records do not hold. A sign placement's label is translatable from the start: the text the author gives is its default, under the translation key signs.. A sign without text, or text on what is not a sign, is refused before anything is written. Changing a placement's resource to a sign gives it a label, and changing it away removes it. worldObjects.create can now place a named resource, with the text a sign shows. New references: text_roles and font_assets. --- docs/manual.md | 21 ++- src/Database/DatabaseCatalog.php | 4 +- .../Projections/KeyedDictionaryProjection.php | 7 +- src/Database/RecordSchemaCatalog.php | 3 +- src/Database/ReferenceCatalog.php | 48 ++++++ src/Field/FieldResourceFields.php | 58 +++++++- src/Field/TextRoleFields.php | 119 +++++++++++++++ src/Field/WorldObjectAuthoring.php | 72 ++++++++- src/Session/EditorSession.php | 4 +- src/Session/SessionHost.php | 15 +- .../Session/DatabaseCoverageSessionTest.php | 3 + tests/Unit/Session/SignAuthoringTest.php | 137 ++++++++++++++++++ 12 files changed, 474 insertions(+), 17 deletions(-) create mode 100644 src/Field/TextRoleFields.php create mode 100644 tests/Unit/Session/SignAuthoringTest.php diff --git a/docs/manual.md b/docs/manual.md index 9ac745b..c6c9569 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1113,7 +1113,8 @@ skill's effects, a quest's rewards) listed under them. | Types | `assets/Data/system.php` | Editable - the project's elements, one per row; an empty list means the Engine's defaults. Weapon, armor and equipment types are the Engine's own | Same | | Terms | `config.php` (`vocab`, `messages`) | Literal terms editable; comments and unrelated expressions preserved | Same | | Player Field Appearance | `assets/Data/Entities/player.php` | Not shown: graphical ownership and art | Choose fixed-player, selected party leader, or absent legacy selector; fixed sheet picker with index/layer controls | -| Field Resources | `assets/Data/Presentation/field.php` (`resources`) | Not shown: graphical resources | One record per named resource, keyed by its stable id: name, ground contact, image or sheet, and an optional occupancy stamp | +| Field Resources | `assets/Data/Presentation/field.php` (`resources`) | Not shown: graphical resources | One record per named resource, keyed by its stable id: name, ground contact, an image or a sign board, and an optional occupancy stamp | +| Text Roles | `assets/Data/Presentation/text.php` (`roles`) | Not shown: graphical text | One record per text role: the project's fonts, first choice first, a size and a colour | | Actor Battle Art, Enemy Battle Art, Battle Scale | `assets/Data/Presentation/battlers.php` | Not shown: graphical battle art | Editable from the actor's or enemy's own page (see Battle Art) | Player Field Appearance edits only `graphicalSubject` and `sprites2d`, preserving @@ -1139,6 +1140,24 @@ as it is typed if the Engine would refuse it, and a save the Engine would refuse writes nothing. A map's world object that names a resource shows a Field Resource picker in place of its own pivot and art, which the resource owns. +A resource shows an image or a sign board (Shows). A sign board names its +board image, cut into fixed left and right caps and a centre that repeats so +the board widens to fit its label, the text role its label is set in, the +label's padding and the board's width limits; cuts are in source pixels, +padding and widths in logical pixels. Switching what a resource shows keeps +the image chosen. Text Roles name the project's own font files, first choice +first, with a size and a colour: the Engine embeds no font, so a project uses +whichever fonts it has the right to. A new role starts on the project's first +font, and the file gains its schema id when it is first written. + +A sign is placed as a world object naming a sign-board resource, with the +text its board shows. Its label is translatable from the start: the text is +the default, under a translation key of the sign's own id +(`signs.`), which translations supply. A sign without text, or text on +what is not a sign, is refused before anything is written; changing a +placement's resource to a sign gives it a label, and changing it away removes +it. Neither the label nor the board changes the map's collision. + Why a category can still turn out read-only: a file the editor cannot evaluate, a value it could not write back out, or a comment inside returned data that holds objects or is not an array literal are each a reason, and the status line names it. You can diff --git a/src/Database/DatabaseCatalog.php b/src/Database/DatabaseCatalog.php index 45fbf27..153ef04 100644 --- a/src/Database/DatabaseCatalog.php +++ b/src/Database/DatabaseCatalog.php @@ -55,7 +55,9 @@ public static function getGraphicalCategories(): array return [...self::all(), new DatabaseCategoryDefinition(PlayerPresentationFields::CATEGORY, 'Player Field Appearance', 'Choose fixed player art or the selected party leader\'s field role.', true), new DatabaseCategoryDefinition(\Ichiloto\Editor\Field\FieldResourceFields::CATEGORY, - 'Field Resources', 'Reusable whole images, such as trees, that maps place by reference.', true)]; + 'Field Resources', 'Reusable whole images, such as trees, that maps place by reference.', true), + new DatabaseCategoryDefinition(\Ichiloto\Editor\Field\TextRoleFields::CATEGORY, + 'Text Roles', 'How text such as a sign\'s label is set: the project\'s fonts, size and colour.', true)]; } /** diff --git a/src/Database/Projections/KeyedDictionaryProjection.php b/src/Database/Projections/KeyedDictionaryProjection.php index 65453ae..89e11a0 100644 --- a/src/Database/Projections/KeyedDictionaryProjection.php +++ b/src/Database/Projections/KeyedDictionaryProjection.php @@ -22,8 +22,10 @@ /** * @param string $key The key holding this category's dictionary. * @param string $identityKey The record field the dictionary key is read into and written from. + * @param array $fileKeys Keys a file must hold that its records do not, such as a schema id, + * written when the file has none of its own; an authored value is never replaced. */ - public function __construct(private string $key, private string $identityKey = 'id') + public function __construct(private string $key, private string $identityKey = 'id', private array $fileKeys = []) { } @@ -59,9 +61,8 @@ public function write(array $whole, array $rows): array if ($dictionary === [] && ! array_key_exists($this->key, $whole)) { return $whole; } - $whole[$this->key] = $dictionary; - return $whole; + return [...$this->fileKeys, ...$whole, $this->key => $dictionary]; } /** diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 0a9e1fe..0f918ab 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -132,7 +132,8 @@ public static function all(bool $graphical = false): array self::battlerArt('actors'), self::battlerArt('enemies'), self::battleScaleReference(), - ...($graphical ? [PlayerPresentationFields::getSchema(), \Ichiloto\Editor\Field\FieldResourceFields::getSchema()] : []), + ...($graphical ? [PlayerPresentationFields::getSchema(), \Ichiloto\Editor\Field\FieldResourceFields::getSchema(), + \Ichiloto\Editor\Field\TextRoleFields::getSchema()] : []), ]; $keyed = []; diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 4f902eb..8342e65 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -13,6 +13,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Animations\Field\FieldPresentationCatalog; +use Ichiloto\Engine\Rendering\Presentation\Text\ProjectTextCatalog; use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Battle\CounterAttackRule; use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; @@ -101,6 +102,8 @@ final class ReferenceCatalog 'effects', 'stage_timelines', 'field_resources', + 'text_roles', + 'font_assets', 'map_regions', 'animation_roles', 'battle_arenas', @@ -294,6 +297,8 @@ public function valuesFor(string $category): array 'effects' => new EffectTimelineLibrary($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets')->findTimelineIds(), 'stage_timelines' => $this->stageTimelineIds(), 'field_resources' => array_map(strval(...), array_keys($this->loadFieldResourceNames())), + 'text_roles' => $this->loadTextRoleIds(), + 'font_assets' => self::getFontAssets($this->workspace->projectRoot), default => $this->recordValues($category), }; } @@ -324,6 +329,49 @@ private function loadFieldResourceNames(): array return array_map(static fn($resource): string => $resource->name, $catalog->resources); } + /** + * The project's text roles (assets/Data/Presentation/text.php), by stable id. A catalogue that cannot be read + * offers none; project validation reports why. + * + * @return list + */ + private function loadTextRoleIds(): array + { + try { + $catalog = ProjectTextCatalog::load($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); + } catch (Throwable) { + return []; + } + + return $catalog === null ? [] : array_map(strval(...), array_keys($catalog->toArray()['roles'])); + } + + /** + * The project's font files under assets, as asset-root-relative paths: what a text role's fonts are chosen + * from. The project owns its fonts; nothing is offered from the machine. + * + * @return list + */ + public static function getFontAssets(string $projectRoot): array + { + $root = realpath(rtrim($projectRoot, '/') . '/assets'); + if ($root === false) { + return []; + } + $paths = []; + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + $resolved = $file->getRealPath(); + if ($file->isFile() && in_array(strtolower($file->getExtension()), ['ttf', 'otf', 'ttc', 'otc'], true) + && $resolved !== false && str_starts_with($resolved, $root . DIRECTORY_SEPARATOR)) { + $paths[] = str_replace(DIRECTORY_SEPARATOR, '/', substr($file->getPathname(), strlen($root) + 1)); + } + } + sort($paths); + + return $paths; + } + private function stageTimelineIds(): array { $library = new EffectTimelineLibrary($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); diff --git a/src/Field/FieldResourceFields.php b/src/Field/FieldResourceFields.php index 436cc86..ac9d577 100644 --- a/src/Field/FieldResourceFields.php +++ b/src/Field/FieldResourceFields.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\Database\Projections\KeyedDictionaryProjection; use Ichiloto\Editor\Database\RecordField; +use Ichiloto\Editor\Database\RecordFieldCodec; use Ichiloto\Editor\Database\RecordRefusal; use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Database\RecordSchema; @@ -65,18 +66,59 @@ public static function makeBlank(string $name, string $projectRoot): array 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => ['asset' => $images[0]]]; } + /** What a resource shows: an image, or a sign board its placements label. */ + public const string IMAGE = 'Image'; + public const string SIGN = 'Sign board'; + /** The font role a new sign board names until its author chooses one. */ + public const string DEFAULT_FONT_ROLE = 'sign'; + /** @return list */ public static function getFields(array $entry): array { + $sign = array_key_exists('sign', $entry); + return [ new RecordField('id', 'Stable Id'), new RecordField('name', 'Name'), new RecordField('pivot.x', 'Ground Contact X (0..1)', InputControlType::FLOAT), new RecordField('pivot.y', 'Ground Contact Y (0..1)', InputControlType::FLOAT), - ...FieldSpriteFields::getFields($entry), + // Not stored: choosing it reshapes the resource ({@see checkEdit()}). + new RecordField('presentation', 'Shows', options: [self::IMAGE, self::SIGN], displayDefault: $sign ? self::SIGN : self::IMAGE), + ...($sign ? self::getSignFields() : FieldSpriteFields::getFields($entry)), + ]; + } + + /** + * A sign board: its image, cut into fixed end caps and a repeating centre so it widens to fit its label, the + * project font role its label is set in, and the label's padding and width limits. Cuts are source pixels; + * padding and widths are logical pixels. + * + * @return list + */ + private static function getSignFields(): array + { + return [ + RecordField::reference('sign.asset', 'Board Image', 'png_assets'), + RecordField::reference('sign.fontRole', 'Label Font Role', 'text_roles'), + new RecordField('sign.left', 'Left Cap Width (source px)', InputControlType::INTEGER), + new RecordField('sign.right', 'Right Cap Width (source px)', InputControlType::INTEGER), + new RecordField('sign.repeatWidth', 'Repeating Centre Width (source px)', InputControlType::INTEGER, removeWhenEmpty: true, + displayDefault: '(the whole centre)'), + new RecordField('sign.density', 'Pixel Density', InputControlType::INTEGER), + new RecordField('sign.padding', 'Label Padding (left, top, right, bottom)', codec: RecordFieldCodec::CSV_INTEGERS), + new RecordField('sign.minimumWidth', 'Minimum Width', InputControlType::INTEGER), + new RecordField('sign.maximumWidth', 'Maximum Width', InputControlType::INTEGER), + new RecordField('sign.layer', 'Field Layer', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), ]; } + /** A sign board as a resource first shows: the given image whole as its centre, for the author to cut. */ + private static function makeSign(string $asset, string $fontRole): array + { + return ['asset' => $asset, 'fontRole' => $fontRole, 'left' => 0, 'right' => 0, 'density' => 1, + 'padding' => [4, 2, 4, 2], 'minimumWidth' => 24, 'maximumWidth' => 192]; + } + /** * The optional authoring stamp: cells relative to a placement's anchor and the physical type each gets when a * brush places the resource. Placing never infers collision from the art. @@ -95,6 +137,20 @@ enumClass: CollisionType::class)], /** A stable id is refused as it is typed, by the Engine's own rule, rather than at the save. */ public static function checkEdit(array $entry, string $field): array { + if ($field === 'presentation') { + // A resource shows exactly one presentation, as the Engine admits it; switching keeps the image chosen. + $choice = $entry['presentation'] ?? null; + unset($entry['presentation']); + $image = $entry['sign']['asset'] ?? $entry['sprites2d']['asset'] ?? ''; + if ($choice === self::SIGN && ! array_key_exists('sign', $entry)) { + unset($entry['sprites2d']); + // The project's sign role by convention; validation names it when the catalogue has none. + $entry['sign'] = self::makeSign($image, self::DEFAULT_FONT_ROLE); + } elseif ($choice === self::IMAGE && array_key_exists('sign', $entry)) { + unset($entry['sign']); + $entry['sprites2d'] = ['asset' => $image]; + } + } if ($field === 'id') { try { WorldObjectResource::assertId($entry['id'] ?? null); diff --git a/src/Field/TextRoleFields.php b/src/Field/TextRoleFields.php new file mode 100644 index 0000000..4bec5bd --- /dev/null +++ b/src/Field/TextRoleFields.php @@ -0,0 +1,119 @@ + ProjectTextCatalog::SCHEMA]), + fieldsFor: self::getFields(...), + subLists: [new RecordSubList(key: 'fonts', prefix: 'font', singular: 'font', + fields: [RecordField::reference('asset', 'Font File', 'font_assets'), + new RecordField('faceIndex', 'Face Index', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0')], + blank: ['asset' => ''], heading: 'Fonts, first choice first')], + saveCheck: self::checkCatalog(...), + prepareEdit: self::checkEdit(...), + requireUnchangedSource: true, + ); + } + + /** A new role, which saves at once as the Engine admits it: set in the project's first font. */ + public static function makeBlank(string $name, string $projectRoot): array + { + $fonts = ReferenceCatalog::getFontAssets($projectRoot); + if ($fonts === []) { + throw new RecordRefusal('A new text role starts on the project\'s first font. Add a font file (.ttf or .otf) under assets first.'); + } + $id = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($name)), '-'); + + return ['id' => preg_match('/^[a-z]/', $id) === 1 ? $id : 'role-' . $id, 'fonts' => [['asset' => $fonts[0]]], 'fontSize' => 12, + 'foreground' => ['kind' => 'rgb', 'r' => 255, 'g' => 255, 'b' => 255]]; + } + + /** @return list */ + public static function getFields(array $entry): array + { + $kind = $entry['foreground']['kind'] ?? 'rgb'; + $colour = $kind === 'rgb' + ? [new RecordField('foreground.r', 'Red (0..255)', InputControlType::INTEGER), + new RecordField('foreground.g', 'Green (0..255)', InputControlType::INTEGER), + new RecordField('foreground.b', 'Blue (0..255)', InputControlType::INTEGER)] + : [new RecordField('foreground.index', 'Palette Index', InputControlType::INTEGER)]; + + return [ + new RecordField('id', 'Stable Id'), + new RecordField('fontSize', 'Font Size', InputControlType::FLOAT), + new RecordField('foreground.kind', 'Colour Kind', options: self::COLOUR_KINDS), + ...$colour, + // Glyph effects are typed PHP values; they are kept as authored, never rewritten here. + ...(array_key_exists('glyphEffects', $entry) ? [new RecordField('glyphEffects', 'Glyph Effects', isReadOnly: true)] : []), + ]; + } + + /** A role's id and colour kind are checked and reshaped as they are typed, by the Engine's own rules. */ + public static function checkEdit(array $entry, string $field): array + { + if ($field === 'id') { + try { + ProjectTextCatalog::assertRoleId($entry['id'] ?? null); + } catch (Throwable $error) { + throw new RecordRefusal($error->getMessage(), previous: $error); + } + } + if ($field === 'foreground.kind') { + $kind = $entry['foreground']['kind'] ?? 'rgb'; + $entry['foreground'] = $kind === 'rgb' + ? ['kind' => 'rgb', 'r' => 255, 'g' => 255, 'b' => 255] + : ['kind' => $kind, 'index' => $entry['foreground']['index'] ?? 15]; + } + + return $entry; + } + + /** The Engine's own admission of the whole catalogue, so a save never writes a file the game refuses. */ + public static function checkCatalog(array $whole, string $projectRoot): ?string + { + try { + ProjectTextCatalog::fromArray($whole); + } catch (Throwable $error) { + return $error->getMessage(); + } + + return null; + } +} diff --git a/src/Field/WorldObjectAuthoring.php b/src/Field/WorldObjectAuthoring.php index 37ea0ed..2cc17c0 100644 --- a/src/Field/WorldObjectAuthoring.php +++ b/src/Field/WorldObjectAuthoring.php @@ -112,12 +112,16 @@ public function getRecords(): ProjectRecordDatabase $cells = new RecordSubList(key: 'covers.cells', prefix: 'cell', singular: 'covered cell', fields: [new RecordField('x', 'X', InputControlType::INTEGER), new RecordField('y', 'Y', InputControlType::INTEGER)], blank: ['x' => 0, 'y' => 0], heading: 'Covered Cells'); + $signs = self::readSignResources($this->map); $schema = new RecordSchema(key: 'world_objects', entryNoun: 'world object', storage: RecordStorage::MAP_OWNED, relativePath: '', fields: [], labelKey: 'id', - fieldsFor: function (array $entry): array { - // A placement of a named resource takes its art and pivot from the resource; it chooses which one. + fieldsFor: function (array $entry) use ($signs): array { + // A placement of a named resource takes its art and pivot from the resource; it chooses which one, and a + // sign placement the text its board shows: a translatable message, and the text where none is given. $presentation = array_key_exists('resource', $entry) - ? [new RecordField('resource', 'Field Resource', reference: 'field_resources')] + ? [new RecordField('resource', 'Field Resource', reference: 'field_resources'), + ...(isset($signs[$entry['resource']]) ? [new RecordField('label.default', 'Sign Text'), + new RecordField('label.message', 'Sign Text Translation Key')] : [])] : [new RecordField('pivot.x', 'Image Pivot X (0..1)', InputControlType::FLOAT), new RecordField('pivot.y', 'Image Pivot Y (0..1)', InputControlType::FLOAT), ...FieldSpriteFields::getFields($entry, true)]; @@ -137,7 +141,13 @@ public function getRecords(): ProjectRecordDatabase subLists: [$variants, $cells], subListsFor: static fn(array $entry): array => [...(array_key_exists('resource', $entry) ? [] : ['variants']), ...(isset($entry['covers']) ? ['covers.cells'] : [])], - prepareEdit: static function (array $entry, string $field): array { + prepareEdit: static function (array $entry, string $field) use ($signs): array { + if ($field === 'resource') { + // A sign placement needs its label and only a sign accepts one; the board's name starts it. + $sign = $signs[$entry['resource'] ?? ''] ?? null; + if ($sign === null) { unset($entry['label']); } + else { $entry['label'] ??= self::makeLabel((string) $entry['id'], $sign); } + } if ($field === 'covers.layer') { if (!isset($entry['covers']['layer'])) { unset($entry['covers']); } else { @@ -158,11 +168,59 @@ public function getRecords(): ProjectRecordDatabase return $records; } - public function createObject(string $id, int $x, int $y): ?Command + /** + * Creates an object at a cell: an inline one with no art yet, or a placement of a named resource. A sign + * placement is given the text its board shows, under a translation key of its own id. + */ + public function createObject(string $id, int $x, int $y, ?string $resource = null, ?string $text = null): ?Command { $entries = self::readEntries($this->map); - $entries[] = ['id' => $id, 'anchor' => ['x' => $x, 'y' => $y], 'pivot' => ['x' => .5, 'y' => 1.0], 'sprites2d' => null]; - return $this->writeEntries($entries, 'Create world object'); + if ($resource === null) { + if ($text !== null) { throw new InvalidArgumentException('Only a sign placement shows text.'); } + $entries[] = ['id' => $id, 'anchor' => ['x' => $x, 'y' => $y], 'pivot' => ['x' => .5, 'y' => 1.0], 'sprites2d' => null]; + return $this->writeEntries($entries, 'Create world object'); + } + $sign = self::readSignResources($this->map)[$resource] ?? null; + $entry = ['id' => $id, 'resource' => $resource, 'anchor' => ['x' => $x, 'y' => $y]]; + if ($sign !== null) { + if ($text === null || trim($text) === '') { throw new InvalidArgumentException('A sign needs the text it shows.'); } + $entry['label'] = ['message' => self::getLabelMessage($id), 'default' => $text]; + } elseif ($text !== null) { + throw new InvalidArgumentException('Only a sign placement shows text.'); + } + $entries[] = $entry; + return $this->writeEntries($entries, $sign === null ? 'Place field resource' : 'Place sign'); + } + + /** A sign placement's translation key: its own map-local id under signs, so translators find each sign. */ + public static function getLabelMessage(string $id): string + { + return 'signs.' . $id; + } + + private static function makeLabel(string $id, string $name): array + { + return ['message' => self::getLabelMessage($id), 'default' => $name]; + } + + /** + * The project's sign resources, by id, with their names: the ones a placement labels. A catalogue that cannot be + * read offers none; validation says why. + * + * @return array + */ + private static function readSignResources(ProjectMap $map): array + { + try { + $catalog = FieldPresentationCatalog::load($map->getAssetRoot()); + } catch (\Throwable) { + return []; + } + $signs = []; + foreach ($catalog->resources as $id => $resource) { + if ($resource->sign !== null) { $signs[(string) $id] = $resource->name; } + } + return $signs; } public function deleteObject(string $id): ?Command diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 1821c4c..922d5b6 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -3907,9 +3907,9 @@ public function readWorldObject(string $mapId, string $id): array 'rows' => array_map(static fn($field): array => self::describeRecordRow($records, $index, [], $field, 'world-object'), $fields)]; } - public function createWorldObject(string $mapId, int $revision, string $id, int $x, int $y): array + public function createWorldObject(string $mapId, int $revision, string $id, int $x, int $y, ?string $resource = null, ?string $text = null): array { - return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->createObject($id, $x, $y)); + return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->createObject($id, $x, $y, $resource, $text)); } public function deleteWorldObject(string $mapId, int $revision, string $id): array diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index e00af46..e8ce07a 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -149,7 +149,8 @@ private function dispatch(string $method, array $params): array 'map.read' => $session->readMap(self::requireString($params, 'map')), 'worldObjects.read' => $session->readWorldObject(self::requireString($params, 'map'), self::requireString($params, 'id')), 'worldObjects.create' => $session->createWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), - self::requireString($params, 'id'), self::requireInt($params, 'x'), self::requireInt($params, 'y')), + self::requireString($params, 'id'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), + self::optionalString($params, 'resource'), self::optionalString($params, 'text')), 'worldObjects.delete' => $session->deleteWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireString($params, 'id')), 'worldObjects.apply' => $session->applyWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireString($params, 'id'), self::requireArray($params, 'key'), self::requireString($params, 'value')), @@ -645,6 +646,18 @@ private static function startPlaytest(EditorSession $session, array $params): ar return $session->startPlaytest(self::requireString($params, 'map'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), $start, $renderer); } + /** + * A string parameter that may be absent or null. + * + * @param array $params + */ + private static function optionalString(array $params, string $key): ?string + { + $value = $params[$key] ?? null; + + return $value === null || is_string($value) ? $value : throw new InvalidRequest(sprintf('"%s" must be a string when given.', $key)); + } + /** @param array $params */ private static function requireInt(array $params, string $key): int { diff --git a/tests/Unit/Session/DatabaseCoverageSessionTest.php b/tests/Unit/Session/DatabaseCoverageSessionTest.php index 9b8f684..fa54541 100644 --- a/tests/Unit/Session/DatabaseCoverageSessionTest.php +++ b/tests/Unit/Session/DatabaseCoverageSessionTest.php @@ -73,6 +73,9 @@ function editRecordOnce(EditorSession $session, string $category, int $index): b file_put_contents($root . '/assets/Graphics/Enemies/blob.txt', "(oo)\n"); // A new field resource starts on the project's first image. writeTilesetTestPng($root . '/assets/Graphics/Props/Coverage.png', 4, 4); + // A new text role starts on the project's first font; the editor never decodes it, so its bytes are synthetic. + mkdir($root . '/assets/Fonts', 0o777, true); + file_put_contents($root . '/assets/Fonts/Coverage.ttf', 'synthetic font'); $session = EditorSession::open($root); $writable = []; diff --git a/tests/Unit/Session/SignAuthoringTest.php b/tests/Unit/Session/SignAuthoringTest.php new file mode 100644 index 0000000..57afd2d --- /dev/null +++ b/tests/Unit/Session/SignAuthoringTest.php @@ -0,0 +1,137 @@ + [\n" + . " 'lamp' => ['name' => 'Lamp', 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => ['asset' => 'Graphics/Props/Lamp.png']],\n" + . " 'wood-sign' => ['name' => 'Wood Sign', 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sign' => ['asset' => 'Graphics/Boards/Wood.png'," + . " 'fontRole' => 'sign', 'left' => 8, 'right' => 8, 'density' => 1, 'padding' => [4, 2, 4, 2], 'minimumWidth' => 24, 'maximumWidth' => 160]],\n" + . "]];\n"); + + return [$root, EditorSession::open($root)]; +} + +function findSignDatabaseRow(EditorSession $session, string $category, int $index, string $field): ?array +{ + return array_find($session->readDatabaseRecord($category, $index)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $field); +} + +function applySignDatabaseRow(EditorSession $session, string $category, int $index, string $field, string $value): array +{ + $row = findSignDatabaseRow($session, $category, $index, $field) ?? throw new RuntimeException('Missing row ' . $field); + + return $session->applyDatabaseRecord($category, $index, $row['key'], $value); +} + +function findSignPlacementRow(EditorSession $session, string $id, string $field): ?array +{ + return array_find($session->readWorldObject('test-map', $id)['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === $field); +} + +it('authors a sign board resource with the Engine\'s board, cut, padding and width fields', function () { + [$root, $session] = createSignProject(); + $index = array_search('Wood Sign', $session->listDatabaseRecords(FieldResourceFields::CATEGORY)['records'], true); + + expect(findSignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'presentation')['value'])->toBe(FieldResourceFields::SIGN) + ->and(findSignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'sign.fontRole'))->toMatchArray(['reference' => 'text_roles']) + ->and(findSignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'sprites2d.asset'))->toBeNull(); + applySignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'sign.padding', '6, 2, 6, 3'); + $session->saveDatabase(FieldResourceFields::CATEGORY); + + expect(FieldPresentationCatalog::load($root . '/assets')->getResource('wood-sign')->sign->padding)->toBe([6, 2, 6, 3]); +}); + +it('switches a resource between an image and a sign board, keeping the image chosen', function () { + [$root, $session] = createSignProject(); + $index = array_search('Lamp', $session->listDatabaseRecords(FieldResourceFields::CATEGORY)['records'], true); + applySignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'presentation', FieldResourceFields::SIGN); + + expect(findSignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'sign.asset')['value'])->toBe('Graphics/Props/Lamp.png') + ->and(findSignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'presentation')['value'])->toBe(FieldResourceFields::SIGN); + applySignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'presentation', FieldResourceFields::IMAGE); + $session->saveDatabase(FieldResourceFields::CATEGORY); + + $lamp = FieldPresentationCatalog::load($root . '/assets')->getResource('lamp'); + expect($lamp->sign)->toBeNull()->and($lamp->sprites)->not->toBeNull(); +}); + +it('creates the text catalogue with its schema and a role set in the project\'s own font', function () { + [$root, $session] = createSignProject(); + $session->createDatabaseRecord(TextRoleFields::CATEGORY); + applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'id', 'sign'); + applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'fontSize', '14'); + $session->saveDatabase(TextRoleFields::CATEGORY); + + $role = ProjectTextCatalog::load($root . '/assets')->getRole('sign'); + expect((require $root . '/assets/Data/Presentation/text.php')['schema'])->toBe(ProjectTextCatalog::SCHEMA) + ->and($role->fonts[0]->asset)->toBe('Fonts/Open.ttf') + ->and($role->fontSize)->toBe(14.0) + ->and(new ReferenceCatalog(ProjectWorkspace::fromProject($root))->valuesFor('text_roles'))->toBe(['sign']) + ->and(fn() => applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'id', 'Sign Font'))->toThrow(SessionRefusal::class); +}); + +it('places a sign with the text it shows under a translation key of its own id', function () { + [$root, $session] = createSignProject(); + $session->createWorldObject('test-map', $session->readMap('test-map')['revision'], 'inn-sign', 1, 1, 'wood-sign', 'Inn'); + + expect($session->readMap('test-map')['worldObjects'][0])->toBe(['id' => 'inn-sign', 'resource' => 'wood-sign', 'anchor' => ['x' => 1, 'y' => 1], + 'label' => ['message' => 'signs.inn-sign', 'default' => 'Inn']]) + ->and(findSignPlacementRow($session, 'inn-sign', 'label.default')['value'])->toBe('Inn'); + $row = findSignPlacementRow($session, 'inn-sign', 'label.default'); + $session->applyWorldObject('test-map', $session->readWorldObject('test-map', 'inn-sign')['revision'], 'inn-sign', $row['key'], 'Weapons'); + $session->saveMap('test-map'); + + $data = require $root . '/assets/Maps/test-map/test-map.data.php'; + $placement = WorldObjectDefinition::readMap($data, loadLayeredMap($root)->getLayerSet(), + catalog: FieldPresentationCatalog::load($root . '/assets'))[0]; + expect($placement->label->default)->toBe('Weapons'); +}); + +it('refuses a sign without text, and text on what is not a sign, writing nothing', function () { + [, $session] = createSignProject(); + $before = $session->readMap('test-map'); + + expect(fn() => $session->createWorldObject('test-map', $before['revision'], 'blank-sign', 1, 1, 'wood-sign', ' '))->toThrow(SessionRefusal::class) + ->and(fn() => $session->createWorldObject('test-map', $before['revision'], 'lamp-1', 1, 1, 'lamp', 'Lamp'))->toThrow(SessionRefusal::class) + ->and($session->readMap('test-map'))->toBe($before); +}); + +it('gives a placement a label when its resource becomes a sign, and takes it away when it stops being one', function () { + [, $session] = createSignProject(); + $session->createWorldObject('test-map', $session->readMap('test-map')['revision'], 'lamp-1', 1, 1, 'lamp'); + $apply = static function (string $field, string $value) use ($session): void { + $row = findSignPlacementRow($session, 'lamp-1', $field); + $session->applyWorldObject('test-map', $session->readWorldObject('test-map', 'lamp-1')['revision'], 'lamp-1', $row['key'], $value); + }; + + $apply('resource', 'wood-sign'); + expect($session->readMap('test-map')['worldObjects'][0]['label'])->toBe(['message' => 'signs.lamp-1', 'default' => 'Wood Sign']); + $apply('resource', 'lamp'); + expect($session->readMap('test-map')['worldObjects'][0])->not->toHaveKey('label'); +}); From 599b5595a48c4b57f6b9b5405ce7f2ec77e9d8ce Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 04:53:45 +0200 Subject: [PATCH 251/344] feat(preview): lay out sign labels through the preview's own renderer client A preview field's scene context now binds the preview's own RendererClient as its text provider, as the Engine's sign contract asks of an isolated host. A sign on the previewed map then asks the editor window to lay out its label in the project's text role, in the same exchange that carries its frames, and never through the game's runtime services. --- src/Cutscenes/Preview/PreviewSceneHost.php | 4 +- tests/Unit/Cutscenes/SignPreviewTextTest.php | 52 ++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Cutscenes/SignPreviewTextTest.php diff --git a/src/Cutscenes/Preview/PreviewSceneHost.php b/src/Cutscenes/Preview/PreviewSceneHost.php index cd9aac8..025cee4 100644 --- a/src/Cutscenes/Preview/PreviewSceneHost.php +++ b/src/Cutscenes/Preview/PreviewSceneHost.php @@ -125,7 +125,9 @@ private function attachContext(): void } $this->scene->setPresentationContext(new ScenePresentationContext($this->grid, $this->client->supports(...), collectPresentations: fn(): array => $this->dialogue->getActivePresentations(), readTime: $this->readTime, - assetRoot: $this->assetRoot)); + assetRoot: $this->assetRoot, + // Text such as a sign's label is laid out by this window, through this preview's own client: never the game's. + textPreparation: $this->client)); // The graphical field draws its effects as sprites, as the game's does in a graphical window. if ($this->scene instanceof PreviewGameScene) { $this->scene->installFieldEffects(EffectPresentation::GRAPHICAL); diff --git a/tests/Unit/Cutscenes/SignPreviewTextTest.php b/tests/Unit/Cutscenes/SignPreviewTextTest.php new file mode 100644 index 0000000..a33480b --- /dev/null +++ b/tests/Unit/Cutscenes/SignPreviewTextTest.php @@ -0,0 +1,52 @@ + ['player' => [ + 'destinationMap' => 'harbour', 'spawnPoint' => ['x' => 4, 'y' => 4], 'spawnSprite' => ['^']]]], true) . ";\n"); + writeTilesetTestPng($root . '/assets/Graphics/Boards/Wood.png', 36, 12); + is_dir($root . '/assets/Fonts') || mkdir($root . '/assets/Fonts', 0o777, true); + file_put_contents($root . '/assets/Fonts/Open.ttf', 'synthetic font'); + is_dir($root . '/assets/Data/Presentation') || mkdir($root . '/assets/Data/Presentation', 0o777, true); + file_put_contents($root . '/assets/Data/Presentation/field.php', " ['wood-sign' => ['name' => 'Wood Sign'," + . " 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sign' => ['asset' => 'Graphics/Boards/Wood.png', 'fontRole' => 'sign', 'left' => 8," + . " 'right' => 8, 'density' => 1, 'padding' => [4, 2, 4, 2], 'minimumWidth' => 24, 'maximumWidth' => 160]]]];\n"); + file_put_contents($root . '/assets/Data/Presentation/text.php', " 'ichiloto.text/1', 'roles' => ['sign' => [" + . "'fonts' => [['asset' => 'Fonts/Open.ttf']], 'fontSize' => 12, 'foreground' => ['kind' => 'rgb', 'r' => 230, 'g' => 215, 'b' => 170]]]];\n"); + $data = $root . '/assets/Maps/harbour/harbour.data.php'; + file_put_contents($data, str_replace("'events' => [],", "'events' => [], 'worldObjects' => [['id' => 'inn-sign', 'resource' => 'wood-sign'," + . " 'anchor' => ['x' => 6, 'y' => 1], 'label' => ['message' => 'signs.inn-sign', 'default' => 'Inn']]],", (string) file_get_contents($data))); + + return $root; +} + +it('asks the editor window to lay out a sign\'s label, through the preview\'s own client', function () { + $session = EditorSession::open(signPreviewProject()); + $index = array_search('ember-spark', $session->listDatabaseRecords('cutscenes/effect')['records'], true); + $shown = $session->showEffectOnField($index, 0, 40, 12); + $ready = json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => [...json_decode(previewWindowReady(), true)['capabilities'], + RendererSessionConfig::TEXT_LAYOUT, RendererSessionConfig::CANVAS_LABELS, RendererSessionConfig::WORLD_CANVAS, RendererSessionConfig::SPRITE_PIVOT]]); + + try { + $messages = $session->exchangeEffectFieldScene([$ready], $shown['sessionId'])['messages']; + $requests = array_values(array_filter($messages, static fn(array $message): bool => $message['type'] === 'prepare_text')); + expect($requests)->not->toBe([]) + ->and($requests[0]['payload']['spec']['text'])->toBe('Inn') + ->and($requests[0]['payload']['spec']['fonts'][0]['asset'])->toBe('Fonts/Open.ttf'); + } finally { + $session->closeEffectField(); + } +}); From 9cd4621ace3097b34c422a9533ddfd2d6cd2e012 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 05:30:51 +0200 Subject: [PATCH 252/344] feat(playtest): skip the title, choose the renderer automatically, open terminals in a window Playtests now use the console's tested play contract: - A cell playtest starts a new game on that cell, skipping the title (--start=new-game). A title playtest plays the real opening (--start=title). - The renderer defaults to Automatic (--renderer=auto): GPUI when it is installed and usable, otherwise the terminal. - A terminal session opens in a terminal window of its own (--terminal-window), which closes when the game ends, while the session still watches, stops and reports the game process. playtest.options now lists Automatic first, then each renderer the console's renderer:list reports, with whether it is usable and why not. A renderer that is not offered, or not usable, is refused with that reason. When the console cannot list renderers, Automatic is still offered and the issue says why. Removed: the GUI-only fixed GPUI default, and the refusal of every renderer but GPUI. --- src/Playtest/PlaytestLauncher.php | 53 ++++++++++++++++++++++-- src/Session/EditorSession.php | 37 +++++++++++++---- tests/Unit/PlaytestTest.php | 2 +- tests/Unit/Session/EditorSessionTest.php | 29 ++++++++++--- 4 files changed, 103 insertions(+), 18 deletions(-) diff --git a/src/Playtest/PlaytestLauncher.php b/src/Playtest/PlaytestLauncher.php index e0420f5..801345d 100644 --- a/src/Playtest/PlaytestLauncher.php +++ b/src/Playtest/PlaytestLauncher.php @@ -51,18 +51,63 @@ public function buildCommand(PlaytestOverlay $overlay): string ); } + /** The renderer choice that lets the console pick: GPUI when it is installed and usable, otherwise the terminal. */ + public const string AUTOMATIC = 'auto'; + /** * Starts the playtest in the background with a renderer of its own, for * an editor that owns no terminal to hand over: the play command runs - * without prompts, reading nothing and writing its output to a log. + * without prompts, reading nothing and writing its output to a log. It + * begins a new game at the overlay's starting position, skipping the + * title, or at the title; a terminal session opens in a terminal window of + * its own, which closes when the game ends. * - * @param string $rendererId The renderer, such as `gpui`. + * @param string $rendererId The renderer, such as `gpui`, or {@see AUTOMATIC}. * @param string $logPath Where the play command's output goes. * @throws RuntimeException When the process cannot start. */ - public function start(PlaytestOverlay $overlay, string $rendererId, string $logPath): PlaytestRun + public function start(PlaytestOverlay $overlay, string $rendererId, string $logPath, PlaytestStart $start = PlaytestStart::CELL): PlaytestRun + { + return $this->spawn($overlay, ['play', '--no-tmux', '--no-interaction', + '--start=' . ($start === PlaytestStart::TITLE ? 'title' : 'new-game'), '--renderer=' . $rendererId, + '--terminal-window', '-d', $overlay->root], $logPath); + } + + /** + * The renderers the project's Engine can launch, as the console reports + * them without starting any: each with its name, whether it is usable, + * and why not when it is not, and the one it prefers. + * + * @return array{renderers: list, preferredId: ?string} + * @throws RuntimeException When the console cannot report them. + */ + public function listRenderers(string $projectRoot): array { - return $this->spawn($overlay, ['play', '--no-tmux', '--no-interaction', '--renderer=' . $rendererId, '-d', $overlay->root], $logPath); + $pipes = []; + $process = @proc_open([PHP_BINARY, $this->consoleBinary, 'renderer:list', '--directory=' . $projectRoot, '--json'], + [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $projectRoot); + if (! is_resource($process)) { + throw new RuntimeException('The renderers could not be listed: the console did not start.'); + } + $output = (string) stream_get_contents($pipes[1]); + $errors = trim((string) stream_get_contents($pipes[2])); + fclose($pipes[1]); + fclose($pipes[2]); + $exit = proc_close($process); + $report = json_decode($output, true); + if ($exit !== 0 || ! is_array($report) || ($report['schema'] ?? null) !== 'ichiloto.renderers/1' || ! is_array($report['renderers'] ?? null)) { + throw new RuntimeException('The renderers could not be listed' . ($errors === '' ? '.' : ': ' . $errors)); + } + $renderers = []; + foreach ($report['renderers'] as $renderer) { + if (! is_array($renderer) || ! is_string($renderer['id'] ?? null) || ! is_bool($renderer['available'] ?? null)) { + throw new RuntimeException('The console reported a renderer the editor cannot read.'); + } + $renderers[] = ['id' => $renderer['id'], 'displayName' => is_string($renderer['displayName'] ?? null) ? $renderer['displayName'] : $renderer['id'], + 'available' => $renderer['available'], 'reason' => is_string($renderer['reason'] ?? null) ? $renderer['reason'] : null]; + } + + return ['renderers' => $renderers, 'preferredId' => is_string($report['preferredId'] ?? null) ? $report['preferredId'] : null]; } /** diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 922d5b6..4e1f784 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -4343,8 +4343,13 @@ public function startPlaytest(?string $mapId, ?int $x, ?int $y, PlaytestStart $s } $options = $this->describePlaytestOptions(); $renderer ??= $options['defaultRenderer']; - if (! in_array($renderer, $options['renderers'], true)) { - throw new SessionRefusal(sprintf('A playtest cannot use the %s renderer from here; choose one of %s.', $renderer, implode(', ', $options['renderers']))); + $offered = array_find($options['renderers'], static fn(array $choice): bool => $choice['id'] === $renderer); + if ($offered === null) { + throw new SessionRefusal(sprintf('A playtest cannot use the %s renderer; choose one of %s.', $renderer, + implode(', ', array_column($options['renderers'], 'id')))); + } + if (! $offered['available']) { + throw new SessionRefusal(sprintf('%s cannot be used for a playtest: %s.', $offered['label'], $offered['reason'] ?? 'it is unavailable')); } if ($start === PlaytestStart::CELL) { if ($mapId === null || $x === null || $y === null) { @@ -4370,7 +4375,7 @@ public function startPlaytest(?string $mapId, ?int $x, ?int $y, PlaytestStart $s : PlaytestOverlay::createForTitle($this->workspace->projectRoot); $launcher = PlaytestLauncher::discover(projectRoot: $this->workspace->projectRoot); $log = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('ichiloto-playtest-', true) . '.log'; - $this->playtest = $launcher->start($overlay, $renderer, $log); + $this->playtest = $launcher->start($overlay, $renderer, $log, $start); $this->playtestStart = $start; } catch (RuntimeException $error) { $overlay?->destroy(); @@ -4381,17 +4386,33 @@ public function startPlaytest(?string $mapId, ?int $x, ?int $y, PlaytestStart $s } /** - * Where a playtest from this editor can begin, and the renderers it can launch, the first being the default. A - * graphical editor owns no terminal to hand over, so it launches only renderers that open a window of their own. + * Where a playtest from this editor can begin, and the renderers it can launch with. The first is Automatic, the + * default: the console picks GPUI when it is installed and usable, otherwise the terminal, in a terminal window + * of its own. Each installed renderer follows, with whether it is usable and why not when it is not. When the + * console cannot list them, Automatic is still offered and the issue says why. * - * @return array{starts: list, renderers: list, defaultRenderer: string} + * @return array{starts: list, renderers: list, + * defaultRenderer: string, issue: ?string} */ public function describePlaytestOptions(): array { + $issue = null; + try { + $listed = PlaytestLauncher::discover(projectRoot: $this->workspace->projectRoot)->listRenderers($this->workspace->projectRoot); + } catch (RuntimeException $error) { + $listed = ['renderers' => [], 'preferredId' => null]; + $issue = $error->getMessage(); + } + $preferred = array_find($listed['renderers'], static fn(array $renderer): bool => $renderer['id'] === $listed['preferredId']); + $automatic = ['id' => PlaytestLauncher::AUTOMATIC, + 'label' => $preferred === null ? 'Automatic' : sprintf('Automatic (%s)', $preferred['displayName']), 'available' => true, 'reason' => null]; + return [ 'starts' => array_map(static fn(PlaytestStart $start): string => $start->value, PlaytestStart::cases()), - 'renderers' => [self::PLAYTEST_RENDERER], - 'defaultRenderer' => self::PLAYTEST_RENDERER, + 'renderers' => [$automatic, ...array_map(static fn(array $renderer): array => ['id' => $renderer['id'], + 'label' => $renderer['displayName'], 'available' => $renderer['available'], 'reason' => $renderer['reason']], $listed['renderers'])], + 'defaultRenderer' => PlaytestLauncher::AUTOMATIC, + 'issue' => $issue, ]; } diff --git a/tests/Unit/PlaytestTest.php b/tests/Unit/PlaytestTest.php index fa0857b..11caac6 100644 --- a/tests/Unit/PlaytestTest.php +++ b/tests/Unit/PlaytestTest.php @@ -278,7 +278,7 @@ function playtestProject(): string $children = trim((string) shell_exec('pgrep -P ' . $pid)); expect($run->isRunning())->toBeTrue() - ->and(file_get_contents($scratch . '/play.log'))->toContain('play --no-tmux --no-interaction --renderer=gpui -d ' . $overlay->root) + ->and(file_get_contents($scratch . '/play.log'))->toContain('play --no-tmux --no-interaction --start=new-game --renderer=gpui --terminal-window -d ' . $overlay->root) ->and($children)->not->toBe(''); $run->stop(); diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index f6ee39f..f04bb92 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -434,17 +434,27 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a it('plays from the title with the project\'s own start, and only with renderers it can launch', function () { $root = makeTemporaryProject(); file_put_contents($root . '/assets/Data/system.php', " ['player' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 2, 'y' => 1]]]];\n"); - // The fake game reports the start position its overlay gives it, then ends. + // The fake console lists GPUI as usable and the terminal as not; the fake game reports the start position its + // overlay gives it and how it was launched, then ends. $console = $root . '/fake-console.php'; - file_put_contents($console, ' "ichiloto.renderers/1", "preferredId" => "gpui",' + . ' "renderers" => [["id" => "gpui", "displayName" => "GPUI", "available" => true, "reason" => null],' + . ' ["id" => "terminal", "displayName" => "Terminal", "available" => false, "reason" => "no terminal application found"]]]); exit(0); }' + . ' $d = $argv[array_search("-d", $argv) + 1]; $s = require $d . "/assets/Data/system.php";' . ' fwrite(STDERR, json_encode($s["startingPositions"]["player"]) . " " . implode(" ", array_slice($argv, 1)) . "\n"); exit(3);'); putenv('ICHILOTO_CONSOLE_BIN=' . $console); try { $session = EditorSession::open($root); - expect($session->describePlaytestOptions())->toBe(['starts' => ['cell', 'title'], 'renderers' => ['gpui'], 'defaultRenderer' => 'gpui']) + expect($session->describePlaytestOptions())->toBe(['starts' => ['cell', 'title'], 'renderers' => [ + ['id' => 'auto', 'label' => 'Automatic (GPUI)', 'available' => true, 'reason' => null], + ['id' => 'gpui', 'label' => 'GPUI', 'available' => true, 'reason' => null], + ['id' => 'terminal', 'label' => 'Terminal', 'available' => false, 'reason' => 'no terminal application found'], + ], 'defaultRenderer' => 'auto', 'issue' => null]) ->and(fn() => $session->startPlaytest(null, null, null, PlaytestStart::TITLE, 'terminal')) - ->toThrow(SessionRefusal::class, 'cannot use the terminal renderer from here'); + ->toThrow(SessionRefusal::class, 'Terminal cannot be used for a playtest: no terminal application found.') + ->and(fn() => $session->startPlaytest(null, null, null, PlaytestStart::TITLE, 'vulkan')) + ->toThrow(SessionRefusal::class, 'cannot use the vulkan renderer'); $map = $session->readMap('test-map'); $session->paint('test-map', $map['revision'], $map['baseLayer'], [[1, 1]], '%'); expect(fn() => $session->startPlaytest(null, null, null, PlaytestStart::TITLE)) @@ -457,7 +467,16 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a usleep(20000); } expect($session->describePlaytest()['log'])->toContain('{"destinationMap":"test-map","spawnPoint":{"x":2,"y":1}}') - ->toContain('--renderer=gpui'); + ->toContain('--start=title')->toContain('--renderer=auto')->toContain('--terminal-window'); + + // From a cell, a new game starts on that cell, skipping the title. + $session->startPlaytest('test-map', 1, 1); + $deadline = microtime(true) + 5; + while ($session->describePlaytest()['running'] && microtime(true) < $deadline) { + usleep(20000); + } + expect($session->describePlaytest()['log'])->toContain('{"destinationMap":"test-map","spawnPoint":{"x":1,"y":1}') + ->toContain('--start=new-game'); } finally { putenv('ICHILOTO_CONSOLE_BIN'); } From c603d148bb0ce7534513cbe4c97f28a6a54b4464 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 06:00:39 +0200 Subject: [PATCH 253/344] test(maps): share the physical occupancy fixtures instead of borrowing another test file's The footprint stamping tests called fixtures defined in the occupancy painting test file, so they passed only when that file loaded first. The fixtures now live in tests/Support, loaded by Pest.php for every file. --- tests/Support/PhysicalOccupancyFixtures.php | 38 +++++++++++++++++++ .../Session/PhysicalOccupancyPaintingTest.php | 29 -------------- 2 files changed, 38 insertions(+), 29 deletions(-) create mode 100644 tests/Support/PhysicalOccupancyFixtures.php diff --git a/tests/Support/PhysicalOccupancyFixtures.php b/tests/Support/PhysicalOccupancyFixtures.php new file mode 100644 index 0000000..663ac11 --- /dev/null +++ b/tests/Support/PhysicalOccupancyFixtures.php @@ -0,0 +1,38 @@ + str_repeat(' ', strlen($row)), explode("\n", $glyphs))); + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource($events, 'EVENT')); + $rows = array_map(static fn(string $row): array => array_fill(0, strlen($row), CollisionType::NONE), explode("\n", $glyphs)); + file_put_contents($directory . '/test-map.data.php', $source ?? ' 'Physical fixture', 'events' => [], 'occupancy' => $rows], true) . ';'); + + return $root; +} + +function getPhysicalOccupancyMap(EditorSession $session): \Ichiloto\Editor\ProjectMap +{ + return new ReflectionProperty(EditorSession::class, 'workspace')->getValue($session)->getMapByIndex(0); +} + +function createPhysicalOccupancyRpc(string $root): Closure +{ + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result'); + + return $request; +} diff --git a/tests/Unit/Session/PhysicalOccupancyPaintingTest.php b/tests/Unit/Session/PhysicalOccupancyPaintingTest.php index 90e4f87..ef1afa0 100644 --- a/tests/Unit/Session/PhysicalOccupancyPaintingTest.php +++ b/tests/Unit/Session/PhysicalOccupancyPaintingTest.php @@ -10,35 +10,6 @@ use Ichiloto\Engine\Events\Enumerations\CollisionType; use Ichiloto\Engine\Field\MapGridSource; -function createPhysicalOccupancyProject(?string $source = null, string $glyphs = "###\n##"): string -{ - $root = makeTemporaryProject('editor-occupancy-paint-'); - $directory = $root . '/assets/Maps/test-map'; - file_put_contents($directory . '/test-map.map.php', MapGridSource::buildSource($glyphs, 'MAP')); - $events = implode("\n", array_map(static fn(string $row): string => str_repeat(' ', strlen($row)), explode("\n", $glyphs))); - file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource($events, 'EVENT')); - $rows = array_map(static fn(string $row): array => array_fill(0, strlen($row), CollisionType::NONE), explode("\n", $glyphs)); - file_put_contents($directory . '/test-map.data.php', $source ?? ' 'Physical fixture', 'events' => [], 'occupancy' => $rows], true) . ';'); - - return $root; -} - -function getPhysicalOccupancyMap(EditorSession $session): \Ichiloto\Editor\ProjectMap -{ - return new ReflectionProperty(EditorSession::class, 'workspace')->getValue($session)->getMapByIndex(0); -} - -function createPhysicalOccupancyRpc(string $root): Closure -{ - $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); - $request = static fn(string $method, array $params): array => - $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); - expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result'); - - return $request; -} - it('reads the resolved physical grid and final collision choices without presentation padding', function () { $root = createPhysicalOccupancyProject(); $session = EditorSession::open($root); From a602229030654a68b2c2b54cc427a49f34eb0948 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 06:00:39 +0200 Subject: [PATCH 254/344] feat(canvas): compose each open map's editing canvas through the Engine's frame composer canvas.exchange and canvas.close give the GUI editor one long-lived canvas per open map: its own renderer client talks to the window through a relay, as a preview field's does, and the Engine's SceneFrameComposer composes the map as it stands in memory, unsaved edits included, from a scene-free source (AuthoringMapSource). Signs keep their WorldSignPlayback per placement while it is unchanged, so their prepared labels survive other edits; a sign covers its cells only once its board and label are drawn. The previous world is kept while the map composes to an equal one, so an unchanged map sends nothing and the window's exchanges settle. A resized map starts a new canvas; closing the map or the session releases everything. Removed: the one-shot worldObjects.preview route, readWorldObjectPreview and WorldObjectPreview::describeWorld, which built a single reset frame without the composer or signs. Their tests now draw through the canvas exchange. Moved: the presentation layer ids to ProjectMap::getPresentationLayerIds. --- docs/manual.md | 5 +- src/Field/AuthoringCanvasHost.php | 120 +++++++++++++ src/Field/AuthoringMapSource.php | 164 ++++++++++++++++++ src/Field/AuthoringSignPlaybacks.php | 65 +++++++ src/Field/WorldObjectPreview.php | 93 ---------- src/ProjectMap.php | 18 ++ src/Session/EditorSession.php | 56 +++++- src/Session/SessionHost.php | 20 +-- tests/Pest.php | 2 + tests/Support/AuthoringCanvasFixtures.php | 46 +++++ tests/Unit/Session/AuthoringCanvasTest.php | 100 +++++++++++ .../Unit/Session/WorldObjectAuthoringTest.php | 43 ++--- .../Unit/Session/WorldObjectResourceTest.php | 4 +- 13 files changed, 599 insertions(+), 137 deletions(-) create mode 100644 src/Field/AuthoringCanvasHost.php create mode 100644 src/Field/AuthoringMapSource.php create mode 100644 src/Field/AuthoringSignPlaybacks.php delete mode 100644 src/Field/WorldObjectPreview.php create mode 100644 tests/Support/AuthoringCanvasFixtures.php create mode 100644 tests/Unit/Session/AuthoringCanvasTest.php diff --git a/docs/manual.md b/docs/manual.md index c6c9569..4cb2d30 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1156,7 +1156,10 @@ the default, under a translation key of the sign's own id (`signs.`), which translations supply. A sign without text, or text on what is not a sign, is refused before anything is written; changing a placement's resource to a sign gives it a label, and changing it away removes -it. Neither the label nor the board changes the map's collision. +it. Neither the label nor the board changes the map's collision. The GUI +editor's Game view draws a placed sign's board and label as the game does, +laid out by the window in its text role; until the label is laid out, the +sign's cells show their glyphs. Why a category can still turn out read-only: a file the editor cannot evaluate, a value it could not write back out, or a comment inside returned diff --git a/src/Field/AuthoringCanvasHost.php b/src/Field/AuthoringCanvasHost.php new file mode 100644 index 0000000..19c7ff9 --- /dev/null +++ b/src/Field/AuthoringCanvasHost.php @@ -0,0 +1,120 @@ +grid = self::getGrid($map); + $this->relay = new PreviewRendererRelay(); + $this->client = new RendererClient($this->relay); + $this->client->start(new RendererSessionConfig('Authoring canvas', $map->getAssetRoot(), $this->grid, RendererProtocolVersion::V2)); + $this->presentation = new RendererPresentation($this->client, $this->grid); + $this->composer = new SceneFrameComposer($map->getAssetRoot()); + $this->signs = new AuthoringSignPlaybacks($map->getAssetRoot()); + } + + /** The grid a map's canvas draws: one renderer cell per map cell, within the renderer's limits. */ + public static function getGrid(ProjectMap $map): RendererGridConfig + { + return new RendererGridConfig(min(self::MAX_COLUMNS, $map->getWidth()), min(self::MAX_ROWS, $map->getHeight()), + FieldViewport::TILE_SIZE, FieldViewport::TILE_SIZE); + } + + /** + * One exchange with the window, presenting the map at a variant selection and preview time. + * + * @param list $events The window's answers, as renderer event lines. + * @param array $variants + * @return list}> + * @throws RendererTransportException When the window reports an error. + */ + public function exchange(array $events, array $variants, float $seconds): array + { + $this->relay->receive($events); + $this->client->pump(); + $invalidate = false; + $expectedGeneration = null; + foreach ($this->client->drainEvents() as $event) { + if ($event->type === RendererEventType::READY) { + $this->ready = true; + } elseif ($event->type === RendererEventType::FRAME_REJECTED) { + $invalidate = true; + $expectedGeneration = max($expectedGeneration ?? 0, $event->expectedGeneration ?? 0); + } elseif ($event->type === RendererEventType::FRAME_ACK) { + $this->presentation->acknowledge($event->generation, $event->presented); + } elseif ($event->type === RendererEventType::ERROR) { + throw new RendererTransportException('Editing canvas error: ' . $event->message, $this->relay->getDiagnostics()); + } + } + foreach ($this->client->drainFrameAcknowledgements() as $acknowledgement) { + $this->presentation->acknowledge($acknowledgement->generation, $acknowledgement->presented); + } + if ($invalidate) { + $this->presentation->invalidate(expectedGeneration: $expectedGeneration); + } + if ($this->ready) { + $this->source = AuthoringMapSource::compose($this->map, $variants, $seconds, $this->signs, $this->client, + $this->grid->columns, $this->grid->rows, $this->source); + // The canvas draws its glyphs itself; the composer's Terminal text is the empty picture of the grid. + $composition = $this->composer->composeFrame($this->source, $this->grid, $this->client->supports(...), + fn(array $excluded, array $worldLayers) => Console::capturePresentation($this->grid->columns, $this->grid->rows, + static function (): void {}, $excluded, $worldLayers, true)); + $this->presentation->presentFrame($composition->prepareFrame($this->presentation), $composition->screenOverlay); + } + + return $this->relay->collect(); + } + + /** What the last composed picture depends on and could not draw, once one has been composed. */ + public function getSource(): ?AuthoringMapSource + { + return $this->source; + } + + /** Releases the signs' prepared labels and closes the renderer client, as closing the map does. */ + public function dispose(): void + { + $this->signs->releaseAll(); + $this->client->shutdown(); + } +} diff --git a/src/Field/AuthoringMapSource.php b/src/Field/AuthoringMapSource.php new file mode 100644 index 0000000..a073b66 --- /dev/null +++ b/src/Field/AuthoringMapSource.php @@ -0,0 +1,164 @@ + $sprites + * @param list $groups + * @param list $assetPaths Every file the picture depends on, for the canvas to watch. + * @param list $diagnostics + */ + private function __construct( + private readonly PresentationWorld $world, + private readonly array $sprites, + private readonly array $groups, + private readonly int $columns, + private readonly int $rows, + private readonly float $scale, + public readonly array $assetPaths, + public readonly array $diagnostics, + public readonly ?string $graphicsIssue, + ) { + } + + /** + * Composes a map at a variant selection and preview time. Signs are laid out through the given provider, the + * canvas's own renderer client; a sign whose label is not prepared yet shows its glyphs until it is. The previous + * picture's world is kept while the map composes to the same one: the renderer retains a world by identity, so + * an unchanged map sends nothing and the window's exchanges settle. + * + * @param array $variants The variant shown for each object, by id; none is the object's own. + */ + public static function compose(ProjectMap $map, array $variants, float $seconds, AuthoringSignPlaybacks $signs, + ?TextPreparationProviderInterface $provider, int $columns, int $rows, ?self $previous = null): self + { + if (! is_finite($seconds) || $seconds < 0 || $seconds > 3600) { + throw new InvalidArgumentException('Preview time requires finite seconds in 0..3600.'); + } + $issue = null; + try { + $graphics = $map->loadGraphics(); + } catch (InvalidArgumentException|\RuntimeException $error) { + $graphics = null; + $issue = $error->getMessage(); + } + $entries = WorldObjectAuthoring::readEntries($map); + $definitions = WorldObjectAuthoring::validateEntries($map, $entries); + if (array_diff(array_keys($variants), array_column($definitions, 'id')) !== []) { + throw new InvalidArgumentException('Preview selection names an unknown map-local object.'); + } + if (array_any($variants, static fn($id): bool => ! is_string($id))) { + throw new InvalidArgumentException('Preview variants must name known variant ids.'); + } + $coverage = $sprites = $groups = $diagnostics = $placed = []; + $assetPaths = array_values($graphics?->tileset->sheets ?? []); + $entriesById = array_column($entries, null, 'id'); + foreach ($definitions as $object) { + $id = 'world-object:' . $map->mapId . ':' . $object->id; + if ($object->resource?->sign !== null) { + // A sign covers its cells only once its board and label are drawn, as the field's does. + $placed[] = $object->id; + $assetPaths[] = $object->resource->sign->asset; + $presentation = $signs->getPlayback($object, $entriesById[$object->id] ?? [])->getPresentation($provider); + if ($presentation !== null) { + $groups[] = new PresentationWorldCanvas($id, $object->x, $object->y, $presentation[1], $presentation[0], + $object->resource->sign->layer); + $coverage = array_replace_recursive($coverage, $object->coverage); + } + continue; + } + $selected = $variants[$object->id] ?? ''; + $definition = $object->sprites; + if ($selected !== '') { + $variant = array_find($object->variants, static fn($variant): bool => $variant['id'] === $selected) + ?? throw new InvalidArgumentException('Choose a known world-object variant to preview.'); + $definition = $variant['sprites']; + } + $role = $definition === null ? null : new FieldSpriteRole($definition, $map->getAssetRoot(), 'World-object authoring ' . $object->id); + if ($definition !== null) { + $assetPaths[] = $definition instanceof FieldPoseAnimation ? $definition->image->asset : $definition->asset; + } + try { + $role?->advance($seconds); + $frame = $role?->getFrame($object->pivot); + if ($definition === null || $frame !== null) { + $coverage = array_replace_recursive($coverage, $object->coverage); + } else { + $diagnostics[] = $object->id . ': the current selected role cannot be drawn; covered glyphs and tiles remain.'; + } + if ($frame !== null) { + $sprites[] = new PresentationSprite($id, $frame->asset, $object->x, $object->y, $frame->width, $frame->height, + $frame->anchor, $frame->layer, $frame->sourceRect, lift: $frame->lift, quarterTurns: $frame->quarterTurns, + pivot: $frame->pivot); + } + } finally { + $role?->release(); + } + } + $signs->retain($placed); + $world = PresentationWorld::getFromLayers($map->getLayerSet(), self::WORLD_ID, $graphics, $map->getAssetRoot(), $coverage); + if ($previous !== null && $previous->world == $world) { + $world = $previous->world; + } + + return new self($world, PresentationSprite::orderedList($sprites), $groups, $columns, $rows, + min($columns / $map->getWidth(), $rows / $map->getHeight()), array_values(array_unique($assetPaths)), $diagnostics, $issue); + } + + public function getPresentationWorld(): ?PresentationWorld + { + return $this->world; + } + + public function getPresentationSprites(): array + { + return $this->sprites; + } + + public function getPresentationWorldCanvases(): array + { + return $this->groups; + } + + /** The whole map in view at its own origin; the canvas pans and zooms over it without moving the camera. */ + public function getPresentationViewport(ConsolePresentationSnapshot|ConsolePresentationChanges $snapshot, array $sprites, array $tiles = []): ?PresentationViewport + { + return new PresentationViewport($this->scale, 0, 0, + new CanvasRectangle(0, 0, $this->columns * FieldViewport::TILE_SIZE, $this->rows * FieldViewport::TILE_SIZE), + spriteIds: array_column($sprites, 'id'), worldId: self::WORLD_ID); + } +} diff --git a/src/Field/AuthoringSignPlaybacks.php b/src/Field/AuthoringSignPlaybacks.php new file mode 100644 index 0000000..74040f7 --- /dev/null +++ b/src/Field/AuthoringSignPlaybacks.php @@ -0,0 +1,65 @@ + */ + private array $playbacks = []; + + public function __construct(private readonly string $assetRoot) + { + } + + /** + * The playback for a sign placement, the existing one while the placement is as it was. + * + * @param array $entry The placement as authored, which identifies what it shows. + */ + public function getPlayback(WorldObjectDefinition $definition, array $entry): WorldSignPlayback + { + $key = json_encode($entry, JSON_THROW_ON_ERROR); + $kept = $this->playbacks[$definition->id] ?? null; + if ($kept !== null && $kept['key'] === $key) { + return $kept['playback']; + } + if ($kept !== null) { + $kept['playback']->release(); + } + $playback = new WorldSignPlayback($definition, $this->assetRoot); + $this->playbacks[$definition->id] = ['key' => $key, 'playback' => $playback]; + + return $playback; + } + + /** + * Releases the playbacks of signs the map no longer places. + * + * @param list $ids The ids of the signs the map places now. + */ + public function retain(array $ids): void + { + foreach (array_diff(array_keys($this->playbacks), $ids) as $id) { + $this->playbacks[$id]['playback']->release(); + unset($this->playbacks[$id]); + } + } + + /** Releases every playback, as closing the canvas does. */ + public function releaseAll(): void + { + $this->retain([]); + } +} diff --git a/src/Field/WorldObjectPreview.php b/src/Field/WorldObjectPreview.php deleted file mode 100644 index bab6050..0000000 --- a/src/Field/WorldObjectPreview.php +++ /dev/null @@ -1,93 +0,0 @@ - - */ - public static function getLayerIds(ProjectMap $map): array - { - $glyphLayers = array_values(array_filter($map->getLayers(), static fn(array $layer): bool => $layer['id'] !== MapLayers::EVENT)); - $ids = []; - foreach ($map->getLayerSet()->layers as $index => $layer) { - $ids[(string) $glyphLayers[$index]['id']] = PresentationLayerPolicy::getMapLayerId($layer); - } - return $ids; - } - - public static function describeWorld(ProjectMap $map, array $variants = [], float $seconds = 0): array - { - if (!is_finite($seconds) || $seconds < 0 || $seconds > 3600) { throw new InvalidArgumentException('Preview time requires finite seconds in 0..3600.'); } - $issue = null; - try { $graphics = $map->loadGraphics(); } - catch (InvalidArgumentException|\RuntimeException $error) { $graphics = null; $issue = $error->getMessage(); } - $entries = WorldObjectAuthoring::readEntries($map); - $definitions = WorldObjectAuthoring::validateEntries($map, $entries); - if (array_diff(array_keys($variants), array_column($definitions, 'id')) !== []) { throw new InvalidArgumentException('Preview selection names an unknown map-local object.'); } - if (array_any($variants, static fn($id): bool => !is_string($id))) { throw new InvalidArgumentException('Preview variants must name known variant ids.'); } - $coverage = $sprites = $diagnostics = []; - $assetPaths = array_values($graphics?->tileset->sheets ?? []); - foreach ($definitions as $object) { - $selected = $variants[$object->id] ?? ''; - $definition = $object->sprites; - if ($selected !== '') { - $variant = array_find($object->variants, static fn($variant): bool => $variant['id'] === $selected) - ?? throw new InvalidArgumentException('Choose a known world-object variant to preview.'); - $definition = $variant['sprites']; - } - $role = $definition === null ? null : new FieldSpriteRole($definition, $map->getAssetRoot(), 'World-object authoring ' . $object->id); - if ($definition !== null) { $assetPaths[] = $definition instanceof FieldPoseAnimation ? $definition->image->asset : $definition->asset; } - try { - $role?->advance($seconds); - $frame = $role?->getFrame($object->pivot); - if ($definition === null || $frame !== null) { - $coverage = array_replace_recursive($coverage, $object->coverage); - } else { $diagnostics[] = $object->id . ': the current selected role cannot be drawn; covered glyphs and tiles remain.'; } - if ($frame !== null) { - $sprites[] = new PresentationSprite('world-object:' . $map->mapId . ':' . $object->id, - $frame->asset, $object->x, $object->y, $frame->width, $frame->height, - $frame->anchor, $frame->layer, $frame->sourceRect, lift: $frame->lift, - quarterTurns: $frame->quarterTurns, pivot: $frame->pivot); - } - } finally { $role?->release(); } - } - $world = PresentationWorld::getFromLayers($map->getLayerSet(), 'map', $graphics, $map->getAssetRoot(), $coverage); - $operations = $world->getOperations(true, true); - $ordered = PresentationSprite::orderedList($sprites); - foreach ($ordered as $order => $sprite) { - $operations[] = ['op' => 'put', 'kind' => 'sprite', 'id' => $sprite->id, - 'value' => [...$sprite->toArray(true, true, true), 'order' => $order]]; - } - $columns = min(512, $map->getWidth()); - $rows = min(256, $map->getHeight()); - return ['map' => $map->mapId, 'assetRoot' => $map->getAssetRoot(), 'layerIds' => self::getLayerIds($map), - 'assetPaths' => array_values(array_unique($assetPaths)), - 'grid' => ['columns' => $columns, 'rows' => $rows, - 'cellWidth' => FieldViewport::TILE_SIZE, 'cellHeight' => FieldViewport::TILE_SIZE], - 'update' => ['frame' => 1, 'baseGeneration' => 0, 'generation' => 1, 'reset' => true, 'present' => true, - 'operations' => $operations, 'viewport' => ['scale' => min($columns / $map->getWidth(), $rows / $map->getHeight()), 'origin' => ['x' => 0, 'y' => 0], - 'clipRect' => ['x' => 0, 'y' => 0, 'width' => $columns * FieldViewport::TILE_SIZE, - 'height' => $rows * FieldViewport::TILE_SIZE], - 'textLayerIds' => [], 'spriteIds' => array_column($ordered, 'id'), 'worldId' => 'map', - 'worldOrigin' => ['column' => 0, 'row' => 0]]], - 'graphicsIssue' => $issue, 'diagnostics' => $diagnostics]; - } -} diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 8841529..41fb142 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -23,6 +23,7 @@ use Ichiloto\Engine\Field\MapLayer; use Ichiloto\Engine\Field\MapLayerSource; use Ichiloto\Engine\Field\MapLayerSet; +use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; use Ichiloto\Engine\Field\MapCollisionResolver; use Ichiloto\Engine\Field\MapPhysicalOccupancy; use Ichiloto\Engine\Events\Enumerations\CollisionType; @@ -569,6 +570,23 @@ public function getLayerSet(): MapLayerSet return $this->layers->getLayerSet(); } + /** + * Each glyph layer's presentation layer id, keyed by the editor's layer id, so an interface can name the layers of + * the composed world as it names the map's own. + * + * @return array + */ + public function getPresentationLayerIds(): array + { + $glyphLayers = array_values(array_filter($this->getLayers(), static fn(array $layer): bool => $layer['id'] !== MapLayers::EVENT)); + $ids = []; + foreach ($this->getLayerSet()->layers as $index => $layer) { + $ids[(string) $glyphLayers[$index]['id']] = PresentationLayerPolicy::getMapLayerId($layer); + } + + return $ids; + } + public function getStoredGridPaths(): array { $this->assertGridSourcesCanonical(); diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 4e1f784..6d62e10 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -55,7 +55,6 @@ use Ichiloto\Editor\Events\EventTypeDefinition; use Ichiloto\Editor\Field\NpcAuthoring; use Ichiloto\Editor\Field\WorldObjectAuthoring; -use Ichiloto\Editor\Field\WorldObjectPreview; use Ichiloto\Editor\Field\FieldSpriteFields; use Ichiloto\Editor\Field\CharacterSheetPreview; use Ichiloto\Editor\Field\NpcChange; @@ -152,6 +151,9 @@ final class EditorSession private ProjectWorkspace $workspace; + /** @var array Each open map's editing canvas, by map id. */ + private array $authoringCanvases = []; + /** The playtest running in the background, or the last one, to report how it ended. */ private ?PlaytestRun $playtest = null; /** Where that playtest began. */ @@ -624,7 +626,7 @@ public function readWorld(string $mapId, bool $tileShadows = false): array } catch (InvalidArgumentException|MapSourceRefusal $error) { throw new SessionRefusal(sprintf('%s cannot be drawn: %s', $mapId, $error->getMessage()), previous: $error); } - $layerIds = WorldObjectPreview::getLayerIds($map); + $layerIds = $map->getPresentationLayerIds(); return [ 'map' => $map->mapId, @@ -3939,13 +3941,52 @@ public function moveWorldObjectVariant(string $mapId, int $revision, string $id, return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->moveVariant($id, $variantId, $offset)); } - public function readWorldObjectPreview(string $mapId, array $variants = [], float $seconds = 0): array + /** + * One exchange with a map's editing canvas: the window's answers in, what it should apply out. The canvas draws + * the map as it stands in memory, unsaved edits included, through the Engine's frame composer, at a variant + * selection and preview time. One canvas lives per open map at its size; a resize starts a new one, whose new + * session id tells the window to start again, and answers meant for an earlier canvas are not applied to it. + * + * @param list $events The window's answers, as renderer event lines. + * @param array $variants + * @return array + * @throws SessionRefusal When the map is unknown or cannot be composed, or the window reports an error. + */ + public function exchangeAuthoringCanvas(string $mapId, array $events, array $variants = [], float $seconds = 0, ?string $sessionId = null): array { + $map = $this->requireMap($mapId); + $host = $this->authoringCanvases[$mapId] ?? null; + if ($host === null || $host->grid != \Ichiloto\Editor\Field\AuthoringCanvasHost::getGrid($map)) { + $host?->dispose(); + $host = $this->authoringCanvases[$mapId] = new \Ichiloto\Editor\Field\AuthoringCanvasHost($map, uniqid('canvas-', true)); + } try { - $map = $this->requireMap($mapId); - return [...WorldObjectPreview::describeWorld($map, $variants, $seconds), 'revision' => $this->getMapRevision($map)]; + $messages = $host->exchange($sessionId === $host->sessionId ? $events : [], $variants, $seconds); + } catch (InvalidArgumentException|MapSourceRefusal|\Ichiloto\Engine\Rendering\Transport\Exceptions\RendererTransportException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); } - catch (InvalidArgumentException|MapSourceRefusal $error) { throw new SessionRefusal($error->getMessage(), previous: $error); } + $source = $host->getSource(); + + return ['sessionId' => $host->sessionId, 'map' => $map->mapId, 'revision' => $this->getMapRevision($map), + 'assetRoot' => $map->getAssetRoot(), 'layerIds' => $map->getPresentationLayerIds(), + 'grid' => ['columns' => $host->grid->columns, 'rows' => $host->grid->rows, + 'cellWidth' => $host->grid->cellWidth, 'cellHeight' => $host->grid->cellHeight], + 'messages' => $messages, 'assetPaths' => $source?->assetPaths ?? [], 'diagnostics' => $source?->diagnostics ?? [], + 'graphicsIssue' => $source?->graphicsIssue]; + } + + /** + * Closes a map's editing canvas, releasing its signs' prepared labels and its renderer client. + * + * @return array{closed: bool} + */ + public function closeAuthoringCanvas(string $mapId): array + { + $host = $this->authoringCanvases[$mapId] ?? null; + $host?->dispose(); + unset($this->authoringCanvases[$mapId]); + + return ['closed' => $host !== null]; } private function createWorldObjectAuthoring(ProjectMap $map): WorldObjectAuthoring @@ -4693,6 +4734,9 @@ private function listBattleUnsavedChanges(): array /** Ends what the session started that would outlive it: a running playtest or battle test. */ public function close(): void { + foreach (array_keys($this->authoringCanvases) as $mapId) { + $this->closeAuthoringCanvas((string) $mapId); + } $this->playtest?->stop(); $this->battleTestRun?->stop(); $this->audition?->stop(); diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index e8ce07a..064fb38 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -150,7 +150,7 @@ private function dispatch(string $method, array $params): array 'worldObjects.read' => $session->readWorldObject(self::requireString($params, 'map'), self::requireString($params, 'id')), 'worldObjects.create' => $session->createWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireString($params, 'id'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), - self::optionalString($params, 'resource'), self::optionalString($params, 'text')), + self::readOptionalString($params, 'resource'), self::readOptionalString($params, 'text')), 'worldObjects.delete' => $session->deleteWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireString($params, 'id')), 'worldObjects.apply' => $session->applyWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireString($params, 'id'), self::requireArray($params, 'key'), self::requireString($params, 'value')), @@ -161,8 +161,10 @@ private function dispatch(string $method, array $params): array is_bool($params['coverage'] ?? false) ? ($params['coverage'] ?? false) : throw new InvalidRequest('"coverage" must be a boolean.')), 'worldObjects.moveVariant' => $session->moveWorldObjectVariant(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireString($params, 'id'), self::requireString($params, 'variant'), self::requireInt($params, 'offset')), - 'worldObjects.preview' => $session->readWorldObjectPreview(self::requireString($params, 'map'), - isset($params['variants']) ? self::requireArray($params, 'variants') : [], isset($params['seconds']) ? self::requireNumber($params, 'seconds') : 0), + 'canvas.exchange' => $session->exchangeAuthoringCanvas(self::requireString($params, 'map'), self::requireLines($params, 'events'), + isset($params['variants']) ? self::requireArray($params, 'variants') : [], isset($params['seconds']) ? self::requireNumber($params, 'seconds') : 0, + self::readOptionalString($params, 'sessionId')), + 'canvas.close' => $session->closeAuthoringCanvas(self::requireString($params, 'map')), 'map.world' => $session->readWorld(self::requireString($params, 'map'), is_bool($params['tileShadows'] ?? false) ? ($params['tileShadows'] ?? false) : throw new InvalidRequest('"tileShadows" must be a boolean.')), 'tiles.palette' => $session->readTilePalette(self::requireString($params, 'map')), @@ -646,18 +648,6 @@ private static function startPlaytest(EditorSession $session, array $params): ar return $session->startPlaytest(self::requireString($params, 'map'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), $start, $renderer); } - /** - * A string parameter that may be absent or null. - * - * @param array $params - */ - private static function optionalString(array $params, string $key): ?string - { - $value = $params[$key] ?? null; - - return $value === null || is_string($value) ? $value : throw new InvalidRequest(sprintf('"%s" must be a string when given.', $key)); - } - /** @param array $params */ private static function requireInt(array $params, string $key): int { diff --git a/tests/Pest.php b/tests/Pest.php index 3bbcb58..8099630 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -380,6 +380,8 @@ function plainTextOfFrame(string $frame, int $width, int $height): string require_once __DIR__ . '/Support/MapFixtures.php'; require_once __DIR__ . '/Support/LayeredMapFixtures.php'; require_once __DIR__ . '/Support/MapGraphicsFixtures.php'; +require_once __DIR__ . '/Support/AuthoringCanvasFixtures.php'; +require_once __DIR__ . '/Support/PhysicalOccupancyFixtures.php'; require_once __DIR__ . '/Support/BattleEntryFixtures.php'; diff --git a/tests/Support/AuthoringCanvasFixtures.php b/tests/Support/AuthoringCanvasFixtures.php new file mode 100644 index 0000000..854a090 --- /dev/null +++ b/tests/Support/AuthoringCanvasFixtures.php @@ -0,0 +1,46 @@ + 2, 'type' => 'ready', 'capabilities' => [...json_decode(previewWindowReady(), true)['capabilities'], + RendererSessionConfig::TEXT_LAYOUT, RendererSessionConfig::CANVAS_LABELS, RendererSessionConfig::WORLD_CANVAS, + RendererSessionConfig::SPRITE_PIVOT, RendererSessionConfig::TILE_BATCHES]], JSON_THROW_ON_ERROR); +} + +/** @return list> */ +function findCanvasMessages(array $answer, string $type): array +{ + return array_values(array_filter($answer['messages'], static fn(array $message): bool => $message['type'] === $type)); +} + +/** The window's acknowledgement of every frame an exchange sent it. */ +function acknowledgeCanvasFrames(array $answer): array +{ + return array_map(static fn(array $frame): string => json_encode(['protocol' => 2, 'type' => 'frame_ack', + 'generation' => $frame['payload']['generation'], 'frame' => $frame['payload']['frame'], 'presented' => true], JSON_THROW_ON_ERROR), + findCanvasMessages($answer, 'frame')); +} + +/** + * A map's whole picture, as a newly opened editing canvas first draws it: the exchange's answer, with every + * operation of the frames it sent. + * + * @param array $variants + * @return array + */ +function drawAuthoringCanvas(EditorSession $session, string $mapId, array $variants = [], float $seconds = 0): array +{ + $session->closeAuthoringCanvas($mapId); + $opened = $session->exchangeAuthoringCanvas($mapId, [], $variants, $seconds); + $drawn = $session->exchangeAuthoringCanvas($mapId, [canvasWindowReady()], $variants, $seconds, $opened['sessionId']); + $session->closeAuthoringCanvas($mapId); + + return [...$drawn, 'operations' => array_merge(...array_map(static fn(array $frame): array => $frame['payload']['operations'], + findCanvasMessages($drawn, 'frame')))]; +} diff --git a/tests/Unit/Session/AuthoringCanvasTest.php b/tests/Unit/Session/AuthoringCanvasTest.php new file mode 100644 index 0000000..59afaa5 --- /dev/null +++ b/tests/Unit/Session/AuthoringCanvasTest.php @@ -0,0 +1,100 @@ + ['wood-sign' => ['name' => 'Wood Sign'," + . " 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sign' => ['asset' => 'Graphics/Boards/Wood.png', 'fontRole' => 'sign', 'left' => 8," + . " 'right' => 8, 'density' => 1, 'padding' => [4, 2, 4, 2], 'minimumWidth' => 24, 'maximumWidth' => 160]]]];\n"); + file_put_contents($root . '/assets/Data/Presentation/text.php', " 'ichiloto.text/1', 'roles' => ['sign' => [" + . "'fonts' => [['asset' => 'Fonts/Open.ttf']], 'fontSize' => 12, 'foreground' => ['kind' => 'rgb', 'r' => 230, 'g' => 215, 'b' => 170]]]];\n"); + $session = EditorSession::open($root); + $session->createWorldObject('test-map', $session->readMap('test-map')['revision'], 'inn-sign', 1, 1, 'wood-sign', 'Inn'); + + return [$root, $session]; +} + +/** The window's measurement of a label it was asked to lay out. */ +function canvasTextPrepared(string $requestId): string +{ + return json_encode(['protocol' => 2, 'type' => 'text_prepared', 'requestId' => $requestId, 'layoutId' => 'layout-inn', + 'metrics' => ['advance' => 18, 'baseline' => 9, 'lineBounds' => ['x' => 0, 'y' => 0, 'width' => 18, 'height' => 12], + 'inkBounds' => ['x' => 0, 'y' => 1, 'width' => 18, 'height' => 10], 'paintBounds' => ['x' => 0, 'y' => 1, 'width' => 18, 'height' => 10]]], + JSON_THROW_ON_ERROR); +} + +it('draws the map only once the window is ready, under one session for the open map', function () { + [, $session] = createCanvasSignProject(); + $first = $session->exchangeAuthoringCanvas('test-map', []); + + expect(findCanvasMessages($first, 'frame'))->toBe([]) + ->and($first)->toMatchArray(['map' => 'test-map', 'grid' => ['columns' => 4, 'rows' => 2, 'cellWidth' => 48, 'cellHeight' => 48]]); + $ready = $session->exchangeAuthoringCanvas('test-map', [canvasWindowReady()], sessionId: $first['sessionId']); + + expect($ready['sessionId'])->toBe($first['sessionId']) + ->and(findCanvasMessages($ready, 'frame'))->not->toBe([]) + ->and($ready['assetPaths'])->toContain('Graphics/Boards/Wood.png'); + $session->closeAuthoringCanvas('test-map'); +}); + +it('asks the window to lay out a sign\'s label, and draws its board once the label is measured', function () { + [, $session] = createCanvasSignProject(); + $first = $session->exchangeAuthoringCanvas('test-map', []); + $ready = $session->exchangeAuthoringCanvas('test-map', [canvasWindowReady()], sessionId: $first['sessionId']); + $request = findCanvasMessages($ready, 'prepare_text')[0] ?? null; + + expect($request['payload']['spec']['text'] ?? null)->toBe('Inn') + ->and(json_encode($ready['messages']))->not->toContain('world_canvas'); + $measured = $session->exchangeAuthoringCanvas('test-map', [canvasTextPrepared($request['payload']['requestId'])], sessionId: $first['sessionId']); + + expect(json_encode($measured['messages']))->toContain('world-object:test-map:inn-sign')->toContain('layout-inn'); + $session->closeAuthoringCanvas('test-map'); +}); + +it('sends nothing while the map is unchanged, and the edit once it changes', function () { + [, $session] = createCanvasSignProject(); + $first = $session->exchangeAuthoringCanvas('test-map', []); + $ready = $session->exchangeAuthoringCanvas('test-map', [canvasWindowReady()], sessionId: $first['sessionId']); + $request = findCanvasMessages($ready, 'prepare_text')[0]; + $measured = $session->exchangeAuthoringCanvas('test-map', [...acknowledgeCanvasFrames($ready), + canvasTextPrepared($request['payload']['requestId'])], sessionId: $first['sessionId']); + $settled = $session->exchangeAuthoringCanvas('test-map', acknowledgeCanvasFrames($measured), sessionId: $first['sessionId']); + + expect(findCanvasMessages($settled, 'frame'))->toBe([]); + $map = $session->readMap('test-map'); + $session->paint('test-map', $map['revision'], $map['baseLayer'], [[0, 0]], '#'); + $edited = $session->exchangeAuthoringCanvas('test-map', [], sessionId: $first['sessionId']); + + expect(findCanvasMessages($edited, 'frame'))->toHaveCount(1) + ->and($edited['revision'])->toBeGreaterThan($map['revision']); + $session->closeAuthoringCanvas('test-map'); +}); + +it('ignores answers meant for an earlier canvas, and releases a closed one', function () { + [, $session] = createCanvasSignProject(); + $first = $session->exchangeAuthoringCanvas('test-map', []); + + // A READY for a session this canvas is not is not applied: nothing is drawn. + expect(findCanvasMessages($session->exchangeAuthoringCanvas('test-map', [canvasWindowReady()], sessionId: 'stale'), 'frame'))->toBe([]) + ->and($session->closeAuthoringCanvas('test-map'))->toBe(['closed' => true]) + ->and($session->closeAuthoringCanvas('test-map'))->toBe(['closed' => false]) + ->and($session->exchangeAuthoringCanvas('test-map', [])['sessionId'])->not->toBe($first['sessionId']); + $session->closeAuthoringCanvas('test-map'); +}); diff --git a/tests/Unit/Session/WorldObjectAuthoringTest.php b/tests/Unit/Session/WorldObjectAuthoringTest.php index a583330..6042f21 100644 --- a/tests/Unit/Session/WorldObjectAuthoringTest.php +++ b/tests/Unit/Session/WorldObjectAuthoringTest.php @@ -115,16 +115,16 @@ function applyWorldObjectRow(EditorSession $session, string $field, string $valu $layer = $session->readMap('test-map')['layers'][0]['name']; applyWorldObjectRow($session, 'covers.layer', $layer); applyWorldObjectRow($session, 'sprites2d.asset', 'Graphics/Props/Replaceable.png'); - $preview = $session->readWorldObjectPreview('test-map'); - $sprite = array_find($preview['update']['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'); + $preview = drawAuthoringCanvas($session, 'test-map'); + $sprite = array_find($preview['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'); expect($sprite['value']['id'])->toBe('world-object:test-map:fixture-prop') ->and($sprite['value']['pivot'])->toBe(['x' => .5, 'y' => 1.0]); unlink($root . '/assets/Graphics/Props/Replaceable.png'); - $missing = $session->readWorldObjectPreview('test-map'); + $missing = drawAuthoringCanvas($session, 'test-map'); expect($missing['diagnostics'])->toHaveCount(1) - ->and(array_filter($missing['update']['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'))->toBe([]); + ->and(array_filter($missing['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'))->toBe([]); writeTilesetTestPng($root . '/assets/Graphics/Props/Replaceable.png', 24, 32); - expect($session->readWorldObjectPreview('test-map')['diagnostics'])->toBe([]); + expect(drawAuthoringCanvas($session, 'test-map')['diagnostics'])->toBe([]); }); it('exports canvas owner revision and layer identities with transient variants and current asset paths', function () { @@ -140,10 +140,10 @@ function applyWorldObjectRow(EditorSession $session, string $field, string $valu applyWorldObjectRow($session, 'variant0Conditions', 'switch:existing-condition'); $before = $session->readMap('test-map'); $source = file_get_contents($file); - $base = $session->readWorldObjectPreview('test-map'); - $absent = $session->readWorldObjectPreview('test-map', ['fixture-prop' => 'absent'], .25); + $base = drawAuthoringCanvas($session, 'test-map'); + $absent = drawAuthoringCanvas($session, 'test-map', ['fixture-prop' => 'absent'], .25); $hasFloor = static function (array $preview): bool { - foreach ($preview['update']['operations'] as $op) { + foreach ($preview['operations'] as $op) { if (($op['op'] ?? '') !== 'worldTiles' || $op['layerId'] !== 'tiles:floor') { continue; } foreach ($op['rows'] as $row) { if ($row['row'] === 1 && array_any($row['cells'], static fn(array $cell): bool => $cell['column'] === 1)) { return true; } @@ -151,7 +151,7 @@ function applyWorldObjectRow(EditorSession $session, string $field, string $valu } return false; }; - $sprite = array_find($base['update']['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'); + $sprite = array_find($base['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'); expect($base['revision'])->toBe($before['revision']) ->and($base['layerIds'])->toBe($session->readWorld('test-map')['layerIds']) ->and($base['assetPaths'])->toContain('Graphics/Props/Replaceable.png') @@ -160,17 +160,17 @@ function applyWorldObjectRow(EditorSession $session, string $field, string $valu ->and($sprite['value']['pivot'])->toBe(['x' => .5, 'y' => 1.0]) ->and($hasFloor($base))->toBeFalse() ->and($hasFloor($absent))->toBeFalse() - ->and(array_filter($absent['update']['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'))->toBe([]) + ->and(array_filter($absent['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'))->toBe([]) ->and($session->readMap('test-map'))->toBe($before) ->and(file_get_contents($file))->toBe($source); unlink($root . '/assets/Graphics/Props/Replaceable.png'); - $missing = $session->readWorldObjectPreview('test-map'); + $missing = drawAuthoringCanvas($session, 'test-map'); expect($missing['assetPaths'])->toContain('Graphics/Props/Replaceable.png') ->and($missing['diagnostics'])->toHaveCount(1)->and($hasFloor($missing))->toBeTrue(); writeTilesetTestPng($root . '/assets/Graphics/Props/Replaceable.png', 24, 32); $session->saveMap('test-map'); $reopened = EditorSession::open($root); - expect($reopened->readWorldObjectPreview('test-map')['diagnostics'])->toBe([]) + expect(drawAuthoringCanvas($reopened, 'test-map')['diagnostics'])->toBe([]) ->and($reopened->readMap('test-map')['worldObjects'][0]['sprites2d']['asset'])->toBe('Graphics/Props/Replaceable.png') ->and($reopened->readMap('test-map')['worldObjects'][0]['anchor'])->toBe($before['worldObjects'][0]['anchor']); }); @@ -179,13 +179,13 @@ function applyWorldObjectRow(EditorSession $session, string $field, string $valu [$root, , $session] = createWorldObjectProject(); $path = 'Graphics/Tilesets/Home_A2.png'; unlink($root . '/assets/' . $path); - $missing = $session->readWorldObjectPreview('test-map'); + $missing = drawAuthoringCanvas($session, 'test-map'); expect($missing['assetPaths'])->toContain($path) - ->and(array_filter($missing['update']['operations'], static fn(array $op): bool => ($op['layerId'] ?? '') === 'tiles:floor'))->toBe([]); + ->and(array_filter($missing['operations'], static fn(array $op): bool => ($op['layerId'] ?? '') === 'tiles:floor'))->toBe([]); writeTilesetTestPng($root . '/assets/' . $path, 32, 24); - $restored = $session->readWorldObjectPreview('test-map'); + $restored = drawAuthoringCanvas($session, 'test-map'); expect($restored['assetPaths'])->toContain($path) - ->and(array_filter($restored['update']['operations'], static fn(array $op): bool => ($op['layerId'] ?? '') === 'tiles:floor'))->not->toBe([]); + ->and(array_filter($restored['operations'], static fn(array $op): bool => ($op['layerId'] ?? '') === 'tiles:floor'))->not->toBe([]); }); it('routes real RPC ownership and cinematic object selectors without a global object database', function () { @@ -196,8 +196,11 @@ function applyWorldObjectRow(EditorSession $session, string $field, string $valu expect(array_column($hello['result']['project']['databases'], 'key'))->not->toContain('world_objects'); $view = $request('worldObjects.read', ['map' => 'test-map', 'id' => 'fixture-prop'])['result']; expect($request('worldObjects.place', ['map' => 'test-map', 'id' => 'fixture-prop', 'revision' => $view['revision'], 'x' => 2, 'y' => 1])['result']['changed'])->toBeTrue(); - expect($request('worldObjects.preview', ['map' => 'test-map'])['result']['update']['reset'])->toBeTrue() - ->and($request('worldObjects.preview', ['map' => 'test-map', 'seconds' => 'wrong'])['error']['kind'])->toBe('request'); + $opened = $request('canvas.exchange', ['map' => 'test-map', 'events' => []])['result']; + $drawn = $request('canvas.exchange', ['map' => 'test-map', 'events' => [canvasWindowReady()], 'sessionId' => $opened['sessionId']])['result']; + expect(findCanvasMessages($drawn, 'frame')[0]['payload']['reset'])->toBeTrue() + ->and($request('canvas.exchange', ['map' => 'test-map', 'events' => [], 'seconds' => 'wrong'])['error']['kind'])->toBe('request') + ->and($request('canvas.close', ['map' => 'test-map'])['result'])->toBe(['closed' => true]); expect(array_column($session->listReferences('test-map', 'map_world_objects'), 'value'))->toBe(['fixture-prop']); $camera = CutsceneSchemas::cinematicCommandVariants(true)['camera'](['operation' => 'focus', 'target' => ['kind' => 'world_object', 'id' => 'fixture-prop']]); expect(array_find($camera, static fn($field): bool => $field->key === 'target.id')->reference)->toBe('map_world_objects'); @@ -258,8 +261,8 @@ function applyWorldObjectRow(EditorSession $session, string $field, string $valu $session->redo(); $session->saveMap('test-map'); expect(EditorSession::open($root)->readWorldObject('test-map', 'fixture-prop')['worldObject']['variants'])->toBe(['first', 'second']) - ->and(fn() => $session->readWorldObjectPreview('test-map', ['fixture-prop'=>true]))->toThrow(SessionRefusal::class, 'known variant ids') - ->and(fn() => $session->readWorldObjectPreview('test-map', ['fixture-prop'=>'unknown']))->toThrow(SessionRefusal::class, 'known world-object variant'); + ->and(fn() => drawAuthoringCanvas($session, 'test-map', ['fixture-prop'=>true]))->toThrow(SessionRefusal::class, 'known variant ids') + ->and(fn() => drawAuthoringCanvas($session, 'test-map', ['fixture-prop'=>'unknown']))->toThrow(SessionRefusal::class, 'known world-object variant'); }); it('preserves known layer references through renames and refuses dangling removal atomically', function () { diff --git a/tests/Unit/Session/WorldObjectResourceTest.php b/tests/Unit/Session/WorldObjectResourceTest.php index 67294c5..3c70cd7 100644 --- a/tests/Unit/Session/WorldObjectResourceTest.php +++ b/tests/Unit/Session/WorldObjectResourceTest.php @@ -40,8 +40,8 @@ function findPlacementRow(EditorSession $session, string $field): ?array it('previews a placed resource with the resource\'s own art, and watches that art', function () { [, , $session] = createResourcePlacementProject(); - $preview = $session->readWorldObjectPreview('test-map'); - $sprite = array_find($preview['update']['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'); + $preview = drawAuthoringCanvas($session, 'test-map'); + $sprite = array_find($preview['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'); expect($preview['diagnostics'])->toBe([]) ->and($sprite['value']['id'])->toBe('world-object:test-map:grove-001') From 7ae8955caed6f2b265b8745faf0a6fc7e32b59a3 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 11:36:10 +0200 Subject: [PATCH 255/344] feat(text-roles): author glyph effects, and never rewrite objects only a factory can build Text Roles gain a Glyph Effects choice: None, or an outline and a shadow, each with its own colour, written as the Engine's plain descriptor, so the file stays editable. Colours of any kind reshape when their kind changes. Fixed: the shared PHP value exporter rebuilt any object whose properties matched its constructor, even when that constructor is private, as the Engine's PresentationColor is. Saving a text role in a file holding constructed glyph effects therefore wrote new PresentationColor(...) calls that cannot load. An object that cannot be instantiated is now unexportable, so such a file stays read-only, with the reason named, and an edit is refused before anything is written. This narrows what the exporter rewrites: factory-only objects are no longer written back. --- docs/manual.md | 9 +- src/Database/PhpValueExporter.php | 6 ++ src/Field/TextRoleFields.php | 122 +++++++++++++++++++---- tests/Unit/ObjectExportTest.php | 23 +++++ tests/Unit/Session/SignAuthoringTest.php | 41 ++++++++ 5 files changed, 182 insertions(+), 19 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 4cb2d30..c8c3687 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1148,7 +1148,11 @@ padding and widths in logical pixels. Switching what a resource shows keeps the image chosen. Text Roles name the project's own font files, first choice first, with a size and a colour: the Engine embeds no font, so a project uses whichever fonts it has the right to. A new role starts on the project's first -font, and the file gains its schema id when it is first written. +font, and the file gains its schema id when it is first written. A role's +Glyph Effects give its letters an outline and a shadow together, each with its +own colour: a one-pixel shadow down and to the right lifts letters off a board, +and a light one cut the same way reads as engraving. Choosing None removes them. +Effects written in the file as constructor calls are shown but never rewritten. A sign is placed as a world object naming a sign-board resource, with the text its board shows. Its label is translatable from the start: the text is @@ -1162,7 +1166,8 @@ laid out by the window in its text role; until the label is laid out, the sign's cells show their glyphs. Why a category can still turn out read-only: a file the editor cannot -evaluate, a value it could not write back out, or a comment inside returned +evaluate, a value it could not write back out (such as an object only its own +factory may build, which a rewritten file could not call), or a comment inside returned data that holds objects or is not an array literal are each a reason, and the status line names it. You can still browse everything: an enemy shows its level, every stat, its sprite, its battle rewards, and its element affinities. diff --git a/src/Database/PhpValueExporter.php b/src/Database/PhpValueExporter.php index cfa9c83..bcff08f 100644 --- a/src/Database/PhpValueExporter.php +++ b/src/Database/PhpValueExporter.php @@ -191,6 +191,12 @@ public static function constructorArguments(object $value): ?array $reflection = new ReflectionClass($value); $constructor = $reflection->getConstructor(); + // Writing 'new' needs a constructor anyone may call: a class built only through its own factories, such as + // one with a private constructor, would be written back as a file that cannot load. + if (! $reflection->isInstantiable()) { + return null; + } + if ($constructor === null) { // Nothing to rebuild it from. An object carrying state with no // constructor to put it back would be exported as an empty shell, diff --git a/src/Field/TextRoleFields.php b/src/Field/TextRoleFields.php index 4bec5bd..7fa8812 100644 --- a/src/Field/TextRoleFields.php +++ b/src/Field/TextRoleFields.php @@ -26,9 +26,16 @@ final class TextRoleFields { public const string CATEGORY = 'text_roles'; - /** The colour kinds a role's foreground may take. */ + /** The colour kinds a role's foreground and glyph effects may take. */ private const array COLOUR_KINDS = ['rgb', 'ansi16', 'ansi256']; + /** Whether a role's glyphs carry an outline and a shadow, as the Engine's plain descriptor holds both or neither. */ + public const string NO_EFFECTS = 'None'; + public const string OUTLINE_AND_SHADOW = 'Outline and shadow'; + + /** The colours a role names, each a typed descriptor reshaped when its kind changes. */ + private const array COLOUR_PATHS = ['foreground', 'glyphEffects.outline.color', 'glyphEffects.shadow.color']; + public static function getSchema(): RecordSchema { return new RecordSchema( @@ -68,20 +75,60 @@ public static function makeBlank(string $name, string $projectRoot): array /** @return list */ public static function getFields(array $entry): array { - $kind = $entry['foreground']['kind'] ?? 'rgb'; - $colour = $kind === 'rgb' - ? [new RecordField('foreground.r', 'Red (0..255)', InputControlType::INTEGER), - new RecordField('foreground.g', 'Green (0..255)', InputControlType::INTEGER), - new RecordField('foreground.b', 'Blue (0..255)', InputControlType::INTEGER)] - : [new RecordField('foreground.index', 'Palette Index', InputControlType::INTEGER)]; - return [ new RecordField('id', 'Stable Id'), new RecordField('fontSize', 'Font Size', InputControlType::FLOAT), - new RecordField('foreground.kind', 'Colour Kind', options: self::COLOUR_KINDS), - ...$colour, - // Glyph effects are typed PHP values; they are kept as authored, never rewritten here. - ...(array_key_exists('glyphEffects', $entry) ? [new RecordField('glyphEffects', 'Glyph Effects', isReadOnly: true)] : []), + ...self::getColourFields($entry, 'foreground', 'Colour'), + ...self::getEffectFields($entry), + ]; + } + + /** + * A role's glyph effects, as the Engine's plain descriptor holds them: an outline and a shadow, each with its + * colour. Effects written as constructed PHP values are shown and kept as authored, never rewritten here. + * + * @return list + */ + private static function getEffectFields(array $entry): array + { + $effects = $entry['glyphEffects'] ?? null; + if ($effects !== null && ! is_array($effects)) { + return [new RecordField('glyphEffects', 'Glyph Effects', isReadOnly: true)]; + } + $choice = new RecordField('effects', 'Glyph Effects', options: [self::NO_EFFECTS, self::OUTLINE_AND_SHADOW], + displayDefault: $effects === null ? self::NO_EFFECTS : self::OUTLINE_AND_SHADOW); + if ($effects === null) { + return [$choice]; + } + + return [ + $choice, + new RecordField('glyphEffects.outline.width', 'Outline Width (0..4 px)', InputControlType::FLOAT), + ...self::getColourFields($entry, 'glyphEffects.outline.color', 'Outline Colour'), + new RecordField('glyphEffects.shadow.offsetX', 'Shadow Offset X (-8..8 px)', InputControlType::FLOAT), + new RecordField('glyphEffects.shadow.offsetY', 'Shadow Offset Y (-8..8 px)', InputControlType::FLOAT), + new RecordField('glyphEffects.shadow.sigma', 'Shadow Blur (0..4)', InputControlType::FLOAT), + new RecordField('glyphEffects.shadow.opacity', 'Shadow Opacity (0..1)', InputControlType::FLOAT), + ...self::getColourFields($entry, 'glyphEffects.shadow.color', 'Shadow Colour'), + ]; + } + + /** + * A colour's kind and the components that kind takes. + * + * @return list + */ + private static function getColourFields(array $entry, string $path, string $label): array + { + $kind = self::readPath($entry, $path)['kind'] ?? 'rgb'; + + return [ + new RecordField($path . '.kind', $label . ' Kind', options: self::COLOUR_KINDS), + ...($kind === 'rgb' + ? [new RecordField($path . '.r', $label . ' Red (0..255)', InputControlType::INTEGER), + new RecordField($path . '.g', $label . ' Green (0..255)', InputControlType::INTEGER), + new RecordField($path . '.b', $label . ' Blue (0..255)', InputControlType::INTEGER)] + : [new RecordField($path . '.index', $label . ' Palette Index', InputControlType::INTEGER)]), ]; } @@ -95,13 +142,54 @@ public static function checkEdit(array $entry, string $field): array throw new RecordRefusal($error->getMessage(), previous: $error); } } - if ($field === 'foreground.kind') { - $kind = $entry['foreground']['kind'] ?? 'rgb'; - $entry['foreground'] = $kind === 'rgb' - ? ['kind' => 'rgb', 'r' => 255, 'g' => 255, 'b' => 255] - : ['kind' => $kind, 'index' => $entry['foreground']['index'] ?? 15]; + if ($field === 'effects') { + // The descriptor holds an outline and a shadow together, so effects come and go whole. + $choice = $entry['effects'] ?? null; + unset($entry['effects']); + if ($choice === self::OUTLINE_AND_SHADOW && ! is_array($entry['glyphEffects'] ?? null)) { + $entry['glyphEffects'] = self::makeEffects(); + } elseif ($choice === self::NO_EFFECTS) { + unset($entry['glyphEffects']); + } + } + foreach (self::COLOUR_PATHS as $path) { + if ($field === $path . '.kind') { + $colour = self::readPath($entry, $path); + $kind = $colour['kind'] ?? 'rgb'; + $light = $path === 'foreground'; + $entry = self::writePath($entry, $path, $kind === 'rgb' + ? ['kind' => 'rgb', 'r' => $light ? 255 : 0, 'g' => $light ? 255 : 0, 'b' => $light ? 255 : 0] + : ['kind' => $kind, 'index' => $colour['index'] ?? ($light ? 15 : 0)]); + } + } + + return $entry; + } + + /** Glyph effects as they first appear: no outline, and a hard one-pixel shadow down and to the right, in black. */ + private static function makeEffects(): array + { + return ['outline' => ['width' => 0, 'color' => ['kind' => 'rgb', 'r' => 0, 'g' => 0, 'b' => 0]], + 'shadow' => ['offsetX' => 1, 'offsetY' => 1, 'sigma' => 0, 'opacity' => 1, + 'color' => ['kind' => 'rgb', 'r' => 0, 'g' => 0, 'b' => 0]]]; + } + + /** @return array The value at a dotted path, or none. */ + private static function readPath(array $entry, string $path): array + { + $value = $entry; + foreach (explode('.', $path) as $key) { + $value = is_array($value) ? $value[$key] ?? null : null; } + return is_array($value) ? $value : []; + } + + private static function writePath(array $entry, string $path, array $value): array + { + [$key, $rest] = array_pad(explode('.', $path, 2), 2, null); + $entry[$key] = $rest === null ? $value : self::writePath(is_array($entry[$key] ?? null) ? $entry[$key] : [], $rest, $value); + return $entry; } diff --git a/tests/Unit/ObjectExportTest.php b/tests/Unit/ObjectExportTest.php index a36e918..73f6354 100644 --- a/tests/Unit/ObjectExportTest.php +++ b/tests/Unit/ObjectExportTest.php @@ -35,6 +35,22 @@ public function __construct(array $entries = []) } } +/** + * A value built only through its own factory, as the Engine's colours are: its + * properties match its constructor, but no file may call that constructor. + */ +final class FactoryOnlyRecord +{ + private function __construct(public readonly string $name) + { + } + + public static function named(string $name): self + { + return new self($name); + } +} + it('exports an object as the constructor call that rebuilds it', function () { $code = PhpValueExporter::export(new ExportableRecord('S-Potion', 50, ['heal'])); @@ -71,6 +87,13 @@ public function __construct(array $entries = []) ->and($reason)->toContain('cannot be rebuilt'); }); +it('refuses an object only its own factory can build, rather than writing a call that cannot load', function () { + $reason = PhpValueExporter::describeUnexportable(['role' => ['colour' => FactoryOnlyRecord::named('black')]]); + + expect($reason)->toContain('FactoryOnlyRecord') + ->and(PhpValueExporter::isExportable(FactoryOnlyRecord::named('black')))->toBeFalse(); +}); + it('accepts an object every argument of which can be read back', function () { expect(PhpValueExporter::describeUnexportable([new ExportableRecord('A')]))->toBeNull() ->and(PhpValueExporter::isExportable(new ExportableRecord('A')))->toBeTrue(); diff --git a/tests/Unit/Session/SignAuthoringTest.php b/tests/Unit/Session/SignAuthoringTest.php index 57afd2d..d8993f0 100644 --- a/tests/Unit/Session/SignAuthoringTest.php +++ b/tests/Unit/Session/SignAuthoringTest.php @@ -96,6 +96,47 @@ function findSignPlacementRow(EditorSession $session, string $id, string $field) ->and(fn() => applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'id', 'Sign Font'))->toThrow(SessionRefusal::class); }); +it('gives a role an outline and shadow as plain data the Engine loads, and takes them away whole', function () { + [$root, $session] = createSignProject(); + $session->createDatabaseRecord(TextRoleFields::CATEGORY); + applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'id', 'sign'); + applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'effects', TextRoleFields::OUTLINE_AND_SHADOW); + applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'glyphEffects.shadow.opacity', '0.5'); + applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'glyphEffects.shadow.color.kind', 'ansi16'); + applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'glyphEffects.outline.width', '1'); + $session->saveDatabase(TextRoleFields::CATEGORY); + + $effects = ProjectTextCatalog::load($root . '/assets')->getRole('sign')->glyphEffects; + expect($effects->shadowOpacity)->toBe(0.5) + ->and($effects->shadowOffsetX)->toBe(1.0) + ->and($effects->outlineWidth)->toBe(1.0) + ->and($effects->shadowColor->toArray())->toBe(['kind' => 'ansi16', 'index' => 0]) + ->and((string) file_get_contents($root . '/assets/Data/Presentation/text.php'))->not->toContain('new '); + applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'effects', TextRoleFields::NO_EFFECTS); + $session->saveDatabase(TextRoleFields::CATEGORY); + + expect(ProjectTextCatalog::load($root . '/assets')->getRole('sign')->glyphEffects)->toBeNull() + ->and(findSignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'glyphEffects.shadow.opacity'))->toBeNull(); +}); + +it('keeps a text file built from constructor calls read-only, refusing an edit before writing anything', function () { + [$root] = createSignProject(); + $path = $root . '/assets/Data/Presentation/text.php'; + file_put_contents($path, " 'ichiloto.text/1', 'roles' => ['sign' => ['fonts' => [['asset' => 'Fonts/Open.ttf']], 'fontSize' => 12," + . " 'foreground' => ['kind' => 'rgb', 'r' => 1, 'g' => 2, 'b' => 3], 'glyphEffects' => new CanvasGlyphEffects(1," + . " PresentationColor::rgb(0, 0, 0), 1, 1, 0, 1, PresentationColor::rgb(0, 0, 0))]]];\n"); + $source = (string) file_get_contents($path); + $session = EditorSession::open($root); + + // Colours are built through their factories, so a rewritten file calling their constructor could not load. + expect($session->readDatabaseRecord(TextRoleFields::CATEGORY, 0)['readOnly'])->toContain('PresentationColor') + ->and(fn() => applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'fontSize', '14'))->toThrow(SessionRefusal::class) + ->and((string) file_get_contents($path))->toBe($source) + ->and(ProjectTextCatalog::load($root . '/assets')->getRole('sign')->glyphEffects->shadowOffsetX)->toBe(1.0); +}); + it('places a sign with the text it shows under a translation key of its own id', function () { [$root, $session] = createSignProject(); $session->createWorldObject('test-map', $session->readMap('test-map')['revision'], 'inn-sign', 1, 1, 'wood-sign', 'Inn'); From 28e20fce7e671c422e25a26f5dd637929f198753 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 12:14:13 +0200 Subject: [PATCH 256/344] feat(validation): warn for ground tiles that can show the background through see-through edges Project validation now warns, on every map with a kind, for cells whose ground tiles (A1 to A5 sheets, from sheets the Engine can use) can leave the background showing in some independent animation-frame combination, by the Engine's TileCoverage: the dark seam the game shows when no ground is painted beneath. Upper-sheet props are not judged. It is a warning only and changes no map; without GD it says it could not check instead of passing. --- docs/manual.md | 6 ++ src/Validation/MapGraphicsValidator.php | 68 +++++++++++++++++- .../Unit/Validation/SeeThroughGroundTest.php | 72 +++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Validation/SeeThroughGroundTest.php diff --git a/docs/manual.md b/docs/manual.md index c8c3687..cc60f9f 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -504,6 +504,12 @@ which glyphs: piece draws, such as a house's walls over blank cells, are not judged; - cells showing the tileset's missing-art placeholder (its `missingArt` tile), which mark art nobody could yet infer. +- cells whose ground tiles (the A1 to A5 sheets) can show the background + through see-through edges in some animation frame, because no ground is + painted beneath them: the game shows that as a dark seam. Paint the map's + base or floor beneath on a lower tile layer. Upper-sheet props are + see-through by design and are not judged. This reads the sheets through + PHP's GD extension; without it, validation says it could not check. These are art still to do, not faults: the game plays the same either way, so saving a map does not warn about them; `ichiloto validate` reports them. diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php index 074eedf..3d0aade 100644 --- a/src/Validation/MapGraphicsValidator.php +++ b/src/Validation/MapGraphicsValidator.php @@ -14,6 +14,8 @@ use Ichiloto\Engine\Field\MapLayerSet; use Ichiloto\Engine\Field\MapTileLayer; use Ichiloto\Engine\Rendering\Sprites\PngAssetPreflight; +use Ichiloto\Engine\Rendering\Tilesets\AutotileShape; +use Ichiloto\Engine\Rendering\Tilesets\TileCoverage; use Ichiloto\Engine\Rendering\Tilesets\TileId; use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Ichiloto\Engine\Rendering\Tilesets\TilesetSheet; @@ -39,6 +41,8 @@ final class MapGraphicsValidator private const string GLYPH_FALLBACK = 'The game shows this map\'s terminal glyphs instead of its graphics.'; /** How many cells a coverage warning names before summing up the rest. */ private const int CELLS_NAMED = 3; + /** The sheets whose tiles are ground, judged for see-through edges. */ + private const array GROUND_SHEETS = [TilesetSheet::A1, TilesetSheet::A2, TilesetSheet::A3, TilesetSheet::A4, TilesetSheet::A5]; private function __construct() { @@ -102,7 +106,8 @@ public static function validateCoverage(ProjectMap $map): array $shown[$cell['y']][$cell['x']] = $cell; } - $issues = [...self::checkStalePieceTiles($map, $tileset, $layers), ...self::checkMissingArtTiles($map, $tileset, $layers)]; + $issues = [...self::checkStalePieceTiles($map, $tileset, $layers), ...self::checkMissingArtTiles($map, $tileset, $layers), + ...self::checkSeeThroughGround($map, $tileset, $layers)]; foreach ($map->getNpcs()->all() as $npc) { $name = self::formatNpcName($npc); if (! array_key_exists('sprites2d', $npc->toArray())) { @@ -326,6 +331,67 @@ private static function checkMissingArtTiles(ProjectMap $map, Tileset $tileset, return $issues; } + /** + * Warns for cells where ground tiles (the A1 to A5 sheets) leave the background showing: a tile with see-through + * edges that blend into the ground beneath it, with no ground authored beneath it, shows the background through + * those edges as a dark seam in the game. Every animation frame counts, by the Engine's own tile coverage. Upper + * sheet props and world objects are see-through by design and are not judged. + * + * @param list $layers The readable tile layers. + * @return list + */ + private static function checkSeeThroughGround(ProjectMap $map, Tileset $tileset, array $layers): array + { + // Tiles from a sheet the Engine cannot use draw nothing, so their glyphs show; checkSheets() says why. + $usable = array_keys($tileset->getUsableSheets($map->getAssetRoot())['sheets'] ?? []); + if ($usable === []) { + return []; + } + usort($layers, static fn(MapTileLayer $a, MapTileLayer $b): int => $a->order <=> $b->order); + $grids = array_map(static fn(MapTileLayer $layer): array => AutotileShape::resolveLayer($layer->tiles), $layers); + // One reader per pass, so replaced artwork is read as it is now. + $coverage = new TileCoverage($tileset, $map->getAssetRoot()); + $verdicts = []; + $cells = []; + foreach ($grids[0] ?? [] as $y => $row) { + foreach ($row as $x => $_) { + $stack = []; + foreach ($grids as $grid) { + $tile = (int) ($grid[$y][$x] ?? TileId::EMPTY); + $sheet = $tile === TileId::EMPTY ? null : TileId::getSheet($tile); + if (in_array($sheet, self::GROUND_SHEETS, true) && in_array($sheet->value, $usable, true)) { + $stack[] = $tile; + } + } + if ($stack === []) { + continue; + } + $signature = implode(',', $stack); + if (! isset($verdicts[$signature])) { + $verdicts[$signature] = $coverage->findUncovered($stack); + if ($verdicts[$signature]['diagnostic'] !== null) { + // The artwork or GD cannot be read: say so once, never guess that the cells are covered. + return [Issue::warning($map->mapId, 'Its ground could not be checked for see-through edges: ' + . $verdicts[$signature]['diagnostic'], 'Install PHP GD, or repair the sheet the tileset names.')]; + } + } + if ($verdicts[$signature]['uncoveredPixels'] > 0) { + $cells[] = "({$x}, {$y})"; + } + } + } + if ($cells === []) { + return []; + } + $named = array_slice($cells, 0, self::CELLS_NAMED); + $rest = count($cells) - count($named); + + return [Issue::warning($map->mapId, + sprintf('%d %s the background through see-through ground tiles in some animation frame, a dark seam in the game: %s%s.', + count($cells), count($cells) === 1 ? 'cell can show' : 'cells can show', implode(', ', $named), $rest > 0 ? " and {$rest} more" : ''), + 'Paint ground beneath them on a lower tile layer, such as the map\'s base or the floor they stand on; the game draws nothing under a tile.')]; + } + private static function formatNpcName(ProjectNpc $npc): string { $name = trim($npc->getName()); diff --git a/tests/Unit/Validation/SeeThroughGroundTest.php b/tests/Unit/Validation/SeeThroughGroundTest.php new file mode 100644 index 0000000..7af7d14 --- /dev/null +++ b/tests/Unit/Validation/SeeThroughGroundTest.php @@ -0,0 +1,72 @@ + pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data)); + file_put_contents($path, "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNCCCCC', $width, $height, 8, 6, 0, 0, 0)) + . $chunk('IDAT', (string) gzcompress(str_repeat("\0" . str_repeat("\0\0\0\0", $width), $height))) . $chunk('IEND', '')); +} + +/** @return list The see-through ground warnings for the test map. */ +function findSeeThroughWarnings(string $root): array +{ + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + + return array_values(array_map(static fn($issue): string => $issue->message, array_filter(MapGraphicsValidator::validateCoverage($map), + static fn($issue): bool => str_contains($issue->message, 'see-through') || str_contains($issue->message, 'could not be checked')))); +} + +beforeEach(function () { + if (! function_exists('imagecreatefrompng')) { + $this->markTestSkipped('Tile coverage reads sheet pixels through the optional GD extension.'); + } +}); + +it('warns for every cell whose ground tile leaves the background showing, naming the first and counting the rest', function () { + $root = mapGraphicsProject(); + writeTransparentTestPng($root . '/assets/Graphics/Tilesets/Home_A2.png', 32, 24); + + expect(findSeeThroughWarnings($root))->toBe(['8 cells can show the background through see-through ground tiles in some animation frame,' + . ' a dark seam in the game: (0, 0), (1, 0), (2, 0) and 5 more.']); +}); + +it('is satisfied once ground is authored beneath', function () { + $root = mapGraphicsProject(); + writeTilesetTestPng($root . '/assets/Graphics/Tilesets/Home_A5.png', 16, 32); + writeTestTileset($root, sheets: ['A2' => 'Graphics/Tilesets/Home_A2.png', 'A5' => 'Graphics/Tilesets/Home_A5.png', + 'B' => 'Graphics/Tilesets/Home_B.png']); + writeTransparentTestPng($root . '/assets/Graphics/Tilesets/Home_A2.png', 32, 24); + writeTileLayer($root . '/assets/Maps/test-map', '00.base.tiles.php', "1536 1536 1536 1536\n1536 1536 1536 1536"); + + expect(findSeeThroughWarnings($root))->toBe([]); +}); + +it('never judges upper-sheet props, which are see-through by design', function () { + $root = mapGraphicsProject(); + writeTransparentTestPng($root . '/assets/Graphics/Tilesets/Home_B.png', 32, 32); + writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "0 0 0 0\n0 0 0 0"); + + // The decor layer's see-through B tiles stand on cells with no ground at all. + expect(findSeeThroughWarnings($root))->toBe([]); +}); + +it('reads the artwork as it is now, so replacing an opaque sheet with a see-through one is noticed', function () { + $root = mapGraphicsProject(); + + expect(findSeeThroughWarnings($root))->toBe([]); + writeTransparentTestPng($root . '/assets/Graphics/Tilesets/Home_A2.png', 32, 24); + expect(findSeeThroughWarnings($root))->toHaveCount(1); +}); From 3b35c94ae22da395f491fa11eecbf08225cea78b Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 13:53:39 +0200 Subject: [PATCH 257/344] fix(validation): count a drawn world object's covered glyphs as hidden, as the field does The untiled-glyph warnings and the GUI's untiled count read the Engine's shown-glyph rule, which knows only tiles, so cells a placed world object covers were still reported as showing their glyphs. WorldObjectAuthoring::readRestingCoverage now resolves the coverage that applies at rest (an object whose base frame draws, or whose sign board reads; one without art by design), and the validator subtracts it. An object whose art cannot be drawn leaves its glyphs reported, as the field shows them. --- src/Field/WorldObjectAuthoring.php | 42 +++++++++++++++ src/Validation/MapGraphicsValidator.php | 8 ++- .../WorldObjectGlyphCoverageTest.php | 53 +++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Validation/WorldObjectGlyphCoverageTest.php diff --git a/src/Field/WorldObjectAuthoring.php b/src/Field/WorldObjectAuthoring.php index 2cc17c0..a433a1d 100644 --- a/src/Field/WorldObjectAuthoring.php +++ b/src/Field/WorldObjectAuthoring.php @@ -21,6 +21,8 @@ use Ichiloto\Editor\Cutscenes\Source\SourceNode; use Ichiloto\Engine\Animations\Field\FieldPresentationCatalog; use Ichiloto\Engine\Field\WorldObjectDefinition; +use Ichiloto\Engine\Rendering\Sprites\FieldSpriteRole; +use Throwable; use InvalidArgumentException; /** A transient record view. Only the map owns or persists these declarations. */ @@ -37,6 +39,46 @@ public static function readEntries(ProjectMap $map): array return $entries; } + /** + * The glyph and tile coverage of a map's world objects at rest, as the field applies it: an object covers its + * cells only while it can be drawn, so its base role's current frame resolves, or its sign board can be read. + * An object without art covers its cells by design. Validation uses it to judge what the field still shows as + * glyphs; a map whose objects cannot be read covers nothing, as its own validation reports why. + * + * @return array>>> + */ + public static function readRestingCoverage(ProjectMap $map): array + { + try { + $definitions = self::validateEntries($map, self::readEntries($map)); + } catch (InvalidArgumentException|MapSourceRefusal) { + return []; + } + $coverage = []; + foreach ($definitions as $object) { + $sign = $object->resource?->sign; + if ($sign !== null) { + try { + $sign->getBoard($map->getAssetRoot()); + } catch (Throwable) { + continue; + } + } elseif ($object->sprites !== null) { + $role = new FieldSpriteRole($object->sprites, $map->getAssetRoot(), 'World-object coverage ' . $object->id); + try { + if ($role->getFrame($object->pivot) === null) { + continue; + } + } finally { + $role->release(); + } + } + $coverage = array_replace_recursive($coverage, $object->coverage); + } + + return $coverage; + } + public static function validateEntries(ProjectMap $map, array $entries, bool $complete = true): array { $owners = array_column($map->describeTileLayers()['layers'], 'owner', 'name'); diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php index 3d0aade..3941b3e 100644 --- a/src/Validation/MapGraphicsValidator.php +++ b/src/Validation/MapGraphicsValidator.php @@ -7,6 +7,7 @@ use Ichiloto\Editor\Canvas\GlyphTilePlanner; use Ichiloto\Editor\Canvas\PieceRole; use Ichiloto\Editor\Field\ProjectNpc; +use Ichiloto\Editor\Field\WorldObjectAuthoring; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Maps\TileLayerSource; use Ichiloto\Editor\ProjectMap; @@ -200,7 +201,12 @@ private static function readCoverage(ProjectMap $map): ?array return null; } - return [$tileset, $layers, new MapGraphics($tileset, $layers, owners: $owners)->getShownGlyphCells($layerSet, $map->getAssetRoot())]; + // A world object that draws covers its glyph cells in the field, as the Engine's presentation applies it. + $covered = WorldObjectAuthoring::readRestingCoverage($map)['glyphs'] ?? []; + $shown = array_values(array_filter(new MapGraphics($tileset, $layers, owners: $owners)->getShownGlyphCells($layerSet, $map->getAssetRoot()), + static fn(array $cell): bool => ! isset($covered[$cell['layer']][$cell['y']][$cell['x']]))); + + return [$tileset, $layers, $shown]; } /** diff --git a/tests/Unit/Validation/WorldObjectGlyphCoverageTest.php b/tests/Unit/Validation/WorldObjectGlyphCoverageTest.php new file mode 100644 index 0000000..0756ba1 --- /dev/null +++ b/tests/Unit/Validation/WorldObjectGlyphCoverageTest.php @@ -0,0 +1,53 @@ +getMapByIndex(0))[0] + ?? throw new RuntimeException('The fixture shows no glyph to cover.'); + writeTilesetTestPng($root . '/assets/Graphics/Props/Counter.png', 12, 16); + is_dir($root . '/assets/Data/Presentation') || mkdir($root . '/assets/Data/Presentation', 0o777, true); + file_put_contents($root . '/assets/Data/Presentation/field.php', " ['counter' => ['name' => 'Counter'," + . " 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => ['asset' => 'Graphics/Props/Counter.png']]]];\n"); + $file = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($file, str_replace("'events' => [],", sprintf("'events' => [], 'worldObjects' => [['id' => 'counter-1', 'resource' => 'counter'," + . " 'anchor' => ['x' => %d, 'y' => %d], 'covers' => ['layer' => '%s', 'cells' => [[%d, %d]], 'tileLayers' => []]]],", + $cell['x'], $cell['y'], $cell['layer'], $cell['x'], $cell['y']), (string) file_get_contents($file))); + + return [$root, $cell]; +} + +/** @return list */ +function listShownCells(string $root): array +{ + return array_map(static fn(array $cell): array => [$cell['x'], $cell['y'], $cell['layer']], + MapGraphicsValidator::listShownGlyphCells(ProjectWorkspace::fromProject($root)->getMapByIndex(0))); +} + +it('stops reporting a glyph a drawn world object covers', function () { + [$root, $cell] = createCoveredGlyphProject(); + + expect(listShownCells($root))->not->toContain([$cell['x'], $cell['y'], $cell['layer']]); +}); + +it('keeps reporting a covered glyph while the object\'s art cannot be drawn, as the field shows it', function () { + [$root, $cell] = createCoveredGlyphProject(); + unlink($root . '/assets/Graphics/Props/Counter.png'); + + expect(listShownCells($root))->toContain([$cell['x'], $cell['y'], $cell['layer']]); +}); From 22878c626fd0d5a4da4e1672b84eddbdf661ab30 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 15:15:25 +0200 Subject: [PATCH 258/344] feat(validation): warn for floor the player can reach that no tile covers Project validation now warns, on every map with a kind, for cells the Engine's reachability says the player can walk to but no usable tile covers: the field draws nothing there, so the player walks on the dark background. A map without a kind or with unreadable graphics is left to the checks that own it. Warning only; nothing is painted or changed. --- docs/manual.md | 4 +- src/Validation/ReachabilityValidator.php | 68 ++++++++++++++++++++++-- tests/Unit/ProjectValidatorTest.php | 20 +++++++ 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index cc60f9f..4f3908c 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -2490,7 +2490,9 @@ stands: moving an NPC or rebuilding a room is fine unless it traps something. An event no reachable cell lies in, or an arrival outside the map or on a wall, is an error; a talkable NPC with no reachable cell beside it, and a map nothing reachable leads to yet, are warnings. Doors and edge triggers that -name a map the project does not have are errors of their own. +name a map the project does not have are errors of their own. On a map with +a kind, a cell the player can reach that no tile covers is a warning: the +game shows the dark background there, so paint its floor or close it off. Effect timelines in `assets/Animations` are checked as each consumer plays them: an animation's `sourceEffect` and `targetEffect` in battle, and the field diff --git a/src/Validation/ReachabilityValidator.php b/src/Validation/ReachabilityValidator.php index e04b972..5e7ac76 100644 --- a/src/Validation/ReachabilityValidator.php +++ b/src/Validation/ReachabilityValidator.php @@ -5,9 +5,14 @@ namespace Ichiloto\Editor\Validation; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Field\MapGraphics; +use Ichiloto\Engine\Field\MapLayerSource; +use Ichiloto\Engine\Field\Reachability\MapReachabilityReport; use Ichiloto\Engine\Field\Reachability\ProjectReachability; use Ichiloto\Engine\Field\Reachability\ReachabilityProblem; use Ichiloto\Engine\Field\Reachability\ReachabilityProblemKind; +use Ichiloto\Engine\Rendering\Tilesets\TileId; +use Throwable; /** * Reports layouts that leave the player stuck or content out of reach, as the @@ -22,12 +27,69 @@ final class ReachabilityValidator /** @return Issue[] */ public function validate(ProjectWorkspace $workspace): array { - $project = ProjectReachability::analyze($workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); + $assetRoot = $workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; + $project = ProjectReachability::analyze($assetRoot); + $untiled = []; + foreach ($project->reports as $mapId => $report) { + $untiled[] = self::checkUntiledFloor($assetRoot, (string) $mapId, $report); + } - return array_values(array_filter(array_map( + return array_values(array_filter([...array_map( static fn(ReachabilityProblem $problem): ?Issue => self::toIssue($problem), $project->getAllProblems(), - ))); + ), ...$untiled])); + } + + /** How many reachable cells a warning names before summing up the rest. */ + private const int CELLS_NAMED = 3; + + /** + * Warns for cells the player can reach that no tile covers in a map with graphics: the field draws nothing + * there, so the player walks on the dark background, as on a map whose floor was never painted. A map without + * a kind, or whose graphics cannot be read, is left to the checks that own it. + */ + private static function checkUntiledFloor(string $assetRoot, string $mapId, MapReachabilityReport $report): ?Issue + { + try { + $directory = $assetRoot . DIRECTORY_SEPARATOR . 'Maps' . DIRECTORY_SEPARATOR . $mapId; + $data = (static fn(string $file): mixed => require $file)($directory . DIRECTORY_SEPARATOR . basename($mapId) . '.data.php'); + if (! is_array($data) || ! is_string($data['tileset'] ?? null)) { + return null; + } + $layers = MapLayerSource::loadFromDirectory($directory, $mapId); + $graphics = MapGraphics::loadFromDirectory($directory, $mapId, $data['tileset'], $layers, $assetRoot, $data['tileLayers'] ?? null); + $usable = $graphics?->tileset->getUsableSheets($assetRoot)['sheets'] ?? []; + } catch (Throwable) { + return null; + } + if ($graphics === null || $usable === []) { + return null; + } + $cells = []; + foreach ($layers->layers[0]->glyphs ?? [] as $y => $row) { + foreach ($row as $x => $_) { + if (! $report->isReachable($x, $y)) { + continue; + } + foreach ($graphics->layers as $layer) { + $tile = (int) ($layer->tiles[$y][$x] ?? TileId::EMPTY); + if ($tile !== TileId::EMPTY && isset($usable[TileId::getSheet($tile)?->value ?? ''])) { + continue 2; + } + } + $cells[] = "({$x}, {$y})"; + } + } + if ($cells === []) { + return null; + } + $named = array_slice($cells, 0, self::CELLS_NAMED); + $rest = count($cells) - count($named); + + return Issue::warning($mapId, sprintf('%d %s the player can reach %s no tile, so the field shows the background there: %s%s.', + count($cells), count($cells) === 1 ? 'cell' : 'cells', count($cells) === 1 ? 'has' : 'have', + implode(', ', $named), $rest > 0 ? " and {$rest} more" : ''), + 'Paint floor under them on a tile layer, or close them off if the player should not walk there.'); } private static function toIssue(ReachabilityProblem $problem): ?Issue diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index 4cb4c2e..541fb15 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -981,6 +981,26 @@ function reachabilityIssueLines(string $root): array ])->and(issuesMentioning($issues, 'Edge trigger 1 leads to "nowhere", which is not a map in this project.'))->toHaveCount(1); }); +it('warns for floor the player can reach that no tile covers, and is satisfied once it is painted', function () { + $root = makeTemporaryProject(); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeConsistentEnemies($root); + // The sample map's chest stands on painted floor. + writeReachableProject($root, "#####\n# #\n#####", " \n E \n ", [1, 1]); + writeTestTileset($root); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace("'triggers' => [],", "'triggers' => [], 'tileset' => 'home',", (string) file_get_contents($data))); + // The walls and the first two floor cells are painted; the third floor cell is not. + writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "5 5 5 5 5\n5 2816 2816 0 5\n5 5 5 5 5"); + $untiled = static fn(): array => array_values(array_map(static fn(Issue $issue): string => $issue->message, + issuesMentioning(validateProject($root), 'shows the background'))); + + expect($untiled())->toBe(['1 cell the player can reach has no tile, so the field shows the background there: (3, 1).']); + writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "5 5 5 5 5\n5 2816 2816 2816 5\n5 5 5 5 5"); + expect($untiled())->toBe([]); +}); + /** Writes a field effect drawing one image cell from a 3 x 4 sheet. */ function writeFieldEffect(string $root, string $id, string $asset): void { From b8c2de608c053557076df26bfe8112a84a18f0ab Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 16:30:43 +0200 Subject: [PATCH 259/344] feat(maps): remove rows and columns through the shared line plan Removal is the counterpart of insertion: one reviewed, reversible source set that removes the lines from every grid, tile layer and declared collision and moves every coordinate beyond them back, in whichever file holds it. Anything authored on a removed line (an event marker, NPC, spawn, transfer, starting position, world object cell or script coordinate surely on the map) refuses the removal and is named; straddling areas shrink. With a save manifest, a mapRemovals step lands saves standing on removed lines on a reachable cell the author confirms, proposed nearest them. Insertion and removal now share MapLineEdit, MapLinePlanner, MapLinePlan, MapLineInventory and MapLineCanvas (renamed from the LineInsertion* classes and LineInsertCanvas). Relative move_route steps on the edited map are now listed as hand edits for both, since a walk across the lines ends elsewhere. --- docs/manual.md | 30 ++- src/Canvas/LineInsertCanvas.php | 202 ---------------- src/Canvas/MapLineCanvas.php | 218 ++++++++++++++++++ src/Editor.php | 16 +- src/Maps/EditableGrid.php | 28 ++- src/Maps/LineInsertion.php | 26 +-- src/Maps/LineRemoval.php | 105 +++++++++ src/Maps/MapLayers.php | 24 ++ src/Maps/MapLineEdit.php | 52 +++++ ...tionInventory.php => MapLineInventory.php} | 99 +++++--- ...{LineInsertionPlan.php => MapLinePlan.php} | 12 +- ...nsertionPlanner.php => MapLinePlanner.php} | 135 +++++++++-- src/Maps/TileLayerSource.php | 29 +++ src/ProjectMap.php | 58 +++++ src/Session/EditorSession.php | 46 +++- src/Session/SessionHost.php | 6 + src/UI/Modal.php | 2 +- src/Validation/SaveCompatibilityValidator.php | 2 +- tests/Pest.php | 1 + tests/Support/MapLineFixtures.php | 191 +++++++++++++++ tests/Unit/MapLineInsertionTest.php | 218 +++--------------- tests/Unit/MapLineRemovalTest.php | 218 ++++++++++++++++++ .../Unit/MapPhysicalOccupancyGeometryTest.php | 8 +- .../Session/MapLineRemovalSessionTest.php | 56 +++++ .../Unit/Session/WorldObjectAuthoringTest.php | 2 +- 25 files changed, 1292 insertions(+), 492 deletions(-) delete mode 100644 src/Canvas/LineInsertCanvas.php create mode 100644 src/Canvas/MapLineCanvas.php create mode 100644 src/Maps/LineRemoval.php create mode 100644 src/Maps/MapLineEdit.php rename src/Maps/{LineInsertionInventory.php => MapLineInventory.php} (81%) rename src/Maps/{LineInsertionPlan.php => MapLinePlan.php} (76%) rename src/Maps/{LineInsertionPlanner.php => MapLinePlanner.php} (68%) create mode 100644 tests/Support/MapLineFixtures.php create mode 100644 tests/Unit/MapLineRemovalTest.php create mode 100644 tests/Unit/Session/MapLineRemovalSessionTest.php diff --git a/docs/manual.md b/docs/manual.md index 4f3908c..6ed347e 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -221,8 +221,8 @@ confirmation, the shared session captures its current resolved physical cells without changing glyphs, artwork or passage. This is one undo step, written only on save; opening or saving a legacy map never converts it automatically. Once converted, terminal appearance and layer edits no longer choose collision. -Resize and row/column insertion preserve the declaration with geometry, adding -`SOLID` physical cells independently of blank glyphs. Malformed or unsupported +Resize and row/column insertion and removal preserve the declaration with +geometry, adding `SOLID` physical cells independently of blank glyphs. Malformed or unsupported source edits refuse before mutation. The TUI receives no graphical conversion control; it preserves the declaration through its existing edit/save/history workflow. @@ -260,7 +260,7 @@ force for terminal edits. Untouched layer files are never written; unchanged rows retain their authored bytes, and all changed files are saved or rolled back together. -### Inserting rows and columns +### Inserting and removing rows and columns In Map mode, `Ctrl+P` offers **Map: Insert rows above the cursor** and **Map: Insert columns left of the cursor**. Both first refuse while the project @@ -287,7 +287,8 @@ What moves, when it is in the map's space and at or beyond the line: - spawn points of transfers into the map from any map, legacy triggers into it, and `startingPositions` in `assets/Data/system.php` that start on it; - script coordinates on the map: `move_player`, `move_route` waypoints (only - the axes a waypoint names; `steps` and `retrace` are relative), `camera` and + the axes a waypoint names; `retrace` is relative, and relative `steps` are + listed as a hand edit because a walk across the line ends elsewhere), `camera` and `field_animation` position targets, `stage_actor`, and `transfer` into the map. Scripts start on their map; after a `transfer` elsewhere, later commands are in that map's space and stay; @@ -308,6 +309,27 @@ insertion raises `contentVersion` by one and appends a `mapShifts` step, so a saved player position on the map moves too. A project without that manifest is told that existing saves are not migrated. +**Map: Remove rows from the cursor down** and **Map: Remove columns from the +cursor rightward** take the same count prompt and confirmation. Removing `N` +rows from the cursor's row deletes those rows from every grid and moves +everything below them up by `N`; columns move left. The map shrinks by `N` and +keeps at least one line. + +Nothing authored may stand on a removed line: an event marker, an NPC, a +spawn point or transfer into the map, a starting position, a world object's +covered cell, or a script coordinate surely on the map. The removal is refused +before anything is written and the refusal names each one, so move them first. +An area that straddles the removed lines shrinks by the lines it loses; one +wholly on them refuses the removal. Coordinates that may be on this map or +another are listed as hand edits, as for an insertion. + +With a save manifest, the removal appends a `mapRemovals` step. A saved +position beyond the removed lines moves back with the map; one standing on a +removed line lands on the cell the confirmation names, the cell the player can +reach today nearest the reachable part of the removed lines, given in the map's +coordinates after the removal. The GUI lets you name another reachable cell +instead. + ### Pieces A map offers the pieces of its [kind](#map-kind)'s tileset (see diff --git a/src/Canvas/LineInsertCanvas.php b/src/Canvas/LineInsertCanvas.php deleted file mode 100644 index 42bb7cb..0000000 --- a/src/Canvas/LineInsertCanvas.php +++ /dev/null @@ -1,202 +0,0 @@ -getSelectedMap(); - if ($map === null || $map->getGridSourceIssue() !== null || $this->editingMode !== self::MODE_MAP) { - return []; - } - - return [ - new PaletteItem('Map: Insert rows above the cursor', '', fn() => $this->openLineInsertPrompt('y')), - new PaletteItem('Map: Insert columns left of the cursor', '', fn() => $this->openLineInsertPrompt('x')), - ]; - } - - private function openLineInsertPrompt(string $axis): void - { - $map = $this->getSelectedMap(); - if ($map === null || ! $this->canInsertLines()) { - return; - } - $this->finalizeActiveStroke(); - $this->lineInsertPrompt = ['axis' => $axis, 'at' => $axis === 'y' ? $this->cursorY : $this->cursorX, 'mapId' => $map->mapId]; - $this->lineInsertCount = new TextFieldEditor(); - $this->lineInsertCount->open('1'); - $this->modals->push(Modal::LINE_INSERT); - $this->requestFullRender(); - } - - /** Refuses while other edits are unsaved: the insertion writes now, and undo restores files. */ - private function canInsertLines(): bool - { - if (! $this->workspace instanceof ProjectWorkspace) { - return false; - } - if ($this->workspace->hasUnsavedChanges()) { - $this->setStatus('Save or undo pending edits before inserting rows or columns. No files were changed.', StatusLevel::WARN); - return false; - } - - return true; - } - - private function closeLineInsertPrompt(): void - { - $this->lineInsertPrompt = null; - $this->lineInsertCount = null; - $this->modals->remove(Modal::LINE_INSERT); - $this->requestFullRender(); - } - - private function handleLineInsertPromptInput(string $input): void - { - $prompt = $this->lineInsertPrompt; - if ($prompt === null || $this->lineInsertCount === null) { - $this->closeLineInsertPrompt(); - return; - } - $control = new InputControl(InputControlType::INTEGER, $this->lineInsertCount->value); - switch ($this->lineInsertCount->handleKey($input, $control)) { - case TextFieldKeyResult::CANCELLED: - $this->closeLineInsertPrompt(); - $this->setStatus('Insertion cancelled. No files were changed.'); - return; - case TextFieldKeyResult::SUBMITTED: - $count = trim($this->lineInsertCount->value); - if (! ctype_digit($count) || (int) $count < 1) { - $this->setStatus('Enter a whole number of at least 1.', StatusLevel::WARN); - return; - } - $this->closeLineInsertPrompt(); - $this->openLineInsertConfirmation($prompt['axis'], $prompt['at'], (int) $count); - return; - default: - $this->requestFullRender(); - } - } - - private function renderLineInsertPrompt(array $layout): void - { - $prompt = $this->lineInsertPrompt; - if ($prompt === null || $this->lineInsertCount === null) { - return; - } - $width = min(68, $layout['width'] - 4); - $rows = $prompt['axis'] === 'y'; - $lines = [ - sprintf('%s to insert %s %s %d: %s', $rows ? 'Rows' : 'Columns', $rows ? 'above' : 'left of', - $rows ? 'row' : 'column', $prompt['at'] + 1, $this->lineInsertCount->value), - sprintf('Everything from %s %d %s moves and the map grows.', $rows ? 'row' : 'column', $prompt['at'] + 1, $rows ? 'down' : 'right'), - 'Spawn points, scripts and saves that name this map follow.', - 'The next step lists every file before anything is written.', - ]; - new EditorWindow( - title: $rows ? 'Insert rows' : 'Insert columns', - help: 'Enter:Plan Up/Down:Count Esc:Cancel', - position: ['x' => max(2, intdiv($layout['width'] - $width, 2)), 'y' => 6], - width: $width, height: count($lines) + 2, - content: $this->fitLines($lines, $this->getWindowContentWidth($width), count($lines)), - )->render(); - } - - /** - * Plans the insertion and lists what it writes, and what it could not - * rewrite, for confirmation. Nothing is written yet. - */ - private function openLineInsertConfirmation(string $axis, int $at, int $count): void - { - $map = $this->getSelectedMap(); - if ($map === null || ! $this->canInsertLines()) { - return; - } - try { - $plan = LineInsertionPlanner::planProject($this->projectRoot, $map->mapId, $axis, $at, $count); - } catch (Throwable $failure) { - $this->setErrorStatus($failure, 'Insertion'); - return; - } - $paths = array_map(fn(string $path): string => substr($path, strlen($this->projectRoot) + 1), $plan->getChangedPaths()); - $this->optionDialogField = ['lineInsertion' => $plan]; - $this->eventOptionDialogMarker = null; - $this->eventOptionDialogPath = null; - $this->eventOptionDialogTitle = 'Insert ' . $plan->insertion->description; - $this->eventOptionDialogEntries = [ - ['label' => 'Cancel', 'value' => 'cancel', 'description' => 'Leave all files unchanged.'], - ['label' => sprintf('Write %d files now', count($paths)), 'value' => 'insert', - 'description' => 'Writes now, not on Save. Ctrl+Z restores files.'], - ...array_map(static fn(string $path): array => [ - 'label' => $path, 'value' => 'preview', - 'description' => 'Written by the insertion. Select Write to apply all files.', - ], $paths), - ...array_map(static fn(array $edit): array => [ - 'label' => sprintf('Hand edit: %s %s', $edit['file'], $edit['where']), 'value' => 'preview', - 'description' => sprintf('Not rewritten: %s. Move it by hand after writing.', $edit['reason']), - ], $plan->handEdits), - ...array_map(static fn(string $note): array => [ - 'label' => $note, 'value' => 'preview', 'description' => $note, - ], $plan->notes), - ]; - $this->selectedEventOptionIndex = 0; - $this->dialogFilter->clear(); - $this->isEventOptionDialogOpen = true; - $this->setStatus(sprintf('Confirm inserting %s. Writes now, not on Save. Ctrl+Z restores files.', $plan->insertion->description)); - $this->renderSelectionDependentArea(); - } - - private function confirmLineInsertion(LineInsertionPlan $plan, bool $confirmed): void - { - if (! $confirmed || ! $this->workspace instanceof ProjectWorkspace) { - $this->closeEventOptionDialog(); - $this->setStatus('Insertion cancelled. No files were changed.'); - return; - } - try { - $command = new SourceSetCommand('Insert ' . $plan->insertion->noun, 'this insertion', $plan->getSourceSet(), $this->workspace, - fn(): ?ProjectWorkspace => $this->workspace, - function (ProjectWorkspace $workspace): void { $this->workspace = $workspace; }, - ); - $command->execute(); - $this->recordCommand($command); - $handEdits = count($plan->handEdits); - $this->closeEventOptionDialog(); - // The files are on disk now, so the result reads as saved. - $this->setStatus(sprintf('Inserted %s.%s Ctrl+Z restores the files.', $plan->insertion->description, - $handEdits === 0 ? '' : sprintf(' %d %s still need a hand edit.', $handEdits, $handEdits === 1 ? 'coordinate' : 'coordinates')), StatusLevel::SUCCESS); - $this->requestFullRender(); - } catch (Throwable $failure) { - $this->closeEventOptionDialog(''); - $this->setErrorStatus($failure, 'Insertion'); - } - } -} diff --git a/src/Canvas/MapLineCanvas.php b/src/Canvas/MapLineCanvas.php new file mode 100644 index 0000000..6557111 --- /dev/null +++ b/src/Canvas/MapLineCanvas.php @@ -0,0 +1,218 @@ +getSelectedMap(); + if ($map === null || $map->getGridSourceIssue() !== null || $this->editingMode !== self::MODE_MAP) { + return []; + } + + return [ + new PaletteItem('Map: Insert rows above the cursor', '', fn() => $this->openMapLinePrompt('y', false)), + new PaletteItem('Map: Insert columns left of the cursor', '', fn() => $this->openMapLinePrompt('x', false)), + new PaletteItem('Map: Remove rows from the cursor down', '', fn() => $this->openMapLinePrompt('y', true)), + new PaletteItem('Map: Remove columns from the cursor rightward', '', fn() => $this->openMapLinePrompt('x', true)), + ]; + } + + private function openMapLinePrompt(string $axis, bool $remove): void + { + $map = $this->getSelectedMap(); + if ($map === null || ! $this->canEditMapLines($remove)) { + return; + } + $this->finalizeActiveStroke(); + $this->mapLinePrompt = ['axis' => $axis, 'at' => $axis === 'y' ? $this->cursorY : $this->cursorX, 'mapId' => $map->mapId, 'remove' => $remove]; + $this->mapLineCount = new TextFieldEditor(); + $this->mapLineCount->open('1'); + $this->modals->push(Modal::MAP_LINES); + $this->requestFullRender(); + } + + /** Refuses while other edits are unsaved: the edit writes now, and undo restores files. */ + private function canEditMapLines(bool $remove): bool + { + if (! $this->workspace instanceof ProjectWorkspace) { + return false; + } + if ($this->workspace->hasUnsavedChanges()) { + $this->setStatus(sprintf('Save or undo pending edits before %s rows or columns. No files were changed.', $remove ? 'removing' : 'inserting'), StatusLevel::WARN); + return false; + } + + return true; + } + + private function closeMapLinePrompt(): void + { + $this->mapLinePrompt = null; + $this->mapLineCount = null; + $this->modals->remove(Modal::MAP_LINES); + $this->requestFullRender(); + } + + private function handleMapLinePromptInput(string $input): void + { + $prompt = $this->mapLinePrompt; + if ($prompt === null || $this->mapLineCount === null) { + $this->closeMapLinePrompt(); + return; + } + $control = new InputControl(InputControlType::INTEGER, $this->mapLineCount->value); + switch ($this->mapLineCount->handleKey($input, $control)) { + case TextFieldKeyResult::CANCELLED: + $this->closeMapLinePrompt(); + $this->setStatus(sprintf('%s cancelled. No files were changed.', $prompt['remove'] ? 'Removal' : 'Insertion')); + return; + case TextFieldKeyResult::SUBMITTED: + $count = trim($this->mapLineCount->value); + if (! ctype_digit($count) || (int) $count < 1) { + $this->setStatus('Enter a whole number of at least 1.', StatusLevel::WARN); + return; + } + $this->closeMapLinePrompt(); + $this->openMapLineConfirmation($prompt['axis'], $prompt['at'], (int) $count, $prompt['remove']); + return; + default: + $this->requestFullRender(); + } + } + + private function renderMapLinePrompt(array $layout): void + { + $prompt = $this->mapLinePrompt; + if ($prompt === null || $this->mapLineCount === null) { + return; + } + $width = min(68, $layout['width'] - 4); + $rows = $prompt['axis'] === 'y'; + $line = sprintf('%s %d', $rows ? 'row' : 'column', $prompt['at'] + 1); + $lines = $prompt['remove'] + ? [ + sprintf('%s to remove from %s %s: %s', $rows ? 'Rows' : 'Columns', $line, $rows ? 'down' : 'rightward', $this->mapLineCount->value), + sprintf('Everything beyond them moves %s and the map shrinks.', $rows ? 'up' : 'left'), + 'Anything authored on a removed line refuses the removal.', + 'The next step lists every file before anything is written.', + ] + : [ + sprintf('%s to insert %s %s: %s', $rows ? 'Rows' : 'Columns', $rows ? 'above' : 'left of', $line, $this->mapLineCount->value), + sprintf('Everything from %s %s moves and the map grows.', $line, $rows ? 'down' : 'right'), + 'Spawn points, scripts and saves that name this map follow.', + 'The next step lists every file before anything is written.', + ]; + new EditorWindow( + title: ($prompt['remove'] ? 'Remove ' : 'Insert ') . ($rows ? 'rows' : 'columns'), + help: 'Enter:Plan Up/Down:Count Esc:Cancel', + position: ['x' => max(2, intdiv($layout['width'] - $width, 2)), 'y' => 6], + width: $width, height: count($lines) + 2, + content: $this->fitLines($lines, $this->getWindowContentWidth($width), count($lines)), + )->render(); + } + + /** + * Plans the edit and lists what it writes, and what it could not + * rewrite, for confirmation. Nothing is written yet. + */ + private function openMapLineConfirmation(string $axis, int $at, int $count, bool $remove): void + { + $map = $this->getSelectedMap(); + if ($map === null || ! $this->canEditMapLines($remove)) { + return; + } + try { + $plan = $remove + ? MapLinePlanner::planRemoval($this->projectRoot, $map->mapId, $axis, $at, $count) + : MapLinePlanner::planInsertion($this->projectRoot, $map->mapId, $axis, $at, $count); + } catch (Throwable $failure) { + $this->setErrorStatus($failure, $remove ? 'Removal' : 'Insertion'); + return; + } + $edit = $plan->edit; + $paths = array_map(fn(string $path): string => substr($path, strlen($this->projectRoot) + 1), $plan->getChangedPaths()); + $this->optionDialogField = ['mapLines' => $plan]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = $edit->verb . ' ' . $edit->description; + $this->eventOptionDialogEntries = [ + ['label' => 'Cancel', 'value' => 'cancel', 'description' => 'Leave all files unchanged.'], + ['label' => sprintf('Write %d files now', count($paths)), 'value' => 'write', + 'description' => 'Writes now, not on Save. Ctrl+Z restores files.'], + ...array_map(static fn(string $path): array => [ + 'label' => $path, 'value' => 'preview', + 'description' => sprintf('Written by the %s. Select Write to apply all files.', $remove ? 'removal' : 'insertion'), + ], $paths), + ...array_map(static fn(array $edit): array => [ + 'label' => sprintf('Hand edit: %s %s', $edit['file'], $edit['where']), 'value' => 'preview', + 'description' => sprintf('Not rewritten: %s. Move it by hand after writing.', $edit['reason']), + ], $plan->handEdits), + ...array_map(static fn(string $note): array => [ + 'label' => $note, 'value' => 'preview', 'description' => $note, + ], $plan->notes), + ]; + $this->selectedEventOptionIndex = 0; + $this->dialogFilter->clear(); + $this->isEventOptionDialogOpen = true; + $this->setStatus(sprintf('Confirm %s %s. Writes now, not on Save. Ctrl+Z restores files.', $remove ? 'removing' : 'inserting', $edit->description)); + $this->renderSelectionDependentArea(); + } + + private function confirmMapLines(MapLinePlan $plan, bool $confirmed): void + { + $remove = $plan->edit instanceof LineRemoval; + if (! $confirmed || ! $this->workspace instanceof ProjectWorkspace) { + $this->closeEventOptionDialog(); + $this->setStatus(sprintf('%s cancelled. No files were changed.', $remove ? 'Removal' : 'Insertion')); + return; + } + try { + $command = new SourceSetCommand($plan->edit->verb . ' ' . $plan->edit->noun, $remove ? 'this removal' : 'this insertion', $plan->getSourceSet(), $this->workspace, + fn(): ?ProjectWorkspace => $this->workspace, + function (ProjectWorkspace $workspace): void { $this->workspace = $workspace; }, + ); + $command->execute(); + $this->recordCommand($command); + $handEdits = count($plan->handEdits); + $this->closeEventOptionDialog(); + // The files are on disk now, so the result reads as saved. + $this->setStatus(sprintf('%s %s.%s Ctrl+Z restores the files.', $remove ? 'Removed' : 'Inserted', $plan->edit->description, + $handEdits === 0 ? '' : sprintf(' %d %s still need a hand edit.', $handEdits, $handEdits === 1 ? 'coordinate' : 'coordinates')), StatusLevel::SUCCESS); + $this->requestFullRender(); + } catch (Throwable $failure) { + $this->closeEventOptionDialog(''); + $this->setErrorStatus($failure, $remove ? 'Removal' : 'Insertion'); + } + } +} diff --git a/src/Editor.php b/src/Editor.php index 2f747cb..dfa8f85 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -56,7 +56,7 @@ use Ichiloto\Editor\Database\RecordSubList; use Ichiloto\Editor\Database\ReferencePicker; use Ichiloto\Editor\Debug\Debug; -use Ichiloto\Editor\Maps\LineInsertionPlan; +use Ichiloto\Editor\Maps\MapLinePlan; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Events\EventAuthoring; use Ichiloto\Editor\Events\EventRefusal; @@ -129,7 +129,7 @@ final class Editor { use \Ichiloto\Editor\Canvas\LayerCanvas; use \Ichiloto\Editor\Canvas\PieceCanvas; - use \Ichiloto\Editor\Canvas\LineInsertCanvas; + use \Ichiloto\Editor\Canvas\MapLineCanvas; use \Ichiloto\Editor\Canvas\GlyphTileCanvas; use \Ichiloto\Editor\Inspector\MapKindField; use CutscenesWorkspace; @@ -1219,7 +1219,7 @@ private function buildInputRouter(): InputRouter $router->bindModal(Modal::DATABASE_ENTRY_DELETE_CONFIRMATION, $this->handleDatabaseEntryDeleteConfirmationInput(...)); $router->bindModal(Modal::RENAME_CONFIRMATION, $this->handleRenameConfirmationInput(...)); $router->bindModal(Modal::LAYER_EDIT, $this->handleLayerPromptInput(...)); - $router->bindModal(Modal::LINE_INSERT, $this->handleLineInsertPromptInput(...)); + $router->bindModal(Modal::MAP_LINES, $this->handleMapLinePromptInput(...)); $router->bindModal(Modal::COMMAND_PALETTE, $this->handleCommandPaletteInput(...)); $router->bindModal(Modal::HELP, $this->handleHelpInput(...)); $router->bindModal(Modal::DATABASE, $this->handleDatabaseInput(...)); @@ -5452,7 +5452,7 @@ private function buildPaletteItems(): array $items = [ ...$this->buildLayerPaletteItems(), ...$this->buildPiecePaletteItems(), - ...$this->buildLineInsertPaletteItems(), + ...$this->buildMapLinePaletteItems(), new PaletteItem('Save Map', 'Ctrl+S', fn() => $this->saveSelectedMap()), new PaletteItem('Move Map to Derived Path', '', fn() => $this->beginExplicitMapMove()), new PaletteItem('Save All', 'Ctrl+A', fn() => $this->saveAllAssets()), @@ -7295,9 +7295,9 @@ private function applySelectedEventOption(): void return; } - if (($field['lineInsertion'] ?? null) instanceof LineInsertionPlan) { + if (($field['mapLines'] ?? null) instanceof MapLinePlan) { if ($selectedEntry['value'] === 'preview') { return; } - $this->confirmLineInsertion($field['lineInsertion'], $selectedEntry['value'] === 'insert'); + $this->confirmMapLines($field['mapLines'], $selectedEntry['value'] === 'write'); return; } @@ -13767,8 +13767,8 @@ private function renderModalOverlays(array $layout): bool return true; } - if ($this->modals->has(Modal::LINE_INSERT)) { - $this->renderLineInsertPrompt($layout); + if ($this->modals->has(Modal::MAP_LINES)) { + $this->renderMapLinePrompt($layout); return true; } diff --git a/src/Maps/EditableGrid.php b/src/Maps/EditableGrid.php index 60e0658..80f95f2 100644 --- a/src/Maps/EditableGrid.php +++ b/src/Maps/EditableGrid.php @@ -16,6 +16,8 @@ final class EditableGrid private array $originalCells; private array $lines; private array $separators; + /** Whether rows were removed: the rows left can equal the authored ones while the grid does not. */ + private bool $rowsRemoved = false; public function __construct(private string $text, private ?string $source = null, private bool $legacyTags = false) { @@ -104,7 +106,7 @@ public function getLines(): array public function getSource(): string { - if ($this->cells === $this->originalCells && $this->source !== null) { + if ($this->cells === $this->originalCells && ! $this->rowsRemoved && $this->source !== null) { return $this->source; } $text = ''; @@ -183,4 +185,28 @@ public function insertLines(string $axis, int $at, int $count): void } unset($row); } + + /** + * Removes rows `$at` to `$at + $count - 1` (axis `y`), or those columns + * from every row that reaches them (axis `x`). Every other cell keeps its + * glyph and style, and rows that only move keep their authored bytes. + */ + public function removeLines(string $axis, int $at, int $count): void + { + if ($axis === 'y') { + array_splice($this->cells, $at, $count); + array_splice($this->originalCells, $at, $count); + array_splice($this->lines, $at, $count); + // The separator before each removed row goes with it; the first rows take the one after them. + array_splice($this->separators, max(0, $at - 1), $count); + $this->rowsRemoved = true; + return; + } + foreach ($this->cells as &$row) { + if (count($row) > $at) { + array_splice($row, $at, $count); + } + } + unset($row); + } } diff --git a/src/Maps/LineInsertion.php b/src/Maps/LineInsertion.php index 4efad21..0d5eca1 100644 --- a/src/Maps/LineInsertion.php +++ b/src/Maps/LineInsertion.php @@ -13,7 +13,7 @@ * * @package Ichiloto\Editor\Maps */ -final class LineInsertion +final class LineInsertion implements MapLineEdit { public function __construct( public readonly string $mapId, @@ -29,28 +29,29 @@ public function __construct( } } - /** `rows` or `columns`, singular for one. */ + public string $verb { + get => 'Insert'; + } + public string $noun { get => ($this->axis === 'y' ? 'row' : 'column') . ($this->count === 1 ? '' : 's'); } - /** What is inserted where, in words: 3 rows above row 5 of town. */ public string $description { get => sprintf('%d %s %s %s %d of %s', $this->count, $this->noun, $this->axis === 'y' ? 'above' : 'left of', $this->axis === 'y' ? 'row' : 'column', $this->at + 1, $this->mapId); } - /** Where a coordinate on the insertion axis moves to. */ public function getShiftedCoordinate(int $value): int { return $value >= $this->at ? $value + $this->count : $value; } /** - * Where a span on the insertion axis moves to: one starting at or beyond - * the line moves, one straddling it stretches, one before it stays. + * One starting at or beyond the line moves, one straddling it stretches, + * one before it stays. * - * @return array{int, int} The new start and size. + * @return array{int, int} */ public function getShiftedSpan(int $start, int $size): array { @@ -61,14 +62,9 @@ public function getShiftedSpan(int $start, int $size): array return $start + $size > $this->at ? [$start, $size + $this->count] : [$start, $size]; } - /** - * The declarative save migration step entry the Engine applies to a - * saved player position. - * - * @return array{map: string, axis: string, at: int, by: int} - */ - public function getManifestEntry(): array + /** @return array{mapShifts: list} */ + public function getManifestEdits(): array { - return ['map' => $this->mapId, 'axis' => $this->axis, 'at' => $this->at, 'by' => $this->count]; + return ['mapShifts' => [['map' => $this->mapId, 'axis' => $this->axis, 'at' => $this->at, 'by' => $this->count]]]; } } diff --git a/src/Maps/LineRemoval.php b/src/Maps/LineRemoval.php new file mode 100644 index 0000000..3050867 --- /dev/null +++ b/src/Maps/LineRemoval.php @@ -0,0 +1,105 @@ +mapId, $this->axis, $this->at, $this->count, $landing); + } + + public string $verb { + get => 'Remove'; + } + + public string $noun { + get => ($this->axis === 'y' ? 'row' : 'column') . ($this->count === 1 ? '' : 's'); + } + + public string $description { + get => sprintf('%d %s from %s %d of %s', $this->count, $this->noun, + $this->axis === 'y' ? 'row' : 'column', $this->at + 1, $this->mapId); + } + + /** Whether a coordinate on the removal axis is on a removed line. */ + public function isRemoved(int $value): bool + { + return $value >= $this->at && $value < $this->at + $this->count; + } + + public function getShiftedCoordinate(int $value): ?int + { + if ($this->isRemoved($value)) { + return null; + } + + return $value >= $this->at ? $value - $this->count : $value; + } + + /** + * One beyond the removed lines moves back, one straddling them shrinks + * by the lines it loses, one before them stays. + * + * @return array{int, int}|null + */ + public function getShiftedSpan(int $start, int $size): ?array + { + $end = $start + $size; + $lost = max(0, min($end, $this->at + $this->count) - max($start, $this->at)); + if ($lost >= $size) { + return null; + } + $newStart = $start < $this->at ? $start : max($this->at, $start - $this->count); + + return [$newStart, $size - $lost]; + } + + /** @return array{mapRemovals: list} */ + public function getManifestEdits(): array + { + if ($this->landing === null) { + throw new InvalidArgumentException('Choose where saves on the removed lines land before planning the save step.'); + } + + return ['mapRemovals' => [['map' => $this->mapId, 'axis' => $this->axis, 'at' => $this->at, 'count' => $this->count, 'to' => $this->landing]]]; + } +} diff --git a/src/Maps/MapLayers.php b/src/Maps/MapLayers.php index 7ede37c..90b7679 100644 --- a/src/Maps/MapLayers.php +++ b/src/Maps/MapLayers.php @@ -325,6 +325,30 @@ public function insertLines(string $axis, int $at, int $count): void $this->tileSources = $tileSources; } + /** + * Removes rows `$at` to `$at + $count - 1` (axis `y`) or those columns + * (axis `x`) from every layer: the terminal layers, the event layer and + * the tile layers. Every tile layer is rewritten before anything changes, + * so one the Engine cannot read refuses the whole removal. + * + * @throws MapSourceRefusal When a tile layer cannot be read or does not match the map. + */ + public function removeLines(string $axis, int $at, int $count): void + { + $tileSources = []; + if ($this->tileSources !== []) { + $set = $this->getLayerSet(); + foreach ($this->tileSources as $path => $source) { + $tileSources[$path] = TileLayerSource::removeLines($source, $this->getDisplayPath($path), $set, $axis, $at, $count, + $this->baselineSources[$path] ?? null); + } + } + foreach ($this->layers as $layer) { + $layer['grid']->removeLines($axis, $at, $count); + } + $this->tileSources = $tileSources; + } + /** * Writes tile entries into the named tile layers with their top-left * cell at (x, y), as one stamped piece: a `0` entry leaves its cell as it diff --git a/src/Maps/MapLineEdit.php b/src/Maps/MapLineEdit.php new file mode 100644 index 0000000..3c9e438 --- /dev/null +++ b/src/Maps/MapLineEdit.php @@ -0,0 +1,52 @@ +>> + */ + public function getManifestEdits(): array; +} diff --git a/src/Maps/LineInsertionInventory.php b/src/Maps/MapLineInventory.php similarity index 81% rename from src/Maps/LineInsertionInventory.php rename to src/Maps/MapLineInventory.php index f61e47f..33b0e24 100644 --- a/src/Maps/LineInsertionInventory.php +++ b/src/Maps/MapLineInventory.php @@ -8,11 +8,13 @@ use Ichiloto\Engine\Events\Triggers\SleepEventTrigger; /** - * Finds every coordinate a row or column insertion moves, across the - * project's evaluated data files. + * Finds every coordinate a row or column insertion or removal moves, across + * the project's evaluated data files. * * A coordinate moves when it is in the edited map's space and at or beyond - * the insertion line. Some places are always in a known map's space: the + * the edited line. One surely on a removed line has nowhere to go: it is + * reported as blocking, so the removal is refused until the author moves it, + * never dropped or clamped. Some places are always in a known map's space: the * map's own NPCs, event areas and bed spawn points, a transfer's spawn point * into the map from any file, a starting position. Script coordinates are in * the space of the map the script is on at that command, which changes with @@ -27,7 +29,7 @@ * * @package Ichiloto\Editor\Maps */ -final class LineInsertionInventory +final class MapLineInventory { /** @var array, value: int}>> Moves by file, keyed by path. */ private array $shifts = []; @@ -35,6 +37,9 @@ final class LineInsertionInventory /** @var array, reason: string}>> Hand edits by file, keyed by path. */ private array $handEdits = []; + /** @var array, reason: string}>> What stands on a removed line, by file, keyed by path. */ + private array $blocked = []; + /** @var array|null>> The contexts each reusable event script is started from. */ private array $commonEventStarts = []; @@ -45,11 +50,11 @@ final class LineInsertionInventory private array $transferContexts = []; /** - * @param LineInsertion $insertion The insertion. + * @param MapLineEdit $edit The insertion or removal. * @param array $commonEvents Reusable event scripts by id. */ public function __construct( - private readonly LineInsertion $insertion, + private readonly MapLineEdit $edit, private readonly array $commonEvents, ) { } @@ -74,6 +79,16 @@ public function getHandEdits(): array return array_map(array_values(...), $this->handEdits); } + /** + * What stands on a removed line, by file; empty for an insertion. + * + * @return array, reason: string}>> + */ + public function getBlocked(): array + { + return array_map(array_values(...), $this->blocked); + } + /** * Adds one map's data file: its own coordinates when it is the edited * map, and everything in it that leads into the edited map. @@ -82,18 +97,24 @@ public function getHandEdits(): array */ public function addMapData(string $file, string $mapId, array $data): void { - $isTarget = $mapId === $this->insertion->mapId; + $isTarget = $mapId === $this->edit->mapId; $here = [$mapId]; if ($isTarget) { foreach (($data['worldObjects'] ?? []) as $index => $object) { $this->shiftPoint($file, ['worldObjects', $index, 'anchor'], $object['anchor']); foreach (($object['covers']['cells'] ?? []) as $cellIndex => $cell) { - $axis = $this->insertion->axis === 'x' ? 0 : 1; + $axis = $this->edit->axis === 'x' ? 0 : 1; $coordinate = $cell[$axis]; - if (is_int($coordinate) && $coordinate >= $this->insertion->at) { + if (is_int($coordinate) && $coordinate >= $this->edit->at) { $path = ['worldObjects', $index, 'covers', 'cells', $cellIndex, $axis]; - $this->shifts[$file][self::formatKey($path)] = ['path' => $path, 'value' => $this->insertion->getShiftedCoordinate($coordinate)]; + $shifted = $this->edit->getShiftedCoordinate($coordinate); + // An object is drawn whole; cutting the cells it covers would change what it hides. + if ($shifted === null) { + $this->reportBlocked($file, $path, sprintf('world object %s covers a cell on a removed line', strval($object['id'] ?? $index))); + } else { + $this->shifts[$file][self::formatKey($path)] = ['path' => $path, 'value' => $shifted]; + } } } } @@ -128,7 +149,7 @@ public function addMapData(string $file, string $mapId, array $data): void $destination = trim(strval($eventData['destinationMap'] ?? '')); $isBed = $destination === '' && $isTarget && ltrim(strval($event['class'] ?? ''), '\\') === SleepEventTrigger::class; - if (is_array($eventData['spawnPoint'] ?? null) && ($destination === $this->insertion->mapId || $isBed)) { + if (is_array($eventData['spawnPoint'] ?? null) && ($destination === $this->edit->mapId || $isBed)) { $this->shiftPoint($file, ['events', $marker, 'data', 'spawnPoint'], $eventData['spawnPoint']); } if (is_array($eventData['script'] ?? null) && $eventData['script'] !== []) { @@ -149,7 +170,7 @@ public function addMapData(string $file, string $mapId, array $data): void if ($isTarget && is_array($trigger['trigger_area'] ?? null)) { $this->shiftArea($file, ['triggers', $index, 'trigger_area'], $trigger['trigger_area']); } - if (trim(strval($trigger['destinationMap'] ?? '')) === $this->insertion->mapId && is_array($trigger['spawn_point'] ?? null)) { + if (trim(strval($trigger['destinationMap'] ?? '')) === $this->edit->mapId && is_array($trigger['spawn_point'] ?? null)) { $this->shiftPoint($file, ['triggers', $index, 'spawn_point'], $trigger['spawn_point']); } } @@ -163,7 +184,7 @@ public function addMapData(string $file, string $mapId, array $data): void public function addSystemData(string $file, array $system): void { foreach ((array) ($system['startingPositions'] ?? []) as $key => $position) { - if (is_array($position) && trim(strval($position['destinationMap'] ?? '')) === $this->insertion->mapId + if (is_array($position) && trim(strval($position['destinationMap'] ?? '')) === $this->edit->mapId && is_array($position['spawnPoint'] ?? null)) { $this->shiftPoint($file, ['startingPositions', $key, 'spawnPoint'], $position['spawnPoint']); } @@ -276,7 +297,7 @@ private function walkCommands(mixed $commands, array $path, ?array $context, ?st if ($type === 'transfer') { $destination = trim(strval($command['map'] ?? '')); - if ($file !== null && $destination === $this->insertion->mapId) { + if ($file !== null && $destination === $this->edit->mapId) { $this->shiftPoint($file, $at, $command); } $context = $destination !== '' ? [$destination] : null; @@ -307,6 +328,11 @@ private function shiftCommand(string $file, string $type, array $command, array $this->shiftPointIn($file, [...$at, 'waypoints', $index], $waypoint, $context); } } + // Steps are relative, so they cannot be rewritten, but a walk across the edited lines now ends elsewhere. + if (array_key_exists('steps', $command) && ($context === null || in_array($this->edit->mapId, $context, true))) { + $this->reportHandEdit($file, [...$at, 'steps'], sprintf('its relative steps may cross the %s lines; check where the route ends', + $this->edit instanceof LineRemoval ? 'removed' : 'inserted')); + } break; case 'camera': $this->shiftTargetIn($file, $at, $command, $context); @@ -357,15 +383,15 @@ private function shiftTargetIn(string $file, array $path, array $source, ?array */ private function shiftPointIn(string $file, array $path, array $point, ?array $context): void { - if ($context === [$this->insertion->mapId]) { + if ($context === [$this->edit->mapId]) { $this->shiftPoint($file, $path, $point); return; } - if ($context !== null && ! in_array($this->insertion->mapId, $context, true)) { + if ($context !== null && ! in_array($this->edit->mapId, $context, true)) { return; } - $key = $this->insertion->axis; - if (array_key_exists($key, $point) && (! is_int($point[$key]) || $point[$key] >= $this->insertion->at)) { + $key = $this->edit->axis; + if (array_key_exists($key, $point) && (! is_int($point[$key]) || $point[$key] >= $this->edit->at)) { $this->reportHandEdit($file, [...$path, $key], $context === null ? 'it runs on a map that is not known here' : sprintf('it may run on %s', implode(' or ', $context))); @@ -373,24 +399,26 @@ private function shiftPointIn(string $file, array $path, array $point, ?array $c } /** - * Moves a point surely in the edited map's space. Only the insertion - * axis moves; a point naming only the other axis stays. + * Moves a point surely in the edited map's space. Only the edited axis + * moves; a point naming only the other axis stays. A point on a removed + * line blocks the removal. * * @param list $path * @param array $point */ private function shiftPoint(string $file, array $path, array $point): void { - $key = $this->insertion->axis; + $key = $this->edit->axis; if (! array_key_exists($key, $point)) { return; } $value = $point[$key]; if (is_int($value)) { - if ($value >= $this->insertion->at) { - $this->shifts[$file][self::formatKey([...$path, $key])] = [ - 'path' => [...$path, $key], 'value' => $this->insertion->getShiftedCoordinate($value), - ]; + $shifted = $this->edit->getShiftedCoordinate($value); + if ($shifted === null) { + $this->reportBlocked($file, [...$path, $key], 'it stands on a removed line'); + } elseif ($shifted !== $value) { + $this->shifts[$file][self::formatKey([...$path, $key])] = ['path' => [...$path, $key], 'value' => $shifted]; } return; } @@ -399,14 +427,16 @@ private function shiftPoint(string $file, array $path, array $point): void /** * Moves an area surely in the edited map's space: one starting at or - * beyond the line moves, one straddling it stretches. + * beyond the line moves, one straddling inserted lines stretches, one + * straddling removed lines shrinks, and one wholly on removed lines + * blocks the removal. * * @param list $path * @param array $area */ private function shiftArea(string $file, array $path, array $area): void { - $startKey = $this->insertion->axis; + $startKey = $this->edit->axis; $sizeKey = $startKey === 'x' ? 'width' : 'height'; $start = $area[$startKey] ?? 0; $size = $area[$sizeKey] ?? 1; @@ -414,9 +444,14 @@ private function shiftArea(string $file, array $path, array $area): void $this->reportHandEdit($file, $path, 'its position or size is not a whole number'); return; } - [$newStart, $newSize] = $this->insertion->getShiftedSpan($start, $size); + $span = $this->edit->getShiftedSpan($start, $size); + if ($span === null) { + $this->reportBlocked($file, $path, 'the whole area is on removed lines'); + return; + } + [$newStart, $newSize] = $span; if ($newStart !== $start) { - $this->shiftPoint($file, $path, $area); + $this->shifts[$file][self::formatKey([...$path, $startKey])] = ['path' => [...$path, $startKey], 'value' => $newStart]; } if ($newSize !== $size) { $this->shifts[$file][self::formatKey([...$path, $sizeKey])] = ['path' => [...$path, $sizeKey], 'value' => $newSize]; @@ -429,6 +464,12 @@ private function reportHandEdit(string $file, array $path, string $reason): void $this->handEdits[$file][self::formatKey($path)] = ['path' => $path, 'reason' => $reason]; } + /** @param list $path */ + private function reportBlocked(string $file, array $path, string $reason): void + { + $this->blocked[$file][self::formatKey($path)] = ['path' => $path, 'reason' => $reason]; + } + /** @param list $path */ private static function formatKey(array $path): string { diff --git a/src/Maps/LineInsertionPlan.php b/src/Maps/MapLinePlan.php similarity index 76% rename from src/Maps/LineInsertionPlan.php rename to src/Maps/MapLinePlan.php index f162fe1..e9b8621 100644 --- a/src/Maps/LineInsertionPlan.php +++ b/src/Maps/MapLinePlan.php @@ -7,21 +7,21 @@ use Ichiloto\Editor\Storage\SourceSetPlan; /** - * A planned row or column insertion: the files it writes, as one reversible - * transaction, and what it could not rewrite. + * A planned row or column insertion or removal: the files it writes, as one + * reversible transaction, and what it could not rewrite. * * @package Ichiloto\Editor\Maps */ -final class LineInsertionPlan +final class MapLinePlan { /** - * @param LineInsertion $insertion What is inserted where. + * @param MapLineEdit $edit What changes where. * @param SourceSetPlan $sources The files written, and how to restore them. * @param list $handEdits Coordinates to move by hand, by project-relative file. * @param list $notes What the author should know, such as whether saves follow. */ public function __construct( - public readonly LineInsertion $insertion, + public readonly MapLineEdit $edit, private readonly SourceSetPlan $sources, public readonly array $handEdits, public readonly array $notes, @@ -32,7 +32,7 @@ public function __construct( public function getSourceSet(): SourceSetPlan { return $this->sources; } - /** @return list Every file the insertion writes. */ + /** @return list Every file the edit writes. */ public function getChangedPaths(): array { return $this->sources->getChangedPaths(); } /** @return list Every changed path. */ diff --git a/src/Maps/LineInsertionPlanner.php b/src/Maps/MapLinePlanner.php similarity index 68% rename from src/Maps/LineInsertionPlanner.php rename to src/Maps/MapLinePlanner.php index 34cc2d0..c428ce5 100644 --- a/src/Maps/LineInsertionPlanner.php +++ b/src/Maps/MapLinePlanner.php @@ -16,24 +16,27 @@ use Ichiloto\Engine\Field\MapGridSource; use Ichiloto\Engine\Field\MapLayerSource; use Ichiloto\Engine\Field\MapPhysicalOccupancy; +use Ichiloto\Engine\Field\Reachability\ProjectReachability; use RuntimeException; use Throwable; /** - * Plans inserting blank rows or columns into a map so the whole project - * stays consistent: the map's grids grow, and every coordinate in its space - * at or beyond the line moves, in whichever file holds it - * ({@see LineInsertionInventory}). Every changed file is rewritten through + * Plans inserting blank rows or columns into a map, or removing them, so the + * whole project stays consistent: the map's grids grow or shrink, and every + * coordinate in its space at or beyond the line moves, in whichever file + * holds it ({@see MapLineInventory}). Every changed file is rewritten through * the source-preserving writers; a coordinate the source cannot take as a - * literal is reported for a hand edit instead of being flattened. When the + * literal is reported for a hand edit instead of being flattened. A removal + * is refused while anything authored stands on a removed line. When the * project has a save compatibility manifest, the plan raises its content - * version with a declarative map shift step, so existing saves follow. + * version with a declarative map edit step, so existing saves follow; saves + * standing on removed lines land on a cell the author confirms. * * Planning reads the project from disk and writes nothing. * * @package Ichiloto\Editor\Maps */ -final class LineInsertionPlanner +final class MapLinePlanner { private const string MANIFEST = 'assets/Data/save-compatibility.php'; private const string SYSTEM = 'assets/Data/system.php'; @@ -48,18 +51,35 @@ private function __construct() * * @throws RuntimeException When the map or a data file cannot be read; nothing is written. */ - public static function planProject(string $projectRoot, string $mapId, string $axis, int $at, int $count): LineInsertionPlan + public static function planInsertion(string $projectRoot, string $mapId, string $axis, int $at, int $count): MapLinePlan { $insertion = new LineInsertion($mapId, $axis, $at, $count); - return ProjectDirectoryContext::run($projectRoot, static fn(string $root): LineInsertionPlan => self::buildPlan($root, $insertion)); + return ProjectDirectoryContext::run($projectRoot, static fn(string $root): MapLinePlan => self::buildPlan($root, $insertion)); } - private static function buildPlan(string $root, LineInsertion $insertion): LineInsertionPlan + /** + * Plans the removal; callers show the plan, including where saves on the + * removed lines land, and obtain confirmation before applying it. + * + * @param array{int, int}|null $landing Where saves on the removed lines land, after the removal; null proposes the reachable cell nearest them. + * @throws RuntimeException When the map or a data file cannot be read, something authored stands on a removed line, or the landing is unusable; nothing is written. + */ + public static function planRemoval(string $projectRoot, string $mapId, string $axis, int $at, int $count, ?array $landing = null): MapLinePlan + { + $removal = new LineRemoval($mapId, $axis, $at, $count, $landing); + + return ProjectDirectoryContext::run($projectRoot, static fn(string $root): MapLinePlan => self::buildPlan($root, $removal)); + } + + private static function buildPlan(string $root, MapLineEdit $edit): MapLinePlan { - $directory = $root . '/assets/Maps/' . $insertion->mapId; + $directory = $root . '/assets/Maps/' . $edit->mapId; $map = ProjectMap::fromDirectory($root . '/assets/Maps', $directory); - $map->insertLines($insertion->axis, $insertion->at, $insertion->count); + [$width, $height] = [$map->getWidth(), $map->getHeight()]; + $edit instanceof LineRemoval + ? $map->removeLines($edit->axis, $edit->at, $edit->count) + : $map->insertLines($edit->axis, $edit->at, $edit->count); $watched = $originals = $proposals = $before = $after = []; $gridPaths = self::findGridPaths($directory); @@ -101,7 +121,7 @@ private static function buildPlan(string $root, LineInsertion $insertion): LineI $commonEvents[basename($path, '.php')] = ['file' => $path, 'commands' => $payloads[$path]]; } } - $inventory = new LineInsertionInventory($insertion, $commonEvents); + $inventory = new MapLineInventory($edit, $commonEvents); foreach ($dataPaths as $path) { if (str_starts_with($path, $root . '/assets/Maps/') && is_array($payloads[$path])) { $inventory->addMapData($path, substr(dirname($path), strlen($root . '/assets/Maps/')), $payloads[$path]); @@ -120,10 +140,21 @@ private static function buildPlan(string $root, LineInsertion $insertion): LineI } $inventory->addCommonEvents(); + $blocked = []; + foreach ($inventory->getBlocked() as $path => $entries) { + foreach ($entries as $entry) { + $blocked[] = sprintf('%s %s (%s)', $relative($path), PhpArraySourceDocument::describePath($entry['path']), $entry['reason']); + } + } + if ($blocked !== []) { + throw new RuntimeException(sprintf('%s %s is refused; nothing was changed. Move these off the removed lines first: %s.', + $edit->verb, $edit->description, implode('; ', $blocked))); + } + $handEdits = []; foreach ($inventory->getHandEdits() as $path => $edits) { - foreach ($edits as $edit) { - $handEdits[] = ['file' => $relative($path), 'where' => PhpArraySourceDocument::describePath($edit['path']), 'reason' => $edit['reason']]; + foreach ($edits as $handEdit) { + $handEdits[] = ['file' => $relative($path), 'where' => PhpArraySourceDocument::describePath($handEdit['path']), 'reason' => $handEdit['reason']]; } } foreach ($inventory->getShifts() as $path => $shifts) { @@ -142,13 +173,18 @@ private static function buildPlan(string $root, LineInsertion $insertion): LineI if (! array_key_exists($manifestPath, $payloads)) { $notes[] = sprintf('Existing saves are not migrated: the project has no %s.', self::MANIFEST); } else { - $next = self::planSaveMigration($watched[$manifestPath], $payloads[$manifestPath], $insertion, $handEdits); + if ($edit instanceof LineRemoval) { + $edit = $edit->withLanding(self::findLanding($root, $edit, $width, $height)); + $notes[] = sprintf('Saves standing on the removed lines land at x %d, y %d.', ...$edit->landing); + } + $next = self::planSaveMigration($watched[$manifestPath], $payloads[$manifestPath], $edit, $handEdits); if ($next !== null) { $originals[$manifestPath] = $watched[$manifestPath]; $proposals[$manifestPath] = $next['source']; $before[$manifestPath] = PhpDataFile::getComparableValue($payloads[$manifestPath]); $after[$manifestPath] = PhpDataFile::getComparableValue($next['value']); - $notes[] = sprintf('Saves move with the map: save content version %d adds this map shift.', $next['value']['contentVersion']); + $notes[] = sprintf('Saves move with the map: save content version %d adds this map %s.', $next['value']['contentVersion'], + $edit instanceof LineRemoval ? 'removal' : 'shift'); } } @@ -157,11 +193,64 @@ private static function buildPlan(string $root, LineInsertion $insertion): LineI static fn(string $path, string $staged): mixed => in_array($path, $gridPaths, true) ? MapGridSource::parseSource((string) file_get_contents($staged), $path) : PhpDataFile::getComparableValue(PhpDataFile::evaluateIsolated($staged, $root)), - 'map line insertion', + 'map line ' . ($edit instanceof LineRemoval ? 'removal' : 'insertion'), 'Map and project data files', ); - return new LineInsertionPlan($insertion, $sources, $handEdits, $notes, $map->getWidth(), $map->getHeight()); + return new MapLinePlan($edit, $sources, $handEdits, $notes, $map->getWidth(), $map->getHeight()); + } + + /** + * Where saves standing on the removed lines land, in the map's + * coordinates after the removal: the author's cell when given, which + * must be one the player can reach today off the removed lines, or else + * the reachable cell nearest the reachable part of the removed lines. + * + * @return array{int, int} + * @throws RuntimeException When no reachable cell is left, or the given landing is not one. + */ + private static function findLanding(string $root, LineRemoval $removal, int $width, int $height): array + { + $report = ProjectReachability::analyze($root . '/assets')->reports[$removal->mapId] ?? null; + $vertical = $removal->axis === 'y'; + // Today's cell for a cell after the removal: lines from the removed ones on sit further along by the count. + $toToday = static fn(int $x, int $y): array => $vertical + ? [$x, $y >= $removal->at ? $y + $removal->count : $y] + : [$x >= $removal->at ? $x + $removal->count : $x, $y]; + if ($removal->landing !== null) { + [$x, $y] = $toToday(...$removal->landing); + if ($x >= $width || $y >= $height || ! ($report?->isReachable($x, $y) ?? false)) { + throw new RuntimeException(sprintf('Saves cannot land at x %d, y %d: the player cannot reach that cell. Choose a reachable cell off the removed lines.', + ...$removal->landing)); + } + + return $removal->landing; + } + $kept = $removed = []; + for ($y = 0; $y < $height; $y++) { + for ($x = 0; $x < $width; $x++) { + if (! ($report?->isReachable($x, $y) ?? false)) { + continue; + } + if ($removal->isRemoved($vertical ? $y : $x)) { + $removed[] = [$x, $y]; + } else { + $kept[] = [$x, $y]; + } + } + } + if ($kept === []) { + throw new RuntimeException(sprintf('%s leaves no cell the player can reach, so saves on it have nowhere to land; nothing was changed.', $removal->mapId)); + } + // Nearest the middle of where a saved player could stand on the removed lines, or of the lines themselves. + $middle = $removed !== [] + ? [array_sum(array_column($removed, 0)) / count($removed), array_sum(array_column($removed, 1)) / count($removed)] + : ($vertical ? [($width - 1) / 2, $removal->at + ($removal->count - 1) / 2] : [$removal->at + ($removal->count - 1) / 2, ($height - 1) / 2]); + usort($kept, static fn(array $a, array $b): int => [abs($a[0] - $middle[0]) + abs($a[1] - $middle[1]), $a[1], $a[0]] + <=> [abs($b[0] - $middle[0]) + abs($b[1] - $middle[1]), $b[1], $b[0]]); + [$x, $y] = $kept[0]; + + return $vertical ? [$x, $y >= $removal->at ? $y - $removal->count : $y] : [$x >= $removal->at ? $x - $removal->count : $x, $y]; } /** @@ -246,16 +335,16 @@ private static function findLiteralRefusal(PhpArraySourceDocument $document, arr /** * Raises the manifest's content version by one with a declarative map - * shift step, or reports why it must be done by hand. + * edit step, or reports why it must be done by hand. * * @param list $handEdits * @return array{source: string, value: array}|null */ - private static function planSaveMigration(string $source, mixed $manifest, LineInsertion $insertion, array &$handEdits): ?array + private static function planSaveMigration(string $source, mixed $manifest, MapLineEdit $edit, array &$handEdits): ?array { $version = is_array($manifest) ? ($manifest['contentVersion'] ?? null) : null; $migrations = is_array($manifest) ? ($manifest['migrations'] ?? []) : null; - $step = ['from' => $version, 'to' => is_int($version) ? $version + 1 : null, 'mapShifts' => [$insertion->getManifestEntry()]]; + $step = ['from' => $version, 'to' => is_int($version) ? $version + 1 : null, ...$edit->getManifestEdits()]; $reason = null; if (! is_int($version) || $version < 0 || ! is_array($migrations) || ! array_is_list($migrations)) { $reason = 'its contentVersion or migrations list is not valid, so saves cannot be migrated automatically'; @@ -346,7 +435,7 @@ private static function evaluate(array $paths, string $root): array try { $payloads[$path] = PhpDataFile::evaluateIsolated($path, $root); } catch (Throwable $failure) { - throw new RuntimeException(sprintf('%s could not be read, so the rows or columns cannot be inserted safely: %s', + throw new RuntimeException(sprintf('%s could not be read, so the rows or columns cannot be changed safely: %s', substr($path, strlen($root) + 1), $failure->getMessage()), previous: $failure); } } diff --git a/src/Maps/TileLayerSource.php b/src/Maps/TileLayerSource.php index fc73296..aa99ecc 100644 --- a/src/Maps/TileLayerSource.php +++ b/src/Maps/TileLayerSource.php @@ -112,6 +112,35 @@ public static function insertLines(string $source, string $displayPath, MapLayer return self::rewrite($rows, $entries, $source, $displayPath, $baseline); } + /** + * Removes rows `$at` to `$at + $count - 1` (axis `y`), or those cells + * from every row that reaches them (axis `x`), as + * {@see EditableGrid::removeLines()} removes them from the terminal + * layers. The result is rewritten as {@see resize()} rewrites a layer. + * + * @param MapLayerSet $layers The map's terminal layers before the removal. + * @throws MapSourceRefusal When the layer cannot be read or does not match the map; nothing is changed. + */ + public static function removeLines(string $source, string $displayPath, MapLayerSet $layers, string $axis, int $at, int $count, ?string $baseline = null): string + { + $layer = self::readMatchingLayer($source, $displayPath, $layers, 'removing rows or columns'); + + $entries = $layer->getEntries(); + $rows = $entries; + if ($axis === 'y') { + array_splice($rows, $at, $count); + } else { + foreach ($rows as &$row) { + if (count($row) > $at) { + array_splice($row, $at, $count); + } + } + unset($row); + } + + return self::rewrite($rows, $entries, $source, $displayPath, $baseline); + } + /** * Writes a piece's tile entries into a tile layer with its top-left cell * at (x, y). A `0` entry leaves its cell as it was; every other entry diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 41fb142..3a14301 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -2129,6 +2129,64 @@ public function insertLines(string $axis, int $at, int $count): void $this->applyGridState($layers, $occupancy); } + /** + * Removes rows `$at` to `$at + $count - 1` (axis `y`) or those columns + * (axis `x`) from every grid the map has: its terminal layers, its event + * layer and its tile layers. Declared physical occupancy loses the same + * cells; absent occupancy stays absent. Ragged rows follow terminal + * geometry: columns affect only rows reaching them. An event marker on a + * removed line refuses the removal, so no event is dropped or cut. Other + * data coordinates move through the project-wide line plan. + * + * @throws MapSourceRefusal When the lines are outside the map, would leave it empty, hold an event marker, or a tile layer cannot lose them; nothing is changed. + */ + public function removeLines(string $axis, int $at, int $count): void + { + $this->assertEditable(); + $size = match ($axis) { + 'x' => $this->getWidth(), + 'y' => $this->getHeight(), + default => throw new MapSourceRefusal("Remove rows (y) or columns (x), not '{$axis}'."), + }; + $noun = $axis === 'y' ? 'rows' : 'columns'; + if ($count < 1 || $at < 0 || $at + $count > $size) { + throw new MapSourceRefusal(sprintf('%s has no %s %d to %d to remove; nothing was changed.', $this->mapId, $noun, $at + 1, $at + $count)); + } + if ($count === $size) { + throw new MapSourceRefusal(sprintf('Removing every one of its %s would leave %s empty; nothing was changed.', $noun, $this->mapId)); + } + $markers = []; + for ($line = $at; $line < $at + $count; $line++) { + for ($other = 0, $extent = $axis === 'y' ? $this->getWidth() : $this->getHeight(); $other < $extent; $other++) { + [$x, $y] = $axis === 'y' ? [$other, $line] : [$line, $other]; + $marker = $this->hasLayerCell(MapLayers::EVENT, $x, $y) ? $this->getEventMarkerAt($x, $y) : null; + if ($marker !== null) { + $markers[] = sprintf('event %s at x %d, y %d', $marker, $x, $y); + } + } + } + if ($markers !== []) { + throw new MapSourceRefusal(sprintf('Move these off the removed %s first; nothing was changed: %s.', $noun, implode('; ', $markers))); + } + + $occupancy = $this->getDeclaredPhysicalOccupancy(); + $layers = $this->copyEditableLayers(); + $layers->removeLines($axis, $at, $count); + if ($occupancy !== null) { + if ($axis === 'y') { + array_splice($occupancy, $at, $count); + } else { + foreach ($occupancy as &$row) { + if (count($row) > $at) { + array_splice($row, $at, $count); + } + } + unset($row); + } + } + $this->applyGridState($layers, $occupancy); + } + /** @return list>|null Null means undeclared, never invalid. */ private function getDeclaredPhysicalOccupancy(): ?array { diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 6d62e10..751fb5b 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -74,7 +74,9 @@ use Ichiloto\Editor\Playtest\PlaytestRun; use Ichiloto\Editor\Playtest\PlaytestStart; use Ichiloto\Editor\Maps\LayerEditor; -use Ichiloto\Editor\Maps\LineInsertionPlanner; +use Ichiloto\Editor\Maps\LineRemoval; +use Ichiloto\Editor\Maps\MapLinePlan; +use Ichiloto\Editor\Maps\MapLinePlanner; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Maps\MapReferences; use Ichiloto\Editor\Maps\TilePalette; @@ -451,6 +453,33 @@ public function moveMap(string $mapId, int $revision, ?string $destination = nul * @throws SessionRefusal When the map or review is stale, edits are pending, or the plan cannot be applied safely. */ public function insertMapLines(string $mapId, int $revision, string $axis, int $at, int $count, ?string $answer = null, ?string $confirm = null): array + { + return $this->editMapLines($mapId, $revision, $answer, $confirm, 'inserted', 'inserting map lines', + fn(): MapLinePlan => MapLinePlanner::planInsertion($this->workspace->projectRoot, $mapId, $axis, $at, $count)); + } + + /** + * Reviews a project-wide row or column removal before writing it as one + * reversible source set, as {@see insertMapLines()} does. Anything + * authored on a removed line refuses the removal. The review names where + * saves on the removed lines land; confirming repeats that landing. + * + * @param array{int, int}|null $landing Where saves on the removed lines land, after the removal; null proposes one. + * @return array + * @throws SessionRefusal When the map or review is stale, edits are pending, or the removal cannot be applied safely. + */ + public function removeMapLines(string $mapId, int $revision, string $axis, int $at, int $count, ?array $landing = null, ?string $answer = null, ?string $confirm = null): array + { + return $this->editMapLines($mapId, $revision, $answer, $confirm, 'removed', 'removing map lines', + fn(): MapLinePlan => MapLinePlanner::planRemoval($this->workspace->projectRoot, $mapId, $axis, $at, $count, $landing)); + } + + /** + * @param callable(): MapLinePlan $plan Plans the edit from the files on disk. + * @return array + * @throws SessionRefusal + */ + private function editMapLines(string $mapId, int $revision, ?string $answer, ?string $confirm, string $done, string $doing, callable $plan): array { $map = $this->requireCurrentMap($mapId, $revision); if ($map->getGridSourceIssue() !== null) { @@ -460,25 +489,26 @@ public function insertMapLines(string $mapId, int $revision, string $axis, int $ return ['status' => 'cancelled', 'map' => $mapId, 'revision' => $revision, 'changed' => false, 'maps' => $this->describeMaps()]; } if ($this->workspace->hasUnsavedChanges()) { - throw new SessionRefusal('Save or undo pending edits before inserting map lines. No files were changed.'); + throw new SessionRefusal(sprintf('Save or undo pending edits before %s. No files were changed.', $doing)); } try { - $plan = LineInsertionPlanner::planProject($this->workspace->projectRoot, $mapId, $axis, $at, $count); + $plan = $plan(); } catch (InvalidArgumentException|RuntimeException $failure) { throw new SessionRefusal($failure->getMessage(), previous: $failure); } + $edit = $plan->edit; $paths = array_map(fn(string $path): string => substr($path, strlen($this->workspace->projectRoot) + 1), $plan->getChangedPaths()); - $description = $plan->insertion->description; - $required = new SourceSetRequired($plan->getSourceSet(), 'Insert ' . $plan->insertion->noun, - 'this map line insertion', 'Insert ' . $description . '?', $paths); + $required = new SourceSetRequired($plan->getSourceSet(), $edit->verb . ' ' . $edit->noun, + 'this map line ' . ($done === 'removed' ? 'removal' : 'insertion'), $edit->verb . ' ' . $edit->description . '?', $paths); $result = $this->writeSourceSet($required, $answer, static fn(): array => [], $confirm); if (($result['status'] ?? null) === 'question') { - return [...$result, 'map' => $mapId, 'revision' => $revision, 'axis' => $axis, 'at' => $at, 'count' => $count, + return [...$result, 'map' => $mapId, 'revision' => $revision, 'axis' => $edit->axis, 'at' => $edit->at, 'count' => $edit->count, + ...($edit instanceof LineRemoval ? ['landing' => $edit->landing] : []), 'paths' => $paths, 'handEdits' => $plan->handEdits, 'notes' => $plan->notes, 'width' => $plan->width, 'height' => $plan->height]; } - return ['status' => 'inserted', 'map' => $mapId, 'revision' => $this->getMapRevision($this->requireMap($mapId)), + return ['status' => $done, 'map' => $mapId, 'revision' => $this->getMapRevision($this->requireMap($mapId)), 'changed' => true, 'maps' => $this->describeMaps()]; } diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 064fb38..51a5de4 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -146,6 +146,12 @@ private function dispatch(string $method, array $params): array self::requireString($params, 'axis'), self::requireInt($params, 'at'), self::requireInt($params, 'count'), self::readOptionalString($params, 'answer'), self::readOptionalString($params, 'confirm'), ), + 'map.removeLines' => $session->removeMapLines( + self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'axis'), self::requireInt($params, 'at'), self::requireInt($params, 'count'), + is_array($params['landing'] ?? null) ? $params['landing'] : null, + self::readOptionalString($params, 'answer'), self::readOptionalString($params, 'confirm'), + ), 'map.read' => $session->readMap(self::requireString($params, 'map')), 'worldObjects.read' => $session->readWorldObject(self::requireString($params, 'map'), self::requireString($params, 'id')), 'worldObjects.create' => $session->createWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), diff --git a/src/UI/Modal.php b/src/UI/Modal.php index 789bca1..2e05c2c 100644 --- a/src/UI/Modal.php +++ b/src/UI/Modal.php @@ -37,7 +37,7 @@ enum Modal: string /** * The count prompt for inserting rows or columns into the map. */ - case LINE_INSERT = 'line_insert'; + case MAP_LINES = 'map_lines'; /** * The Ctrl+P command palette. Opens above the Database screen and the * main shell, below the safety modals. diff --git a/src/Validation/SaveCompatibilityValidator.php b/src/Validation/SaveCompatibilityValidator.php index de30361..db9ef39 100644 --- a/src/Validation/SaveCompatibilityValidator.php +++ b/src/Validation/SaveCompatibilityValidator.php @@ -20,7 +20,7 @@ final class SaveCompatibilityValidator { private const string WHERE = 'assets/Data/save-compatibility.php'; - private const string DECLARED_EDITS_HINT = 'Use a class for a project migration, or declared position edits: mapShifts (map, axis x or y, the 0-based line inserted at, a count of at least 1) for inserted rows and columns, relocations (map, cells, to) for cells authored content now occupies.'; + private const string DECLARED_EDITS_HINT = 'Use a class for a project migration, or declared position edits: mapShifts (map, axis x or y, the 0-based line inserted at, a count of at least 1) for inserted rows and columns, mapRemovals (map, axis, the 0-based first removed line at, a count of at least 1, the cell to that saves on removed lines land on) for removed rows and columns, relocations (map, cells, to) for cells authored content now occupies.'; /** * @var InventoryCatalog|null The project's inventory identity, read once. diff --git a/tests/Pest.php b/tests/Pest.php index 8099630..703f612 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -382,6 +382,7 @@ function plainTextOfFrame(string $frame, int $width, int $height): string require_once __DIR__ . '/Support/MapGraphicsFixtures.php'; require_once __DIR__ . '/Support/AuthoringCanvasFixtures.php'; require_once __DIR__ . '/Support/PhysicalOccupancyFixtures.php'; +require_once __DIR__ . '/Support/MapLineFixtures.php'; require_once __DIR__ . '/Support/BattleEntryFixtures.php'; diff --git a/tests/Support/MapLineFixtures.php b/tests/Support/MapLineFixtures.php new file mode 100644 index 0000000..e204a1a --- /dev/null +++ b/tests/Support/MapLineFixtures.php @@ -0,0 +1,191 @@ + "######\n#....#\n#.~~.#\n#....#\n######", + 'layers/02.detail.deco.php' => " \n d \n \n d \n ", + 'home.event.php' => " \n W \n \n D S \n ", + ]; + foreach ($grids as $file => $text) { + file_put_contents($home . '/' . $file, MapGridSource::buildSource($text, 'AUTHORED', "// keep {$file}\n")); + } + writeTileLayer($home, '01.floor.tiles.php', implode("\n", array_map( + static fn(int $row): string => implode(' ', array_fill(0, 6, $row === 2 ? '5' : '2816')), range(0, 4))), + "// Painted in the GUI editor.\n"); + file_put_contents($home . '/home.data.php', <<<'PHP' + 'Home', + 'region' => '', + 'tileset' => 'home', + 'station' => ['x' => 3, 'y' => 3], + 'tileLayers' => ['floor' => ['offset' => [0, 3]]], + 'npcs' => [ + ['id' => 'cat', 'name' => 'Cat', 'sprite' => 'c', 'x' => 2, 'y' => 3, + 'movement' => 'wander', 'wanderArea' => ['x' => 1, 'y' => 1, 'width' => 3, 'height' => 3]], + ['id' => 'mother', 'name' => 'Mother', 'sprite' => '@', 'x' => 4, 'y' => 0, 'script' => [ + ['type' => 'move_player', 'x' => 1, 'y' => 4], + ['type' => 'transfer', 'map' => 'town', 'x' => 2, 'y' => 3], + ['type' => 'move_player', 'x' => 3, 'y' => 3], + ]], + ['id' => 'lamp', 'name' => 'Lamp', 'sprite' => 'i', 'x' => 1, 'y' => 2 + 1], + ], + 'events' => [ + 'D' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\SleepEventTrigger', + 'data' => ['spawnPoint' => ['x' => 1, 'y' => 4], 'spawnSprite' => ['South'], 'cost' => 0], + ], + 'W' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'home-only'], + ], + 'S' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'shared'], + ], + ], +]; +PHP); + + $town = $root . '/assets/Maps/town'; + mkdir($town, 0o777, true); + file_put_contents($town . '/town.map.php', MapGridSource::buildSource("######\n# #\n######", 'ICHILOTO_MAP')); + file_put_contents($town . '/town.event.php', MapGridSource::buildSource(" AB S \n \n ", 'ICHILOTO_EVENT_MAP')); + file_put_contents($town . '/town.data.php', <<<'PHP' + 'Town', + 'region' => '', + 'npcs' => [ + ['id' => 'guard', 'name' => 'Guard', 'sprite' => 'g', 'x' => 1, 'y' => 1, 'script' => [ + ['type' => 'transfer', 'map' => 'home', 'x' => 1, 'y' => 3], + ['type' => 'move_player', 'x' => 2, 'y' => 4], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 5], ['y' => 4]]], + ]], + ], + 'events' => [ + 'A' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\TransferPlayerTrigger', + 'data' => ['destinationMap' => 'home', 'spawnPoint' => ['x' => 2, 'y' => 4], 'spawnSprite' => ['North']], + ], + 'B' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\TransferPlayerTrigger', + 'data' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 2, 'y' => 4], 'spawnSprite' => ['North']], + ], + 'S' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'shared'], + ], + ], +]; +PHP); + + file_put_contents($root . '/assets/Events/home-only.php', <<<'PHP' + 'move_player', 'x' => 3, 'y' => 4], + ['type' => 'camera', 'operation' => 'pan', 'target' => ['kind' => 'position', 'x' => 2, 'y' => 1], 'seconds' => 1.0], +]; +PHP); + file_put_contents($root . '/assets/Events/shared.php', <<<'PHP' + 'move_player', 'x' => 3, 'y' => 4], +]; +PHP); + file_put_contents($root . '/assets/Data/system.php', <<<'PHP' + [ + 'player' => ['destinationMap' => 'home', 'spawnPoint' => ['x' => 1, 'y' => 4], 'spawnSprite' => ['South']], + ], +]; +PHP); + + return $root; +} + +/** Plans an insertion on the fixture's home map. */ +function planLineInsertion(string $root, string $axis, int $at, int $count): Ichiloto\Editor\Maps\MapLinePlan +{ + return MapLinePlanner::planInsertion($root, 'home', $axis, $at, $count); +} + +/** @return list Each hand edit as `file where`. */ +function describeMapLineHandEdits(Ichiloto\Editor\Maps\MapLinePlan $plan): array +{ + return array_map(static fn(array $edit): string => $edit['file'] . ' ' . $edit['where'], $plan->handEdits); +} + +/** @return list The messages validation reports for the save manifest. */ +function describeSaveCompatibilityIssues(string $root): array +{ + return array_values(array_map(static fn($issue): string => $issue->message, array_filter(validateProject($root), + static fn($issue): bool => str_starts_with($issue->where, 'assets/Data/save-compatibility.php')))); +} + +/** The editor over the fixture with `home` selected and the cursor on the canvas. */ +function mapLineEditor(string $root): Editor +{ + $editor = deletionEditor($root); + setEditorProperty($editor, 'focusedPane', 'canvas'); + expect(getEditorProperty($editor, 'workspace')->getMapByIndex(0)->mapId)->toBe('home'); + + return $editor; +} + +function openMapLinePaletteItem(Editor $editor, string $label): void +{ + $items = callEditorMethod($editor, 'buildPaletteItems'); + $item = array_values(array_filter($items, static fn($item) => $item->label === $label))[0] ?? null; + expect($item)->not->toBeNull(); + ($item->action)(); +} + +/** The map line project with a collision dictionary, so the player's reach on `home` is known. */ +function mapLineRemovalProject(): string +{ + $root = mapLineProject(); + file_put_contents($root . '/assets/Maps/collisions.php', " CollisionType::SOLID, '.' => CollisionType::NONE, '~' => CollisionType::SOLID, ' ' => CollisionType::NONE];\n"); + // The start and the town arrival stand in a gap in the bottom wall, so the floor is reachable from them. + file_put_contents($root . '/assets/Maps/home/layers/01.terrain.map.php', MapGridSource::buildSource( + "######\n#....#\n#.~~.#\n#....#\n#..###", 'AUTHORED', "// keep layers/01.terrain.map.php\n")); + // The game reads a bed only with its question. + $home = $root . '/assets/Maps/home/home.data.php'; + file_put_contents($home, str_replace("'cost' => 0]", "'cost' => 0, 'confirmDialogue' => ['text' => 'Rest?']]", (string) file_get_contents($home))); + + return $root; +} + +/** Plans a removal on the fixture's home map. */ +function planLineRemoval(string $root, string $axis, int $at, int $count, ?array $landing = null): Ichiloto\Editor\Maps\MapLinePlan +{ + return MapLinePlanner::planRemoval($root, 'home', $axis, $at, $count, $landing); +} diff --git a/tests/Unit/MapLineInsertionTest.php b/tests/Unit/MapLineInsertionTest.php index e32a6e7..5bc12ca 100644 --- a/tests/Unit/MapLineInsertionTest.php +++ b/tests/Unit/MapLineInsertionTest.php @@ -2,178 +2,18 @@ declare(strict_types=1); -use Ichiloto\Editor\Editor; -use Ichiloto\Editor\Maps\LineInsertionPlanner; use Ichiloto\Editor\Maps\TileLayerSource; use Ichiloto\Editor\ProjectMap; -use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Field\MapGridSource; /** * Inserting rows or columns into a map moves everything in its space at or - * beyond the line, in every file, as one undoable write. The fixture is a - * 6 x 5 layered map `home` with a styled terrain layer, a decoration layer, - * events and a tile layer, and a legacy map `town` whose events and scripts - * lead into it. + * beyond the line, in every file, as one undoable write. The fixture is + * {@see mapLineProject()}. */ -/** Writes the synthetic insertion project and returns its root. */ -function lineInsertionProject(): string -{ - $root = makeTemporaryProject('ichiloto-line-insertion-'); - writeTestTileset($root); - $home = $root . '/assets/Maps/home'; - mkdir($home . '/layers', 0o777, true); - $grids = [ - 'layers/01.terrain.map.php' => "######\n#....#\n#.~~.#\n#....#\n######", - 'layers/02.detail.deco.php' => " \n d \n \n d \n ", - 'home.event.php' => " \n W \n \n D S \n ", - ]; - foreach ($grids as $file => $text) { - file_put_contents($home . '/' . $file, MapGridSource::buildSource($text, 'AUTHORED', "// keep {$file}\n")); - } - writeTileLayer($home, '01.floor.tiles.php', implode("\n", array_map( - static fn(int $row): string => implode(' ', array_fill(0, 6, $row === 2 ? '5' : '2816')), range(0, 4))), - "// Painted in the GUI editor.\n"); - file_put_contents($home . '/home.data.php', <<<'PHP' - 'Home', - 'region' => '', - 'tileset' => 'home', - 'station' => ['x' => 3, 'y' => 3], - 'tileLayers' => ['floor' => ['offset' => [0, 3]]], - 'npcs' => [ - ['id' => 'cat', 'name' => 'Cat', 'sprite' => 'c', 'x' => 2, 'y' => 3, - 'movement' => 'wander', 'wanderArea' => ['x' => 1, 'y' => 1, 'width' => 3, 'height' => 3]], - ['id' => 'mother', 'name' => 'Mother', 'sprite' => '@', 'x' => 4, 'y' => 0, 'script' => [ - ['type' => 'move_player', 'x' => 1, 'y' => 4], - ['type' => 'transfer', 'map' => 'town', 'x' => 2, 'y' => 3], - ['type' => 'move_player', 'x' => 3, 'y' => 3], - ]], - ['id' => 'lamp', 'name' => 'Lamp', 'sprite' => 'i', 'x' => 1, 'y' => 2 + 1], - ], - 'events' => [ - 'D' => [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\SleepEventTrigger', - 'data' => ['spawnPoint' => ['x' => 1, 'y' => 4], 'spawnSprite' => ['South'], 'cost' => 0], - ], - 'W' => [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', - 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'home-only'], - ], - 'S' => [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', - 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'shared'], - ], - ], -]; -PHP); - - $town = $root . '/assets/Maps/town'; - mkdir($town, 0o777, true); - file_put_contents($town . '/town.map.php', MapGridSource::buildSource("######\n# #\n######", 'ICHILOTO_MAP')); - file_put_contents($town . '/town.event.php', MapGridSource::buildSource(" AB S \n \n ", 'ICHILOTO_EVENT_MAP')); - file_put_contents($town . '/town.data.php', <<<'PHP' - 'Town', - 'region' => '', - 'npcs' => [ - ['id' => 'guard', 'name' => 'Guard', 'sprite' => 'g', 'x' => 1, 'y' => 1, 'script' => [ - ['type' => 'transfer', 'map' => 'home', 'x' => 1, 'y' => 3], - ['type' => 'move_player', 'x' => 2, 'y' => 4], - ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 5], ['y' => 4]]], - ]], - ], - 'events' => [ - 'A' => [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\TransferPlayerTrigger', - 'data' => ['destinationMap' => 'home', 'spawnPoint' => ['x' => 2, 'y' => 4], 'spawnSprite' => ['North']], - ], - 'B' => [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\TransferPlayerTrigger', - 'data' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 2, 'y' => 4], 'spawnSprite' => ['North']], - ], - 'S' => [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', - 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'shared'], - ], - ], -]; -PHP); - - file_put_contents($root . '/assets/Events/home-only.php', <<<'PHP' - 'move_player', 'x' => 3, 'y' => 4], - ['type' => 'camera', 'operation' => 'pan', 'target' => ['kind' => 'position', 'x' => 2, 'y' => 1], 'seconds' => 1.0], -]; -PHP); - file_put_contents($root . '/assets/Events/shared.php', <<<'PHP' - 'move_player', 'x' => 3, 'y' => 4], -]; -PHP); - file_put_contents($root . '/assets/Data/system.php', <<<'PHP' - [ - 'player' => ['destinationMap' => 'home', 'spawnPoint' => ['x' => 1, 'y' => 4], 'spawnSprite' => ['South']], - ], -]; -PHP); - - return $root; -} - -/** Plans an insertion on the fixture's home map. */ -function planLineInsertion(string $root, string $axis, int $at, int $count): Ichiloto\Editor\Maps\LineInsertionPlan -{ - return LineInsertionPlanner::planProject($root, 'home', $axis, $at, $count); -} - -/** @return list Each hand edit as `file where`. */ -function describeLineInsertionHandEdits(Ichiloto\Editor\Maps\LineInsertionPlan $plan): array -{ - return array_map(static fn(array $edit): string => $edit['file'] . ' ' . $edit['where'], $plan->handEdits); -} - -/** @return list The messages validation reports for the save manifest. */ -function describeSaveCompatibilityIssues(string $root): array -{ - return array_values(array_map(static fn($issue): string => $issue->message, array_filter(validateProject($root), - static fn($issue): bool => str_starts_with($issue->where, 'assets/Data/save-compatibility.php')))); -} - -/** The editor over the fixture with `home` selected and the cursor on the canvas. */ -function lineInsertionEditor(string $root): Editor -{ - $editor = deletionEditor($root); - setEditorProperty($editor, 'focusedPane', 'canvas'); - expect(getEditorProperty($editor, 'workspace')->getMapByIndex(0)->mapId)->toBe('home'); - - return $editor; -} - -function openLineInsertPaletteItem(Editor $editor, string $label): void -{ - $items = callEditorMethod($editor, 'buildPaletteItems'); - $item = array_values(array_filter($items, static fn($item) => $item->label === $label))[0] ?? null; - expect($item)->not->toBeNull(); - ($item->action)(); -} - it('splices blank rows into every grid, keeping styled cells and the rows that only move', function () { - $root = lineInsertionProject(); + $root = mapLineProject(); $plan = planLineInsertion($root, 'y', 2, 2); $plan->apply(); $home = $root . '/assets/Maps/home'; @@ -198,7 +38,7 @@ function openLineInsertPaletteItem(Editor $editor, string $label): void }); it('splices blank columns into every grid, keeping styled runs', function () { - $root = lineInsertionProject(); + $root = mapLineProject(); planLineInsertion($root, 'x', 1, 1)->apply(); $home = $root . '/assets/Maps/home'; @@ -213,7 +53,7 @@ function openLineInsertPaletteItem(Editor $editor, string $label): void }); it('moves points, stretches straddling areas and follows transfers into the map from every file', function () { - $root = lineInsertionProject(); + $root = mapLineProject(); $plan = planLineInsertion($root, 'y', 2, 2); $plan->apply(); $home = require $root . '/assets/Maps/home/home.data.php'; @@ -248,11 +88,11 @@ function openLineInsertPaletteItem(Editor $editor, string $label): void }); it('reports what it cannot rewrite instead of guessing or flattening it', function () { - $root = lineInsertionProject(); + $root = mapLineProject(); $shared = (string) file_get_contents($root . '/assets/Events/shared.php'); $plan = planLineInsertion($root, 'y', 2, 2); - expect(describeLineInsertionHandEdits($plan))->toEqualCanonicalizing([ + expect(describeMapLineHandEdits($plan))->toEqualCanonicalizing([ 'assets/Maps/home/home.data.php npcs.2.y', 'assets/Events/shared.php 0.y', ]) @@ -268,7 +108,7 @@ function openLineInsertPaletteItem(Editor $editor, string $label): void }); it('raises the save content version with a declarative map shift step', function () { - $root = lineInsertionProject(); + $root = mapLineProject(); $plan = planLineInsertion($root, 'y', 2, 2); expect($plan->notes)->toBe(['Saves move with the map: save content version 1 adds this map shift.']); $plan->apply(); @@ -285,7 +125,7 @@ function openLineInsertPaletteItem(Editor $editor, string $label): void }); it('says existing saves are not migrated when the project has no save manifest', function () { - $root = lineInsertionProject(); + $root = mapLineProject(); unlink($root . '/assets/Data/save-compatibility.php'); expect(planLineInsertion($root, 'x', 0, 1)->notes) @@ -293,7 +133,7 @@ function openLineInsertPaletteItem(Editor $editor, string $label): void }); it('moves a cutscene cast and script that start on the map, and a finalizer transfer into it', function () { - $root = lineInsertionProject(); + $root = mapLineProject(); $directory = $root . '/assets/Cutscenes/Cinematics/arrival'; mkdir($directory, 0o777, true); file_put_contents($directory . '/arrival.data.php', "has(Ichiloto\Editor\UI\Modal::LINE_INSERT))->toBeTrue(); + openMapLinePaletteItem($editor, 'Map: Insert rows above the cursor'); + expect(getEditorProperty($editor, 'modals')->has(Ichiloto\Editor\UI\Modal::MAP_LINES))->toBeTrue(); pressKeys($editor, "\177", '2', "\r"); expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue() @@ -357,15 +197,15 @@ function openLineInsertPaletteItem(Editor $editor, string $label): void }); it('cancels from the count prompt and from the confirmation without writing', function () { - $root = lineInsertionProject(); + $root = mapLineProject(); $before = sourceHashTree($root . '/assets'); - $editor = lineInsertionEditor($root); + $editor = mapLineEditor($root); - openLineInsertPaletteItem($editor, 'Map: Insert columns left of the cursor'); + openMapLinePaletteItem($editor, 'Map: Insert columns left of the cursor'); pressKeys($editor, "\033"); - expect(getEditorProperty($editor, 'modals')->has(Ichiloto\Editor\UI\Modal::LINE_INSERT))->toBeFalse(); + expect(getEditorProperty($editor, 'modals')->has(Ichiloto\Editor\UI\Modal::MAP_LINES))->toBeFalse(); - openLineInsertPaletteItem($editor, 'Map: Insert columns left of the cursor'); + openMapLinePaletteItem($editor, 'Map: Insert columns left of the cursor'); pressKeys($editor, "\177", '0', "\r"); expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() ->and(getEditorProperty($editor, 'statusMessage'))->toContain('at least 1'); @@ -375,20 +215,20 @@ function openLineInsertPaletteItem(Editor $editor, string $label): void }); it('refuses to plan while the workspace has unsaved changes', function () { - $root = lineInsertionProject(); + $root = mapLineProject(); $before = sourceHashTree($root . '/assets'); - $editor = lineInsertionEditor($root); + $editor = mapLineEditor($root); getEditorProperty($editor, 'workspace')->getMapByIndex(0)->setTileSymbol(1, 1, 'x'); - openLineInsertPaletteItem($editor, 'Map: Insert rows above the cursor'); + openMapLinePaletteItem($editor, 'Map: Insert rows above the cursor'); - expect(getEditorProperty($editor, 'modals')->has(Ichiloto\Editor\UI\Modal::LINE_INSERT))->toBeFalse() + expect(getEditorProperty($editor, 'modals')->has(Ichiloto\Editor\UI\Modal::MAP_LINES))->toBeFalse() ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Save or undo pending edits') ->and(sourceHashTree($root . '/assets'))->toBe($before); }); it('offers the insert commands only on a map in Map mode', function () { - $editor = lineInsertionEditor(lineInsertionProject()); + $editor = mapLineEditor(mapLineProject()); $labels = static fn(): array => array_map(static fn($item): string => $item->label, callEditorMethod($editor, 'buildPaletteItems')); expect($labels())->toContain('Map: Insert rows above the cursor') @@ -398,15 +238,15 @@ function openLineInsertPaletteItem(Editor $editor, string $label): void }); it('reports coordinates in a list built with a spread instead of rewriting the wrong entry', function () { - $root = lineInsertionProject(); + $root = mapLineProject(); $path = $root . '/assets/Maps/home/home.data.php'; file_put_contents($path, str_replace(" 'npcs' => [\n", " 'npcs' => [\n ...array_map(static fn(int \$y): array => ['id' => \"post-{\$y}\", 'name' => 'Post', 'sprite' => '|', 'x' => 5, 'y' => \$y], [3]),\n", (string) file_get_contents($path))); $source = (string) file_get_contents($path); $plan = planLineInsertion($root, 'y', 2, 1); $plan->apply(); - expect(describeLineInsertionHandEdits($plan))->toContain('assets/Maps/home/home.data.php npcs.0.y') - ->and(describeLineInsertionHandEdits($plan))->toContain('assets/Maps/home/home.data.php npcs.1.y') + expect(describeMapLineHandEdits($plan))->toContain('assets/Maps/home/home.data.php npcs.0.y') + ->and(describeMapLineHandEdits($plan))->toContain('assets/Maps/home/home.data.php npcs.1.y') ->and(array_column(array_filter($plan->handEdits, static fn(array $edit): bool => $edit['where'] === 'npcs.1.y'), 'reason')) ->toBe(['it is inside an array built with a spread or a computed key']) // Everything else in the file still moves. @@ -416,7 +256,7 @@ function openLineInsertPaletteItem(Editor $editor, string $label): void }); it('validates declarative map shift steps as the Engine reads them', function () { - $root = lineInsertionProject(); + $root = mapLineProject(); file_put_contents($root . '/assets/Data/save-compatibility.php', " 2, 'migrations' => [ @@ -433,7 +273,7 @@ function openLineInsertPaletteItem(Editor $editor, string $label): void }); it('validates declarative relocation steps as the Engine reads them', function () { - $root = lineInsertionProject(); + $root = mapLineProject(); $write = static fn(array $migrations, int $version) => file_put_contents($root . '/assets/Data/save-compatibility.php', " $version, 'migrations' => $migrations, diff --git a/tests/Unit/MapLineRemovalTest.php b/tests/Unit/MapLineRemovalTest.php new file mode 100644 index 0000000..14d11f1 --- /dev/null +++ b/tests/Unit/MapLineRemovalTest.php @@ -0,0 +1,218 @@ +apply(); + $home = $root . '/assets/Maps/home'; + + expect(MapGridSource::readFile($home . '/layers/01.terrain.map.php'))->toBe("######\n#....#\n#....#\n#..###") + ->and(MapGridSource::readFile($home . '/layers/02.detail.deco.php'))->toBe(" \n d \n d \n ") + ->and(MapGridSource::readFile($home . '/home.event.php'))->toBe(" \n W \n D S \n ") + ->and(file_get_contents($home . '/layers/01.terrain.map.php'))->toContain("// keep layers/01.terrain.map.php\n") + ->and(TileLayerSource::readLayer((string) file_get_contents($home . '/graphics/01.floor.tiles.php'), 'floor')->getEntries()) + ->toBe(array_fill(0, 4, array_fill(0, 6, '2816'))) + ->and(file_get_contents($home . '/graphics/01.floor.tiles.php'))->toContain('// Painted in the GUI editor.'); + + $map = ProjectMap::fromDirectory($root . '/assets/Maps', $home); + expect($map->getHeight())->toBe(4) + ->and($map->getEventBounds('D'))->toMatchArray(['x' => 1, 'y' => 2]); +}); + +it('moves what is beyond the removed lines back, shrinks straddling areas and follows transfers into the map', function () { + $root = mapLineRemovalProject(); + $plan = planLineRemoval($root, 'y', 2, 1); + $plan->apply(); + $home = require $root . '/assets/Maps/home/home.data.php'; + $town = require $root . '/assets/Maps/town/town.data.php'; + + expect($home['npcs'][0])->toMatchArray(['x' => 2, 'y' => 2]) + ->and($home['npcs'][0]['wanderArea'])->toBe(['x' => 1, 'y' => 1, 'width' => 3, 'height' => 2]) + ->and($home['npcs'][1])->toMatchArray(['x' => 4, 'y' => 0]) + ->and($home['npcs'][1]['script'][0])->toBe(['type' => 'move_player', 'x' => 1, 'y' => 3]) + // After the transfer the script is on town, which keeps its rows. + ->and($home['npcs'][1]['script'][2])->toBe(['type' => 'move_player', 'x' => 3, 'y' => 3]) + ->and($home['events']['D']['data']['spawnPoint'])->toBe(['x' => 1, 'y' => 3]) + ->and($town['events']['A']['data']['spawnPoint'])->toBe(['x' => 2, 'y' => 3]) + ->and($town['events']['B']['data']['spawnPoint'])->toBe(['x' => 2, 'y' => 4]) + ->and($town['npcs'][0]['script'])->toBe([ + ['type' => 'transfer', 'map' => 'home', 'x' => 1, 'y' => 2], + ['type' => 'move_player', 'x' => 2, 'y' => 3], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 5], ['y' => 3]]], + ]) + ->and((require $root . '/assets/Data/system.php')['startingPositions']['player']['spawnPoint'])->toBe(['x' => 1, 'y' => 3]) + ->and((require $root . '/assets/Events/home-only.php')[0])->toBe(['type' => 'move_player', 'x' => 3, 'y' => 3]) + ->and(describeMapLineHandEdits($plan))->toEqualCanonicalizing([ + 'assets/Maps/home/home.data.php npcs.2.y', + 'assets/Events/shared.php 0.y', + ]); +}); + +it('refuses to remove a line an event marker stands on, writing nothing', function () { + $root = mapLineRemovalProject(); + $before = sourceHashTree($root . '/assets'); + + expect(fn() => planLineRemoval($root, 'y', 3, 1)) + ->toThrow(MapSourceRefusal::class, 'Move these off the removed rows first; nothing was changed: event D at x 1, y 3; event S at x 4, y 3.') + ->and(sourceHashTree($root . '/assets'))->toBe($before); +}); + +it('refuses to remove lines that NPCs, spawn points, starting positions or scripts stand on, naming each one', function () { + $root = mapLineRemovalProject(); + $before = sourceHashTree($root . '/assets'); + + try { + planLineRemoval($root, 'y', 4, 1); + $this->fail('The removal should be refused.'); + } catch (RuntimeException $refusal) { + expect($refusal->getMessage())->toStartWith('Remove 1 row from row 5 of home is refused; nothing was changed.') + ->and($refusal->getMessage())->toContain('assets/Maps/home/home.data.php events.D.data.spawnPoint.y (it stands on a removed line)') + ->and($refusal->getMessage())->toContain('assets/Maps/town/town.data.php events.A.data.spawnPoint.y') + ->and($refusal->getMessage())->toContain('assets/Data/system.php startingPositions.player.spawnPoint.y') + ->and($refusal->getMessage())->toContain('assets/Events/home-only.php 0.y'); + } + expect(fn() => planLineRemoval($root, 'x', 2, 1))->toThrow(RuntimeException::class, 'assets/Maps/home/home.data.php npcs.0.x (it stands on a removed line)') + ->and(sourceHashTree($root . '/assets'))->toBe($before); +}); + +it('moves spans beyond removed lines back, shrinks straddling ones and loses only spans wholly removed', function () { + $removal = new Ichiloto\Editor\Maps\LineRemoval('home', 'y', 2, 2); + + expect($removal->getShiftedSpan(0, 2))->toBe([0, 2]) + ->and($removal->getShiftedSpan(1, 2))->toBe([1, 1]) + ->and($removal->getShiftedSpan(1, 5))->toBe([1, 3]) + ->and($removal->getShiftedSpan(3, 3))->toBe([2, 2]) + ->and($removal->getShiftedSpan(4, 1))->toBe([2, 1]) + ->and($removal->getShiftedSpan(2, 2))->toBeNull() + ->and($removal->getShiftedCoordinate(3))->toBeNull() + ->and($removal->getShiftedCoordinate(5))->toBe(3) + ->and($removal->getShiftedCoordinate(1))->toBe(1); +}); + +it('refuses lines outside the map and a removal that would leave it empty', function () { + $root = mapLineRemovalProject(); + + expect(fn() => planLineRemoval($root, 'y', 4, 2))->toThrow(MapSourceRefusal::class, 'home has no rows 5 to 6 to remove') + ->and(fn() => planLineRemoval($root, 'x', 0, 6))->toThrow(MapSourceRefusal::class, 'would leave home empty'); +}); + +it('refuses a removal through a world object, which is drawn whole, and moves one beyond it back', function () { + $inventory = new Ichiloto\Editor\Maps\MapLineInventory(new Ichiloto\Editor\Maps\LineRemoval('home', 'y', 2, 1), []); + $inventory->addMapData('home.data.php', 'home', ['worldObjects' => [ + ['id' => 'shelf', 'resource' => 'shelf', 'anchor' => ['x' => 3, 'y' => 1], 'covers' => ['layer' => 'terrain', 'cells' => [[3, 1], [3, 2]]]], + ['id' => 'stool', 'resource' => 'stool', 'anchor' => ['x' => 1, 'y' => 3], 'covers' => ['layer' => 'terrain', 'cells' => [[1, 3]]]], + ]]); + + expect($inventory->getBlocked()['home.data.php'])->toBe([ + ['path' => ['worldObjects', 0, 'covers', 'cells', 1, 1], 'reason' => 'world object shelf covers a cell on a removed line'], + ]) + ->and($inventory->getShifts()['home.data.php'])->toBe([ + ['path' => ['worldObjects', 1, 'anchor', 'y'], 'value' => 2], + ['path' => ['worldObjects', 1, 'covers', 'cells', 0, 1], 'value' => 2], + ]); +}); + +it('lands saves standing on removed lines on the reachable cell nearest them, in the map after the removal', function () { + $root = mapLineRemovalProject(); + $plan = planLineRemoval($root, 'y', 2, 1); + + expect($plan->edit->landing)->toBe([2, 1]) + ->and($plan->notes)->toBe([ + 'Saves standing on the removed lines land at x 2, y 1.', + 'Saves move with the map: save content version 1 adds this map removal.', + ]); + $plan->apply(); + + expect(require $root . '/assets/Data/save-compatibility.php')->toBe([ + 'contentVersion' => 1, + 'migrations' => [['from' => 0, 'to' => 1, 'mapRemovals' => [['map' => 'home', 'axis' => 'y', 'at' => 2, 'count' => 1, 'to' => [2, 1]]]]], + 'aliases' => [], + 'tombstones' => [], + ]) + ->and(Ichiloto\Engine\IO\SaveCompatibility\SaveCompatibilityManifest::fromProjectRoot($root)->createMigrationFrom(0)) + ->toBeInstanceOf(Ichiloto\Engine\IO\SaveCompatibility\DeclaredPositionContentMigration::class) + ->and(describeSaveCompatibilityIssues($root))->toBe([]); +}); + +it('lands saves on the author\'s cell when it is reachable, and refuses one that is not', function () { + $root = mapLineRemovalProject(); + + expect(planLineRemoval($root, 'y', 2, 1, [3, 2])->edit->landing)->toBe([3, 2]) + ->and(fn() => planLineRemoval($root, 'y', 2, 1, [0, 1])) + ->toThrow(RuntimeException::class, 'Saves cannot land at x 0, y 1: the player cannot reach that cell.'); +}); + +it('says existing saves are not migrated, and needs no landing, when the project has no save manifest', function () { + $root = mapLineRemovalProject(); + unlink($root . '/assets/Data/save-compatibility.php'); + unlink($root . '/assets/Maps/collisions.php'); + $plan = planLineRemoval($root, 'y', 2, 1); + + expect($plan->notes)->toBe(['Existing saves are not migrated: the project has no assets/Data/save-compatibility.php.']) + ->and($plan->edit->landing)->toBeNull(); +}); + +it('reports relative route steps that may cross the edited lines for a hand check', function () { + $root = mapLineRemovalProject(); + $path = $root . '/assets/Events/home-only.php'; + file_put_contents($path, str_replace("return [\n", "return [\n ['type' => 'move_route', 'subject' => 'player', 'steps' => [['direction' => 'down', 'count' => 2]]],\n", + (string) file_get_contents($path))); + + expect(array_column(planLineRemoval($root, 'y', 2, 1)->handEdits, 'reason', 'where')) + ->toMatchArray(['0.steps' => 'its relative steps may cross the removed lines; check where the route ends']) + ->and(array_column(MapLinePlanner::planInsertion($root, 'home', 'y', 2, 1)->handEdits, 'reason', 'where')) + ->toMatchArray(['0.steps' => 'its relative steps may cross the inserted lines; check where the route ends']); +}); + +it('removes through the palette after a count and a confirmation, and one undo restores every file', function () { + $root = mapLineRemovalProject(); + $before = sourceHashTree($root . '/assets'); + $editor = mapLineEditor($root); + setEditorProperty($editor, 'cursorY', 2); + + openMapLinePaletteItem($editor, 'Map: Remove rows from the cursor down'); + expect(getEditorProperty($editor, 'modals')->has(Ichiloto\Editor\UI\Modal::MAP_LINES))->toBeTrue(); + pressKeys($editor, "\r"); + + $labels = array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label'); + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Remove 1 row from row 3 of home') + ->and($labels)->toContain('Saves standing on the removed lines land at x 2, y 1.') + ->and($labels)->toContain('Hand edit: assets/Events/shared.php 0.y') + ->and(sourceHashTree($root . '/assets'))->toBe($before); + + pressKeys($editor, "\033[B", "\r"); + expect(getEditorProperty($editor, 'workspace')->getMapByIndex(0)->getHeight())->toBe(4) + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Removed 1 row from row 3 of home.'); + + pressKeys($editor, "\x1a"); + expect(sourceHashTree($root . '/assets'))->toBe($before) + ->and(getEditorProperty($editor, 'workspace')->getMapByIndex(0)->getHeight())->toBe(5); +}); + +it('reports a refused removal in the status line without opening a confirmation', function () { + $root = mapLineRemovalProject(); + $before = sourceHashTree($root . '/assets'); + $editor = mapLineEditor($root); + setEditorProperty($editor, 'cursorY', 4); + + openMapLinePaletteItem($editor, 'Map: Remove rows from the cursor down'); + pressKeys($editor, "\r"); + + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('is refused; nothing was changed.') + ->and(sourceHashTree($root . '/assets'))->toBe($before); +}); diff --git a/tests/Unit/MapPhysicalOccupancyGeometryTest.php b/tests/Unit/MapPhysicalOccupancyGeometryTest.php index 15e71b7..3f1062a 100644 --- a/tests/Unit/MapPhysicalOccupancyGeometryTest.php +++ b/tests/Unit/MapPhysicalOccupancyGeometryTest.php @@ -5,7 +5,7 @@ use Ichiloto\Editor\Database\PhpValueExporter; use Ichiloto\Editor\History\CommandHistory; use Ichiloto\Editor\History\GenericCommand; -use Ichiloto\Editor\Maps\LineInsertionPlanner; +use Ichiloto\Editor\Maps\MapLinePlanner; use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\Session\EditorSession; @@ -330,7 +330,7 @@ function loadOccupancyGeometryMap(string $root): ProjectMap } else { array_splice($expected, 1, 0, array_fill(0, 2, array_fill(0, 4, CollisionType::SOLID->value))); } - $plan = LineInsertionPlanner::planProject($root, 'test-map', $axis, 1, 2); + $plan = MapLinePlanner::planInsertion($root, 'test-map', $axis, 1, 2); expect(sourceHashTree($root))->toBe($before)->and($plan->getChangedPaths())->toContain($path); $plan->apply(); $after = sourceHashTree($root); @@ -355,7 +355,7 @@ function loadOccupancyGeometryMap(string $root): ProjectMap writeGeometryOccupancyData($map->directory, $malformed ? null : $rows, expression: $malformed ? null : 'array_values(' . PhpValueExporter::export($rows, 1) . ')'); $before = sourceHashTree($root); - expect(fn() => LineInsertionPlanner::planProject($root, 'test-map', 'y', 1, 1))->toThrow(MapSourceRefusal::class) + expect(fn() => MapLinePlanner::planInsertion($root, 'test-map', 'y', 1, 1))->toThrow(MapSourceRefusal::class) ->and(sourceHashTree($root))->toBe($before); })->with([false, true]); @@ -365,7 +365,7 @@ function loadOccupancyGeometryMap(string $root): ProjectMap file_put_contents($path, str_replace("'events' => [],", "'events' => [], 'futureObject' => new \\DateTimeImmutable('2001-02-03'),", (string) file_get_contents($path))); $before = sourceHashTree($root); - $plan = LineInsertionPlanner::planProject($root, 'test-map', 'x', 1, 1); + $plan = MapLinePlanner::planInsertion($root, 'test-map', 'x', 1, 1); $plan->apply(); $map = loadOccupancyGeometryMap($root); expect($map->hasMapDataField(['occupancy']))->toBe($declared) diff --git a/tests/Unit/Session/MapLineRemovalSessionTest.php b/tests/Unit/Session/MapLineRemovalSessionTest.php new file mode 100644 index 0000000..17e0a8f --- /dev/null +++ b/tests/Unit/Session/MapLineRemovalSessionTest.php @@ -0,0 +1,56 @@ +readMap('home')['revision']; + $before = sourceHashTree($root); + + $question = $session->removeMapLines('home', $revision, 'y', 2, 1); + expect($question)->toMatchArray(['status' => 'question', 'map' => 'home', 'axis' => 'y', 'at' => 2, 'count' => 1, + 'landing' => [2, 1], 'width' => 6, 'height' => 4]) + ->and($question['question'])->toBe('Remove 1 row from row 3 of home?') + ->and($question['notes'])->toContain('Saves standing on the removed lines land at x 2, y 1.') + ->and($question['paths'])->toContain('assets/Maps/home/layers/01.terrain.map.php', 'assets/Data/save-compatibility.php') + ->and(sourceHashTree($root))->toBe($before); + + $written = $session->removeMapLines('home', $revision, 'y', 2, 1, $question['landing'], 'write', $question['confirm']); + expect($written)->toMatchArray(['status' => 'removed', 'map' => 'home', 'changed' => true]) + ->and($session->readMap('home')['height'])->toBe(4); + + $session->undo(); + expect(sourceHashTree($root))->toBe($before) + ->and($session->readMap('home')['height'])->toBe(5); +}); + +it('refuses a removal that something authored stands on, and one the review did not cover', function () { + $root = mapLineRemovalProject(); + $session = EditorSession::open($root); + $revision = $session->readMap('home')['revision']; + $before = sourceHashTree($root); + + expect(fn() => $session->removeMapLines('home', $revision, 'y', 4, 1))->toThrow(SessionRefusal::class, 'is refused; nothing was changed.'); + $question = $session->removeMapLines('home', $revision, 'y', 2, 1); + expect(fn() => $session->removeMapLines('home', $revision, 'y', 2, 1, [3, 2], 'write', $question['confirm']))->toThrow(SessionRefusal::class) + ->and(sourceHashTree($root))->toBe($before); +}); + +it('routes map.removeLines through the host with an optional landing', function () { + $root = mapLineRemovalProject(); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w+'), fopen('php://memory', 'w+')); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result'); + $revision = $request('map.read', ['map' => 'home'])['result']['revision']; + + expect($request('map.removeLines', ['map' => 'home', 'revision' => $revision, 'axis' => 'y', 'at' => 2, 'count' => 1, 'landing' => [3, 2]])['result']) + ->toMatchArray(['status' => 'question', 'landing' => [3, 2]]); +}); diff --git a/tests/Unit/Session/WorldObjectAuthoringTest.php b/tests/Unit/Session/WorldObjectAuthoringTest.php index 6042f21..137b369 100644 --- a/tests/Unit/Session/WorldObjectAuthoringTest.php +++ b/tests/Unit/Session/WorldObjectAuthoringTest.php @@ -291,7 +291,7 @@ function applyWorldObjectRow(EditorSession $session, string $field, string $valu ->and($map->getWidth())->toBe(4); $data = $map->getMapDataField([]); $data['worldObjects'][0]['covers'] = ['layer'=>'terrain','cells'=>[[1,1],[3,1]],'tileLayers'=>['floor']]; - $inventory = new Ichiloto\Editor\Maps\LineInsertionInventory(new Ichiloto\Editor\Maps\LineInsertion('test-map','x',1,2), []); + $inventory = new Ichiloto\Editor\Maps\MapLineInventory(new Ichiloto\Editor\Maps\LineInsertion('test-map','x',1,2), []); $inventory->addMapData('fixture.data.php','test-map',$data); $shifts = $inventory->getShifts()['fixture.data.php']; expect(array_column($shifts,'path'))->toContain(['worldObjects',0,'anchor','x'], ['worldObjects',0,'covers','cells',0,0], ['worldObjects',0,'covers','cells',1,0]) From af69918ffc539086786ed1de5c45f6c9ec5c0adf Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 16:41:11 +0200 Subject: [PATCH 260/344] feat(maps): draw and erase forests of whole trees with the tree brush A tree type is an image field resource with an occupancy stamp. Drawing over a region plants the chosen types in an order fixed by map and cell, so redrawing plants nothing new and drawing beside trees fills between them. Trunks go only on passable ground no event, NPC or trunk takes, inside the region, and never where they would cut a walkable area in two; each trunk is made solid in the map's own collision, so a map without it is asked to separate collision first. Erasing removes the region's trees and frees their trunk cells. The terminal sees the forest on a brush-owned canopy decoration layer. Drawings past the 128 world-object limit are refused with the room left. One undo step each, written on Save; served as trees.types and trees.draw. --- docs/manual.md | 20 ++ src/Maps/TreeBrush.php | 493 +++++++++++++++++++++++++++ src/Session/EditorSession.php | 40 +++ src/Session/SessionHost.php | 4 + tests/Pest.php | 1 + tests/Support/TreeBrushFixtures.php | 73 ++++ tests/Unit/Session/TreeBrushTest.php | 168 +++++++++ 7 files changed, 799 insertions(+) create mode 100644 src/Maps/TreeBrush.php create mode 100644 tests/Support/TreeBrushFixtures.php create mode 100644 tests/Unit/Session/TreeBrushTest.php diff --git a/docs/manual.md b/docs/manual.md index 6ed347e..009aad3 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1193,6 +1193,26 @@ editor's Game view draws a placed sign's board and label as the game does, laid out by the window in its text role; until the label is laid out, the sign's cells show their glyphs. +Forests are drawn with the tree brush. A tree type is any image resource with +an occupancy stamp, its trunk. Choose the types to vary between and drag over a +region: the brush plants whole trees across it, choosing type and spot in an +order fixed by the map and cell, so drawing the same region again plants +nothing new and drawing beside existing trees fills the gaps between them. +Crowns may overlap; trunks keep half a crown apart. A trunk goes only on +passable ground that no event, NPC or other trunk takes, never outside the +region, and never where making it solid would cut a walkable area in two, so +every cell the player could reach stays reachable. Each trunk becomes solid in +the map's own collision, never through its image, so the brush asks you to +Separate collision first on a map that does not have it yet. Erasing removes +the trees standing in the region and makes their trunk cells walkable again, +unless another tree still stands there. The terminal sees the same forest on a +`canopy` decoration layer the brush owns and redraws: bright green `(` `)` +crowns, as wide as the image above the trunk and narrowing upwards, over gray +`|` trunks; the Game view leaves decoration layers out, so they never hide the +images. A drawing that would take the map past its 128 world objects is +refused with the number that still fit. Each drawing or erasing is one undo +step and is written on Save. + Why a category can still turn out read-only: a file the editor cannot evaluate, a value it could not write back out (such as an object only its own factory may build, which a rewritten file could not call), or a comment inside returned diff --git a/src/Maps/TreeBrush.php b/src/Maps/TreeBrush.php new file mode 100644 index 0000000..2885e87 --- /dev/null +++ b/src/Maps/TreeBrush.php @@ -0,0 +1,493 @@ +'; + private const string TRUNK_STYLE = ''; + + /** @var array Tree types by id. */ + private array $types; + + private function __construct(private readonly ProjectMap $map, FieldPresentationCatalog $catalog) + { + $this->types = array_filter($catalog->resources, self::isTree(...)); + } + + /** + * The project's tree types: placeable images with a ground footprint. + * + * @return array Names by id. + */ + public static function listTypes(ProjectMap $map): array + { + return array_map(static fn(WorldObjectResource $type): string => $type->name, + array_filter(self::loadCatalog($map)->resources, self::isTree(...))); + } + + /** + * Plants the chosen tree types over the region's cells. + * + * @param list $types Tree type ids to vary between. + * @param list $region + * @return array{command: ?Command, planted: int, removed: int} + * @throws MapSourceRefusal When the map cannot take trees or a type is unknown; nothing is changed. + */ + public static function draw(ProjectMap $map, array $types, array $region): array + { + $brush = new self($map, self::loadCatalog($map)); + $chosen = $brush->requireTypes($types); + + return $brush->apply('Draw trees', static fn(array &$entries, array &$solid) => $brush->plant($chosen, $region, $entries, $solid)); + } + + /** + * Removes every tree standing in the region, clearing its trunk's collision. + * + * @param list $region + * @return array{command: ?Command, planted: int, removed: int} + * @throws MapSourceRefusal When the map cannot take trees; nothing is changed. + */ + public static function erase(ProjectMap $map, array $region): array + { + $brush = new self($map, self::loadCatalog($map)); + + return $brush->apply('Erase trees', static fn(array &$entries, array &$solid) => $brush->fell($region, $entries, $solid)); + } + + private static function isTree(WorldObjectResource $resource): bool + { + return $resource->sprites instanceof GraphicalSpriteDefinition && $resource->sign === null && $resource->occupancyStamp !== []; + } + + /** The image's width in map cells. */ + private static function getCellsWide(WorldObjectResource $type): int + { + return max(1, intdiv($type->sprites->width, FieldViewport::TILE_SIZE)); + } + + /** The image's height in map cells. */ + private static function getCellsTall(WorldObjectResource $type): int + { + return max(1, intdiv($type->sprites->height, FieldViewport::TILE_SIZE)); + } + + private static function loadCatalog(ProjectMap $map): FieldPresentationCatalog + { + try { + return FieldPresentationCatalog::load($map->getAssetRoot()); + } catch (Throwable $error) { + throw new MapSourceRefusal('The field resources cannot be read, so no tree can be drawn: ' . $error->getMessage(), previous: $error); + } + } + + /** @return list */ + private function requireTypes(array $ids): array + { + if ($ids === []) { + throw new MapSourceRefusal('Choose at least one tree type to draw. Nothing was changed.'); + } + return array_map(fn(string $id): WorldObjectResource => $this->types[$id] + ?? throw new MapSourceRefusal("'{$id}' is not a tree type: a tree is a field resource image with a trunk stamp. Nothing was changed."), + array_values(array_unique($ids))); + } + + /** + * Runs one change over the map's world objects and solid trunk cells, then + * redraws the terminal canopy, recording everything as one step. + * + * @param callable(array, array): array{0: int, 1: int} $change Mutates the entries and solid writes; returns planted and removed counts. + * @return array{command: ?Command, planted: int, removed: int} + */ + private function apply(string $label, callable $change): array + { + $map = $this->map; + $map->assertSourcesUnchanged(); + if (! $map->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + throw new MapSourceRefusal('Trees need the map\'s own collision so their trunks can be solid. Separate collision first, then draw. Nothing was changed.'); + } + $entries = $map->hasMapDataField([WorldObjectDefinition::DATA_KEY]) ? $map->getMapDataField([WorldObjectDefinition::DATA_KEY]) : []; + $writes = []; + [$planted, $removed] = $change($entries, $writes); + if ($planted === 0 && $removed === 0) { + return ['command' => null, 'planted' => 0, 'removed' => 0]; + } + if (count($entries) > WorldObjectDefinition::MAX_OBJECTS) { + $room = WorldObjectDefinition::MAX_OBJECTS - (count($entries) - $planted); + throw new MapSourceRefusal(sprintf('A map holds at most %d world objects; this drawing needs %d more and only %d still fit. Draw a smaller region. Nothing was changed.', + WorldObjectDefinition::MAX_OBJECTS, $planted, max(0, $room))); + } + + try { + WorldObjectAuthoring::validateEntries($map, $entries, false); + } catch (InvalidArgumentException $invalid) { + throw new MapSourceRefusal($invalid->getMessage() . ' Nothing was changed.', previous: $invalid); + } + + $before = $map->captureLayerSnapshot(); + try { + if (! in_array(self::CANOPY_LAYER, array_column($map->getLayers(), 'name'), true)) { + $map->createLayer(self::CANOPY_LAYER, true); + } + $map->setMapDataField([WorldObjectDefinition::DATA_KEY], $entries); + $map->writePhysicalOccupancy($writes); + $this->drawCanopy($entries); + } catch (Throwable $error) { + $map->restoreLayerSnapshot($before); + throw $error instanceof MapSourceRefusal ? $error : new MapSourceRefusal($error->getMessage() . ' Nothing was changed.', previous: $error); + } + $after = $map->captureLayerSnapshot(); + + return ['command' => new GenericCommand($label, + static fn() => $map->restoreLayerSnapshot($after), + static fn() => $map->restoreLayerSnapshot($before)), 'planted' => $planted, 'removed' => $removed]; + } + + /** + * @param list $types + * @param list $region + * @return array{0: int, 1: int} + */ + private function plant(array $types, array $region, array &$entries, array &$writes): array + { + $inRegion = []; + foreach ($region as [$x, $y]) { + $inRegion[$y][$x] = true; + } + $passable = $this->readPassable(); + $claimed = $this->readClaimedCells($entries); + $anchors = $this->readTreeAnchors($entries); + $planted = 0; + foreach ($this->orderCells($region) as [$x, $y]) { + $type = $types[$this->hash($x, $y, 'type') % count($types)]; + if (! $this->canPlant($type, $x, $y, $inRegion, $passable, $claimed, $anchors)) { + continue; + } + foreach ($type->occupancyStamp as $cell) { + [$cx, $cy] = [$x + $cell['x'], $y + $cell['y']]; + $writes[] = [$cx, $cy, $cell['type']]; + $claimed[$cy][$cx] = true; + if ($cell['type'] !== CollisionType::NONE) { + unset($passable[$cy][$cx]); + } + } + $anchors[] = [$x, $y, $type]; + $entries[] = ['id' => $this->findUnusedId($entries, $type->id), 'resource' => $type->id, 'anchor' => ['x' => $x, 'y' => $y]]; + $planted++; + } + + return [$planted, 0]; + } + + /** + * @param list $region + * @return array{0: int, 1: int} + */ + private function fell(array $region, array &$entries, array &$writes): array + { + $inRegion = []; + foreach ($region as [$x, $y]) { + $inRegion[$y][$x] = true; + } + $kept = $felled = []; + foreach ($entries as $entry) { + $type = $this->types[$entry['resource'] ?? ''] ?? null; + $at = $entry['anchor'] ?? []; + if ($type !== null && isset($inRegion[$at['y'] ?? -1][$at['x'] ?? -1])) { + $felled[] = [$at['x'], $at['y'], $type]; + } else { + $kept[] = $entry; + } + } + if ($felled === []) { + return [0, 0]; + } + // A trunk cell another standing tree still stamps keeps its collision. + $stillStamped = []; + foreach ($this->readTreeAnchors($kept) as [$x, $y, $type]) { + foreach ($type->occupancyStamp as $cell) { + $stillStamped[$y + $cell['y']][$x + $cell['x']] = true; + } + } + foreach ($felled as [$x, $y, $type]) { + foreach ($type->occupancyStamp as $cell) { + [$cx, $cy] = [$x + $cell['x'], $y + $cell['y']]; + if (! isset($stillStamped[$cy][$cx])) { + $writes[] = [$cx, $cy, CollisionType::NONE]; + } + } + } + $entries = $kept; + + return [0, count($felled)]; + } + + /** + * Whether a tree fits with its anchor here: every stamped cell inside the + * region and on passable ground nothing claims, room from other trunks, + * and no solid cell cutting a walkable area in two. + * + * @param array> $inRegion + * @param array> $passable + * @param array> $claimed + * @param list $anchors + */ + private function canPlant(WorldObjectResource $type, int $x, int $y, array $inRegion, array $passable, array $claimed, array $anchors): bool + { + foreach ($type->occupancyStamp as $cell) { + [$cx, $cy] = [$x + $cell['x'], $y + $cell['y']]; + if (! isset($inRegion[$cy][$cx], $passable[$cy][$cx]) || isset($claimed[$cy][$cx])) { + return false; + } + } + foreach ($anchors as [$ax, $ay, $other]) { + // Crowns may overlap, as in a forest; trunks keep half a crown apart. + $reachX = intdiv(max(self::getCellsWide($type), self::getCellsWide($other)), 2); + $reachY = max(1, intdiv(max(self::getCellsTall($type), self::getCellsTall($other)), 3)); + if (abs($ax - $x) <= $reachX && abs($ay - $y) <= $reachY) { + return false; + } + } + $walkable = $passable; + foreach ($type->occupancyStamp as $cell) { + if ($cell['type'] === CollisionType::NONE) { + continue; + } + [$cx, $cy] = [$x + $cell['x'], $y + $cell['y']]; + if (! self::isSimple($walkable, $cx, $cy)) { + return false; + } + unset($walkable[$cy][$cx]); + } + + return true; + } + + /** + * Whether making a walkable cell solid keeps the walkable cells around it + * connected: its walkable side neighbours must still reach each other + * through the eight cells around it. Then any path through it can step + * around it, so no walkable area is cut in two. + * + * @param array> $walkable + */ + private static function isSimple(array $walkable, int $x, int $y): bool + { + $open = []; + for ($dy = -1; $dy <= 1; $dy++) { + for ($dx = -1; $dx <= 1; $dx++) { + if (($dx !== 0 || $dy !== 0) && isset($walkable[$y + $dy][$x + $dx])) { + $open["{$dx},{$dy}"] = [$dx, $dy]; + } + } + } + $sides = array_values(array_filter([[0, -1], [1, 0], [0, 1], [-1, 0]], static fn(array $side): bool => isset($open["{$side[0]},{$side[1]}"]))); + if (count($sides) < 2) { + return true; + } + $seen = ["{$sides[0][0]},{$sides[0][1]}" => true]; + $queue = [$sides[0]]; + while ($queue !== []) { + [$cx, $cy] = array_shift($queue); + foreach ([[0, -1], [1, 0], [0, 1], [-1, 0]] as [$sx, $sy]) { + $key = ($cx + $sx) . ',' . ($cy + $sy); + if (isset($open[$key]) && ! isset($seen[$key])) { + $seen[$key] = true; + $queue[] = $open[$key]; + } + } + } + foreach ($sides as [$sx, $sy]) { + if (! isset($seen["{$sx},{$sy}"])) { + return false; + } + } + + return true; + } + + /** @return array> Cells the player can stand on today. */ + private function readPassable(): array + { + $passable = []; + foreach ($this->map->getResolvedCollisionMap() as $y => $row) { + foreach ($row as $x => $value) { + if ($value === CollisionType::NONE->value) { + $passable[$y][$x] = true; + } + } + } + + return $passable; + } + + /** + * Cells no trunk may take: events, NPCs and every standing tree's stamp. + * + * @return array> + */ + private function readClaimedCells(array $entries): array + { + $claimed = []; + for ($y = 0, $height = $this->map->getHeight(); $y < $height; $y++) { + for ($x = 0, $width = $this->map->getWidth(); $x < $width; $x++) { + if ($this->map->hasLayerCell(MapLayers::EVENT, $x, $y) && $this->map->getEventMarkerAt($x, $y) !== null) { + $claimed[$y][$x] = true; + } + } + } + foreach ((array) ($this->map->getEditableData()['npcs'] ?? []) as $npc) { + if (is_int($npc['x'] ?? null) && is_int($npc['y'] ?? null)) { + $claimed[$npc['y']][$npc['x']] = true; + } + } + foreach ($this->readTreeAnchors($entries) as [$x, $y, $type]) { + foreach ($type->occupancyStamp as $cell) { + $claimed[$y + $cell['y']][$x + $cell['x']] = true; + } + } + + return $claimed; + } + + /** @return list Every standing tree: its anchor and type. */ + private function readTreeAnchors(array $entries): array + { + $anchors = []; + foreach ($entries as $entry) { + $type = $this->types[$entry['resource'] ?? ''] ?? null; + if ($type !== null && is_int($entry['anchor']['x'] ?? null) && is_int($entry['anchor']['y'] ?? null)) { + $anchors[] = [$entry['anchor']['x'], $entry['anchor']['y'], $type]; + } + } + + return $anchors; + } + + /** + * The region's cells in the order trees are tried: fixed by the map and + * cell, so the same region always grows the same forest. + * + * @param list $region + * @return list + */ + private function orderCells(array $region): array + { + $cells = array_values(array_unique(array_map(static fn(array $cell): string => $cell[0] . ',' . $cell[1], $region))); + $cells = array_map(static fn(string $key): array => array_map('intval', explode(',', $key)), $cells); + usort($cells, fn(array $a, array $b): int => [$this->hash($a[0], $a[1], 'order'), $a[1], $a[0]] <=> [$this->hash($b[0], $b[1], 'order'), $b[1], $b[0]]); + + return $cells; + } + + private function hash(int $x, int $y, string $purpose): int + { + return hexdec(substr(hash('xxh32', "{$this->map->mapId}:{$x}:{$y}:{$purpose}"), 0, 7)); + } + + private function findUnusedId(array $entries, string $stem): string + { + $ids = array_flip(array_column($entries, 'id')); + for ($number = 1; isset($ids[$stem . '-' . $number]); $number++) { + } + + return $stem . '-' . $number; + } + + /** + * Redraws the canopy layer from the standing trees: each crown spans the + * image's width in the row above its trunk and narrows evenly to one or two + * cells at the top of the image, and its trunk cells read `|`. + */ + private function drawCanopy(array $entries): void + { + $glyphs = []; + foreach ($this->readTreeAnchors($entries) as [$x, $y, $type]) { + $width = self::getCellsWide($type); + $left = $x - (int) round($type->pivot->x * $width - 0.5); + $rows = self::getCellsTall($type) - 1; + $narrowest = intdiv($width + 1, 2) - 1; + for ($row = 1; $row <= $rows; $row++) { + $inset = intdiv(($row - 1) * $narrowest, max(1, $rows - 1)); + $span = $width - 2 * $inset; + for ($i = 0; $i < $span; $i++) { + $glyphs[$y - $row][$left + $inset + $i] ??= $i < intdiv($span, 2) ? '(' : ')'; + } + } + } + foreach ($this->readTreeAnchors($entries) as [$x, $y, $type]) { + foreach ($type->occupancyStamp as $cell) { + if ($cell['type'] !== CollisionType::NONE) { + $glyphs[$y + $cell['y']][$x + $cell['x']] = '|'; + } + } + } + $layer = $this->findCanopyLayerId(); + for ($y = 0, $height = $this->map->getHeight(); $y < $height; $y++) { + for ($x = 0, $width = $this->map->getWidth(); $x < $width; $x++) { + if (! $this->map->hasLayerCell($layer, $x, $y)) { + continue; + } + $glyph = $glyphs[$y][$x] ?? ' '; + [$prefix, $suffix] = match ($glyph) { + ' ' => ['', ''], + '|' => [self::TRUNK_STYLE, ''], + default => [self::CROWN_STYLE, ''], + }; + if ($this->map->getLayerSymbol($layer, $x, $y) !== $glyph || $this->map->getLayerCellStyle($layer, $x, $y) !== ['prefix' => $prefix, 'suffix' => $suffix]) { + $this->map->setLayerCell($layer, $x, $y, $glyph, $prefix, $suffix); + } + } + } + } + + private function findCanopyLayerId(): string + { + foreach ($this->map->getLayers() as $layer) { + if ($layer['name'] === self::CANOPY_LAYER) { + return $layer['id']; + } + } + throw new MapSourceRefusal('The canopy layer is missing. Nothing was changed.'); + } +} diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 751fb5b..70d3bb8 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -80,6 +80,7 @@ use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Maps\MapReferences; use Ichiloto\Editor\Maps\TilePalette; +use Ichiloto\Editor\Maps\TreeBrush; use Ichiloto\Editor\History\SourceSetCommand; use Ichiloto\Editor\History\CommandGroup; use Ichiloto\Editor\History\SourceSetRequired; @@ -1424,6 +1425,45 @@ public function paintOccupancy(string $mapId, int $revision, array $cells, int $ return ['status' => 'applied', 'changed' => $applied['changed'], 'revision' => $this->getMapRevision($map)]; } + /** + * The project's tree types for the tree brush, by id. + * + * @return array{map: string, types: array} + * @throws SessionRefusal When the map is unknown or the field resources cannot be read. + */ + public function listTreeTypes(string $mapId): array + { + try { + return ['map' => $mapId, 'types' => TreeBrush::listTypes($this->requireMap($mapId))]; + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + } + + /** + * Draws the chosen tree types over a region, or erases the trees in it, + * as one undo step ({@see TreeBrush}). + * + * @param list $cells + * @param list $types + * @return array{status: 'applied', planted: int, removed: int, revision: int} + * @throws SessionRefusal When the map is stale or read-only, has no collision of its own, or the drawing does not fit. + */ + public function drawTrees(string $mapId, int $revision, array $cells, array $types, bool $erase = false): array + { + $map = $this->requireCurrentMap($mapId, $revision); + try { + $result = $erase ? TreeBrush::erase($map, $cells) : TreeBrush::draw($map, $types, $cells); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($result['command'] !== null) { + $this->history->record($result['command']); + } + + return ['status' => 'applied', 'planted' => $result['planted'], 'removed' => $result['removed'], 'revision' => $this->getMapRevision($map)]; + } + /** @return array{cells: list, map: string, revision: int} */ public function getOccupancyFillRegion(string $mapId, int $x, int $y, int $revision): array { diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 51a5de4..8d07f84 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -152,6 +152,10 @@ private function dispatch(string $method, array $params): array is_array($params['landing'] ?? null) ? $params['landing'] : null, self::readOptionalString($params, 'answer'), self::readOptionalString($params, 'confirm'), ), + 'trees.types' => $session->listTreeTypes(self::requireString($params, 'map')), + 'trees.draw' => $session->drawTrees(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireArray($params, 'cells'), is_array($params['types'] ?? null) ? array_values(array_filter($params['types'], is_string(...))) : [], + ($params['erase'] ?? false) === true), 'map.read' => $session->readMap(self::requireString($params, 'map')), 'worldObjects.read' => $session->readWorldObject(self::requireString($params, 'map'), self::requireString($params, 'id')), 'worldObjects.create' => $session->createWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), diff --git a/tests/Pest.php b/tests/Pest.php index 703f612..a9c7086 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -383,6 +383,7 @@ function plainTextOfFrame(string $frame, int $width, int $height): string require_once __DIR__ . '/Support/AuthoringCanvasFixtures.php'; require_once __DIR__ . '/Support/PhysicalOccupancyFixtures.php'; require_once __DIR__ . '/Support/MapLineFixtures.php'; +require_once __DIR__ . '/Support/TreeBrushFixtures.php'; require_once __DIR__ . '/Support/BattleEntryFixtures.php'; diff --git a/tests/Support/TreeBrushFixtures.php b/tests/Support/TreeBrushFixtures.php new file mode 100644 index 0000000..c7c1eb0 --- /dev/null +++ b/tests/Support/TreeBrushFixtures.php @@ -0,0 +1,73 @@ + array_map( + static fn(string $glyph): CollisionType => $glyph === '#' ? CollisionType::SOLID : CollisionType::NONE, str_split($row)), $rows); + $data = ['name' => 'Forest', 'region' => '', 'events' => ['E' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => ['mode' => 'action', 'reusable' => true, 'script' => [['type' => 'show_text', 'text' => 'Hello']]]]], + 'npcs' => [['id' => 'ranger', 'name' => 'Ranger', 'sprite' => 'r', 'x' => 2, 'y' => 2]]]; + if ($declared) { + $data['occupancy'] = $occupancy; + } + file_put_contents($directory . '/test-map.data.php', ' [ + 'oak' => ['name' => 'Oak', 'pivot' => ['x' => 0.5, 'y' => 0.9375], + 'sprites2d' => ['asset' => 'Graphics/Objects/Oak.png', 'layer' => 100, 'cells' => ['width' => 3, 'height' => 4]], + 'occupancyStamp' => [['x' => 0, 'y' => 0, 'type' => {$solid}]]], + 'pair' => ['name' => 'Pair', 'pivot' => ['x' => 0.375, 'y' => 0.95], + 'sprites2d' => ['asset' => 'Graphics/Objects/Pair.png', 'layer' => 100, 'cells' => ['width' => 4, 'height' => 5]], + 'occupancyStamp' => [['x' => 0, 'y' => 0, 'type' => {$solid}], ['x' => 2, 'y' => 0, 'type' => {$solid}]]], + 'counter' => ['name' => 'Counter', 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => ['asset' => 'Graphics/Objects/Counter.png']], +]]; +PHP); + + return $root; +} + +/** @return list Every cell of a rectangle. */ +function listRectangleCells(int $x0, int $y0, int $x1, int $y1): array +{ + $cells = []; + for ($y = $y0; $y <= $y1; $y++) { + for ($x = $x0; $x <= $x1; $x++) { + $cells[] = [$x, $y]; + } + } + + return $cells; +} diff --git a/tests/Unit/Session/TreeBrushTest.php b/tests/Unit/Session/TreeBrushTest.php new file mode 100644 index 0000000..16ef098 --- /dev/null +++ b/tests/Unit/Session/TreeBrushTest.php @@ -0,0 +1,168 @@ + Every solid cell inside the border. */ +function listInnerSolidCells(\Ichiloto\Editor\ProjectMap $map): array +{ + $cells = []; + foreach ($map->getResolvedCollisionMap() as $y => $row) { + foreach ($row as $x => $value) { + if ($x > 0 && $y > 0 && $x < 11 && $y < 7 && $value !== CollisionType::NONE->value) { + $cells[] = [$x, $y]; + } + } + } + + return $cells; +} + +/** Whether every walkable cell reaches every other one, moving in four directions. */ +function isWalkableConnected(\Ichiloto\Editor\ProjectMap $map): bool +{ + $grid = $map->getResolvedCollisionMap(); + $open = []; + foreach ($grid as $y => $row) { + foreach ($row as $x => $value) { + if ($value === CollisionType::NONE->value) { + $open["{$x},{$y}"] = [$x, $y]; + } + } + } + $start = array_key_first($open); + $seen = [$start => true]; + $queue = [$open[$start]]; + while ($queue !== []) { + [$x, $y] = array_shift($queue); + foreach ([[1, 0], [-1, 0], [0, 1], [0, -1]] as [$dx, $dy]) { + $key = ($x + $dx) . ',' . ($y + $dy); + if (isset($open[$key]) && ! isset($seen[$key])) { + $seen[$key] = true; + $queue[] = $open[$key]; + } + } + } + + return count($seen) === count($open); +} + +it('offers only placeable images with a trunk stamp as tree types', function () { + [$session] = openTreeBrushSession(); + + expect($session->listTreeTypes('test-map')['types'])->toBe(['oak' => 'Oak', 'pair' => 'Pair']); +}); + +it('plants trees over the region with solid trunks, and draws the same forest again as nothing new', function () { + [$session, $map] = openTreeBrushSession(); + $revision = $session->readMap('test-map')['revision']; + + $drawn = $session->drawTrees('test-map', $revision, listRectangleCells(1, 1, 10, 6), ['oak', 'pair']); + $objects = $session->readMap('test-map')['worldObjects']; + $trunks = listInnerSolidCells($map); + + expect($drawn['planted'])->toBeGreaterThan(2) + ->and(count($objects))->toBe($drawn['planted']) + ->and(array_unique(array_column($objects, 'resource')))->toEqualCanonicalizing(['oak', 'pair']) + // Only trunks and the inner wall are solid; no trunk on the event, the NPC or the wall's gap. + ->and($trunks)->not->toContain([9, 2], [2, 2], [6, 6]) + ->and(isWalkableConnected($map))->toBeTrue(); + foreach ($objects as $object) { + expect($map->getResolvedCollisionMap()[$object['anchor']['y']][$object['anchor']['x']])->toBe(CollisionType::SOLID->value); + } + + expect($session->drawTrees('test-map', $drawn['revision'], listRectangleCells(1, 1, 10, 6), ['oak', 'pair'])['planted'])->toBe(0); +}); + +it('grows the same forest from the same region on another copy of the map', function () { + $draw = static function (): array { + [$session] = openTreeBrushSession(); + $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(1, 1, 10, 6), ['oak', 'pair']); + + return array_map(static fn(array $object): array => [$object['resource'], $object['anchor']], $session->readMap('test-map')['worldObjects']); + }; + + expect($draw())->toBe($draw()); +}); + +it('draws the trees for the terminal on its own canopy decoration layer', function () { + [$session, $map] = openTreeBrushSession(); + $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(2, 4, 4, 6), ['oak']); + $object = $session->readMap('test-map')['worldObjects'][0]; + [$x, $y] = [$object['anchor']['x'], $object['anchor']['y']]; + $layer = array_find($map->getLayers(), static fn(array $layer): bool => $layer['name'] === 'canopy'); + + expect($layer['decoration'])->toBeTrue() + ->and($map->getLayerSymbol($layer['id'], $x, $y))->toBe('|') + ->and([$map->getLayerSymbol($layer['id'], $x - 1, $y - 1), $map->getLayerSymbol($layer['id'], $x, $y - 1), $map->getLayerSymbol($layer['id'], $x + 1, $y - 1)]) + ->toBe(['(', ')', ')']) + ->and($map->getLayerColor($layer['id'], $x, $y - 1))->toBe('bright-green') + // The terrain the player walks on is untouched. + ->and($map->getLayerSymbol($map->getBaseLayerId(), $x, $y))->toBe('.'); +}); + +it('keeps the only gap through a wall open', function () { + [$session, $map] = openTreeBrushSession(); + $session->drawTrees('test-map', $session->readMap('test-map')['revision'], [[6, 6], [5, 6], [7, 6]], ['oak']); + + expect(isWalkableConnected($map))->toBeTrue() + ->and($map->getResolvedCollisionMap()[6][6])->toBe(CollisionType::NONE->value); +}); + +it('erases the trees standing in a region, clearing their trunks and crowns, and undo brings them back', function () { + [$session, $map] = openTreeBrushSession(); + $drawn = $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(1, 1, 10, 6), ['oak']); + $layer = array_find($map->getLayers(), static fn(array $layer): bool => $layer['name'] === 'canopy')['id']; + $before = $session->readMap('test-map')['worldObjects']; + + $erased = $session->drawTrees('test-map', $drawn['revision'], listRectangleCells(1, 1, 5, 6), [], true); + $left = $session->readMap('test-map')['worldObjects']; + + expect($erased['removed'])->toBeGreaterThan(0) + ->and(array_filter($left, static fn(array $object): bool => $object['anchor']['x'] <= 5))->toBe([]) + ->and(array_filter(listInnerSolidCells($map), static fn(array $cell): bool => $cell[0] <= 5))->toBe([]); + for ($y = 0; $y < 8; $y++) { + expect($map->getLayerSymbol($layer, 1, $y))->toBe(' '); + } + + $session->undo(); + expect($session->readMap('test-map')['worldObjects'])->toBe($before); +}); + +it('refuses on a map without its own collision, and a drawing past the world-object limit', function () { + [$session] = openTreeBrushSession(false); + expect(fn() => $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(1, 1, 10, 6), ['oak'])) + ->toThrow(SessionRefusal::class, 'Separate collision first'); + + [$session, $map] = openTreeBrushSession(); + $map->setMapDataField(['worldObjects'], array_map(static fn(int $n): array => ['id' => "stone-{$n}", 'anchor' => ['x' => 1, 'y' => 1], + 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null], range(1, 127))); + expect(fn() => $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(1, 1, 10, 6), ['oak'])) + ->toThrow(SessionRefusal::class, 'only 1 still fit'); +}); + +it('refuses a type that is not a tree', function () { + [$session] = openTreeBrushSession(); + + expect(fn() => $session->drawTrees('test-map', $session->readMap('test-map')['revision'], [[3, 3]], ['counter'])) + ->toThrow(SessionRefusal::class, "'counter' is not a tree type"); +}); From c1b71b0254d3ed0d99235e5b6f9a3748731669ae Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 16:56:58 +0200 Subject: [PATCH 261/344] feat(maps): draw and erase trees over the terminal canvas selection Ctrl+P offers Map: Draw trees in the selection, varying between every tree type, and Map: Erase trees in the selection, through the same TreeBrush and one undo step as the GUI. --- docs/manual.md | 5 ++- src/Canvas/TreeCanvas.php | 61 ++++++++++++++++++++++++++++ src/Editor.php | 2 + tests/Unit/Session/TreeBrushTest.php | 24 +++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/Canvas/TreeCanvas.php diff --git a/docs/manual.md b/docs/manual.md index 009aad3..76c3a12 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1211,7 +1211,10 @@ crowns, as wide as the image above the trunk and narrowing upwards, over gray `|` trunks; the Game view leaves decoration layers out, so they never hide the images. A drawing that would take the map past its 128 world objects is refused with the number that still fit. Each drawing or erasing is one undo -step and is written on Save. +step and is written on Save. The GUI editor's Trees tool chooses the types and +covers the region with any brush shape, Shift or the eraser erasing; in the +terminal editor, `Ctrl+P` offers **Map: Draw trees in the selection**, which +varies between every tree type, and **Map: Erase trees in the selection**. Why a category can still turn out read-only: a file the editor cannot evaluate, a value it could not write back out (such as an object only its own diff --git a/src/Canvas/TreeCanvas.php b/src/Canvas/TreeCanvas.php new file mode 100644 index 0000000..7782e96 --- /dev/null +++ b/src/Canvas/TreeCanvas.php @@ -0,0 +1,61 @@ +getSelectedMap(); + if ($map === null || $map->getGridSourceIssue() !== null || $this->editingMode !== self::MODE_MAP) { + return []; + } + + return [ + new PaletteItem('Map: Draw trees in the selection', '', fn() => $this->drawTreesInSelection(false)), + new PaletteItem('Map: Erase trees in the selection', '', fn() => $this->drawTreesInSelection(true)), + ]; + } + + private function drawTreesInSelection(bool $erase): void + { + $map = $this->getSelectedMap(); + if ($map === null) { + return; + } + if ($this->canvasSelection === null) { + $this->setStatus('Select a region of the canvas first; the trees go inside it.', StatusLevel::WARN); + return; + } + $this->finalizeActiveStroke(); + $cells = array_map(static fn(array $cell): array => [$cell['x'], $cell['y']], $this->getSelectionCells($this->canvasSelection)); + try { + $result = $erase ? TreeBrush::erase($map, $cells) : TreeBrush::draw($map, array_keys(TreeBrush::listTypes($map)), $cells); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + return; + } + if ($result['command'] === null) { + $this->setStatus($erase ? 'No tree stands in the selection.' + : 'No tree fits there: trunks need open ground clear of events, NPCs and other trunks, and never close a passage.', StatusLevel::WARN); + return; + } + $this->recordCommand($result['command']); + $this->setStatus($erase ? sprintf('Erased %d trees and freed their trunks. Ctrl+Z undoes it.', $result['removed']) + : sprintf('Planted %d trees with solid trunks. Ctrl+Z undoes it; Save writes them.', $result['planted'])); + $this->requestFullRender(); + } +} diff --git a/src/Editor.php b/src/Editor.php index dfa8f85..0d7e201 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -130,6 +130,7 @@ final class Editor use \Ichiloto\Editor\Canvas\LayerCanvas; use \Ichiloto\Editor\Canvas\PieceCanvas; use \Ichiloto\Editor\Canvas\MapLineCanvas; + use \Ichiloto\Editor\Canvas\TreeCanvas; use \Ichiloto\Editor\Canvas\GlyphTileCanvas; use \Ichiloto\Editor\Inspector\MapKindField; use CutscenesWorkspace; @@ -5453,6 +5454,7 @@ private function buildPaletteItems(): array ...$this->buildLayerPaletteItems(), ...$this->buildPiecePaletteItems(), ...$this->buildMapLinePaletteItems(), + ...$this->buildTreePaletteItems(), new PaletteItem('Save Map', 'Ctrl+S', fn() => $this->saveSelectedMap()), new PaletteItem('Move Map to Derived Path', '', fn() => $this->beginExplicitMapMove()), new PaletteItem('Save All', 'Ctrl+A', fn() => $this->saveAllAssets()), diff --git a/tests/Unit/Session/TreeBrushTest.php b/tests/Unit/Session/TreeBrushTest.php index 16ef098..aed0816 100644 --- a/tests/Unit/Session/TreeBrushTest.php +++ b/tests/Unit/Session/TreeBrushTest.php @@ -166,3 +166,27 @@ function isWalkableConnected(\Ichiloto\Editor\ProjectMap $map): bool expect(fn() => $session->drawTrees('test-map', $session->readMap('test-map')['revision'], [[3, 3]], ['counter'])) ->toThrow(SessionRefusal::class, "'counter' is not a tree type"); }); + +it('draws and erases trees over the terminal canvas selection, one undo step each', function () { + $editor = deletionEditor(createTreeBrushProject()); + setEditorProperty($editor, 'focusedPane', 'canvas'); + $map = static fn() => getEditorProperty($editor, 'workspace')->getMapByIndex(0); + expect($map()->mapId)->toBe('test-map'); + $count = static fn(): int => count($map()->hasMapDataField(['worldObjects']) ? $map()->getMapDataField(['worldObjects']) : []); + + setEditorProperty($editor, 'canvasSelection', ['x' => 1, 'y' => 1, 'width' => 10, 'height' => 6]); + openMapLinePaletteItem($editor, 'Map: Draw trees in the selection'); + $planted = $count(); + expect($planted)->toBeGreaterThan(2) + ->and(getEditorProperty($editor, 'statusMessage'))->toContain("Planted {$planted} trees"); + + openMapLinePaletteItem($editor, 'Map: Erase trees in the selection'); + expect($count())->toBe(0); + pressKeys($editor, "\x1a"); + expect($count())->toBe($planted); + + setEditorProperty($editor, 'canvasSelection', null); + openMapLinePaletteItem($editor, 'Map: Draw trees in the selection'); + expect(getEditorProperty($editor, 'statusMessage'))->toContain('Select a region of the canvas first') + ->and($count())->toBe($planted); +}); From 487854475c225acaa0ec43c1b6007885f992de1a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 18:11:18 +0200 Subject: [PATCH 262/344] feat(maps): move existing saves off floor a map save makes solid, once reviewed Andrew chose reviewed relocation steps. Saving a map that makes floor solid where the player can stand in the saved map now first lists each such cell and its landing, the nearest open, reachable floor walked to from it and never an event or NPC cell. Confirming writes the map and a declared relocations step in the save manifest together; the step is its own undo. Save All leaves such a map unsaved and names it (skippedRelocations), so its save is always reviewed. map.save takes answer and confirm, as the terminal editor's Ctrl+S shows the same review. Appending a manifest step is shared with line edits through SaveManifestStep. --- docs/manual.md | 17 +++ src/Editor.php | 75 +++++++++- src/Maps/MapLinePlanner.php | 23 +--- src/Maps/SaveManifestStep.php | 68 +++++++++ src/Maps/SaveRelocationPlan.php | 77 +++++++++++ src/Maps/SaveRelocationPlanner.php | 160 ++++++++++++++++++++++ src/Session/EditorSession.php | 42 +++++- src/Session/SessionHost.php | 3 +- src/Storage/WorkspaceSave.php | 15 ++ src/Storage/WorkspaceSaveResult.php | 2 + tests/Unit/Session/SaveRelocationTest.php | 115 ++++++++++++++++ 11 files changed, 572 insertions(+), 25 deletions(-) create mode 100644 src/Maps/SaveManifestStep.php create mode 100644 src/Maps/SaveRelocationPlan.php create mode 100644 src/Maps/SaveRelocationPlanner.php create mode 100644 tests/Unit/Session/SaveRelocationTest.php diff --git a/docs/manual.md b/docs/manual.md index 76c3a12..0557281 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -2420,6 +2420,23 @@ matches. `Ctrl+A` saves every dirty map and every editable database in one pass. Read-only categories are never included - they hold no edits. +### Saves follow what a map makes solid + +When a map is saved after an edit makes floor solid that the player can reach +in the saved map (furniture, a wall, painted collision, trees), a save +standing on such a cell would load inside it. With a save compatibility +manifest (`assets/Data/save-compatibility.php`), `Ctrl+S` first shows each such +cell and where saves on it will land: the nearest open floor the player can +reach today, found by walking from the cell, never an event or NPC cell. +**Save and move saves** writes the map and a `relocations` step that raises +the manifest's content version by one; **Cancel** saves nothing. The step is +its own undo: `Ctrl+Z` after the save removes it from the manifest, and undoing +the edit itself is then saved as usual. `Ctrl+A` leaves such a map unsaved and +names it, so its save is always reviewed on its own. A cell no open floor can +be reached from refuses the save until a path is opened or the edit undone. A +project without a manifest is saved at once, as before. The GUI editor shows +the same review when it saves. + Saving writes through a temporary file and a rename, so an interrupted save cannot truncate your work. When a save regenerates a data file, everything from ` ' ' . $mapId, $result->skippedRenames), '']; } + if ($result->skippedRelocations !== []) { + $detailLines[] = 'Skipped - saving would move existing saves off cells now solid (use Ctrl+S on the map to review where):'; + $detailLines = [...$detailLines, ...array_map(static fn(string $mapId): string => ' ' . $mapId, $result->skippedRelocations), '']; + } + if ($result->failures !== []) { $detailLines = [...$detailLines, 'Failed:', ...array_map(static fn(string $failure): string => ' ' . $failure, $result->failures), '']; } @@ -6417,7 +6425,7 @@ private function saveAllAssets(): void if ($result->failures !== []) { $this->setStatus($result->summary . sprintf(' %d failed (Ctrl+E for details).', count($result->failures)), StatusLevel::ERROR, $detailLines); - } elseif ($result->skippedRenames !== [] || $result->warnings !== [] || $result->backupFailures !== []) { + } elseif ($result->skippedRenames !== [] || $result->skippedRelocations !== [] || $result->warnings !== [] || $result->backupFailures !== []) { $this->setStatus($result->summary . ' See Ctrl+E for skipped saves and warnings.', StatusLevel::WARN, $detailLines); } else { $this->setStatus($result->summary, StatusLevel::SUCCESS); @@ -6607,7 +6615,7 @@ private function deriveProposedMapId(ProjectMap $map): string * * @return void */ - private function performSaveSelectedMap(): void + private function performSaveSelectedMap(?SaveRelocationPlan $reviewed = null): void { if (! $this->workspace instanceof ProjectWorkspace) { return; @@ -6619,14 +6627,37 @@ private function performSaveSelectedMap(): void return; } + try { + $relocation = SaveRelocationPlanner::plan($selectedMap, $this->workspace->projectRoot); + } catch (Throwable $throwable) { + $this->setErrorStatus($throwable, 'Save'); + return; + } + if ($relocation !== null && ($reviewed === null || ! hash_equals($relocation->getFingerprint(), $reviewed->getFingerprint()))) { + $this->openSaveRelocationReview($relocation); + return; + } + $warnings = MapValidator::validate($selectedMap, $this->getMapsById()); + $step = null; try { $previousMapId = $selectedMap->mapId; + // Saves standing where the map is now solid move with it; the step is its own undo. + $step = $relocation?->apply(); // The save backs up, once, exactly the files it is about to // replace -- so a clean map or an untouched member never // produces a backup copy. - $savedMapId = $selectedMap->save($this->backupBeforeSave(...)); + try { + $savedMapId = $selectedMap->save($this->backupBeforeSave(...)); + } catch (Throwable $failure) { + $relocation?->revert(); + $step = null; + throw $failure; + } + if ($step !== null) { + $this->recordCommand($step); + } if ($savedMapId !== $previousMapId) { // The folder moved: swap in a freshly parsed map instance and @@ -6646,8 +6677,10 @@ private function performSaveSelectedMap(): void $this->clampInspectorSelection(); } + $moved = $step === null ? '' : sprintf(' Saves on %d cells it makes solid now move to open floor (save content version %d).', + array_sum(array_map(static fn(array $relocation): int => count($relocation['cells']), $relocation->relocations)), $relocation->contentVersion); if ($warnings === []) { - $this->setStatus(sprintf('Saved %s.', $savedMapId), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Saved %s.%s', $savedMapId, $moved), StatusLevel::SUCCESS); } else { $this->setStatus( sprintf('Saved %s with %d warning%s (Ctrl+E for details).', $savedMapId, count($warnings), count($warnings) === 1 ? '' : 's'), @@ -6662,6 +6695,32 @@ private function performSaveSelectedMap(): void $this->renderSelectionDependentArea(); } + /** + * Lists where saving the map moves existing saves, for confirmation: + * nothing is written until Write is chosen. + */ + private function openSaveRelocationReview(SaveRelocationPlan $plan): void + { + $this->optionDialogField = ['saveRelocation' => $plan]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = sprintf('Save %s and move existing saves', $plan->mapId); + $this->eventOptionDialogEntries = [ + ['label' => 'Cancel', 'value' => 'cancel', 'description' => 'Save nothing.'], + ['label' => 'Save and move saves', 'value' => 'write', + 'description' => sprintf('Saves the map and adds save content version %d to %s. Ctrl+Z removes that step.', $plan->contentVersion, SaveManifestStep::PATH)], + ...array_map(static fn(string $move): array => [ + 'label' => 'Move saves on ' . $move, 'value' => 'preview', + 'description' => 'A save standing there would load inside what is now solid.', + ], $plan->describeMoves()), + ]; + $this->selectedEventOptionIndex = 0; + $this->dialogFilter->clear(); + $this->isEventOptionDialogOpen = true; + $this->setStatus(sprintf('%s is now solid where saves could stand. Review where they move before saving.', $plan->mapId), StatusLevel::WARN); + $this->renderSelectionDependentArea(); + } + /** * Creates a new blank map and begins editing its display name. * @@ -7297,6 +7356,14 @@ private function applySelectedEventOption(): void return; } + if (($field['saveRelocation'] ?? null) instanceof SaveRelocationPlan) { + if ($selectedEntry['value'] === 'preview') { return; } + $plan = $field['saveRelocation']; + $this->closeEventOptionDialog(); + $selectedEntry['value'] === 'write' ? $this->performSaveSelectedMap($plan) : $this->setStatus('Save cancelled. No files were changed.'); + return; + } + if (($field['mapLines'] ?? null) instanceof MapLinePlan) { if ($selectedEntry['value'] === 'preview') { return; } $this->confirmMapLines($field['mapLines'], $selectedEntry['value'] === 'write'); diff --git a/src/Maps/MapLinePlanner.php b/src/Maps/MapLinePlanner.php index c428ce5..f4d705b 100644 --- a/src/Maps/MapLinePlanner.php +++ b/src/Maps/MapLinePlanner.php @@ -38,7 +38,7 @@ */ final class MapLinePlanner { - private const string MANIFEST = 'assets/Data/save-compatibility.php'; + private const string MANIFEST = SaveManifestStep::PATH; private const string SYSTEM = 'assets/Data/system.php'; private function __construct() @@ -342,24 +342,13 @@ private static function findLiteralRefusal(PhpArraySourceDocument $document, arr */ private static function planSaveMigration(string $source, mixed $manifest, MapLineEdit $edit, array &$handEdits): ?array { - $version = is_array($manifest) ? ($manifest['contentVersion'] ?? null) : null; - $migrations = is_array($manifest) ? ($manifest['migrations'] ?? []) : null; - $step = ['from' => $version, 'to' => is_int($version) ? $version + 1 : null, ...$edit->getManifestEdits()]; - $reason = null; - if (! is_int($version) || $version < 0 || ! is_array($migrations) || ! array_is_list($migrations)) { - $reason = 'its contentVersion or migrations list is not valid, so saves cannot be migrated automatically'; - } else { - $next = $manifest; - $next['contentVersion'] = $version + 1; - $next['migrations'] = [...$migrations, $step]; - try { - return ['source' => ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $manifest, $next)->source, 'value' => $next]; - } catch (SourceUnreadable|SourcePreservationRefusal $refusal) { - $reason = rtrim($refusal->getMessage(), '.'); - } + $next = SaveManifestStep::append($source, $manifest, $edit->getManifestEdits()); + if (is_array($next)) { + return $next; } $handEdits[] = ['file' => self::MANIFEST, 'where' => 'contentVersion and migrations', - 'reason' => sprintf('%s; raise contentVersion by one and add the step %s by hand', $reason, var_export($step, true))]; + 'reason' => sprintf('%s; raise contentVersion by one and add the step %s by hand', $next, + var_export(SaveManifestStep::describe($manifest, $edit->getManifestEdits()), true))]; return null; } diff --git a/src/Maps/SaveManifestStep.php b/src/Maps/SaveManifestStep.php new file mode 100644 index 0000000..22df9af --- /dev/null +++ b/src/Maps/SaveManifestStep.php @@ -0,0 +1,68 @@ +>> $edits Declared position edits by manifest key. + * @return array + */ + public static function describe(mixed $manifest, array $edits): array + { + $version = is_array($manifest) ? ($manifest['contentVersion'] ?? null) : null; + + return ['from' => $version, 'to' => is_int($version) ? $version + 1 : null, ...$edits]; + } + + /** + * Appends the step. + * + * @param string $source The manifest's source. + * @param mixed $manifest The manifest as evaluated. + * @param array>> $edits Declared position edits by manifest key. + * @return array{source: string, value: array}|string The rewritten manifest, or why it cannot be. + */ + public static function append(string $source, mixed $manifest, array $edits): array|string + { + $version = is_array($manifest) ? ($manifest['contentVersion'] ?? null) : null; + $migrations = is_array($manifest) ? ($manifest['migrations'] ?? []) : null; + if (! is_int($version) || $version < 0 || ! is_array($migrations) || ! array_is_list($migrations)) { + return 'its contentVersion or migrations list is not valid, so saves cannot be migrated automatically'; + } + $next = $manifest; + $next['contentVersion'] = $version + 1; + $next['migrations'] = [...$migrations, self::describe($manifest, $edits)]; + try { + return ['source' => ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $manifest, $next)->source, 'value' => $next]; + } catch (SourceUnreadable|SourcePreservationRefusal $refusal) { + return rtrim($refusal->getMessage(), '.'); + } + } +} diff --git a/src/Maps/SaveRelocationPlan.php b/src/Maps/SaveRelocationPlan.php new file mode 100644 index 0000000..8ed7634 --- /dev/null +++ b/src/Maps/SaveRelocationPlan.php @@ -0,0 +1,77 @@ +, to: array{int, int}}> $relocations + */ + public function __construct( + public readonly string $mapId, + public readonly array $relocations, + public readonly int $contentVersion, + private readonly SourceSetPlan $sources, + ) { + } + + /** What the save also does, in words: for the question and the status line. */ + public string $description { + get => sprintf('Saves standing where %s is now solid move to open floor: save content version %d relocates %s.', + $this->mapId, $this->contentVersion, implode('; ', $this->describeMoves())); + } + + /** @return list Each move: x 2, y 5 to x 2, y 6. */ + public function describeMoves(): array + { + $moves = []; + foreach ($this->relocations as $relocation) { + $from = implode(', ', array_map(static fn(array $cell): string => sprintf('x %d, y %d', ...$cell), $relocation['cells'])); + $moves[] = sprintf('%s to x %d, y %d', $from, ...$relocation['to']); + } + + return $moves; + } + + /** Names this exact plan, so the write is the one the author was shown. */ + public function getFingerprint(): string + { + return $this->sources->getFingerprint(); + } + + /** @return list The manifest, which the plan writes. */ + public function getChangedPaths(): array + { + return $this->sources->getChangedPaths(); + } + + /** + * Writes the manifest step and returns the undo step that removes it: the + * map is saved by the caller, and undoing restores the manifest as it was. + */ + public function apply(): Command + { + $sources = $this->sources; + $sources->apply(); + + return new GenericCommand('Add save relocations', static fn() => $sources->apply(), static fn() => $sources->revert()); + } + + public function revert(): void + { + $this->sources->revert(); + } +} diff --git a/src/Maps/SaveRelocationPlanner.php b/src/Maps/SaveRelocationPlanner.php new file mode 100644 index 0000000..9260038 --- /dev/null +++ b/src/Maps/SaveRelocationPlanner.php @@ -0,0 +1,160 @@ + self::buildPlan($map, $root)); + } + + private static function buildPlan(ProjectMap $map, string $root): ?SaveRelocationPlan + { + $manifestPath = $root . '/' . SaveManifestStep::PATH; + if (! is_file($manifestPath) || ! $map->isDirty()) { + return null; + } + $report = ProjectReachability::analyze($root . '/assets')->reports[$map->mapId] ?? null; + if ($report === null) { + return null; + } + + $grid = $map->getResolvedCollisionMap(); + $open = static fn(int $x, int $y): bool => ($grid[$y][$x] ?? null) === CollisionType::NONE->value; + $blocked = []; + foreach ($grid as $y => $row) { + foreach ($row as $x => $value) { + if ($value !== CollisionType::NONE->value && $report->isReachable($x, $y)) { + $blocked["{$x},{$y}"] = [$x, $y]; + } + } + } + if ($blocked === []) { + return null; + } + + $claimed = self::readClaimedCells($map); + $canLand = static fn(int $x, int $y): bool => $open($x, $y) && $report->isReachable($x, $y) && ! isset($claimed["{$x},{$y}"]); + $groups = []; + foreach ($blocked as [$x, $y]) { + $landing = self::findLanding($x, $y, $open, $canLand, $grid) + ?? throw new RuntimeException(sprintf('Saves standing on x %d, y %d of %s would have no open floor to move to. Open a path to it, or undo the change, then save.', + $x, $y, $map->mapId)); + $groups[implode(',', $landing)]['to'] = $landing; + $groups[implode(',', $landing)]['cells'][] = [$x, $y]; + } + $relocations = array_map(static fn(array $group): array => ['map' => $map->mapId, 'cells' => $group['cells'], 'to' => $group['to']], array_values($groups)); + + $source = (string) file_get_contents($manifestPath); + try { + $manifest = PhpDataFile::evaluateIsolated($manifestPath, $root); + } catch (Throwable $failure) { + throw new RuntimeException(sprintf('%s could not be read, so the saves standing where %s is now solid cannot be moved: %s', + SaveManifestStep::PATH, $map->mapId, $failure->getMessage()), previous: $failure); + } + $next = SaveManifestStep::append($source, $manifest, ['relocations' => $relocations]); + if (is_string($next)) { + throw new RuntimeException(sprintf('%s cannot take the save relocations %s needs: %s. Add the step %s by hand.', + SaveManifestStep::PATH, $map->mapId, $next, var_export(SaveManifestStep::describe($manifest, ['relocations' => $relocations]), true))); + } + + $sources = new SourceSetPlan($root, [$manifestPath => $source], [$manifestPath => $source], [$manifestPath => $next['source']], + [$manifestPath => PhpDataFile::getComparableValue($manifest)], [$manifestPath => PhpDataFile::getComparableValue($next['value'])], + static fn(): array => [$manifestPath], + static fn(string $path, string $staged): mixed => PhpDataFile::getComparableValue(PhpDataFile::evaluateIsolated($staged, $root)), + 'save relocations', 'Save compatibility manifest'); + + return new SaveRelocationPlan($map->mapId, $relocations, $next['value']['contentVersion'], $sources); + } + + /** + * The nearest open floor a save can land on: first by walking from the + * cell's open neighbours, so the landing is on the same side of whatever + * was built, else the closest such cell anywhere. + * + * @param callable(int, int): bool $open + * @param callable(int, int): bool $canLand + * @return array{int, int}|null + */ + private static function findLanding(int $x, int $y, callable $open, callable $canLand, array $grid): ?array + { + $seen = ["{$x},{$y}" => true]; + $queue = [[$x, $y]]; + while ($queue !== []) { + [$cx, $cy] = array_shift($queue); + foreach ([[0, 1], [1, 0], [-1, 0], [0, -1]] as [$dx, $dy]) { + [$nx, $ny] = [$cx + $dx, $cy + $dy]; + if (isset($seen["{$nx},{$ny}"]) || ! $open($nx, $ny)) { + continue; + } + if ($canLand($nx, $ny)) { + return [$nx, $ny]; + } + $seen["{$nx},{$ny}"] = true; + $queue[] = [$nx, $ny]; + } + } + $best = null; + foreach ($grid as $cy => $row) { + foreach (array_keys($row) as $cx) { + if ($canLand($cx, $cy) && ($best === null || abs($cx - $x) + abs($cy - $y) < $best[2])) { + $best = [$cx, $cy, abs($cx - $x) + abs($cy - $y)]; + } + } + } + + return $best === null ? null : [$best[0], $best[1]]; + } + + /** @return array Event markers and NPCs, where a loaded save must not stand. */ + private static function readClaimedCells(ProjectMap $map): array + { + $claimed = []; + for ($y = 0, $height = $map->getHeight(); $y < $height; $y++) { + for ($x = 0, $width = $map->getWidth(); $x < $width; $x++) { + if ($map->hasLayerCell(MapLayers::EVENT, $x, $y) && $map->getEventMarkerAt($x, $y) !== null) { + $claimed["{$x},{$y}"] = true; + } + } + } + foreach ((array) ($map->getEditableData()['npcs'] ?? []) as $npc) { + if (is_int($npc['x'] ?? null) && is_int($npc['y'] ?? null)) { + $claimed["{$npc['x']},{$npc['y']}"] = true; + } + } + + return $claimed; + } +} diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 70d3bb8..015c178 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -81,6 +81,8 @@ use Ichiloto\Editor\Maps\MapReferences; use Ichiloto\Editor\Maps\TilePalette; use Ichiloto\Editor\Maps\TreeBrush; +use Ichiloto\Editor\Maps\SaveManifestStep; +use Ichiloto\Editor\Maps\SaveRelocationPlanner; use Ichiloto\Editor\History\SourceSetCommand; use Ichiloto\Editor\History\CommandGroup; use Ichiloto\Editor\History\SourceSetRequired; @@ -4415,11 +4417,38 @@ public function redo(): array * @return array{saved: string, warnings: list, backupFailures: list} * @throws SessionRefusal When the map is unknown or refuses to save. */ - public function saveMap(string $mapId): array + public function saveMap(string $mapId, ?string $answer = null, ?string $confirm = null): array { $map = $this->requireMap($mapId); + try { + $relocation = SaveRelocationPlanner::plan($map, $this->workspace->projectRoot); + } catch (RuntimeException $failure) { + throw new SessionRefusal($failure->getMessage(), previous: $failure); + } + if ($relocation !== null) { + // Saves standing where the map is now solid move with the save, once the author has seen where. + if ($answer === null) { + return ['status' => 'question', 'map' => $mapId, 'question' => sprintf('Save %s and move existing saves off cells it makes solid?', $mapId), + 'answers' => [ + ['key' => 'cancel', 'label' => 'Cancel', 'description' => 'Save nothing.'], + ['key' => 'write', 'label' => 'Save and move saves', 'description' => sprintf('Saves the map and writes %s. Undo removes the save step.', SaveManifestStep::PATH)], + ], + 'relocations' => $relocation->relocations, 'moves' => $relocation->describeMoves(), 'contentVersion' => $relocation->contentVersion, + 'confirm' => $relocation->getFingerprint()]; + } + if ($answer === 'cancel') { + return ['status' => 'cancelled', 'map' => $mapId, 'saved' => null]; + } + if ($answer !== 'write') { + throw new SessionRefusal(sprintf('Answer write or cancel, not %s.', $answer)); + } + if ($confirm === null || ! hash_equals($relocation->getFingerprint(), $confirm)) { + throw new SessionRefusal('The map or the save manifest changed since the save relocations were shown, so nothing was written. Review them again.'); + } + } $warnings = MapValidator::validate($map, $this->getMapsById()); $failures = []; + $step = $relocation?->apply(); try { $saved = $map->save(function (string ...$paths) use (&$failures): void { @@ -4428,10 +4457,16 @@ public function saveMap(string $mapId): array } }); } catch (MapSourceRefusal|FileSetTransactionFailure $refusal) { + // The map is not saved, so neither is the step that moves saves for it. + $relocation?->revert(); throw new SessionRefusal($refusal->getMessage(), previous: $refusal); } + if ($step !== null) { + $this->history->record($step); + } - return ['saved' => $saved, 'warnings' => array_values($warnings), 'backupFailures' => array_values($failures)]; + return ['saved' => $saved, 'warnings' => array_values($warnings), 'backupFailures' => array_values($failures), + 'relocated' => $relocation?->describeMoves() ?? []]; } /** @@ -4878,7 +4913,7 @@ public function listUnsavedChanges(): array * editor's Save All does, and names what is still unsaved after it: a * map whose save would move its folder, or a document that failed. * - * @return array{summary: string, skippedRenames: list, failures: list, warnings: list, backupFailures: list, unsaved: list} + * @return array{summary: string, skippedRenames: list, skippedRelocations: list, failures: list, warnings: list, backupFailures: list, unsaved: list} */ public function saveAll(): array { @@ -4887,6 +4922,7 @@ public function saveAll(): array return [ 'summary' => $result->summary, 'skippedRenames' => $result->skippedRenames, + 'skippedRelocations' => $result->skippedRelocations, 'failures' => $result->failures, 'warnings' => $result->warnings, 'backupFailures' => $result->backupFailures, diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 8d07f84..06ce2b3 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -361,7 +361,8 @@ private function dispatch(string $method, array $params): array array_key_exists('movesWith', $params) && (is_string($params['movesWith']) || $params['movesWith'] === null) ? $params['movesWith'] : throw new InvalidRequest('"movesWith" must be a gameplay layer name or null.'), ), - 'map.save' => $session->saveMap(self::requireString($params, 'map')), + 'map.save' => $session->saveMap(self::requireString($params, 'map'), + self::readOptionalString($params, 'answer'), self::readOptionalString($params, 'confirm')), 'mapPlacement.apply' => $session->applyMapPlacement( is_array($params['context'] ?? null) ? $params['context'] : throw new InvalidRequest('A placement needs its owner.'), self::requireKey($params), diff --git a/src/Storage/WorkspaceSave.php b/src/Storage/WorkspaceSave.php index f63c00c..91af33a 100644 --- a/src/Storage/WorkspaceSave.php +++ b/src/Storage/WorkspaceSave.php @@ -7,6 +7,7 @@ use Ichiloto\Editor\Backup\BackupWriter; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\SharedFileTransaction; +use Ichiloto\Editor\Maps\SaveRelocationPlanner; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Validation\MapValidator; use Throwable; @@ -31,6 +32,7 @@ public static function saveAll(ProjectWorkspace $workspace, BackupWriter $backup $savedMaps = 0; $savedDatabases = 0; $skippedRenames = []; + $skippedRelocations = []; $failures = []; $warnings = []; $mapsById = []; @@ -49,6 +51,18 @@ public static function saveAll(ProjectWorkspace $workspace, BackupWriter $backup continue; } + // Saves that would load inside what the map now makes solid are moved only once the author has seen where. + try { + $relocation = SaveRelocationPlanner::plan($map, $workspace->projectRoot); + } catch (Throwable $throwable) { + $failures[] = sprintf('%s: %s', $map->mapId, $throwable->getMessage()); + continue; + } + if ($relocation !== null) { + $skippedRelocations[] = $map->mapId; + continue; + } + foreach (MapValidator::validate($map, $mapsById) as $warning) { $warnings[] = sprintf('%s: %s', $map->mapId, $warning); } @@ -107,6 +121,7 @@ public static function saveAll(ProjectWorkspace $workspace, BackupWriter $backup savedDatabases: $savedDatabases, savedCutscenes: count($cutscenes['saved']), skippedRenames: $skippedRenames, + skippedRelocations: $skippedRelocations, failures: $failures, warnings: $warnings, backupFailures: array_values(array_unique($backupFailures)), diff --git a/src/Storage/WorkspaceSaveResult.php b/src/Storage/WorkspaceSaveResult.php index e036aa5..91a6784 100644 --- a/src/Storage/WorkspaceSaveResult.php +++ b/src/Storage/WorkspaceSaveResult.php @@ -22,6 +22,7 @@ final class WorkspaceSaveResult /** * @param list $skippedRenames Maps left unsaved because saving would move their folder. + * @param list $skippedRelocations Maps left unsaved because saving them moves existing saves, which their own save reviews. * @param list $failures Each document that could not be written, with why. * @param list $warnings What validation noticed in the maps written, which never blocks a save. * @param list $backupFailures Files whose backup failed; their save still ran. @@ -31,6 +32,7 @@ public function __construct( public readonly int $savedDatabases, public readonly int $savedCutscenes, public readonly array $skippedRenames, + public readonly array $skippedRelocations, public readonly array $failures, public readonly array $warnings, public readonly array $backupFailures, diff --git a/tests/Unit/Session/SaveRelocationTest.php b/tests/Unit/Session/SaveRelocationTest.php new file mode 100644 index 0000000..4d29016 --- /dev/null +++ b/tests/Unit/Session/SaveRelocationTest.php @@ -0,0 +1,115 @@ +readMap('home'); + $session->paint('home', $map['revision'], $map['baseLayer'], [[2, 3]], '#'); + + return [$root, $session]; +} + +function readSaveManifest(string $root): array +{ + return require $root . '/assets/Data/save-compatibility.php'; +} + +it('asks before saving a map that makes reachable floor solid, naming where those saves land', function () { + [$root, $session] = openWalledHomeSession(); + $before = sourceHashTree($root); + + $question = $session->saveMap('home'); + + expect($question)->toMatchArray(['status' => 'question', 'map' => 'home', 'contentVersion' => 1]) + ->and($question['relocations'])->toBe([['map' => 'home', 'cells' => [[2, 3]], 'to' => [2, 4]]]) + ->and($question['moves'])->toBe(['x 2, y 3 to x 2, y 4']) + ->and(array_column($question['answers'], 'key'))->toBe(['cancel', 'write']) + ->and(sourceHashTree($root))->toBe($before) + ->and($session->saveMap('home', 'cancel'))->toMatchArray(['status' => 'cancelled']) + ->and(sourceHashTree($root))->toBe($before); +}); + +it('saves the map and the relocation step together once confirmed, and undo removes the step', function () { + [$root, $session] = openWalledHomeSession(); + $manifest = (string) file_get_contents($root . '/assets/Data/save-compatibility.php'); + $question = $session->saveMap('home'); + + $saved = $session->saveMap('home', 'write', $question['confirm']); + + expect($saved['relocated'])->toBe(['x 2, y 3 to x 2, y 4']) + ->and(readSaveManifest($root)['contentVersion'])->toBe(1) + ->and(readSaveManifest($root)['migrations'])->toBe([['from' => 0, 'to' => 1, + 'relocations' => [['map' => 'home', 'cells' => [[2, 3]], 'to' => [2, 4]]]]]) + ->and(Ichiloto\Engine\IO\SaveCompatibility\SaveCompatibilityManifest::fromProjectRoot($root)->createMigrationFrom(0)) + ->toBeInstanceOf(Ichiloto\Engine\IO\SaveCompatibility\DeclaredPositionContentMigration::class) + ->and(describeSaveCompatibilityIssues($root))->toBe([]) + // Saved: nothing is left to move. + ->and(SaveRelocationPlanner::plan(getPhysicalOccupancyMap($session), $root))->toBeNull(); + + $session->undo(); + expect(file_get_contents($root . '/assets/Data/save-compatibility.php'))->toBe($manifest); +}); + +it('refuses a write the author was not shown', function () { + [$root, $session] = openWalledHomeSession(); + $before = sourceHashTree($root); + + expect(fn() => $session->saveMap('home', 'write', 'not-the-shown-review'))->toThrow(SessionRefusal::class, 'Review them again') + ->and(sourceHashTree($root))->toBe($before); +}); + +it('saves at once when nothing reachable becomes solid, or when the project keeps no save manifest', function () { + $root = mapLineRemovalProject(); + $session = EditorSession::open($root); + $map = $session->readMap('home'); + // A floor cell becomes another kind of floor: nothing a save could stand on turns solid. + $session->paint('home', $map['revision'], $map['baseLayer'], [[2, 3]], ' '); + expect($session->saveMap('home'))->toHaveKey('saved'); + + [$root, $session] = openWalledHomeSession(); + unlink($root . '/assets/Data/save-compatibility.php'); + expect($session->saveMap('home'))->toHaveKey('saved'); +}); + +it('leaves a map that would move saves out of Save All, for its own reviewed save', function () { + [$root, $session] = openWalledHomeSession(); + $workspace = new ReflectionProperty(EditorSession::class, 'workspace')->getValue($session); + $result = WorkspaceSave::saveAll($workspace, new Ichiloto\Editor\Backup\BackupWriter(Ichiloto\Editor\Backup\BackupSettings::fromProject($root), $root)); + + expect($result->skippedRelocations)->toBe(['home']) + ->and(getPhysicalOccupancyMap($session)->isDirty())->toBeTrue() + ->and(readSaveManifest($root)['contentVersion'])->toBe(0); +}); + +it('reviews the relocation in the terminal editor before Ctrl+S writes both', function () { + $root = mapLineRemovalProject(); + $editor = mapLineEditor($root); + getEditorProperty($editor, 'workspace')->getMapByIndex(0)->setTileSymbol(2, 3, '#'); + + pressKeys($editor, "\x13"); + $labels = array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label'); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue() + ->and($labels)->toContain('Save and move saves', 'Move saves on x 2, y 3 to x 2, y 4') + ->and(readSaveManifest($root)['contentVersion'])->toBe(0); + + pressKeys($editor, "\033[B", "\r"); + expect(readSaveManifest($root)['contentVersion'])->toBe(1) + ->and(getEditorProperty($editor, 'workspace')->getMapByIndex(0)->isDirty())->toBeFalse(); +}); From 4800fb426b04b86ac2220bc696e2197510047970 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 18:17:07 +0200 Subject: [PATCH 263/344] docs(manual): name the cells the save relocation review does not cover --- docs/manual.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/manual.md b/docs/manual.md index 0557281..a230bad 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -2435,7 +2435,9 @@ the edit itself is then saved as usual. `Ctrl+A` leaves such a map unsaved and names it, so its save is always reviewed on its own. A cell no open floor can be reached from refuses the save until a path is opened or the edit undone. A project without a manifest is saved at once, as before. The GUI editor shows -the same review when it saves. +the same review when it saves. A cell that a resize cuts off the map is not +covered by this review; removing rows or columns lands saves through its own +review instead. Saving writes through a temporary file and a rename, so an interrupted save cannot truncate your work. When a save regenerates a data file, everything from From 0ec7df370d76c9704739daaae8a7830b7e8f0086 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 12 Oct 2026 01:38:45 +0200 Subject: [PATCH 264/344] fix(maps): move saves only off cells a map save really takes The save relocation review treated every non-floor cell as solid, so walkable grass, exits and save points the map kept asked to move saves, and an NPC placed on open floor moved none. It now uses the Engine's shared passability contract: a reachable cell is taken when its new type blocks movement or an NPC now stands where none stood on disk. The tree brush's path keeping counts the same walkable ground, so a trunk never closes a path through grass. --- docs/manual.md | 16 +++-- src/Editor.php | 8 +-- src/Maps/SaveRelocationPlan.php | 2 +- src/Maps/SaveRelocationPlanner.php | 84 ++++++++++++++++------- src/Maps/TreeBrush.php | 51 ++++++++------ src/Session/EditorSession.php | 4 +- tests/Support/MapLineFixtures.php | 8 ++- tests/Support/TreeBrushFixtures.php | 11 ++- tests/Unit/Session/SaveRelocationTest.php | 31 +++++++++ tests/Unit/Session/TreeBrushTest.php | 14 ++++ 10 files changed, 165 insertions(+), 64 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index a230bad..4829271 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1199,9 +1199,10 @@ region: the brush plants whole trees across it, choosing type and spot in an order fixed by the map and cell, so drawing the same region again plants nothing new and drawing beside existing trees fills the gaps between them. Crowns may overlap; trunks keep half a crown apart. A trunk goes only on -passable ground that no event, NPC or other trunk takes, never outside the +plain floor that no event, NPC or other trunk takes, never outside the region, and never where making it solid would cut a walkable area in two, so -every cell the player could reach stays reachable. Each trunk becomes solid in +every cell the player could reach stays reachable, grass, exits and save +points included. Each trunk becomes solid in the map's own collision, never through its image, so the brush asks you to Separate collision first on a map that does not have it yet. Erasing removes the trees standing in the region and makes their trunk cells walkable again, @@ -2422,11 +2423,14 @@ Read-only categories are never included - they hold no edits. ### Saves follow what a map makes solid -When a map is saved after an edit makes floor solid that the player can reach -in the saved map (furniture, a wall, painted collision, trees), a save -standing on such a cell would load inside it. With a save compatibility +When a map is saved after an edit takes floor that the player can reach in +the saved map, a save standing on such a cell would load inside it. A cell is +taken when it becomes something that blocks movement (furniture, a wall, +painted collision, trees) or when an NPC is added or moved onto it. Walkable +grass, exits and save points take nothing, and an NPC that stays put moves no +save. With a save compatibility manifest (`assets/Data/save-compatibility.php`), `Ctrl+S` first shows each such -cell and where saves on it will land: the nearest open floor the player can +cell and where saves on it will land: the nearest plain floor the player can reach today, found by walking from the cell, never an event or NPC cell. **Save and move saves** writes the map and a `relocations` step that raises the manifest's content version by one; **Cancel** saves nothing. The step is diff --git a/src/Editor.php b/src/Editor.php index f4d7d30..0bb4f18 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -6407,7 +6407,7 @@ private function saveAllAssets(): void } if ($result->skippedRelocations !== []) { - $detailLines[] = 'Skipped - saving would move existing saves off cells now solid (use Ctrl+S on the map to review where):'; + $detailLines[] = 'Skipped - saving would move existing saves off cells now solid or taken by an NPC (use Ctrl+S on the map to review where):'; $detailLines = [...$detailLines, ...array_map(static fn(string $mapId): string => ' ' . $mapId, $result->skippedRelocations), '']; } @@ -6643,7 +6643,7 @@ private function performSaveSelectedMap(?SaveRelocationPlan $reviewed = null): v try { $previousMapId = $selectedMap->mapId; - // Saves standing where the map is now solid move with it; the step is its own undo. + // Saves standing where the map is now solid or taken by an NPC move with it; the step is its own undo. $step = $relocation?->apply(); // The save backs up, once, exactly the files it is about to // replace -- so a clean map or an untouched member never @@ -6711,13 +6711,13 @@ private function openSaveRelocationReview(SaveRelocationPlan $plan): void 'description' => sprintf('Saves the map and adds save content version %d to %s. Ctrl+Z removes that step.', $plan->contentVersion, SaveManifestStep::PATH)], ...array_map(static fn(string $move): array => [ 'label' => 'Move saves on ' . $move, 'value' => 'preview', - 'description' => 'A save standing there would load inside what is now solid.', + 'description' => 'A save standing there would load inside what is now solid, or on an NPC.', ], $plan->describeMoves()), ]; $this->selectedEventOptionIndex = 0; $this->dialogFilter->clear(); $this->isEventOptionDialogOpen = true; - $this->setStatus(sprintf('%s is now solid where saves could stand. Review where they move before saving.', $plan->mapId), StatusLevel::WARN); + $this->setStatus(sprintf('%s now blocks cells where saves could stand. Review where they move before saving.', $plan->mapId), StatusLevel::WARN); $this->renderSelectionDependentArea(); } diff --git a/src/Maps/SaveRelocationPlan.php b/src/Maps/SaveRelocationPlan.php index 8ed7634..e7d2a82 100644 --- a/src/Maps/SaveRelocationPlan.php +++ b/src/Maps/SaveRelocationPlan.php @@ -30,7 +30,7 @@ public function __construct( /** What the save also does, in words: for the question and the status line. */ public string $description { - get => sprintf('Saves standing where %s is now solid move to open floor: save content version %d relocates %s.', + get => sprintf('Saves standing where %s is now solid or taken by an NPC move to open floor: save content version %d relocates %s.', $this->mapId, $this->contentVersion, implode('; ', $this->describeMoves())); } diff --git a/src/Maps/SaveRelocationPlanner.php b/src/Maps/SaveRelocationPlanner.php index 9260038..da9d810 100644 --- a/src/Maps/SaveRelocationPlanner.php +++ b/src/Maps/SaveRelocationPlanner.php @@ -9,15 +9,19 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\Storage\SourceSetPlan; use Ichiloto\Engine\Events\Enumerations\CollisionType; +use Ichiloto\Engine\Field\MapSourceReader; +use Ichiloto\Engine\Field\NpcPlacement; use Ichiloto\Engine\Field\Reachability\ProjectReachability; use RuntimeException; use Throwable; /** - * Plans the save relocations a map save needs: when the unsaved map makes a - * cell solid that the player can reach in the saved map (new furniture, a - * wall, painted collision), a save standing there would load inside it. The - * plan moves saves on each such cell to the nearest open floor the player can + * Plans the save relocations a map save needs: when the unsaved map takes a + * cell the player can reach in the saved map, a save standing there would + * load inside it. A cell is taken when its new type blocks movement (new + * furniture, a wall, painted collision; walkable grass, exits and save points + * do not) or when an authored NPC now stands on it, added or moved. The plan + * moves saves on each such cell to the nearest plain floor the player can * reach today, as one declared `relocations` step in the project's save * compatibility manifest, for the author to review before the save writes * both. A project without a manifest, or a map not yet on disk, needs none. @@ -52,11 +56,15 @@ private static function buildPlan(ProjectMap $map, string $root): ?SaveRelocatio } $grid = $map->getResolvedCollisionMap(); - $open = static fn(int $x, int $y): bool => ($grid[$y][$x] ?? null) === CollisionType::NONE->value; + $npcs = self::readNpcCells((array) ($map->getEditableData()['npcs'] ?? [])); + $passable = static fn(int $x, int $y): bool => isset($grid[$y][$x]) && ! isset($npcs["{$x},{$y}"]) + && ! CollisionType::from($grid[$y][$x])->blocksMovement(); + // An NPC already standing there on disk is no change: reachability lets saves stand where a wanderer starts. + $arrived = array_diff_key($npcs, self::readNpcCells(self::readSavedNpcs($root, $map->mapId))); $blocked = []; foreach ($grid as $y => $row) { - foreach ($row as $x => $value) { - if ($value !== CollisionType::NONE->value && $report->isReachable($x, $y)) { + foreach (array_keys($row) as $x) { + if ($report->isReachable($x, $y) && (CollisionType::from($grid[$y][$x])->blocksMovement() || isset($arrived["{$x},{$y}"]))) { $blocked["{$x},{$y}"] = [$x, $y]; } } @@ -65,11 +73,13 @@ private static function buildPlan(ProjectMap $map, string $root): ?SaveRelocatio return null; } - $claimed = self::readClaimedCells($map); - $canLand = static fn(int $x, int $y): bool => $open($x, $y) && $report->isReachable($x, $y) && ! isset($claimed["{$x},{$y}"]); + // A save lands on plain floor: never on grass, an exit, a save point, an event or an NPC. + $events = self::readEventCells($map); + $canLand = static fn(int $x, int $y): bool => ($grid[$y][$x] ?? null) === CollisionType::NONE->value + && $passable($x, $y) && $report->isReachable($x, $y) && ! isset($events["{$x},{$y}"]); $groups = []; foreach ($blocked as [$x, $y]) { - $landing = self::findLanding($x, $y, $open, $canLand, $grid) + $landing = self::findLanding($x, $y, $passable, $canLand, $grid) ?? throw new RuntimeException(sprintf('Saves standing on x %d, y %d of %s would have no open floor to move to. Open a path to it, or undo the change, then save.', $x, $y, $map->mapId)); $groups[implode(',', $landing)]['to'] = $landing; @@ -81,7 +91,7 @@ private static function buildPlan(ProjectMap $map, string $root): ?SaveRelocatio try { $manifest = PhpDataFile::evaluateIsolated($manifestPath, $root); } catch (Throwable $failure) { - throw new RuntimeException(sprintf('%s could not be read, so the saves standing where %s is now solid cannot be moved: %s', + throw new RuntimeException(sprintf('%s could not be read, so the saves standing where %s is now taken cannot be moved: %s', SaveManifestStep::PATH, $map->mapId, $failure->getMessage()), previous: $failure); } $next = SaveManifestStep::append($source, $manifest, ['relocations' => $relocations]); @@ -100,15 +110,15 @@ private static function buildPlan(ProjectMap $map, string $root): ?SaveRelocatio } /** - * The nearest open floor a save can land on: first by walking from the - * cell's open neighbours, so the landing is on the same side of whatever - * was built, else the closest such cell anywhere. + * The nearest floor a save can land on: first by walking from the cell's + * passable neighbours, so the landing is on the same side of whatever was + * built, else the closest such cell anywhere. * - * @param callable(int, int): bool $open + * @param callable(int, int): bool $passable * @param callable(int, int): bool $canLand * @return array{int, int}|null */ - private static function findLanding(int $x, int $y, callable $open, callable $canLand, array $grid): ?array + private static function findLanding(int $x, int $y, callable $passable, callable $canLand, array $grid): ?array { $seen = ["{$x},{$y}" => true]; $queue = [[$x, $y]]; @@ -116,7 +126,7 @@ private static function findLanding(int $x, int $y, callable $open, callable $ca [$cx, $cy] = array_shift($queue); foreach ([[0, 1], [1, 0], [-1, 0], [0, -1]] as [$dx, $dy]) { [$nx, $ny] = [$cx + $dx, $cy + $dy]; - if (isset($seen["{$nx},{$ny}"]) || ! $open($nx, $ny)) { + if (isset($seen["{$nx},{$ny}"]) || ! $passable($nx, $ny)) { continue; } if ($canLand($nx, $ny)) { @@ -138,23 +148,47 @@ private static function findLanding(int $x, int $y, callable $open, callable $ca return $best === null ? null : [$best[0], $best[1]]; } - /** @return array Event markers and NPCs, where a loaded save must not stand. */ - private static function readClaimedCells(ProjectMap $map): array + /** @return array Event markers, where a loaded save must not stand. */ + private static function readEventCells(ProjectMap $map): array { - $claimed = []; + $events = []; for ($y = 0, $height = $map->getHeight(); $y < $height; $y++) { for ($x = 0, $width = $map->getWidth(); $x < $width; $x++) { if ($map->hasLayerCell(MapLayers::EVENT, $x, $y) && $map->getEventMarkerAt($x, $y) !== null) { - $claimed["{$x},{$y}"] = true; + $events["{$x},{$y}"] = true; } } } - foreach ((array) ($map->getEditableData()['npcs'] ?? []) as $npc) { - if (is_int($npc['x'] ?? null) && is_int($npc['y'] ?? null)) { - $claimed["{$npc['x']},{$npc['y']}"] = true; + + return $events; + } + + /** + * Where NPCs stand when the map loads. Every NPC starts on its authored + * cell and a wanderer never steps onto the player, so these are the only + * cells an NPC can hold under a loaded save. + * + * @param array $entries The map's `npcs` entries. + * @return array + */ + private static function readNpcCells(array $entries): array + { + $npcs = []; + foreach ($entries as $entry) { + $npc = NpcPlacement::fromArray($entry); + if ($npc !== null) { + $npcs["{$npc->x},{$npc->y}"] = true; } } - return $claimed; + return $npcs; + } + + /** @return array The map's `npcs` entries as saved on disk. */ + private static function readSavedNpcs(string $root, string $mapId): array + { + $source = MapSourceReader::readFiles(MapSourceReader::resolvePaths($root . '/assets/Maps', $mapId)); + + return (array) ($source['data']['npcs'] ?? []); } } diff --git a/src/Maps/TreeBrush.php b/src/Maps/TreeBrush.php index 2885e87..eeafd1d 100644 --- a/src/Maps/TreeBrush.php +++ b/src/Maps/TreeBrush.php @@ -27,10 +27,10 @@ * an occupancy stamp, its trunk. Drawing lays out the chosen types over the * region in an order fixed by the map and cell, so drawing the same region * again plants nothing new, and a region beside existing trees fills the gaps - * between them instead of crowding them. A trunk goes only on passable ground + * between them instead of crowding them. A trunk goes only on plain floor * that no event, NPC or other trunk claims, and never where it would cut a - * walkable area in two: every walkable cell the player could reach before - * stays reachable. The map's own declared collision makes each trunk solid; + * walkable area in two: every cell the player could walk to before, grass, + * exits and save points included, stays reachable. The map's own declared collision makes each trunk solid; * the image never does. * * The terminal sees the same forest on the `canopy` decoration layer, which @@ -194,21 +194,21 @@ private function plant(array $types, array $region, array &$entries, array &$wri foreach ($region as [$x, $y]) { $inRegion[$y][$x] = true; } - $passable = $this->readPassable(); + [$walkable, $floor] = $this->readGround(); $claimed = $this->readClaimedCells($entries); $anchors = $this->readTreeAnchors($entries); $planted = 0; foreach ($this->orderCells($region) as [$x, $y]) { $type = $types[$this->hash($x, $y, 'type') % count($types)]; - if (! $this->canPlant($type, $x, $y, $inRegion, $passable, $claimed, $anchors)) { + if (! $this->canPlant($type, $x, $y, $inRegion, $walkable, $floor, $claimed, $anchors)) { continue; } foreach ($type->occupancyStamp as $cell) { [$cx, $cy] = [$x + $cell['x'], $y + $cell['y']]; $writes[] = [$cx, $cy, $cell['type']]; $claimed[$cy][$cx] = true; - if ($cell['type'] !== CollisionType::NONE) { - unset($passable[$cy][$cx]); + if ($cell['type']->blocksMovement()) { + unset($walkable[$cy][$cx], $floor[$cy][$cx]); } } $anchors[] = [$x, $y, $type]; @@ -264,19 +264,22 @@ private function fell(array $region, array &$entries, array &$writes): array /** * Whether a tree fits with its anchor here: every stamped cell inside the - * region and on passable ground nothing claims, room from other trunks, - * and no solid cell cutting a walkable area in two. + * region and on plain floor nothing claims, room from other trunks, and no + * trunk cutting a walkable area in two. Trunks stand only on plain floor, + * so erasing a tree gives back exactly the floor it took; grass, exits and + * save points still count as walkable ground a trunk must not cut off. * * @param array> $inRegion - * @param array> $passable + * @param array> $walkable + * @param array> $floor * @param array> $claimed * @param list $anchors */ - private function canPlant(WorldObjectResource $type, int $x, int $y, array $inRegion, array $passable, array $claimed, array $anchors): bool + private function canPlant(WorldObjectResource $type, int $x, int $y, array $inRegion, array $walkable, array $floor, array $claimed, array $anchors): bool { foreach ($type->occupancyStamp as $cell) { [$cx, $cy] = [$x + $cell['x'], $y + $cell['y']]; - if (! isset($inRegion[$cy][$cx], $passable[$cy][$cx]) || isset($claimed[$cy][$cx])) { + if (! isset($inRegion[$cy][$cx], $floor[$cy][$cx]) || isset($claimed[$cy][$cx])) { return false; } } @@ -288,9 +291,8 @@ private function canPlant(WorldObjectResource $type, int $x, int $y, array $inRe return false; } } - $walkable = $passable; foreach ($type->occupancyStamp as $cell) { - if ($cell['type'] === CollisionType::NONE) { + if (! $cell['type']->blocksMovement()) { continue; } [$cx, $cy] = [$x + $cell['x'], $y + $cell['y']]; @@ -346,19 +348,26 @@ private static function isSimple(array $walkable, int $x, int $y): bool return true; } - /** @return array> Cells the player can stand on today. */ - private function readPassable(): array + /** + * @return array{0: array>, 1: array>} The cells the + * player can walk on today, and those of them that are plain floor. + */ + private function readGround(): array { - $passable = []; + $walkable = $floor = []; foreach ($this->map->getResolvedCollisionMap() as $y => $row) { foreach ($row as $x => $value) { - if ($value === CollisionType::NONE->value) { - $passable[$y][$x] = true; + $type = CollisionType::from($value); + if (! $type->blocksMovement()) { + $walkable[$y][$x] = true; + } + if ($type === CollisionType::NONE) { + $floor[$y][$x] = true; } } } - return $passable; + return [$walkable, $floor]; } /** @@ -457,7 +466,7 @@ private function drawCanopy(array $entries): void } foreach ($this->readTreeAnchors($entries) as [$x, $y, $type]) { foreach ($type->occupancyStamp as $cell) { - if ($cell['type'] !== CollisionType::NONE) { + if ($cell['type']->blocksMovement()) { $glyphs[$y + $cell['y']][$x + $cell['x']] = '|'; } } diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 015c178..511b442 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -4426,9 +4426,9 @@ public function saveMap(string $mapId, ?string $answer = null, ?string $confirm throw new SessionRefusal($failure->getMessage(), previous: $failure); } if ($relocation !== null) { - // Saves standing where the map is now solid move with the save, once the author has seen where. + // Saves standing where the map is now solid or taken by an NPC move with the save, once the author has seen where. if ($answer === null) { - return ['status' => 'question', 'map' => $mapId, 'question' => sprintf('Save %s and move existing saves off cells it makes solid?', $mapId), + return ['status' => 'question', 'map' => $mapId, 'question' => sprintf('Save %s and move existing saves off cells it makes solid or puts an NPC on?', $mapId), 'answers' => [ ['key' => 'cancel', 'label' => 'Cancel', 'description' => 'Save nothing.'], ['key' => 'write', 'label' => 'Save and move saves', 'description' => sprintf('Saves the map and writes %s. Undo removes the save step.', SaveManifestStep::PATH)], diff --git a/tests/Support/MapLineFixtures.php b/tests/Support/MapLineFixtures.php index e204a1a..46092e6 100644 --- a/tests/Support/MapLineFixtures.php +++ b/tests/Support/MapLineFixtures.php @@ -168,12 +168,16 @@ function openMapLinePaletteItem(Editor $editor, string $label): void ($item->action)(); } -/** The map line project with a collision dictionary, so the player's reach on `home` is known. */ +/** + * The map line project with a collision dictionary, so the player's reach on + * `home` is known. `g` paints walkable encounter grass and `P` a save point. + */ function mapLineRemovalProject(): string { $root = mapLineProject(); file_put_contents($root . '/assets/Maps/collisions.php', " CollisionType::SOLID, '.' => CollisionType::NONE, '~' => CollisionType::SOLID, ' ' => CollisionType::NONE];\n"); + . "return ['#' => CollisionType::SOLID, '.' => CollisionType::NONE, '~' => CollisionType::SOLID, ' ' => CollisionType::NONE," + . " 'g' => CollisionType::ENCOUNTER, 'P' => CollisionType::SAVE_POINT];\n"); // The start and the town arrival stand in a gap in the bottom wall, so the floor is reachable from them. file_put_contents($root . '/assets/Maps/home/layers/01.terrain.map.php', MapGridSource::buildSource( "######\n#....#\n#.~~.#\n#....#\n#..###", 'AUTHORED', "// keep layers/01.terrain.map.php\n")); diff --git a/tests/Support/TreeBrushFixtures.php b/tests/Support/TreeBrushFixtures.php index c7c1eb0..240f355 100644 --- a/tests/Support/TreeBrushFixtures.php +++ b/tests/Support/TreeBrushFixtures.php @@ -13,8 +13,9 @@ * `counter` resource with no trunk stamp, which is not a tree. * * @param bool $declared Whether the map declares its own collision. + * @param list|null $rows Another 12 x 8 layout: `#` solid, `g` walkable encounter grass, anything else floor. */ -function createTreeBrushProject(bool $declared = true): string +function createTreeBrushProject(bool $declared = true, ?array $rows = null): string { $root = makeTemporaryProject('editor-tree-brush-'); $directory = $root . '/assets/Maps/test-map'; @@ -22,13 +23,17 @@ function createTreeBrushProject(bool $declared = true): string is_file($file) && unlink($file); } mkdir($directory . '/layers', 0o777, true); - $rows = ['############', '#..........#', '#.....#....#', '#.....#....#', '#.....#....#', '#.....#....#', '#..........#', '############']; + $rows ??= ['############', '#..........#', '#.....#....#', '#.....#....#', '#.....#....#', '#.....#....#', '#..........#', '############']; file_put_contents($directory . '/layers/01.terrain.map.php', MapGridSource::buildSource(implode("\n", $rows), 'MAP')); $events = array_fill(0, 8, str_repeat(' ', 12)); $events[2][9] = 'E'; file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource(implode("\n", $events), 'EVENTS')); $occupancy = array_map(static fn(string $row): array => array_map( - static fn(string $glyph): CollisionType => $glyph === '#' ? CollisionType::SOLID : CollisionType::NONE, str_split($row)), $rows); + static fn(string $glyph): CollisionType => match ($glyph) { + '#' => CollisionType::SOLID, + 'g' => CollisionType::ENCOUNTER, + default => CollisionType::NONE, + }, str_split($row)), $rows); $data = ['name' => 'Forest', 'region' => '', 'events' => ['E' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', 'data' => ['mode' => 'action', 'reusable' => true, 'script' => [['type' => 'show_text', 'text' => 'Hello']]]]], 'npcs' => [['id' => 'ranger', 'name' => 'Ranger', 'sprite' => 'r', 'x' => 2, 'y' => 2]]]; diff --git a/tests/Unit/Session/SaveRelocationTest.php b/tests/Unit/Session/SaveRelocationTest.php index 4d29016..10866ed 100644 --- a/tests/Unit/Session/SaveRelocationTest.php +++ b/tests/Unit/Session/SaveRelocationTest.php @@ -88,6 +88,37 @@ function readSaveManifest(string $root): array expect($session->saveMap('home'))->toHaveKey('saved'); }); +it('moves no save off walkable grass or a save point, and moves saves off grass that becomes solid', function () { + $root = mapLineRemovalProject(); + $session = EditorSession::open($root); + $paint = static function (array $cells, string $glyph) use ($session): void { + $map = $session->readMap('home'); + $session->paint('home', $map['revision'], $map['baseLayer'], $cells, $glyph); + }; + + // New grass and a new save point are walkable: nothing to move. + $paint([[3, 1]], 'g'); + $paint([[1, 1]], 'P'); + expect($session->saveMap('home'))->toHaveKey('saved'); + + // Saved grass and save points the map keeps are not new blockers either. + $paint([[2, 1]], ' '); + expect($session->saveMap('home'))->toHaveKey('saved'); + + // Grass that becomes solid does move the saves on it. + $paint([[3, 1]], '#'); + expect($session->saveMap('home')['relocations'])->toBe([['map' => 'home', 'cells' => [[3, 1]], 'to' => [2, 1]]]); +}); + +it('moves saves off floor an NPC now stands on, and lands them on no other NPC', function () { + $root = mapLineRemovalProject(); + $session = EditorSession::open($root); + // The lamp moves from (1, 3) onto open floor at (3, 3); the cat still stands at (2, 3). + $session->moveNpc('home', $session->readMap('home')['revision'], 2, 3, 3); + + expect($session->saveMap('home')['relocations'])->toBe([['map' => 'home', 'cells' => [[3, 3]], 'to' => [4, 2]]]); +}); + it('leaves a map that would move saves out of Save All, for its own reviewed save', function () { [$root, $session] = openWalledHomeSession(); $workspace = new ReflectionProperty(EditorSession::class, 'workspace')->getValue($session); diff --git a/tests/Unit/Session/TreeBrushTest.php b/tests/Unit/Session/TreeBrushTest.php index aed0816..9aef3b8 100644 --- a/tests/Unit/Session/TreeBrushTest.php +++ b/tests/Unit/Session/TreeBrushTest.php @@ -128,6 +128,20 @@ function isWalkableConnected(\Ichiloto\Editor\ProjectMap $map): bool ->and($map->getResolvedCollisionMap()[6][6])->toBe(CollisionType::NONE->value); }); +it('counts grass as walkable ground, so a trunk never closes a path through it', function () { + // Two rooms joined only by grass, floor and grass at y 3. + $session = EditorSession::open(createTreeBrushProject(true, ['############', '#...###....#', '#...###....#', '#...g.g....#', + '#...###....#', '#...###....#', '#...###....#', '############'])); + $map = getPhysicalOccupancyMap($session); + + $drawn = $session->drawTrees('test-map', $session->readMap('test-map')['revision'], [[4, 3], [5, 3], [6, 3]], ['oak']); + + // The floor cell between the grass would cut the rooms apart; the grass itself is no place for a trunk. + expect($drawn['planted'])->toBe(0) + ->and(array_slice($map->getResolvedCollisionMap()[3], 4, 3)) + ->toBe([CollisionType::ENCOUNTER->value, CollisionType::NONE->value, CollisionType::ENCOUNTER->value]); +}); + it('erases the trees standing in a region, clearing their trunks and crowns, and undo brings them back', function () { [$session, $map] = openTreeBrushSession(); $drawn = $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(1, 1, 10, 6), ['oak']); From c27ce9e22cb181ea3caeb36a3e2069c67f0e3eed Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 12 Oct 2026 04:43:15 +0200 Subject: [PATCH 265/344] feat(cutscenes): let the session answer a cinematic preview's dialogue and choices The terminal editor answers a waiting line or choice from the preview pane, but the session offered no such control, so the GUI preview could only wait. The session now reports what the preview waits on and takes confirm and choice controls over the existing protocol. The battle wait line loses its em dash. --- .../Preview/CinematicPreviewSession.php | 8 ++- src/Cutscenes/Preview/PreviewPresentation.php | 13 +++++ src/Session/EditorSession.php | 11 ++-- src/Session/SessionHost.php | 3 +- .../Session/CinematicPreviewControlTest.php | 51 +++++++++++++++++++ 5 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/Cutscenes/Preview/CinematicPreviewSession.php b/src/Cutscenes/Preview/CinematicPreviewSession.php index 1cce97e..2d325ec 100644 --- a/src/Cutscenes/Preview/CinematicPreviewSession.php +++ b/src/Cutscenes/Preview/CinematicPreviewSession.php @@ -253,6 +253,12 @@ public function moveChoice(int $delta): bool return $this->presentation->moveHighlight($delta); } + /** What waits on the author: `dialogue`, `choice`, or null. */ + public function getWaitKind(): ?string + { + return $this->presentation->getWaitKind(); + } + /** * Plays to the end without waiting on dialogue, choosing the first option * of every choice and resolving battles as configured. @@ -407,7 +413,7 @@ public function skipRefusalReason(): ?string public function waitDescription(): ?string { if ($this->status() === self::STATUS_BATTLE) { - return sprintf('Battle in progress — resolved as %s on the next step (preview assumption)', $this->battleOutcomeLabel()); + return sprintf('Battle in progress: resolved as %s on the next step (preview assumption)', $this->battleOutcomeLabel()); } return $this->presentation->describeWait(); diff --git a/src/Cutscenes/Preview/PreviewPresentation.php b/src/Cutscenes/Preview/PreviewPresentation.php index b6de73c..0696bb8 100644 --- a/src/Cutscenes/Preview/PreviewPresentation.php +++ b/src/Cutscenes/Preview/PreviewPresentation.php @@ -131,6 +131,19 @@ public function isWaiting(): bool return ($this->dialogue !== null || $this->choice !== null) && ! $this->confirmed; } + /** + * What the author answers now: `choice` (move the highlight, then + * confirm), `dialogue` (confirm), or null when nothing waits. + */ + public function getWaitKind(): ?string + { + return match (true) { + ! $this->isWaiting() => null, + $this->choice !== null => 'choice', + default => 'dialogue', + }; + } + /** * A short line for the controls strip. */ diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 511b442..63d9202 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -2406,15 +2406,17 @@ public function startCinematicPreview(int $index, int $width, int $height, bool /** * Plays, pauses, steps, advances while playing, skips, or restarts the - * cinematic previewed. An advance is the time since the last one, so the + * cinematic previewed, or answers what it waits on: `confirm` turns the + * dialogue's page or picks the highlighted option, `choice` moves that + * highlight by `delta`. An advance is the time since the last one, so the * preview keeps real time however often it is asked. An accepted skip * plays the authored finalizer, even when the preview was paused. * - * @param 'play'|'pause'|'step'|'tick'|'skip'|'restart' $action + * @param 'play'|'pause'|'step'|'tick'|'skip'|'restart'|'confirm'|'choice' $action * @return array The preview, as {@see describeCinematicPreview()}. * @throws SessionRefusal When no cinematic is previewed, the action is unknown, or a skip is refused. */ - public function controlCinematicPreview(string $action, float $seconds = 0.0): array + public function controlCinematicPreview(string $action, float $seconds = 0.0, int $delta = 0): array { $preview = $this->cinematicPreview ?? throw new SessionRefusal('No cinematic is being previewed.'); if ($action === 'skip') { @@ -2433,6 +2435,8 @@ public function controlCinematicPreview(string $action, float $seconds = 0.0): a 'step' => $preview->step(), 'tick' => $preview->tick(max(0.0, min($seconds, 1.0))), 'restart' => $this->cinematicPreview = $preview->restart(), + 'confirm' => $preview->confirm(), + 'choice' => $preview->moveChoice($delta), default => throw new SessionRefusal(sprintf('A cinematic preview cannot %s.', $action)), }; @@ -2628,6 +2632,7 @@ private function describeCinematicPreview(): array 'finished' => $preview->isFinished(), 'failure' => $preview->failure()['message'] ?? null, 'wait' => $preview->waitDescription(), + 'waitingFor' => $preview->getWaitKind(), ]; } /** diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 06ce2b3..38234fb 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -487,7 +487,8 @@ private function dispatch(string $method, array $params): array 'cutscenes.cinematicPreview' => $session->startCinematicPreview(self::requireInt($params, 'index'), self::requireInt($params, 'width'), self::requireInt($params, 'height'), ($params['play'] ?? false) === true, ($params['keep'] ?? false) === true), 'cutscenes.cinematicControl' => $session->controlCinematicPreview(self::requireString($params, 'action'), - is_int($params['seconds'] ?? null) || is_float($params['seconds'] ?? null) ? (float) $params['seconds'] : 0.0), + is_int($params['seconds'] ?? null) || is_float($params['seconds'] ?? null) ? (float) $params['seconds'] : 0.0, + is_int($params['delta'] ?? null) ? $params['delta'] : 0), 'cutscenes.cinematicStop' => $session->stopCinematicPreview(), 'cutscenes.cinematicScene' => $session->exchangeCinematicScene(self::requireLines($params, 'events'), self::readOptionalString($params, 'sessionId')), 'cutscenes.cinematicSceneDetach' => $session->detachCinematicScene(), diff --git a/tests/Unit/Session/CinematicPreviewControlTest.php b/tests/Unit/Session/CinematicPreviewControlTest.php index 734379a..67d5d29 100644 --- a/tests/Unit/Session/CinematicPreviewControlTest.php +++ b/tests/Unit/Session/CinematicPreviewControlTest.php @@ -185,3 +185,54 @@ function readControlledCinematic(EditorSession $session): CinematicPreviewSessio $session->close(); } }); + +it('answers waiting dialogue and choices through the session, as the terminal editor does', function () { + $root = cutsceneProject(); + file_put_contents($root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php', <<<'PHP_SOURCE' + 'text', 'name' => 'Keeper', 'text' => 'Mind the step.'], + ['type' => 'choice', 'prompt' => 'Go on?', 'options' => [ + ['text' => 'Yes', 'then' => [['type' => 'set_switch', 'name' => 'went_on', 'value' => true]]], + ['text' => 'No', 'then' => [['type' => 'set_switch', 'name' => 'stayed', 'value' => true]]], + ]], +]; +PHP_SOURCE); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + try { + $session->startCinematicPreview(0, 40, 12); + $session->controlCinematicPreview('play'); + expect($session->controlCinematicPreview('tick', 0.1))->toMatchArray(['status' => 'waiting', 'waitingFor' => 'dialogue']); + + $session->controlCinematicPreview('confirm'); + expect($session->controlCinematicPreview('tick', 0.1))->toMatchArray(['waitingFor' => 'choice']); + + $session->controlCinematicPreview('choice', delta: 1); + $session->controlCinematicPreview('confirm'); + $session->controlCinematicPreview('tick', 0.1); + expect(readControlledCinematic($session)->snapshot()->values['switch stayed'] ?? null)->toBeTrue() + ->and(readControlledCinematic($session)->snapshot()->values)->not->toHaveKey('switch went_on') + ->and(sourceHashTree($root))->toBe($before); + } finally { + $session->close(); + } +}); + +it('takes the choice step from the host request', function () { + $root = cutsceneProject(); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params])); + $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + $session = new ReflectionProperty($host, 'session')->getValue($host); + try { + $request('cutscenes.cinematicPreview', ['index' => 0, 'width' => 40, 'height' => 12]); + expect($request('cutscenes.cinematicControl', ['action' => 'choice', 'delta' => 1]))->toHaveKey('result') + ->and($request('cutscenes.cinematicControl', ['action' => 'confirm']))->toHaveKey('result') + ->and($request('cutscenes.cinematicControl', ['action' => 'dance'])['error']['kind'])->toBe('refusal'); + } finally { + $session->close(); + } +}); From a4619ee9b627ca63d2ed1d6969d84d274ce5ee56 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 12 Oct 2026 06:36:11 +0200 Subject: [PATCH 266/344] fix(cutscenes): page preview dialogue on the text screen, not the field camera Once the graphical field attached, the preview's camera counted its few physical ground cells, and dialogue pages, choice boxes and the Terminal window were measured on it, so a 78-column page shrank to 16 columns and short lines wrapped. The preview now keeps its own text screen size, as the game pages its text box on the console screen, and writes its window there. --- src/Cutscenes/Preview/PreviewField.php | 3 +- src/Cutscenes/Preview/PreviewPresentation.php | 34 +++++++++--- .../CinematicNarrationPresentationTest.php | 54 +++++++++++++++++++ 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/Cutscenes/Preview/PreviewField.php b/src/Cutscenes/Preview/PreviewField.php index e5464cb..d17ee16 100644 --- a/src/Cutscenes/Preview/PreviewField.php +++ b/src/Cutscenes/Preview/PreviewField.php @@ -78,7 +78,7 @@ public static function open(string $projectRoot, string $mapId, Vector2 $spawn, $field->scene = new PreviewGameScene(new PreviewSceneManager(), $field->screenWidth, $field->screenHeight, $mapId); $field->scene->installFieldEffects(EffectPresentation::TERMINAL); $camera = $field->scene->previewCamera(); - $field->presentation = new PreviewPresentation($camera); + $field->presentation = new PreviewPresentation($field->screenWidth, $field->screenHeight); $player = new PreviewPlayer($field->scene, $spawn); $field->scene->installPlayer($player); try { @@ -227,6 +227,7 @@ public function resize(int $width, int $height): void $this->screenWidth = max(20, $width); $this->screenHeight = max(8, $height); $this->scene->previewCamera()->resize($this->screenWidth, $this->screenHeight); + $this->presentation->resizeScreen($this->screenWidth, $this->screenHeight); } /** diff --git a/src/Cutscenes/Preview/PreviewPresentation.php b/src/Cutscenes/Preview/PreviewPresentation.php index 0696bb8..1db4c5e 100644 --- a/src/Cutscenes/Preview/PreviewPresentation.php +++ b/src/Cutscenes/Preview/PreviewPresentation.php @@ -7,6 +7,7 @@ use Closure; use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\Events\Interpreter\EventDialoguePresentationInterface; +use Ichiloto\Engine\IO\Console\Console; use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\Messaging\Dialogue\Presentation\DialogueContext; use Ichiloto\Engine\Messaging\Dialogue\Presentation\DialoguePageLayout; @@ -26,6 +27,11 @@ * own windows are ({@see getActivePresentations()}), so a graphical preview * draws it the game's way, and is drawn into the Terminal picture on its own * UI layer. + * + * Pages and boxes are measured on the preview's text screen, as the game's + * dialogue is on its console screen, never on the field camera: a graphical + * field's camera counts physical ground cells, which are far fewer than the + * text cells a page is laid out in. */ final class PreviewPresentation implements EventDialoguePresentationInterface { @@ -43,10 +49,21 @@ final class PreviewPresentation implements EventDialoguePresentationInterface /** @var array */ public array $log = []; - public function __construct(private readonly PreviewCamera $camera) + /** + * @param int $screenWidth The preview's text screen, in columns. + * @param int $screenHeight The preview's text screen, in rows. + */ + public function __construct(private int $screenWidth, private int $screenHeight) { } + /** Takes the preview's new text screen size; the next line is paged and placed on it. */ + public function resizeScreen(int $width, int $height): void + { + $this->screenWidth = $width; + $this->screenHeight = $height; + } + public function beginText(string $text, string $name = ''): void { $this->beginDialogue($text, $name, new DialogueContext()); @@ -57,7 +74,7 @@ public function beginDialogue(string $text, string $speaker, DialogueContext $co $this->reset(); $layout = $this->pageLayout === null ? null : ($this->pageLayout)($speaker, $context, ''); $this->dialogue = new PreviewDialogueOwner(DialoguePaginationBuilder::buildPagination($text, $speaker, '', $context, - $this->camera->screen->getWidth(), $this->camera->screen->getHeight(), $layout)); + $this->screenWidth, $this->screenHeight, $layout)); $this->log[] = ['kind' => 'text', 'text' => $text, 'speaker' => $speaker]; } @@ -68,7 +85,7 @@ public function beginChoice(string $prompt, array $options, string $title = ''): $width = $this->getBoxWidth(); $height = count($options) + count(TerminalText::wrapToWidth($prompt, $width - 4)) + ($title === '' ? 2 : 3); $this->choice = new PreviewChoiceOwner($title, $prompt, $options, 0, new Rect( - max(0, intdiv($this->camera->screen->getWidth() - $width, 2)), max(0, $this->camera->screen->getHeight() - $height - 1), $width, $height)); + max(0, intdiv($this->screenWidth - $width, 2)), max(0, $this->screenHeight - $height - 1), $width, $height)); $this->log[] = ['kind' => 'choice', 'text' => $prompt, 'speaker' => $title]; } @@ -232,13 +249,16 @@ private function drawBox(): void } $rows[] = $border; - $width = $this->camera->screen->getWidth(); - $height = $this->camera->screen->getHeight(); - $this->camera->draw($rows, max(0, intdiv($width - $boxWidth, 2)), max(0, $height - count($rows) - 1)); + // A screen window, as the game's text box is: on the text screen, not clipped to the field camera. + $x = max(0, intdiv($this->screenWidth - $boxWidth, 2)); + $y = max(0, $this->screenHeight - count($rows) - 1); + foreach (array_slice($rows, 0, $this->screenHeight) as $index => $row) { + Console::write(TerminalText::truncateToWidth($row, $this->screenWidth - $x), $x, min($y + $index, $this->screenHeight - 1)); + } } private function getBoxWidth(): int { - return $this->dialogue?->pagination->windowWidth ?? max(12, min($this->camera->screen->getWidth(), 60)); + return $this->dialogue?->pagination->windowWidth ?? max(12, min($this->screenWidth, 60)); } } diff --git a/tests/Unit/Cutscenes/CinematicNarrationPresentationTest.php b/tests/Unit/Cutscenes/CinematicNarrationPresentationTest.php index 860cccb..d2cd4b0 100644 --- a/tests/Unit/Cutscenes/CinematicNarrationPresentationTest.php +++ b/tests/Unit/Cutscenes/CinematicNarrationPresentationTest.php @@ -196,3 +196,57 @@ function acknowledgeNarrationPreviewFrame(array $frame): string $preview->dispose(); } }); + +/** A second, plainer dialogue theme, so the measure is the preview's screen and not one theme's metrics. */ +function writePlainDialogueTheme(string $root): void +{ + file_put_contents($root . '/assets/Data/Presentation/dialogue.php', ' 'ichiloto.dialogue/1', + 'theme' => ['schema' => 'ichiloto.menu/1', 'colors' => ['text' => [240, 240, 240]], + 'frames' => ['dialogue' => ['asset' => 'Graphics/UI/narration-frame.png', 'cuts' => [4, 4, 4, 4]], + 'nameplate' => ['asset' => 'Graphics/UI/narration-frame.png', 'cuts' => [4, 4, 4, 4]]]], + ], true) . ';'); +} + +it('pages graphical dialogue on the wide text screen, not the few physical cells the field camera counts', function (bool $plainTheme, string $speaker) { + if ($plainTheme) { + writePlainDialogueTheme($this->root); + } + $definition = CinematicDefinition::fromArrays(['id' => 'preview-talk', 'name' => 'Preview Talk', 'startMap' => 'skyfield-night'], [ + // The line begins after READY has selected the graphical field, whose camera counts physical cells. + ['type' => 'wait', 'seconds' => 0.2], + ['type' => 'text', 'name' => $speaker, 'text' => "Second route selected\nThen the bridge."], + ['type' => 'choice', 'prompt' => 'Which way now?', 'options' => [ + ['text' => 'Along the river bank', 'then' => []], + ['text' => 'Over the hill', 'then' => []], + ]], + ]); + $preview = CinematicPreviewSession::start($this->root, $definition, ['x' => 10, 'y' => 8, 'width' => 100, 'height' => 30]); + + try { + $preview->step(); + $first = getNarrationPreviewFrame($preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId())); + $preview->play(); + $preview->tick(0.2); + $preview->tick(0.1); + $frame = getNarrationPreviewFrame($preview->exchangeScene([acknowledgeNarrationPreviewFrame($first)], $preview->getSceneSessionId())); + $prose = array_find(getNarrationPreviewObjects($frame, 'canvas_text'), static fn(array $layer): bool => str_ends_with($layer['id'], 'dialogue-text')); + + // Each authored line stays whole; only the authored line break divides them. + expect($prose)->not->toBeNull() + ->and(array_column($prose['runs'], 'text'))->toBe(['Second route selected', 'Then the bridge.']); + + // The Terminal picture places the same window on the text screen, whole. + $preview->detachScene(); + $terminal = implode("\n", array_map(TerminalText::stripAnsi(...), $preview->frame())); + expect($terminal)->toContain('Second route selected', 'Then the bridge.'); + + $preview->confirm(); + $preview->tick(0.1); + $terminal = implode("\n", array_map(TerminalText::stripAnsi(...), $preview->frame())); + expect($preview->getWaitKind())->toBe('choice') + ->and($terminal)->toContain('Which way now?', '> Along the river bank', 'Over the hill'); + } finally { + $preview->dispose(); + } +})->with(['menu theme' => false, 'plain theme' => true])->with(['no speaker' => '', 'speaker' => 'Keeper']); From b9afff5c32ac6f296833dfe53f13f1234d8c263f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 12 Oct 2026 07:29:10 +0200 Subject: [PATCH 267/344] feat(world-objects): let any world object choose a field resource, signs included An object created at a cell had no Field Resource row, so neither editor could turn it into a named placement such as a sign. Every world object now offers the resource picker: choosing one makes it a placement, a sign starting on its board's name as Sign Text, and choosing none gives it its own art again. The edit goes through the existing source-preserving apply, Undo and save paths. --- docs/manual.md | 10 ++++-- src/Field/WorldObjectAuthoring.php | 16 ++++++++-- tests/Unit/Session/SignAuthoringTest.php | 40 ++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 4829271..abdcf99 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1167,6 +1167,10 @@ starts on the project's first image for the author to replace, an id is refused as it is typed if the Engine would refuse it, and a save the Engine would refuse writes nothing. A map's world object that names a resource shows a Field Resource picker in place of its own pivot and art, which the resource owns. +Every world object offers that picker, a new one included: choosing a +resource makes the object a placement of it, dropping its own pivot, art and +variants, and choosing none gives it back an art of its own, starting empty +on the ground, as a newly created object does. A resource shows an image or a sign board (Shows). A sign board names its board image, cut into fixed left and right caps and a centre that repeats so @@ -1187,8 +1191,10 @@ text its board shows. Its label is translatable from the start: the text is the default, under a translation key of the sign's own id (`signs.`), which translations supply. A sign without text, or text on what is not a sign, is refused before anything is written; changing a -placement's resource to a sign gives it a label, and changing it away removes -it. Neither the label nor the board changes the map's collision. The GUI +placement's resource to a sign gives it a label, starting on the board's name +for the author to change under Sign Text, and changing it away removes it. So +a sign can also be made by creating an object at a cell and choosing its board +as the Field Resource. Neither the label nor the board changes the map's collision. The GUI editor's Game view draws a placed sign's board and label as the game does, laid out by the window in its text role; until the label is laid out, the sign's cells show their glyphs. diff --git a/src/Field/WorldObjectAuthoring.php b/src/Field/WorldObjectAuthoring.php index a433a1d..100f476 100644 --- a/src/Field/WorldObjectAuthoring.php +++ b/src/Field/WorldObjectAuthoring.php @@ -160,11 +160,16 @@ public function getRecords(): ProjectRecordDatabase fieldsFor: function (array $entry) use ($signs): array { // A placement of a named resource takes its art and pivot from the resource; it chooses which one, and a // sign placement the text its board shows: a translatable message, and the text where none is given. + // An object with its own art may become a placement by choosing a resource, and a placement choosing + // none goes back to its own art. + $resource = new RecordField('resource', 'Field Resource', reference: 'field_resources', allowsNone: true, + removeWhenEmpty: true, displayDefault: '(its own art)'); $presentation = array_key_exists('resource', $entry) - ? [new RecordField('resource', 'Field Resource', reference: 'field_resources'), + ? [$resource, ...(isset($signs[$entry['resource']]) ? [new RecordField('label.default', 'Sign Text'), new RecordField('label.message', 'Sign Text Translation Key')] : [])] - : [new RecordField('pivot.x', 'Image Pivot X (0..1)', InputControlType::FLOAT), + : [$resource, + new RecordField('pivot.x', 'Image Pivot X (0..1)', InputControlType::FLOAT), new RecordField('pivot.y', 'Image Pivot Y (0..1)', InputControlType::FLOAT), ...FieldSpriteFields::getFields($entry, true)]; $fields = [new RecordField('id', 'Stable Map-local Id', isReadOnly: true), @@ -189,6 +194,13 @@ public function getRecords(): ProjectRecordDatabase $sign = $signs[$entry['resource'] ?? ''] ?? null; if ($sign === null) { unset($entry['label']); } else { $entry['label'] ??= self::makeLabel((string) $entry['id'], $sign); } + // A placement's art and pivot are its resource's; without one, the object holds its own, as a new one does. + if (array_key_exists('resource', $entry)) { + unset($entry['pivot'], $entry['sprites2d'], $entry['variants']); + } else { + $entry['pivot'] ??= ['x' => .5, 'y' => 1.0]; + $entry['sprites2d'] ??= null; + } } if ($field === 'covers.layer') { if (!isset($entry['covers']['layer'])) { unset($entry['covers']); } diff --git a/tests/Unit/Session/SignAuthoringTest.php b/tests/Unit/Session/SignAuthoringTest.php index d8993f0..3986eb0 100644 --- a/tests/Unit/Session/SignAuthoringTest.php +++ b/tests/Unit/Session/SignAuthoringTest.php @@ -176,3 +176,43 @@ function findSignPlacementRow(EditorSession $session, string $id, string $field) $apply('resource', 'lamp'); expect($session->readMap('test-map')['worldObjects'][0])->not->toHaveKey('label'); }); + +it('turns an object created at a cell into a sign placement through its Field Resource row, and back', function () { + [$root, $session] = createSignProject(); + $session->createWorldObject('test-map', $session->readMap('test-map')['revision'], 'gate-sign', 1, 1); + $apply = static function (string $field, string $value) use ($session): void { + $row = findSignPlacementRow($session, 'gate-sign', $field) ?? throw new RuntimeException('Missing row ' . $field); + $session->applyWorldObject('test-map', $session->readWorldObject('test-map', 'gate-sign')['revision'], 'gate-sign', $row['key'], $value); + }; + + // A new object has no art yet, and still offers the resource it may place. + expect(findSignPlacementRow($session, 'gate-sign', 'resource'))->not->toBeNull(); + + $apply('resource', 'wood-sign'); + $placed = $session->readMap('test-map')['worldObjects'][0]; + expect($placed)->toMatchArray(['id' => 'gate-sign', 'resource' => 'wood-sign', 'anchor' => ['x' => 1, 'y' => 1], + 'label' => ['message' => 'signs.gate-sign', 'default' => 'Wood Sign']]) + ->and($placed)->not->toHaveKeys(['pivot', 'sprites2d']); + + $apply('label.default', 'North Gate'); + expect($session->readMap('test-map')['worldObjects'][0]['label']['default'])->toBe('North Gate'); + + // It saves as the same placement. + $session->saveMap('test-map'); + $saved = (require $root . '/assets/Maps/test-map/test-map.data.php')['worldObjects'][0]; + expect($saved)->toMatchArray(['id' => 'gate-sign', 'resource' => 'wood-sign', + 'label' => ['message' => 'signs.gate-sign', 'default' => 'North Gate']]) + ->and($saved)->not->toHaveKeys(['pivot', 'sprites2d']); + + // Undo steps back through the text and the resource to the object as it was created. + $session->undo(); + $session->undo(); + expect($session->readMap('test-map')['worldObjects'][0])->toMatchArray(['id' => 'gate-sign', 'pivot' => ['x' => .5, 'y' => 1.0], 'sprites2d' => null]) + ->and($session->readMap('test-map')['worldObjects'][0])->not->toHaveKeys(['resource', 'label']); + + // Choosing no resource again makes it an object with no art, without its label. + $apply('resource', 'wood-sign'); + $apply('resource', ''); + expect($session->readMap('test-map')['worldObjects'][0])->toMatchArray(['pivot' => ['x' => .5, 'y' => 1.0], 'sprites2d' => null]) + ->and($session->readMap('test-map')['worldObjects'][0])->not->toHaveKeys(['resource', 'label']); +}); From 4d2f922e6fb9adcf85c4996580d488b6a34c82fe Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 12 Oct 2026 13:23:25 +0200 Subject: [PATCH 268/344] fix(maps): draw the tree brush's terminal forest on a gameplay layer The canopy layer was a decoration layer, which the terminal never draws, so trees were invisible in the terminal while their trunks blocked movement. The canopy is now a gameplay layer on top of the map's others; its glyphs never decide collision, which the map's own collision holds. Each tree covers the canopy cells it draws, so the graphical field shows its image there and the glyphs only when the image cannot be shown. A canopy left as decoration becomes gameplay the next time trees are drawn, even where none are planted. --- docs/manual.md | 13 ++- src/Maps/TreeBrush.php | 138 ++++++++++++++++++++------- tests/Unit/Session/TreeBrushTest.php | 57 ++++++++++- 3 files changed, 166 insertions(+), 42 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index abdcf99..f34a5b2 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1213,10 +1213,15 @@ the map's own collision, never through its image, so the brush asks you to Separate collision first on a map that does not have it yet. Erasing removes the trees standing in the region and makes their trunk cells walkable again, unless another tree still stands there. The terminal sees the same forest on a -`canopy` decoration layer the brush owns and redraws: bright green `(` `)` -crowns, as wide as the image above the trunk and narrowing upwards, over gray -`|` trunks; the Game view leaves decoration layers out, so they never hide the -images. A drawing that would take the map past its 128 world objects is +`canopy` gameplay layer the brush owns and redraws, on top of the map's other +layers: bright green `(` `)` crowns, as wide as the image above the trunk and +narrowing upwards, over gray `|` trunks. Its glyphs never decide collision, +which the map's own collision holds. Each tree covers the canopy cells it +draws, a crown shared by two trees going to the tree drawn first, so the +graphical field and the Game view show the tree's image there instead of its +glyphs, and the glyphs again if the image cannot be shown. A canopy left as a +decoration layer, which the terminal never draws, becomes gameplay the next +time trees are drawn on the map, even where none are planted. A drawing that would take the map past its 128 world objects is refused with the number that still fit. Each drawing or erasing is one undo step and is written on Save. The GUI editor's Trees tool chooses the types and covers the region with any brush shape, Shift or the eraser erasing; in the diff --git a/src/Maps/TreeBrush.php b/src/Maps/TreeBrush.php index eeafd1d..5dafc91 100644 --- a/src/Maps/TreeBrush.php +++ b/src/Maps/TreeBrush.php @@ -33,10 +33,14 @@ * exits and save points included, stays reachable. The map's own declared collision makes each trunk solid; * the image never does. * - * The terminal sees the same forest on the `canopy` decoration layer, which - * the brush owns: bright green `(` `)` crowns narrowing upwards over gray `|` - * trunks, redrawn wherever trees are drawn or erased. The graphical field - * leaves decoration layers out, so the crowns never hide the images. + * The terminal sees the same forest on the `canopy` gameplay layer, which the + * brush owns: bright green `(` `)` crowns narrowing upwards over gray `|` + * trunks, redrawn wherever trees are drawn or erased. Its glyphs never decide + * collision, which the map's own declared collision holds. Each tree covers + * the canopy cells it draws, so the graphical field shows the tree's image + * there instead of its glyphs, and the glyphs again if the image is + * unavailable. A canopy left as a decoration layer, which the terminal never + * draws, becomes gameplay on the next drawing. * * @package Ichiloto\Editor\Maps */ @@ -146,32 +150,39 @@ private function apply(string $label, callable $change): array if (! $map->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { throw new MapSourceRefusal('Trees need the map\'s own collision so their trunks can be solid. Separate collision first, then draw. Nothing was changed.'); } - $entries = $map->hasMapDataField([WorldObjectDefinition::DATA_KEY]) ? $map->getMapDataField([WorldObjectDefinition::DATA_KEY]) : []; + $standing = $map->hasMapDataField([WorldObjectDefinition::DATA_KEY]) ? $map->getMapDataField([WorldObjectDefinition::DATA_KEY]) : []; + $entries = $standing; $writes = []; [$planted, $removed] = $change($entries, $writes); - if ($planted === 0 && $removed === 0) { - return ['command' => null, 'planted' => 0, 'removed' => 0]; - } if (count($entries) > WorldObjectDefinition::MAX_OBJECTS) { $room = WorldObjectDefinition::MAX_OBJECTS - (count($entries) - $planted); throw new MapSourceRefusal(sprintf('A map holds at most %d world objects; this drawing needs %d more and only %d still fit. Draw a smaller region. Nothing was changed.', WorldObjectDefinition::MAX_OBJECTS, $planted, max(0, $room))); } - - try { - WorldObjectAuthoring::validateEntries($map, $entries, false); - } catch (InvalidArgumentException $invalid) { - throw new MapSourceRefusal($invalid->getMessage() . ' Nothing was changed.', previous: $invalid); + [$glyphs, $entries] = $this->layOutCanopy($entries); + $canopy = $this->findCanopyLayer(); + // Drawing where nothing grows still brings a stale canopy up to date. + $stale = $canopy === null ? $glyphs !== [] + : $canopy['decoration'] || $entries !== $standing || $this->findCanopyChanges($canopy['id'], $glyphs) !== []; + if ($planted === 0 && $removed === 0 && ! $stale) { + return ['command' => null, 'planted' => 0, 'removed' => 0]; } $before = $map->captureLayerSnapshot(); try { - if (! in_array(self::CANOPY_LAYER, array_column($map->getLayers(), 'name'), true)) { - $map->createLayer(self::CANOPY_LAYER, true); + if ($canopy === null) { + $map->createLayer(self::CANOPY_LAYER); + } elseif ($canopy['decoration']) { + $map->setLayerDecoration($canopy['id'], false); + } + try { + WorldObjectAuthoring::validateEntries($map, $entries, false); + } catch (InvalidArgumentException $invalid) { + throw new MapSourceRefusal($invalid->getMessage() . ' Nothing was changed.', previous: $invalid); } $map->setMapDataField([WorldObjectDefinition::DATA_KEY], $entries); $map->writePhysicalOccupancy($writes); - $this->drawCanopy($entries); + $this->drawCanopy($glyphs); } catch (Throwable $error) { $map->restoreLayerSnapshot($before); throw $error instanceof MapSourceRefusal ? $error : new MapSourceRefusal($error->getMessage() . ' Nothing was changed.', previous: $error); @@ -444,34 +455,80 @@ private function findUnusedId(array $entries, string $stem): string } /** - * Redraws the canopy layer from the standing trees: each crown spans the + * Lays out the canopy from the standing trees: each crown spans the * image's width in the row above its trunk and narrows evenly to one or two - * cells at the top of the image, and its trunk cells read `|`. + * cells at the top of the image, and its trunk cells read `|`. Where crowns + * overlap, the cell goes to the tree drawn first, and a trunk always to its + * own tree. Each tree covers exactly the canopy cells it owns. + * + * @return array{0: array>, 1: array} The canopy glyphs by row and column, and the entries with each tree's covers. */ - private function drawCanopy(array $entries): void + private function layOutCanopy(array $entries): array { - $glyphs = []; - foreach ($this->readTreeAnchors($entries) as [$x, $y, $type]) { - $width = self::getCellsWide($type); - $left = $x - (int) round($type->pivot->x * $width - 0.5); + $glyphs = $owners = []; + $width = $this->map->getWidth(); + $height = $this->map->getHeight(); + $inside = static fn(int $x, int $y): bool => $x >= 0 && $y >= 0 && $x < $width && $y < $height; + $trees = []; + foreach ($entries as $index => $entry) { + $type = $this->types[$entry['resource'] ?? ''] ?? null; + if ($type !== null && is_int($entry['anchor']['x'] ?? null) && is_int($entry['anchor']['y'] ?? null)) { + $trees[$index] = [$entry['anchor']['x'], $entry['anchor']['y'], $type]; + } + } + foreach ($trees as $index => [$x, $y, $type]) { + $wide = self::getCellsWide($type); + $left = $x - (int) round($type->pivot->x * $wide - 0.5); $rows = self::getCellsTall($type) - 1; - $narrowest = intdiv($width + 1, 2) - 1; + $narrowest = intdiv($wide + 1, 2) - 1; for ($row = 1; $row <= $rows; $row++) { $inset = intdiv(($row - 1) * $narrowest, max(1, $rows - 1)); - $span = $width - 2 * $inset; + $span = $wide - 2 * $inset; for ($i = 0; $i < $span; $i++) { - $glyphs[$y - $row][$left + $inset + $i] ??= $i < intdiv($span, 2) ? '(' : ')'; + [$cx, $cy] = [$left + $inset + $i, $y - $row]; + if ($inside($cx, $cy) && ! isset($glyphs[$cy][$cx])) { + $glyphs[$cy][$cx] = $i < intdiv($span, 2) ? '(' : ')'; + $owners[$cy][$cx] = $index; + } } } } - foreach ($this->readTreeAnchors($entries) as [$x, $y, $type]) { + foreach ($trees as $index => [$x, $y, $type]) { foreach ($type->occupancyStamp as $cell) { - if ($cell['type']->blocksMovement()) { - $glyphs[$y + $cell['y']][$x + $cell['x']] = '|'; + [$cx, $cy] = [$x + $cell['x'], $y + $cell['y']]; + if ($cell['type']->blocksMovement() && $inside($cx, $cy)) { + $glyphs[$cy][$cx] = '|'; + $owners[$cy][$cx] = $index; } } } - $layer = $this->findCanopyLayerId(); + $cells = []; + ksort($owners); + foreach ($owners as $cy => $row) { + ksort($row); + foreach ($row as $cx => $index) { + $cells[$index][] = [$cx, $cy]; + } + } + foreach ($trees as $index => $_) { + unset($entries[$index]['covers']); + if (isset($cells[$index])) { + $entries[$index]['covers'] = ['layer' => self::CANOPY_LAYER, 'cells' => $cells[$index], 'tileLayers' => []]; + } + } + + return [$glyphs, $entries]; + } + + /** + * The canopy cells whose glyph or style differs from the layout. + * + * @param array> $glyphs + * @return list Each cell's x, y, glyph, style prefix and suffix. + */ + private function findCanopyChanges(string $layer, array $glyphs): array + { + $changes = []; for ($y = 0, $height = $this->map->getHeight(); $y < $height; $y++) { for ($x = 0, $width = $this->map->getWidth(); $x < $width; $x++) { if (! $this->map->hasLayerCell($layer, $x, $y)) { @@ -484,19 +541,32 @@ private function drawCanopy(array $entries): void default => [self::CROWN_STYLE, ''], }; if ($this->map->getLayerSymbol($layer, $x, $y) !== $glyph || $this->map->getLayerCellStyle($layer, $x, $y) !== ['prefix' => $prefix, 'suffix' => $suffix]) { - $this->map->setLayerCell($layer, $x, $y, $glyph, $prefix, $suffix); + $changes[] = [$x, $y, $glyph, $prefix, $suffix]; } } } + + return $changes; + } + + /** Redraws the canopy layer's cells that differ from the layout. */ + private function drawCanopy(array $glyphs): void + { + $layer = $this->findCanopyLayer()['id'] ?? throw new MapSourceRefusal('The canopy layer is missing. Nothing was changed.'); + foreach ($this->findCanopyChanges($layer, $glyphs) as [$x, $y, $glyph, $prefix, $suffix]) { + $this->map->setLayerCell($layer, $x, $y, $glyph, $prefix, $suffix); + } } - private function findCanopyLayerId(): string + /** @return array{id: string, decoration: bool}|null */ + private function findCanopyLayer(): ?array { foreach ($this->map->getLayers() as $layer) { if ($layer['name'] === self::CANOPY_LAYER) { - return $layer['id']; + return $layer; } } - throw new MapSourceRefusal('The canopy layer is missing. Nothing was changed.'); + + return null; } } diff --git a/tests/Unit/Session/TreeBrushTest.php b/tests/Unit/Session/TreeBrushTest.php index 9aef3b8..57870fc 100644 --- a/tests/Unit/Session/TreeBrushTest.php +++ b/tests/Unit/Session/TreeBrushTest.php @@ -5,12 +5,13 @@ use Ichiloto\Editor\Session\EditorSession; use Ichiloto\Editor\Session\SessionRefusal; use Ichiloto\Engine\Events\Enumerations\CollisionType; +use Ichiloto\Engine\IO\Console\TerminalText; /** * The tree brush plants whole trees over a drawn region as one undo step: * trunks become solid in the map's own collision, never on events, NPCs or * walls, and never where they would cut a walkable area in two; the terminal - * sees the same trees on the brush's canopy layer. The fixture is + * sees the same trees on the brush's canopy gameplay layer. The fixture is * {@see createTreeBrushProject()}. */ @@ -104,22 +105,70 @@ function isWalkableConnected(\Ichiloto\Editor\ProjectMap $map): bool expect($draw())->toBe($draw()); }); -it('draws the trees for the terminal on its own canopy decoration layer', function () { +it('draws the trees for the terminal on its own canopy gameplay layer, which the terminal shows', function () { [$session, $map] = openTreeBrushSession(); $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(2, 4, 4, 6), ['oak']); $object = $session->readMap('test-map')['worldObjects'][0]; [$x, $y] = [$object['anchor']['x'], $object['anchor']['y']]; $layer = array_find($map->getLayers(), static fn(array $layer): bool => $layer['name'] === 'canopy'); + $terminal = $map->getLayerSet()->getComposedGrid(); - expect($layer['decoration'])->toBeTrue() + expect($layer['decoration'])->toBeFalse() ->and($map->getLayerSymbol($layer['id'], $x, $y))->toBe('|') ->and([$map->getLayerSymbol($layer['id'], $x - 1, $y - 1), $map->getLayerSymbol($layer['id'], $x, $y - 1), $map->getLayerSymbol($layer['id'], $x + 1, $y - 1)]) ->toBe(['(', ')', ')']) ->and($map->getLayerColor($layer['id'], $x, $y - 1))->toBe('bright-green') - // The terrain the player walks on is untouched. + // What the terminal draws is the tree, over the terrain it stands on. + ->and(TerminalText::stripAnsi($terminal[$y][$x]))->toBe('|') + ->and(TerminalText::stripAnsi($terminal[$y - 1][$x - 1]))->toBe('(') ->and($map->getLayerSymbol($map->getBaseLayerId(), $x, $y))->toBe('.'); }); +it('has each tree cover exactly the canopy cells it draws, so the graphical field shows its image there', function () { + [$session, $map] = openTreeBrushSession(); + $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(1, 1, 10, 6), ['oak', 'pair']); + $layer = array_find($map->getLayers(), static fn(array $layer): bool => $layer['name'] === 'canopy')['id']; + $drawn = $covered = []; + for ($y = 0; $y < 8; $y++) { + for ($x = 0; $x < 12; $x++) { + if ($map->getLayerSymbol($layer, $x, $y) !== ' ') { + $drawn[] = "{$x},{$y}"; + } + } + } + foreach ($session->readMap('test-map')['worldObjects'] as $object) { + expect($object['covers']['layer'])->toBe('canopy') + ->and($object['covers']['tileLayers'])->toBe([]) + ->and($object['covers']['cells'])->toContain([$object['anchor']['x'], $object['anchor']['y']]); + foreach ($object['covers']['cells'] as [$x, $y]) { + $covered[] = "{$x},{$y}"; + } + } + + // Every drawn cell is covered once, by one tree, and nothing else is. + expect($covered)->toHaveCount(count(array_unique($covered))) + ->and($covered)->toEqualCanonicalizing($drawn); +}); + +it('makes a canopy left as decoration, which the terminal never draws, gameplay on the next drawing', function () { + [$session, $map] = openTreeBrushSession(); + $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(1, 1, 10, 6), ['oak']); + $expected = $session->readMap('test-map')['worldObjects']; + $layer = array_find($map->getLayers(), static fn(array $layer): bool => $layer['name'] === 'canopy')['id']; + // As the brush once left it: a decoration layer, and trees covering nothing. + $map->setMapDataField(['worldObjects'], array_map(static function (array $object): array { + unset($object['covers']); + return $object; + }, $expected)); + $map->setLayerDecoration($layer, true); + + $again = $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(1, 1, 10, 6), ['oak']); + + expect($again['planted'])->toBe(0) + ->and(array_find($map->getLayers(), static fn(array $layer): bool => $layer['name'] === 'canopy')['decoration'])->toBeFalse() + ->and($session->readMap('test-map')['worldObjects'])->toBe($expected); +}); + it('keeps the only gap through a wall open', function () { [$session, $map] = openTreeBrushSession(); $session->drawTrees('test-map', $session->readMap('test-map')['revision'], [[6, 6], [5, 6], [7, 6]], ['oak']); From eb413b68cf85509132dc8dbc9214485b3fd2d124 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 12 Oct 2026 23:23:35 +0200 Subject: [PATCH 269/344] fix(maps): let shared cap tiles stay free and move saves off every walkable cell A tile a glyph-less piece also draws, such as a stone wall cap that also tops a stone wall, is now free: placed on a cell it plays the piece of the glyph already there, or none, so a cap beside a wall never becomes a wall, and validation no longer asks for its glyph. This lets a tileset add a stone wall piece whose top is the existing cap. Save relocation now also moves saves off walkable floor no route reaches today, since an older layout may have reached it; previously only cells reachable on disk were moved, which could leave an old save inside a new wall. --- docs/manual.md | 10 +++- src/Canvas/CanvasEditor.php | 8 +++- src/Canvas/TileGlyphBinding.php | 46 +++++++++++++++++-- src/Maps/SaveRelocationPlanner.php | 38 +++++++++++++-- src/Validation/MapGraphicsValidator.php | 7 +++ tests/Unit/MapGraphicsTest.php | 13 ++++++ tests/Unit/Session/SaveRelocationTest.php | 19 ++++++++ .../Session/TilesCarryGlyphsSessionTest.php | 35 ++++++++++++++ 8 files changed, 167 insertions(+), 9 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index f34a5b2..1bdc760 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -466,6 +466,10 @@ intact: map as it was. The brush remembers the answer for its glyph until a glyph is typed again, and the eyedropper picks up the piece a glyph draws. Tiles no piece accounts for, such as a floor under a wall, stay where they are. A + tile that a glyph-less piece also draws, such as a stone wall cap that also + tops the stone wall, is free: placed on a cell it plays the piece of the + glyph already there, or none, so a cap beside a wall never becomes a wall, + and validation never asks for its glyph. A piece that keeps the tiles beneath it, such as a window mounted on a wall face, leaves them when it arrives; erasing it gives the cell back to the piece those kept tiles stand for, such as the face's `#`, so the wall is @@ -2434,8 +2438,10 @@ Read-only categories are never included - they hold no edits. ### Saves follow what a map makes solid -When a map is saved after an edit takes floor that the player can reach in -the saved map, a save standing on such a cell would load inside it. A cell is +When a map is saved after an edit takes floor that the player could stand on +in the saved map, a save standing on such a cell would load inside it. That +includes walkable floor no route reaches today, since an older layout may have +reached it. A cell is taken when it becomes something that blocks movement (furniture, a wall, painted collision, trees) or when an NPC is added or moved onto it. Walkable grass, exits and save points take nothing, and an NPC that stays put moves no diff --git a/src/Canvas/CanvasEditor.php b/src/Canvas/CanvasEditor.php index 77c90e6..f423d57 100644 --- a/src/Canvas/CanvasEditor.php +++ b/src/Canvas/CanvasEditor.php @@ -422,7 +422,13 @@ private static function planStampGlyphs(ProjectMap $map, TileGlyphBinding $bindi // Then arriving: the glyph each placed tile stands for. foreach ($stamp as $position => $entry) { [$x, $y] = array_map(intval(...), explode(',', (string) $position)); - $bound = self::chooseBinding($binding->findBindings($tileLayer, $entry), $tileLayer, $x, $y, $stamp, $glyphAt, $choices[$entry] ?? null); + $bindings = $binding->findBindings($tileLayer, $entry); + if ($binding->isFree($tileLayer, $entry)) { + // A free tile plays the role of the glyph already on its cell, or none: it never brings a glyph. + $bindings = array_values(array_filter($bindings, static fn(TileBinding $bound): bool => + $glyphAt($bound->layerId, $x - $bound->dx, $y - $bound->dy) === $bound->glyph)); + } + $bound = self::chooseBinding($bindings, $tileLayer, $x, $y, $stamp, $glyphAt, $choices[$entry] ?? null); if ($bound instanceof TileBinding) { $arrive($bound, $x - $bound->dx, $y - $bound->dy); } elseif ($bound !== null) { diff --git a/src/Canvas/TileGlyphBinding.php b/src/Canvas/TileGlyphBinding.php index aa1f053..6f61885 100644 --- a/src/Canvas/TileGlyphBinding.php +++ b/src/Canvas/TileGlyphBinding.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\ProjectMap; +use Ichiloto\Engine\Rendering\Tilesets\TileId; use Ichiloto\Engine\Rendering\Tilesets\TilesetPiece; use InvalidArgumentException; use RuntimeException; @@ -17,15 +18,19 @@ * wall's tile for `-`, `|` and `+` on buildings. Those tiles and their glyph * must change together, so the glyph, its collision and its tiles never * disagree. A tile no role draws, such as a rug or a picture, stands for - * nothing in the terminal and is free to place anywhere. + * nothing in the terminal and is free to place anywhere. A tile that a + * glyph-less piece also draws, such as a stone wall cap that also tops the + * stone wall's own glyphs, is free too: placed on a cell it plays the role of + * the glyph already there, or none, and never brings a glyph of its own. */ final readonly class TileGlyphBinding { /** * @param array>> $bindings By tile layer name, then tile entry. * @param array $planners Each gameplay layer's planner, by layer id. + * @param array> $free Tile entries a glyph-less piece draws, by tile layer name. */ - private function __construct(private array $bindings, private array $planners) {} + private function __construct(private array $bindings, private array $planners, private array $free = []) {} /** The bindings of a map's tileset pieces, over the gameplay layers the map has. */ public static function fromMap(ProjectMap $map): self @@ -36,6 +41,7 @@ public static function fromMap(ProjectMap $map): self $pieces = []; } $bindings = $planners = []; + $free = self::findFreeEntries($pieces); foreach ($map->getLayers() as $layer) { if ($pieces === [] || $layer['id'] === MapLayers::EVENT || $layer['decoration']) { continue; @@ -56,7 +62,41 @@ public static function fromMap(ProjectMap $map): self } } - return new self($bindings, $planners); + return new self($bindings, $planners, $free); + } + + /** + * The tile entries glyph-less pieces draw, such as a rug or a wall cap, + * by tile layer name. Such a tile may stand on any cell without its glyph. + * + * @param iterable $pieces + * @return array> + */ + public static function findFreeEntries(iterable $pieces): array + { + $free = []; + foreach ($pieces as $piece) { + if ($piece->connects !== null || PieceRole::readPiece($piece) !== []) { + continue; + } + foreach ($piece->tiles as $tileLayer => $rows) { + foreach ($rows as $entries) { + foreach ($entries as $entry) { + if ($entry !== (string) TileId::EMPTY) { + $free[$tileLayer][$entry] = true; + } + } + } + } + } + + return $free; + } + + /** Whether a tile is free: one a glyph-less piece also draws, which needs no glyph of its own. */ + public function isFree(string $tileLayer, string $entry): bool + { + return isset($this->free[$tileLayer][$entry]); } /** @return list The glyphs a tile on a tile layer can stand for; none for a free tile. */ diff --git a/src/Maps/SaveRelocationPlanner.php b/src/Maps/SaveRelocationPlanner.php index da9d810..731abfd 100644 --- a/src/Maps/SaveRelocationPlanner.php +++ b/src/Maps/SaveRelocationPlanner.php @@ -9,6 +9,7 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\Storage\SourceSetPlan; use Ichiloto\Engine\Events\Enumerations\CollisionType; +use Ichiloto\Engine\Field\MapCollisionResolver; use Ichiloto\Engine\Field\MapSourceReader; use Ichiloto\Engine\Field\NpcPlacement; use Ichiloto\Engine\Field\Reachability\ProjectReachability; @@ -17,8 +18,8 @@ /** * Plans the save relocations a map save needs: when the unsaved map takes a - * cell the player can reach in the saved map, a save standing there would - * load inside it. A cell is taken when its new type blocks movement (new + * cell the player could stand on in the saved map, reachable today or not, a + * save standing there would load inside it. A cell is taken when its new type blocks movement (new * furniture, a wall, painted collision; walkable grass, exits and save points * do not) or when an authored NPC now stands on it, added or moved. The plan * moves saves on each such cell to the nearest plain floor the player can @@ -61,10 +62,14 @@ private static function buildPlan(ProjectMap $map, string $root): ?SaveRelocatio && ! CollisionType::from($grid[$y][$x])->blocksMovement(); // An NPC already standing there on disk is no change: reachability lets saves stand where a wanderer starts. $arrived = array_diff_key($npcs, self::readNpcCells(self::readSavedNpcs($root, $map->mapId))); + // Any cell walkable on disk may hold a save, even one no route reaches today: an older layout may have + // reached it, and a save made there must never load inside what now takes it. + $walkable = self::readSavedWalkableCells($root, $map->mapId); $blocked = []; foreach ($grid as $y => $row) { foreach (array_keys($row) as $x) { - if ($report->isReachable($x, $y) && (CollisionType::from($grid[$y][$x])->blocksMovement() || isset($arrived["{$x},{$y}"]))) { + if (($report->isReachable($x, $y) || isset($walkable["{$x},{$y}"])) + && (CollisionType::from($grid[$y][$x])->blocksMovement() || isset($arrived["{$x},{$y}"]))) { $blocked["{$x},{$y}"] = [$x, $y]; } } @@ -185,6 +190,33 @@ private static function readNpcCells(array $entries): array } /** @return array The map's `npcs` entries as saved on disk. */ + /** + * The cells of the map on disk that a player could stand on, by `x,y`: + * walkable by its saved collision, reachable or not. None when the + * project has no readable collision dictionary. + * + * @return array + */ + private static function readSavedWalkableCells(string $root, string $mapId): array + { + try { + $dictionary = MapSourceReader::loadCollisionDictionary($root . '/assets/Maps/collisions.php'); + } catch (Throwable) { + $dictionary = []; + } + $source = MapSourceReader::readFiles(MapSourceReader::resolvePaths($root . '/assets/Maps', $mapId)); + $walkable = []; + foreach (MapCollisionResolver::resolveMap($source['layers'], $source['data'], $dictionary)->collisionGrid as $y => $row) { + foreach ($row as $x => $value) { + if (! CollisionType::from($value)->blocksMovement()) { + $walkable["{$x},{$y}"] = true; + } + } + } + + return $walkable; + } + private static function readSavedNpcs(string $root, string $mapId): array { $source = MapSourceReader::readFiles(MapSourceReader::resolvePaths($root . '/assets/Maps', $mapId)); diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php index 3941b3e..184e681 100644 --- a/src/Validation/MapGraphicsValidator.php +++ b/src/Validation/MapGraphicsValidator.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\Canvas\GlyphTilePlanner; use Ichiloto\Editor\Canvas\PieceRole; +use Ichiloto\Editor\Canvas\TileGlyphBinding; use Ichiloto\Editor\Field\ProjectNpc; use Ichiloto\Editor\Field\WorldObjectAuthoring; use Ichiloto\Editor\Maps\MapLayers; @@ -233,6 +234,12 @@ private static function checkStalePieceTiles(ProjectMap $map, Tileset $tileset, } } } + // A tile a glyph-less piece also draws, such as a stone wall cap, may stand on any cell. + foreach (TileGlyphBinding::findFreeEntries($tileset->pieces) as $tileLayer => $entries) { + foreach ($entries as $entry => $_) { + unset($drawn[$tileLayer][(int) $entry]); + } + } // Every role a glyph on the map could play accounts for its tiles, // including those of a piece's blank cells beside it. diff --git a/tests/Unit/MapGraphicsTest.php b/tests/Unit/MapGraphicsTest.php index 5950c93..1c3177e 100644 --- a/tests/Unit/MapGraphicsTest.php +++ b/tests/Unit/MapGraphicsTest.php @@ -376,6 +376,19 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array expect(array_filter(graphicsIssueLines($root), static fn(string $line): bool => str_contains($line, 'no longer there')))->toBe([]); }); +it('never judges a tile that a glyph-less piece also draws, wherever it stands', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: [ + 'window' => ['name' => 'Window', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['decor' => ['5']]], + // A cap the window's tile also tops, placed anywhere without a glyph. + 'cap' => ['name' => 'Cap (visual)', 'layer' => 'buildings', 'glyphs' => [' '], 'tiles' => ['decor' => ['5']]], + ]); + // Window tiles stand on the x cells and, as caps, on blank cells beside them. + writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "5 0 0 5\n0 5 5 5"); + + expect(array_filter(graphicsIssueLines($root), static fn(string $line): bool => str_contains($line, 'no longer there')))->toBe([]); +}); + it('lists the cells showing the tileset\'s missing-art placeholder', function () { $root = mapGraphicsProject(); writeTestTileset($root, missingArt: 255); diff --git a/tests/Unit/Session/SaveRelocationTest.php b/tests/Unit/Session/SaveRelocationTest.php index 10866ed..b13675f 100644 --- a/tests/Unit/Session/SaveRelocationTest.php +++ b/tests/Unit/Session/SaveRelocationTest.php @@ -110,6 +110,25 @@ function readSaveManifest(string $root): array expect($session->saveMap('home')['relocations'])->toBe([['map' => 'home', 'cells' => [[3, 1]], 'to' => [2, 1]]]); }); +it('moves saves off walkable floor no route reaches today, since an older layout may have reached it', function () { + $root = mapLineRemovalProject(); + $session = EditorSession::open($root); + $paint = static function (array $cells, string $glyph) use ($session): void { + $map = $session->readMap('home'); + $session->paint('home', $map['revision'], $map['baseLayer'], $cells, $glyph); + }; + // Walling in (4, 1) leaves it walkable on disk but cut off. + $paint([[3, 1], [4, 2]], '#'); + $question = $session->saveMap('home'); + $session->saveMap('home', 'write', $question['confirm']); + + $paint([[4, 1]], '#'); + $relocations = $session->saveMap('home')['relocations']; + + expect(array_merge(...array_column($relocations, 'cells')))->toBe([[4, 1]]) + ->and($relocations[0]['to'])->not->toBe([4, 1]); +}); + it('moves saves off floor an NPC now stands on, and lands them on no other NPC', function () { $root = mapLineRemovalProject(); $session = EditorSession::open($root); diff --git a/tests/Unit/Session/TilesCarryGlyphsSessionTest.php b/tests/Unit/Session/TilesCarryGlyphsSessionTest.php index 5e294e6..eb247bb 100644 --- a/tests/Unit/Session/TilesCarryGlyphsSessionTest.php +++ b/tests/Unit/Session/TilesCarryGlyphsSessionTest.php @@ -127,6 +127,41 @@ function stampSessionTiles(EditorSession $session, string $layer, array $cells, ->and(readSessionBuildingRows($session))->toBe([' / ', ' xx ']); }); +/** Wood and stone walls, and a glyph-less cap that draws the stone wall's own tile. */ +function stoneCapSession(): EditorSession +{ + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: [ + 'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'connects' => 'lines', + 'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'], 'tiles' => ['walls' => '5888']], + 'stone-wall' => ['name' => 'Stone wall', 'layer' => 'buildings', 'connects' => 'lines', + 'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'], 'tiles' => ['walls' => '5936']], + 'stone-cap' => ['name' => 'Stone cap (visual)', 'layer' => 'buildings', 'glyphs' => [' '], 'tiles' => ['walls' => ['5936']]], + ]); + + return EditorSession::open($root); +} + +it('lets a tile a glyph-less piece also draws stand on a blank cell without bringing a glyph', function () { + $session = stoneCapSession(); + + expect(stampSessionTiles($session, 'walls', [[0, 0, 5936], [3, 1, 5936]]))->toMatchArray(['status' => 'applied', 'changed' => 2, 'glyphs' => 0]) + ->and(readSessionBuildingRows($session))->toBe([' / ', ' xx ']); +}); + +it('tops a wall with the shared tile as the wall whose glyph is already there, keeping the glyph', function () { + $session = stoneCapSession(); + // A wood wall running down the first column. + $session->paint('test-map', $session->readMap('test-map')['revision'], 'map:4', [[0, 0], [0, 1]], '|', null, ['|' => 'wall:vertical']); + expect(array_map(static fn(string $row): string => $row[0], readSessionBuildingRows($session)))->toBe(['|', '|']) + ->and(array_column(readSessionTiles($session, 'walls'), 0))->toBe([5888, 5888]); + + // The wood wall's tiles leave with their role, and the stone wall arrives on the same glyphs. + stampSessionTiles($session, 'walls', [[0, 0, 5936], [0, 1, 5936]]); + expect(array_map(static fn(string $row): string => $row[0], readSessionBuildingRows($session)))->toBe(['|', '|']) + ->and(array_column(readSessionTiles($session, 'walls'), 0))->toBe([5936, 5936]); +}); + it('asks which glyph a tile shared by several pieces stands for, changing nothing until answered', function () { $session = tilesCarryGlyphsSession(); From bd61b35389a61d1c6ea1064229ed57b6631c81f7 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 12 Oct 2026 23:31:04 +0200 Subject: [PATCH 270/344] fix(maps): skip relocations on maps the player reaches nowhere today Moving saves off unreachable walkable cells needs reachable floor to land on; a map with none holds no save a relocation could place, so those cells are skipped instead of refusing the save. A reachable cell without a landing is still refused. --- src/Maps/SaveRelocationPlanner.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Maps/SaveRelocationPlanner.php b/src/Maps/SaveRelocationPlanner.php index 731abfd..2240346 100644 --- a/src/Maps/SaveRelocationPlanner.php +++ b/src/Maps/SaveRelocationPlanner.php @@ -84,12 +84,19 @@ private static function buildPlan(ProjectMap $map, string $root): ?SaveRelocatio && $passable($x, $y) && $report->isReachable($x, $y) && ! isset($events["{$x},{$y}"]); $groups = []; foreach ($blocked as [$x, $y]) { - $landing = self::findLanding($x, $y, $passable, $canLand, $grid) - ?? throw new RuntimeException(sprintf('Saves standing on x %d, y %d of %s would have no open floor to move to. Open a path to it, or undo the change, then save.', - $x, $y, $map->mapId)); + $landing = self::findLanding($x, $y, $passable, $canLand, $grid); + if ($landing === null && ! $report->isReachable($x, $y)) { + // A map the player reaches nowhere today holds no save a relocation could place. + continue; + } + $landing ?? throw new RuntimeException(sprintf('Saves standing on x %d, y %d of %s would have no open floor to move to. Open a path to it, or undo the change, then save.', + $x, $y, $map->mapId)); $groups[implode(',', $landing)]['to'] = $landing; $groups[implode(',', $landing)]['cells'][] = [$x, $y]; } + if ($groups === []) { + return null; + } $relocations = array_map(static fn(array $group): array => ['map' => $map->mapId, 'cells' => $group['cells'], 'to' => $group['to']], array_values($groups)); $source = (string) file_get_contents($manifestPath); From 76918d52817ffec680c72a515cc8e505cc9938f4 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 13 Oct 2026 04:45:30 +0200 Subject: [PATCH 271/344] feat(playtest): launch every playtest from the real project root PlaytestOverlay is removed. It mirrored the project as symlinks with a generated system.php and a copied start map, which the Engine's asset containment now refuses, so playtests showed glyphs only. PlaytestLaunch gives the Engine a launch context instead: the start (title, a cell, a cinematic on arrival, or the battle test as edited) and a data root of its own outside the project, holding a copy of the player settings and logs. The game reads the project's own files and never its saves. Removed or changed behaviour: - A cell playtest from the terminal editor now starts past the title, as the Engine starts a context cell only in a new game. - Battle tests run 'ichiloto play' with the battle test as launch context, not 'ichiloto battle'. - The refusals for system data that cannot be rewritten and for a start map whose event grid cannot be copied are gone with the copying; their tests are removed. - Playtest logs now live in the data root and are kept in memory once it is removed, instead of being left in the temporary directory. --- docs/manual.md | 37 +- docs/roadmap.md | 8 +- src/Cutscenes/Editing/CutscenePreviewPane.php | 17 +- src/Editor.php | 25 +- src/Playtest/PlaytestLaunch.php | 229 +++++++++ src/Playtest/PlaytestLauncher.php | 112 ++--- src/Playtest/PlaytestOverlay.php | 443 ------------------ src/Playtest/PlaytestRun.php | 25 +- src/Session/EditorSession.php | 46 +- .../Unit/Cutscenes/CinematicEndToEndTest.php | 25 +- tests/Unit/MapIsolationTest.php | 14 - tests/Unit/PlaytestTest.php | 250 +++++----- tests/Unit/Session/BattleTestSessionTest.php | 41 +- tests/Unit/Session/EditorSessionTest.php | 16 +- 14 files changed, 521 insertions(+), 767 deletions(-) create mode 100644 src/Playtest/PlaytestLaunch.php delete mode 100644 src/Playtest/PlaytestOverlay.php diff --git a/docs/manual.md b/docs/manual.md index 1bdc760..e52fdd8 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -2103,11 +2103,10 @@ the grid is unchanged; a retired view cannot supply its picture or feedback to the replacement. An effect without a selected start map now previews on diagnosed undefined terrain instead of refusing to open. -`Ctrl+T` plays the saved cinematic in the real game through the playtest -overlay: the start map is copied into the throwaway root with one extra -event, an automatic single-use `CinematicEventTrigger` on the spawn tile, -so the game launches the real asset through its real trigger the moment -the playtest begins. Your map files are not modified. +`Ctrl+T` plays the saved cinematic in the real game: a new game starts on +the cinematic's origin cell, and the Engine plays the cinematic once the +player has arrived, through the same controller a trigger would start it +with. No map file is copied or modified for it. #### Skip and the finalizer @@ -2622,27 +2621,23 @@ is printed in the `?` overlay. ## Playtesting -`Ctrl+T` launches the game on the selected map, spawning at the canvas cursor. -The editor hands the terminal to the game and takes it back when the game -exits. +`Ctrl+T` starts a new game on the selected map at the canvas cursor, past the +title. The editor hands the terminal to the game and takes it back when the +game exits. -How it avoids touching your project: the editor builds a temporary project root -of symlinks back to your real files, and replaces exactly two entries - a -generated `assets/Data/system.php` carrying the playtest spawn, and an empty -`.data/` so playtest saves cannot overwrite your save slots. Maps and every -other asset are symlinks, so the playtest runs against your live files. The -overlay is deleted when the game exits. +How it avoids touching your project: the game runs from your project's own +root and reads your live files, and the editor tells the Engine, through its +launch context, where to start and where to keep the playtest's player data. +That data lives in a fresh directory outside the project, holding a copy of +your player settings (so your volume and mute carry over) and nothing else: +settings changed during the playtest, its saves and its logs stay there, and +your own saves are never read or written. The directory is deleted when the +game exits. The Engine checks the start before the game takes any input, and +refuses a cell that is outside the map, blocks movement or has an NPC on it. The map must be saved first: the game reads the file on disk, so the editor refuses to playtest a dirty map rather than silently running the old version. -Current limits, both engine-side: - -- The engine offers no starting-map override, which is why the overlay exists. -- The game still boots to its title screen, so a playtest is `Ctrl+T` then - "New Game". Booting straight into the field would need an engine hook in - `GameLoader::loadNewGame()` and `Game::start()`. - `Ctrl+T` needs the console binary, which it looks for in this order: the project's own `vendor/bin/ichiloto`, any `vendor/bin/ichiloto` above the installed editor package, then `ichiloto` on your `PATH`. Set diff --git a/docs/roadmap.md b/docs/roadmap.md index 0d049d7..90187b9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -692,6 +692,11 @@ stay maintainable at similar scale: > `suspendForChildProcess()`/`resumeAfterChildProcess()` to hand the tty over > and take it back. The editor refuses to playtest a *dirty* map rather than > silently running the on-disk version. +> *2026-10-13:* the Engine now takes a launch context (start mode, start cell, +> cinematic, battle test and an isolated data root), and its asset loaders +> refuse paths that resolve outside `assets`, which the overlay's symlinks +> did. `PlaytestOverlay` is retired: every playtest runs from the real project +> root through `PlaytestLaunch`, with no generated `system.php` or copied map. > **The manual is real**: `docs/manual.md` (531 lines) plus six task guides > under `docs/guides/` (1,096 lines total) — index, make-a-new-map, > add-an-npc, wire-a-quest, author-a-cutscene, write-a-skit — following the @@ -1145,7 +1150,8 @@ presentation that waits for the author) and hands the asset to the engine's pause or step, read-only views of lanes, checkpoints, failures (mapped back to outline rows through the engine's own command paths) and final state, a watched-versus-skipped comparison, and a playtest overlay whose start map -carries an automatic `CinematicEventTrigger`. `SummonPreviewSession` does +carries an automatic `CinematicEventTrigger` (since 2026-10-13, a launch +context naming the cinematic instead). `SummonPreviewSession` does the same over `SummonPlaybackSession`: the playhead, frame clock, active segments and cue schedule are the engine's; the editor draws the frame by the battle field's rules and logs the cues it crossed. diff --git a/src/Cutscenes/Editing/CutscenePreviewPane.php b/src/Cutscenes/Editing/CutscenePreviewPane.php index 0a1b327..642e68f 100644 --- a/src/Cutscenes/Editing/CutscenePreviewPane.php +++ b/src/Cutscenes/Editing/CutscenePreviewPane.php @@ -14,7 +14,7 @@ use Ichiloto\Editor\Cutscenes\Preview\TimelinePreviewSession; use Ichiloto\Editor\EditorWindow; use Ichiloto\Editor\Playtest\PlaytestLauncher; -use Ichiloto\Editor\Playtest\PlaytestOverlay; +use Ichiloto\Editor\Playtest\PlaytestLaunch; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Status\StatusLevel; use Ichiloto\Editor\UI\CutscenesScreen; @@ -37,7 +37,7 @@ * failure it stopped on — with Play, Pause, Step, Skip, Restart and Stop, * a jump from a failure to the command that failed, a watched-versus-skipped * comparison, a duration overview, and a route into the real game through a - * playtest overlay. The command tree stays the one thing being edited; the + * playtest launch. The command tree stays the one thing being edited; the * pane only reads it. * * @package Ichiloto\Editor\Cutscenes\Editing @@ -915,8 +915,9 @@ private function findActiveTimelineKeys(CutsceneAsset $asset): array } /** - * Plays the cinematic in the real game: a playtest overlay whose start - * map carries an automatic trigger for it on the spawn tile. + * Plays the cinematic in the real game: a new game starting on its + * origin cell, which the Engine plays the cinematic on once the player + * has arrived. */ private function playtestSelectedCinematic(): void { @@ -956,10 +957,10 @@ private function playtestSelectedCinematic(): void return; } - $overlay = null; + $launch = null; try { - $overlay = PlaytestOverlay::createForCinematic( + $launch = PlaytestLaunch::fromCinematic( $this->workspace->projectRoot, $origin['mapId'], $origin['x'], @@ -970,7 +971,7 @@ private function playtestSelectedCinematic(): void $this->terminal->suspendForChildProcess(); try { - $launcher->run($overlay); + $launcher->run($launch); } finally { $this->terminal->resumeAfterChildProcess($this->lastTerminalSize); } @@ -979,7 +980,7 @@ private function playtestSelectedCinematic(): void } catch (Throwable $throwable) { $this->setErrorStatus($throwable, 'Cinematic playtest'); } finally { - $overlay?->destroy(); + $launch?->destroy(); } $this->requestFullRender(); diff --git a/src/Editor.php b/src/Editor.php index 0bb4f18..fd75773 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -79,7 +79,7 @@ use Ichiloto\Editor\IO\KeyBinding; use Ichiloto\Editor\Navigation\NavigationEntry; use Ichiloto\Editor\Playtest\PlaytestLauncher; -use Ichiloto\Editor\Playtest\PlaytestOverlay; +use Ichiloto\Editor\Playtest\PlaytestLaunch; use Ichiloto\Editor\Navigation\NavigationStack; use Ichiloto\Editor\Runtime\EditorLoop; use Ichiloto\Editor\Runtime\TerminalHost; @@ -4986,13 +4986,12 @@ private function pasteCanvasClipboard(array $choices = []): void } /** - * Launches the game on the selected map, spawning at the cursor. + * Launches a new game on the selected map, starting at the cursor. * - * The author's project is never modified: `PlaytestOverlay` builds a - * temporary root of symlinks with a generated `system.php` and an isolated - * save directory, and the overlay is torn down when the game exits. The - * engine offers no starting-map override today, so this is the honest way - * to do it - see the Phase 6 deferral note in `docs/roadmap.md`. + * The author's project is never modified: the game runs from the project's + * own root, and a {@see PlaytestLaunch} gives the Engine the start cell and + * a data root of its own for settings, saves and logs, removed when the + * game exits. * * @return void */ @@ -5007,17 +5006,17 @@ private function startPlaytest(): void } if ($selectedMap->isDirty()) { - // The playtest reads the map through a symlink, so an unsaved - // edit simply would not appear. Say so rather than confuse. + // The playtest reads the map from disk, so an unsaved edit + // simply would not appear. Say so rather than confuse. $this->setStatus('Save this map (Ctrl+S) before playtesting - the game reads the file on disk.', StatusLevel::WARN); $this->renderFooter(); return; } - $overlay = null; + $launch = null; try { - $overlay = PlaytestOverlay::create( + $launch = PlaytestLaunch::fromCell( $this->workspace->projectRoot, $selectedMap->mapId, $this->cursorX, @@ -5028,7 +5027,7 @@ private function startPlaytest(): void $this->terminal->suspendForChildProcess(); try { - $launcher->run($overlay); + $launcher->run($launch); } finally { $this->terminal->resumeAfterChildProcess($this->lastTerminalSize); } @@ -5040,7 +5039,7 @@ private function startPlaytest(): void } catch (Throwable $throwable) { $this->setErrorStatus($throwable, 'Playtest'); } finally { - $overlay?->destroy(); + $launch?->destroy(); } $this->requestFullRender(); diff --git a/src/Playtest/PlaytestLaunch.php b/src/Playtest/PlaytestLaunch.php new file mode 100644 index 0000000..4570883 --- /dev/null +++ b/src/Playtest/PlaytestLaunch.php @@ -0,0 +1,229 @@ + $context The Engine launch context, data root included. + * @param string|null $mapId The map the playtest starts on; null from the title or in a battle test. + * @param int|null $x The starting column. + * @param int|null $y The starting row. + */ + private function __construct( + public readonly string $projectRoot, + public readonly string $dataRoot, + private readonly array $context, + public readonly ?string $mapId = null, + public readonly ?int $x = null, + public readonly ?int $y = null, + ) { + } + + /** A new game starting on a map cell, past the title. */ + public static function fromCell(string $projectRoot, string $mapId, int $x, int $y): self + { + return self::prepare($projectRoot, ['mode' => GameStartupMode::NEW_GAME->value, + 'start' => ['mapId' => $mapId, 'x' => $x, 'y' => $y]], $mapId, $x, $y); + } + + /** The game from its title with the project's own starting position: the opening as a player first meets it. */ + public static function fromTitle(string $projectRoot): self + { + return self::prepare($projectRoot, ['mode' => GameStartupMode::TITLE->value]); + } + + /** A new game starting on a map cell that plays a cinematic once the player has arrived. */ + public static function fromCinematic(string $projectRoot, string $mapId, int $x, int $y, string $cinematicId): self + { + return self::prepare($projectRoot, ['mode' => GameStartupMode::NEW_GAME->value, + 'start' => ['mapId' => $mapId, 'x' => $x, 'y' => $y], 'cinematicId' => $cinematicId], $mapId, $x, $y); + } + + /** + * A battle test, as the Engine's ProjectBattleTest writes it: the troop, + * and the party and arena when it sets them. It is the battle test as the + * author has it now, unsaved edits included; the project's own is not read. + * + * @param array $battleTest + */ + public static function fromBattleTest(string $projectRoot, array $battleTest): self + { + return self::prepare($projectRoot, ['battleTest' => $battleTest]); + } + + /** Where the launch's own output goes: the play command's, and the game's standard output. */ + public function getLogPath(): string + { + return $this->dataRoot . DIRECTORY_SEPARATOR . self::LOG_DIRECTORY . DIRECTORY_SEPARATOR . 'playtest.log'; + } + + /** + * The environment the play command runs with: this one's, carrying the + * launch context, and without an inherited start mode that would compete + * with it. The Engine consumes the context once and clears it, so it never + * reaches a process the game starts. + * + * @return array + */ + public function buildEnvironment(): array + { + $environment = getenv(); + $environment = is_array($environment) ? $environment : []; + unset($environment[GameStartupMode::ENVIRONMENT_VARIABLE]); + $environment[GameLaunchContext::ENVIRONMENT_VARIABLE] = self::encodeContext($this->context); + + return $environment; + } + + /** + * The launch context as the Engine reads it. + * + * @return array + */ + public function getContext(): array + { + return $this->context; + } + + /** + * Removes the data root and everything the playtest wrote there. Nothing + * in it links anywhere (the Engine refuses data that follows a link), and + * a link is still only unlinked, never followed into what it names. + */ + public function destroy(): void + { + self::removeTree($this->dataRoot); + } + + /** + * Checks the intent the Engine's way, then fills a fresh data root. + * + * @param array $intent The launch context without its data root. + * @throws RuntimeException When the intent is not one the Engine reads, or the data root cannot be made. + */ + private static function prepare(string $projectRoot, array $intent, ?string $mapId = null, ?int $x = null, ?int $y = null): self + { + $project = realpath($projectRoot); + if ($project === false || ! is_dir($project)) { + throw new RuntimeException("The project to playtest is not at {$projectRoot}."); + } + try { + GameLaunchContext::fromArray($intent); + } catch (InvalidArgumentException $invalid) { + throw new RuntimeException('The game cannot start that way: ' . $invalid->getMessage(), previous: $invalid); + } + + $created = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('ichiloto-playtest-', true); + if (! @mkdir($created, 0o700, true) && ! is_dir($created)) { + throw new RuntimeException("Unable to create the playtest's data directory at {$created}."); + } + // The Engine takes the data root only by its canonical path: the temporary directory may sit behind a link. + $root = realpath($created) ?: $created; + + try { + if (! @mkdir($root . DIRECTORY_SEPARATOR . self::LOG_DIRECTORY, 0o700)) { + throw new RuntimeException("Unable to create the playtest's log directory in {$root}."); + } + self::copyPlayerSettings($project, $root); + touch($root . DIRECTORY_SEPARATOR . self::LOG_DIRECTORY . DIRECTORY_SEPARATOR . 'playtest.log'); + LaunchDataRoot::validate($root, $project); + $context = [...$intent, 'dataRoot' => $root]; + self::encodeContext($context); + } catch (InvalidArgumentException $invalid) { + self::removeTree($root); + + throw new RuntimeException('The playtest cannot keep its data apart from the project: ' . $invalid->getMessage(), previous: $invalid); + } catch (\Throwable $throwable) { + self::removeTree($root); + + throw $throwable; + } + + return new self($project, $root, $context, $mapId, $x, $y); + } + + /** + * Copies the author's player settings into the data root: volume and + * mute, controls and other preferences belong to the player, so a + * playtest honours them, while the copy keeps any change made during the + * playtest out of the project. Saves stay behind. + */ + private static function copyPlayerSettings(string $projectRoot, string $root): void + { + $source = $projectRoot . DIRECTORY_SEPARATOR . '.data' . DIRECTORY_SEPARATOR . self::PLAYER_SETTINGS; + + if (is_file($source) && ! copy($source, $root . DIRECTORY_SEPARATOR . self::PLAYER_SETTINGS)) { + throw new RuntimeException("Unable to copy the player settings into the playtest from {$source}."); + } + } + + /** + * @param array $context + * @throws RuntimeException When the context is too large for the Engine to take. + */ + private static function encodeContext(array $context): string + { + try { + $json = json_encode($context, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } catch (JsonException $error) { + throw new RuntimeException('The playtest launch could not be written: ' . $error->getMessage(), previous: $error); + } + if (strlen($json) > GameLaunchContext::MAX_TRANSPORT_BYTES) { + throw new RuntimeException(sprintf('The playtest launch is larger than the %d bytes the game takes.', GameLaunchContext::MAX_TRANSPORT_BYTES)); + } + + return $json; + } + + private static function removeTree(string $directory): void + { + if (is_link($directory) || is_file($directory)) { + @unlink($directory); + + return; + } + if (! is_dir($directory)) { + return; + } + + foreach (scandir($directory) ?: [] as $entry) { + if ($entry !== '.' && $entry !== '..') { + self::removeTree($directory . DIRECTORY_SEPARATOR . $entry); + } + } + + @rmdir($directory); + } +} diff --git a/src/Playtest/PlaytestLauncher.php b/src/Playtest/PlaytestLauncher.php index 801345d..9e5d555 100644 --- a/src/Playtest/PlaytestLauncher.php +++ b/src/Playtest/PlaytestLauncher.php @@ -8,7 +8,9 @@ use RuntimeException; /** - * Runs `ichiloto play` against a playtest overlay. + * Runs `ichiloto play` from the author's real project root for a + * {@see PlaytestLaunch}, which carries where the game starts and where its + * data lives in the Engine's launch context. * * `--no-tmux` is always passed: the editor already owns this terminal, and * letting the play command open or reuse a tmux session would detach the game @@ -36,19 +38,14 @@ public static function discover(?string $override = null, ?string $projectRoot = } /** - * Returns the shell command that runs the playtest. + * The play command's arguments, from its interpreter on: the game in this + * terminal, from the project's own root. * - * @param PlaytestOverlay $overlay The overlay to play. - * @return string + * @return list */ - public function buildCommand(PlaytestOverlay $overlay): string + public function buildPlayArguments(PlaytestLaunch $launch): array { - return sprintf( - '%s %s play --no-tmux -d %s', - escapeshellcmd(PHP_BINARY), - escapeshellarg($this->consoleBinary), - escapeshellarg($overlay->root), - ); + return [PHP_BINARY, $this->consoleBinary, 'play', '--no-tmux', '-d', $launch->projectRoot]; } /** The renderer choice that lets the console pick: GPUI when it is installed and usable, otherwise the terminal. */ @@ -57,20 +54,40 @@ public function buildCommand(PlaytestOverlay $overlay): string /** * Starts the playtest in the background with a renderer of its own, for * an editor that owns no terminal to hand over: the play command runs - * without prompts, reading nothing and writing its output to a log. It - * begins a new game at the overlay's starting position, skipping the - * title, or at the title; a terminal session opens in a terminal window of - * its own, which closes when the game ends. + * without prompts, reading nothing and writing its output to the launch's + * log. A terminal session opens in a terminal window of its own, which + * closes when the game ends. Field playtests and battle tests start the + * same way; the launch context says which it is. * * @param string $rendererId The renderer, such as `gpui`, or {@see AUTOMATIC}. - * @param string $logPath Where the play command's output goes. * @throws RuntimeException When the process cannot start. */ - public function start(PlaytestOverlay $overlay, string $rendererId, string $logPath, PlaytestStart $start = PlaytestStart::CELL): PlaytestRun + public function start(PlaytestLaunch $launch, string $rendererId): PlaytestRun { - return $this->spawn($overlay, ['play', '--no-tmux', '--no-interaction', - '--start=' . ($start === PlaytestStart::TITLE ? 'title' : 'new-game'), '--renderer=' . $rendererId, - '--terminal-window', '-d', $overlay->root], $logPath); + $log = @fopen($launch->getLogPath(), 'ab'); + if (! is_resource($log)) { + throw new RuntimeException("Unable to open the playtest log at {$launch->getLogPath()}."); + } + + try { + $pipes = []; + $process = @proc_open( + [PHP_BINARY, $this->consoleBinary, 'play', '--no-tmux', '--no-interaction', '--renderer=' . $rendererId, + '--terminal-window', '-d', $launch->projectRoot], + [0 => ['file', '/dev/null', 'r'], 1 => $log, 2 => $log], + $pipes, + $launch->projectRoot, + $launch->buildEnvironment(), + ); + } finally { + fclose($log); + } + + if (! is_resource($process)) { + throw new RuntimeException('The playtest could not be started.'); + } + + return new PlaytestRun($process, $launch); } /** @@ -111,62 +128,19 @@ public function listRenderers(string $projectRoot): array } /** - * Starts a battle test in the background with a renderer of its own, as - * {@see start()} starts a playtest: `ichiloto battle` against a battle - * test overlay, fighting the named troop with the party and arena its - * system data's battle test sets. + * Runs the playtest in this terminal, returning the play command's exit code. * - * @param string $troop The troop to fight, by name. * @throws RuntimeException When the process cannot start. */ - public function startBattle(PlaytestOverlay $overlay, string $rendererId, string $logPath, string $troop): PlaytestRun - { - return $this->spawn($overlay, ['battle', '--no-interaction', '--renderer=' . $rendererId, '-d', $overlay->root, '--troop', $troop], $logPath); - } - - /** - * Runs a console command against an overlay, reading nothing and writing - * its output to a log. - * - * @param list $arguments The command and its options. - */ - private function spawn(PlaytestOverlay $overlay, array $arguments, string $logPath): PlaytestRun + public function run(PlaytestLaunch $launch): int { - $log = @fopen($logPath, 'ab'); - if (! is_resource($log)) { - throw new RuntimeException("Unable to open the playtest log at {$logPath}."); - } - - try { - $pipes = []; - $process = @proc_open( - [PHP_BINARY, $this->consoleBinary, ...$arguments], - [0 => ['file', '/dev/null', 'r'], 1 => $log, 2 => $log], - $pipes, - $overlay->root, - ); - } finally { - fclose($log); - } - + $pipes = []; + $process = @proc_open($this->buildPlayArguments($launch), [0 => STDIN, 1 => STDOUT, 2 => STDERR], $pipes, + $launch->projectRoot, $launch->buildEnvironment()); if (! is_resource($process)) { throw new RuntimeException('The playtest could not be started.'); } - return new PlaytestRun($process, $overlay, $logPath); - } - - /** - * Runs the playtest, returning the child process exit code. - * - * @param PlaytestOverlay $overlay The overlay to play. - * @return int - */ - public function run(PlaytestOverlay $overlay): int - { - $exitCode = 0; - passthru($this->buildCommand($overlay), $exitCode); - - return $exitCode; + return proc_close($process); } } diff --git a/src/Playtest/PlaytestOverlay.php b/src/Playtest/PlaytestOverlay.php deleted file mode 100644 index 74f7b4d..0000000 --- a/src/Playtest/PlaytestOverlay.php +++ /dev/null @@ -1,443 +0,0 @@ - $spawnX, 'y' => $spawnY]; - $player['spawnSprite'] = $player['spawnSprite'] ?? ['^']; - $system['startingPositions']['player'] = $player; - - return $system; - }, 'a playtest spawn'); - - return new self($root, $mapId, $spawnX, $spawnY); - } - - /** - * Builds an overlay that plays the game from its title with the project's own starting position: the opening - * as a player first meets it, still without touching the author's saves. - */ - public static function createForTitle(string $projectRoot): self - { - return new self(self::build($projectRoot, static fn(array $system): array => $system, 'a playtest from the title')); - } - - /** - * Builds an overlay for a battle test: the author's project with its - * system data's battle test replaced by the one given (unsaved edits - * included), or removed when it is empty, so `ichiloto battle` reads the - * party and arena the author set without the project being written. - * - * @param array $battleTest The battle test, as the Engine's ProjectBattleTest writes it. - */ - public static function createForBattle(string $projectRoot, array $battleTest): self - { - return new self(self::build($projectRoot, static function (array $system) use ($battleTest): array { - unset($system[ProjectBattleTest::SYSTEM_KEY]); - - return $battleTest === [] ? $system : [...$system, ProjectBattleTest::SYSTEM_KEY => $battleTest]; - }, 'a battle test')); - } - - /** - * Fills a fresh overlay directory: links to the project throughout, a - * fresh `.data` holding only the player settings, and a system.php the - * given change has rewritten. - * - * @param callable(array): array $rewriteSystem - * @param string $purpose What the rewrite is for, as an error names it. - * @return string The overlay root. - */ - private static function build(string $projectRoot, callable $rewriteSystem, string $purpose): string - { - $projectRoot = rtrim($projectRoot, DIRECTORY_SEPARATOR); - $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('ichiloto-playtest-', true); - - if (! mkdir($root, 0777, true) && ! is_dir($root)) { - throw new RuntimeException("Unable to create the playtest overlay at {$root}."); - } - - try { - // Everything at the project root is a symlink, except `assets` - // (which needs one file replaced) and `.data` (which must be - // isolated). - self::mirrorDirectory($projectRoot, $root, ['assets', '.data']); - - $assetsSource = $projectRoot . DIRECTORY_SEPARATOR . 'assets'; - $assetsTarget = $root . DIRECTORY_SEPARATOR . 'assets'; - - if (! is_dir($assetsSource)) { - throw new RuntimeException("The project has no assets directory at {$assetsSource}."); - } - - mkdir($assetsTarget, 0777, true); - self::mirrorDirectory($assetsSource, $assetsTarget, ['Data']); - - $dataSource = $assetsSource . DIRECTORY_SEPARATOR . 'Data'; - $dataTarget = $assetsTarget . DIRECTORY_SEPARATOR . 'Data'; - mkdir($dataTarget, 0777, true); - self::mirrorDirectory($dataSource, $dataTarget, ['system.php']); - - mkdir($root . DIRECTORY_SEPARATOR . '.data', 0777, true); - self::copyPlayerSettings($projectRoot, $root); - - self::writeSystemOverride( - $dataSource . DIRECTORY_SEPARATOR . 'system.php', - $dataTarget . DIRECTORY_SEPARATOR . 'system.php', - $rewriteSystem, - $purpose, - ); - } catch (\Throwable $throwable) { - // An overlay that could not be finished is not left behind: what - // was mirrored so far is links and two generated entries, removed - // without ever following a link into the author's project. - self::removeTree($root); - - throw $throwable; - } - - return $root; - } - - /** - * Builds an overlay whose starting map launches a cinematic on arrival. - * - * The map is copied into the overlay (its tiles stay a link) with one - * extra event: an automatic, single-use `CinematicEventTrigger` on the - * spawn tile naming the cinematic. The game then plays the real asset - * through its real trigger the moment the playtest begins, and the - * author's map files are never written. - * - * @param string $projectRoot The real project root. - * @param string $mapId The map to start on. - * @param int $spawnX The spawn column. - * @param int $spawnY The spawn row. - * @param string $cinematicId The cinematic to launch. - */ - public static function createForCinematic(string $projectRoot, string $mapId, int $spawnX, int $spawnY, string $cinematicId): self - { - $overlay = self::create($projectRoot, $mapId, $spawnX, $spawnY); - - try { - $overlay->installCinematicLaunch(rtrim($projectRoot, DIRECTORY_SEPARATOR), $cinematicId); - } catch (\Throwable $throwable) { - $overlay->destroy(); - - throw $throwable; - } - - return $overlay; - } - - /** - * Replaces the overlay's start map with a copy carrying the launch trigger. - */ - private function installCinematicLaunch(string $projectRoot, string $cinematicId): void - { - $mapsSource = $projectRoot . '/assets/Maps'; - $mapsTarget = $this->root . '/assets/Maps'; - if ($this->mapId === null || $this->spawnX === null || $this->spawnY === null) { - throw new RuntimeException('A cinematic playtest starts on a map cell.'); - } - - $segments = explode('/', trim(str_replace('\\', '/', $this->mapId), '/')); - $leaf = $segments[array_key_last($segments)]; - $mapSourceDirectory = $mapsSource . '/' . implode('/', $segments); - - foreach (['data', 'map', 'event'] as $part) { - if (! is_file($mapSourceDirectory . '/' . $leaf . '.' . $part . '.php')) { - throw new RuntimeException(sprintf('Map %s has no %s file to playtest from.', $this->mapId, $part)); - } - } - - // assets/Maps was one link; rebuild it as real directories down to - // the start map, linking every sibling on the way. - if (is_link($mapsTarget)) { - unlink($mapsTarget); - } - - mkdir($mapsTarget, 0777, true); - $currentSource = $mapsSource; - $currentTarget = $mapsTarget; - - foreach ($segments as $depth => $segment) { - $isLast = $depth === count($segments) - 1; - self::mirrorDirectory($currentSource, $currentTarget, [$segment]); - $currentSource .= '/' . $segment; - $currentTarget .= '/' . $segment; - mkdir($currentTarget, 0777, true); - - if (! $isLast) { - continue; - } - - self::mirrorDirectory($currentSource, $currentTarget, [$leaf . '.data.php', $leaf . '.event.php']); - self::writeCinematicLaunchFiles($currentSource, $currentTarget, $leaf, $cinematicId); - } - } - - /** - * Writes the start map's data and event-layer files with the launch - * trigger on the spawn tile. - */ - private function writeCinematicLaunchFiles(string $sourceDirectory, string $targetDirectory, string $leaf, string $cinematicId): void - { - $eventText = MapGridSource::readFile($sourceDirectory . '/' . $leaf . '.event.php'); - $data = require $sourceDirectory . '/' . $leaf . '.data.php'; - - if (! is_array($data) || ! is_string($eventText)) { - throw new RuntimeException(sprintf('Map %s could not be read for the playtest.', $this->mapId)); - } - - if (! PhpValueExporter::isExportable($data)) { - throw new RuntimeException(sprintf('Map %s holds PHP objects, so a launch trigger cannot be written into a copy.', $this->mapId)); - } - - $events = is_array($data['events'] ?? null) ? $data['events'] : []; - $lines = preg_split('/\r\n|\n|\r/', rtrim($eventText, "\r\n")) ?: []; - $marker = self::freeEventMarker($events, $lines); - - if ($marker === null) { - throw new RuntimeException(sprintf('Map %s has no free event marker for the launch trigger.', $this->mapId)); - } - - $row = $lines[$this->spawnY] ?? null; - - if ($row === null) { - throw new RuntimeException(sprintf('Spawn row %d is outside map %s.', $this->spawnY, $this->mapId)); - } - - $symbols = preg_split('//u', $row, -1, PREG_SPLIT_NO_EMPTY) ?: []; - - if (! isset($symbols[$this->spawnX])) { - throw new RuntimeException(sprintf('Spawn column %d is outside map %s.', $this->spawnX, $this->mapId)); - } - - $symbols[$this->spawnX] = $marker; - $lines[$this->spawnY] = implode('', $symbols); - $events[$marker] = [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\CinematicEventTrigger', - 'data' => ['cinematicId' => $cinematicId, 'mode' => 'auto', 'reusable' => false], - ]; - $data['events'] = $events; - - PhpDataFile::writeTransactionally( - $targetDirectory . '/' . $leaf . '.data.php', - " $events - * @param string[] $lines - */ - private static function freeEventMarker(array $events, array $lines): ?string - { - preg_match_all('/\X/u', implode('', $lines), $symbols); - - return EventMarkers::findFreeMarker([...array_map(strval(...), array_keys($events)), ...$symbols[0]]); - } - - /** - * Removes the overlay directory. - * - * Only symlinks and the two generated entries live here, so unlinking is - * safe: `unlink()` on a symlink never follows it into the real project. - * - * @return void - */ - public function destroy(): void - { - self::removeTree($this->root); - } - - /** - * Copies the author's player settings into the overlay's isolated data - * directory: volume and mute, controls and other preferences belong to - * the player, so a playtest honours them, while the copy keeps any - * change made during the playtest out of the project. Saves stay behind. - */ - private static function copyPlayerSettings(string $projectRoot, string $root): void - { - $source = $projectRoot . DIRECTORY_SEPARATOR . '.data' . DIRECTORY_SEPARATOR . self::PLAYER_SETTINGS; - - if (is_file($source) && ! copy($source, $root . DIRECTORY_SEPARATOR . '.data' . DIRECTORY_SEPARATOR . self::PLAYER_SETTINGS)) { - throw new RuntimeException("Unable to copy the player settings into the playtest from {$source}."); - } - } - - /** - * Symlinks every entry of a directory, skipping the named exceptions. - * - * @param string $source The directory to mirror. - * @param string $target The directory to fill. - * @param string[] $skip Entry names to leave out. - * @return void - */ - private static function mirrorDirectory(string $source, string $target, array $skip = []): void - { - foreach (scandir($source) ?: [] as $entry) { - if ($entry === '.' || $entry === '..' || in_array($entry, $skip, true)) { - continue; - } - - @symlink($source . DIRECTORY_SEPARATOR . $entry, $target . DIRECTORY_SEPARATOR . $entry); - } - } - - /** - * Writes a copy of system.php with one change made: a playtest's spawn, - * or a battle test's setup. - * - * The rest of the author's system data — party, battle settings, title - * screen — is preserved exactly, so the playtest is the real game. - * - * @param string $sourcePath The project's system.php. - * @param string $targetPath The overlay's system.php. - * @param callable(array): array $rewriteSystem The change. - * @param string $purpose What the change is for, as an error names it. - * @return void - */ - private static function writeSystemOverride( - string $sourcePath, - string $targetPath, - callable $rewriteSystem, - string $purpose, - ): void { - if (! is_file($sourcePath)) { - throw new RuntimeException("The project has no assets/Data/system.php to base a playtest on."); - } - - try { - $payload = require $sourcePath; - } catch (Throwable $throwable) { - throw new RuntimeException("Unable to read system.php: {$throwable->getMessage()}."); - } - - if (! is_array($payload)) { - throw new RuntimeException('system.php did not return an array.'); - } - - if (! PhpValueExporter::isExportable($payload)) { - throw new RuntimeException( - "system.php contains PHP objects, so {$purpose} cannot be written without rewriting them.", - ); - } - - $payload = $rewriteSystem($payload); - - PhpDataFile::writeTransactionally( - $targetPath, - "finished) { @@ -52,7 +55,7 @@ public function getExitCode(): ?int return $this->isRunning() ? null : $this->exitCode; } - /** Ends the game, its renderer and the play command, then removes the overlay. */ + /** Ends the game, its renderer and the play command, then removes the data root. */ public function stop(): void { if (! $this->isRunning()) { @@ -78,7 +81,7 @@ public function wasStopped(): bool /** The end of the play command's output, for saying why a run failed. */ public function readLogTail(int $lines = 12): string { - $content = is_file($this->logPath) ? (string) file_get_contents($this->logPath) : ''; + $content = $this->finalLog ?? self::readLog($this->launch->getLogPath()); $kept = array_slice(preg_split('/\R/', trim($content)) ?: [], -$lines); return implode("\n", $kept); @@ -93,7 +96,13 @@ private function finish(bool $closed = false): void if (! $closed) { proc_close($this->process); } - $this->overlay->destroy(); + $this->finalLog = self::readLog($this->launch->getLogPath()); + $this->launch->destroy(); + } + + private static function readLog(string $path): string + { + return is_file($path) ? (string) file_get_contents($path) : ''; } /** diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 63d9202..9a4ad7c 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -70,7 +70,7 @@ use Ichiloto\Editor\Inspector\MapInspector; use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Playtest\PlaytestLauncher; -use Ichiloto\Editor\Playtest\PlaytestOverlay; +use Ichiloto\Editor\Playtest\PlaytestLaunch; use Ichiloto\Editor\Playtest\PlaytestRun; use Ichiloto\Editor\Playtest\PlaytestStart; use Ichiloto\Editor\Maps\LayerEditor; @@ -4475,9 +4475,9 @@ public function saveMap(string $mapId, ?string $answer = null, ?string $confirm } /** - * Plays the game in the background, in its own graphical window, as the terminal editor's playtest does: from a - * temporary overlay of the project that writes nothing into it, with the author's player settings (volume and - * mute included) and no saves. It starts on the open map at a cell, or at the game's title with the project's own + * Plays the game in the background, in its own graphical window, as the terminal editor's playtest does: from the + * project's own root, writing nothing into it, with its player data in a data root of its own holding the + * author's player settings (volume and mute included) and none of their saves. It starts on the open map at a cell, or at the game's title with the project's own * starting position. The game reads maps from disk, so a playtest is refused while a map it plays from is unsaved: * the start map, or, from the title, any map. * @@ -4519,17 +4519,16 @@ public function startPlaytest(?string $mapId, ?int $x, ?int $y, PlaytestStart $s throw new SessionRefusal(sprintf('Save %s before playing from the title; the game reads every map on disk.', implode(', ', $unsaved))); } } - $overlay = null; + $launch = null; try { - $overlay = $start === PlaytestStart::CELL - ? PlaytestOverlay::create($this->workspace->projectRoot, (string) $mapId, (int) $x, (int) $y) - : PlaytestOverlay::createForTitle($this->workspace->projectRoot); + $launch = $start === PlaytestStart::CELL + ? PlaytestLaunch::fromCell($this->workspace->projectRoot, (string) $mapId, (int) $x, (int) $y) + : PlaytestLaunch::fromTitle($this->workspace->projectRoot); $launcher = PlaytestLauncher::discover(projectRoot: $this->workspace->projectRoot); - $log = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('ichiloto-playtest-', true) . '.log'; - $this->playtest = $launcher->start($overlay, $renderer, $log, $start); + $this->playtest = $launcher->start($launch, $renderer); $this->playtestStart = $start; } catch (RuntimeException $error) { - $overlay?->destroy(); + $launch?->destroy(); throw new SessionRefusal(sprintf('The playtest could not start: %s', $error->getMessage()), previous: $error); } @@ -4582,9 +4581,9 @@ public function describePlaytest(): array return [ 'running' => $running, 'start' => $run === null ? null : $this->playtestStart->value, - 'map' => $run?->overlay->mapId, - 'x' => $run?->overlay->spawnX, - 'y' => $run?->overlay->spawnY, + 'map' => $run?->launch->mapId, + 'x' => $run?->launch->x, + 'y' => $run?->launch->y, 'stopped' => $run?->wasStopped() ?? false, 'exitCode' => $exitCode, // Only a run that ended on its own with an error says why. @@ -4699,10 +4698,10 @@ public function applyBattleTest(array $battleTest): array /** * Plays the battle test in the background in its own graphical window, * as `ichiloto battle` does: the named troop, or the battle test's own, - * fought by the battle test's party in its arena. It runs from an - * overlay of the project carrying the battle test as it is now, unsaved - * edits included, and writes nothing into the project; anything else a - * battle reads from disk must be saved first. + * fought by the battle test's party in its arena. It runs from the + * project's own root with the battle test as it is now, unsaved edits + * included, as its launch, and writes nothing into the project; anything + * else a battle reads from disk must be saved first. * * @param int|null $troopIndex The troop to fight; null for the battle test's troop. * @return array The battle test run, as {@see describeBattleTestRun()}. @@ -4728,15 +4727,14 @@ public function startBattleTest(?int $troopIndex = null): array if ($described['issue'] !== null || $described['problems'] !== []) { throw new SessionRefusal("The battle test party cannot be set up:\n" . implode("\n", array_filter([$described['issue'], ...$described['problems']]))); } - $overlay = null; + $launch = null; try { - $overlay = PlaytestOverlay::createForBattle($this->workspace->projectRoot, $this->readBattleTestEntry()); + $launch = PlaytestLaunch::fromBattleTest($this->workspace->projectRoot, [...$this->readBattleTestEntry(), 'troop' => $troop]); $launcher = PlaytestLauncher::discover(projectRoot: $this->workspace->projectRoot); - $log = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('ichiloto-battle-test-', true) . '.log'; - $this->battleTestRun = $launcher->startBattle($overlay, self::PLAYTEST_RENDERER, $log, $troop); + $this->battleTestRun = $launcher->start($launch, self::PLAYTEST_RENDERER); $this->battleTestTroop = $troop; } catch (RuntimeException $error) { - $overlay?->destroy(); + $launch?->destroy(); throw new SessionRefusal(sprintf('The battle test could not start: %s', $error->getMessage()), previous: $error); } @@ -4820,7 +4818,7 @@ private function loadItemStore(): ItemStore /** * What a battle reads from disk that has unsaved edits: every database - * but System (whose battle test the overlay carries) and every + * but System (whose battle test the launch carries) and every * cutscene, since summons play them. Maps are not part of a battle. * * @return list diff --git a/tests/Unit/Cutscenes/CinematicEndToEndTest.php b/tests/Unit/Cutscenes/CinematicEndToEndTest.php index dfaf51e..98f6686 100644 --- a/tests/Unit/Cutscenes/CinematicEndToEndTest.php +++ b/tests/Unit/Cutscenes/CinematicEndToEndTest.php @@ -4,7 +4,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewSession; -use Ichiloto\Editor\Playtest\PlaytestOverlay; +use Ichiloto\Editor\Playtest\PlaytestLaunch; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\UI\CutscenesScreen; use Ichiloto\Editor\Validation\Issue; @@ -261,25 +261,16 @@ $cutsceneIssues = array_values(array_filter($issues, static fn(Issue $issue): bool => str_starts_with($issue->where, 'cinematic '))); expect(array_map(static fn(Issue $issue): string => $issue->message, $cutsceneIssues))->toBe([]); - // 16. The playtest route: an overlay whose start map launches the cinematic on arrival. - file_put_contents($root . '/assets/Data/system.php', " ['members' => ['Kaelion']], 'startingPositions' => ['player' => ['destinationMap' => 'harbour', 'spawnPoint' => ['x' => 1, 'y' => 1], 'spawnSprite' => ['^']]]];\n"); - $overlay = PlaytestOverlay::createForCinematic($root, 'skyfield-night', 2, 2, 'dawn-crossing'); + // 16. The playtest route: a new game on the cinematic's origin that the Engine plays it on, the project untouched. + $mapFiles = sourceHashTree($root . '/assets/Maps'); + $launch = PlaytestLaunch::fromCinematic($root, 'skyfield-night', 2, 2, 'dawn-crossing'); try { - $mapData = require $overlay->root . '/assets/Maps/skyfield-night/skyfield-night.data.php'; - $eventLayer = require $overlay->root . '/assets/Maps/skyfield-night/skyfield-night.event.php'; - $triggers = array_filter($mapData['events'], static fn(array $event): bool => str_contains($event['class'], 'CinematicEventTrigger')); - $marker = array_key_first($triggers); - expect($triggers)->toHaveCount(1) - ->and($triggers[$marker]['data'])->toBe(['cinematicId' => 'dawn-crossing', 'mode' => 'auto', 'reusable' => false]) - ->and(explode("\n", $eventLayer)[2][2])->toBe($marker) - ->and(is_link($overlay->root . '/assets/Maps/skyfield-night/skyfield-night.map.php'))->toBeTrue() - ->and(is_link($overlay->root . '/assets/Maps/skyfield-dawn'))->toBeTrue() - ->and(is_link($overlay->root . '/assets/Cutscenes'))->toBeTrue(); - // The real project's map is untouched. - expect(file_get_contents($root . '/assets/Maps/skyfield-night/skyfield-night.event.php'))->not->toContain($marker === ' ' ? 'never' : $marker . ' '); + expect($launch->getContext())->toBe(['mode' => 'new-game', 'start' => ['mapId' => 'skyfield-night', 'x' => 2, 'y' => 2], + 'cinematicId' => 'dawn-crossing', 'dataRoot' => $launch->dataRoot]) + ->and(sourceHashTree($root . '/assets/Maps'))->toBe($mapFiles); } finally { - $overlay->destroy(); + $launch->destroy(); } // 17. & 18. Exactly-once completion, and the session released so a save may follow. diff --git a/tests/Unit/MapIsolationTest.php b/tests/Unit/MapIsolationTest.php index f479f1f..85eb162 100644 --- a/tests/Unit/MapIsolationTest.php +++ b/tests/Unit/MapIsolationTest.php @@ -4,7 +4,6 @@ use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Editor; -use Ichiloto\Editor\Playtest\PlaytestOverlay; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Status\StatusLevel; @@ -54,19 +53,6 @@ expect($good->isDirty())->toBeTrue(); }); -it('refuses a cinematic playtest event grid before evaluating its map data', function (): void { - $root = makeTemporaryProject('ichiloto-map-playtest-'); - $directory = $root . '/assets/Maps/test-map'; - $executed = $root . '/executed'; - file_put_contents($root . '/assets/Data/system.php', ' PlaytestOverlay::createForCinematic($root, 'test-map', 1, 1, 'intro')) - ->toThrow(InvalidArgumentException::class, 'literal nowdoc'); - expect(is_file($executed))->toBeFalse(); -}); - it('writes parseable map and event grids when authored rows resemble nowdoc closers', function (): void { $root = makeTemporaryProject('ichiloto-map-markers-'); $directory = $root . '/assets/Maps/marker-map'; diff --git a/tests/Unit/PlaytestTest.php b/tests/Unit/PlaytestTest.php index 11caac6..4b0eba8 100644 --- a/tests/Unit/PlaytestTest.php +++ b/tests/Unit/PlaytestTest.php @@ -2,8 +2,11 @@ declare(strict_types=1); +use Ichiloto\Editor\Playtest\PlaytestLaunch; use Ichiloto\Editor\Playtest\PlaytestLauncher; -use Ichiloto\Editor\Playtest\PlaytestOverlay; +use Ichiloto\Engine\Core\GameLaunchContext; +use Ichiloto\Engine\Core\GameStartupMode; +use Ichiloto\Engine\IO\LaunchDataRoot; /** * Returns a path => sha1 map of every file under a directory, following no @@ -27,133 +30,146 @@ function checksumTree(string $directory): array return $checksums; } -it('builds a playtest overlay without writing into the project', function (): void { - $root = makeTemporaryProject(); - // The fixture has no system.php; a playtest needs one. - file_put_contents($root . '/assets/Data/system.php', <<<'PHP' - ['members' => ['Kaelion']], - 'startingPositions' => [ - 'player' => [ - 'destinationMap' => 'happyville/home', - 'spawnPoint' => ['x' => 8, 'y' => 4], - 'spawnSprite' => ['^'], - ], - ], - ]; - PHP); +/** The playtest data roots in the temporary directory, to show a refused launch leaves none behind. */ +function listPlaytestDataRoots(): array +{ + return glob(sys_get_temp_dir() . '/ichiloto-playtest-*', GLOB_ONLYDIR) ?: []; +} +it('plays the real project from its own root, keeping its data apart and writing nothing into the project', function (): void { + $root = playtestProject(); $before = checksumTree($root); - $overlay = PlaytestOverlay::create($root, 'test-map', 12, 5); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 12, 5); try { - expect(is_dir($overlay->root))->toBeTrue(); - expect($overlay->root)->not->toStartWith($root); + expect($launch->projectRoot)->toBe(realpath($root)) + ->and($launch->getContext())->toBe(['mode' => 'new-game', 'start' => ['mapId' => 'test-map', 'x' => 12, 'y' => 5], + 'dataRoot' => $launch->dataRoot]) + ->and([$launch->mapId, $launch->x, $launch->y])->toBe(['test-map', 12, 5]) + // Outside the project and not above it, as the Engine requires, and nothing in it but the logs. + ->and($launch->dataRoot)->not->toStartWith(realpath($root)) + ->and(LaunchDataRoot::validate($launch->dataRoot, realpath($root)))->toBe($launch->dataRoot) + ->and(array_values(array_diff(scandir($launch->dataRoot), ['.', '..'])))->toBe(['logs']) + ->and(checksumTree($root))->toBe($before); + } finally { + $launch->destroy(); + } - // The project is byte-identical. - expect(checksumTree($root))->toBe($before); + expect(is_dir($launch->dataRoot))->toBeFalse() + ->and(checksumTree($root))->toBe($before); +}); - // Assets reach through to the author's live files. - expect(is_link($overlay->root . '/assets/Maps'))->toBeTrue(); - expect(is_link($overlay->root . '/ichiloto.json'))->toBeTrue(); +it('carries the player settings into the playtest and leaves the saves behind', function (): void { + $root = playtestProject(); + mkdir($root . '/.data/saves', 0777, true); + file_put_contents($root . '/.data/player-settings.json', '{"audio":{"music":false,"sfx":false}}'); + file_put_contents($root . '/.data/saves/slot-1.json', '{}'); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 1, 1); - // system.php is a real generated file, not a symlink. - $systemPath = $overlay->root . '/assets/Data/system.php'; - expect(is_link($systemPath))->toBeFalse(); - expect(is_file($systemPath))->toBeTrue(); + try { + // A copy, so a playtest's own changes never reach the project. + expect(file_get_contents($launch->dataRoot . '/player-settings.json'))->toBe('{"audio":{"music":false,"sfx":false}}') + ->and(is_link($launch->dataRoot . '/player-settings.json'))->toBeFalse() + ->and(is_dir($launch->dataRoot . '/saves'))->toBeFalse(); + } finally { + $launch->destroy(); + } - $system = require $systemPath; - expect($system['startingPositions']['player']['destinationMap'])->toBe('test-map'); - expect($system['startingPositions']['player']['spawnPoint'])->toBe(['x' => 12, 'y' => 5]); - // Untouched system data survives. - expect($system['party']['members'])->toBe(['Kaelion']); + expect(file_get_contents($root . '/.data/saves/slot-1.json'))->toBe('{}'); +}); - // Other data files still symlink to the originals. - expect(is_link($overlay->root . '/assets/Data/quests.php'))->toBeTrue(); +it('says where each kind of playtest starts in the launch context the Engine reads', function (): void { + $root = playtestProject(); + $launches = [ + 'title' => PlaytestLaunch::fromTitle($root), + 'cinematic' => PlaytestLaunch::fromCinematic($root, 'test-map', 2, 2, 'dawn-crossing'), + 'battle' => PlaytestLaunch::fromBattleTest($root, ['troop' => 'Pair', 'members' => [['actor' => 'Kaelion', 'level' => 3]]]), + ]; - // Saves are isolated from the project's own. - expect(is_dir($overlay->root . '/.data'))->toBeTrue(); - expect(is_link($overlay->root . '/.data'))->toBeFalse(); + try { + foreach ($launches as $launch) { + putenv(GameLaunchContext::ENVIRONMENT_VARIABLE . '=' . $launch->buildEnvironment()[GameLaunchContext::ENVIRONMENT_VARIABLE]); + $contexts[] = GameLaunchContext::consumeEnvironment(); + } + [$title, $cinematic, $battle] = $contexts; + + expect($title->mode)->toBe(GameStartupMode::TITLE) + ->and($title->start)->toBeNull() + ->and($title->dataRoot)->toBe($launches['title']->dataRoot) + ->and($cinematic->mode)->toBe(GameStartupMode::NEW_GAME) + ->and([$cinematic->start->mapId, $cinematic->start->x, $cinematic->start->y])->toBe(['test-map', 2, 2]) + ->and($cinematic->cinematicId)->toBe('dawn-crossing') + ->and($battle->mode)->toBeNull() + ->and($battle->battleTest->troop)->toBe('Pair') + ->and($battle->battleTest->toArray())->toBe(['troop' => 'Pair', 'members' => [['actor' => 'Kaelion', 'level' => 3]]]); } finally { - $overlay->destroy(); + array_walk($launches, static fn(PlaytestLaunch $launch) => $launch->destroy()); } - - expect(is_dir($overlay->root))->toBeFalse(); - expect(checksumTree($root))->toBe($before); - - removeDirectoryRecursively($root); }); -it('destroys the overlay without following symlinks into the project', function (): void { - $root = makeTemporaryProject(); - file_put_contents($root . '/assets/Data/system.php', " ['player' => []]];\n"); +it('hands the play command the launch context without a start mode that would compete with it', function (): void { + $root = playtestProject(); + putenv(GameStartupMode::ENVIRONMENT_VARIABLE . '=title'); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 1, 1); - $questsPath = $root . '/assets/Data/quests.php'; - $before = checksumTree($root); + try { + $environment = $launch->buildEnvironment(); - $overlay = PlaytestOverlay::create($root, 'test-map', 1, 1); - $overlay->destroy(); + expect($environment)->not->toHaveKey(GameStartupMode::ENVIRONMENT_VARIABLE) + ->and(json_decode($environment[GameLaunchContext::ENVIRONMENT_VARIABLE], true))->toBe($launch->getContext()); + } finally { + putenv(GameStartupMode::ENVIRONMENT_VARIABLE); + $launch->destroy(); + } +}); - expect(is_file($questsPath))->toBeTrue(); - expect(is_dir($root . '/assets/Maps/test-map'))->toBeTrue(); - expect(checksumTree($root))->toBe($before); +it('refuses a start the Engine would not read, leaving no data root behind', function (): void { + $root = playtestProject(); + $before = listPlaytestDataRoots(); - removeDirectoryRecursively($root); + expect(fn() => PlaytestLaunch::fromCell($root, 'test-map', -1, 0))->toThrow(RuntimeException::class, 'The game cannot start that way') + ->and(fn() => PlaytestLaunch::fromCinematic($root, 'test-map', 0, 0, 'Not A Stable Id'))->toThrow(RuntimeException::class) + ->and(listPlaytestDataRoots())->toBe($before); }); -it('refuses a playtest when system.php cannot be rewritten', function (): void { +it('plays a project whose system data holds PHP objects, since nothing is rewritten for a playtest', function (): void { $root = makeTemporaryProject(); - // An object carrying state with no constructor to put it back cannot be - // written out, so the overlay refuses rather than losing it. file_put_contents( $root . '/assets/Data/system.php', "kind = 'party';\n\nreturn ['startingPositions' => ['player' => ['scope' => \$scope]]];\n", ); - expect(fn() => PlaytestOverlay::create($root, 'test-map', 1, 1)) - ->toThrow(RuntimeException::class, 'system.php contains'); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 1, 1); + $launch->destroy(); - removeDirectoryRecursively($root); + expect(is_dir($launch->dataRoot))->toBeFalse(); }); -it('runs the play command against the overlay without tmux', function (): void { - $root = makeTemporaryProject(); - file_put_contents($root . '/assets/Data/system.php', " ['player' => []]];\n"); - - $overlay = PlaytestOverlay::create($root, 'test-map', 3, 4); +it('runs the play command in this terminal from the project root without tmux', function (): void { + $root = playtestProject(); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 3, 4); try { - $launcher = PlaytestLauncher::discover(__FILE__); - $command = $launcher->buildCommand($overlay); - - expect($command)->toContain('play') - ->toContain('--no-tmux') - ->toContain(escapeshellarg($overlay->root)) - ->toContain(escapeshellarg(__FILE__)); + expect(PlaytestLauncher::discover(__FILE__)->buildPlayArguments($launch)) + ->toBe([PHP_BINARY, __FILE__, 'play', '--no-tmux', '-d', realpath($root)]); } finally { - $overlay->destroy(); + $launch->destroy(); } - - removeDirectoryRecursively($root); }); it('prefers the console the project itself installed', function (): void { $root = makeTemporaryProject(); mkdir($root . '/vendor/bin', 0o777, true); touch($root . '/vendor/bin/ichiloto'); - file_put_contents($root . '/assets/Data/system.php', " ['player' => []]];\n"); - - $overlay = PlaytestOverlay::create($root, 'test-map', 0, 0); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 0, 0); try { // How a game project installs the tooling, and the only copy // guaranteed to match the engine that project depends on. - expect(PlaytestLauncher::discover(projectRoot: $root)->buildCommand($overlay)) - ->toContain(escapeshellarg($root . '/vendor/bin/ichiloto')); + expect(PlaytestLauncher::discover(projectRoot: $root)->buildPlayArguments($launch)[1]) + ->toBe($root . '/vendor/bin/ichiloto'); } finally { - $overlay->destroy(); + $launch->destroy(); removeDirectoryRecursively($root); } }); @@ -167,17 +183,16 @@ function checksumTree(string $directory): array putenv('PATH=' . $binDirectory); $root = makeTemporaryProject(); - file_put_contents($root . '/assets/Data/system.php', " ['player' => []]];\n"); - $overlay = PlaytestOverlay::create($root, 'test-map', 0, 0); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 0, 0); try { // `composer global require ichiloto/console`, which is an install // with no vendor directory anywhere near the project. - expect(PlaytestLauncher::discover()->buildCommand($overlay)) - ->toContain(escapeshellarg($binDirectory . '/ichiloto')); + expect(PlaytestLauncher::discover()->buildPlayArguments($launch)[1]) + ->toBe($binDirectory . '/ichiloto'); } finally { putenv('PATH=' . $previousPath); - $overlay->destroy(); + $launch->destroy(); removeDirectoryRecursively($root); @unlink($binDirectory . '/ichiloto'); @rmdir($binDirectory); @@ -213,13 +228,14 @@ function checksumTree(string $directory): array }); /** - * A stand-in console: it records its arguments, starts a child that would - * outlive it, and either keeps running or exits with the code given. + * A stand-in console: it records its arguments and the launch context it was + * given, starts a child that would outlive it, and either keeps running or + * exits with the code given. */ function writeFakeConsole(string $directory, ?int $exitCode = null): string { $path = $directory . '/fake-console.php'; - file_put_contents($path, 'root . '/.data/player-settings.json'))->toBe('{"audio":{"music":false,"sfx":false}}') - ->and(is_link($overlay->root . '/.data/player-settings.json'))->toBeFalse() - ->and(is_dir($overlay->root . '/.data/saves'))->toBeFalse(); - } finally { - $overlay->destroy(); - } - - removeDirectoryRecursively($root); -}); - it('runs a playtest in the background with its renderer and stops the whole game', function (): void { $root = playtestProject(); $scratch = playtestScratch(); - $overlay = PlaytestOverlay::create($root, 'test-map', 2, 3); - $run = (new PlaytestLauncher(writeFakeConsole($scratch)))->start($overlay, 'gpui', $scratch . '/play.log'); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 2, 3); + $run = (new PlaytestLauncher(writeFakeConsole($scratch)))->start($launch, 'gpui'); try { $deadline = microtime(true) + 5; - while (! str_contains((string) @file_get_contents($scratch . '/play.log'), 'play') && microtime(true) < $deadline) { + while (! str_contains((string) @file_get_contents($launch->getLogPath()), 'dataRoot') && microtime(true) < $deadline) { usleep(20000); } $pid = $run->getProcessId(); $children = trim((string) shell_exec('pgrep -P ' . $pid)); expect($run->isRunning())->toBeTrue() - ->and(file_get_contents($scratch . '/play.log'))->toContain('play --no-tmux --no-interaction --start=new-game --renderer=gpui --terminal-window -d ' . $overlay->root) + ->and($run->readLogTail())->toContain('play --no-tmux --no-interaction --renderer=gpui --terminal-window -d ' . realpath($root)) + ->and($run->readLogTail())->toContain(json_encode($launch->getContext(), JSON_UNESCAPED_SLASHES)) ->and($children)->not->toBe(''); $run->stop(); usleep(100000); expect($run->isRunning())->toBeFalse() ->and($run->wasStopped())->toBeTrue() - ->and(is_dir($overlay->root))->toBeFalse() + ->and(is_dir($launch->dataRoot))->toBeFalse() // The child the play command started went with it. ->and(trim((string) shell_exec('ps -p ' . (int) $children . ' -o pid=')))->toBe(''); } finally { - // A failed expectation still stops the game it started and takes its overlay with it. + // A failed expectation still stops the game it started and takes its data root with it. $run->stop(); - $overlay->destroy(); + $launch->destroy(); } }); -it('reports how a failed playtest ended and removes its overlay', function (): void { +it('reports how a failed playtest ended once its data root is gone', function (): void { $root = playtestProject(); $scratch = playtestScratch(); - $overlay = PlaytestOverlay::create($root, 'test-map', 0, 0); - $run = (new PlaytestLauncher(writeFakeConsole($scratch, 3)))->start($overlay, 'gpui', $scratch . '/play.log'); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 0, 0); + $run = (new PlaytestLauncher(writeFakeConsole($scratch, 3)))->start($launch, 'gpui'); try { $deadline = microtime(true) + 5; @@ -308,10 +306,10 @@ function playtestProject(): string } expect($run->getExitCode())->toBe(3) - ->and($run->readLogTail())->toContain('renderer missing') - ->and(is_dir($overlay->root))->toBeFalse(); + ->and(is_dir($launch->dataRoot))->toBeFalse() + ->and($run->readLogTail())->toContain('renderer missing'); } finally { $run->stop(); - $overlay->destroy(); + $launch->destroy(); } }); diff --git a/tests/Unit/Session/BattleTestSessionTest.php b/tests/Unit/Session/BattleTestSessionTest.php index 862f5c2..f4e4ae7 100644 --- a/tests/Unit/Session/BattleTestSessionTest.php +++ b/tests/Unit/Session/BattleTestSessionTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use Ichiloto\Editor\Playtest\PlaytestOverlay; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Session\EditorSession; use Ichiloto\Editor\Session\SessionRefusal; @@ -81,23 +80,35 @@ expect(fn() => $session->startBattleTest(0))->toThrow(SessionRefusal::class, 'first; the battle reads them on disk'); }); -it('plays a battle test from an overlay carrying the battle test as it stands, never writing the project', function () { +it('plays a battle test from the project root with the battle test as it stands, never writing the project', function () { $root = battleTestProject(); $system = $root . '/assets/Data/system.php'; $before = (string) file_get_contents($system); - $entry = ['troop' => 'Pair', 'members' => [['actor' => 'Kaelion', 'level' => 3]]]; - - $overlay = PlaytestOverlay::createForBattle($root, $entry); - $copied = require $overlay->root . '/assets/Data/system.php'; - $emptied = PlaytestOverlay::createForBattle($root, []); - $none = require $emptied->root . '/assets/Data/system.php'; - $overlay->destroy(); - $emptied->destroy(); - - expect($copied[ProjectBattleTest::SYSTEM_KEY])->toBe($entry) - ->and($copied['startingParty'])->toBe(['Kaelion']) - ->and($none)->not->toHaveKey(ProjectBattleTest::SYSTEM_KEY) - ->and(file_get_contents($system))->toBe($before); + // The stand-in console reports the launch context it was given. + $console = $root . '/fake-console.php'; + file_put_contents($console, 'applyBattleTest(['members' => [['actor' => 'Kaelion', 'level' => 3]]]); + $session->startBattleTest(0); + $deadline = microtime(true) + 5; + while ($session->describeBattleTestRun()['running'] && microtime(true) < $deadline) { + usleep(20000); + } + $log = (string) $session->describeBattleTestRun()['log']; + $context = json_decode(substr($log, 0, strpos($log, ' play ')), true); + + expect($context['battleTest'])->toEqual(['troop' => 'Pair', 'members' => [['actor' => 'Kaelion', 'level' => 3]]]) + ->and($context)->not->toHaveKey('mode') + ->and($log)->toContain(' play --no-tmux --no-interaction --renderer=gpui --terminal-window -d ' . realpath($root)) + ->and(is_dir($context['dataRoot']))->toBeFalse() + ->and(file_get_contents($system))->toBe($before); + } finally { + putenv('ICHILOTO_CONSOLE_BIN'); + } }); it('describes a draft the dialog is editing without writing it', function () { diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php index f04bb92..6c7ea6a 100644 --- a/tests/Unit/Session/EditorSessionTest.php +++ b/tests/Unit/Session/EditorSessionTest.php @@ -434,14 +434,13 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a it('plays from the title with the project\'s own start, and only with renderers it can launch', function () { $root = makeTemporaryProject(); file_put_contents($root . '/assets/Data/system.php', " ['player' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 2, 'y' => 1]]]];\n"); - // The fake console lists GPUI as usable and the terminal as not; the fake game reports the start position its - // overlay gives it and how it was launched, then ends. + // The fake console lists GPUI as usable and the terminal as not; the fake game reports the launch context it + // was given and how it was launched, then ends. $console = $root . '/fake-console.php'; file_put_contents($console, ' "ichiloto.renderers/1", "preferredId" => "gpui",' . ' "renderers" => [["id" => "gpui", "displayName" => "GPUI", "available" => true, "reason" => null],' . ' ["id" => "terminal", "displayName" => "Terminal", "available" => false, "reason" => "no terminal application found"]]]); exit(0); }' - . ' $d = $argv[array_search("-d", $argv) + 1]; $s = require $d . "/assets/Data/system.php";' - . ' fwrite(STDERR, json_encode($s["startingPositions"]["player"]) . " " . implode(" ", array_slice($argv, 1)) . "\n"); exit(3);'); + . ' fwrite(STDERR, getenv("ICHILOTO_LAUNCH_CONTEXT") . " " . implode(" ", array_slice($argv, 1)) . "\n"); exit(3);'); putenv('ICHILOTO_CONSOLE_BIN=' . $console); try { @@ -466,8 +465,10 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a while ($session->describePlaytest()['running'] && microtime(true) < $deadline) { usleep(20000); } - expect($session->describePlaytest()['log'])->toContain('{"destinationMap":"test-map","spawnPoint":{"x":2,"y":1}}') - ->toContain('--start=title')->toContain('--renderer=auto')->toContain('--terminal-window'); + // The game plays the project's own root, at its title, with its data kept apart. + expect($session->describePlaytest()['log'])->toContain('{"mode":"title","dataRoot":"') + ->toContain('--renderer=auto')->toContain('--terminal-window')->toContain('-d ' . realpath($root)) + ->not->toContain('--start'); // From a cell, a new game starts on that cell, skipping the title. $session->startPlaytest('test-map', 1, 1); @@ -475,8 +476,7 @@ function inspectorRow(array $inspector, string $label, ?string $after = null): a while ($session->describePlaytest()['running'] && microtime(true) < $deadline) { usleep(20000); } - expect($session->describePlaytest()['log'])->toContain('{"destinationMap":"test-map","spawnPoint":{"x":1,"y":1}') - ->toContain('--start=new-game'); + expect($session->describePlaytest()['log'])->toContain('{"mode":"new-game","start":{"mapId":"test-map","x":1,"y":1},"dataRoot":"'); } finally { putenv('ICHILOTO_CONSOLE_BIN'); } From 31e528e3e10601f725aba3e5cff03047d9f73820 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 13 Oct 2026 04:58:08 +0200 Subject: [PATCH 272/344] feat(database): author whether a level up restores HP and MP System gains Level Up Restores HP/MP, the Engine's progression.restoreVitalsOnLevelUp. An absent rule reads as the Engine's default, on; setting it writes true or false and keeps the rest of system.php. Project validation reports progression rules the Engine would not read as an error, through its own ProgressionSettings. --- docs/manual.md | 2 +- src/Database/RecordSchemaCatalog.php | 8 +++-- src/Validation/ProgressionValidator.php | 36 +++++++++++++++++++ src/Validation/ProjectValidator.php | 1 + tests/Unit/ProjectSystemDatabaseTest.php | 44 +++++++++++++++++++++++- 5 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 src/Validation/ProgressionValidator.php diff --git a/docs/manual.md b/docs/manual.md index e52fdd8..d251ce1 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1140,7 +1140,7 @@ skill's effects, a quest's rewards) listed under them. | Optimize Weights | `assets/Data/equipment-optimization.php` | Editable | Same | | Optimize Outcomes | `assets/Data/equipment-optimization.php` | Editable | Same | | Optimize Exclusions | `assets/Data/equipment-optimization.php` | Editable | Same | -| System | `assets/Data/system.php` | Editable - title, starting gold, party, inventory and position, battle engine, openings and time gauge settings | Same | +| System | `assets/Data/system.php` | Editable - title, starting gold, party, inventory and position, battle engine, openings and time gauge settings, and whether a level up restores HP and MP (on unless turned off) | Same | | Configuration | `config.php` (`save`, `accessibility`, `ui`, `graphics`, `audio`, `inn`) | Editable - one row per setting, typed by what it holds; field zoom is kept from 1 to 8 | Same | | Types | `assets/Data/system.php` | Editable - the project's elements, one per row; an empty list means the Engine's defaults. Weapon, armor and equipment types are the Engine's own | Same | | Terms | `config.php` (`vocab`, `messages`) | Literal terms editable; comments and unrelated expressions preserved | Same | diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 0f918ab..f748f4a 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -897,8 +897,8 @@ private static function optimizeExclusions(): RecordSchema /** * System — `assets/Data/system.php`, one map the engine reads at start: - * the title, starting gold, party, inventory and position, and battle - * settings. Its elements are the Types category's. The file is the category's one record, + * the title, starting gold, party, inventory and position, battle + * settings, and progression rules. Its elements are the Types category's. The file is the category's one record, * only ever edited. The starting party and inventory are lists of their * own; a party member is a bare actor id, as the file authors it. * @@ -934,13 +934,15 @@ private static function system(): RecordSchema new RecordField('battle.activeTime.speedFactorPercent', 'Time Gauge Speed Factor %', InputControlType::INTEGER), new RecordField('battle.activeTime.openingVariance', 'Time Gauge Opening Variance', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField('battle.activeTime.openingSpeedFactorPercent', 'Time Gauge Opening Speed %', InputControlType::INTEGER, removeWhenEmpty: true), + // The Engine's ProgressionSettings: a level gained refills HP and MP unless the project turns it off. + RecordField::boolean('progression.restoreVitalsOnLevelUp', 'Level Up Restores HP/MP', removeWhenEmpty: false, displayDefault: 'true'), // Test settings, as RPG Maker keeps its Battle Test: normal play never reads them. new RecordField(ProjectBattleTest::SYSTEM_KEY, 'Battle Test', removeWhenEmpty: true, codec: RecordFieldCodec::BATTLE_TEST), ], labelKey: 'title', identityKey: null, // The keys System owns; its elements are the Types category's. - projection: new WholeFileProjection(['title', 'currency', 'startingPositions', 'startingParty', 'startingInventory', 'battle', ProjectBattleTest::SYSTEM_KEY]), + projection: new WholeFileProjection(['title', 'currency', 'startingPositions', 'startingParty', 'startingInventory', 'battle', 'progression', ProjectBattleTest::SYSTEM_KEY]), subLists: [ new RecordSubList( key: 'startingParty', diff --git a/src/Validation/ProgressionValidator.php b/src/Validation/ProgressionValidator.php new file mode 100644 index 0000000..32903f0 --- /dev/null +++ b/src/Validation/ProgressionValidator.php @@ -0,0 +1,36 @@ +getRecordDatabase('system')?->getRecordByIndex(0)?->toArray(); + if (! is_array($system) || ! array_key_exists('progression', $system)) { + return []; + } + try { + ProgressionSettings::fromArray($system['progression']); + } catch (InvalidArgumentException $invalid) { + return [Issue::error(self::WHERE, 'The game cannot read its progression rules: ' . $invalid->getMessage(), + 'Set Level Up Restores HP/MP in Database > System, which writes true or false, and remove any other progression key.')]; + } + + return []; + } +} diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 1231833..2f6a88d 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -146,6 +146,7 @@ public function validate(ProjectWorkspace $workspace): array ...new CounterAttackValidator()->validate($workspace), ...new TroopFormationValidator()->validate($workspace), ...new BattleTestValidator()->validate($workspace), + ...new ProgressionValidator()->validate($workspace), ...$this->checkDefinitionIdentities($workspace), ...$this->checkActorDefinitions($workspace), ...new ActorReferenceValidator()->validate($workspace), diff --git a/tests/Unit/ProjectSystemDatabaseTest.php b/tests/Unit/ProjectSystemDatabaseTest.php index a13a228..6cfef99 100644 --- a/tests/Unit/ProjectSystemDatabaseTest.php +++ b/tests/Unit/ProjectSystemDatabaseTest.php @@ -120,7 +120,7 @@ function productionSystem(): array expect($records['editable'])->toBeTrue() ->and($records['canCreate'])->toBeFalse() - ->and($labels)->toContain('Title', 'Starting Gold', 'Start Map', 'Battle Engine', 'Starting Party', 'Starting Inventory'); + ->and($labels)->toContain('Title', 'Starting Gold', 'Start Map', 'Battle Engine', 'Level Up Restores HP/MP', 'Starting Party', 'Starting Inventory'); }); it('keeps a value written as an enum case\'s value written that way, as the new case', function (): void { @@ -180,3 +180,45 @@ function productionSystem(): array projection: new Ichiloto\Editor\Database\Projections\WholeFileProjection(['elements']), ))->toThrow(LogicException::class, 'The stray category edits "title", a key its file projection does not own.'); }); + +it('sets whether a level up restores HP and MP, reading an absent rule as the Engine\'s default, and keeps the rest', function (): void { + $root = scratchSystemProject(); + $path = $root . '/assets/Data/system.php'; + file_put_contents($path, "fields, + static fn($field): bool => $field->key === 'progression.restoreVitalsOnLevelUp'); + + $database = systemRecords($root); + $database->setField(0, 'progression.restoreVitalsOnLevelUp', 'false'); + $database->save(); + $saved = require $path; + + expect($field->displayDefault)->toBe('true') + ->and(Ichiloto\Engine\Progression\ProgressionSettings::fromSystemData(productionSystem())->restoreVitalsOnLevelUp)->toBeTrue() + ->and($saved)->toBe([...productionSystem(), 'progression' => ['restoreVitalsOnLevelUp' => false]]) + ->and(Ichiloto\Engine\Progression\ProgressionSettings::fromSystemData($saved)->restoreVitalsOnLevelUp)->toBeFalse(); + + $database = systemRecords($root); + $database->setField(0, 'progression.restoreVitalsOnLevelUp', 'true'); + $database->save(); + + expect((require $path)['progression'])->toBe(['restoreVitalsOnLevelUp' => true]); +}); + +it('reports progression rules the Engine would not read as an error', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/system.php'; + $system = is_file($path) ? require $path : []; + $validate = static fn(): array => array_values(array_filter( + new Ichiloto\Editor\Validation\ProgressionValidator()->validate(Ichiloto\Editor\ProjectWorkspace::fromProject($root)), + static fn($issue): bool => str_contains($issue->where, 'progression'))); + + expect($validate())->toBe([]); + + file_put_contents($path, " ['restoreVitalsOnLevelUp' => 'no']], true) . ";\n"); + $issues = $validate(); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Ichiloto\Editor\Validation\Severity::ERROR) + ->and($issues[0]->message)->toContain('must be a boolean'); +}); From ed7416701eed98909b38647719c8e5f2e502964c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 13 Oct 2026 05:13:03 +0200 Subject: [PATCH 273/344] feat(playtest): hand the launch to the console as --launch-context The play command now receives the launch context as its option, so the Console can refuse a project Engine that cannot read it before the game runs, keep the game's logs in the playtest's data root, and give the context to the game process alone. The playtest environment no longer carries ICHILOTO_LAUNCH_CONTEXT, and drops any inherited one with the inherited start mode. --- src/Playtest/PlaytestLaunch.php | 25 +++++++++++++------- src/Playtest/PlaytestLauncher.php | 4 ++-- tests/Unit/PlaytestTest.php | 23 ++++++++++-------- tests/Unit/Session/BattleTestSessionTest.php | 5 ++-- tests/Unit/Session/EditorSessionTest.php | 2 +- 5 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/Playtest/PlaytestLaunch.php b/src/Playtest/PlaytestLaunch.php index 4570883..b78bdc5 100644 --- a/src/Playtest/PlaytestLaunch.php +++ b/src/Playtest/PlaytestLaunch.php @@ -14,8 +14,9 @@ /** * One playtest of the author's real project, *without writing a single byte * into it*: the game runs from the project's own root and reads its own - * assets, and the Engine's launch context says where it starts and where its - * player data lives. + * assets, and the Engine's launch context, which the play command carries to + * it (`--launch-context`), says where it starts and where its player data + * lives. * * The launch owns a fresh data root outside the project, holding a copy of * the author's player settings (so a playtest honours their volume and mute) @@ -90,10 +91,9 @@ public function getLogPath(): string } /** - * The environment the play command runs with: this one's, carrying the - * launch context, and without an inherited start mode that would compete - * with it. The Engine consumes the context once and clears it, so it never - * reaches a process the game starts. + * The environment the play command runs with: this one's, without an + * inherited start mode or launch context that would compete with the + * launch the play command is given ({@see buildPlayOption()}). * * @return array */ @@ -101,12 +101,21 @@ public function buildEnvironment(): array { $environment = getenv(); $environment = is_array($environment) ? $environment : []; - unset($environment[GameStartupMode::ENVIRONMENT_VARIABLE]); - $environment[GameLaunchContext::ENVIRONMENT_VARIABLE] = self::encodeContext($this->context); + unset($environment[GameStartupMode::ENVIRONMENT_VARIABLE], $environment[GameLaunchContext::ENVIRONMENT_VARIABLE]); return $environment; } + /** + * The play command's option carrying the launch: the Console checks the + * project's Engine can read it, keeps the game's logs in the data root, + * and hands it to the game process alone. + */ + public function buildPlayOption(): string + { + return '--launch-context=' . self::encodeContext($this->context); + } + /** * The launch context as the Engine reads it. * diff --git a/src/Playtest/PlaytestLauncher.php b/src/Playtest/PlaytestLauncher.php index 9e5d555..4c08d48 100644 --- a/src/Playtest/PlaytestLauncher.php +++ b/src/Playtest/PlaytestLauncher.php @@ -45,7 +45,7 @@ public static function discover(?string $override = null, ?string $projectRoot = */ public function buildPlayArguments(PlaytestLaunch $launch): array { - return [PHP_BINARY, $this->consoleBinary, 'play', '--no-tmux', '-d', $launch->projectRoot]; + return [PHP_BINARY, $this->consoleBinary, 'play', '--no-tmux', $launch->buildPlayOption(), '-d', $launch->projectRoot]; } /** The renderer choice that lets the console pick: GPUI when it is installed and usable, otherwise the terminal. */ @@ -73,7 +73,7 @@ public function start(PlaytestLaunch $launch, string $rendererId): PlaytestRun $pipes = []; $process = @proc_open( [PHP_BINARY, $this->consoleBinary, 'play', '--no-tmux', '--no-interaction', '--renderer=' . $rendererId, - '--terminal-window', '-d', $launch->projectRoot], + '--terminal-window', $launch->buildPlayOption(), '-d', $launch->projectRoot], [0 => ['file', '/dev/null', 'r'], 1 => $log, 2 => $log], $pipes, $launch->projectRoot, diff --git a/tests/Unit/PlaytestTest.php b/tests/Unit/PlaytestTest.php index 4b0eba8..1b29d67 100644 --- a/tests/Unit/PlaytestTest.php +++ b/tests/Unit/PlaytestTest.php @@ -88,7 +88,7 @@ function listPlaytestDataRoots(): array try { foreach ($launches as $launch) { - putenv(GameLaunchContext::ENVIRONMENT_VARIABLE . '=' . $launch->buildEnvironment()[GameLaunchContext::ENVIRONMENT_VARIABLE]); + putenv(GameLaunchContext::ENVIRONMENT_VARIABLE . '=' . substr($launch->buildPlayOption(), strlen('--launch-context='))); $contexts[] = GameLaunchContext::consumeEnvironment(); } [$title, $cinematic, $battle] = $contexts; @@ -107,18 +107,22 @@ function listPlaytestDataRoots(): array } }); -it('hands the play command the launch context without a start mode that would compete with it', function (): void { +it('hands the play command the launch as its option, with no start mode or context of its own to compete', function (): void { $root = playtestProject(); putenv(GameStartupMode::ENVIRONMENT_VARIABLE . '=title'); + putenv(GameLaunchContext::ENVIRONMENT_VARIABLE . '={"mode":"title"}'); $launch = PlaytestLaunch::fromCell($root, 'test-map', 1, 1); try { $environment = $launch->buildEnvironment(); expect($environment)->not->toHaveKey(GameStartupMode::ENVIRONMENT_VARIABLE) - ->and(json_decode($environment[GameLaunchContext::ENVIRONMENT_VARIABLE], true))->toBe($launch->getContext()); + ->and($environment)->not->toHaveKey(GameLaunchContext::ENVIRONMENT_VARIABLE) + ->and($launch->buildPlayOption())->toStartWith('--launch-context=') + ->and(json_decode(substr($launch->buildPlayOption(), strlen('--launch-context=')), true))->toBe($launch->getContext()); } finally { putenv(GameStartupMode::ENVIRONMENT_VARIABLE); + putenv(GameLaunchContext::ENVIRONMENT_VARIABLE); $launch->destroy(); } }); @@ -151,7 +155,7 @@ function listPlaytestDataRoots(): array try { expect(PlaytestLauncher::discover(__FILE__)->buildPlayArguments($launch)) - ->toBe([PHP_BINARY, __FILE__, 'play', '--no-tmux', '-d', realpath($root)]); + ->toBe([PHP_BINARY, __FILE__, 'play', '--no-tmux', $launch->buildPlayOption(), '-d', realpath($root)]); } finally { $launch->destroy(); } @@ -228,14 +232,13 @@ function listPlaytestDataRoots(): array }); /** - * A stand-in console: it records its arguments and the launch context it was - * given, starts a child that would outlive it, and either keeps running or - * exits with the code given. + * A stand-in console: it records its arguments, starts a child that would + * outlive it, and either keeps running or exits with the code given. */ function writeFakeConsole(string $directory, ?int $exitCode = null): string { $path = $directory . '/fake-console.php'; - file_put_contents($path, 'isRunning())->toBeTrue() - ->and($run->readLogTail())->toContain('play --no-tmux --no-interaction --renderer=gpui --terminal-window -d ' . realpath($root)) - ->and($run->readLogTail())->toContain(json_encode($launch->getContext(), JSON_UNESCAPED_SLASHES)) + ->and($run->readLogTail())->toContain('play --no-tmux --no-interaction --renderer=gpui --terminal-window ' + . $launch->buildPlayOption() . ' -d ' . realpath($root)) ->and($children)->not->toBe(''); $run->stop(); diff --git a/tests/Unit/Session/BattleTestSessionTest.php b/tests/Unit/Session/BattleTestSessionTest.php index f4e4ae7..f493f0e 100644 --- a/tests/Unit/Session/BattleTestSessionTest.php +++ b/tests/Unit/Session/BattleTestSessionTest.php @@ -84,9 +84,10 @@ $root = battleTestProject(); $system = $root . '/assets/Data/system.php'; $before = (string) file_get_contents($system); - // The stand-in console reports the launch context it was given. + // The stand-in console reports the launch context it was given, then its other arguments. $console = $root . '/fake-console.php'; - file_put_contents($console, ' "ichiloto.renderers/1", "preferredId" => "gpui",' . ' "renderers" => [["id" => "gpui", "displayName" => "GPUI", "available" => true, "reason" => null],' . ' ["id" => "terminal", "displayName" => "Terminal", "available" => false, "reason" => "no terminal application found"]]]); exit(0); }' - . ' fwrite(STDERR, getenv("ICHILOTO_LAUNCH_CONTEXT") . " " . implode(" ", array_slice($argv, 1)) . "\n"); exit(3);'); + . ' fwrite(STDERR, implode(" ", array_slice($argv, 1)) . "\n"); exit(3);'); putenv('ICHILOTO_CONSOLE_BIN=' . $console); try { From deffba4b9f9aa839b7f3e2074af73489cb8578f3 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 13 Oct 2026 05:54:49 +0200 Subject: [PATCH 274/344] build(deps): lock the Engine at its current develop composer.lock still pinned ichiloto/engine at 2e32b6f from 2026-09-24, so CI installed an Engine without the classes the Editor has used since, and every job failed. The lock now takes Engine develop 739da66, within the existing dev-develop constraint; no other package changes. --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 1449258..816c5ff 100644 --- a/composer.lock +++ b/composer.lock @@ -260,12 +260,12 @@ "source": { "type": "git", "url": "https://github.com/ichiloto/engine.git", - "reference": "2e32b6f68bcfe441b639f57b5098147fe3a224dc" + "reference": "739da6638fe4808d6ed3209e9710bf4210493023" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ichiloto/engine/zipball/2e32b6f68bcfe441b639f57b5098147fe3a224dc", - "reference": "2e32b6f68bcfe441b639f57b5098147fe3a224dc", + "url": "https://api.github.com/repos/ichiloto/engine/zipball/739da6638fe4808d6ed3209e9710bf4210493023", + "reference": "739da6638fe4808d6ed3209e9710bf4210493023", "shasum": "" }, "require": { @@ -306,7 +306,7 @@ "issues": "https://github.com/ichiloto/engine/issues", "source": "https://github.com/ichiloto/engine/tree/develop" }, - "time": "2026-09-24T12:17:01+00:00" + "time": "2026-10-13T03:29:37+00:00" }, { "name": "league/climate", From e359d4a3ab2413ce8bcc051b306716f00d42f268 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 13 Oct 2026 05:54:49 +0200 Subject: [PATCH 275/344] fix(session): declare every result saveMap returns The return type named only the saved result, so PHPStan rejected the relocation question and the cancelled answer, and the saved result's relocated moves were missing. --- src/Session/EditorSession.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 9a4ad7c..bd97909 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -4417,10 +4417,18 @@ public function redo(): array /** * Saves one map through its transaction, after the warnings validation - * has for it, which never block a save. - * - * @return array{saved: string, warnings: list, backupFailures: list} - * @throws SessionRefusal When the map is unknown or refuses to save. + * has for it, which never block a save. When the save would take cells + * existing saves may stand on, nothing is written until the author has + * seen where those saves move: the first call asks, and `write` with the + * question's `confirm` saves the map and the relocation step together. + * + * @return array{saved: string, warnings: list, backupFailures: list, relocated: list} + * |array{status: 'question', map: string, question: string, answers: list, + * relocations: list, to: array{int, int}}>, moves: list, + * contentVersion: int, confirm: string} + * |array{status: 'cancelled', map: string, saved: null} + * @throws SessionRefusal When the map is unknown or refuses to save, the answer is not write or cancel, or the + * relocations changed since they were shown. */ public function saveMap(string $mapId, ?string $answer = null, ?string $confirm = null): array { From 3f35a2918738c9b3a64a96b1b9af37eb273f1b8f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 13 Oct 2026 06:05:49 +0200 Subject: [PATCH 276/344] docs(manual): link the GUI editor's plan and README by their published URLs The manual linked ../../gui-editor/ paths, which resolve only where the GUI editor is checked out beside this repository, so the cross-link test failed in CI. --- docs/manual.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index d251ce1..6d96ce7 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -588,7 +588,7 @@ replace it. RPG Maker-like GUI editor for graphical materials, atlases and crop authoring. The planned `ichiloto edit` entry point will offer a TUI/GUI choice; that choice and the GUI itself are not delivered by this TUI boundary correction. See the -[GUI editor plan](../../gui-editor/docs/plan.md). +[GUI editor plan](https://github.com/ichiloto/gui-editor/blob/develop/docs/plan.md). ### Normal mode @@ -774,7 +774,7 @@ character says is read in the pane rather than in the edit buffer. Existing NPC `sprites2d` data remains preserved when editing terminal glyphs, and its model services and source-preserving validation remain available. Graphical NPC authoring belongs in the pending - [GUI editor](../../gui-editor/README.md), not a terminal form. Terminal base + [GUI editor](https://github.com/ichiloto/gui-editor/blob/develop/README.md), not a terminal form. Terminal base and directional glyphs, identity, dialogue, movement and conditions remain editable as before. This correction does not claim that all other actor or database artwork controls have been removed. From 85ebd58e0198369b5632751a653585b9c521ebb6 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 13 Oct 2026 12:25:08 +0200 Subject: [PATCH 277/344] feat(preview): accept a transfer's completion callback GameScene::transferPlayer is gaining an optional callback that runs once the transfer has committed. The cinematic preview's override now takes the same optional parameter and runs it after the destination is in place, before events resume or anything autosaves, so it stays compatible before and after the Engine change. --- src/Cutscenes/Preview/PreviewGameScene.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Cutscenes/Preview/PreviewGameScene.php b/src/Cutscenes/Preview/PreviewGameScene.php index 753ce06..3776477 100644 --- a/src/Cutscenes/Preview/PreviewGameScene.php +++ b/src/Cutscenes/Preview/PreviewGameScene.php @@ -132,7 +132,7 @@ public function previewCamera(): PreviewCamera return $camera; } - public function transferPlayer(Location $location, bool $useConfiguredTransition = true): bool + public function transferPlayer(Location $location, bool $useConfiguredTransition = true, ?\Closure $onCommitted = null): bool { $this->transfers[] = [ 'map' => $location->mapFilename, @@ -164,6 +164,10 @@ public function transferPlayer(Location $location, bool $useConfiguredTransition $this->camera->resetPosition($this->player); } + // The transfer has committed: its owner's completion runs before events resume or anything saves. + if ($onCommitted !== null) { + $onCommitted(); + } $this->eventInterpreter?->resumeAfterTransfer(); $this->autoSave(); return true; From 5345c5c341994fe21bcb864cfb68e9fda24b9b52 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 13 Oct 2026 12:48:03 +0200 Subject: [PATCH 278/344] fix(preview): complete a transfer only when its map loads 85ebd58 ran a transfer's completion callback even when the preview's map failed to load, so a trigger could complete with no valid destination. The callback now runs only after the destination map actually loaded. A failed map keeps its diagnosed undefined-terrain preview, as before, but completes nothing. A test covers both paths. --- src/Cutscenes/Preview/PreviewGameScene.php | 8 ++++-- .../PreviewLifetimeDiagnosticsTest.php | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/Cutscenes/Preview/PreviewGameScene.php b/src/Cutscenes/Preview/PreviewGameScene.php index 3776477..9266cbf 100644 --- a/src/Cutscenes/Preview/PreviewGameScene.php +++ b/src/Cutscenes/Preview/PreviewGameScene.php @@ -153,8 +153,10 @@ public function transferPlayer(Location $location, bool $useConfiguredTransition $this->currentMapId = $location->mapFilename; + $loaded = false; try { $this->previewMap->loadForPreview($location->mapFilename); + $loaded = $location->mapFilename !== ''; } catch (\Throwable) { // The map manager clears failed geometry and records why; the // cinematic can continue over explicitly diagnosed undefined terrain. @@ -164,8 +166,10 @@ public function transferPlayer(Location $location, bool $useConfiguredTransition $this->camera->resetPosition($this->player); } - // The transfer has committed: its owner's completion runs before events resume or anything saves. - if ($onCommitted !== null) { + // Only a destination that actually loaded commits the transfer, so its owner's completion runs before + // events resume or anything saves. A map that failed keeps its diagnosed undefined-terrain preview but + // never completes the trigger that sent the player there. + if ($loaded && $onCommitted !== null) { $onCommitted(); } $this->eventInterpreter?->resumeAfterTransfer(); diff --git a/tests/Unit/Cutscenes/PreviewLifetimeDiagnosticsTest.php b/tests/Unit/Cutscenes/PreviewLifetimeDiagnosticsTest.php index cc916ae..bf2ac19 100644 --- a/tests/Unit/Cutscenes/PreviewLifetimeDiagnosticsTest.php +++ b/tests/Unit/Cutscenes/PreviewLifetimeDiagnosticsTest.php @@ -373,3 +373,30 @@ function readPreviewContractField(EditorSession $session, bool $effect = false): } expect(sourceHashTree($root))->toBe($before); }); + +it('completes a transfer only when its destination map actually loads, keeping the undefined-terrain preview otherwise', function () { + $root = createPreviewContractProject('valid'); + $session = EditorSession::open($root); + try { + $session->startCinematicPreview(0, 40, 12); + $scene = readPreviewContractField($session)->scene; + $completed = 0; + $complete = static function () use (&$completed): void { $completed++; }; + + // Maps resolve against the project, as the session's own preview calls do. + $transfer = static fn(string $map): bool => Ichiloto\Editor\ProjectDirectoryContext::run($root, + static fn(): bool => $scene->transferPlayer(new Ichiloto\Engine\Field\Location($map, new Ichiloto\Engine\Core\Vector2(2, 3), []), true, $complete)); + $failed = $transfer('no-such-preview-map'); + + expect($failed)->toBeTrue() + ->and($completed)->toBe(0) + ->and(readPreviewContractField($session)->mapFailure)->not->toBeNull(); + + $transfer('harbour'); + + expect(readPreviewContractField($session)->mapFailure)->toBeNull() + ->and($completed)->toBe(1); + } finally { + $session->close(); + } +}); From eb82327ed9e4e633ac386cdae4b70862fa553cd7 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 15 Oct 2026 11:11:19 +0200 Subject: [PATCH 279/344] feat(authoring): author chest-state conditions and terminal symbols The Engine now reads a map_event condition (a map and its one-shot event marker) and a world object's terminal symbol, on the object and per variant, so an opened chest can show its open art in GPUI and turn white in the Terminal. The condition codec writes and reads the marker after the map id, the grammar offers the map picker and a marker field to the GUI editor, and world objects and their variants carry an optional Terminal Symbol that, left empty, falls back to the object's or the map's glyph. The type-cycling test now follows the Engine's own type order. --- src/Database/ConditionCodec.php | 16 ++++++++++++-- src/Database/ConditionEditor.php | 3 +++ src/Field/WorldObjectAuthoring.php | 16 ++++++++++++-- tests/Unit/ConditionEditorTest.php | 18 ++++++++++++++-- .../Unit/Session/WorldObjectAuthoringTest.php | 21 +++++++++++++++++++ 5 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/Database/ConditionCodec.php b/src/Database/ConditionCodec.php index 4b0914b..da90e8d 100644 --- a/src/Database/ConditionCodec.php +++ b/src/Database/ConditionCodec.php @@ -18,8 +18,9 @@ * identically. * * Wire form: entries separated by `;`, each `[!]type:name[:extras]`, where - * extras depend on the type (quest status, switch `false`, item quantity, or - * variable `op:value`). A leading `!` negates. + * extras depend on the type (quest status, switch `false`, item quantity, + * variable `op:value`, or a map event's marker after its map id). A leading + * `!` negates. */ final class ConditionCodec { @@ -80,6 +81,9 @@ public static function encode(array $condition): string $parts[] = strval($condition['op'] ?? '=='); $parts[] = strval($condition['value'] ?? 0); break; + case 'map_event': + $parts[] = strval($condition['marker'] ?? ''); + break; } $negate = ($condition['negate'] ?? false) === true ? '!' : ''; @@ -188,6 +192,14 @@ public static function decode(string $segment): ?array $rawValue = $parts[3] ?? '0'; $condition['value'] = is_numeric($rawValue) ? intval($rawValue) : $rawValue; break; + case 'map_event': + // A map event is named by its map and its one-shot event marker; without a marker it is unreadable. + $marker = $parts[2] ?? ''; + if ($marker === '') { + return null; + } + $condition['marker'] = $marker; + break; } if ($negate) { diff --git a/src/Database/ConditionEditor.php b/src/Database/ConditionEditor.php index fd10c83..63bbac1 100644 --- a/src/Database/ConditionEditor.php +++ b/src/Database/ConditionEditor.php @@ -32,6 +32,7 @@ final class ConditionEditor 'variable' => ['Variable', null], 'item' => ['Item', 'inventory'], 'key_item' => ['Key Item', 'inventory'], + 'map_event' => ['Map Event', 'maps'], ]; /** The quest statuses a condition can require; the first is the default. */ @@ -70,6 +71,7 @@ public static function getGrammar(): array ], 'item' => [['key' => 'quantity', 'label' => 'Quantity', 'kind' => 'integer', 'default' => 1]], 'key_item' => [['key' => 'quantity', 'label' => 'Quantity', 'kind' => 'integer', 'default' => 1]], + 'map_event' => [['key' => 'marker', 'label' => 'Event Marker', 'kind' => 'text', 'default' => '']], ]; return array_map(static fn(string $type): array => [ @@ -407,6 +409,7 @@ public static function describe(array $condition): string : 'is held', 'variable' => sprintf('%s %s', strval($condition['op'] ?? '=='), strval($condition['value'] ?? 0)), 'event' => 'happened', + 'map_event' => sprintf('event %s is done', strval($condition['marker'] ?? '')), default => '', }; diff --git a/src/Field/WorldObjectAuthoring.php b/src/Field/WorldObjectAuthoring.php index 100f476..19fb67e 100644 --- a/src/Field/WorldObjectAuthoring.php +++ b/src/Field/WorldObjectAuthoring.php @@ -93,12 +93,13 @@ public static function validateEntries(ProjectMap $map, array $entries, bool $co } $ids = []; foreach ($variants as $variant) { - if (!is_array($variant) || array_diff(array_keys($variant), ['id', 'conditions', 'sprites2d']) !== [] + if (!is_array($variant) || array_diff(array_keys($variant), ['id', 'conditions', 'sprites2d', 'terminal']) !== [] || !array_key_exists('sprites2d', $variant) || !array_key_exists('conditions', $variant)) { throw new InvalidArgumentException('A variant requires id, conditions and explicit sprites2d.'); } $checked = WorldObjectDefinition::readMap(['worldObjects' => [[ 'id' => $variant['id'] ?? null, 'anchor' => $entry['anchor'], 'pivot' => $entry['pivot'], 'sprites2d' => $variant['sprites2d'], + ...(array_key_exists('terminal', $variant) ? ['terminal' => $variant['terminal']] : []), ]]], $map->getLayerSet()); $id = $checked[0]->id; if (isset($ids[$id])) { throw new InvalidArgumentException('Duplicate world-object variant id.'); } @@ -149,7 +150,7 @@ public function getRecords(): ProjectRecordDatabase 'conditions' => [], 'sprites2d' => null], heading: 'Ordered Variants', removeWhenEmpty: true, getFields: static fn(array $entry): array => [new RecordField('id', 'Variant Id'), new RecordField('conditions', 'When (all conditions)', codec: RecordFieldCodec::CONDITIONS), - ...FieldSpriteFields::getFields($entry, true)], + ...FieldSpriteFields::getFields($entry, true), self::createTerminalField('(the object\'s)')], prepareEdit: static fn(array $entry, string $field): array => FieldSpriteFields::prepareEdit($entry, $field, true)); $cells = new RecordSubList(key: 'covers.cells', prefix: 'cell', singular: 'covered cell', fields: [new RecordField('x', 'X', InputControlType::INTEGER), new RecordField('y', 'Y', InputControlType::INTEGER)], @@ -176,6 +177,7 @@ public function getRecords(): ProjectRecordDatabase new RecordField('anchor.x', 'Anchor X', InputControlType::INTEGER), new RecordField('anchor.y', 'Anchor Y', InputControlType::INTEGER), ...$presentation, + self::createTerminalField('(the map\'s glyph)'), new RecordField('covers.layer', 'Covered Gameplay Layer', options: ['', ...array_column(array_filter($this->map->getLayers(), static fn($layer): bool => !$layer['decoration'] && $layer['id'] !== \Ichiloto\Editor\Maps\MapLayers::EVENT), 'name')], removeWhenEmpty: true, displayDefault: '(no coverage)')]; @@ -222,6 +224,16 @@ public function getRecords(): ProjectRecordDatabase return $records; } + /** + * The one styled terminal cell an object shows at its anchor in the Terminal, as `m`: a chest + * yellow while closed and white once opened. Left empty, the field is removed, so the map's own glyph shows + * for the object, and a variant shows its object's. + */ + private static function createTerminalField(string $default): RecordField + { + return new RecordField('terminal', 'Terminal Symbol', removeWhenEmpty: true, displayDefault: $default); + } + /** * Creates an object at a cell: an inline one with no art yet, or a placement of a named resource. A sign * placement is given the text its board shows, under a translation key of its own id. diff --git a/tests/Unit/ConditionEditorTest.php b/tests/Unit/ConditionEditorTest.php index 96b824f..6fb93b9 100644 --- a/tests/Unit/ConditionEditorTest.php +++ b/tests/Unit/ConditionEditorTest.php @@ -158,8 +158,9 @@ function conditionEditorOn(string $encoded): ConditionEditor $editor->open('conditions', 'Conditions', []); $editor->add(); - // A new condition starts as a switch; four steps on is the key item. - $editor->cycleType(4); + // A new condition starts as a switch; the key item is as many steps on as the Engine lists it. + $types = ConditionCodec::types(); + $editor->cycleType(array_search('key_item', $types, true) - array_search('switch', $types, true)); $editor->setName('Rusty Key'); expect($editor->encoded())->toBe('key_item:Rusty Key'); @@ -179,3 +180,16 @@ function conditionEditorOn(string $encoded): ConditionEditor expect($values)->toContain('breakfast-duty'); }); + +it('writes and reads a map event condition by its map and event marker', function () { + $condition = ['type' => 'map_event', 'name' => 'garden-of-roads/lanternrest-wayhouse', 'marker' => 'C', 'negate' => true]; + $line = ConditionCodec::encode($condition); + expect($line)->toBe('!map_event:garden-of-roads/lanternrest-wayhouse:C') + ->and(ConditionCodec::decode($line))->toBe($condition) + ->and(ConditionCodec::decode('map_event:garden-of-roads/lanternrest-wayhouse'))->toBeNull() + ->and(fn() => ConditionCodec::decodeAllStrictly('map_event:town'))->toThrow(InvalidArgumentException::class) + ->and(ConditionEditor::describe($condition))->toBe('NOT Map Event garden-of-roads/lanternrest-wayhouse event C is done'); + $grammar = array_find(ConditionEditor::getGrammar(), static fn(array $type): bool => $type['type'] === 'map_event'); + expect($grammar['nameCategory'])->toBe('maps') + ->and(array_column($grammar['extras'], 'key'))->toBe(['marker']); +}); diff --git a/tests/Unit/Session/WorldObjectAuthoringTest.php b/tests/Unit/Session/WorldObjectAuthoringTest.php index 137b369..8f460f2 100644 --- a/tests/Unit/Session/WorldObjectAuthoringTest.php +++ b/tests/Unit/Session/WorldObjectAuthoringTest.php @@ -322,3 +322,24 @@ function applyWorldObjectRow(EditorSession $session, string $field, string $valu $session->redo(); expect($session->readMap('test-map')['worldObjects'])->toBe([]); }); + +it('carries terminal symbols for an object and its map-event variant through save and reopen', function () { + [$root, , $session] = createWorldObjectProject(); + $view = $session->readWorldObject('test-map', 'fixture-prop'); + $heading = array_find($view['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === 'variantList'); + $session->changeWorldObjectItem('test-map', $view['revision'], 'fixture-prop', $heading['key'], false); + // A chest: yellow while its one-shot event is open, white once it is done. + applyWorldObjectRow($session, 'variant0Conditions', 'map_event:test-map:C'); + applyWorldObjectRow($session, 'variant0Sprites2dasset', 'Graphics/Props/Replaceable.png'); + applyWorldObjectRow($session, 'variant0Terminal', 'm'); + applyWorldObjectRow($session, 'terminal', 'm'); + $session->saveMap('test-map'); + $object = EditorSession::open($root)->readMap('test-map')['worldObjects'][0]; + expect($object['terminal'])->toBe('m') + ->and($object['variants'][0]['terminal'])->toBe('m') + ->and($object['variants'][0]['conditions'])->toBe([['type' => 'map_event', 'name' => 'test-map', 'marker' => 'C']]); + // Left empty, a variant's symbol goes and the object's shows for it. + applyWorldObjectRow($session, 'variant0Terminal', ''); + expect($session->readMap('test-map')['worldObjects'][0]['variants'][0])->not->toHaveKey('terminal'); + expect(fn() => applyWorldObjectRow($session, 'terminal', 'mm'))->toThrow(SessionRefusal::class); +}); From 3816caf34b4a880a1933b921497c50bd4bd5dc2f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 15 Oct 2026 11:13:52 +0200 Subject: [PATCH 280/344] fix(authoring): start a map event condition at the map's first event marker Every condition type's defaults must make an entry the codec writes and reads back; an empty marker could not. --- src/Database/ConditionEditor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/ConditionEditor.php b/src/Database/ConditionEditor.php index 63bbac1..b54fb4d 100644 --- a/src/Database/ConditionEditor.php +++ b/src/Database/ConditionEditor.php @@ -71,7 +71,7 @@ public static function getGrammar(): array ], 'item' => [['key' => 'quantity', 'label' => 'Quantity', 'kind' => 'integer', 'default' => 1]], 'key_item' => [['key' => 'quantity', 'label' => 'Quantity', 'kind' => 'integer', 'default' => 1]], - 'map_event' => [['key' => 'marker', 'label' => 'Event Marker', 'kind' => 'text', 'default' => '']], + 'map_event' => [['key' => 'marker', 'label' => 'Event Marker', 'kind' => 'text', 'default' => 'A']], ]; return array_map(static fn(string $type): array => [ From dd7b8ec82562f01f2e31ea2c1cf25101194a39a9 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 15 Oct 2026 11:19:23 +0200 Subject: [PATCH 281/344] feat(authoring): choose a map event condition's event from the map it names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A map event condition's marker was free text. The grammar now says its event is chosen from the map_event_markers references of the map the condition names, and the session lists that map's events, each read as what it is ("E · Chest"), so an author picks the chest rather than recalling its letter. --- src/Database/ConditionEditor.php | 4 +++- src/Database/ReferenceCatalog.php | 4 ++++ src/Session/EditorSession.php | 13 +++++++++++++ tests/Unit/Session/NpcSessionTest.php | 11 +++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/Database/ConditionEditor.php b/src/Database/ConditionEditor.php index b54fb4d..8f8cf52 100644 --- a/src/Database/ConditionEditor.php +++ b/src/Database/ConditionEditor.php @@ -71,7 +71,9 @@ public static function getGrammar(): array ], 'item' => [['key' => 'quantity', 'label' => 'Quantity', 'kind' => 'integer', 'default' => 1]], 'key_item' => [['key' => 'quantity', 'label' => 'Quantity', 'kind' => 'integer', 'default' => 1]], - 'map_event' => [['key' => 'marker', 'label' => 'Event Marker', 'kind' => 'text', 'default' => 'A']], + 'map_event' => [['key' => 'marker', 'label' => 'Event', 'kind' => 'text', 'default' => 'A', + // Chosen from the events of the map the condition names. + 'category' => 'map_event_markers', 'mapFrom' => 'name']], ]; return array_map(static fn(string $type): array => [ diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 8342e65..044f502 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -78,6 +78,8 @@ final class ReferenceCatalog 'map_npcs', 'map_world_objects', 'world_object_tile_layers', + // A map event condition's marker, listed for the map that condition names. + 'map_event_markers', 'knowledge_subjects', 'knowledge_reports', 'knowledge_record_types', @@ -256,6 +258,8 @@ public function valuesFor(string $category): array 'map_npcs' => $this->getSubjectMap()?->getNpcs()->ids() ?? [], 'map_world_objects' => $this->getSubjectMap() === null ? [] : array_column(\Ichiloto\Editor\Field\WorldObjectAuthoring::readEntries($this->getSubjectMap()), 'id'), 'world_object_tile_layers' => $this->currentMap === null ? [] : array_column($this->currentMap->describeTileLayers()['layers'], 'name'), + // Only meaningful for a named map; the session lists them for the map a condition names. + 'map_event_markers' => [], // Older references name an animation; current ones store its id. 'animations' => array_map( static fn(ProjectRecord $animation): string => strval($animation->get('name')), diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index bd97909..9f4157e 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -2997,6 +2997,19 @@ public function listReferences(?string $mapId, string $category, ?array $record return array_map(static fn($layer): array => ['value' => $layer['name'], 'label' => $layer['name']], array_values(array_filter($map->describeTileLayers()['layers'], static fn($layer): bool => $layer['owner'] === ($object['covers']['layer'] ?? null)))); } + if ($category === 'map_event_markers') { + // The events of the map a map event condition names, each read as what it is. + $named = is_string($record['map'] ?? null) ? $this->getMapsById()[$record['map']] ?? null : null; + if ($named === null) { return []; } + $events = $named->getMapDataField(['events']); + $choices = []; + foreach (is_array($events) ? $events : [] as $marker => $event) { + $kind = preg_replace('/(EventTrigger|Trigger)$/', '', basename(str_replace('\\', '/', strval($event['class'] ?? '')))); + $what = $event['data']['scriptId'] ?? $event['data']['cinematicId'] ?? $event['data']['destinationMap'] ?? null; + $choices[] = ['value' => (string) $marker, 'label' => trim(sprintf('%s · %s %s', $marker, $kind, is_string($what) ? $what : ''))]; + } + return $choices; + } if ($category === 'cinematic_movement_routes' && $record !== null) { $catalog = $catalog->createMovementContext($this->getMovementOwnerCommands($mapId, $record)); } diff --git a/tests/Unit/Session/NpcSessionTest.php b/tests/Unit/Session/NpcSessionTest.php index a654d79..9f1fb10 100644 --- a/tests/Unit/Session/NpcSessionTest.php +++ b/tests/Unit/Session/NpcSessionTest.php @@ -228,3 +228,14 @@ function npcSessionRow(array $read, string $fieldId): array } } }); + +it('offers the events of the map a map event condition names, each read as what it is', function () { + $session = EditorSession::open(npcSessionProject([])); + $grammar = array_find($session->describeWorldStateGrammar()['conditions'], static fn(array $type): bool => $type['type'] === 'map_event'); + $marker = $grammar['extras'][0]; + expect($marker['category'])->toBe('map_event_markers') + ->and($marker['mapFrom'])->toBe('name') + ->and($session->listReferences('test-map', 'map_event_markers', ['map' => 'test-map'])) + ->toContain(['value' => 'E', 'label' => 'E · Chest']) + ->and($session->listReferences('test-map', 'map_event_markers', ['map' => 'no-such-map']))->toBe([]); +}); From 5f2aff7613b81ab85d8bcc8436a6dadbba1c298d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 15 Oct 2026 11:41:08 +0200 Subject: [PATCH 282/344] feat(authoring): choose each terminal presentation form and pick map events in the Terminal The Engine (3854c86) lets an object present in the Terminal as its map's glyph, a styled symbol at its anchor or a recolour of its covered glyphs (['color' => ...]), and a variant also inherit its object's or restore the map's glyph with an explicit null. A plain text field could not hold the colour form, so each object and variant now has a Terminal Shows choice with its symbol or colour beside it, read from and written back to exactly those forms. Unfinished variants are checked with their object's real coverage, which a footprint colour needs. The shared source writer replaced an array by a single value only by refusing; an array written as plain data, with no comment or expression, now takes the new value, and anything more is still refused. A map event's event is picked from its named map's events in the Terminal Editor too (e), through the existing reference picker, from one list the reference catalog shares with the GUI. --- src/Cutscenes/Source/ArraySourceWriter.php | 24 ++++- src/Database/ConditionEditor.php | 19 ++++ src/Database/ReferenceCatalog.php | 21 ++++ src/Editor.php | 54 ++++++++++- src/Field/WorldObjectAuthoring.php | 95 ++++++++++++++++--- src/Session/EditorSession.php | 15 +-- tests/Unit/ConditionEditorTest.php | 10 ++ .../Unit/Cutscenes/ArraySourceWriterTest.php | 13 +++ .../Unit/Session/WorldObjectAuthoringTest.php | 81 ++++++++++++---- 9 files changed, 290 insertions(+), 42 deletions(-) diff --git a/src/Cutscenes/Source/ArraySourceWriter.php b/src/Cutscenes/Source/ArraySourceWriter.php index 1abc54d..73d40b9 100644 --- a/src/Cutscenes/Source/ArraySourceWriter.php +++ b/src/Cutscenes/Source/ArraySourceWriter.php @@ -157,9 +157,16 @@ private function diff(SourceNode $node, mixed $old, mixed $new, array $path): vo return; } - // A scalar or null now sits here. + // A scalar or null now sits here. An array written as plain data (literals only, no comments) holds nothing + // authored beyond its values, so the new value takes its place; anything more stays refused below. match ($node->kind) { SourceNode::SCALAR => $this->edits[] = $this->replacementFor($node, $path, $new), + SourceNode::ARRAY => $this->isPlainData($node) + ? $this->edits[] = $this->replacementFor($node, $path, $new) + : throw new SourcePreservationRefusal(sprintf( + 'The value at %s is an array in the file and cannot be replaced by a single value without rewriting it.', + PhpArraySourceDocument::describePath($path), + )), SourceNode::VARIABLE => $this->retargetVariable($node, $path, $new), SourceNode::EXPRESSION => throw new SourcePreservationRefusal(sprintf( 'The value at %s is written as an expression the editor cannot rewrite; change it in the file, or write it as plain data.', @@ -172,6 +179,21 @@ private function diff(SourceNode $node, mixed $old, mixed $new, array $path): vo }; } + /** Whether an array's source is only literal keys and values, with no comment, expression or variable in it. */ + private function isPlainData(SourceNode $node): bool + { + foreach ($node->entries as $entry) { + $value = $entry->value; + if ($value->kind === SourceNode::ARRAY ? !$this->isPlainData($value) : $value->kind !== SourceNode::SCALAR) { + return false; + } + } + foreach (\PhpToken::tokenize('document->source, $node->start, $node->end - $node->start)) as $token) { + if ($token->is([T_COMMENT, T_DOC_COMMENT])) { return false; } + } + return ! $node->hasOpaqueKey; + } + /** * @param array|null $old * @param array $new diff --git a/src/Database/ConditionEditor.php b/src/Database/ConditionEditor.php index 8f8cf52..19557cc 100644 --- a/src/Database/ConditionEditor.php +++ b/src/Database/ConditionEditor.php @@ -343,6 +343,25 @@ public function setName(string $name): void $this->conditions[$this->selectedIndex()] = $condition; } + /** + * Sets which event a map event condition waits on, chosen from the events + * of the map it names. + * + * @param string $marker The event's marker. + * @return void + */ + public function setMarker(string $marker): void + { + $condition = $this->selected(); + + if ($condition === null || strval($condition['type'] ?? '') !== 'map_event' || trim($marker) === '') { + return; + } + + $condition['marker'] = trim($marker); + $this->conditions[$this->selectedIndex()] = $condition; + } + /** * Sets a variable condition's value. * diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 044f502..6c528be 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -816,6 +816,27 @@ private function inventoryCatalog(): InventoryCatalog * @param string $category The kind of reference. * @return bool True when it is. */ + /** + * The events of a map a map event condition names, each read as what it is + * ("E · Chest"): what a marker is chosen from in both editors. + * + * @return array{values: list, labels: array} + */ + public function listMapEventMarkers(string $mapId): array + { + $index = array_search($mapId, $this->workspace->mapIds, true); + $map = $index === false ? null : ($this->workspace->maps[$index] ?? null); + $events = $map?->getMapDataField(['events']); + $values = $labels = []; + foreach (is_array($events) ? $events : [] as $marker => $event) { + $kind = preg_replace('/(EventTrigger|Trigger)$/', '', basename(str_replace('\\', '/', strval($event['class'] ?? '')))); + $what = $event['data']['scriptId'] ?? $event['data']['cinematicId'] ?? $event['data']['destinationMap'] ?? null; + $values[] = (string) $marker; + $labels[(string) $marker] = trim(sprintf('%s · %s %s', $marker, $kind, is_string($what) ? $what : '')); + } + return ['values' => $values, 'labels' => $labels]; + } + public static function knows(string $category): bool { return in_array($category, self::CATEGORIES, true); diff --git a/src/Editor.php b/src/Editor.php index fd75773..9aad954 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -525,6 +525,7 @@ final class Editor * condition rather than to set a field. */ private const string CONDITION_NAME_FIELD = '__condition_name'; + private const string CONDITION_MARKER_FIELD = '__condition_marker'; /** * The picker field id used when the map's NPC list is opened to select @@ -10292,6 +10293,7 @@ private function handleConditionEditorInput(string $input): void $input === 'X' => $this->conditionEditor->cycleExtra(-1), $input === '!' => $this->conditionEditor->toggleNegate(), $input === 'n' => $this->beginConditionName(), + $input === 'e' => $this->beginConditionMarker(), default => null, }; @@ -10299,6 +10301,41 @@ private function handleConditionEditorInput(string $input): void $this->renderDatabasePanes(['settings']); } + /** + * Starts choosing which event of its named map a map event condition + * waits on, from that map's own events. + * + * @return void + */ + private function beginConditionMarker(): void + { + $condition = $this->conditionEditor->selected(); + + if ($condition === null || strval($condition['type'] ?? '') !== 'map_event') { + return; + } + + $map = trim(strval($condition['name'] ?? '')); + if ($map === '') { + $this->setStatus('Choose the map first (n), then its event.', StatusLevel::WARN); + + return; + } + + $markers = $this->referenceCatalog()->listMapEventMarkers($map); + + if (! $this->referencePicker->open( + self::CONDITION_MARKER_FIELD, + 'Event', + 'map_event_markers', + $markers['values'], + strval($condition['marker'] ?? ''), + $markers['labels'], + )) { + $this->setStatus(sprintf('%s has no events to choose from.', $map), StatusLevel::WARN); + } + } + /** * Starts setting what the selected condition names. * @@ -10683,6 +10720,17 @@ private function commitReferenceSelection(): void return; } + if ($fieldId === self::CONDITION_MARKER_FIELD) { + if ($selected !== null) { + $this->conditionEditor->setMarker($selected); + } + + $this->statusMessage = 'Building conditions.'; + $this->renderDatabasePanes(['settings']); + + return; + } + if ($selected === null) { $this->statusMessage = 'Nothing matched.'; $this->renderDatabasePanes(['settings']); @@ -13065,9 +13113,9 @@ private function createDatabaseSettingsWindow(array $layout): EditorWindow ), $this->conditionEditor->isOpen() => $this->fitHelp( $layout['settingsWidth'], - 'a:Add d:Del t:Type n:Name x:Value !:Not Enter:Done Esc:Cancel', - 'a:Add d:Del t:Type n:Name x:Value !:Not Enter:Done', - 'a/d:Add/Del t:Type n:Name x:Value !:Not', + 'a:Add d:Del t:Type n:Name e:Event x:Value !:Not Enter:Done Esc:Cancel', + 'a:Add d:Del t:Type n:Name e:Event x:Value !:Not Enter:Done', + 'a/d:Add/Del t:Type n:Name e:Event x:Value !:Not', 'a/d:Add/Del t/n/x:Edit ?:Help', '?:Help', ), diff --git a/src/Field/WorldObjectAuthoring.php b/src/Field/WorldObjectAuthoring.php index 19fb67e..5def193 100644 --- a/src/Field/WorldObjectAuthoring.php +++ b/src/Field/WorldObjectAuthoring.php @@ -99,8 +99,10 @@ public static function validateEntries(ProjectMap $map, array $entries, bool $co } $checked = WorldObjectDefinition::readMap(['worldObjects' => [[ 'id' => $variant['id'] ?? null, 'anchor' => $entry['anchor'], 'pivot' => $entry['pivot'], 'sprites2d' => $variant['sprites2d'], + // A footprint colour recolours the parent's covered glyphs, so the variant is checked with them. + ...(isset($entry['covers']) ? ['covers' => $entry['covers']] : []), ...(array_key_exists('terminal', $variant) ? ['terminal' => $variant['terminal']] : []), - ]]], $map->getLayerSet()); + ]]], $map->getLayerSet(), tileOwners: $owners); $id = $checked[0]->id; if (isset($ids[$id])) { throw new InvalidArgumentException('Duplicate world-object variant id.'); } $ids[$id] = true; @@ -150,8 +152,8 @@ public function getRecords(): ProjectRecordDatabase 'conditions' => [], 'sprites2d' => null], heading: 'Ordered Variants', removeWhenEmpty: true, getFields: static fn(array $entry): array => [new RecordField('id', 'Variant Id'), new RecordField('conditions', 'When (all conditions)', codec: RecordFieldCodec::CONDITIONS), - ...FieldSpriteFields::getFields($entry, true), self::createTerminalField('(the object\'s)')], - prepareEdit: static fn(array $entry, string $field): array => FieldSpriteFields::prepareEdit($entry, $field, true)); + ...FieldSpriteFields::getFields($entry, true), ...self::getTerminalFields($entry, true)], + prepareEdit: fn(array $entry, string $field): array => FieldSpriteFields::prepareEdit($this->prepareTerminalEdit($entry, $field), $field, true)); $cells = new RecordSubList(key: 'covers.cells', prefix: 'cell', singular: 'covered cell', fields: [new RecordField('x', 'X', InputControlType::INTEGER), new RecordField('y', 'Y', InputControlType::INTEGER)], blank: ['x' => 0, 'y' => 0], heading: 'Covered Cells'); @@ -177,7 +179,7 @@ public function getRecords(): ProjectRecordDatabase new RecordField('anchor.x', 'Anchor X', InputControlType::INTEGER), new RecordField('anchor.y', 'Anchor Y', InputControlType::INTEGER), ...$presentation, - self::createTerminalField('(the map\'s glyph)'), + ...self::getTerminalFields($entry, false), new RecordField('covers.layer', 'Covered Gameplay Layer', options: ['', ...array_column(array_filter($this->map->getLayers(), static fn($layer): bool => !$layer['decoration'] && $layer['id'] !== \Ichiloto\Editor\Maps\MapLayers::EVENT), 'name')], removeWhenEmpty: true, displayDefault: '(no coverage)')]; @@ -190,7 +192,7 @@ public function getRecords(): ProjectRecordDatabase subLists: [$variants, $cells], subListsFor: static fn(array $entry): array => [...(array_key_exists('resource', $entry) ? [] : ['variants']), ...(isset($entry['covers']) ? ['covers.cells'] : [])], - prepareEdit: static function (array $entry, string $field) use ($signs): array { + prepareEdit: function (array $entry, string $field) use ($signs): array { if ($field === 'resource') { // A sign placement needs its label and only a sign accepts one; the board's name starts it. $sign = $signs[$entry['resource'] ?? ''] ?? null; @@ -211,12 +213,14 @@ public function getRecords(): ProjectRecordDatabase $entry['covers']['tileLayers'] ??= []; } } - return FieldSpriteFields::prepareEdit($entry, $field, true); + return FieldSpriteFields::prepareEdit($this->prepareTerminalEdit($entry, $field), $field, true); }); foreach ($entries as &$entry) { if (isset($entry['covers'])) { $entry['covers']['cells'] = array_map(static fn($cell): array => ['x' => $cell[0], 'y' => $cell[1]], $entry['covers']['cells']); } + $entry = self::readTerminalChoice($entry, false); + foreach ($entry['variants'] ?? [] as $index => $variant) { $entry['variants'][$index] = self::readTerminalChoice($variant, true); } } unset($entry); $records = ProjectRecordDatabase::overOwnedList($schema, $this->map->dataPath, $entries, static function (): void {}, graphical: true); @@ -224,14 +228,81 @@ public function getRecords(): ProjectRecordDatabase return $records; } + /** How an object or a variant presents in the Terminal, as an author chooses it. */ + private const string TERMINAL_MAP_GLYPH = "map's own glyph"; + private const string TERMINAL_INHERIT = "the object's"; + private const string TERMINAL_SYMBOL = 'a symbol at the anchor'; + private const string TERMINAL_COLOUR = 'its covered glyphs recoloured'; + /** The terminal's named colours; an authored colour outside them stays selectable as written. */ + private const array TERMINAL_COLOURS = ['white', 'yellow', 'red', 'green', 'blue', 'magenta', 'cyan', 'gray', 'black', + 'bright-white', 'bright-yellow', 'bright-red', 'bright-green', 'bright-blue', 'bright-magenta', 'bright-cyan']; + /** - * The one styled terminal cell an object shows at its anchor in the Terminal, as `m`: a chest - * yellow while closed and white once opened. Left empty, the field is removed, so the map's own glyph shows - * for the object, and a variant shows its object's. + * The Terminal presentation as the record shows it: which kind it is, and its symbol or colour. An object without + * one shows the map's glyph; a variant without one shows its object's, and an explicit null the map's glyph again. + * Stored data is written back exactly ({@see writeTerminalChoice()}): a string symbol, or `['color' => ...]`. */ - private static function createTerminalField(string $default): RecordField + private static function readTerminalChoice(array $entry, bool $variant): array + { + $exists = array_key_exists('terminal', $entry); + $terminal = $entry['terminal'] ?? null; + $entry['terminal'] = match (true) { + !$exists => ['mode' => $variant ? self::TERMINAL_INHERIT : self::TERMINAL_MAP_GLYPH], + is_string($terminal) => ['mode' => self::TERMINAL_SYMBOL, 'symbol' => $terminal], + is_array($terminal) => ['mode' => self::TERMINAL_COLOUR, 'color' => (string) ($terminal['color'] ?? '')], + default => ['mode' => self::TERMINAL_MAP_GLYPH], + }; + return $entry; + } + + private static function writeTerminalChoice(array $entry, bool $variant): array { - return new RecordField('terminal', 'Terminal Symbol', removeWhenEmpty: true, displayDefault: $default); + $choice = $entry['terminal'] ?? null; + unset($entry['terminal']); + if (!is_array($choice) || !isset($choice['mode'])) { return $entry; } + return match ($choice['mode']) { + self::TERMINAL_SYMBOL => [...$entry, 'terminal' => (string) ($choice['symbol'] ?? '')], + self::TERMINAL_COLOUR => [...$entry, 'terminal' => ['color' => (string) ($choice['color'] ?? '')]], + // Inheriting is the absence of the key; a variant's map glyph is its explicit null. + self::TERMINAL_MAP_GLYPH => $variant ? [...$entry, 'terminal' => null] : $entry, + default => $entry, + }; + } + + /** @return list */ + private static function getTerminalFields(array $entry, bool $variant): array + { + $choice = is_array($entry['terminal'] ?? null) ? $entry['terminal'] : []; + $modes = $variant ? [self::TERMINAL_INHERIT, self::TERMINAL_MAP_GLYPH, self::TERMINAL_SYMBOL, self::TERMINAL_COLOUR] + : [self::TERMINAL_MAP_GLYPH, self::TERMINAL_SYMBOL, self::TERMINAL_COLOUR]; + $fields = [new RecordField('terminal.mode', 'Terminal Shows', options: $modes)]; + if (($choice['mode'] ?? null) === self::TERMINAL_SYMBOL) { + $fields[] = new RecordField('terminal.symbol', 'Terminal Symbol (one styled cell)'); + } + if (($choice['mode'] ?? null) === self::TERMINAL_COLOUR) { + $colour = (string) ($choice['color'] ?? ''); + $fields[] = new RecordField('terminal.color', 'Terminal Colour', + options: array_values(array_unique([...self::TERMINAL_COLOURS, ...($colour === '' ? [] : [$colour])]))); + } + return $fields; + } + + /** + * A new kind starts from what the cell shows now: the anchor's own glyph as the symbol, and white as the colour, + * the Terminal's ordinary text; the author changes either at once. + */ + private function prepareTerminalEdit(array $entry, string $field): array + { + if ($field !== 'terminal.mode') { return $entry; } + $mode = $entry['terminal']['mode'] ?? null; + if ($mode === self::TERMINAL_SYMBOL && !isset($entry['terminal']['symbol'])) { + $anchor = $entry['anchor'] ?? null; + $glyph = is_array($anchor) ? ($this->map->getLayerSet()->getComposedGrid()[$anchor['y']][$anchor['x']] ?? ' ') : ' '; + $glyph = \Ichiloto\Engine\IO\Console\TerminalText::stripAnsi($glyph); + $entry['terminal']['symbol'] = '' . (trim($glyph) === '' ? '*' : $glyph) . ''; + } + if ($mode === self::TERMINAL_COLOUR && !isset($entry['terminal']['color'])) { $entry['terminal']['color'] = 'white'; } + return $entry; } /** @@ -404,6 +475,8 @@ private function writeRecords(ProjectRecordDatabase $records, string $label): ?C $entries = array_map(static fn($record): array => (array) $record->toArray(), $records->getRecords()); foreach ($entries as &$entry) { if (isset($entry['covers'])) { $entry['covers']['cells'] = array_map(static fn($cell): array => [$cell['x'], $cell['y']], $entry['covers']['cells']); } + $entry = self::writeTerminalChoice($entry, false); + foreach ($entry['variants'] ?? [] as $index => $variant) { $entry['variants'][$index] = self::writeTerminalChoice($variant, true); } } unset($entry); return $this->writeEntries($entries, $label); diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 9f4157e..a76018c 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -2998,17 +2998,10 @@ public function listReferences(?string $mapId, string $category, ?array $record array_values(array_filter($map->describeTileLayers()['layers'], static fn($layer): bool => $layer['owner'] === ($object['covers']['layer'] ?? null)))); } if ($category === 'map_event_markers') { - // The events of the map a map event condition names, each read as what it is. - $named = is_string($record['map'] ?? null) ? $this->getMapsById()[$record['map']] ?? null : null; - if ($named === null) { return []; } - $events = $named->getMapDataField(['events']); - $choices = []; - foreach (is_array($events) ? $events : [] as $marker => $event) { - $kind = preg_replace('/(EventTrigger|Trigger)$/', '', basename(str_replace('\\', '/', strval($event['class'] ?? '')))); - $what = $event['data']['scriptId'] ?? $event['data']['cinematicId'] ?? $event['data']['destinationMap'] ?? null; - $choices[] = ['value' => (string) $marker, 'label' => trim(sprintf('%s · %s %s', $marker, $kind, is_string($what) ? $what : ''))]; - } - return $choices; + // The events of the map a map event condition names. + if (!is_string($record['map'] ?? null)) { return []; } + $markers = $catalog->listMapEventMarkers($record['map']); + return array_map(static fn(string $marker): array => ['value' => $marker, 'label' => $markers['labels'][$marker]], $markers['values']); } if ($category === 'cinematic_movement_routes' && $record !== null) { $catalog = $catalog->createMovementContext($this->getMovementOwnerCommands($mapId, $record)); diff --git a/tests/Unit/ConditionEditorTest.php b/tests/Unit/ConditionEditorTest.php index 6fb93b9..b21536d 100644 --- a/tests/Unit/ConditionEditorTest.php +++ b/tests/Unit/ConditionEditorTest.php @@ -193,3 +193,13 @@ function conditionEditorOn(string $encoded): ConditionEditor expect($grammar['nameCategory'])->toBe('maps') ->and(array_column($grammar['extras'], 'key'))->toBe(['marker']); }); + +it('sets a map event condition\'s event as picked from its map', function () { + $editor = conditionEditorOn('map_event:town:A'); + $editor->setMarker('C'); + expect($editor->encoded())->toBe('map_event:town:C'); + // Only a map event carries a marker. + $other = conditionEditorOn('switch:lamp'); + $other->setMarker('C'); + expect($other->encoded())->toBe('switch:lamp'); +}); diff --git a/tests/Unit/Cutscenes/ArraySourceWriterTest.php b/tests/Unit/Cutscenes/ArraySourceWriterTest.php index aefbbc2..b52b11d 100644 --- a/tests/Unit/Cutscenes/ArraySourceWriterTest.php +++ b/tests/Unit/Cutscenes/ArraySourceWriterTest.php @@ -595,3 +595,16 @@ function rewriteAuthored(callable $change): string ->and(fn() => ArraySourceWriter::rewrite($document, $old, ['case' => SourceWriterBacked::SECOND])) ->toThrow(SourcePreservationRefusal::class, 'case'); }); + +it('replaces a plain-data array with a single value and keeps an array holding a comment', function () { + $plain = " 1, // stays\n 'terminal' => ['color' => 'yellow'],\n];\n"; + $document = PhpArraySourceDocument::parse($plain); + $old = evaluateSource($plain); + $rewritten = ArraySourceWriter::rewrite($document, $old, [...$old, 'terminal' => 'm'])->source; + expect($rewritten)->toBe(str_replace("['color' => 'yellow']", "'m'", $plain)); + + $commented = " [\n // chosen with Andrew\n 'color' => 'yellow',\n ],\n];\n"; + $old = evaluateSource($commented); + expect(fn() => ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($commented), $old, ['terminal' => null])) + ->toThrow(SourcePreservationRefusal::class, 'cannot be replaced by a single value'); +}); diff --git a/tests/Unit/Session/WorldObjectAuthoringTest.php b/tests/Unit/Session/WorldObjectAuthoringTest.php index 8f460f2..e5b1f7b 100644 --- a/tests/Unit/Session/WorldObjectAuthoringTest.php +++ b/tests/Unit/Session/WorldObjectAuthoringTest.php @@ -323,23 +323,72 @@ function applyWorldObjectRow(EditorSession $session, string $field, string $valu expect($session->readMap('test-map')['worldObjects'])->toBe([]); }); -it('carries terminal symbols for an object and its map-event variant through save and reopen', function () { - [$root, , $session] = createWorldObjectProject(); - $view = $session->readWorldObject('test-map', 'fixture-prop'); - $heading = array_find($view['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === 'variantList'); - $session->changeWorldObjectItem('test-map', $view['revision'], 'fixture-prop', $heading['key'], false); - // A chest: yellow while its one-shot event is open, white once it is done. - applyWorldObjectRow($session, 'variant0Conditions', 'map_event:test-map:C'); - applyWorldObjectRow($session, 'variant0Sprites2dasset', 'Graphics/Props/Replaceable.png'); - applyWorldObjectRow($session, 'variant0Terminal', 'm'); - applyWorldObjectRow($session, 'terminal', 'm'); +function createTerminalChestProject(): array +{ + $root = mapGraphicsProject(); + $file = $root . '/assets/Maps/test-map/test-map.data.php'; + // A two-cell chest over the buildings layer's xx: yellow while closed, white once its event is done. + file_put_contents($file, str_replace("'events' => [],", "'events' => [], 'worldObjects' => [ + ['id' => 'fixture-prop', 'anchor' => ['x' => 1, 'y' => 1], 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null, + 'terminal' => ['color' => 'yellow'], + 'covers' => ['layer' => 'buildings', 'cells' => [[1, 1], [2, 1]], 'tileLayers' => []], + 'variants' => [ + ['id' => 'opened', 'conditions' => [['type' => 'map_event', 'name' => 'test-map', 'marker' => 'E']], 'sprites2d' => null, 'terminal' => ['color' => 'white']], + ['id' => 'plain', 'conditions' => [['type' => 'switch', 'name' => 'plain']], 'sprites2d' => null, 'terminal' => null], + ['id' => 'inherits', 'conditions' => [['type' => 'switch', 'name' => 'inherits']], 'sprites2d' => null], + ]], + ],", (string) file_get_contents($file))); + return [$root, $file, EditorSession::open($root)]; +} + +function readWorldObjectRow(EditorSession $session, string $field): ?array +{ + return array_find($session->readWorldObject('test-map', 'fixture-prop')['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === $field); +} + +it('preserves every terminal presentation form, inheritance and explicit null through an unrelated edit', function () { + [$root, , $session] = createTerminalChestProject(); + expect(readWorldObjectRow($session, 'terminal.mode')['value'])->toBe('its covered glyphs recoloured') + ->and(readWorldObjectRow($session, 'terminal.color')['value'])->toBe('yellow') + ->and(readWorldObjectRow($session, 'variant1Terminalmode')['value'])->toBe("map's own glyph") + ->and(readWorldObjectRow($session, 'variant2Terminalmode')['value'])->toBe("the object's"); + applyWorldObjectRow($session, 'pivot.x', '0.25'); + $session->saveMap('test-map'); + $object = EditorSession::open($root)->readMap('test-map')['worldObjects'][0]; + expect($object['terminal'])->toBe(['color' => 'yellow']) + ->and($object['variants'][0]['terminal'])->toBe(['color' => 'white']) + ->and($object['variants'][1])->toHaveKey('terminal') + ->and($object['variants'][1]['terminal'])->toBeNull() + ->and($object['variants'][2])->not->toHaveKey('terminal'); +}); + +it('edits each terminal presentation form through its explicit choice and refuses an invalid symbol', function () { + [$root, , $session] = createTerminalChestProject(); + // An anchor symbol starts from the glyph the anchor shows. + applyWorldObjectRow($session, 'terminal.mode', 'a symbol at the anchor'); + expect(readWorldObjectRow($session, 'terminal.symbol')['value'])->toBe('x'); + applyWorldObjectRow($session, 'terminal.symbol', 'm'); + applyWorldObjectRow($session, 'variant0Terminalcolor', 'bright-white'); + applyWorldObjectRow($session, 'variant1Terminalmode', "the object's"); + applyWorldObjectRow($session, 'variant2Terminalmode', "map's own glyph"); + expect(fn() => applyWorldObjectRow($session, 'terminal.symbol', 'mm'))->toThrow(SessionRefusal::class); $session->saveMap('test-map'); $object = EditorSession::open($root)->readMap('test-map')['worldObjects'][0]; expect($object['terminal'])->toBe('m') - ->and($object['variants'][0]['terminal'])->toBe('m') - ->and($object['variants'][0]['conditions'])->toBe([['type' => 'map_event', 'name' => 'test-map', 'marker' => 'C']]); - // Left empty, a variant's symbol goes and the object's shows for it. - applyWorldObjectRow($session, 'variant0Terminal', ''); - expect($session->readMap('test-map')['worldObjects'][0]['variants'][0])->not->toHaveKey('terminal'); - expect(fn() => applyWorldObjectRow($session, 'terminal', 'mm'))->toThrow(SessionRefusal::class); + ->and($object['variants'][0]['terminal'])->toBe(['color' => 'bright-white']) + ->and($object['variants'][1])->not->toHaveKey('terminal') + ->and($object['variants'][2]['terminal'])->toBeNull(); + // Back to the map's glyph: the object's key goes. + applyWorldObjectRow($session, 'terminal.mode', "map's own glyph"); + expect($session->readMap('test-map')['worldObjects'][0])->not->toHaveKey('terminal'); +}); + +it('checks an unfinished variant against its object\'s real coverage', function () { + [, , $session] = createTerminalChestProject(); + $view = $session->readWorldObject('test-map', 'fixture-prop'); + $heading = array_find($view['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === 'variantList'); + $session->changeWorldObjectItem('test-map', $view['revision'], 'fixture-prop', $heading['key'], false); + // A footprint colour on a draft variant is admitted because its object covers glyphs. + applyWorldObjectRow($session, 'variant3Terminalmode', 'its covered glyphs recoloured'); + expect($session->readMap('test-map')['worldObjects'][0]['variants'][3]['terminal'])->toBe(['color' => 'white']); }); From ad8b6929cf886c05254efa43f0d3d6fa51be8305 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 16 Oct 2026 11:41:02 +0200 Subject: [PATCH 283/344] feat(events): author which way the player faces to start an event Andrew wants events that start only when the player faces a given way. The Engine reads an optional facing list on any trigger (89fff33); the shared map inspector now offers one choice per heading, writes the chosen ones in compass order, drops the key when none is left, and undoes to the event as it was. A facing the Engine would refuse is named rather than offered, and the validator reports it. The GUI editor shows the headings as checkboxes through the same session rows. --- src/Inspector/MapInspector.php | 64 +++++++++- src/Validation/ProjectValidator.php | 25 +++- tests/Unit/EventFacingAuthoringTest.php | 151 ++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/EventFacingAuthoringTest.php diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index 449a496..2052a9b 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -22,6 +22,7 @@ use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\ProjectMap; +use Ichiloto\Engine\Core\Enumerations\MovementHeading; use Ichiloto\Engine\Events\Enumerations\ChestType; use Ichiloto\Engine\Events\Enumerations\LootType; use Ichiloto\Engine\Events\Triggers\EventCueKind; @@ -712,6 +713,9 @@ private function applyEventField(ProjectMap $map, array $field, mixed $value): ? if ($path === ['cue', 'kind']) { return $this->applyEventCueKind($map, $marker, $value); } + if (is_string($field['facingHeading'] ?? null)) { + return $this->applyEventFacing($map, $marker, $field['facingHeading'], $value === true); + } $held = $map->hasEventField($marker, $path); $oldValue = $map->getEventField($marker, $path); // Optional data cleared is removed, as it was before it was set. @@ -730,6 +734,32 @@ private function applyEventField(ProjectMap $map, array $field, mixed $value): ? return new GenericCommand(sprintf('%s edit', $field['label'] ?? 'Event field'), $apply, $restore); } + /** + * Adds or removes one heading the player may face to start the event. The headings keep compass order, and an + * event left with none drops the key, so it starts whichever way the player faces, as before it was set. + */ + private function applyEventFacing(ProjectMap $map, string $marker, string $name, bool $chosen): ?Command + { + $held = $map->hasEventField($marker, ['facing']); + $old = $map->getEventField($marker, ['facing']); + $names = array_map(static fn(MovementHeading $heading): string => $heading->value, self::listFacingHeadings()); + if ($old !== null && (! is_array($old) || ! array_is_list($old) || array_diff($old, $names) !== [])) { + throw new InspectorRefusal('The event facing must list North, East, South or West before a heading can be changed.'); + } + $current = $old ?? []; + $next = array_values(array_filter($names, static fn(string $heading): bool => $heading === $name ? $chosen : in_array($heading, $current, true))); + if ($next === $current || ($next === [] && ! $held)) { + return null; + } + $apply = $next === [] ? static fn() => $map->removeEventField($marker, ['facing']) + : static fn() => $map->setEventField($marker, ['facing'], $next); + $restore = $held ? static fn() => $map->setEventField($marker, ['facing'], $old) + : static fn() => $map->removeEventField($marker, ['facing']); + $apply(); + + return new GenericCommand('Facing edit', $apply, $restore); + } + /** Kind edits own the cue's optional parent so undo also restores its omission. */ private function applyEventCueKind(ProjectMap $map, string $marker, mixed $value): ?Command { @@ -1211,7 +1241,7 @@ public function buildEventDataFields(string $marker, array $definition): array if ($hasPresentation) { $fields = [...$fields, ...$this->buildInnPresentationFields($marker, $presentation)]; } } - $rootFields = array_diff_key($definition, ['class' => true, 'data' => true]); + $rootFields = array_diff_key($definition, ['class' => true, 'data' => true, 'facing' => true]); // Cue is a generic trigger capability, including for definitions // created before the field existed. Supplying an empty editor-only // default exposes the opt-in without changing the stored event until @@ -1239,11 +1269,41 @@ public function buildEventDataFields(string $marker, array $definition): array ...$fields, ...$this->decorateEventInspectorFields( $marker, - [...$this->flattenInspectorFields($rootFields, []), ...$cueKindFields], + [...$this->flattenInspectorFields($rootFields, []), ...$cueKindFields, ...self::buildEventFacingFields($definition)], ), ]; } + /** + * Which way the player faces to start the event: one row per heading, none chosen for any. A facing the Engine + * would refuse stays as one row that names it, for the author to repair in the source, never flattened. + * + * @param array $definition + * @return list> + */ + private static function buildEventFacingFields(array $definition): array + { + $facing = $definition['facing'] ?? []; + $names = array_map(static fn(MovementHeading $heading): string => $heading->value, self::listFacingHeadings()); + if (! is_array($facing) || ! array_is_list($facing) || array_diff($facing, $names) !== []) { + return [['label' => 'Facing', 'value' => '(not North, East, South or West)', 'editable' => false, 'path' => ['facing']]]; + } + + return array_map(static fn(string $name): array => [ + 'label' => "Facing {$name}", + 'value' => in_array($name, $facing, true) ? 'true' : 'false', + 'control' => new InputControl(InputControlType::BOOLEAN, in_array($name, $facing, true) ? 'true' : 'false'), + 'path' => ['facing', $name], + 'facingHeading' => $name, + ], $names); + } + + /** @return list The headings an event may require, in compass order. */ + private static function listFacingHeadings(): array + { + return array_values(array_filter(MovementHeading::cases(), static fn(MovementHeading $heading): bool => $heading !== MovementHeading::NONE)); + } + /** Inline routes use the same schema/coercion as command records; the map remains their owner. */ private function projectMovementRoute(array $route, ?array $ownerCommands = null): ProjectRecordDatabase { diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 2f6a88d..e52f538 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -2,6 +2,7 @@ namespace Ichiloto\Editor\Validation; +use Ichiloto\Engine\Core\Enumerations\MovementHeading; use Ichiloto\Editor\Database\BattleEntryActorResolver; use Ichiloto\Editor\Database\BattleEntryRuleContract; use Ichiloto\Editor\Database\InventoryCatalog; @@ -3821,6 +3822,7 @@ protected function checkMapReferences(ProjectWorkspace $workspace, array $known) $issues = [ ...$issues, ...$this->checkEventCue($definition['cue'] ?? null, $where, $known), + ...$this->checkEventFacing($definition, $where), ...$this->checkConditions((array) ($definition['conditions'] ?? []), $where, $known), ...$this->checkDialogueVariants((array) ($data['dialogue'] ?? []), $where, $known), ...$this->checkCommands( @@ -4110,13 +4112,13 @@ protected function checkScriptTrigger( array $npcIdsByMap, ): array { $issues = []; - $allowedRootFields = ['class', 'data', 'conditions', 'sets', 'whenBlocked', 'cue']; + $allowedRootFields = ['class', 'data', 'conditions', 'sets', 'whenBlocked', 'cue', 'facing']; foreach (array_diff(array_keys($definition), $allowedRootFields) as $field) { $issues[] = Issue::error( $where, sprintf('ScriptEventTrigger uses unsupported root field "%s".', $field), - 'Use class, data, conditions, sets, whenBlocked, or cue.' + 'Use class, data, conditions, sets, whenBlocked, cue, or facing.' ); } @@ -4198,6 +4200,25 @@ protected function checkScriptTrigger( * * @return Issue[] The issues found. */ + /** + * An event's optional facing: the player headings it engages from, as the Engine reads them. + * + * @param array $definition The event definition. + * @return Issue[] The issues found. + */ + protected function checkEventFacing(array $definition, string $where): array + { + if (! array_key_exists('facing', $definition)) { + return []; + } + $facing = $definition['facing']; + $valid = is_array($facing) && array_is_list($facing) && array_all($facing, static fn(mixed $name): bool => is_string($name) + && ($heading = MovementHeading::tryFrom($name)) !== null && $heading !== MovementHeading::NONE); + + return $valid ? [] : [Issue::error($where, 'Its facing must list player headings: North, East, South or West.', + 'Choose the headings the player faces to start it, or none for any.')]; + } + protected function checkEventCue(mixed $cue, string $where, array $known): array { if ($cue === null) { diff --git a/tests/Unit/EventFacingAuthoringTest.php b/tests/Unit/EventFacingAuthoringTest.php new file mode 100644 index 0000000..121849e --- /dev/null +++ b/tests/Unit/EventFacingAuthoringTest.php @@ -0,0 +1,151 @@ + $facingSource,"; + file_put_contents($root . '/assets/Maps/test-map/test-map.data.php', << 'Facing fixture', + 'region' => '', + 'description' => strtoupper('kept'), + 'triggers' => [], + 'events' => [ + 'E' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\\\$class', + 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'synthetic-note'], + // The event's own comment. + 'conditions' => [], + $facingField + ], + ], + ]; + PHP); + + return $root; +} + +function findFacingMap(string $root): ProjectMap +{ + return array_find(ProjectWorkspace::fromProject($root)->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map') + ?? throw new RuntimeException('The synthetic facing map is missing.'); +} + +/** @return array> The facing rows by heading. */ +function listFacingRows(MapInspector $inspector, ProjectMap $map): array +{ + $rows = array_filter($inspector->getEventFields($map, 'E'), static fn(array $row): bool => is_string($row['facingHeading'] ?? null)); + + return array_column($rows, null, 'facingHeading'); +} + +it('offers one choice per heading, none chosen, without writing anything for an event that faces any way', function (bool $graphical) { + $root = createFacingProject(); + $hashes = sourceHashTree($root); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $rows = listFacingRows(new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical), $map); + + expect(array_keys($rows))->toBe(['North', 'East', 'South', 'West']) + ->and(array_column($rows, 'value'))->toBe(['false', 'false', 'false', 'false']) + ->and(array_column($rows, 'label'))->toBe(['Facing North', 'Facing East', 'Facing South', 'Facing West']) + ->and($map->isDirty())->toBeFalse(); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes); +})->with([false, true]); + +it('writes chosen headings in compass order, keeps the source, and undoes to the event as it was', function (bool $graphical) { + $root = createFacingProject(); + $hashes = sourceHashTree($root); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $before = $map->getEventDefinition('E'); + + $east = $inspector->apply($map, listFacingRows($inspector, $map)['East'], 'true'); + $north = $inspector->apply($map, listFacingRows($inspector, $map)['North'], 'true'); + $map->save(); + + expect($map->getEventDefinition('E')['facing'])->toBe(['North', 'East']) + ->and(findFacingMap($root)->getEventDefinition('E')['facing'])->toBe(['North', 'East']) + ->and(file_get_contents($map->dataPath))->toContain('// The event\'s own comment.', "strtoupper('kept')") + ->and(array_column(listFacingRows($inspector, $map), 'value'))->toBe(['true', 'true', 'false', 'false']); + $north->undo(); + $east->undo(); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes) + ->and(findFacingMap($root)->getEventDefinition('E'))->toBe($before); + $east->execute(); + $map->save(); + expect(findFacingMap($root)->getEventDefinition('E')['facing'])->toBe(['East']); +})->with([false, true]); + +it('removes the facing once no heading is left, so the event starts whichever way the player faces', function () { + $root = createFacingProject("['West']"); + $workspace = ProjectWorkspace::fromProject($root); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $inspector = new MapInspector(new ReferenceCatalog($workspace)); + + $change = $inspector->apply($map, listFacingRows($inspector, $map)['West'], 'false'); + $map->save(); + + expect(findFacingMap($root)->getEventDefinition('E'))->not->toHaveKey('facing'); + $change->undo(); + $map->save(); + expect(findFacingMap($root)->getEventDefinition('E')['facing'])->toBe(['West']); +}); + +it('names a facing the Engine would refuse instead of offering headings that would rewrite it', function () { + $root = createFacingProject("['Up']"); + $workspace = ProjectWorkspace::fromProject($root); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $rows = array_values(array_filter((new MapInspector(new ReferenceCatalog($workspace)))->getEventFields($map, 'E'), + static fn(array $row): bool => ($row['path'] ?? null) === ['facing'] || is_string($row['facingHeading'] ?? null))); + + expect($rows)->toHaveCount(1) + ->and($rows[0]['editable'])->toBeFalse() + ->and(implode("\n", array_map(static fn($issue): string => $issue->message, new ProjectValidator()->validate(ProjectWorkspace::fromProject($root))))) + ->toContain('Its facing must list player headings'); +}); + +it('accepts a facing on any trigger kind without calling it an unsupported field', function (string $class) { + $root = createFacingProject("['North']", $class); + $messages = array_map(static fn($issue): string => $issue->message, new ProjectValidator()->validate(ProjectWorkspace::fromProject($root))); + + expect(array_filter($messages, static fn(string $message): bool => str_contains($message, 'facing')))->toBe([]); +})->with(['ScriptEventTrigger', 'SleepEventTrigger']); + +it('serves the headings to a graphical editor as checkboxes it edits, saves and undoes through the session', function () { + $root = createFacingProject(); + $mapPath = $root . '/assets/Maps/test-map/test-map.data.php'; + $original = file_get_contents($mapPath); + $session = Ichiloto\Editor\Session\EditorSession::open($root); + $view = $session->readInspector('test-map', 'E'); + $rows = array_values(array_filter($view['rows'], static fn(array $row): bool => ($row['key']['path'][0] ?? null) === 'facing')); + + expect(array_column($rows, 'label'))->toBe(['Facing North', 'Facing East', 'Facing South', 'Facing West']) + ->and(array_unique(array_column($rows, 'kind')))->toBe(['boolean']); + $session->applyInspector('test-map', $view['revision'], $rows[1]['key'], 'true'); + $session->saveMap('test-map'); + expect((require $mapPath)['events']['E']['facing'])->toBe(['East']); + $session->undo(); + $session->saveMap('test-map'); + expect(file_get_contents($mapPath))->toBe($original); +}); From 8f1f0ecf0a116e3fa544138c94ff64794c3f57f7 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 16 Oct 2026 16:15:10 +0200 Subject: [PATCH 284/344] feat(events): author whether an event starts on its cells or by facing it The Engine lets an event placed on an object start when the player stands next to it and faces it (engagement facing, 0a8a320). The shared map inspector now offers that choice beside standing on the event, writes facing to the map's source, drops the key for the default, and undoes to the event as it was. A value the Engine would refuse is named rather than offered, and the validator reports it. The GUI editor shows the choice through the same session rows. --- src/Inspector/MapInspector.php | 64 ++++++++- src/Validation/ProjectValidator.php | 23 +++- tests/Unit/EventEngagementAuthoringTest.php | 138 ++++++++++++++++++++ 3 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/EventEngagementAuthoringTest.php diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index 2052a9b..12e49b0 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -24,6 +24,7 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Engine\Core\Enumerations\MovementHeading; use Ichiloto\Engine\Events\Enumerations\ChestType; +use Ichiloto\Engine\Events\Enumerations\EventEngagement; use Ichiloto\Engine\Events\Enumerations\LootType; use Ichiloto\Engine\Events\Triggers\EventCueKind; use Ichiloto\Engine\Field\MapGraphics; @@ -66,6 +67,12 @@ ['label' => 'Route', 'value' => EventCueKind::ROUTE->value, 'description' => 'Route guidance that belongs to its map cell.'], ]; + /** Where the player stands to start an event, as an author picks it. */ + public const array EVENT_ENGAGEMENT_CHOICES = [ + ['label' => 'Stand on it', 'value' => 'inside', 'description' => 'The player starts it from one of its cells, as events always have.'], + ['label' => 'Face it from next to it', 'value' => 'facing', 'description' => 'Placed on an object: the player starts it from the next cell, facing the object.'], + ]; + /** A chest event's presentations, as an author picks one. */ public const array CHEST_TYPE_CHOICES = [ ['label' => 'Common', 'value' => ChestType::COMMON->value, 'description' => 'Standard chest presentation for ordinary treasure.'], @@ -713,6 +720,9 @@ private function applyEventField(ProjectMap $map, array $field, mixed $value): ? if ($path === ['cue', 'kind']) { return $this->applyEventCueKind($map, $marker, $value); } + if ($path === ['engagement']) { + return $this->applyEventEngagement($map, $marker, $value); + } if (is_string($field['facingHeading'] ?? null)) { return $this->applyEventFacing($map, $marker, $field['facingHeading'], $value === true); } @@ -734,6 +744,31 @@ private function applyEventField(ProjectMap $map, array $field, mixed $value): ? return new GenericCommand(sprintf('%s edit', $field['label'] ?? 'Event field'), $apply, $restore); } + /** + * Sets where the player stands to start the event. Standing on it is what an event without the key does, so + * choosing it drops the key, and undo restores the event as it was. + */ + private function applyEventEngagement(ProjectMap $map, string $marker, mixed $value): ?Command + { + $engagement = is_string($value) ? EventEngagement::tryFrom($value) : null; + if ($engagement === null) { + throw new InspectorRefusal('Choose to stand on the event or to face it from next to it.'); + } + $held = $map->hasEventField($marker, ['engagement']); + $old = $map->getEventField($marker, ['engagement']); + $next = $engagement === EventEngagement::INSIDE ? null : $engagement->value; + if ($old === $next || ($next === null && ! $held)) { + return null; + } + $apply = $next === null ? static fn() => $map->removeEventField($marker, ['engagement']) + : static fn() => $map->setEventField($marker, ['engagement'], $next); + $restore = $held ? static fn() => $map->setEventField($marker, ['engagement'], $old) + : static fn() => $map->removeEventField($marker, ['engagement']); + $apply(); + + return new GenericCommand('Engagement edit', $apply, $restore); + } + /** * Adds or removes one heading the player may face to start the event. The headings keep compass order, and an * event left with none drops the key, so it starts whichever way the player faces, as before it was set. @@ -1241,7 +1276,7 @@ public function buildEventDataFields(string $marker, array $definition): array if ($hasPresentation) { $fields = [...$fields, ...$this->buildInnPresentationFields($marker, $presentation)]; } } - $rootFields = array_diff_key($definition, ['class' => true, 'data' => true, 'facing' => true]); + $rootFields = array_diff_key($definition, ['class' => true, 'data' => true, 'facing' => true, 'engagement' => true]); // Cue is a generic trigger capability, including for definitions // created before the field existed. Supplying an empty editor-only // default exposes the opt-in without changing the stored event until @@ -1269,7 +1304,8 @@ public function buildEventDataFields(string $marker, array $definition): array ...$fields, ...$this->decorateEventInspectorFields( $marker, - [...$this->flattenInspectorFields($rootFields, []), ...$cueKindFields, ...self::buildEventFacingFields($definition)], + [...$this->flattenInspectorFields($rootFields, []), ...$cueKindFields, self::buildEventEngagementField($definition), + ...self::buildEventFacingFields($definition)], ), ]; } @@ -1298,6 +1334,29 @@ private static function buildEventFacingFields(array $definition): array ], $names); } + /** + * Where the player stands to start the event: on its cells, or next to them facing one, for an event placed on an + * object. An engagement the Engine would refuse is named rather than offered, never rewritten. + * + * @param array $definition + * @return array + */ + private static function buildEventEngagementField(array $definition): array + { + $engagement = $definition['engagement'] ?? EventEngagement::INSIDE->value; + if (! is_string($engagement) || EventEngagement::tryFrom($engagement) === null) { + return ['label' => 'Engagement', 'value' => '(not inside or facing)', 'editable' => false, 'path' => ['engagement']]; + } + + return [ + 'label' => 'Engagement', + 'value' => $engagement, + 'selectedValue' => $engagement, + 'optionLabels' => array_column(self::EVENT_ENGAGEMENT_CHOICES, 'label', 'value'), + 'path' => ['engagement'], + ]; + } + /** @return list The headings an event may require, in compass order. */ private static function listFacingHeadings(): array { @@ -1503,6 +1562,7 @@ private function decorateEventInspectorFields(string $marker, array $fields): ar $choices = match ($path) { ['cue', 'kind'] => self::EVENT_CUE_KIND_CHOICES, + ['engagement'] => self::EVENT_ENGAGEMENT_CHOICES, ['data', 'chestType'] => self::CHEST_TYPE_CHOICES, ['data', 'lootType'] => self::LOOT_TYPE_CHOICES, default => null, diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index e52f538..8d35f0e 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -3,6 +3,7 @@ namespace Ichiloto\Editor\Validation; use Ichiloto\Engine\Core\Enumerations\MovementHeading; +use Ichiloto\Engine\Events\Enumerations\EventEngagement; use Ichiloto\Editor\Database\BattleEntryActorResolver; use Ichiloto\Editor\Database\BattleEntryRuleContract; use Ichiloto\Editor\Database\InventoryCatalog; @@ -3823,6 +3824,7 @@ protected function checkMapReferences(ProjectWorkspace $workspace, array $known) ...$issues, ...$this->checkEventCue($definition['cue'] ?? null, $where, $known), ...$this->checkEventFacing($definition, $where), + ...$this->checkEventEngagement($definition, $where), ...$this->checkConditions((array) ($definition['conditions'] ?? []), $where, $known), ...$this->checkDialogueVariants((array) ($data['dialogue'] ?? []), $where, $known), ...$this->checkCommands( @@ -4112,13 +4114,13 @@ protected function checkScriptTrigger( array $npcIdsByMap, ): array { $issues = []; - $allowedRootFields = ['class', 'data', 'conditions', 'sets', 'whenBlocked', 'cue', 'facing']; + $allowedRootFields = ['class', 'data', 'conditions', 'sets', 'whenBlocked', 'cue', 'facing', 'engagement']; foreach (array_diff(array_keys($definition), $allowedRootFields) as $field) { $issues[] = Issue::error( $where, sprintf('ScriptEventTrigger uses unsupported root field "%s".', $field), - 'Use class, data, conditions, sets, whenBlocked, cue, or facing.' + 'Use class, data, conditions, sets, whenBlocked, cue, facing, or engagement.' ); } @@ -4219,6 +4221,23 @@ protected function checkEventFacing(array $definition, string $where): array 'Choose the headings the player faces to start it, or none for any.')]; } + /** + * An event's optional engagement: standing on its cells, or next to them facing one. + * + * @param array $definition The event definition. + * @return Issue[] The issues found. + */ + protected function checkEventEngagement(array $definition, string $where): array + { + if (! array_key_exists('engagement', $definition)) { + return []; + } + $engagement = $definition['engagement']; + + return is_string($engagement) && EventEngagement::tryFrom($engagement) !== null ? [] : [Issue::error($where, + 'Its engagement must be inside or facing.', 'Choose to stand on the event, or to face it from next to it.')]; + } + protected function checkEventCue(mixed $cue, string $where, array $known): array { if ($cue === null) { diff --git a/tests/Unit/EventEngagementAuthoringTest.php b/tests/Unit/EventEngagementAuthoringTest.php new file mode 100644 index 0000000..0ccdecb --- /dev/null +++ b/tests/Unit/EventEngagementAuthoringTest.php @@ -0,0 +1,138 @@ + $engagementSource,"; + file_put_contents($root . '/assets/Maps/test-map/test-map.data.php', << 'Engagement fixture', + 'region' => '', + 'description' => strtoupper('kept'), + 'triggers' => [], + 'events' => [ + 'R' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'synthetic-records'], + // The event's own comment. + 'facing' => ['North'], + $field + ], + ], + ]; + PHP); + + return $root; +} + +function findEngagementMap(string $root): ProjectMap +{ + return array_find(ProjectWorkspace::fromProject($root)->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map') + ?? throw new RuntimeException('The synthetic engagement map is missing.'); +} + +function getEngagementRow(MapInspector $inspector, ProjectMap $map): array +{ + return array_find($inspector->getEventFields($map, 'R'), static fn(array $row): bool => ($row['path'] ?? null) === ['engagement']) + ?? throw new RuntimeException('The engagement choice is missing.'); +} + +it('offers standing on the event or facing it, showing the default without writing it', function (bool $graphical) { + $root = createEngagementProject(); + $hashes = sourceHashTree($root); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $row = getEngagementRow(new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical), $map); + + expect($row['options'])->toBe(['inside', 'facing']) + ->and($row['selectedValue'])->toBe('inside') + ->and($row)->not->toHaveKey('control'); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes); +})->with([false, true]); + +it('writes facing to the source, keeps the event\'s headings, and undoes to the event as it was', function (bool $graphical) { + $root = createEngagementProject(); + $hashes = sourceHashTree($root); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $before = $map->getEventDefinition('R'); + + $change = $inspector->apply($map, getEngagementRow($inspector, $map), 'facing'); + $map->save(); + + expect(findEngagementMap($root)->getEventDefinition('R'))->toBe([...$before, 'engagement' => 'facing']) + ->and(file_get_contents($map->dataPath))->toContain('// The event\'s own comment.', "strtoupper('kept')"); + $change->undo(); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes); + $change->execute(); + $map->save(); + expect(findEngagementMap($root)->getEventDefinition('R')['engagement'])->toBe('facing'); +})->with([false, true]); + +it('drops the key when the author goes back to standing on the event', function () { + $root = createEngagementProject("'facing'"); + $workspace = ProjectWorkspace::fromProject($root); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $inspector = new MapInspector(new ReferenceCatalog($workspace)); + + $change = $inspector->apply($map, getEngagementRow($inspector, $map), 'inside'); + $map->save(); + + expect(findEngagementMap($root)->getEventDefinition('R'))->not->toHaveKey('engagement'); + $change->undo(); + $map->save(); + expect(findEngagementMap($root)->getEventDefinition('R')['engagement'])->toBe('facing'); +}); + +it('names an engagement the Engine would refuse, and the validator reports it', function () { + $root = createEngagementProject("'beside'"); + $workspace = ProjectWorkspace::fromProject($root); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $row = getEngagementRow(new MapInspector(new ReferenceCatalog($workspace)), $map); + $messages = array_map(static fn($issue): string => $issue->message, new ProjectValidator()->validate($workspace)); + + expect($row['editable'])->toBeFalse() + ->and(implode("\n", $messages))->toContain('Its engagement must be inside or facing') + ->and(array_filter($messages, static fn(string $message): bool => str_contains($message, 'unsupported root field')))->toBe([]); +}); + +it('serves the choice to a graphical editor as options it edits and saves through the session', function () { + $root = createEngagementProject(); + $mapPath = $root . '/assets/Maps/test-map/test-map.data.php'; + $original = file_get_contents($mapPath); + $session = EditorSession::open($root); + $view = $session->readInspector('test-map', 'R'); + $row = array_find($view['rows'], static fn(array $row): bool => ($row['key']['path'] ?? null) === ['engagement']); + + expect($row['kind'])->toBe('options') + ->and($row['options'])->toBe(['inside', 'facing']) + ->and($row['optionLabels'])->toBe(['Stand on it', 'Face it from next to it']); + $session->applyInspector('test-map', $view['revision'], $row['key'], 'facing'); + $session->saveMap('test-map'); + expect((require $mapPath)['events']['R']['engagement'])->toBe('facing'); + $session->undo(); + $session->saveMap('test-map'); + expect(file_get_contents($mapPath))->toBe($original); +}); From fa49b0a12e662a849326c596ee6cd226e75d018e Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 16 Oct 2026 21:55:38 +0200 Subject: [PATCH 285/344] feat(skills): author each skill's battle message and preview what it shows Andrew wants every game to choose its battle wording, as RPG Maker lets it. The skill field reads Battle Message, new skills start at %2 (the action's name), and the record's preview shows what the Engine will display, formatted by the Engine's own BattleAnnouncement (f23b3f4): the authored text with the actor and skill named, or nothing for a blank message, with a reminder of %1, %2 and blank. --- src/Database/RecordPanes.php | 24 +++++++++++++++++++++++- src/Database/RecordSchemaCatalog.php | 7 +++++-- tests/Unit/SkillRecordFilesTest.php | 22 ++++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/Database/RecordPanes.php b/src/Database/RecordPanes.php index 6fb905c..0427259 100644 --- a/src/Database/RecordPanes.php +++ b/src/Database/RecordPanes.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor\Database; +use Ichiloto\Engine\Battle\Presentation\BattleAnnouncement; use Ichiloto\Editor\ProjectActor; use Ichiloto\Editor\ProjectClass; use Ichiloto\Editor\ProjectQuest; @@ -158,6 +159,27 @@ private static function describeClass(?ProjectClass $class): array * @param array|null $data The skill's record data. * @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> */ + /** + * What a skill's battle message will show, formatted by the Engine as a battle formats it, with how to write one. + * + * @param array $invocation + * @return list + */ + private static function describeBattleMessage(array $invocation, string $name): array + { + $message = $invocation['message'] ?? RecordSchemaCatalog::SKILL_MESSAGE_DEFAULT; + if (! is_string($message)) { + return ['Battle message: (not text)']; + } + $shown = BattleAnnouncement::format($message, 'Actor', $name === '' ? 'Skill' : $name); + + return [ + sprintf('Battle message: %s', trim($message) === '' ? '(blank)' : $message), + $shown === '' ? 'Shows: nothing, the skill is used silently' : sprintf('Shows: %s', $shown), + '%1 = who acts, %2 = this skill, blank = no message', + ]; + } + private static function describeSkill(?array $data, ?string $file): array { if ($data === null) { @@ -192,7 +214,7 @@ private static function describeSkill(?array $data, ?string $file): array sprintf('Cooldown: %d', intval($data['cooldown'] ?? 0)), '', sprintf('Scope: %s / %s / %s', strval($scope['side'] ?? ''), strval($scope['number'] ?? ''), strval($scope['status'] ?? '')), - sprintf('Invoke: %s', strval($invocation['message'] ?? '')), + ...self::describeBattleMessage($invocation, strval($data['name'] ?? '')), '', 'Effects', ...$effects, diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index f748f4a..ef4ba14 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -77,6 +77,9 @@ */ final class RecordSchemaCatalog { + /** A skill's battle message where none is authored: the Engine shows the action's name. */ + public const string SKILL_MESSAGE_DEFAULT = '%2'; + /** * The event-script command types built into the engine's interpreter. * @@ -1271,7 +1274,7 @@ private static function skills(): RecordSchema new RecordField('scope.number', 'Scope Number', options: array_map(static fn(ItemScopeNumber $number): string => $number->value, ItemScopeNumber::cases())), new RecordField('scope.status', 'Scope Status', options: array_map(static fn(ItemScopeStatus $status): string => $status->value, ItemScopeStatus::cases())), new RecordField('scope.targetCount', 'Target Count', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: 'Auto'), - new RecordField('invocation.message', 'Invoke Text'), + new RecordField('invocation.message', 'Battle Message', displayDefault: self::SKILL_MESSAGE_DEFAULT), new RecordField('invocation.speed', 'Invoke Speed', InputControlType::INTEGER), new RecordField('invocation.accuracy', 'Accuracy', InputControlType::INTEGER), new RecordField('invocation.repeat', 'Repeat', InputControlType::INTEGER), @@ -1334,7 +1337,7 @@ private static function skills(): RecordSchema 'cooldown' => 0, 'occasion' => Occasion::BATTLE_SCREEN->value, 'scope' => ['side' => ItemScopeSide::ENEMY->value, 'number' => ItemScopeNumber::ONE->value, 'status' => ItemScopeStatus::ALIVE->value], - 'invocation' => ['message' => '$1 uses $2!', 'speed' => 0, 'accuracy' => 100, 'repeat' => 1, 'apGain' => 10], + 'invocation' => ['message' => self::SKILL_MESSAGE_DEFAULT, 'speed' => 0, 'accuracy' => 100, 'repeat' => 1, 'apGain' => 10], 'effects' => [], ], subList: new RecordSubList( diff --git a/tests/Unit/SkillRecordFilesTest.php b/tests/Unit/SkillRecordFilesTest.php index 074feb3..2bd29d6 100644 --- a/tests/Unit/SkillRecordFilesTest.php +++ b/tests/Unit/SkillRecordFilesTest.php @@ -68,3 +68,25 @@ function skillRecordsProject(): string ->and($lunge?->effects[0]?->stateId)->toBe('poison') ->and(SkillCatalog::load($root . '/assets')->getProblems())->toBe([]); }); + +it('seeds a new skill with the action-name battle message and previews what each message shows, as a battle formats it', function () { + $root = skillRecordsProject(); + $skills = loadRecordDatabase($root, 'skills'); + $created = (new RecordAuthoring())->createRecord($skills); + $skills->setField(0, 'invocation.message', '%1 swings at the foe!'); + $skills->setField(1, 'invocation.message', ''); + $skills->save(); + $workspace = Ichiloto\Editor\ProjectWorkspace::fromProject($root); + $lines = static fn(int $index): string => implode("\n", array_merge(...array_map( + static fn(array $pane): array => array_map('strval', (array) ($pane['lines'] ?? $pane[1] ?? [])), + array_values(Ichiloto\Editor\Database\RecordPanes::describe($workspace, 'skills', $index)), + ))); + + expect(loadRecordDatabase($root, 'skills')->getRecordByIndex($created->index)?->get('invocation')['message'])->toBe('%2') + ->and(array_find(Ichiloto\Editor\Database\RecordSchemaCatalog::forKey('skills')->fields, + static fn($field): bool => $field->key === 'invocation.message')->label)->toBe('Battle Message') + ->and($lines(0))->toContain('Shows: Actor swings at the foe!') + ->and($lines(1))->toContain('Shows: nothing, the skill is used silently') + ->and($lines(2))->toContain('Shows: Ember') + ->and($lines(2))->toContain('%1 = who acts, %2 = this skill, blank = no message'); +}); From bc27cf3870fa3d0bb2b289ec2bb82182e8e34951 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 16 Oct 2026 22:02:04 +0200 Subject: [PATCH 286/344] feat(terms): offer every Engine message in Terms with its default and help Andrew wants every game to reword what the Engine says from the Editor, as RPG Maker's Terms allows. Terms listed only the messages a project's config.php already held, so a message the Engine says with its own default could never be found. Terms now also offers each message in the Engine's catalog (EngineMessageTerms, 7498a7c) with its default, what it is for and the placeholders it fills; rewording one writes it into config.php without disturbing the file, choosing its default writes nothing, and a blank battle callout stays blank. Battle callouts preview what a battle will show, formatted by the Engine. --- src/Database/RecordPanes.php | 30 +++++++++++ src/Database/RecordSchemaCatalog.php | 5 ++ src/ProjectConfig.php | 30 +++++++++-- tests/Unit/EngineMessageTermsTest.php | 78 +++++++++++++++++++++++++++ tests/Unit/RecordDatabaseTest.php | 16 +++--- 5 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 tests/Unit/EngineMessageTermsTest.php diff --git a/src/Database/RecordPanes.php b/src/Database/RecordPanes.php index 0427259..beb53ad 100644 --- a/src/Database/RecordPanes.php +++ b/src/Database/RecordPanes.php @@ -40,6 +40,7 @@ public static function describe(ProjectWorkspace $workspace, string $category, i return match ($category) { 'actors' => self::describeActor($workspace->actorDatabase->getActorByIndex($index)), 'classes' => self::describeClass(($row = $data()) === null ? null : ProjectClass::fromArray($row, $index + 1)), + 'terms' => self::describeTerm($data()), 'skills' => self::describeSkill($data(), $workspace->getRecordDatabase('skills')?->getRecordByIndex($index)?->recordId), 'quests' => self::describeQuest(($row = $data()) === null ? null : new ProjectQuest($row)), 'system' => self::describeSystem($workspace), @@ -159,6 +160,35 @@ private static function describeClass(?ProjectClass $class): array * @param array|null $data The skill's record data. * @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> */ + /** + * A term as the Engine says it: what it is for, its default, the placeholders it fills in, and for a battle + * callout what a battle will show. + * + * @param array|null $data + * @return array}> + */ + private static function describeTerm(?array $data): array + { + if ($data === null || ! array_key_exists('default', $data) || ! is_array($data['placeholders'] ?? null)) { + return []; + } + $value = $data['value'] ?? null; + $text = is_string($value) ? $value : strval($data['default']); + $lines = [ + strval($data['description'] ?? ''), + '', + sprintf('Default: %s', trim(strval($data['default'])) === '' ? '(blank)' : strval($data['default'])), + ...array_map(static fn(string $token, string $meaning): string => sprintf('%s = %s', $token, $meaning), + array_keys($data['placeholders']), array_values($data['placeholders'])), + ]; + if (str_starts_with(strval($data['path'] ?? ''), 'messages.battle.announcements.')) { + $shown = BattleAnnouncement::format($text, 'Actor', 'Action'); + $lines = [...$lines, '', $shown === '' ? 'Shows: nothing, the command is used silently' : sprintf('Shows: %s', $shown)]; + } + + return ['preview' => ['title' => 'Term', 'lines' => $lines]]; + } + /** * What a skill's battle message will show, formatted by the Engine as a battle formats it, with how to write one. * diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index ef4ba14..53024d0 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -1525,6 +1525,11 @@ private static function terms(): RecordSchema labelKey: 'path', identityKey: 'path', configPath: ['vocab', 'messages'], + // A message the Engine says reads as its default until the project rewords it. + fieldsFor: static fn(array $row): array => [ + new RecordField('path', 'Term', isReadOnly: true), + new RecordField('value', 'Value', displayDefault: array_key_exists('default', $row) ? ProjectRecord::stringify($row['default']) : null), + ], ); } diff --git a/src/ProjectConfig.php b/src/ProjectConfig.php index 6e957fd..454553a 100644 --- a/src/ProjectConfig.php +++ b/src/ProjectConfig.php @@ -15,6 +15,7 @@ use Ichiloto\Editor\Storage\FileSetOperations; use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Editor\Storage\FilesystemFileSetOperations; +use Ichiloto\Engine\Localization\EngineMessageTerms; use Ichiloto\Engine\Rendering\FieldViewport; use RuntimeException; use Throwable; @@ -74,13 +75,34 @@ public function getRecord(string $path): ProjectRecord $this->originalValues[$path] = $value; // A setting the engine knows carries the default it reads when // the file holds none, which is what the setting reads as here. - $this->records[$path] = new ProjectRecord(array_key_exists($path, self::ENGINE_DEFAULTS) - ? ['path' => $path, 'value' => $value, 'default' => self::ENGINE_DEFAULTS[$path]] - : ['path' => $path, 'value' => $value]); + $term = self::listEngineMessageTerms()[$path] ?? null; + $this->records[$path] = new ProjectRecord(match (true) { + array_key_exists($path, self::ENGINE_DEFAULTS) => ['path' => $path, 'value' => $value, 'default' => self::ENGINE_DEFAULTS[$path]], + // A message the Engine says carries its default, what it accepts and what it is for. + $term !== null => ['path' => $path, 'value' => $value, 'default' => $term['default'], + 'placeholders' => $term['placeholders'], 'description' => $term['description']], + default => ['path' => $path, 'value' => $value], + }); } return $this->records[$path]; } + /** + * Every message the Engine says, by its path in config.php, as the Engine's own catalog lists it: each one is + * offered in Terms with its default even where the project leaves it out, so any game can reword it. + * + * @return array, description: string}> + */ + private static function listEngineMessageTerms(): array + { + $terms = []; + foreach (EngineMessageTerms::listTerms() as $path => $term) { + $terms['messages.' . $path] = $term; + } + + return $terms; + } + /** @return list */ public function getTermRecords(array $roots): array { @@ -104,7 +126,7 @@ public function getTermRecords(array $roots): array } // A setting the engine knows is offered even where the file leaves it // out, so it can be set without being typed in by hand. - foreach (array_keys(self::ENGINE_DEFAULTS) as $path) { + foreach ([...array_keys(self::ENGINE_DEFAULTS), ...array_keys(self::listEngineMessageTerms())] as $path) { $root = explode('.', $path)[0]; if (in_array($root, $roots, true) && ! in_array($path, array_map(static fn(ProjectRecord $record): mixed => $record->get('path'), $records), true) && $this->issue === null) { $records[] = $this->getRecord($path); diff --git a/tests/Unit/EngineMessageTermsTest.php b/tests/Unit/EngineMessageTermsTest.php new file mode 100644 index 0000000..1e9ae11 --- /dev/null +++ b/tests/Unit/EngineMessageTermsTest.php @@ -0,0 +1,78 @@ + ['title' => 'Hello'],\n 'messages' => [/* kept */ 'file' => 'Slot', 'computed' => strtoupper('keep')],\n];\n"); + + return $root; +} + +/** @return array{0: Ichiloto\Editor\Database\ProjectRecordDatabase, 1: int} The Terms category and a term's index. */ +function findTerm(ProjectWorkspace $workspace, string $path): array +{ + $terms = $workspace->getRecordDatabase('terms'); + $index = array_search($path, $terms->getEntryLabels(), true); + + return [$terms, $index === false ? throw new RuntimeException("No term {$path}.") : $index]; +} + +it('offers every Engine message, with the project\'s own wording where it has one', function () { + $workspace = ProjectWorkspace::fromProject(createTermsProject()); + $labels = $workspace->getRecordDatabase('terms')->getEntryLabels(); + + foreach (array_keys(EngineMessageTerms::listTerms()) as $path) { + expect($labels)->toContain('messages.' . $path); + } + expect(array_count_values($labels)['messages.file'])->toBe(1); + [$terms, $index] = findTerm($workspace, 'messages.file'); + expect($terms->getRecordByIndex($index)?->get('value'))->toBe('Slot'); +}); + +it('writes a reworded Engine message into config.php, keeps the source, writes nothing for its default, and keeps blank', function () { + $root = createTermsProject(); + $original = file_get_contents($root . '/config.php'); + $workspace = ProjectWorkspace::fromProject($root); + [$terms, $index] = findTerm($workspace, 'messages.battle.announcements.attack'); + + $terms->setField($index, 'value', '%2'); + expect($workspace->hasUnsavedChanges())->toBeFalse(); + $terms->setField($index, 'value', '%1 attacks!'); + $terms->save(); + $saved = file_get_contents($root . '/config.php'); + expect((require $root . '/config.php')['messages']['battle']['announcements']['attack'])->toBe('%1 attacks!') + ->and($saved)->toContain('// Keep header.', '/* kept */', "strtoupper('keep')"); + + [$terms, $guard] = findTerm(ProjectWorkspace::fromProject($root), 'messages.battle.announcements.guard'); + $terms->setField($guard, 'value', ''); + $terms->save(); + expect((require $root . '/config.php')['messages']['battle']['announcements']['guard'])->toBe('') + ->and($original)->not->toBe(file_get_contents($root . '/config.php')); +}); + +it('describes a term as the Engine does, and shows what a battle callout will display', function () { + $root = createTermsProject(); + $workspace = ProjectWorkspace::fromProject($root); + [$terms, $index] = findTerm($workspace, 'messages.battle.announcements.item'); + $terms->setField($index, 'value', '%1 uses %2!'); + $lines = implode("\n", RecordPanes::describe($workspace, 'terms', $index)['preview']['lines']); + $term = EngineMessageTerms::listTerms()['battle.announcements.item']; + + expect($lines)->toContain($term['description'], 'Default: %2', '%1 = Actor name', 'Shows: Actor uses Action!'); + [, $file] = findTerm($workspace, 'messages.file'); + expect(implode("\n", RecordPanes::describe($workspace, 'terms', $file)['preview']['lines']))->not->toContain('Shows:'); +}); diff --git a/tests/Unit/RecordDatabaseTest.php b/tests/Unit/RecordDatabaseTest.php index a35990b..043209a 100644 --- a/tests/Unit/RecordDatabaseTest.php +++ b/tests/Unit/RecordDatabaseTest.php @@ -363,14 +363,14 @@ $database = loadRecordDatabase($root, 'terms'); expect($database->isEditable())->toBeTrue(); - expect($database->getEntryLabels())->toBe([ - 'vocab.game.new_game', - 'vocab.game.continue', - 'vocab.currency.name', - 'vocab.currency.symbol', - 'messages.file', - 'messages.confirm.quit', - ]); + // The project's own terms in file order, then every other message the Engine says, offered with its default. + $own = ['vocab.game.new_game', 'vocab.game.continue', 'vocab.currency.name', 'vocab.currency.symbol', 'messages.file', 'messages.confirm.quit']; + $labels = $database->getEntryLabels(); + expect(array_slice($labels, 0, count($own)))->toBe($own) + ->and(array_slice($labels, count($own)))->toEqualCanonicalizing(array_values(array_diff( + array_map(static fn(string $path): string => 'messages.' . $path, array_keys(Ichiloto\Engine\Localization\EngineMessageTerms::listTerms())), + $own, + ))); $database->setField(0, 'value', 'Begin'); $database->setField(2, 'value', 'Zenny'); From 5cf11676b2d12dc9060a5092a13b946fd0497757 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 16 Oct 2026 22:09:12 +0200 Subject: [PATCH 287/344] fix(terms): show inherited vocabulary wording as a message's default Two Engine messages inherit a project's own vocabulary wording when it has one (vocabularyDefault, Engine d548c02). Terms now reads that wording from the project's own config.php, so the default shown is what the game reads, and never from another project's loaded state. --- src/ProjectConfig.php | 13 ++++++++++--- tests/Unit/EngineMessageTermsTest.php | 13 +++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/ProjectConfig.php b/src/ProjectConfig.php index 454553a..4935143 100644 --- a/src/ProjectConfig.php +++ b/src/ProjectConfig.php @@ -75,7 +75,7 @@ public function getRecord(string $path): ProjectRecord $this->originalValues[$path] = $value; // A setting the engine knows carries the default it reads when // the file holds none, which is what the setting reads as here. - $term = self::listEngineMessageTerms()[$path] ?? null; + $term = $this->listEngineMessageTerms()[$path] ?? null; $this->records[$path] = new ProjectRecord(match (true) { array_key_exists($path, self::ENGINE_DEFAULTS) => ['path' => $path, 'value' => $value, 'default' => self::ENGINE_DEFAULTS[$path]], // A message the Engine says carries its default, what it accepts and what it is for. @@ -93,10 +93,17 @@ public function getRecord(string $path): ProjectRecord * * @return array, description: string}> */ - private static function listEngineMessageTerms(): array + private function listEngineMessageTerms(): array { $terms = []; foreach (EngineMessageTerms::listTerms() as $path => $term) { + // A message that inherits the project's own vocabulary wording reads as that wording here, as the game + // reads it, taken from this project's file, never from another project's loaded runtime state. + $inherited = isset($term['vocabularyDefault']) ? self::getValue($this->payload, 'vocab.' . $term['vocabularyDefault']) : null; + if (is_string($inherited)) { + $term['default'] = $inherited; + } + unset($term['vocabularyDefault']); $terms['messages.' . $path] = $term; } @@ -126,7 +133,7 @@ public function getTermRecords(array $roots): array } // A setting the engine knows is offered even where the file leaves it // out, so it can be set without being typed in by hand. - foreach ([...array_keys(self::ENGINE_DEFAULTS), ...array_keys(self::listEngineMessageTerms())] as $path) { + foreach ([...array_keys(self::ENGINE_DEFAULTS), ...array_keys($this->listEngineMessageTerms())] as $path) { $root = explode('.', $path)[0]; if (in_array($root, $roots, true) && ! in_array($path, array_map(static fn(ProjectRecord $record): mixed => $record->get('path'), $records), true) && $this->issue === null) { $records[] = $this->getRecord($path); diff --git a/tests/Unit/EngineMessageTermsTest.php b/tests/Unit/EngineMessageTermsTest.php index 1e9ae11..5049336 100644 --- a/tests/Unit/EngineMessageTermsTest.php +++ b/tests/Unit/EngineMessageTermsTest.php @@ -76,3 +76,16 @@ function findTerm(ProjectWorkspace $workspace, string $path): array [, $file] = findTerm($workspace, 'messages.file'); expect(implode("\n", RecordPanes::describe($workspace, 'terms', $file)['preview']['lines']))->not->toContain('Shows:'); }); + +it('shows a message that inherits the project\'s vocabulary wording with that project\'s own wording as its default', function () { + $inheriting = makeTemporaryProject('editor-terms-'); + file_put_contents($inheriting . '/config.php', " ['shop' => ['exp_total' => 'EXP So Far']],\n 'messages' => [],\n];\n"); + $plain = createTermsProject(); + + [$terms, $index] = findTerm(ProjectWorkspace::fromProject($inheriting), 'messages.exp_total'); + expect($terms->getRecordByIndex($index)?->get('default'))->toBe('EXP So Far'); + // Another project opened afterwards reads the Engine's own default, not the first project's wording. + [$terms, $index] = findTerm(ProjectWorkspace::fromProject($plain), 'messages.exp_total'); + expect($terms->getRecordByIndex($index)?->get('default'))->toBe(EngineMessageTerms::listTerms()['exp_total']['default']) + ->and($terms->getRecordByIndex($index)?->get('default'))->not->toBe('EXP So Far'); +}); From 3eb7eee6f31f6bc9030c1e478162dcb2a74add8b Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 17 Oct 2026 12:47:12 +0200 Subject: [PATCH 288/344] feat(troops): compose troop members as RPG Maker's Troops tab Add TroopAuthoring, one shared service the terminal editor and the GUI session both use: add a member by choosing its enemy, remove one, clear them all and name the troop after its members, each one undo step through the troops category's own record rules, keeping keys the editor does not show (id, events) as authored. Session: troops.page (members, the enemies to add, the formation or why there is none, and Align's availability), troops.addMember, troops.removeMember, troops.clearMembers and troops.autoName. The troops.formation RPC is removed; troops.page carries the same formation. Terminal: on Troops, Shift+O opens the enemy picker and adds a member fighting the one chosen; Shift+C clears the members and Shift+N names the troop after them, with border hints and help lines. Schema: a Battle Music (bgm) reference with the project's battle theme as its unset meaning. The new-troop blank no longer adds a Regular Bat member and the blank member no longer names Regular Bat: a troop starts empty and a member's enemy is always chosen. Validation reports a member with no battle placement, which the graphical battle refuses to start without. Align and a new member's starting placement wait for the Engine's enemy arrangement rule. --- docs/manual.md | 26 ++- src/Database/RecordSchemaCatalog.php | 19 +- src/Database/TroopAuthoring.php | 182 ++++++++++++++++++ src/Editor.php | 122 +++++++++++- src/Session/EditorSession.php | 116 +++++++++++ src/Session/SessionHost.php | 6 +- src/Validation/TroopFormationValidator.php | 5 +- tests/Support/BattleFormationFixtures.php | 12 ++ tests/Unit/RecordDatabaseTest.php | 2 +- .../Session/FormationClearanceSessionTest.php | 12 +- tests/Unit/Session/TroopPageSessionTest.php | 113 +++++++++++ tests/Unit/TroopMembersEditorTest.php | 42 ++++ 12 files changed, 645 insertions(+), 12 deletions(-) create mode 100644 src/Database/TroopAuthoring.php create mode 100644 tests/Unit/Session/TroopPageSessionTest.php create mode 100644 tests/Unit/TroopMembersEditorTest.php diff --git a/docs/manual.md b/docs/manual.md index 6d96ce7..a27dfba 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1021,7 +1021,10 @@ Controls: - `Shift+A`: create a new entry in the current category - `Delete`: delete the selected entry (destructive confirmation) - `Shift+O`: append a sub-list entry (objective, beat, member, command, or - movement-route step when a route-step row is selected) + movement-route step when a route-step row is selected); a troop member is + added by choosing its enemy first +- `Shift+C` / `Shift+N` (Troops): clear the troop's members, or name the troop + after them - `Shift+X`: remove the last sub-list entry at the selected level - `/`: filter the entry list - `Ctrl+S`: save the current category @@ -1423,8 +1426,25 @@ list of members flattened into the settings pane as `Member 1 Enemy`, engine default (`allowed`); choose `forbidden` for a battle that must be won or resolved by its authored continuation. -`Shift+O` appends a member, `Shift+X` removes the last one. The `Enemy` value -must match a name in the Enemies category. +Members are composed as in RPG Maker's Troops tab. `Shift+O` opens the +project's enemies (type to narrow, `Enter` to choose) and adds a member +fighting the one chosen, with the cursor on it; `Esc` adds nothing. +`Shift+X` or `Del` removes the member the cursor is on, `Shift+C` clears every +member and `Shift+N` names the troop after its members (`Regular Bat x 2, +Marsh Wisp`), numbering a name another troop already has. Each is one undo +step. A new troop starts with no members. + +A new member starts at the blank terminal position and has no `Battle +Placement` yet. Set its terminal `X`/`Y` here; its graphical placement is set +on the GUI editor's troop canvas or typed into the row. A member without a +battle placement is reported by validation, since the graphical battle will +not start without one. Arranging members automatically (Align) waits for the +Engine's arrangement rule. + +`Battle Music` names a track in `assets/Audio/BGM` that the battle plays in +place of the project's battle theme; left unset, the project's theme plays. +Keys the editor does not show, such as a troop's `id` and its battle `events`, +are kept as they are. A troop may also declare a `Classification` - `ordinary` or `boss` - which battle-entry rules match against. An omitted classification is the engine's diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 53024d0..0303f7f 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -274,14 +274,24 @@ private static function troops(): RecordSchema removeWhenEmpty: true, displayDefault: 'ordinary', ), + // A troop's own battle music (a boss theme) outranks the + // project's battle theme; left unset, the battle plays that. + new RecordField( + 'bgm', + 'Battle Music', + removeWhenEmpty: true, + reference: 'bgm', + allowsNone: true, + displayDefault: 'Project battle theme', + ), ], labelKey: 'name', identityKey: 'name', + // A new troop starts empty, as in RPG Maker: its members are + // chosen from the project's own enemies, never assumed. blank: [ 'name' => 'New Troop', - 'enemies' => [ - ['enemy' => 'Regular Bat', 'position' => [15, 7]], - ], + 'enemies' => [], ], subList: new RecordSubList( key: 'enemies', @@ -295,7 +305,8 @@ private static function troops(): RecordSchema // and its contain limits, never the terminal position above. new RecordField('graphicalPlacement', 'Battle Placement', codec: RecordFieldCodec::BATTLER_SLOT, removeWhenEmpty: true), ], - blank: ['enemy' => 'Regular Bat', 'position' => [15, 7]], + // The enemy is chosen when a member is added (TroopAuthoring). + blank: ['enemy' => '', 'position' => [15, 7]], ), ); } diff --git a/src/Database/TroopAuthoring.php b/src/Database/TroopAuthoring.php new file mode 100644 index 0000000..d185cdd --- /dev/null +++ b/src/Database/TroopAuthoring.php @@ -0,0 +1,182 @@ + $enemies The enemies the project has, by name. + * @throws RecordRefusal When the troops cannot be written, there is no such troop, or the project has no such enemy. + */ + public function addMember(ProjectRecordDatabase $troops, int $index, string $enemy, array $enemies): RecordChange + { + $enemy = trim($enemy); + if (! in_array($enemy, $enemies, true)) { + throw new RecordRefusal($enemy === '' ? 'Choose the enemy the new member fights.' : sprintf('The project has no enemy named %s.', $enemy)); + } + $list = $this->requireMembers($troops, $index); + $at = $troops->addSubItem($index, [...$list->blank, 'enemy' => $enemy], listKey: $list->key); + $entry = $at === null ? null : ($troops->getSubItems($index, $list->key)[$at] ?? null); + if ($at === null || ! is_array($entry)) { + throw new RecordRefusal(sprintf('A member cannot be added to %s.', $this->describeTroop($troops, $index))); + } + try { + $troops->assertSourceAccepts($index); + } catch (SourcePreservationRefusal|SourceIdentityConflict|RuntimeException $refused) { + $troops->removeSubItem($index, $at, $list->key); + + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + + return new RecordChange(new GenericCommand( + sprintf('Add %s', $enemy), + static fn() => $troops->insertSubItem($index, $at, $entry, $list->key), + static fn() => $troops->removeSubItem($index, $at, $list->key), + ), $at); + } + + /** + * Removes one member with everything it carries. + * + * @throws RecordRefusal When the troops cannot be written, or there is no such troop or member. + */ + public function removeMember(ProjectRecordDatabase $troops, int $index, int $member): RecordChange + { + $list = $this->requireMembers($troops, $index); + if (! array_key_exists($member, $troops->getSubItems($index, $list->key))) { + throw new RecordRefusal(sprintf('%s has no member %d.', $this->describeTroop($troops, $index), $member + 1)); + } + + return $this->records->removeEntry($troops, $index, [], $member, $list->key); + } + + /** + * Removes every member at once; nothing changes for a troop with none. + * + * @throws RecordRefusal When the troops cannot be written, or there is no such troop. + */ + public function clearMembers(ProjectRecordDatabase $troops, int $index): RecordChange + { + $list = $this->requireMembers($troops, $index); + $record = $troops->getRecordByIndex($index); + if ($record === null || $troops->getSubItems($index, $list->key) === []) { + return new RecordChange(null, $index); + } + $before = $record->toArray(); + $record->setEntries($list, []); + $after = $record->toArray(); + try { + $troops->assertSourceAccepts($index); + } catch (SourcePreservationRefusal|SourceIdentityConflict|RuntimeException $refused) { + $record->restorePayload($before); + + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + + return new RecordChange(new GenericCommand( + 'Clear members', + static fn() => $record->restorePayload($after), + static fn() => $record->restorePayload($before), + ), $index); + } + + /** + * Names the troop after its members, through its name row's own rules. + * The name another troop already has gets a number after it, since a + * troop is known by its name. + * + * @throws RecordRefusal When the troops cannot be written, there is no such troop, or it has no member to be named after. + */ + public function nameAfterMembers(ProjectRecordDatabase $troops, int $index): RecordChange + { + $list = $this->requireMembers($troops, $index); + $enemies = array_map(static fn(array $member): string => trim(strval($member['enemy'] ?? '')), $troops->getSubItems($index, $list->key)); + $name = self::describeMembers($enemies); + if ($name === '') { + throw new RecordRefusal('Add a member first; the troop is named after its members.'); + } + $taken = []; + foreach ($troops->getRecords() as $position => $record) { + if ($position !== $index) { + $taken[] = strtolower(trim(strval($record->get($troops->schema->labelKey)))); + } + } + $unique = $name; + for ($number = 2; in_array(strtolower($unique), $taken, true); $number++) { + $unique = sprintf('%s %d', $name, $number); + } + + return $this->records->applyField($troops, $index, [], $troops->schema->labelKey, $unique, 'Auto-name'); + } + + /** + * What a troop of these enemies is called: each enemy once, in the order + * it first appears, with how many there are when more than one. + * + * @param list $enemies The members' enemies, in order. + */ + public static function describeMembers(array $enemies): string + { + $counts = []; + foreach ($enemies as $enemy) { + if ($enemy !== '') { + $counts[$enemy] = ($counts[$enemy] ?? 0) + 1; + } + } + $parts = []; + foreach ($counts as $enemy => $count) { + $parts[] = $count > 1 ? sprintf('%s x %d', $enemy, $count) : $enemy; + } + + return implode(', ', $parts); + } + + /** @throws RecordRefusal When the troops cannot be written, or there is no such troop. */ + private function requireMembers(ProjectRecordDatabase $troops, int $index): RecordSubList + { + if (! $troops->isEditable()) { + throw new RecordRefusal(sprintf('Read-only: %s.', $troops->getReadOnlyReason() ?? 'the troops cannot be written')); + } + $troops->getRecordByIndex($index) ?? throw new RecordRefusal(sprintf('troops has no record %d.', $index)); + + return $troops->schema->findInlineSubList(self::MEMBERS) + ?? throw new RecordRefusal('Troops have no members list to compose.'); + } + + private function describeTroop(ProjectRecordDatabase $troops, int $index): string + { + $record = $troops->getRecordByIndex($index); + + return $record === null ? sprintf('Troop %d', $index + 1) : $troops->getEntryLabel($record); + } +} diff --git a/src/Editor.php b/src/Editor.php index 9aad954..c0d10b1 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -54,6 +54,7 @@ use Ichiloto\Editor\Database\SummonAssignmentDiagnostics; use Ichiloto\Editor\Actors\ActorAuthoring; use Ichiloto\Editor\Database\RecordSubList; +use Ichiloto\Editor\Database\TroopAuthoring; use Ichiloto\Editor\Database\ReferencePicker; use Ichiloto\Editor\Debug\Debug; use Ichiloto\Editor\Maps\MapLinePlan; @@ -533,6 +534,12 @@ final class Editor */ private const string NPC_SELECT_FIELD = '__npc_select'; + /** + * The picker field id used when the enemy list is opened to add a troop + * member fighting the one chosen. + */ + private const string TROOP_MEMBER_ENEMY_FIELD = '__troop_member_enemy'; + /** How actors are authored: their rows, their edits, and what their panes show. */ private readonly ActorAuthoring $actorAuthoring; /** @@ -2999,6 +3006,26 @@ private function handleDatabaseInput(string $input, string $normalizedInput): vo return; } + // A troop member is added by choosing its enemy first, as RPG Maker's + // Troops tab adds one; Shift+N names the troop after its members and + // Shift+C clears them. + if ($this->getSelectedDatabaseCategoryDefinition()->key === 'troops') { + if ($this->isShiftLetterShortcut($input, 'O')) { + $this->openTroopMemberPicker(); + return; + } + + if ($this->isShiftLetterShortcut($input, 'N')) { + $this->nameTroopAfterMembers(); + return; + } + + if ($this->isShiftLetterShortcut($input, 'C')) { + $this->clearTroopMembers(); + return; + } + } + // Shift+O/Shift+X are the one sub-list idiom: quest objectives, skit // beats, troop members, and event-script commands all use them. if (($this->getSelectedRecordDatabase()?->schema->getInlineSubLists() ?? []) !== [] && $this->isShiftLetterShortcut($input, 'O')) { @@ -5308,7 +5335,10 @@ private function getHelpLines(): array $lines[] = ' to a timeline, previewed first'; } $lines[] = ' Shift+O / Shift+X add or remove an objective, beat,'; - $lines[] = ' troop member or script command'; + $lines[] = ' troop member or script command;'; + $lines[] = ' a troop member starts from its enemy'; + $lines[] = ' Shift+C / Shift+N clear a troop\'s members, or name'; + $lines[] = ' the troop after them'; $lines[] = ' Del the same, on the settings field'; $lines[] = ' the cursor is on'; $lines[] = ' Enter edit, or open the picker on a field'; @@ -8901,6 +8931,82 @@ private function addDatabaseRecordSubItem(): void } } + /** Opens the project's enemies to add a member to the selected troop, fighting the one chosen. */ + private function openTroopMemberPicker(): void + { + $catalog = $this->referenceCatalog(); + if (! $this->referencePicker->open(self::TROOP_MEMBER_ENEMY_FIELD, 'Enemy', 'enemies', $catalog->valuesFor('enemies'), '', $catalog->labelsFor('enemies'))) { + $this->setStatus('This project defines no enemies to add to a troop.', StatusLevel::WARN); + $this->renderDatabaseArea(); + + return; + } + + $this->statusMessage = 'Choose the enemy the new member fights.'; + $this->renderDatabasePanes(['settings']); + } + + /** Adds a member fighting the enemy named to the selected troop, and puts the cursor on it. */ + private function addTroopMember(string $enemy): void + { + $database = $this->getSelectedRecordDatabase(); + if (! $database instanceof ProjectRecordDatabase) { + return; + } + + $recordIndex = $this->getSelectedRecordIndex(); + $enemies = $this->referenceCatalog()->valuesFor('enemies'); + $change = $this->applyRecordChange(static fn(): RecordChange => new TroopAuthoring()->addMember($database, $recordIndex, $enemy, $enemies)); + if ($change === null) { + return; + } + + $member = (int) $change->index; + $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; + $this->selectDatabaseFieldById(ProjectRecordDatabase::subFieldId('member', $member, 'enemy')); + $this->setStatus(sprintf('Added %s as member %d.', $enemy, $member + 1), StatusLevel::INFO); + $this->renderDatabaseArea(); + } + + /** Names the selected troop after its members. */ + private function nameTroopAfterMembers(): void + { + $database = $this->getSelectedRecordDatabase(); + if (! $database instanceof ProjectRecordDatabase) { + return; + } + + $recordIndex = $this->getSelectedRecordIndex(); + $change = $this->applyRecordChange(static fn(): RecordChange => new TroopAuthoring()->nameAfterMembers($database, $recordIndex)); + if ($change === null) { + return; + } + + $record = $database->getRecordByIndex($recordIndex); + $name = $record === null ? '' : $database->getEntryLabel($record); + $this->setStatus($change->command === null ? sprintf('The troop is already named %s.', $name) : sprintf('Named the troop %s.%s', $name, $change->note === null ? '' : ' ' . $change->note), StatusLevel::INFO); + $this->renderDatabaseArea(); + } + + /** Removes every member of the selected troop; undo brings them back. */ + private function clearTroopMembers(): void + { + $database = $this->getSelectedRecordDatabase(); + if (! $database instanceof ProjectRecordDatabase) { + return; + } + + $recordIndex = $this->getSelectedRecordIndex(); + $change = $this->applyRecordChange(static fn(): RecordChange => new TroopAuthoring()->clearMembers($database, $recordIndex)); + if ($change === null) { + return; + } + + $this->clampDatabaseSettingSelection(); + $this->setStatus($change->command === null ? 'The troop has no members to clear.' : 'Cleared the troop\'s members. Undo brings them back.', StatusLevel::INFO); + $this->renderDatabaseArea(); + } + /** New beats start with a resource choice, never an invented speaker. */ private function selectNewSkitActor(int $entryIndex): void { @@ -10670,6 +10776,12 @@ private function commitReferenceSelection(): void return; } + if ($fieldId === self::TROOP_MEMBER_ENEMY_FIELD) { + $selected === null ? $this->renderDatabasePanes(['settings']) : $this->addTroopMember($selected); + + return; + } + if ($fieldId === self::WORLD_WRITE_NAME_FIELD) { if ($selected !== null) { $this->worldWriteEditor->setName($selected); @@ -13120,6 +13232,14 @@ private function createDatabaseSettingsWindow(array $layout): EditorWindow '?:Help', ), $this->isDatabaseEditing => 'Enter:Apply Esc:Cancel', + $this->getSelectedDatabaseCategoryDefinition()->key === 'troops' => $this->fitHelp( + $layout['settingsWidth'], + 'Enter:Edit Shift+O:Add Shift+X/Del:Remove Shift+C:Clear Shift+N:Auto-name', + 'Enter:Edit Shift+O:Add Del:Remove Shift+C:Clear Shift+N:Name', + 'Enter:Edit Shift+O/Del:Add/Del Shift+C/N', + 'Enter:Edit ?:Help', + 'Enter:Edit', + ), $this->hasDatabaseSubList() => $this->fitHelp( $layout['settingsWidth'], 'Enter:Edit Shift+O:Add Shift+X/Del:Remove', diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index a76018c..69629b4 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -101,6 +101,7 @@ use Ichiloto\Engine\Battle\Presentation\BattlerBindings; use Ichiloto\Editor\Database\PhpDataFile; use Ichiloto\Editor\Database\RecordSchemaCatalog; +use Ichiloto\Editor\Database\TroopAuthoring; use Ichiloto\Engine\Battle\Presentation\BattleFormationBattler; use Ichiloto\Engine\Battle\Presentation\BattleFormationLayout; use Ichiloto\Engine\Battle\Presentation\BattlerSlot; @@ -203,6 +204,9 @@ final class EditorSession /** How actors are authored, with what this editor's actor panes show. */ private readonly ActorAuthoring $actorAuthoring; + /** How a troop's members are composed, as the terminal editor composes them. */ + private readonly TroopAuthoring $troopAuthoring; + /** * Where each map's revisions count from. A map read afresh, when a file * set written at once reloads the project or its undo puts the earlier @@ -219,6 +223,7 @@ private function __construct( ) { $this->workspace = $workspace; $this->actorAuthoring = new ActorAuthoring(graphical: true); + $this->troopAuthoring = new TroopAuthoring(); } /** Opens the project at a root, as the terminal editor opens it. */ @@ -3085,6 +3090,117 @@ public function readTroopFormation(int $index, ?string $arena = null): array return [...$view, 'members' => array_values($members)]; } + /** + * A troop as its Troops page shows it, the way RPG Maker's Troops tab + * does: its members with the enemy each fights, its terminal position + * and its battle placement; the project's enemies a member can be added + * from; and its graphical formation over a previewed arena + * ({@see readTroopFormation()}), or why there is none, so a game with + * only a terminal battle still composes its troops here. Align waits for + * the Engine's rule for arranging enemies, and says so. + * + * @return array{members: list>, enemies: list, formation: ?array, formationIssue: ?string, align: array{available: bool, reason: ?string}} + * @throws SessionRefusal When the troop is unknown. + */ + public function readTroopPage(int $index, ?string $arena = null): array + { + $record = $this->requireRecordDatabase('troops')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('troops has no record %d.', $index)); + $members = []; + foreach ($record->getSubList(TroopAuthoring::MEMBERS) as $entry) { + $position = is_array($entry['position'] ?? null) && array_is_list($entry['position']) ? array_values($entry['position']) : null; + $members[] = [ + 'enemy' => strval($entry['enemy'] ?? ''), + 'position' => $position, + 'placement' => is_array($entry['graphicalPlacement'] ?? null) ? $entry['graphicalPlacement'] : null, + ]; + } + $page = [ + 'members' => $members, + 'enemies' => $this->listEnemyNames(), + 'formation' => null, + 'formationIssue' => null, + 'align' => ['available' => false, 'reason' => 'Align needs the Engine\'s rule for arranging enemies, which it does not have yet.'], + ]; + try { + $page['formation'] = $this->readTroopFormation($index, $arena); + } catch (SessionRefusal $refusal) { + $page['formationIssue'] = $refusal->getMessage(); + } + + return $page; + } + + /** + * Adds a member fighting one of the project's enemies to a troop, as one + * undo step ({@see TroopAuthoring::addMember()}). + * + * @return array{changed: bool, member: int, records: list} + * @throws SessionRefusal When the troop is unknown or read-only, or the project has no such enemy. + */ + public function addTroopMember(int $index, string $enemy): array + { + $troops = $this->requireRecordDatabase('troops'); + $enemies = $this->listEnemyNames(); + $change = $this->changeRecord(fn(): RecordChange => $this->troopAuthoring->addMember($troops, $index, $enemy, $enemies)); + + return ['changed' => true, 'member' => (int) $change->index, 'records' => $troops->getEntryLabels()]; + } + + /** + * Removes one member of a troop, as one undo step. + * + * @return array{changed: bool, records: list} + * @throws SessionRefusal When the troop or member is unknown, or the troop is read-only. + */ + public function removeTroopMember(int $index, int $member): array + { + $troops = $this->requireRecordDatabase('troops'); + $change = $this->changeRecord(fn(): RecordChange => $this->troopAuthoring->removeMember($troops, $index, $member)); + + return ['changed' => $change->command !== null, 'records' => $troops->getEntryLabels()]; + } + + /** + * Removes every member of a troop, as one undo step. + * + * @return array{changed: bool, records: list} + * @throws SessionRefusal When the troop is unknown or read-only. + */ + public function clearTroopMembers(int $index): array + { + $troops = $this->requireRecordDatabase('troops'); + $change = $this->changeRecord(fn(): RecordChange => $this->troopAuthoring->clearMembers($troops, $index)); + + return ['changed' => $change->command !== null, 'records' => $troops->getEntryLabels()]; + } + + /** + * Names a troop after its members, as one undo step. + * + * @return array{changed: bool, name: string, records: list, note?: string} + * @throws SessionRefusal When the troop is unknown or read-only, has no member, or the name is refused. + */ + public function nameTroopAfterMembers(int $index): array + { + $troops = $this->requireRecordDatabase('troops'); + $change = $this->changeRecord(fn(): RecordChange => $this->troopAuthoring->nameAfterMembers($troops, $index)); + $record = $troops->getRecordByIndex($index); + + return array_filter([ + 'changed' => $change->command !== null, + 'name' => $record === null ? '' : $troops->getEntryLabel($record), + 'records' => $troops->getEntryLabels(), + 'note' => $change->note, + ], static fn(mixed $value): bool => $value !== null); + } + + /** @return list The project's enemies, by the name a troop member names them by. */ + private function listEnemyNames(): array + { + return array_values(array_map(strval(...), new ReferenceCatalog($this->workspace)->valuesFor('enemies'))); + } + /** * An enemy as Database > Enemies shows it: its terminal sprite, read the * way the game reads it, and its battle art composed by the Engine's diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 38234fb..579c7dc 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -431,8 +431,12 @@ private function dispatch(string $method, array $params): array self::requireInt($params, 'y'), ), 'database.records' => $session->listDatabaseRecords(self::requireString($params, 'category')), - 'troops.formation' => $session->readTroopFormation(self::requireInt($params, 'index'), + 'troops.page' => $session->readTroopPage(self::requireInt($params, 'index'), is_string($params['arena'] ?? null) ? $params['arena'] : null), + 'troops.addMember' => $session->addTroopMember(self::requireInt($params, 'index'), self::requireString($params, 'enemy')), + 'troops.removeMember' => $session->removeTroopMember(self::requireInt($params, 'index'), self::requireInt($params, 'member')), + 'troops.clearMembers' => $session->clearTroopMembers(self::requireInt($params, 'index')), + 'troops.autoName' => $session->nameTroopAfterMembers(self::requireInt($params, 'index')), 'actors.preview' => $session->readActorPreview(self::requireInt($params, 'index'), is_string($params['arena'] ?? null) ? $params['arena'] : null), 'enemies.preview' => $session->readEnemyPreview(self::requireInt($params, 'index'), diff --git a/src/Validation/TroopFormationValidator.php b/src/Validation/TroopFormationValidator.php index ee90abb..e27e557 100644 --- a/src/Validation/TroopFormationValidator.php +++ b/src/Validation/TroopFormationValidator.php @@ -65,7 +65,10 @@ public function validate(ProjectWorkspace $workspace): array $where = sprintf('assets/Data/troops.php: %s', $troop); $placed = $members = []; foreach ($record->getSubList('enemies') as $index => $entry) { - if (!is_array($entry) || !is_array($entry['graphicalPlacement'] ?? null)) { + if (!is_array($entry['graphicalPlacement'] ?? null)) { + // The graphical battle needs a slot for every member and refuses to start without one. + $issues[] = Issue::error($where, sprintf('%s (member %d) has no battle placement, so a graphical battle with this troop cannot start.', + strval($entry['enemy'] ?? ''), $index + 1), 'Place it on the troop\'s formation canvas.'); continue; } try { diff --git a/tests/Support/BattleFormationFixtures.php b/tests/Support/BattleFormationFixtures.php index 2fa63aa..b72ca8e 100644 --- a/tests/Support/BattleFormationFixtures.php +++ b/tests/Support/BattleFormationFixtures.php @@ -60,6 +60,18 @@ function enemyPreviewProject(): string return $root; } +/** The troop formation project with a second enemy, a wisp, beside its bat. */ +function troopPageProject(): string +{ + $root = enemyPreviewProject(); + file_put_contents($root . '/assets/Data/Enemies/marsh-wisp.php', " Enemy::class, 'data' => " + . var_export(['name' => 'Marsh Wisp', 'level' => 1, 'imagePath' => 'wisp', 'rewards' => ['experience' => 1, 'gold' => 1], 'stats' => [ + 'maxHp' => 5, 'maxMp' => 0, 'attack' => 1, 'defence' => 1, 'magicAttack' => 1, 'magicDefence' => 1, 'speed' => 1, 'grace' => 1, 'evasion' => 1, + ]], true) . "];\n"); + + return $root; +} + /** The Regular Bat the troop formation project's troop fights, as its one enemy record, with its terminal sprite. */ function writeRegularBatEnemy(string $root): void { diff --git a/tests/Unit/RecordDatabaseTest.php b/tests/Unit/RecordDatabaseTest.php index 043209a..cc3698b 100644 --- a/tests/Unit/RecordDatabaseTest.php +++ b/tests/Unit/RecordDatabaseTest.php @@ -298,7 +298,7 @@ expect($database->countSubItems(1))->toBe(2); $removed = $database->removeSubItem(1, 1); - expect($removed)->toBe(['enemy' => 'Regular Bat', 'position' => [15, 7]]); + expect($removed)->toBe(['enemy' => '', 'position' => [15, 7]]); expect($database->countSubItems(1))->toBe(1); $database->insertSubItem(1, 1, $removed); diff --git a/tests/Unit/Session/FormationClearanceSessionTest.php b/tests/Unit/Session/FormationClearanceSessionTest.php index 4de9ad8..245df60 100644 --- a/tests/Unit/Session/FormationClearanceSessionTest.php +++ b/tests/Unit/Session/FormationClearanceSessionTest.php @@ -21,7 +21,8 @@ function formationClearanceProject(int $y): string (string) file_get_contents($layout), )); $troops = $root . '/assets/Data/troops.php'; - file_put_contents($troops, str_replace("'y' => 300,", "'y' => {$y},", (string) file_get_contents($troops))); + // Every member placed: the clearance check is what these tests are about. + file_put_contents($troops, str_replace(["'y' => 300,", " ['enemy' => 'Regular Bat', 'position' => [15, 20]],\n"], ["'y' => {$y},", ""], (string) file_get_contents($troops))); $session = EditorSession::open($root); $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index']; setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png'); @@ -68,3 +69,12 @@ function readTroopClearanceIssues(string $root): array expect($battler['diagnostics'])->toBe([]) ->and($battler['image']['asset'])->toBe('Graphics/Enemies/Bat.png'); }); + +it('reports a member with no battle placement, which the graphical battle refuses to start without', function () { + $issues = array_values(array_filter(new TroopFormationValidator()->validate(ProjectWorkspace::fromProject(troopFormationProject())), + static fn(Issue $issue): bool => str_contains($issue->message, 'no battle placement'))); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->where)->toBe('assets/Data/troops.php: Pair') + ->and($issues[0]->message)->toContain('Regular Bat (member 2)'); +}); diff --git a/tests/Unit/Session/TroopPageSessionTest.php b/tests/Unit/Session/TroopPageSessionTest.php new file mode 100644 index 0000000..0d4bcf4 --- /dev/null +++ b/tests/Unit/Session/TroopPageSessionTest.php @@ -0,0 +1,113 @@ +readTroopPage(0); + + expect($page['members'])->toBe([ + ['enemy' => 'Regular Bat', 'position' => [15, 7], 'placement' => ['x' => 260, 'y' => 300, 'width' => 275, 'height' => 190]], + ['enemy' => 'Regular Bat', 'position' => [15, 20], 'placement' => null], + ]) + ->and($page['enemies'])->toEqualCanonicalizing(['Regular Bat', 'Marsh Wisp']) + ->and($page['formation']['members'])->toHaveCount(2) + ->and($page['formationIssue'])->toBeNull() + ->and($page['align']['available'])->toBeFalse(); +}); + +it('reads a troop page without a graphical battle, saying why there is no formation', function () { + $root = troopPageProject(); + unlink($root . '/assets/Data/Presentation/battle.php'); + $page = EditorSession::open($root)->readTroopPage(0); + + expect($page['members'])->toHaveCount(2) + ->and($page['formation'])->toBeNull() + ->and($page['formationIssue'])->toBeString()->not->toBe(''); +}); + +it('adds a member by its enemy as one undo step, keeping the file\'s comments and keys it does not know', function () { + $root = troopPageProject(); + $troops = $root . '/assets/Data/troops.php'; + file_put_contents($troops, str_replace("'name' => 'Pair',", "'name' => 'Pair',\n 'id' => 'pair',\n 'events' => [['conditions' => ['turn' => 1]]],", (string) file_get_contents($troops))); + $session = EditorSession::open($root); + + $added = $session->addTroopMember(0, 'Marsh Wisp'); + expect($added['member'])->toBe(2) + ->and($session->readTroopPage(0)['members'][2])->toBe(['enemy' => 'Marsh Wisp', 'position' => [15, 7], 'placement' => null]) + ->and(fn() => $session->addTroopMember(0, 'Dragon'))->toThrow(SessionRefusal::class, 'Dragon') + ->and(fn() => $session->addTroopMember(0, ''))->toThrow(SessionRefusal::class, 'Choose'); + + $session->saveDatabase('troops'); + $written = (string) file_get_contents($troops); + $data = require $troops; + expect($written)->toContain('// The leader, placed for the graphical battle.') + ->and($data[0]['id'])->toBe('pair') + ->and($data[0]['events'])->toBe([['conditions' => ['turn' => 1]]]) + ->and(array_column($data[0]['enemies'], 'enemy'))->toBe(['Regular Bat', 'Regular Bat', 'Marsh Wisp']); + + $session->undo(); + expect($session->readTroopPage(0)['members'])->toHaveCount(2); +}); + +it('removes one member and clears them all, each one undo step', function () { + $session = EditorSession::open(troopPageProject()); + $session->addTroopMember(0, 'Marsh Wisp'); + + $session->removeTroopMember(0, 0); + expect(array_column($session->readTroopPage(0)['members'], 'enemy'))->toBe(['Regular Bat', 'Marsh Wisp']) + ->and(fn() => $session->removeTroopMember(0, 5))->toThrow(SessionRefusal::class, 'member 6'); + + expect($session->clearTroopMembers(0)['changed'])->toBeTrue() + ->and($session->readTroopPage(0)['members'])->toBe([]) + ->and($session->clearTroopMembers(0)['changed'])->toBeFalse(); + + $session->undo(); + expect(array_column($session->readTroopPage(0)['members'], 'enemy'))->toBe(['Regular Bat', 'Marsh Wisp']); + $session->undo(); + expect($session->readTroopPage(0)['members'][0]['placement'])->toBe(['x' => 260, 'y' => 300, 'width' => 275, 'height' => 190]); +}); + +it('names a troop after its members, numbering a name another troop has', function () { + $session = EditorSession::open(troopPageProject()); + $session->addTroopMember(0, 'Marsh Wisp'); + + expect($session->nameTroopAfterMembers(0)['name'])->toBe('Regular Bat x 2, Marsh Wisp'); + $session->undo(); + expect($session->listDatabaseRecords('troops')['records'])->toBe(['Pair']); + + $created = $session->createDatabaseRecord('troops'); + expect($session->readTroopPage($created['index'])['members'])->toBe([]) + ->and(fn() => $session->nameTroopAfterMembers($created['index']))->toThrow(SessionRefusal::class, 'Add a member first'); + $session->addTroopMember($created['index'], 'Regular Bat'); + $session->addTroopMember($created['index'], 'Regular Bat'); + $session->addTroopMember($created['index'], 'Marsh Wisp'); + $session->nameTroopAfterMembers(0); + + expect($session->nameTroopAfterMembers($created['index'])['name'])->toBe('Regular Bat x 2, Marsh Wisp 2'); +}); + +it('describes members by each enemy once, in order, with how many there are', function () { + expect(TroopAuthoring::describeMembers(['Bat', 'Rat', 'Bat', '']))->toBe('Bat x 2, Rat') + ->and(TroopAuthoring::describeMembers([]))->toBe(''); +}); + +it('sets a troop\'s battle music from the project\'s tracks, and unsetting it leaves the battle theme to the project', function () { + $root = troopPageProject(); + @mkdir($root . '/assets/Audio/BGM', 0o777, true); + file_put_contents($root . '/assets/Audio/BGM/Boss Theme.ogg', 'ogg'); + $session = EditorSession::open($root); + $music = array_find($session->readDatabaseRecord('troops', 0)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'bgm'); + + expect($music)->toMatchArray(['kind' => 'reference', 'reference' => 'bgm', 'value' => 'Project battle theme']); + $session->applyDatabaseRecord('troops', 0, $music['key'], 'Boss Theme'); + $session->saveDatabase('troops'); + expect((require $root . '/assets/Data/troops.php')[0]['bgm'])->toBe('Boss Theme'); + + $session->applyDatabaseRecord('troops', 0, $music['key'], ''); + $session->saveDatabase('troops'); + expect((require $root . '/assets/Data/troops.php')[0])->not->toHaveKey('bgm'); +}); diff --git a/tests/Unit/TroopMembersEditorTest.php b/tests/Unit/TroopMembersEditorTest.php new file mode 100644 index 0000000..ec66a92 --- /dev/null +++ b/tests/Unit/TroopMembersEditorTest.php @@ -0,0 +1,42 @@ +getRecordDatabase('troops'); + $picker = getEditorProperty($editor, 'referencePicker'); + + // Shift+O opens the enemies first; Escape adds nothing. + callEditorMethod($editor, 'dispatchInput', 'O'); + expect($picker->isOpen())->toBeTrue() + ->and($troops->countSubItems(0))->toBe(2); + callEditorMethod($editor, 'dispatchInput', "\x1b"); + expect($troops->countSubItems(0))->toBe(2); + + // Typing narrows the enemies; Enter adds a member fighting the one chosen, with the cursor on it. + callEditorMethod($editor, 'dispatchInput', 'O'); + foreach (str_split('Wisp') as $letter) { + callEditorMethod($editor, 'dispatchInput', $letter); + } + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(array_column($troops->getSubItems(0), 'enemy'))->toBe(['Regular Bat', 'Regular Bat', 'Marsh Wisp']) + ->and(callEditorMethod($editor, 'getSelectedDatabaseFieldId'))->toBe('member2Enemy'); + + callEditorMethod($editor, 'dispatchInput', 'N'); + expect($troops->getEntryLabels())->toBe(['Regular Bat x 2, Marsh Wisp']); + + callEditorMethod($editor, 'dispatchInput', 'C'); + expect($troops->countSubItems(0))->toBe(0); + + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($troops->countSubItems(0))->toBe(3); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($troops->getEntryLabels())->toBe(['Pair']); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($troops->countSubItems(0))->toBe(2); +}); From ef778ae21ede26a067c6ad92b9e274e5a802b4c6 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 17 Oct 2026 12:48:15 +0200 Subject: [PATCH 289/344] feat(map): author minimap landmarks in the Terminal and GUI inspectors Maps gain source-preserving authoring for the Engine's `landmarks` contract (Engine 81386f2, docs/maps.md "Active-map landmarks and discovery"). - MapLandmarks reads a map's declarations and resolves its anchors through the Engine's own MapLandmarkAnchors adapter; every edit is validated by MapLandmarkCatalog::readDeclarations against the map as it stands. - MapLandmarkFields supplies the shared inspector rows: a map list (anchor and kind chosen from what the map offers, name and translation key, secret, approach, reveal conditions) and a Minimap row on each placed event and, in the GUI session, on each selected world object. Ids are fixed once created; a first name takes the Engine's map-scoped message key. - Reference safety: an anchored event or world object cannot be deleted, and an anchored event cannot change between a transfer and another type, until the landmark changes. MapValidator reports declarations the Engine refuses. Shared Terminal inspector fixes made on the way: - The map conditions editor reads its row's own conditions, title and hint and applies through the row's target, replacing its music-variant-only path (Editor::applyMapBgmVariantValue is removed; MapInspector keeps it). - A row offering choices without options opens a list to pick from. - An inspector edit that is refused no longer also reports "updated". - The cursor stays on the edited row when an edit adds rows above it; the row key moves from EditorSession to MapInspector::describeRowKey for both. The golden shell snapshot gains the Minimap Landmarks section. --- docs/manual.md | 41 ++ src/Editor.php | 124 +++--- src/Events/EventAuthoring.php | 16 + src/Field/MapLandmarks.php | 391 ++++++++++++++++++ src/Field/WorldObjectReferences.php | 3 +- src/Inspector/MapInspector.php | 89 +++- src/Inspector/MapLandmarkFields.php | 297 +++++++++++++ src/Session/EditorSession.php | 60 +-- src/Validation/MapValidator.php | 20 + tests/Pest.php | 1 + tests/Support/MapLandmarkFixtures.php | 56 +++ tests/Unit/MapLandmarkTerminalTest.php | 102 +++++ .../Unit/Session/MapLandmarkAuthoringTest.php | 172 ++++++++ tests/__snapshots__/main_shell_100x30.ansi | 2 +- 14 files changed, 1296 insertions(+), 78 deletions(-) create mode 100644 src/Field/MapLandmarks.php create mode 100644 src/Inspector/MapLandmarkFields.php create mode 100644 tests/Support/MapLandmarkFixtures.php create mode 100644 tests/Unit/MapLandmarkTerminalTest.php create mode 100644 tests/Unit/Session/MapLandmarkAuthoringTest.php diff --git a/docs/manual.md b/docs/manual.md index 6d96ce7..87522a7 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -989,6 +989,47 @@ with the shape named, and no edit anywhere rewrites it. Validation reports an arena, in an encounter or a `start_battle` command, that the battle presentation does not declare, or that a project without one names. +### Minimap landmarks + +`Minimap Landmarks` authors the map's `landmarks` list, the places the +minimap marks (Engine `docs/maps.md`, "Active-map landmarks and discovery"). +A landmark names its anchor and never copies its cells: an event by marker, a +world object by id, or a legacy transfer that carries an `id`. Moving the +event or object moves the landmark. + +- `Unnamed Route Markers` counts the transfers no landmark describes. Every + transfer is marked on the minimap at once without a name; describing one + names it, keeps it secret or makes it an entrance or exit. +- On an event, the `Minimap` row says what the event is on the minimap. A + transfer steps through `Unnamed route marker`, `Passage`, `Entrance` and + `Exit`; any other event through `Not on the minimap`, `Building` and + `Landmark`. Choosing a kind describes the event; choosing the first option + removes the description. In the GUI editor a selected world object has the + same row. +- `Landmarks` is the Inspector's usual list. `Shift+O` describes the next + place no landmark describes yet; `Del`/`Shift+X` removes a landmark. + `Anchor` is picked from the map's events, world objects and legacy + transfers that no other landmark uses, and `Kind` from the kinds that + anchor may be. Moving a landmark between a transfer and anything else makes + it a passage or a landmark accordingly. +- The id, shown on the landmark's heading, is fixed: saves remember what the + party has learned by it. An object's own id is used when it is a valid + landmark id; otherwise the next free `landmark-N`. +- `Name` is the label the minimap shows once the place is known; clearing it + removes the name. A first name takes the Engine's map-scoped translation + key, shown and editable as `Name Translation Key`. +- `Secret` hides the marker until play reveals it explicitly; a secret says + nothing about approach. Otherwise `Learned On Approach` and `Approach + Distance (cells)` set how near the party must come, `(engine default: 3)` + until set. `Revealed When` builds the world conditions that reveal it + through play with the shared conditions editor; an empty list removes them. + +Every edit is checked by the Engine's own validator against the map as it +stands and is one undo step. An event or world object a landmark anchors +cannot be deleted, and an anchored event cannot change between a transfer and +any other type, until the landmark is changed or removed. Validation reports +landmarks the Engine would refuse. + In Event mode, painting or selecting a marker with no definition opens the Event Type picker. **Story Script** creates the engine's `ScriptEventTrigger`. Its inspector exposes `Script Id`, `Mode` diff --git a/src/Editor.php b/src/Editor.php index 9aad954..fdfbc6c 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -7382,8 +7382,8 @@ private function applySelectedEventOption(): void } try { - $this->applyInspectorFieldValue($field, (string) $selectedEntry['value']); - $this->closeEventOptionDialog(sprintf('Set %s to %s.', mb_strtolower($title), $selectedEntry['label'])); + $applied = $this->applyInspectorFieldValue($field, (string) $selectedEntry['value']); + $this->closeEventOptionDialog($applied ? sprintf('Set %s to %s.', mb_strtolower($title), $selectedEntry['label']) : ''); } catch (Throwable $throwable) { $this->closeEventOptionDialog(''); $this->setErrorStatus($throwable, sprintf('%s selection', $title)); @@ -7966,6 +7966,12 @@ private function activateInspectorField(): void return; } + // A short enum also lists its options and is stepped through below; a longer list is picked from. + if (is_array($field['choices'] ?? null) && $field['choices'] !== [] && ! isset($field['options'])) { + $this->openInspectorChoiceDialog($field); + return; + } + if ($this->isChestTypeField($field)) { $this->openEventOptionDialog( (string) ($field['marker'] ?? ''), @@ -8011,7 +8017,7 @@ private function activateInspectorField(): void } if (($field['mapConditions'] ?? false) === true) { - $this->openMapVariantConditionEditor($field); + $this->openMapConditionEditor($field); return; } @@ -8146,11 +8152,11 @@ private function resolveInspectorFieldOptions(array $field): ?array private function applyInspectorAdjustment(array $field, string $rawValue): void { try { - $this->applyInspectorFieldValue($field, $rawValue); + $applied = $this->applyInspectorFieldValue($field, $rawValue); $this->clampCursor(); $this->clampCanvasOffsets(); $this->clampInspectorSelection(); - $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::INFO); + if ($applied) { $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::INFO); } } catch (Throwable $throwable) { $this->setErrorStatus($throwable, sprintf('%s edit', $field['label'] ?? 'Field')); } @@ -8314,11 +8320,11 @@ private function commitInspectorEdit(): void } try { - $this->applyInspectorFieldValue($field, $this->inspectorEditBuffer); + $applied = $this->applyInspectorFieldValue($field, $this->inspectorEditBuffer); $this->clampCursor(); $this->clampCanvasOffsets(); $this->clampInspectorSelection(); - $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::INFO); + if ($applied) { $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::INFO); } } catch (Throwable $throwable) { $this->setErrorStatus($throwable, sprintf('%s edit', $field['label'] ?? 'Field')); } @@ -8334,25 +8340,49 @@ private function commitInspectorEdit(): void * * @param array $field The inspector field descriptor. * @param string $rawValue The raw edited value. - * @return void + * @return bool False when the edit was refused, whose reason is then the status; callers announce success only on true. */ - private function applyInspectorFieldValue(array $field, string $rawValue): void + private function applyInspectorFieldValue(array $field, string $rawValue): bool { $selectedMap = $this->getSelectedMap(); if (! $selectedMap instanceof ProjectMap) { - return; + return false; } try { $command = $this->createMapInspector()->apply($selectedMap, $field, $rawValue); } catch (InspectorRefusal $refusal) { $this->setStatus($refusal->getMessage(), $refusal->details === [] ? StatusLevel::WARN : StatusLevel::ERROR, $refusal->details); - return; + return false; } if ($command !== null) { $this->recordCommand($command); + $this->reselectInspectorField(MapInspector::describeRowKey($field)); + } + + return true; + } + + /** + * Keeps the cursor on the row an edit was made through: an edit can add or remove rows above it (describing an + * event lists its landmark among the map's rows), and the cursor follows the row, not its old position. + * + * @param array|null $key The row's key ({@see MapInspector::describeRowKey()}). + */ + private function reselectInspectorField(?array $key): void + { + if ($key === null) { return; } + ksort($key); + foreach ($this->getInspectorFields() as $index => $candidate) { + $candidateKey = MapInspector::describeRowKey($candidate); + if ($candidateKey === null) { continue; } + ksort($candidateKey); + if ($candidateKey === $key) { + $this->selectedInspectorFieldIndex = $index; + return; + } } } /** @@ -10439,8 +10469,10 @@ private function commitConditions(): void $selectedMap = $this->getSelectedMap(); if ($selectedMap instanceof ProjectMap) { - $this->applyMapBgmVariantValue($selectedMap, $field, ConditionCodec::decodeAll($encoded)); - $this->setStatus(sprintf('%s updated.', $label), StatusLevel::INFO); + // The row applies the line as any interface sends it: its target owns the list it changes. + if ($this->applyInspectorFieldValue($field, $encoded)) { + $this->setStatus(sprintf('%s updated.', $label), StatusLevel::INFO); + } } $this->renderSelectionDependentArea(); @@ -11048,25 +11080,6 @@ private function adjustDatabaseOptionField(int $step): void $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); } - /** - * Writes one music variant row as one undo step (see - * {@see MapInspector::applyMapBgmVariantValue()}). - * - * @param array $field The inspector field descriptor. - */ - private function applyMapBgmVariantValue(ProjectMap $map, array $field, mixed $value): void - { - try { - $command = $this->createMapInspector()->applyMapBgmVariantValue($map, $field, $value); - } catch (InspectorRefusal $refusal) { - $this->setStatus($refusal->getMessage(), StatusLevel::WARN); - return; - } - - if ($command !== null) { - $this->recordCommand($command); - } - } /** * Returns the variant row the inspector cursor is inside, or null. * @@ -11138,29 +11151,22 @@ private function moveMapBgmVariant(int $step): void } /** - * Opens the shared Condition editor on a music variant's conditions, - * hosted in the Inspector pane. + * Opens the shared Condition editor on a map row's conditions (a music + * variant's, a landmark's reveal), hosted in the Inspector pane. The row + * carries its conditions, the editor's title and its hint; the result + * applies back through the row. * * @param array $field The inspector field descriptor. */ - private function openMapVariantConditionEditor(array $field): void + private function openMapConditionEditor(array $field): void { - $selectedMap = $this->getSelectedMap(); - - if (! $selectedMap instanceof ProjectMap) { - return; - } - - $variants = MapBgmVariants::fromMap($selectedMap); - $index = (int) ($field['index'] ?? 0); - $this->mapConditionField = $field; $this->conditionEditor->open( (string) ($field['field'] ?? 'conditions'), - sprintf('Variant %d Conditions', $index + 1), - $variants->conditionsAt($index), + (string) ($field['conditionsTitle'] ?? trim((string) ($field['label'] ?? 'Conditions'))), + ConditionCodec::decodeAll((string) ($field['encoded'] ?? '')), ); - $this->statusMessage = 'Building conditions. An empty list always matches.'; + $this->statusMessage = (string) ($field['conditionsHint'] ?? 'Building conditions.'); $this->renderInspectorArea(); } @@ -12529,6 +12535,30 @@ private function openInspectorReferenceDialog(array $field): bool return true; } + /** + * Opens the list a row's value is chosen from, as it describes its choices; the chosen value applies through + * the row like any other inspector edit. + * + * @param array $field + */ + private function openInspectorChoiceDialog(array $field): void + { + $title = trim((string) ($field['label'] ?? 'Choice')); + $this->optionDialogField = $field; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = $title; + $this->eventOptionDialogEntries = array_values(array_map(static fn(array $choice): array => [ + 'label' => (string) ($choice['label'] ?? $choice['value'] ?? ''), + 'value' => (string) ($choice['value'] ?? ''), + 'description' => (string) ($choice['description'] ?? ''), + ], $field['choices'])); + $this->selectedEventOptionIndex = $this->resolveEventOptionSelectionIndex((string) ($field['value'] ?? '')); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = sprintf('Choose %s.', mb_strtolower($title)); + $this->renderSelectionDependentArea(); + } + private function isChestTypeField(array $field): bool { return ($field['target'] ?? null) === 'event' diff --git a/src/Events/EventAuthoring.php b/src/Events/EventAuthoring.php index d1137d8..8f4210f 100644 --- a/src/Events/EventAuthoring.php +++ b/src/Events/EventAuthoring.php @@ -8,7 +8,9 @@ use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Maps\MapLayers; +use Ichiloto\Editor\Field\MapLandmarks; use Ichiloto\Editor\ProjectMap; +use Ichiloto\Engine\Field\MapLandmarkKind; /** * An event's life on a map as every editor interface authors it: created at @@ -94,6 +96,15 @@ public static function setEventType(ProjectMap $map, string $marker, EventTypeDe return null; } + // A route landmark marks a transfer and any other marks something else: the type cannot cross that line + // under a landmark that describes the event. + $landmark = MapLandmarks::findAnchoredEntry($map, ['event' => $marker]); + $kind = MapLandmarkKind::tryFrom((string) ($landmark['kind'] ?? '')); + if ($kind !== null && $kind->isRoute() !== MapLandmarks::isTransfer($definition)) { + throw new EventRefusal(sprintf('Event %s is the %s landmark %s; change or remove the landmark before making it a %s.', + $marker, $kind->value, $landmark['id'] ?? '?', $type->label)); + } + $map->setEventDefinition($marker, $definition); return new GenericCommand( @@ -122,6 +133,11 @@ public static function deleteEvent(ProjectMap $map, string $marker): Command { self::assertEditable($map); self::assertEventExists($map, $marker); + // A landmark names its event and never copies its cells, so the event cannot go while one describes it. + $references = MapLandmarks::describeAnchorReferences($map, ['event' => $marker]); + if ($references !== []) { + throw new EventRefusal(sprintf('Event %s anchors %s; remove that landmark first.', $marker, implode(', ', $references))); + } return self::recordEdit($map, 'Event delete', static function () use ($map, $marker): void { $map->removeEventDefinition($marker); diff --git a/src/Field/MapLandmarks.php b/src/Field/MapLandmarks.php new file mode 100644 index 0000000..52d6608 --- /dev/null +++ b/src/Field/MapLandmarks.php @@ -0,0 +1,391 @@ +> $entries */ + private function __construct(public ProjectMap $map, public array $entries) {} + + /** @throws InvalidArgumentException When the authored block is not a list of declarations. */ + public static function readMap(ProjectMap $map): self + { + $block = $map->getMapDataField([self::KEY]) ?? []; + if (!is_array($block) || !array_is_list($block) || array_any($block, static fn($entry): bool => !is_array($entry))) { + throw new InvalidArgumentException('landmarks must be a list of declarations; it cannot be edited as rows.'); + } + + return new self($map, $block); + } + + /** + * What the map's landmarks may anchor to, read by the Engine's own adapter from the map as it stands: placed + * events (an event without cells has nothing to mark), world objects at their anchor cell, and legacy + * transfers. Only a legacy transfer with a stable `id` can be chosen as an anchor. + * + * @throws InvalidArgumentException When world objects cannot be read. + */ + public static function readAnchors(ProjectMap $map): MapLandmarkAnchors + { + $events = []; + foreach ($map->getEventDefinitions() as $marker => $definition) { + $area = $map->getEventArea((string) $marker); + if ($area === null || !is_array($definition)) { continue; } + $events[] = [...$definition, 'marker' => (string) $marker, 'area' => $area->toArray()]; + } + $triggers = $map->getEditableData()['triggers'] ?? []; + + return MapLandmarkAnchors::readMap(['events' => $events, 'worldObjects' => WorldObjectAuthoring::readEntries($map), + 'triggers' => is_array($triggers) ? $triggers : []]); + } + + /** @return list The number of real cells on each row, so ragged maps keep their shape. */ + public static function readRowWidths(ProjectMap $map): array + { + return array_values(array_map(count(...), $map->getLayerSet()->getComposedGrid())); + } + + /** + * Checks declarations against the map as it stands, through the Engine's validator. + * + * @param list> $entries + * @throws InvalidArgumentException With the Engine's reason when the map would not load them. + */ + public static function validateEntries(ProjectMap $map, array $entries): MapLandmarkCatalog + { + return MapLandmarkCatalog::readDeclarations($entries, self::readAnchors($map), self::readRowWidths($map)); + } + + /** Whether an event definition moves the player to another map: the only kind of event a route marks. */ + public static function isTransfer(array $definition): bool + { + return is_string($definition['class'] ?? null) && is_a($definition['class'], TransferPlayerTrigger::class, true); + } + + public function findIndex(string $id): int + { + foreach ($this->entries as $index => $entry) { if (($entry['id'] ?? null) === $id) { return $index; } } + throw new InvalidArgumentException("This map has no landmark {$id}."); + } + + /** @param array $anchor */ + public function findAnchorIndex(array $anchor): ?int + { + foreach ($this->entries as $index => $entry) { + if (self::readAnchor($entry) === $anchor) { return $index; } + } + return null; + } + + /** + * An entry's anchor as `[kind => id]`, with an event marker as a string whatever PHP made of its key. + * + * @return array|null + */ + public static function readAnchor(array $entry): ?array + { + $anchor = $entry['anchor'] ?? null; + if (!is_array($anchor) || count($anchor) !== 1) { return null; } + $kind = array_key_first($anchor); + $id = $anchor[$kind]; + return in_array($kind, ['event', 'object', 'trigger'], true) && (is_string($id) || is_int($id)) ? [$kind => (string) $id] : null; + } + + /** A selector's value for an anchor: its kind, a colon, then its id (a marker or an id may itself hold one). */ + public static function encodeAnchor(array $anchor): string + { + $kind = (string) array_key_first($anchor); + return $kind . ':' . $anchor[$kind]; + } + + /** @return array */ + public static function decodeAnchor(string $value): array + { + [$kind, $id] = array_pad(explode(':', $value, 2), 2, ''); + if (!in_array($kind, ['event', 'object', 'trigger'], true) || $id === '') { + throw new InvalidArgumentException('Choose a listed event, world object or legacy transfer.'); + } + return [$kind => $id]; + } + + /** + * Every anchor on the map with what an author reads for it, whether it is a transfer, and the landmark already + * describing it, if any. + * + * @return list, value: string, label: string, transfer: bool, landmark: ?string}> + */ + public function describeAnchors(): array + { + $anchors = self::readAnchors($this->map); + $claimed = []; + foreach ($this->entries as $entry) { + $anchor = self::readAnchor($entry); + if ($anchor !== null && is_string($entry['id'] ?? null)) { $claimed[self::encodeAnchor($anchor)] = $entry['id']; } + } + $described = []; + foreach ([['event', $anchors->events], ['object', $anchors->objects], ['trigger', $anchors->triggers]] as [$kind, $table]) { + foreach ($table as $id => $anchor) { + // A legacy transfer without an id is keyed by its position, which is no identity to keep. + if ($kind === 'trigger' && str_starts_with((string) $id, '@')) { continue; } + $reference = [$kind => (string) $id]; + $value = self::encodeAnchor($reference); + $described[] = ['anchor' => $reference, 'value' => $value, 'label' => $this->describeAnchor($reference), + 'transfer' => $anchor->isTransfer, 'landmark' => $claimed[$value] ?? null]; + } + } + return $described; + } + + /** What an author reads for an anchor: an event by marker and type (and where a transfer leads), or an id. */ + public function describeAnchor(array $anchor): string + { + $kind = (string) array_key_first($anchor); + $id = (string) $anchor[$kind]; + if ($kind === 'object') { return 'World object ' . $id; } + if ($kind === 'trigger') { return 'Legacy transfer ' . $id; } + $definition = $this->map->getEventDefinition($id); + $type = EventTypeCatalog::describeClass(is_string($definition['class'] ?? null) ? $definition['class'] : null); + $destination = $definition['data']['destinationMap'] ?? null; + return sprintf('Event %s · %s%s', $id, $type, is_string($destination) && $destination !== '' ? ' to ' . $destination : ''); + } + + /** + * The kinds a landmark on this anchor may be: a transfer is a route (passage, entrance or exit), anything else + * a building or a landmark. + * + * @return list + */ + public function findKindsFor(array $anchor): array + { + return array_values(array_filter(MapLandmarkKind::cases(), + fn(MapLandmarkKind $kind): bool => $kind->isRoute() === $this->isRouteAnchor($anchor))); + } + + public function isRouteAnchor(array $anchor): bool + { + $kind = (string) array_key_first($anchor); + if ($kind === 'trigger') { return true; } + if ($kind === 'object') { return false; } + $definition = $this->map->getEventDefinition((string) $anchor[$kind]); + return is_array($definition) && self::isTransfer($definition); + } + + /** + * Describes a place: a declaration for an anchor no landmark describes yet. A route starts as the passage its + * automatic marker already is, so describing it changes nothing on the minimap until an author edits it; any + * other anchor starts as the kind chosen, or a landmark. Its id is the anchor's own stable id where that is a + * valid landmark id, otherwise the next free `landmark-N`, and never changes afterwards: saves remember + * discoveries by it. + * + * @return array{entries: list>, id: string} + */ + public function addLandmark(array $anchor, ?MapLandmarkKind $kind = null): array + { + if ($this->findAnchorIndex($anchor) !== null) { + throw new InvalidArgumentException(sprintf('%s already anchors a landmark.', $this->describeAnchor($anchor))); + } + $kinds = $this->findKindsFor($anchor); + $kind ??= $kinds[0] === MapLandmarkKind::PASSAGE ? MapLandmarkKind::PASSAGE : MapLandmarkKind::LANDMARK; + if (!in_array($kind, $kinds, true)) { + throw new InvalidArgumentException(sprintf('%s cannot be a %s.', $this->describeAnchor($anchor), $kind->value)); + } + $id = $this->findUnusedId($anchor); + return ['entries' => [...$this->entries, ['id' => $id, 'kind' => $kind->value, 'anchor' => $anchor]], 'id' => $id]; + } + + /** + * Points a landmark at another anchor. Moving between a route and anything else changes what the landmark can + * be, so its kind becomes what a new landmark there starts as; everything else it says is kept. + * + * @return list> + */ + public function setAnchor(int $index, array $anchor): array + { + $entries = $this->entries; + $current = self::readAnchor($entries[$index]); + if ($current === $anchor) { return $entries; } + $other = $this->findAnchorIndex($anchor); + if ($other !== null) { + throw new InvalidArgumentException(sprintf('%s already anchors landmark %s.', $this->describeAnchor($anchor), $entries[$other]['id'] ?? $other)); + } + $entries[$index]['anchor'] = $anchor; + $kind = MapLandmarkKind::tryFrom((string) ($entries[$index]['kind'] ?? '')); + if ($kind === null || !in_array($kind, $this->findKindsFor($anchor), true)) { + $entries[$index]['kind'] = ($this->isRouteAnchor($anchor) ? MapLandmarkKind::PASSAGE : MapLandmarkKind::LANDMARK)->value; + } + return $entries; + } + + /** @return list> */ + public function setKind(int $index, MapLandmarkKind $kind): array + { + $entries = $this->entries; + $entries[$index]['kind'] = $kind->value; + return $entries; + } + + /** + * Names a landmark, or removes its name when the text is blank. A first name takes the Engine's map-scoped + * translation key; a name already keyed keeps its key. + * + * @return list> + */ + public function setName(int $index, string $text): array + { + $entries = $this->entries; + if (trim($text) === '') { + unset($entries[$index]['name']); + return $entries; + } + $message = $entries[$index]['name']['message'] ?? MapLandmarkCatalog::getNameMessageKey($this->map->mapId, (string) $entries[$index]['id']); + $entries[$index]['name'] = ['message' => $message, 'default' => $text]; + return $entries; + } + + /** @return list> */ + public function setNameMessage(int $index, string $message): array + { + $entries = $this->entries; + if (!isset($entries[$index]['name'])) { throw new InvalidArgumentException('Name the landmark before choosing its translation key.'); } + if (trim($message) === '') { throw new InvalidArgumentException('A named landmark needs a translation key.'); } + $entries[$index]['name']['message'] = trim($message); + return $entries; + } + + /** + * Makes a landmark secret or ordinary. A secret is revealed only explicitly, so it says nothing about approach; + * an ordinary one is learned on approach at the Engine's distance until an author sets another. + * + * @return list> + */ + public function setSecret(int $index, bool $secret): array + { + $entries = $this->entries; + if ($secret) { + $entries[$index]['secret'] = true; + unset($entries[$index]['approachDistance']); + } else { + unset($entries[$index]['secret']); + } + return $entries; + } + + /** + * Sets how near the party must come to learn a landmark: a distance in cells, or null when approach never + * reveals it. + * + * @return list> + */ + public function setApproachDistance(int $index, ?int $distance): array + { + $entries = $this->entries; + if (($entries[$index]['secret'] ?? false) === true) { + throw new InvalidArgumentException('A secret landmark is revealed only explicitly; approach never reveals it.'); + } + $entries[$index]['approachDistance'] = $distance; + return $entries; + } + + /** + * Returns a landmark to the Engine's approach distance by saying nothing about it. + * + * @return list> + */ + public function resetApproachDistance(int $index): array + { + $entries = $this->entries; + unset($entries[$index]['approachDistance']); + return $entries; + } + + /** + * Sets the world conditions that reveal a landmark through play. An empty list removes them: the Engine reads a + * declared empty list as an error, never as "always". + * + * @param list> $conditions + * @return list> + */ + public function setRevealConditions(int $index, array $conditions): array + { + $entries = $this->entries; + if ($conditions === []) { unset($entries[$index]['revealConditions']); } + else { $entries[$index]['revealConditions'] = array_values($conditions); } + return $entries; + } + + /** @return list> */ + public function removeLandmark(int $index): array + { + $entries = $this->entries; + array_splice($entries, $index, 1); + return $entries; + } + + /** + * The landmarks that anchor to a source, which keep it from being deleted or from changing between a route and + * anything else. A landmark block that cannot be read is reported, since its references cannot be proved. + * + * @param array $anchor + * @return list + */ + public static function describeAnchorReferences(ProjectMap $map, array $anchor): array + { + try { + $landmarks = self::readMap($map); + } catch (InvalidArgumentException $error) { + return [sprintf('%s landmarks: %s', $map->mapId, $error->getMessage())]; + } + $index = $landmarks->findAnchorIndex($anchor); + return $index === null ? [] : [sprintf('%s landmark %s (%s)', $map->mapId, $landmarks->entries[$index]['id'] ?? $index, + $landmarks->entries[$index]['kind'] ?? 'no kind')]; + } + + /** + * The landmark an anchor carries, when one does and its block can be read. + * + * @param array $anchor + * @return array|null + */ + public static function findAnchoredEntry(ProjectMap $map, array $anchor): ?array + { + try { + $landmarks = self::readMap($map); + } catch (InvalidArgumentException) { + return null; + } + $index = $landmarks->findAnchorIndex($anchor); + return $index === null ? null : $landmarks->entries[$index]; + } + + private function findUnusedId(array $anchor): string + { + $ids = array_column($this->entries, 'id'); + $own = (string) $anchor[array_key_first($anchor)]; + if (array_key_first($anchor) !== 'event' && preg_match(MapLandmarkCatalog::ID_PATTERN, $own) === 1 && !in_array($own, $ids, true)) { + return $own; + } + for ($number = 1; ; $number++) { + if (!in_array('landmark-' . $number, $ids, true)) { return 'landmark-' . $number; } + } + } +} diff --git a/src/Field/WorldObjectReferences.php b/src/Field/WorldObjectReferences.php index 488b85c..b2a10ce 100644 --- a/src/Field/WorldObjectReferences.php +++ b/src/Field/WorldObjectReferences.php @@ -18,7 +18,8 @@ public function __construct(private ProjectWorkspace $workspace) {} public function describeReferences(ProjectMap $map, string $id): array { - $found = $scripts = []; + $found = MapLandmarks::describeAnchorReferences($map, ['object' => $id]); + $scripts = []; $cinematics = $this->workspace->cutscenes?->assets(CutsceneType::CINEMATIC) ?? []; $knownFiles = array_merge([], ...array_map(static fn($asset): array => $asset->paths(), $cinematics)); foreach (glob($this->workspace->projectRoot . '/' . CutsceneType::CINEMATIC->relativeRoot() . '/*/*.php') ?: [] as $path) { diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index 12e49b0..acb91e3 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -19,6 +19,7 @@ use Ichiloto\Editor\Events\EventTypeDefinition; use Ichiloto\Editor\Field\MapBgmVariants; use Ichiloto\Editor\Field\MapEncounters; +use Ichiloto\Editor\Field\MapLandmarks; use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\ProjectMap; @@ -31,6 +32,7 @@ use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandReference; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; +use Closure; use InvalidArgumentException; use Throwable; @@ -165,6 +167,7 @@ public function getMapFields(ProjectMap $map): array 'editable' => false, ], ...$this->mapRuntimeFields($map), + ...MapLandmarkFields::describeMapFields($map), ]; } @@ -245,7 +248,7 @@ public function getEventFields(ProjectMap $map, string $marker): array $fields = \Ichiloto\Editor\Maps\MapPlacement::describeEventFields($definition, $fields); } - return $fields; + return [...$fields, ...MapLandmarkFields::describeEventFields($map, $marker)]; } /** @@ -306,10 +309,69 @@ public function apply(ProjectMap $map, array $field, string $rawValue): ?Command 'event' => $this->applyEventField($map, $field, $value), 'event-type' => $this->applyEventType($map, (string) $field['marker'], $rawValue), 'event-bounds' => $this->applyEventBounds($map, (string) $field['marker'], (string) $field['field'], (int) $value), + MapLandmarkFields::TARGET, MapLandmarkFields::EVENT_TARGET, MapLandmarkFields::OBJECT_TARGET => $this->applyLandmarkValue($map, $field, $value), default => null, }; } + /** + * Applies one minimap landmark row's edit: the declarations it leaves are checked by the Engine against the map + * as it stands, then written as one undo step. + * + * @param array $field + * @throws InspectorRefusal When the edit cannot be made or the map would not load the result. + */ + private function applyLandmarkValue(ProjectMap $map, array $field, mixed $value): ?Command + { + try { + $entries = MapLandmarkFields::applyValue($map, $field, $value); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + + return $entries === null ? null : $this->writeLandmarks($map, $entries, trim((string) ($field['label'] ?? 'Landmark'))); + } + + /** + * Writes a map's landmark declarations once the Engine accepts them; none removes the key. + * + * @param list> $entries + * @throws InspectorRefusal When the map would not load them. + */ + private function writeLandmarks(ProjectMap $map, array $entries, string $label): ?Command + { + $problem = MapLandmarkFields::findProblem($map, $entries); + if ($problem !== null) { + throw new InspectorRefusal($problem); + } + + return $this->writeMapData($map, [MapLandmarks::KEY], $entries === [] ? null : $entries, 'Landmark ' . lcfirst($label)); + } + + /** + * What names a row so it can be found again among a map's current rows: its target and the members that tell + * it from its neighbours. Null for a row nothing is applied through. Every interface finds a row by this. + * + * @param array $field + * @return array|null + */ + public static function describeRowKey(array $field): ?array + { + if (! is_string($field['target'] ?? null)) { + return null; + } + + return array_filter([ + 'target' => $field['target'], + 'field' => isset($field['field']) ? (string) $field['field'] : null, + 'path' => isset($field['path']) ? array_values(array_map('strval', (array) $field['path'])) : null, + 'marker' => isset($field['marker']) ? (string) $field['marker'] : null, + 'index' => isset($field['index']) ? (int) $field['index'] : null, + // A list heading has no path of its own; its list's names it. + 'list' => is_array($field['list']['path'] ?? null) ? array_values(array_map('strval', $field['list']['path'])) : null, + ], static fn(mixed $value): bool => $value !== null); + } + /** * The values a row is picked from: its `choices`, or the `options` it * steps through. Null for a row that is typed or referenced. @@ -391,6 +453,10 @@ public function addListEntry(ProjectMap $map, array $field): ?InspectorListEdit sprintf('Added %s to the encounter table.', $available[0])); } + if (($field['target'] ?? null) === MapLandmarkFields::TARGET && is_array($field['landmarkList'] ?? null)) { + return $this->editLandmarkList($map, static fn(): array => MapLandmarkFields::addEntry($map), 'add'); + } + if (($field['target'] ?? null) === 'map-bgm-variants' && is_array($field['bgmVariantList'] ?? null)) { $variants = $this->requireEditableBgmVariants($map); @@ -441,6 +507,10 @@ public function removeListEntry(ProjectMap $map, array $field): ?InspectorListEd : sprintf('Removed %s from the encounter table.', $rows[$index]['name'])); } + if (($field['target'] ?? null) === MapLandmarkFields::TARGET && is_array($field['landmarkList'] ?? null)) { + return $this->editLandmarkList($map, static fn(): array => MapLandmarkFields::removeEntry($map, $field), 'remove'); + } + if (($field['target'] ?? null) === 'map-bgm-variants' && is_array($field['bgmVariantList'] ?? null)) { $variants = $this->requireEditableBgmVariants($map); $index = (int) $field['bgmVariantList']['index']; @@ -969,6 +1039,21 @@ private function requireArena(string $value, string $none): ?string return $value; } + /** + * @param Closure(): array{entries: list>, summary: string} $edit + * @throws InspectorRefusal When the list cannot take the edit or the map would not load the result. + */ + private function editLandmarkList(ProjectMap $map, Closure $edit, string $action): InspectorListEdit + { + try { + $result = $edit(); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + + return new InspectorListEdit($this->writeLandmarks($map, $result['entries'], $action), $result['summary']); + } + /** @throws InspectorRefusal When the map's encounters cannot be edited here. */ private function requireEditableEncounters(ProjectMap $map): MapEncounters { @@ -1237,6 +1322,8 @@ private function mapBgmVariantFields(ProjectMap $map, array $known): array 'editable' => $issue === null, 'mapConditions' => $issue === null, 'encoded' => $issue === null ? ConditionCodec::encodeAll($conditions) : null, + 'conditionsTitle' => sprintf('Variant %d Conditions', $index + 1), + 'conditionsHint' => 'Building conditions. An empty list always matches.', ]; } diff --git a/src/Inspector/MapLandmarkFields.php b/src/Inspector/MapLandmarkFields.php new file mode 100644 index 0000000..7952c2c --- /dev/null +++ b/src/Inspector/MapLandmarkFields.php @@ -0,0 +1,297 @@ +> */ + public static function describeMapFields(ProjectMap $map): array + { + try { + $landmarks = MapLandmarks::readMap($map); + $anchors = $landmarks->describeAnchors(); + } catch (InvalidArgumentException $error) { + return [['label' => 'Minimap Landmarks', 'value' => '', 'editable' => false], + ['label' => ' ! Read-only', 'value' => $error->getMessage(), 'editable' => false]]; + } + $automatic = count(array_filter($anchors, static fn(array $anchor): bool => $anchor['transfer'] && $anchor['landmark'] === null)); + $count = count($landmarks->entries); + $fields = [ + ['label' => 'Minimap Landmarks', 'value' => '', 'editable' => false], + // Every transfer no landmark describes is still marked, without a name. + ['label' => ' Unnamed Route Markers', 'value' => (string) $automatic, 'editable' => false], + // The list's own heading, so adding works on an empty list as on every other inspector list. + ['label' => sprintf(' Landmarks · %d', $count), 'value' => '', 'editable' => false, + 'target' => self::TARGET, 'landmarkList' => ['index' => max(0, $count - 1)]], + ]; + $problem = self::findProblem($map, $landmarks->entries); + if ($problem !== null) { + $fields[] = ['label' => ' ! Problem', 'value' => $problem, 'editable' => false]; + } + foreach ($landmarks->entries as $index => $entry) { + array_push($fields, ...self::describeEntryFields($landmarks, $anchors, $index, $entry)); + } + return $fields; + } + + /** + * An event's minimap row: what the event is on the minimap, chosen among the kinds it may be. A transfer that + * no landmark describes still shows as an unnamed route; choosing a kind describes it, and choosing the first + * option removes the description. An event without cells has nothing to mark. + * + * @return list> + */ + public static function describeEventFields(ProjectMap $map, string $marker): array + { + return $map->getEventArea($marker) === null ? [] : self::describeAnchorFields($map, ['event' => $marker]); + } + + /** + * A world object's minimap row, as an event's: a building or a landmark once described. + * + * @return list> + */ + public static function describeObjectFields(ProjectMap $map, string $id): array + { + return self::describeAnchorFields($map, ['object' => $id]); + } + + /** + * @param array $anchor + * @return list> + */ + private static function describeAnchorFields(ProjectMap $map, array $anchor): array + { + $key = isset($anchor['event']) ? ['target' => self::EVENT_TARGET, 'marker' => $anchor['event']] + : ['target' => self::OBJECT_TARGET, 'path' => [$anchor['object']]]; + try { + $landmarks = MapLandmarks::readMap($map); + } catch (InvalidArgumentException $error) { + return [['label' => 'Minimap', 'value' => '! ' . $error->getMessage(), 'editable' => false]]; + } + $index = $landmarks->findAnchorIndex($anchor); + $route = $landmarks->isRouteAnchor($anchor); + $choices = [['label' => $route ? 'Unnamed route marker' : 'Not on the minimap', 'value' => self::UNDESCRIBED, 'description' => $route + ? 'Every transfer is marked at once; describe it to name it or keep it secret.' + : 'Only described places are marked.']]; + foreach ($landmarks->findKindsFor($anchor) as $kind) { + $choices[] = ['label' => self::describeKind($kind), 'value' => $kind->value, 'description' => self::explainKind($kind)]; + } + $entry = $index === null ? null : $landmarks->entries[$index]; + // A short enum: picked or stepped through, as an event's cue kind is. + $fields = [['label' => 'Minimap', 'value' => is_string($entry['kind'] ?? null) ? $entry['kind'] : self::UNDESCRIBED, + 'editable' => true, 'choices' => $choices, 'options' => array_column($choices, 'value'), ...$key]]; + if ($entry !== null) { + $fields[] = ['label' => ' Landmark', 'value' => sprintf('%s · %s', $entry['id'] ?? '?', self::describeName($entry)), 'editable' => false]; + } + return $fields; + } + + /** + * The declarations an edit to one of these rows leaves, or null when it changes nothing. + * + * @param array $field + * @return list>|null + * @throws InvalidArgumentException When the edit cannot be made as asked. + */ + public static function applyValue(ProjectMap $map, array $field, mixed $value): ?array + { + $landmarks = MapLandmarks::readMap($map); + $anchor = match ($field['target'] ?? null) { + self::EVENT_TARGET => ['event' => (string) $field['marker']], + self::OBJECT_TARGET => ['object' => (string) ($field['path'][0] ?? '')], + default => null, + }; + if ($anchor !== null) { + $index = $landmarks->findAnchorIndex($anchor); + if ($value === self::UNDESCRIBED) { return $index === null ? null : $landmarks->removeLandmark($index); } + $kind = self::requireKind($landmarks, $anchor, (string) $value); + return $index === null ? $landmarks->addLandmark($anchor, $kind)['entries'] : $landmarks->setKind($index, $kind); + } + $index = (int) ($field['index'] ?? -1); + $entry = $landmarks->entries[$index] ?? throw new InvalidArgumentException('That landmark is no longer here; read the inspector again.'); + $anchor = MapLandmarks::readAnchor($entry); + return match ((string) ($field['field'] ?? '')) { + 'anchor' => $landmarks->setAnchor($index, MapLandmarks::decodeAnchor((string) $value)), + 'kind' => $landmarks->setKind($index, self::requireKind($landmarks, $anchor + ?? throw new InvalidArgumentException('Choose this landmark\'s anchor first.'), (string) $value)), + 'name' => $landmarks->setName($index, (string) $value), + 'nameMessage' => $landmarks->setNameMessage($index, (string) $value), + 'secret' => $landmarks->setSecret($index, (bool) $value), + 'approach' => $value === true + ? (array_key_exists('approachDistance', $entry) && $entry['approachDistance'] === null ? $landmarks->resetApproachDistance($index) : null) + : $landmarks->setApproachDistance($index, null), + 'approachDistance' => $landmarks->setApproachDistance($index, self::requireDistance($value)), + 'revealConditions' => $landmarks->setRevealConditions($index, is_array($value) ? $value : []), + default => null, + }; + } + + /** + * Describes the next place no landmark describes yet, in map order: events, then world objects, then legacy + * transfers. Its anchor row then chooses any other. + * + * @return array{entries: list>, summary: string} + */ + public static function addEntry(ProjectMap $map): array + { + $landmarks = MapLandmarks::readMap($map); + $anchors = $landmarks->describeAnchors(); + $free = array_find($anchors, static fn(array $anchor): bool => $anchor['landmark'] === null) + ?? throw new InvalidArgumentException($anchors === [] + ? 'This map has no placed event, world object or legacy transfer to mark.' + : 'Every event, world object and legacy transfer here already anchors a landmark.'); + $added = $landmarks->addLandmark($free['anchor']); + return ['entries' => $added['entries'], 'summary' => sprintf('Added landmark %s on %s; choose its anchor and kind.', $added['id'], $free['label'])]; + } + + /** + * @param array $field + * @return array{entries: list>, summary: string} + */ + public static function removeEntry(ProjectMap $map, array $field): array + { + $landmarks = MapLandmarks::readMap($map); + $index = (int) ($field['landmarkList']['index'] ?? -1); + $entry = $landmarks->entries[$index] ?? throw new InvalidArgumentException('There is no landmark here to remove.'); + $anchor = MapLandmarks::readAnchor($entry); + $route = $anchor !== null && $landmarks->isRouteAnchor($anchor); + return ['entries' => $landmarks->removeLandmark($index), 'summary' => sprintf('Removed landmark %s%s.', $entry['id'] ?? $index + 1, + $route ? '; its route shows as an unnamed marker again' : '')]; + } + + /** + * Why the map's declarations would not load, by the Engine's own check, or null when they would. + * + * @param list> $entries + */ + public static function findProblem(ProjectMap $map, array $entries): ?string + { + try { + MapLandmarks::validateEntries($map, $entries); + return null; + } catch (InvalidArgumentException $error) { + return $error->getMessage(); + } + } + + /** + * @param list, value: string, label: string, transfer: bool, landmark: ?string}> $anchors + * @return list> + */ + private static function describeEntryFields(MapLandmarks $landmarks, array $anchors, int $index, array $entry): array + { + $row = static fn(array $field): array => [...$field, 'target' => self::TARGET, 'index' => $index, 'landmarkList' => ['index' => $index]]; + $id = is_string($entry['id'] ?? null) ? $entry['id'] : sprintf('(entry %d has no id)', $index + 1); + $anchor = MapLandmarks::readAnchor($entry); + $anchorValue = $anchor === null ? '' : MapLandmarks::encodeAnchor($anchor); + $anchorChoices = array_map(static fn(array $choice): array => ['label' => $choice['label'], 'value' => $choice['value'], + 'description' => $choice['transfer'] ? 'A route: a passage, entrance or exit.' : 'A building or a landmark.'], + array_values(array_filter($anchors, static fn(array $choice): bool => $choice['landmark'] === null || $choice['value'] === $anchorValue))); + $kinds = $anchor === null ? [] : $landmarks->findKindsFor($anchor); + $secret = ($entry['secret'] ?? false) === true; + $named = isset($entry['name']); + $conditions = is_array($entry['revealConditions'] ?? null) ? $entry['revealConditions'] : []; + $fields = [ + // The id is the identity saves remember discoveries by: shown, never edited. + $row(['label' => ' Landmark ' . $id, 'value' => $anchor === null ? '(no anchor)' : $landmarks->describeAnchor($anchor), 'editable' => false]), + $row(['label' => ' Anchor', 'value' => $anchorValue, 'editable' => true, 'choices' => $anchorChoices, 'field' => 'anchor']), + $row(['label' => ' Kind', 'value' => (string) ($entry['kind'] ?? ''), 'editable' => true, 'field' => 'kind', + 'choices' => array_map(static fn(MapLandmarkKind $kind): array => ['label' => self::describeKind($kind), + 'value' => $kind->value, 'description' => self::explainKind($kind)], $kinds), + 'options' => array_map(static fn(MapLandmarkKind $kind): string => $kind->value, $kinds)]), + $row(['label' => ' Name', 'value' => $named ? (string) ($entry['name']['default'] ?? '') : '(unnamed · the minimap shows no label)', + 'control' => new InputControl(InputControlType::TEXT, $named ? (string) ($entry['name']['default'] ?? '') : ''), 'field' => 'name']), + ]; + if ($named) { + $fields[] = $row(['label' => ' Name Translation Key', 'value' => (string) ($entry['name']['message'] ?? ''), + 'control' => new InputControl(InputControlType::TEXT, (string) ($entry['name']['message'] ?? '')), 'field' => 'nameMessage']); + } + $fields[] = $row(['label' => ' Secret', 'value' => $secret ? 'yes · hidden until explicitly revealed' : 'no', + 'control' => new InputControl(InputControlType::BOOLEAN, $secret ? 'true' : 'false'), 'field' => 'secret']); + if (!$secret) { + $distance = array_key_exists('approachDistance', $entry) ? $entry['approachDistance'] : MapLandmarkCatalog::DEFAULT_APPROACH_DISTANCE; + $fields[] = $row(['label' => ' Learned On Approach', 'value' => $distance === null ? 'no · only by its reveal conditions' : 'yes', + 'control' => new InputControl(InputControlType::BOOLEAN, $distance === null ? 'false' : 'true'), 'field' => 'approach']); + if ($distance !== null) { + $fields[] = $row(['label' => ' Approach Distance (cells)', 'value' => array_key_exists('approachDistance', $entry) + ? (string) $distance : sprintf('(engine default: %d)', MapLandmarkCatalog::DEFAULT_APPROACH_DISTANCE), + 'control' => new InputControl(InputControlType::INTEGER, (string) $distance), 'field' => 'approachDistance']); + } + } + $fields[] = $row(['label' => ' Revealed When', 'value' => $conditions === [] + ? ($secret ? '(no conditions · stays hidden)' : '(no conditions · learned on approach only)') + : ConditionCodec::encodeAll($conditions), + 'field' => 'revealConditions', 'mapConditions' => true, 'encoded' => ConditionCodec::encodeAll($conditions), + 'conditionsTitle' => sprintf('Landmark %s Reveal Conditions', $id), + 'conditionsHint' => 'All conditions must hold to reveal the landmark. An empty list removes them.']); + return $fields; + } + + private static function requireDistance(mixed $value): int + { + if (!is_int($value) || $value < MapLandmarkCatalog::MIN_APPROACH_DISTANCE || $value > MapLandmarkCatalog::MAX_APPROACH_DISTANCE) { + throw new InvalidArgumentException(sprintf('An approach distance is %d to %d cells.', + MapLandmarkCatalog::MIN_APPROACH_DISTANCE, MapLandmarkCatalog::MAX_APPROACH_DISTANCE)); + } + return $value; + } + + /** @param array $anchor */ + private static function requireKind(MapLandmarks $landmarks, array $anchor, string $value): MapLandmarkKind + { + $kind = MapLandmarkKind::tryFrom($value); + if ($kind === null || !in_array($kind, $landmarks->findKindsFor($anchor), true)) { + throw new InvalidArgumentException(sprintf('%s cannot be a %s; choose one of: %s.', $landmarks->describeAnchor($anchor), $value, + implode(', ', array_map(static fn(MapLandmarkKind $kind): string => $kind->value, $landmarks->findKindsFor($anchor))))); + } + return $kind; + } + + private static function describeName(array $entry): string + { + return isset($entry['name']['default']) ? (string) $entry['name']['default'] : 'unnamed'; + } + + private static function describeKind(MapLandmarkKind $kind): string + { + return ucfirst($kind->value); + } + + private static function explainKind(MapLandmarkKind $kind): string + { + return match ($kind) { + MapLandmarkKind::PASSAGE => 'A way through, marked at once.', + MapLandmarkKind::ENTRANCE => 'A way in, marked at once.', + MapLandmarkKind::EXIT => 'A way out, marked at once.', + MapLandmarkKind::BUILDING => 'A building, marked once learned.', + MapLandmarkKind::LANDMARK => 'A notable place, marked once learned.', + }; + } +} diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index a76018c..1f75f7b 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -68,6 +68,7 @@ use Ichiloto\Editor\Inspector\InspectorListEdit; use Ichiloto\Editor\Inspector\InspectorRefusal; use Ichiloto\Editor\Inspector\MapInspector; +use Ichiloto\Editor\Inspector\MapLandmarkFields; use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Playtest\PlaytestLauncher; use Ichiloto\Editor\Playtest\PlaytestLaunch; @@ -1961,7 +1962,7 @@ private function requireInspectorField(ProjectMap $map, array $key): array ksort($key); return array_find($this->collectInspectorFields($map, $marker), static function (array $candidate) use ($key): bool { - $candidateKey = self::describeKey($candidate); + $candidateKey = MapInspector::describeRowKey($candidate); if ($candidateKey !== null) { ksort($candidateKey); } @@ -3989,7 +3990,9 @@ public function readWorldObject(string $mapId, string $id): array 'worldObject' => ['id' => $id, 'variants' => array_column($object['variants'] ?? [], 'id'), 'anchor' => $object['anchor'], 'covers' => $object['covers'] ?? null, 'issue' => $issue], 'issue' => $issue, - 'rows' => array_map(static fn($field): array => self::describeRecordRow($records, $index, [], $field, 'world-object'), $fields)]; + 'rows' => [...array_map(static fn($field): array => self::describeRecordRow($records, $index, [], $field, 'world-object'), $fields), + // What the object is on the minimap: a map landmark row, applied through the map's inspector. + ...array_map(self::describeRow(...), MapLandmarkFields::describeObjectFields($map, $id))]]; } public function createWorldObject(string $mapId, int $revision, string $id, int $x, int $y, ?string $resource = null, ?string $text = null): array @@ -4004,6 +4007,9 @@ public function deleteWorldObject(string $mapId, int $revision, string $id): arr public function applyWorldObject(string $mapId, int $revision, string $id, array $key, string $value): array { + if (($key['target'] ?? null) === MapLandmarkFields::OBJECT_TARGET) { + return $this->applyWorldObjectLandmark($mapId, $revision, $id, $key, $value); + } $field = is_string($key['field'] ?? null) ? $key['field'] : throw new SessionRefusal('Name a current world-object field.'); return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->applyField($id, $field, $value)); } @@ -4072,6 +4078,28 @@ public function closeAuthoringCanvas(string $mapId): array return ['closed' => $host !== null]; } + /** + * Applies a world object's minimap row, found again among its current rows, as one undo step. + * + * @param array $key + * @return array{changed: bool, revision: int} + * @throws SessionRefusal When the map is stale, the row is gone or the edit is refused. + */ + private function applyWorldObjectLandmark(string $mapId, int $revision, string $id, array $key, string $value): array + { + $map = $this->requireCurrentMap($mapId, $revision); + ksort($key); + $field = array_find(MapLandmarkFields::describeObjectFields($map, $id), static function (array $candidate) use ($key): bool { + $candidateKey = MapInspector::describeRowKey($candidate); + if ($candidateKey !== null) { ksort($candidateKey); } + return $candidateKey === $key; + }) ?? throw new SessionRefusal('That row is no longer in the inspector; read it again.'); + $inspector = $this->createMapInspector($map); + $command = $this->runEdit(static fn(): ?Command => $inspector->apply($map, $field, $value)); + + return ['changed' => $command !== null, 'revision' => $this->getMapRevision($map)]; + } + private function createWorldObjectAuthoring(ProjectMap $map): WorldObjectAuthoring { return new WorldObjectAuthoring($map, new ReferenceCatalog($this->workspace, $map)); @@ -5039,12 +5067,12 @@ private static function describeRow(array $field): array }, default => 'info', }; - $list = $field['list'] ?? $field['encounterList'] ?? $field['bgmVariantList'] ?? null; + $list = $field['list'] ?? $field['encounterList'] ?? $field['bgmVariantList'] ?? $field['landmarkList'] ?? null; // The terminal nests a row by indenting its label; an interface is given the depth instead. $label = (string) ($field['label'] ?? ''); $depth = intdiv(strlen($label) - strlen(ltrim($label, ' ')), 2); - $key = self::describeKey($field); + $key = MapInspector::describeRowKey($field); return array_filter([ 'label' => trim($label), @@ -5111,30 +5139,6 @@ private static function describeAffinityEntries(string $line): array return $entries; } - /** - * What names a row for an edit: its target and the field, path, marker - * and index it edits. Null for a row no edit applies through. - * - * @param array $field - * @return array|null - */ - private static function describeKey(array $field): ?array - { - if (! is_string($field['target'] ?? null)) { - return null; - } - - return array_filter([ - 'target' => $field['target'], - 'field' => isset($field['field']) ? (string) $field['field'] : null, - 'path' => isset($field['path']) ? array_values(array_map('strval', (array) $field['path'])) : null, - 'marker' => isset($field['marker']) ? (string) $field['marker'] : null, - 'index' => isset($field['index']) ? (int) $field['index'] : null, - // A list heading has no path of its own; its list's names it. - 'list' => is_array($field['list']['path'] ?? null) ? array_values(array_map('strval', $field['list']['path'])) : null, - ], static fn(mixed $value): bool => $value !== null); - } - /** @throws SessionRefusal */ private function requireRecordDatabase(string $category): ProjectRecordDatabase { diff --git a/src/Validation/MapValidator.php b/src/Validation/MapValidator.php index 49bb4da..dd2e68b 100644 --- a/src/Validation/MapValidator.php +++ b/src/Validation/MapValidator.php @@ -4,7 +4,10 @@ namespace Ichiloto\Editor\Validation; +use Ichiloto\Editor\Field\MapLandmarks; +use Ichiloto\Editor\Inspector\MapLandmarkFields; use Ichiloto\Editor\ProjectMap; +use InvalidArgumentException; /** * Runs the advisory pre-save checks over a map. @@ -32,11 +35,28 @@ public static function validate(ProjectMap $map, array $mapsById): array ...self::findDanglingDestinations($map, $mapsById), ...self::findOutOfRangeSpawnPoints($map, $mapsById), ...self::findRetiredData($map), + ...self::findLandmarkProblems($map), ...array_map(static fn(Issue $issue): string => $issue->message, MapGraphicsValidator::validate($map)), ]; } + /** + * Finds minimap landmarks the engine would refuse, by its own check. + * + * @return string[] + */ + private static function findLandmarkProblems(ProjectMap $map): array + { + try { + $problem = MapLandmarkFields::findProblem($map, MapLandmarks::readMap($map)->entries); + } catch (InvalidArgumentException $error) { + $problem = $error->getMessage(); + } + + return $problem === null ? [] : ['Minimap landmarks: ' . $problem]; + } + /** * Finds map data the engine no longer reads. * diff --git a/tests/Pest.php b/tests/Pest.php index a9c7086..8d20682 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -384,6 +384,7 @@ function plainTextOfFrame(string $frame, int $width, int $height): string require_once __DIR__ . '/Support/PhysicalOccupancyFixtures.php'; require_once __DIR__ . '/Support/MapLineFixtures.php'; require_once __DIR__ . '/Support/TreeBrushFixtures.php'; +require_once __DIR__ . '/Support/MapLandmarkFixtures.php'; require_once __DIR__ . '/Support/BattleEntryFixtures.php'; diff --git a/tests/Support/MapLandmarkFixtures.php b/tests/Support/MapLandmarkFixtures.php new file mode 100644 index 0000000..c9bf7b2 --- /dev/null +++ b/tests/Support/MapLandmarkFixtures.php @@ -0,0 +1,56 @@ + [],", << [ + 'T' => ['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\TransferPlayerTrigger', + 'data' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 1, 'y' => 0]]], + 'E' => ['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\DialogueEventTrigger', 'data' => []], + ], + // Map-owned presentation stays authored. + 'worldObjects' => [['id' => 'old-tower', 'anchor' => ['x' => 1, 'y' => 1], 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null]], + {$landmarks} +PHP, (string) file_get_contents($file))); + + return [$root, $file, EditorSession::open($root)]; +} + +/** The map inspector row whose key has these members, read fresh. */ +function findLandmarkRow(EditorSession $session, array $key, ?string $marker = null): array +{ + $rows = $session->readInspector('test-map', $marker)['rows']; + return array_find($rows, static fn(array $row): bool => array_intersect_assoc($key, $row['key'] ?? []) === $key && array_diff_key($key, $row['key'] ?? []) === []) + ?? throw new RuntimeException('Missing inspector row ' . json_encode($key)); +} + +function applyLandmarkRow(EditorSession $session, array $key, string $value, ?string $marker = null): array +{ + $row = findLandmarkRow($session, $key, $marker); + return $session->applyInspector('test-map', $session->readMap('test-map')['revision'], $row['key'], $value); +} + +/** The map's declarations as the Editor holds them, unsaved edits included. */ +function readLandmarkEntries(EditorSession $session): array +{ + return Ichiloto\Editor\Field\MapLandmarks::readMap(getSessionMap($session))->entries; +} + +function getSessionMap(EditorSession $session): Ichiloto\Editor\ProjectMap +{ + return new ReflectionMethod(EditorSession::class, 'requireMap')->invoke($session, 'test-map'); +} diff --git a/tests/Unit/MapLandmarkTerminalTest.php b/tests/Unit/MapLandmarkTerminalTest.php new file mode 100644 index 0000000..dc3e20f --- /dev/null +++ b/tests/Unit/MapLandmarkTerminalTest.php @@ -0,0 +1,102 @@ + $row) { + if (($row['target'] ?? null) === 'map-landmarks' && ($row['field'] ?? null) === $field) { + setEditorProperty($editor, 'selectedInspectorFieldIndex', $index); + return; + } + } + throw new RuntimeException('No landmark row edits ' . $field); +} + +/** Opens the row's list and chooses the entry with this value. */ +function chooseLandmarkOption(Editor $editor, string $field, string $value): void +{ + selectLandmarkField($editor, $field); + callEditorMethod($editor, 'activateInspectorField'); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue(); + $entries = getEditorProperty($editor, 'eventOptionDialogEntries'); + $index = array_search($value, array_column($entries, 'value'), true); + expect($index)->toBeInt(); + setEditorProperty($editor, 'selectedEventOptionIndex', $index); + callEditorMethod($editor, 'applySelectedEventOption'); +} + +it('steps an event through what it is on the minimap and picks a landmark anchor from a list', function () { + $editor = landmarkTerminalEditor(); + expect(inspectorValueOf($editor, 'Unnamed Route Markers'))->toBe('1'); + + setEditorProperty($editor, 'editingMode', 'event'); + setEditorProperty($editor, 'cursorX', 0); + setEditorProperty($editor, 'cursorY', 0); + expect(array_column(selectInspectorField($editor, 'Minimap')['choices'], 'label'))->toBe(['Unnamed route marker', 'Passage', 'Entrance', 'Exit']); + // Enter moves a short enum on, as Left and Right do. + callEditorMethod($editor, 'activateInspectorField'); + expect(mapDataOf($editor)['landmarks'])->toBe([['id' => 'landmark-1', 'kind' => 'passage', 'anchor' => ['event' => 'T']]]); + callEditorMethod($editor, 'adjustInspectorOptionField', 1); + callEditorMethod($editor, 'adjustInspectorOptionField', 1); + expect(mapDataOf($editor)['landmarks'][0]['kind'])->toBe('exit') + ->and(inspectorValueOf($editor, 'Landmark'))->toBe('landmark-1 · unnamed'); + + setEditorProperty($editor, 'editingMode', 'map'); + selectLandmarkField($editor, 'anchor'); + callEditorMethod($editor, 'activateInspectorField'); + expect(array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label')) + ->toBe(['Event T · Transfer Player to test-map', 'Event E · Dialogue', 'World object old-tower']); + callEditorMethod($editor, 'closeEventOptionDialog'); + chooseLandmarkOption($editor, 'anchor', 'object:old-tower'); + expect(mapDataOf($editor)['landmarks'][0])->toBe(['id' => 'landmark-1', 'kind' => 'landmark', 'anchor' => ['object' => 'old-tower']]); + callEditorMethod($editor, 'performUndo'); + expect(mapDataOf($editor)['landmarks'][0]['anchor'])->toBe(['event' => 'T']); +}); + +it('builds reveal conditions with the shared editor and reports a refusal without claiming success', function () { + $editor = landmarkTerminalEditor(); + setEditorProperty($editor, 'editingMode', 'event'); + setEditorProperty($editor, 'cursorX', 3); + setEditorProperty($editor, 'cursorY', 0); + selectInspectorField($editor, 'Minimap'); + callEditorMethod($editor, 'activateInspectorField'); + expect(mapDataOf($editor)['landmarks'][0]['kind'])->toBe('building'); + setEditorProperty($editor, 'editingMode', 'map'); + + selectLandmarkField($editor, 'revealConditions'); + callEditorMethod($editor, 'activateInspectorField'); + $conditionEditor = getEditorProperty($editor, 'conditionEditor'); + expect($conditionEditor->isOpen())->toBeTrue() + ->and($conditionEditor->label())->toBe('Landmark landmark-1 Reveal Conditions'); + callEditorMethod($editor, 'dispatchInput', 'a'); + callEditorMethod($editor, 'dispatchInput', 't'); + $conditionEditor->setName('heard_of_tower'); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(mapDataOf($editor)['landmarks'][0]['revealConditions'])->toBe([['type' => 'event', 'name' => 'heard_of_tower']]) + ->and(getEditorProperty($editor, 'mapConditionField'))->toBeNull(); + + $toasts = getEditorProperty($editor, 'toasts'); + $toasts->clear(); + selectLandmarkField($editor, 'approachDistance'); + setEditorProperty($editor, 'isInspectorEditing', true); + setEditorProperty($editor, 'inspectorEditBuffer', '99'); + callEditorMethod($editor, 'commitInspectorEdit'); + expect($toasts->current()?->message)->toBe('An approach distance is 0 to 64 cells.') + ->and($toasts->pendingCount())->toBe(0) + ->and(mapDataOf($editor)['landmarks'][0])->not->toHaveKey('approachDistance'); +}); diff --git a/tests/Unit/Session/MapLandmarkAuthoringTest.php b/tests/Unit/Session/MapLandmarkAuthoringTest.php new file mode 100644 index 0000000..df258d7 --- /dev/null +++ b/tests/Unit/Session/MapLandmarkAuthoringTest.php @@ -0,0 +1,172 @@ + 'event-landmark', 'marker' => 'T'], 'T'); + expect($row['kind'])->toBe('options') + ->and($row['options'])->toBe(['undescribed', 'passage', 'entrance', 'exit']) + ->and($row['value'])->toBe('undescribed'); + // A dialogue is no route: it can only be a building or a landmark. + expect(findLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'E'], 'E')['options'])->toBe(['undescribed', 'building', 'landmark']); + + applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'T'], 'exit', 'T'); + expect(readLandmarkEntries($session))->toBe([['id' => 'landmark-1', 'kind' => 'exit', 'anchor' => ['event' => 'T']]]) + ->and(file_get_contents($file))->toBe($before); + $session->undo(); + expect(readLandmarkEntries($session))->toBe([]); + $session->redo(); + + applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'name', 'index' => 0], 'Harbour Gate'); + $session->saveMap('test-map'); + $saved = file_get_contents($file); + expect($saved)->toContain('// Map-owned presentation stays authored.', "'landmarks' =>", "'Harbour Gate'"); + $reopened = Ichiloto\Editor\Field\MapLandmarks::readMap(getSessionMap(EditorSession::open($root)))->entries; + expect($reopened[0]['name'])->toBe(['message' => MapLandmarkCatalog::getNameMessageKey('test-map', 'landmark-1'), 'default' => 'Harbour Gate']); + + // Choosing the first option again removes the description; the route is an unnamed marker once more. + applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'T'], 'undescribed', 'T'); + expect(readLandmarkEntries($session))->toBe([]); + expect(getSessionMap($session)->hasMapDataField(['landmarks']))->toBeFalse(); +}); + +it('offers only unclaimed anchors and the kinds an anchor may be, keeping the id a save remembers', function () { + [, , $session] = createLandmarkProject(); + $heading = findLandmarkRow($session, ['target' => 'map-landmarks']); + $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], $heading['key']); + $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], $heading['key']); + // Events first, in map order, each starting as what its marker already is. + expect(readLandmarkEntries($session))->toBe([ + ['id' => 'landmark-1', 'kind' => 'passage', 'anchor' => ['event' => 'T']], + ['id' => 'landmark-2', 'kind' => 'landmark', 'anchor' => ['event' => 'E']], + ]); + $anchor = findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'anchor', 'index' => 1]); + expect($anchor['options'])->toBe(['event:E', 'object:old-tower']) + ->and($anchor['optionLabels'])->toBe(['Event E · Dialogue', 'World object old-tower']); + expect(findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 0])['options'])->toBe(['passage', 'entrance', 'exit']); + + applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 1], 'building'); + applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'anchor', 'index' => 1], 'object:old-tower'); + expect(readLandmarkEntries($session)[1])->toBe(['id' => 'landmark-2', 'kind' => 'building', 'anchor' => ['object' => 'old-tower']]); + + // A transfer is a route, so moving a building onto one makes it the passage that marker already was. + applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'anchor', 'index' => 0], 'event:E'); + applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'anchor', 'index' => 1], 'event:T'); + expect(readLandmarkEntries($session))->toBe([ + ['id' => 'landmark-1', 'kind' => 'landmark', 'anchor' => ['event' => 'E']], + ['id' => 'landmark-2', 'kind' => 'passage', 'anchor' => ['event' => 'T']], + ]); + expect(fn() => applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 1], 'building')) + ->toThrow(SessionRefusal::class); + expect(findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'anchor', 'index' => 0])['options'])->not->toContain('event:T'); + + // An object anchor's own id becomes the landmark id when it is a valid one. + $remove = findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 1]); + $session->removeInspectorListEntry('test-map', $session->readMap('test-map')['revision'], $remove['key']); + $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], findLandmarkRow($session, ['target' => 'map-landmarks'])['key']); + $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], findLandmarkRow($session, ['target' => 'map-landmarks'])['key']); + expect(array_column(readLandmarkEntries($session), 'id'))->toBe(['landmark-1', 'landmark-2', 'old-tower']); + expect(fn() => $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], + findLandmarkRow($session, ['target' => 'map-landmarks'])['key']))->toThrow(SessionRefusal::class, 'already anchors a landmark'); + expect(findLandmarkRow($session, ['target' => 'map-landmarks', 'index' => 0])['kind'])->toBe('info'); +}); + +it('keeps discovery separate from names: secrets say nothing of approach, empty conditions are removed', function () { + [, , $session] = createLandmarkProject(); + applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'E'], 'building', 'E'); + $key = static fn(string $field): array => ['target' => 'map-landmarks', 'field' => $field, 'index' => 0]; + + applyLandmarkRow($session, $key('approachDistance'), '5'); + expect(readLandmarkEntries($session)[0]['approachDistance'])->toBe(5); + expect(fn() => applyLandmarkRow($session, $key('approachDistance'), '65'))->toThrow(SessionRefusal::class, '0 to 64 cells'); + applyLandmarkRow($session, $key('approach'), 'false'); + expect(readLandmarkEntries($session)[0])->toHaveKey('approachDistance', null); + applyLandmarkRow($session, $key('approach'), 'true'); + expect(readLandmarkEntries($session)[0])->not->toHaveKey('approachDistance'); + expect(findLandmarkRow($session, $key('approachDistance'))['value'])->toBe('(engine default: 3)'); + + applyLandmarkRow($session, $key('approachDistance'), '2'); + applyLandmarkRow($session, $key('secret'), 'true'); + expect(readLandmarkEntries($session)[0])->toMatchArray(['secret' => true])->not->toHaveKey('approachDistance'); + $rows = $session->readInspector('test-map')['rows']; + expect(array_filter($rows, static fn(array $row): bool => in_array($row['key']['field'] ?? null, ['approach', 'approachDistance'], true)))->toBe([]); + + applyLandmarkRow($session, $key('revealConditions'), 'event:heard_of_tower'); + expect(readLandmarkEntries($session)[0]['revealConditions'])->toBe([['type' => 'event', 'name' => 'heard_of_tower']]); + applyLandmarkRow($session, $key('revealConditions'), ''); + expect(readLandmarkEntries($session)[0])->not->toHaveKey('revealConditions'); + + applyLandmarkRow($session, $key('name'), 'Old Tower'); + applyLandmarkRow($session, $key('nameMessage'), 'maps.harbour.tower'); + applyLandmarkRow($session, $key('name'), 'The Old Tower'); + expect(readLandmarkEntries($session)[0]['name'])->toBe(['message' => 'maps.harbour.tower', 'default' => 'The Old Tower']); + expect(fn() => applyLandmarkRow($session, $key('nameMessage'), ' '))->toThrow(SessionRefusal::class, 'needs a translation key'); + applyLandmarkRow($session, $key('name'), ''); + expect(readLandmarkEntries($session)[0])->not->toHaveKey('name'); +}); + +it('refuses to delete or retype an anchor a landmark describes, until the landmark goes', function () { + [, , $session] = createLandmarkProject(); + applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'T'], 'exit', 'T'); + $revision = $session->readMap('test-map')['revision']; + expect(fn() => $session->deleteEvent('test-map', $revision, 'T'))->toThrow(SessionRefusal::class, 'landmark landmark-1'); + $type = findLandmarkRow($session, ['target' => 'event-type', 'marker' => 'T'], 'T'); + expect(fn() => $session->applyInspector('test-map', $revision, $type['key'], 'Dialogue'))->toThrow(SessionRefusal::class, 'exit landmark landmark-1'); + + $heading = findLandmarkRow($session, ['target' => 'map-landmarks']); + $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], $heading['key']); + applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'anchor', 'index' => 1], 'object:old-tower'); + expect(fn() => $session->deleteWorldObject('test-map', $session->readMap('test-map')['revision'], 'old-tower')) + ->toThrow(SessionRefusal::class, 'test-map landmark landmark-2'); + + foreach ([1, 0] as $index) { + $session->removeInspectorListEntry('test-map', $session->readMap('test-map')['revision'], + findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => $index])['key']); + } + $session->deleteWorldObject('test-map', $session->readMap('test-map')['revision'], 'old-tower'); + $session->deleteEvent('test-map', $session->readMap('test-map')['revision'], 'T'); + expect(getSessionMap($session)->getEventDefinition('T'))->toBeNull(); +}); + +it('follows an anchor that moves without copying its cells, on ragged maps too', function () { + [, , $session] = createLandmarkProject(ragged: true); + applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'E'], 'landmark', 'E'); + $session->moveEvent('test-map', $session->readMap('test-map')['revision'], 'E', -1, 0); + $entries = readLandmarkEntries($session); + expect($entries)->toBe([['id' => 'landmark-1', 'kind' => 'landmark', 'anchor' => ['event' => 'E']]]); + $catalog = Ichiloto\Editor\Field\MapLandmarks::validateEntries(getSessionMap($session), $entries); + expect($catalog->landmarks[0]->area->cells)->toBe([[2, 0]]); +}); + +it('reports declarations the Engine refuses, and still lets an author repair them', function () { + [, , $session] = createLandmarkProject("'landmarks' => [['id' => 'gate', 'kind' => 'building', 'anchor' => ['event' => 'T']]],"); + $map = getSessionMap($session); + expect(MapValidator::validate($map, ['test-map' => $map]))->toContain('Minimap landmarks: A transfer landmark must use passage, entrance or exit so ordinary routes remain visible.'); + expect(findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 0])['options'])->toBe(['passage', 'entrance', 'exit']); + applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 0], 'entrance'); + expect(MapValidator::validate($map, ['test-map' => $map]))->not->toContain('Minimap landmarks: A transfer landmark must use passage, entrance or exit so ordinary routes remain visible.'); +}); + +it('describes a selected world object from its own inspector, through the same rows', function () { + [, , $session] = createLandmarkProject(); + $view = $session->readWorldObject('test-map', 'old-tower'); + $row = array_find($view['rows'], static fn(array $row): bool => ($row['key']['target'] ?? null) === 'object-landmark'); + expect($row['options'])->toBe(['undescribed', 'building', 'landmark']) + ->and($row['optionLabels'][0])->toBe('Not on the minimap'); + + $session->applyWorldObject('test-map', $view['revision'], 'old-tower', $row['key'], 'building'); + expect(readLandmarkEntries($session))->toBe([['id' => 'old-tower', 'kind' => 'building', 'anchor' => ['object' => 'old-tower']]]); + $view = $session->readWorldObject('test-map', 'old-tower'); + expect(array_find($view['rows'], static fn(array $row): bool => $row['label'] === 'Landmark')['value'])->toBe('old-tower · unnamed'); + expect(fn() => $session->applyWorldObject('test-map', $view['revision'], 'old-tower', $row['key'], 'exit')) + ->toThrow(SessionRefusal::class); + $session->undo(); + expect(readLandmarkEntries($session))->toBe([]); +}); diff --git a/tests/__snapshots__/main_shell_100x30.ansi b/tests/__snapshots__/main_shell_100x30.ansi index 947cc65..a2eca12 100644 --- a/tests/__snapshots__/main_shell_100x30.ansi +++ b/tests/__snapshots__/main_shell_100x30.ansi @@ -1 +1 @@ -[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint L:Layer ?:Help─────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Kind: Not set ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ > Terrain · visible ││ Events · visible ││ ││ │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file +[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint L:Layer ?:Help─────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Kind: Not set ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ Minimap Landmarks ││ Unnamed Route Markers · 0 ││ Landmarks · 0 ││ > Terrain · visible │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file From f5609fc4cbc96ccd01363330c006811dbc4f36fd Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 17 Oct 2026 12:50:02 +0200 Subject: [PATCH 290/344] docs(troops): record the Troops page and its Engine waits in the roadmap Item 6 of Database completion: what both editors now do through TroopAuthoring, and what waits on the Engine contracts requested on 2026-10-17 (enemy arrangement, battle event pages, arenas as data). Battle events do not run in the Engine yet. --- docs/roadmap.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index 90187b9..17c33cc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -301,6 +301,18 @@ Order of work, each a complete slice before the next: (they read an unused enum under `assets/Data/Types` before). 5. The manual's category table has a GUI column (done) and states each category's real coverage. +6. Troops as RPG Maker's Troops tab (in progress, 2026-10-17). Done in both + editors through `TroopAuthoring`: add a member by choosing its enemy, + remove, clear, auto-name, `Battle Music`, keys the editor does not show + (`id`, `events`) kept, a new troop or member never assuming a game's + enemy, and validation of a member with no battle placement. The GUI page + (canvas, member and enemy lists, member-driven inspector) works without a + graphical battle too. Waiting on Engine contracts requested on + 2026-10-17: the enemy arrangement rule (Align, and the starting terminal + position and battle placement of a new member, today the blank + `[15, 7]` and none), battle event pages (conditions, span, commands + through the shared command editor) and arenas as authorable data. Battle + events do not run in the Engine yet. Acceptance criteria: From 59d5b98872734e02b95b9f82a91fdf73832b8c72 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 17 Oct 2026 12:51:22 +0200 Subject: [PATCH 291/344] docs(roadmap): keep the explored Scenario Director blueprint authoring plan Andrew's idea, explored on 15 October 2026 and not implemented. Andrew, 2026-10-17: "Scenario Director was my idea, commit it. We were exploring an Idea and it is safe to keep a historical record of it." --- docs/roadmap.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index 17c33cc..75cf76d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -117,6 +117,42 @@ preview; helpful empty states; live resize handling. ## The plan +### Scenario blueprint authoring + +Planned 15 October 2026; not implemented. The coordinating +[Scenario Director village plan](../../engine/docs/roadmap.md#scenario-director-village-implementation-plan) +owns milestone order and gates. This section owns **SD3**, using the free Engine +contracts from SD0–SD2; GUI panels are tracked in the GUI plan. + +- [ ] Project the proposed single scenario catalog through existing record + schemas/source-preserving PHP services: world/conflict, factions, origins, + situations, placement references and policies. Preserve unrelated source and + refuse unsupported edits before mutation. +- [ ] Add shared list/read/create/revise/validate/preview operations to + EditorSession and SessionHost, with expected revisions, structured diagnostics + and explicit capability description. Resource references use real selectors. +- [ ] Qualify linked edits and grouped undo in the same backing catalog with + SharedFileTransaction/history. That mechanism does not provide arbitrary + multi-file atomic saves; a map-plus-catalog transaction needs shared + qualification before it can be offered. +- [ ] Expose gameplay records, conditions, consequences and textual region + selection through the TUI. No graphical art or GUI licence is a prerequisite. +- [ ] Provide an isolated campaign-preview service over the real Engine runtime. + Start with synthetic proposals; connect the Director later without changing + document ownership. Preview does not write installation assets or canon. +- [ ] Verify round trips, stale revisions, refusal, undo/redo, external changes, + cross-frontend field preservation and cancelled preview cleanup with synthetic + projects. Author both village origins without manual PHP. + +Basic contracts/editing/validation remain free. Maintained Director generation, +coherence and generation-testing workflows belong to the optional addon; native +visual authoring belongs to the GUI frontend. No second AI parser, writer or +document session may bypass these services. + +**SD3 gate:** author the origins and custody/negotiation situation through the +TUI and host, undo/redo linked changes, save/reopen safely and execute a preview. +Detailed effort and dependencies remain in the coordinating plan. + ### Renderer-neutral presentation authoring (planned) Runtime presentation support is not complete Editor authoring support. The TUI From a6d760ece908c6678f817b23d6b3ba0aa3d6ec52 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 17 Oct 2026 13:07:22 +0200 Subject: [PATCH 292/344] feat(database): author the minimap atlas theme in a Menu Theme category The graphical Database gains Menu Theme, the settings of the game's shared menu presentation catalog (assets/Data/Presentation/menus.php), starting with the minimap atlas the Engine reads (corner sizes, margin, marker size, frame). - ProjectSettingsFile is the shared source-preserving owner extracted from ProjectConfig: shared records, changed-leaf writes over the original bytes, staged and proven saves. ProjectConfig keeps config.php's defaults, message terms and inn presentation on top of it; its getTermRecords is replaced by the shared getSettingRecords. - MenuPresentationSettings owns menus.php. Atlas defaults come from the Engine's MapAtlasTheme, and each value is checked by MapAtlasTheme::fromArray beside its neighbours; the frame is chosen from MapAtlasTheme::FRAME_ROLES. - ProjectRecordDatabase's settings storage takes either owner; the workspace shares one owner per file and saves it with the others. - The category is graphical only: the Terminal's opened map keeps its layout. - The roadmap's presentation-authoring gap now names what Menu Theme still lacks (palette, frames, icon roles and the rest of the catalog). --- docs/manual.md | 13 + docs/roadmap.md | 11 +- src/Database/DatabaseCatalog.php | 4 +- src/Database/MenuThemeFields.php | 63 +++++ src/Database/ProjectRecordDatabase.php | 50 ++-- src/Database/RecordSchemaCatalog.php | 2 +- src/MenuPresentationSettings.php | 57 ++++ src/ProjectConfig.php | 255 ++---------------- src/ProjectSettingsFile.php | 284 ++++++++++++++++++++ src/ProjectWorkspace.php | 16 +- tests/Unit/Session/MenuThemeSessionTest.php | 94 +++++++ 11 files changed, 586 insertions(+), 263 deletions(-) create mode 100644 src/Database/MenuThemeFields.php create mode 100644 src/MenuPresentationSettings.php create mode 100644 src/ProjectSettingsFile.php create mode 100644 tests/Unit/Session/MenuThemeSessionTest.php diff --git a/docs/manual.md b/docs/manual.md index 52f3182..ce708cf 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1191,6 +1191,7 @@ skill's effects, a quest's rewards) listed under them. | Player Field Appearance | `assets/Data/Entities/player.php` | Not shown: graphical ownership and art | Choose fixed-player, selected party leader, or absent legacy selector; fixed sheet picker with index/layer controls | | Field Resources | `assets/Data/Presentation/field.php` (`resources`) | Not shown: graphical resources | One record per named resource, keyed by its stable id: name, ground contact, an image or a sign board, and an optional occupancy stamp | | Text Roles | `assets/Data/Presentation/text.php` (`roles`) | Not shown: graphical text | One record per text role: the project's fonts, first choice first, a size and a colour | +| Menu Theme | `assets/Data/Presentation/menus.php` (`atlas`) | Not shown: graphical presentation | One row per setting: the minimap atlas's corner sizes, margin, marker size and frame | | Actor Battle Art, Enemy Battle Art, Battle Scale | `assets/Data/Presentation/battlers.php` | Not shown: graphical battle art | Editable from the actor's or enemy's own page (see Battle Art) | Player Field Appearance edits only `graphicalSubject` and `sprites2d`, preserving @@ -1203,6 +1204,18 @@ and save/reopen restore its settings. Unsupported expressions and external source conflicts are refused before overwriting files. These controls have automated coverage; native visual inspection remains pending. +Menu Theme edits the game's shared menu presentation catalog by setting, as +Configuration edits `config.php`. It offers the minimap atlas: the compact and +larger corner sizes, their margin, the marker size and the frame (`quiet` or +`panel`) the atlas is drawn in, all in logical pixels of the GPUI atlas. Each +setting the catalog leaves out shows the Engine's default, and choosing that +default writes nothing. A value is checked by the Engine with its neighbours, +so the larger corner must first be widened before the compact one may exceed +it. Only changed leaves are written; comments and settings other pages own are +kept. A project without `menus.php` shows the category read-only and says why. +The Terminal's opened map keeps its own layout, so the Terminal editor does not +list this category. + Field Resources are reusable whole images, such as a tree, that a map places by reference. A resource owns its art and its ground contact (the pivot, such as a trunk base) and may carry an occupancy stamp: cells relative to a placement's diff --git a/docs/roadmap.md b/docs/roadmap.md index 75cf76d..fb74425 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -158,8 +158,15 @@ Detailed effort and dependencies remain in the coordinating plan. Runtime presentation support is not complete Editor authoring support. The TUI must expose the same editable data and operations as a future graphical Editor; graphical previews are an enhancement, not a prerequisite for authoring a game. -The current database/reference catalogues do not yet expose the shared menu -theme or PNG portrait selection. This remains an implementation gap. +The graphical Database's Menu Theme category edits the shared menu theme +(`assets/Data/Presentation/menus.php`) through its source-preserving owner +`MenuPresentationSettings`, which shares `ProjectSettingsFile` with +`config.php`. It offers only the minimap atlas so far (corner sizes, margin, +marker size, frame), with the Engine's defaults and validation. The palette, +metrics, row metrics and artwork, frames, icon roles (including the atlas's +`map.*` markers), cursor, portraits and notifications, and PNG portrait +selection, remain an implementation gap; each joins Menu Theme through that +owner as the Engine exposes its rules. Acceptance criteria for the presentation-authoring slice: diff --git a/src/Database/DatabaseCatalog.php b/src/Database/DatabaseCatalog.php index 153ef04..0c5fa80 100644 --- a/src/Database/DatabaseCatalog.php +++ b/src/Database/DatabaseCatalog.php @@ -57,7 +57,9 @@ public static function getGraphicalCategories(): array new DatabaseCategoryDefinition(\Ichiloto\Editor\Field\FieldResourceFields::CATEGORY, 'Field Resources', 'Reusable whole images, such as trees, that maps place by reference.', true), new DatabaseCategoryDefinition(\Ichiloto\Editor\Field\TextRoleFields::CATEGORY, - 'Text Roles', 'How text such as a sign\'s label is set: the project\'s fonts, size and colour.', true)]; + 'Text Roles', 'How text such as a sign\'s label is set: the project\'s fonts, size and colour.', true), + new DatabaseCategoryDefinition(MenuThemeFields::CATEGORY, + 'Menu Theme', 'The shared menu theme: the minimap atlas\'s corner sizes, margin, markers and frame.', true)]; } /** diff --git a/src/Database/MenuThemeFields.php b/src/Database/MenuThemeFields.php new file mode 100644 index 0000000..9b56dbc --- /dev/null +++ b/src/Database/MenuThemeFields.php @@ -0,0 +1,63 @@ + + */ + public static function getFields(array $row): array + { + $default = array_key_exists('default', $row) ? ProjectRecord::stringify($row['default']) : null; + $kind = array_key_exists('default', $row) ? $row['default'] : ($row['value'] ?? null); + + return [ + new RecordField('path', 'Setting', isReadOnly: true), + match (true) { + ($row['path'] ?? null) === MenuPresentationSettings::ATLAS . '.frameRole' + => new RecordField('value', 'Value', options: MapAtlasTheme::FRAME_ROLES, displayDefault: $default), + is_int($kind) => new RecordField('value', 'Value', InputControlType::INTEGER, displayDefault: $default), + default => new RecordField('value', 'Value', displayDefault: $default), + }, + ]; + } +} diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 654036d..f94e78b 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -13,7 +13,9 @@ use Ichiloto\Editor\History\TracksPersistedState; use Ichiloto\Editor\ProjectDirectoryContext; +use Ichiloto\Editor\MenuPresentationSettings; use Ichiloto\Editor\ProjectConfig; +use Ichiloto\Editor\ProjectSettingsFile; use Ichiloto\Editor\Database\Projections\WholeFileProjection; use Ichiloto\Editor\Inspector\InputControl; use Ichiloto\Editor\Inspector\InputControlType; @@ -75,11 +77,11 @@ private function assertProposedRecord(ProjectRecord $record, ProjectRecord $draf if ($record->toArray() === $draft->toArray()) { return; } if ($this->schema->storage === RecordStorage::CONFIG_SUBTREE) { $path = strval($draft->get('path')); - $this->config?->assertAcceptableValue($path, $draft->get('value'), complete: false); + $this->settings?->assertAcceptableValue($path, $draft->get('value'), complete: false); if ($path === ProjectConfig::INN_PRESENTATION) { InnPresentationFields::assertValid($draft->get('value'), $this->authoringReferences, complete: false); } - $this->config?->assertSourceAcceptsValue($path, $draft->get('value')); + $this->settings?->assertSourceAcceptsValue($path, $draft->get('value')); } elseif (is_array($draft->toArray())) { InnPresentationFields::assertChangedCommandsValid((array) $record->toArray(), $draft->toArray(), $this->authoringReferences, complete: false); if ($this->schema->storage === RecordStorage::DIRECTORY) { @@ -115,7 +117,7 @@ private function __construct( private array $stagedDeletions = [], private ?\Closure $writeBack = null, private ?string $projectRoot = null, - private ?ProjectConfig $config = null, + private ?ProjectSettingsFile $settings = null, private ?\Closure $identityReferences = null, ) { $this->learnKeyOrder(); @@ -339,7 +341,7 @@ public static function overOwnedList(RecordSchema $schema, string $ownerPath, ar * @param RecordSchema $schema The category schema. * @return self */ - public static function fromProject(string $projectRoot, RecordSchema $schema, ?ProjectConfig $config = null, bool $graphical = false): self + public static function fromProject(string $projectRoot, RecordSchema $schema, ?ProjectSettingsFile $settings = null, bool $graphical = false): self { // The categories evaluate authored files that construct engine // objects, and creating an entry constructs them too. The runtime @@ -352,7 +354,7 @@ public static function fromProject(string $projectRoot, RecordSchema $schema, ?P $database = match ($schema->storage) { RecordStorage::LIST_FILE => self::loadListFile($path, $schema, $projectRoot), RecordStorage::DIRECTORY => self::loadDirectory($path, $schema, $projectRoot), - RecordStorage::CONFIG_SUBTREE => self::loadConfigSubtree($path, $schema, $projectRoot, $config), + RecordStorage::CONFIG_SUBTREE => self::loadConfigSubtree($path, $schema, $projectRoot, $settings), RecordStorage::MAP_OWNED => throw new RuntimeException('Map-owned records are built over their owner, not loaded from a path.'), }; $database->graphical = $graphical; @@ -413,7 +415,7 @@ public function isEditable(): bool public function isDirty(): bool { - if ($this->config !== null) { + if ($this->settings !== null) { foreach ($this->records as $record) { if ($record->isDirty()) { return true; } } @@ -534,7 +536,7 @@ public function getSettingsFields(int $index): array } $isEditable = $this->isEditable() && $record->isEditable(); - $configIssue = $this->config?->getFieldIssue($record->getDisplayValue('path')); + $configIssue = $this->settings?->getFieldIssue($record->getDisplayValue('path')); $isEditable = $isEditable && $configIssue === null; $fields = []; @@ -751,7 +753,7 @@ public function setField(int $index, string $fieldId, string $rawValue): void if (! $record instanceof ProjectRecord || ! $this->isEditable() || ! $record->isEditable()) { return; } - if ($this->config !== null && ($issue = $this->config->getFieldIssue($record->getDisplayValue('path'))) !== null) { + if ($this->settings !== null && ($issue = $this->settings->getFieldIssue($record->getDisplayValue('path'))) !== null) { throw new \Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal($issue); } @@ -808,7 +810,7 @@ public function setField(int $index, string $fieldId, string $rawValue): void if ($this->schema->storage === RecordStorage::CONFIG_SUBTREE) { // A setting the file leaves out already reads as its default: // choosing that default writes nothing. - if ($record->get('value') === null && ! $this->config?->holds(strval($record->get('path'))) && $value == $record->get('default')) { + if ($record->get('value') === null && ! $this->settings?->holds(strval($record->get('path'))) && $value == $record->get('default')) { return; } } @@ -1631,8 +1633,8 @@ public function save(): void if (! $this->isEditable()) { throw new RuntimeException(sprintf('%s is read-only: %s.', $this->schema->entryNoun, $this->readOnlyReason)); } - if ($this->config !== null) { - $this->config->save(); + if ($this->settings !== null) { + $this->settings->save(); return; } @@ -1777,17 +1779,29 @@ private static function loadDirectory(string $path, RecordSchema $schema, ?strin } /** - * Loads a category stored as a flattened config.php subtree. + * Loads a category stored as flattened settings of a project settings file: config.php, or the menu + * presentation catalog. The file's one owner is shared by every category over it. * - * @param string $path The config.php path. + * @param string $path The settings file path. * @param RecordSchema $schema The category schema. * @return self */ - private static function loadConfigSubtree(string $path, RecordSchema $schema, ?string $projectRoot = null, ?ProjectConfig $config = null): self + private static function loadConfigSubtree(string $path, RecordSchema $schema, ?string $projectRoot = null, ?ProjectSettingsFile $settings = null): self { - $config ??= new ProjectConfig($projectRoot ?? dirname($path)); - return new self($schema, $path, $config->getTermRecords($schema->configPath), - readOnlyReason: $config->getReadOnlyReason(), config: $config); + $projectRoot ??= dirname($path); + $settings ??= self::createSettingsFile($projectRoot, $schema->relativePath); + return new self($schema, $path, $settings->getSettingRecords($schema->configPath), + readOnlyReason: $settings->getReadOnlyReason(), settings: $settings); + } + + /** The owner of a settings file, by where it lives in a project. */ + public static function createSettingsFile(string $projectRoot, string $relativePath): ProjectSettingsFile + { + return match ($relativePath) { + 'config.php' => new ProjectConfig($projectRoot), + MenuPresentationSettings::RELATIVE_PATH => new MenuPresentationSettings($projectRoot), + default => throw new RuntimeException(sprintf('%s has no settings owner.', $relativePath)), + }; } /** @@ -2795,7 +2809,7 @@ public function assertSourceAccepts(int $index): void match ($this->schema->storage) { RecordStorage::LIST_FILE => SharedFileTransaction::preview([$this]), RecordStorage::DIRECTORY => $record->file?->composeContents($this->composeRecordFilePayload($record)), - RecordStorage::CONFIG_SUBTREE => $this->config?->getSource(), + RecordStorage::CONFIG_SUBTREE => $this->settings?->getSource(), default => null, }; } diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 0303f7f..b7e8228 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -136,7 +136,7 @@ public static function all(bool $graphical = false): array self::battlerArt('enemies'), self::battleScaleReference(), ...($graphical ? [PlayerPresentationFields::getSchema(), \Ichiloto\Editor\Field\FieldResourceFields::getSchema(), - \Ichiloto\Editor\Field\TextRoleFields::getSchema()] : []), + \Ichiloto\Editor\Field\TextRoleFields::getSchema(), MenuThemeFields::getSchema()] : []), ]; $keyed = []; diff --git a/src/MenuPresentationSettings.php b/src/MenuPresentationSettings.php new file mode 100644 index 0000000..7bee73e --- /dev/null +++ b/src/MenuPresentationSettings.php @@ -0,0 +1,57 @@ + $default) { + $settings[self::ATLAS . '.' . $key] = ['default' => $default]; + } + + return $settings; + } + + /** + * Refuses an atlas value the Engine would not accept beside the atlas settings as they stand: its theme type + * checks each value and how they relate (the larger corner at least as large as the compact one). + * + * @throws \InvalidArgumentException + */ + public function assertAcceptableValue(string $path, mixed $value, bool $complete = true): void + { + if (! str_starts_with($path, self::ATLAS . '.')) { return; } + $atlas = []; + foreach (array_keys(get_object_vars(new MapAtlasTheme())) as $key) { + $current = $this->getCurrentValue(self::ATLAS . '.' . $key); + if ($current !== null) { $atlas[$key] = $current; } + } + $key = substr($path, strlen(self::ATLAS) + 1); + if ($value === null) { unset($atlas[$key]); } else { $atlas[$key] = $value; } + MapAtlasTheme::fromArray($atlas); + } +} diff --git a/src/ProjectConfig.php b/src/ProjectConfig.php index 4935143..39168e8 100644 --- a/src/ProjectConfig.php +++ b/src/ProjectConfig.php @@ -4,24 +4,12 @@ namespace Ichiloto\Editor; -use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; -use Ichiloto\Editor\Cutscenes\Source\ArraySourceWriter; -use Ichiloto\Editor\Cutscenes\Source\SourceNode; -use Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal; -use Ichiloto\Editor\Database\PhpDataFile; -use Ichiloto\Editor\Database\PhpValueExporter; -use Ichiloto\Editor\Database\ProjectRecord; use Ichiloto\Editor\Database\InnPresentationFields; -use Ichiloto\Editor\Storage\FileSetOperations; -use Ichiloto\Editor\Storage\FileSetTransaction; -use Ichiloto\Editor\Storage\FilesystemFileSetOperations; use Ichiloto\Engine\Localization\EngineMessageTerms; use Ichiloto\Engine\Rendering\FieldViewport; -use RuntimeException; -use Throwable; /** One source owner for config.php, shared by Terms and Configuration. */ -final class ProjectConfig +final class ProjectConfig extends ProjectSettingsFile { public const string FIELD_ZOOM = 'graphics.field.zoom'; /** The inn's rest stage timeline when an inn names none of its own (InnStay). */ @@ -38,64 +26,20 @@ final class ProjectConfig * ({@see \Ichiloto\Editor\Database\ReferenceCatalog}) they are chosen from. */ public const array ENGINE_REFERENCES = [self::INN_PRESENTATION => 'stage_timelines']; - private ?PhpArraySourceDocument $document = null; - private ?string $issue = null; - private array $payload = []; - /** @var array */ - private array $records = []; - private array $originalValues = []; - private array $fieldIssues = []; - private string $persistedSource = ''; - public readonly string $path; - public function __construct(private readonly string $projectRoot) + public function __construct(string $projectRoot) { - $this->path = rtrim($projectRoot, '/') . '/config.php'; - try { - if (! is_file($this->path)) { - throw new RuntimeException('config.php does not exist in this project.'); - } - $this->persistedSource = (string) file_get_contents($this->path); - $file = PhpDataFile::load($this->path, $projectRoot); - $this->payload = is_array($file->payload) ? $file->payload : []; - $this->document = PhpArraySourceDocument::parse($this->persistedSource); - if (! is_array($file->payload)) { - throw new RuntimeException($file->readOnlyReason ?? 'config.php must return an array.'); - } - } catch (Throwable $error) { - $this->issue = $error->getMessage(); - } - } - - /** Records are shared identities: the ordinary record undo path edits this owner too. */ - public function getRecord(string $path): ProjectRecord - { - if (! isset($this->records[$path])) { - $value = self::getValue($this->payload, $path); - $this->originalValues[$path] = $value; - // A setting the engine knows carries the default it reads when - // the file holds none, which is what the setting reads as here. - $term = $this->listEngineMessageTerms()[$path] ?? null; - $this->records[$path] = new ProjectRecord(match (true) { - array_key_exists($path, self::ENGINE_DEFAULTS) => ['path' => $path, 'value' => $value, 'default' => self::ENGINE_DEFAULTS[$path]], - // A message the Engine says carries its default, what it accepts and what it is for. - $term !== null => ['path' => $path, 'value' => $value, 'default' => $term['default'], - 'placeholders' => $term['placeholders'], 'description' => $term['description']], - default => ['path' => $path, 'value' => $value], - }); - } - return $this->records[$path]; + parent::__construct($projectRoot, 'config.php'); } /** - * Every message the Engine says, by its path in config.php, as the Engine's own catalog lists it: each one is - * offered in Terms with its default even where the project leaves it out, so any game can reword it. - * - * @return array, description: string}> + * The settings the engine reads with a default, then every message the Engine says, by its path in config.php, + * with its default, what it accepts and what it is for: each is offered even where the project leaves it out, + * so any game can reword it. */ - private function listEngineMessageTerms(): array + protected function listEngineSettings(): array { - $terms = []; + $settings = array_map(static fn(mixed $default): array => ['default' => $default], self::ENGINE_DEFAULTS); foreach (EngineMessageTerms::listTerms() as $path => $term) { // A message that inherits the project's own vocabulary wording reads as that wording here, as the game // reads it, taken from this project's file, never from another project's loaded runtime state. @@ -103,48 +47,11 @@ private function listEngineMessageTerms(): array if (is_string($inherited)) { $term['default'] = $inherited; } - unset($term['vocabularyDefault']); - $terms['messages.' . $path] = $term; - } - - return $terms; - } - - /** @return list */ - public function getTermRecords(array $roots): array - { - $records = []; - $visit = function (array $tree, string $prefix, bool $ambiguous = false) use (&$visit, &$records): void { - foreach ($tree as $key => $value) { - $path = $prefix . '.' . $key; - $hasDottedKey = $ambiguous || str_contains((string) $key, '.'); - if ($path === self::INN_PRESENTATION && ! $hasDottedKey) { $records[] = $this->getRecord($path); } - elseif (is_array($value)) { $visit($value, $path, $hasDottedKey); } - elseif (! is_object($value) || $value instanceof \UnitEnum) { - if ($hasDottedKey) { - $this->fieldIssues[$path] = $path . ' contains a dotted literal key, ambiguous with a nested path; it is preserved read-only.'; - $records[] = new ProjectRecord(['path' => $path, 'value' => $value]); - } else { $records[] = $this->getRecord($path); } - } - } - }; - foreach ($roots as $root) { - if (is_array($this->payload[$root] ?? null)) { $visit($this->payload[$root], $root); } - } - // A setting the engine knows is offered even where the file leaves it - // out, so it can be set without being typed in by hand. - foreach ([...array_keys(self::ENGINE_DEFAULTS), ...array_keys($this->listEngineMessageTerms())] as $path) { - $root = explode('.', $path)[0]; - if (in_array($root, $roots, true) && ! in_array($path, array_map(static fn(ProjectRecord $record): mixed => $record->get('path'), $records), true) && $this->issue === null) { - $records[] = $this->getRecord($path); - } + $settings['messages.' . $path] = ['default' => $term['default'], 'placeholders' => $term['placeholders'], + 'description' => $term['description']]; } - return $records; - } - public function getReadOnlyReason(): ?string - { - return $this->issue; + return $settings; } /** @@ -165,70 +72,16 @@ public function assertAcceptableValue(string $path, mixed $value, bool $complete } } - public function getFieldIssue(string $path): ?string - { - if (isset($this->fieldIssues[$path])) { return $this->fieldIssues[$path]; } - try { - $this->createFieldEdit($this->document, $path, '1', $path === self::INN_PRESENTATION); - return null; - } catch (Throwable $error) { - return $error->getMessage(); - } - } - - /** Whether the file authors a setting at all, as opposed to leaving it to its default. */ - public function holds(string $path): bool - { - return $this->document?->nodeAt(explode('.', $path)) !== null; - } - - public function isDirty(): bool - { - foreach ($this->records as $record) { - if ($record->isDirty()) { return true; } - } - return false; - } - - /** Rebuild only the changed leaf spans over the original bytes, including after save/undo. */ - public function getSource(): string - { - return $this->composeSource(); - } - - /** Proves a proposed structured value is source-editable before changing its shared record. */ - public function assertSourceAcceptsValue(string $path, mixed $value): void - { - $this->composeSource([$path => $value]); - } - - private function composeSource(array $overrides = []): string + /** The inn's rest presentation is one setting, a whole timeline binding. */ + protected function isWholeValue(string $path): bool { - $document = $this->document; - foreach ($this->records as $path => $record) { - $value = array_key_exists($path, $overrides) ? $overrides[$path] : $record->get('value'); - if ($value === $this->originalValues[$path]) { continue; } - if ($path === self::INN_PRESENTATION && is_array($this->originalValues[$path])) { - $this->createFieldEdit($document, $path, '1', true); - $old = $this->payload; - $new = $old; - $next = &$new; - foreach (explode('.', $path) as $segment) { $next = &$next[$segment]; } - $next = $value; - unset($next); - $document = ArraySourceWriter::rewrite($document, $old, $new, $this->getLeaderKeyRenames($this->originalValues[$path], $value)); - continue; - } - $edit = $this->createFieldEdit($document, $path, PhpValueExporter::export($value)); - $document = $document->withEdits([$edit]); - } - return $document?->source ?? $this->persistedSource; + return $path === self::INN_PRESENTATION; } /** One picker key replacement keeps the binding's authored comments and value span. */ - private function getLeaderKeyRenames(array $old, mixed $new): array + protected function listKeyRenames(string $path, mixed $old, mixed $new): array { - if (! is_array($new) || ($old['treatment'] ?? null) !== 'leader' || ($new['treatment'] ?? null) !== 'leader' + if (! is_array($old) || ! is_array($new) || ($old['treatment'] ?? null) !== 'leader' || ($new['treatment'] ?? null) !== 'leader' || ! is_array($old['leaders'] ?? null) || ! is_array($new['leaders'] ?? null)) { return []; } $before = array_keys($old['leaders']); $after = array_keys($new['leaders']); @@ -241,80 +94,4 @@ private function getLeaderKeyRenames(array $old, mixed $new): array } return $renames; } - - /** Saves all pending configuration leaves together, never a second whole-value rewrite. */ - public function save(?FileSetOperations $files = null): void - { - if (! $this->isDirty()) { return; } - foreach ($this->records as $path => $record) { - if ($record->isDirty() && $record->get('value') !== $this->originalValues[$path]) { $this->assertAcceptableValue($path, $record->get('value')); } - } - $source = $this->getSource(); - $transaction = new FileSetTransaction($this->projectRoot, $files ?? new FilesystemFileSetOperations()); - $transaction->write($this->path, $source); - try { - $staged = $transaction->stage(); - if ((string) file_get_contents($this->path) !== $this->persistedSource) { - throw new SourcePreservationRefusal('config.php changed on disk. Reload before saving; pending edits were not written.'); - } - $evaluated = PhpDataFile::evaluateIsolated($staged[$this->path], $this->projectRoot); - foreach ($this->records as $path => $record) { - if ($record->get('value') === $this->originalValues[$path]) { continue; } - if (self::getValue(is_array($evaluated) ? $evaluated : [], $path) !== $record->get('value')) { - throw new SourcePreservationRefusal('The staged config.php does not preserve ' . $path . '.'); - } - } - $transaction->commit(); - } catch (Throwable $error) { - $transaction->rollBack(); - throw $error; - } - $this->persistedSource = $source; - foreach ($this->records as $record) { $record->markClean(); } - } - - private function createFieldEdit(?PhpArraySourceDocument $document, string $path, string $literal, bool $allowArray = false): array - { - if ($this->issue !== null || $document === null) { throw new SourcePreservationRefusal($this->issue ?? 'config.php cannot be preserved.'); } - $segments = explode('.', $path); - $walked = []; - $node = $document->root(); - foreach ($segments as $index => $key) { - if ($node->kind !== SourceNode::ARRAY || $node->hasOpaqueKey || count(array_unique(array_column($node->entries, 'key'), SORT_REGULAR)) !== count($node->entries)) { - throw new SourcePreservationRefusal($path . ' needs literal, uniquely keyed parent arrays; opaque configuration is preserved read-only.'); - } - $entry = $node->entryFor($key); - if ($entry === null) { - foreach (array_reverse(array_slice($segments, $index + 1)) as $child) { - $literal = '[' . var_export($child, true) . ' => ' . $literal . ']'; - } - return $document->insertEntryEdit($walked, count($node->entries), $key, $literal); - } - $walked[] = $key; - $node = $entry->value; - } - // An enum case is written as one: replacing it with another case of - // its enum keeps the file reading the same kind of value. - if ($node->kind !== SourceNode::SCALAR && ! ($allowArray && $node->kind === SourceNode::ARRAY) && ! self::getValue($this->payload, $path) instanceof \UnitEnum) { - throw new SourcePreservationRefusal($path . ' is an authored expression, not an editable literal.'); - } - return $document->replaceValueEdit($walked, $literal); - } - - private static function getValue(array $payload, string $path): mixed - { - $value = $payload; - foreach (explode('.', $path) as $key) { $value = is_array($value) ? ($value[$key] ?? null) : null; } - return $value; - } - - /** - * Returns the files a save of this database would overwrite. - * - * @return string[] - */ - public function getBackupPaths(): array - { - return [$this->path]; - } } diff --git a/src/ProjectSettingsFile.php b/src/ProjectSettingsFile.php new file mode 100644 index 0000000..aa81500 --- /dev/null +++ b/src/ProjectSettingsFile.php @@ -0,0 +1,284 @@ + */ + private array $records = []; + private array $originalValues = []; + private array $fieldIssues = []; + private string $persistedSource = ''; + public readonly string $path; + + public function __construct(protected readonly string $projectRoot, public readonly string $relativePath) + { + $this->path = rtrim($projectRoot, '/') . '/' . $relativePath; + try { + if (! is_file($this->path)) { + throw new RuntimeException(sprintf('%s does not exist in this project.', $relativePath)); + } + $this->persistedSource = (string) file_get_contents($this->path); + $file = PhpDataFile::load($this->path, $projectRoot); + $this->payload = is_array($file->payload) ? $file->payload : []; + $this->document = PhpArraySourceDocument::parse($this->persistedSource); + if (! is_array($file->payload)) { + throw new RuntimeException($file->readOnlyReason ?? sprintf('%s must return an array.', $relativePath)); + } + } catch (Throwable $error) { + $this->issue = $error->getMessage(); + } + } + + /** + * The settings the Engine reads where the file leaves them out, by path, with what their record carries beyond + * its path and value: at least the `default` the Engine reads. Each is offered even where the file is silent. + * + * @return array> + */ + abstract protected function listEngineSettings(): array; + + /** + * Refuses a value the Engine would not accept at a path, before anything changes. + * + * @throws \InvalidArgumentException + */ + public function assertAcceptableValue(string $path, mixed $value, bool $complete = true): void {} + + /** Whether a setting's value is a whole array edited as one setting, rather than a tree of settings. */ + protected function isWholeValue(string $path): bool + { + return false; + } + + /** + * Keys a whole-value edit renames rather than replaces, so the renamed entries keep their authored comments. + * + * @return list, key: string}> + */ + protected function listKeyRenames(string $path, mixed $old, mixed $new): array + { + return []; + } + + public function getRecord(string $path): ProjectRecord + { + if (! isset($this->records[$path])) { + $value = self::getValue($this->payload, $path); + $this->originalValues[$path] = $value; + $setting = $this->listEngineSettings()[$path] ?? null; + $this->records[$path] = new ProjectRecord($setting === null + ? ['path' => $path, 'value' => $value] + : ['path' => $path, 'value' => $value, ...$setting]); + } + return $this->records[$path]; + } + + /** + * The settings under these roots: every literal leaf the file holds, then every setting the Engine knows there + * that the file leaves out. + * + * @param list $roots + * @return list + */ + public function getSettingRecords(array $roots): array + { + $records = []; + $visit = function (array $tree, string $prefix, bool $ambiguous = false) use (&$visit, &$records): void { + foreach ($tree as $key => $value) { + $path = $prefix . '.' . $key; + $hasDottedKey = $ambiguous || str_contains((string) $key, '.'); + if ($this->isWholeValue($path) && ! $hasDottedKey) { $records[] = $this->getRecord($path); } + elseif (is_array($value)) { $visit($value, $path, $hasDottedKey); } + elseif (! is_object($value) || $value instanceof \UnitEnum) { + if ($hasDottedKey) { + $this->fieldIssues[$path] = $path . ' contains a dotted literal key, ambiguous with a nested path; it is preserved read-only.'; + $records[] = new ProjectRecord(['path' => $path, 'value' => $value]); + } else { $records[] = $this->getRecord($path); } + } + } + }; + foreach ($roots as $root) { + if (is_array($this->payload[$root] ?? null)) { $visit($this->payload[$root], $root); } + } + foreach (array_keys($this->listEngineSettings()) as $path) { + $root = explode('.', $path)[0]; + if (in_array($root, $roots, true) && ! in_array($path, array_map(static fn(ProjectRecord $record): mixed => $record->get('path'), $records), true) && $this->issue === null) { + $records[] = $this->getRecord($path); + } + } + return $records; + } + + public function getReadOnlyReason(): ?string + { + return $this->issue; + } + + public function getFieldIssue(string $path): ?string + { + if (isset($this->fieldIssues[$path])) { return $this->fieldIssues[$path]; } + try { + $this->createFieldEdit($this->document, $path, '1', $this->isWholeValue($path)); + return null; + } catch (Throwable $error) { + return $error->getMessage(); + } + } + + /** Whether the file authors a setting at all, as opposed to leaving it to its default. */ + public function holds(string $path): bool + { + return $this->document?->nodeAt(explode('.', $path)) !== null; + } + + public function isDirty(): bool + { + foreach ($this->records as $record) { + if ($record->isDirty()) { return true; } + } + return false; + } + + /** Rebuild only the changed leaf spans over the original bytes, including after save/undo. */ + public function getSource(): string + { + return $this->composeSource(); + } + + /** Proves a proposed structured value is source-editable before changing its shared record. */ + public function assertSourceAcceptsValue(string $path, mixed $value): void + { + $this->composeSource([$path => $value]); + } + + /** The value a setting holds now, pending edits included, or what the file holds where none was read. */ + protected function getCurrentValue(string $path): mixed + { + return isset($this->records[$path]) ? $this->records[$path]->get('value') : self::getValue($this->payload, $path); + } + + private function composeSource(array $overrides = []): string + { + $document = $this->document; + foreach ($this->records as $path => $record) { + $value = array_key_exists($path, $overrides) ? $overrides[$path] : $record->get('value'); + if ($value === $this->originalValues[$path]) { continue; } + if ($this->isWholeValue($path) && is_array($this->originalValues[$path])) { + $this->createFieldEdit($document, $path, '1', true); + $old = $this->payload; + $new = $old; + $next = &$new; + foreach (explode('.', $path) as $segment) { $next = &$next[$segment]; } + $next = $value; + unset($next); + $document = ArraySourceWriter::rewrite($document, $old, $new, $this->listKeyRenames($path, $this->originalValues[$path], $value)); + continue; + } + $edit = $this->createFieldEdit($document, $path, PhpValueExporter::export($value)); + $document = $document->withEdits([$edit]); + } + return $document?->source ?? $this->persistedSource; + } + + /** Saves all pending settings together, never a second whole-value rewrite. */ + public function save(?FileSetOperations $files = null): void + { + if (! $this->isDirty()) { return; } + foreach ($this->records as $path => $record) { + if ($record->isDirty() && $record->get('value') !== $this->originalValues[$path]) { $this->assertAcceptableValue($path, $record->get('value')); } + } + $source = $this->getSource(); + $transaction = new FileSetTransaction($this->projectRoot, $files ?? new FilesystemFileSetOperations()); + $transaction->write($this->path, $source); + try { + $staged = $transaction->stage(); + if ((string) file_get_contents($this->path) !== $this->persistedSource) { + throw new SourcePreservationRefusal($this->relativePath . ' changed on disk. Reload before saving; pending edits were not written.'); + } + $evaluated = PhpDataFile::evaluateIsolated($staged[$this->path], $this->projectRoot); + foreach ($this->records as $path => $record) { + if ($record->get('value') === $this->originalValues[$path]) { continue; } + if (self::getValue(is_array($evaluated) ? $evaluated : [], $path) !== $record->get('value')) { + throw new SourcePreservationRefusal('The staged ' . $this->relativePath . ' does not preserve ' . $path . '.'); + } + } + $transaction->commit(); + } catch (Throwable $error) { + $transaction->rollBack(); + throw $error; + } + $this->persistedSource = $source; + foreach ($this->records as $record) { $record->markClean(); } + } + + private function createFieldEdit(?PhpArraySourceDocument $document, string $path, string $literal, bool $allowArray = false): array + { + if ($this->issue !== null || $document === null) { throw new SourcePreservationRefusal($this->issue ?? $this->relativePath . ' cannot be preserved.'); } + $segments = explode('.', $path); + $walked = []; + $node = $document->root(); + foreach ($segments as $index => $key) { + if ($node->kind !== SourceNode::ARRAY || $node->hasOpaqueKey || count(array_unique(array_column($node->entries, 'key'), SORT_REGULAR)) !== count($node->entries)) { + throw new SourcePreservationRefusal($path . ' needs literal, uniquely keyed parent arrays; opaque settings are preserved read-only.'); + } + $entry = $node->entryFor($key); + if ($entry === null) { + foreach (array_reverse(array_slice($segments, $index + 1)) as $child) { + $literal = '[' . var_export($child, true) . ' => ' . $literal . ']'; + } + return $document->insertEntryEdit($walked, count($node->entries), $key, $literal); + } + $walked[] = $key; + $node = $entry->value; + } + // An enum case is written as one: replacing it with another case of + // its enum keeps the file reading the same kind of value. + if ($node->kind !== SourceNode::SCALAR && ! ($allowArray && $node->kind === SourceNode::ARRAY) && ! self::getValue($this->payload, $path) instanceof \UnitEnum) { + throw new SourcePreservationRefusal($path . ' is an authored expression, not an editable literal.'); + } + return $document->replaceValueEdit($walked, $literal); + } + + protected static function getValue(array $payload, string $path): mixed + { + $value = $payload; + foreach (explode('.', $path) as $key) { $value = is_array($value) ? ($value[$key] ?? null) : null; } + return $value; + } + + /** + * Returns the files a save would overwrite. + * + * @return string[] + */ + public function getBackupPaths(): array + { + return [$this->path]; + } +} diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index a227831..ece0203 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -53,6 +53,7 @@ public function __construct( public array $recordDatabases = [], public ?CutsceneLibrary $cutscenes = null, public ?ProjectConfig $config = null, + public ?MenuPresentationSettings $menuPresentation = null, public ?ProjectScriptCommands $scriptCommands = null, ) { // A quest's id follows its name until something refers to it, and @@ -207,6 +208,9 @@ private static function loadFromProject(string $projectRoot, bool $graphical): s // failure instead of the author's enemies. EngineDataBootstrap::ensure($projectRoot); $projectConfig = new ProjectConfig($projectRoot); + // Each settings file has one owner, shared by every category over it. + $menuPresentation = new MenuPresentationSettings($projectRoot); + $settingsFiles = ['config.php' => $projectConfig, MenuPresentationSettings::RELATIVE_PATH => $menuPresentation]; // Before any schema is built: the command editor offers, and Engine // validation accepts, exactly this project's registered commands. $scriptCommands = ProjectScriptCommands::fromProject($projectRoot); @@ -221,11 +225,13 @@ private static function loadFromProject(string $projectRoot, bool $graphical): s mapIds: array_map(static fn(ProjectMap $map): string => $map->mapId, $maps), actorDatabase: ProjectActorDatabase::fromProject($projectRoot), recordDatabases: array_map( - static fn(RecordSchema $schema): ProjectRecordDatabase => ProjectRecordDatabase::fromProject($projectRoot, $schema, $projectConfig, $graphical), + static fn(RecordSchema $schema): ProjectRecordDatabase => ProjectRecordDatabase::fromProject($projectRoot, $schema, + $settingsFiles[$schema->relativePath] ?? null, $graphical), RecordSchemaCatalog::all($graphical), ), cutscenes: CutsceneLibrary::fromProject($projectRoot), config: $projectConfig, + menuPresentation: $menuPresentation, scriptCommands: $scriptCommands, ); } @@ -328,6 +334,7 @@ public function hasUnsavedChanges(): bool return $this->actorDatabase->isDirty() || ($this->config?->isDirty() ?? false) + || ($this->menuPresentation?->isDirty() ?? false) || ($this->cutscenes?->hasUnsavedChanges() ?? false); } @@ -365,7 +372,7 @@ public function listUnsavedChanges(): array * Read-only record categories are left out: they hold no edits, and * asking them to save would raise instead of doing nothing. * - * @return array + * @return array */ public function listSaveableDatabases(): array { @@ -377,6 +384,10 @@ public function listSaveableDatabases(): array $databases['Project configuration'] = $this->config; } + if ($this->menuPresentation !== null) { + $databases['Menu theme'] = $this->menuPresentation; + } + foreach ($this->recordDatabases as $categoryKey => $recordDatabase) { if ($recordDatabase->isEditable()) { // By its own label: an embedded category (battle art) has no @@ -456,6 +467,7 @@ private function withMaps(array $maps): self recordDatabases: $this->recordDatabases, cutscenes: $this->cutscenes, config: $this->config, + menuPresentation: $this->menuPresentation, scriptCommands: $this->scriptCommands, ); } diff --git a/tests/Unit/Session/MenuThemeSessionTest.php b/tests/Unit/Session/MenuThemeSessionTest.php new file mode 100644 index 0000000..e65be43 --- /dev/null +++ b/tests/Unit/Session/MenuThemeSessionTest.php @@ -0,0 +1,94 @@ + 'ichiloto.menu/1', + 'showInputHints' => true, // A setting another page owns. + 'atlas' => [ + 'margin' => 24, // Breathing room. + ], +]; +PHP); + return [$root, $file, EditorSession::open($root)]; +} + +/** The index of a theme setting by its path. */ +function findThemeSetting(EditorSession $session, string $path): int +{ + $labels = $session->listDatabaseRecords(MenuThemeFields::CATEGORY)['records']; + $index = array_search($path, $labels, true); + return is_int($index) ? $index : throw new RuntimeException('No theme setting ' . $path); +} + +function setThemeSetting(EditorSession $session, string $path, string $value): array +{ + $index = findThemeSetting($session, $path); + $row = array_find($session->readDatabaseRecord(MenuThemeFields::CATEGORY, $index)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === 'value'); + return $session->applyDatabaseRecord(MenuThemeFields::CATEGORY, $index, $row['key'], $value); +} + +it('offers every atlas setting with the Engine default, authored ones first, only in the graphical editor', function () { + [, , $session] = createMenuThemeProject(); + $labels = $session->listDatabaseRecords(MenuThemeFields::CATEGORY)['records']; + $engine = array_map(static fn(string $key): string => 'atlas.' . $key, array_keys(get_object_vars(new MapAtlasTheme()))); + expect($labels[0])->toBe('atlas.margin') + ->and($labels)->toEqualCanonicalizing($engine); + $frame = array_find($session->readDatabaseRecord(MenuThemeFields::CATEGORY, findThemeSetting($session, 'atlas.frameRole'))['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === 'value'); + expect($frame['options'])->toBe(MapAtlasTheme::FRAME_ROLES); + // The Terminal edits no graphical presentation. + expect(array_map(static fn($category): string => $category->key, DatabaseCatalog::all()))->not->toContain(MenuThemeFields::CATEGORY) + ->and(array_map(static fn($category): string => $category->key, DatabaseCatalog::getGraphicalCategories()))->toContain(MenuThemeFields::CATEGORY); +}); + +it('validates each value with its neighbours through the Engine, saves only changed leaves and undoes', function () { + [$root, $file, $session] = createMenuThemeProject(); + $default = (new MapAtlasTheme())->largeWidth; + expect(fn() => setThemeSetting($session, 'atlas.compactWidth', (string) ($default + 1)))->toThrow(SessionRefusal::class); + expect(fn() => setThemeSetting($session, 'atlas.markerSize', '99'))->toThrow(SessionRefusal::class); + + setThemeSetting($session, 'atlas.largeWidth', '600'); + setThemeSetting($session, 'atlas.compactWidth', '500'); + setThemeSetting($session, 'atlas.margin', '20'); + setThemeSetting($session, 'atlas.frameRole', 'panel'); + $session->undo(); + $session->saveDatabase(MenuThemeFields::CATEGORY); + $source = (string) file_get_contents($file); + expect($source)->toContain('// Theme header stays authored.', '// A setting another page owns.', '// Breathing room.') + ->and($source)->not->toContain('frameRole'); + $saved = (require $file)['atlas']; + ksort($saved); + expect($saved)->toBe(['compactWidth' => 500, 'largeWidth' => 600, 'margin' => 20]); + expect(MapAtlasTheme::fromArray($saved)->compactWidth)->toBe(500); + + // Choosing the default for a setting the theme leaves out writes nothing. + setThemeSetting($session, 'atlas.markerSize', (string) (new MapAtlasTheme())->markerSize); + expect($session->listDatabaseRecords(MenuThemeFields::CATEGORY)['dirty'])->toBeFalse(); + expect(EditorSession::open($root)->listDatabaseRecords(MenuThemeFields::CATEGORY)['records'][0])->toBe('atlas.margin'); +}); + +it('is read-only, saying why, in a project without a menu theme', function () { + $root = makeTemporaryProject('editor-menu-theme-none-'); + $listing = EditorSession::open($root)->listDatabaseRecords(MenuThemeFields::CATEGORY); + expect($listing['editable'])->toBeFalse() + ->and($listing['readOnly'])->toContain(MenuPresentationSettings::RELATIVE_PATH) + ->and($listing['records'])->toBe([]); +}); From dd8a64b8c28b4786e3c476c8790dd5f9a9e1d8d4 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 17 Oct 2026 13:15:53 +0200 Subject: [PATCH 293/344] feat(database): show the Engine's atlas limits on Menu Theme size rows Size, margin and marker rows read their accepted range from MapAtlasTheme's constants (Engine e549259), so an author sees the limits before a refusal. Validation still goes through MapAtlasTheme::fromArray. --- src/Database/MenuThemeFields.php | 17 ++++++++++++++++- tests/Unit/Session/MenuThemeSessionTest.php | 3 +++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Database/MenuThemeFields.php b/src/Database/MenuThemeFields.php index 9b56dbc..58781fd 100644 --- a/src/Database/MenuThemeFields.php +++ b/src/Database/MenuThemeFields.php @@ -55,9 +55,24 @@ public static function getFields(array $row): array match (true) { ($row['path'] ?? null) === MenuPresentationSettings::ATLAS . '.frameRole' => new RecordField('value', 'Value', options: MapAtlasTheme::FRAME_ROLES, displayDefault: $default), - is_int($kind) => new RecordField('value', 'Value', InputControlType::INTEGER, displayDefault: $default), + is_int($kind) => new RecordField('value', self::describeRange((string) ($row['path'] ?? '')), + InputControlType::INTEGER, displayDefault: $default), default => new RecordField('value', 'Value', displayDefault: $default), }, ]; } + + /** A number's label with the logical-pixel range the Engine accepts for it, so an author sees it before a refusal. */ + private static function describeRange(string $path): string + { + $range = match ($path) { + 'atlas.compactWidth', 'atlas.compactHeight', 'atlas.largeWidth', 'atlas.largeHeight' + => [MapAtlasTheme::MIN_EXTENT, MapAtlasTheme::MAX_EXTENT], + 'atlas.margin' => [0, MapAtlasTheme::MAX_MARGIN], + 'atlas.markerSize' => [MapAtlasTheme::MIN_MARKER_SIZE, MapAtlasTheme::MAX_MARKER_SIZE], + default => null, + }; + + return $range === null ? 'Value' : sprintf('Pixels (%d..%d)', ...$range); + } } diff --git a/tests/Unit/Session/MenuThemeSessionTest.php b/tests/Unit/Session/MenuThemeSessionTest.php index e65be43..a490447 100644 --- a/tests/Unit/Session/MenuThemeSessionTest.php +++ b/tests/Unit/Session/MenuThemeSessionTest.php @@ -54,6 +54,9 @@ function setThemeSetting(EditorSession $session, string $path, string $value): a $frame = array_find($session->readDatabaseRecord(MenuThemeFields::CATEGORY, findThemeSetting($session, 'atlas.frameRole'))['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'value'); expect($frame['options'])->toBe(MapAtlasTheme::FRAME_ROLES); + $margin = array_find($session->readDatabaseRecord(MenuThemeFields::CATEGORY, findThemeSetting($session, 'atlas.margin'))['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === 'value'); + expect($margin['label'])->toBe(sprintf('Pixels (0..%d)', MapAtlasTheme::MAX_MARGIN)); // The Terminal edits no graphical presentation. expect(array_map(static fn($category): string => $category->key, DatabaseCatalog::all()))->not->toContain(MenuThemeFields::CATEGORY) ->and(array_map(static fn($category): string => $category->key, DatabaseCatalog::getGraphicalCategories()))->toContain(MenuThemeFields::CATEGORY); From f59ad4d5078936eb04f153838a54eadd99a682cc Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 17 Oct 2026 23:58:36 +0200 Subject: [PATCH 294/344] feat(map): author atlas scope, parent entries and place categories Maps and landmarks gain the Engine's atlas and category contract (MapAtlasDefinition, MapCategoryCatalog, readDeclarations categories): - Map inspector Atlas rows, in both editors: Scope (local or global) and, for a local map, its Parent Entry chosen from the landmarks other maps declare, those whose door leads here first. Every edit is resolved through MapAtlasDefinition::resolveGlobal over the project's maps as they stand, so cycles, missing landmarks and over-deep chains are refused; the row shows the global map the chain reaches. - Landmarks gain a Category selector over the Engine's generic categories and the project's own. - Place Categories, a Database page over assets/Data/map-categories.php (schema ichiloto.map-categories/1) validated by the Engine's catalogue; a new category starts as a kind of the first generic category. - Reference safety: a landmark another map names as its atlas entry cannot be removed; deleting or moving a map lists maps naming it as parent; a place category a landmark uses keeps its id and cannot be deleted (ProjectRecordDatabase::useDeletionReferences, a general hook the workspace sets, also refusing identity edits while referenced). - MapValidator reports atlas chains and categories the Engine refuses. - MapLandmarkFields becomes an instance over ReferenceCatalog, which now lists maps and supplies the category catalogue with unsaved edits. The golden shell snapshot gains the Atlas rows. --- docs/manual.md | 30 +++++ src/Database/DatabaseCatalog.php | 1 + src/Database/ProjectRecordDatabase.php | 34 +++++ src/Database/RecordAuthoring.php | 7 +- src/Database/RecordSchemaCatalog.php | 1 + src/Database/ReferenceCatalog.php | 39 ++++++ src/Field/MapAtlas.php | 128 +++++++++++++++++++ src/Field/MapCategoryFields.php | 111 ++++++++++++++++ src/Field/MapLandmarks.php | 70 ++++++++-- src/Inspector/MapAtlasFields.php | 120 +++++++++++++++++ src/Inspector/MapInspector.php | 41 +++++- src/Inspector/MapLandmarkFields.php | 118 ++++++++++++++--- src/Maps/MapReferences.php | 8 +- src/ProjectWorkspace.php | 4 + src/Session/EditorSession.php | 4 +- src/Validation/MapValidator.php | 23 +++- tests/Support/MapLandmarkFixtures.php | 39 ++++-- tests/Unit/MapLandmarkTerminalTest.php | 26 ++++ tests/Unit/Session/MapAtlasAuthoringTest.php | 110 ++++++++++++++++ tests/__snapshots__/main_shell_100x30.ansi | 2 +- 20 files changed, 866 insertions(+), 50 deletions(-) create mode 100644 src/Field/MapAtlas.php create mode 100644 src/Field/MapCategoryFields.php create mode 100644 src/Inspector/MapAtlasFields.php create mode 100644 tests/Unit/Session/MapAtlasAuthoringTest.php diff --git a/docs/manual.md b/docs/manual.md index ce708cf..6acc6ea 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1015,6 +1015,10 @@ event or object moves the landmark. - The id, shown on the landmark's heading, is fixed: saves remember what the party has learned by it. An object's own id is used when it is a valid landmark id; otherwise the next free `landmark-N`. +- `Category` says what the place offers, whatever its kind: the Engine's + `inn`, `shop`, `home` and `organization`, or a category of the project's + own (see Place Categories). An entrance into a shop is a route with the + shop category. `(none)` leaves the marker to the kind. - `Name` is the label the minimap shows once the place is known; clearing it removes the name. A first name takes the Engine's map-scoped translation key, shown and editable as `Name Translation Key`. @@ -1030,6 +1034,22 @@ cannot be deleted, and an anchored event cannot change between a transfer and any other type, until the landmark is changed or removed. Validation reports landmarks the Engine would refuse. +### Atlas + +`Atlas` places the map in the atlas (Engine `docs/maps.md`). `Scope` is +`Local` (a town, a house, a dungeon; what a map is when it says nothing) or +`Global`, the overworld geography itself. A local map's `Parent Entry` is the +landmark on another map that is its entrance there: a house's on its town, the +town's on the world. It is picked from the landmarks other maps declare, those +whose door leads to this map first; a global map has no parent. `Global Map` +shows where the chain of parents reaches the global map, as the Engine +resolves it. No coordinates are copied: moving the parent's door moves the +entry. An edit the Engine would refuse (a cycle, a missing landmark, too deep +a chain) changes nothing and says why. A landmark another map names as its +entry cannot be removed until that map chooses another, and deleting or moving +a map lists the maps that name it as parent. Validation reports an atlas the +Engine would refuse. + In Event mode, painting or selecting a marker with no definition opens the Event Type picker. **Story Script** creates the engine's `ScriptEventTrigger`. Its inspector exposes `Script Id`, `Mode` @@ -1188,6 +1208,7 @@ skill's effects, a quest's rewards) listed under them. | Configuration | `config.php` (`save`, `accessibility`, `ui`, `graphics`, `audio`, `inn`) | Editable - one row per setting, typed by what it holds; field zoom is kept from 1 to 8 | Same | | Types | `assets/Data/system.php` | Editable - the project's elements, one per row; an empty list means the Engine's defaults. Weapon, armor and equipment types are the Engine's own | Same | | Terms | `config.php` (`vocab`, `messages`) | Literal terms editable; comments and unrelated expressions preserved | Same | +| Place Categories | `assets/Data/map-categories.php` (`categories`) | Editable - one record per category: stable id, name and translation key, and the generic category it specializes | Same | | Player Field Appearance | `assets/Data/Entities/player.php` | Not shown: graphical ownership and art | Choose fixed-player, selected party leader, or absent legacy selector; fixed sheet picker with index/layer controls | | Field Resources | `assets/Data/Presentation/field.php` (`resources`) | Not shown: graphical resources | One record per named resource, keyed by its stable id: name, ground contact, an image or a sign board, and an optional occupancy stamp | | Text Roles | `assets/Data/Presentation/text.php` (`roles`) | Not shown: graphical text | One record per text role: the project's fonts, first choice first, a size and a colour | @@ -1216,6 +1237,15 @@ kept. A project without `menus.php` shows the category read-only and says why. The Terminal's opened map keeps its own layout, so the Terminal editor does not list this category. +Place Categories name the kinds of place a game marks on its maps beyond the +Engine's generic `inn`, `shop`, `home` and `organization`: a school, a temple, +a guild. Each record has a stable id, a name with its translation key, and the +generic category it specializes, whose marker it shows until the Menu Theme +gives it one of its own. A new category starts as a kind of `inn`, the +Engine's first generic category, for the author to change. Its id may change +until a landmark uses it; while one does, the id stays and the category cannot +be deleted. + Field Resources are reusable whole images, such as a tree, that a map places by reference. A resource owns its art and its ground contact (the pivot, such as a trunk base) and may carry an occupancy stamp: cells relative to a placement's diff --git a/src/Database/DatabaseCatalog.php b/src/Database/DatabaseCatalog.php index 0c5fa80..cc8b2cb 100644 --- a/src/Database/DatabaseCatalog.php +++ b/src/Database/DatabaseCatalog.php @@ -31,6 +31,7 @@ public static function all(): array new DatabaseCategoryDefinition('states', 'States', 'Define status effects and conditions.', true), new DatabaseCategoryDefinition('animations', 'Animations', 'Create reusable keyframed effects.', true), new DatabaseCategoryDefinition('tilesets', 'Tilesets', 'Assign tiles and terrain behavior.', true), + new DatabaseCategoryDefinition(\Ichiloto\Editor\Field\MapCategoryFields::CATEGORY, 'Place Categories', 'Name the kinds of place a game marks on its maps beyond inn, shop, home and organization.', true), new DatabaseCategoryDefinition('common_events', 'Common Events', 'Author cutscene and event command lists.', true), new DatabaseCategoryDefinition('quests', 'Quests', 'Author quests, objectives, and rewards.', true), new DatabaseCategoryDefinition('skits', 'Skits', 'Write optional party banter scenes.', true), diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index f94e78b..6b68c18 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -47,6 +47,8 @@ final class ProjectRecordDatabase * @var list */ private array $retiredIdentities = []; + /** @var (\Closure(string): list)|null What keeps a record from being deleted ({@see useDeletionReferences()}). */ + private ?\Closure $deletionReferences = null; private ?ReferenceCatalog $authoringReferences = null; private bool $graphical = false; @@ -820,6 +822,12 @@ public function setField(int $index, string $fieldId, string $rawValue): void : $this->assertValueUnclaimed($record, $field, $value); } $previousIdentity = $this->schema->identityKey === null ? null : $record->get($this->schema->identityKey); + // An identity something still names stays, so the reference keeps resolving. + if ($field->key === $this->schema->identityKey && $this->deletionReferences !== null && strval($value) !== strval($previousIdentity) + && ($references = ($this->deletionReferences)(strval($previousIdentity))) !== []) { + throw new RecordRefusal(sprintf('%s %s is still used, so its id stays: %s.', ucfirst($this->schema->entryNoun), + strval($previousIdentity), implode('; ', $references))); + } $before = $record->toArray(); $draft = clone $record; if ($this->schema->key === PlayerPresentationFields::CATEGORY) { @@ -1020,6 +1028,32 @@ public function useIdentityReferences(\Closure $isReferenced): void $this->identityReferences = $isReferenced; } + /** + * Says what in the project still names a record by its identity, which keeps the record from being deleted + * (a place category a landmark uses). The workspace supplies it: what refers to a record lives across the + * project. + * + * @param \Closure(string): list $describe Where each reference lives, in words. + */ + public function useDeletionReferences(\Closure $describe): void + { + $this->deletionReferences = $describe; + } + + /** + * What still names the record at an index by its identity, in words; empty when it may go. + * + * @return list + */ + public function describeDeletionReferences(int $index): array + { + $record = array_values($this->records)[$index] ?? null; + if ($this->deletionReferences === null || ! $record instanceof ProjectRecord || $this->schema->identityKey === null) { + return []; + } + return ($this->deletionReferences)((string) $record->get($this->schema->identityKey)); + } + /** * Refuses a list member another record of the category already holds, * naming that record, before anything changes. diff --git a/src/Database/RecordAuthoring.php b/src/Database/RecordAuthoring.php index 3912f88..ac757c5 100644 --- a/src/Database/RecordAuthoring.php +++ b/src/Database/RecordAuthoring.php @@ -422,12 +422,17 @@ public function moveRecord(ProjectRecordDatabase $database, int $index, int $ste * undo honest. The change's index is the record to select next: the * one before it, or null when none is left. * - * @throws RecordRefusal When the category is read-only, the record is gone, or the category keeps its entries. + * @throws RecordRefusal When the category is read-only, the record is gone, something still names it, or the + * category keeps its entries. */ public function deleteRecord(ProjectRecordDatabase $database, int $index): RecordChange { $this->requireRecord($database, $index); $label = $database->getEntryLabels()[$index] ?? ''; + $references = $database->describeDeletionReferences($index); + if ($references !== []) { + throw new RecordRefusal(sprintf('%s %s is still used: %s.', ucfirst($database->schema->entryNoun), $label, implode('; ', $references))); + } $removed = $database->removeRecord($index) ?? throw new RecordRefusal('This category does not support entry deletion yet.'); $remaining = count($database->getRecords()); diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index b7e8228..fe6ec21 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -129,6 +129,7 @@ public static function all(bool $graphical = false): array self::optimizeExclusions(), self::commonEvents($graphical), self::terms(), + \Ichiloto\Editor\Field\MapCategoryFields::getSchema(), self::configuration($graphical), self::types(), self::tilesets($graphical), diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 6c528be..006939f 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -1085,6 +1085,45 @@ private function loadTilesetNames(): array return $names; } + /** A project map by id, unsaved edits included, as the workspace holds it. */ + public function findMap(string $mapId): ?ProjectMap + { + $index = array_search($mapId, $this->workspace->mapIds, true); + return is_int($index) ? ($this->workspace->maps[$index] ?? null) : null; + } + + /** @return list Every project map, unsaved edits included. */ + public function listMaps(): array + { + return array_values($this->workspace->maps); + } + + /** + * The project's place categories as the Engine reads them: its generic ones and those the project declares, + * as its Place Categories page holds them. + * + * @throws \InvalidArgumentException When the project's catalogue cannot be read. + */ + public function getMapCategoryCatalog(): \Ichiloto\Engine\Field\MapCategoryCatalog + { + // The project's own categories as the Place Categories page holds them, unsaved edits included. + $database = $this->workspace->getRecordDatabase(\Ichiloto\Editor\Field\MapCategoryFields::CATEGORY); + if ($database === null) { + return \Ichiloto\Engine\Field\MapCategoryCatalog::load(dirname($this->workspace->getMapsRoot())); + } + if (($reason = $database->getReadOnlyReason()) !== null) { + throw new \InvalidArgumentException($reason); + } + $records = []; + foreach ($database->getRecords() as $record) { + $row = (array) $record->toArray(); + $id = (string) ($row['id'] ?? ''); + unset($row['id']); + $records[$id] = $row; + } + return new \Ichiloto\Engine\Field\MapCategoryCatalog($records); + } + /** * Returns the arenas the project's battle presentation declares, key => * display name, in authored order: none for a project without one, or diff --git a/src/Field/MapAtlas.php b/src/Field/MapAtlas.php new file mode 100644 index 0000000..1838ea4 --- /dev/null +++ b/src/Field/MapAtlas.php @@ -0,0 +1,128 @@ + + * @throws InvalidArgumentException When the block is not an array. + */ + public static function readBlock(ProjectMap $map): array + { + $block = $map->getMapDataField([self::KEY]) ?? []; + return is_array($block) ? $block : throw new InvalidArgumentException('atlas must be an array; it cannot be edited as rows.'); + } + + /** + * Sets the scope. Local is what a map is when it says nothing, so choosing it removes the key; a global map has + * no parent, so becoming global removes the parent. + * + * @param array $block + * @return array + */ + public static function setScope(array $block, string $scope): array + { + if (!in_array($scope, MapAtlasDefinition::SCOPES, true)) { + throw new InvalidArgumentException('Choose a listed atlas scope.'); + } + if ($scope === self::LOCAL) { unset($block['scope']); return $block; } + unset($block['parent']); + return [...$block, 'scope' => $scope]; + } + + /** + * Sets or clears the parent entry: a stable landmark on the parent map. + * + * @param array $block + * @return array + */ + public static function setParent(array $block, ?string $mapId, ?string $landmarkId): array + { + if ($mapId === null || $landmarkId === null) { unset($block['parent']); return $block; } + return [...$block, 'parent' => ['map' => $mapId, 'landmark' => $landmarkId]]; + } + + /** A parent choice's value: the map id and the landmark id, which neither may contain. */ + public static function encodeParent(string $mapId, string $landmarkId): string + { + return $mapId . '#' . $landmarkId; + } + + /** @return array{0: string, 1: string}|null */ + public static function decodeParent(string $value): ?array + { + if ($value === '') { return null; } + $parts = explode('#', $value, 2); + return count($parts) === 2 && $parts[0] !== '' && $parts[1] !== '' ? $parts + : throw new InvalidArgumentException('Choose a listed parent entry.'); + } + + /** + * Checks a block through the Engine as the map would stand with it, and returns where the chain reaches the + * global map: its id and the entry on it, null entry for a global map itself, or null when no global map is + * above. + * + * @param array $block + * @param Closure(string): ?ProjectMap $findMap Finds a project map by id, unsaved edits included. + * @return array{map: string, landmark: ?string}|null + * @throws InvalidArgumentException With the Engine's reason when the atlas is invalid. + */ + public static function resolveGlobal(ProjectMap $map, array $block, Closure $findMap, ?MapCategoryCatalog $categories = null): ?array + { + $resolve = static function (string $mapId) use ($map, $block, $findMap): array { + $other = $mapId === $map->mapId ? $map : ($findMap($mapId) ?? throw new InvalidArgumentException("Atlas parent map '{$mapId}' does not exist.")); + $source = MapLandmarks::readResolvedSource($other); + if ($other === $map) { + $source['data'][self::KEY] = $block; + if ($block === []) { unset($source['data'][self::KEY]); } + } + return $source; + }; + MapAtlasDefinition::readMap($block === [] ? [] : [self::KEY => $block]); + return MapAtlasDefinition::resolveGlobal($map->mapId, $resolve, $categories); + } + + /** + * Where maps name a map as their atlas parent, or a landmark on it as their entry: what keeps that landmark from + * being removed, and what a move or delete of the map leaves behind. + * + * @param iterable $maps + * @return list + */ + public static function describeParentReferences(iterable $maps, string $mapId, ?string $landmarkId = null): array + { + $found = []; + foreach ($maps as $map) { + $parent = $map->getMapDataField([self::KEY, 'parent']); + if (!is_array($parent) || ($parent['map'] ?? null) !== $mapId) { continue; } + if ($landmarkId !== null && ($parent['landmark'] ?? null) !== $landmarkId) { continue; } + $found[] = sprintf('the atlas parent of %s (entry %s)', $map->mapId, is_string($parent['landmark'] ?? null) ? $parent['landmark'] : '?'); + } + return $found; + } +} diff --git a/src/Field/MapCategoryFields.php b/src/Field/MapCategoryFields.php new file mode 100644 index 0000000..6d170cb --- /dev/null +++ b/src/Field/MapCategoryFields.php @@ -0,0 +1,111 @@ + MapCategoryCatalog::SCHEMA]), + fieldsFor: self::getFields(...), + saveCheck: self::checkCatalog(...), + prepareEdit: self::checkEdit(...), + requireUnchangedSource: true, + ); + } + + /** + * A new category, which saves at once as the Engine admits it: its id is its name's slug, its name takes the + * Engine's translation key, and it starts as a kind of the Engine's first generic category for the author to + * change, as a new text role starts on the project's first font. + */ + public static function makeBlank(string $name, string $projectRoot): array + { + $slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($name)), '-'); + $id = preg_match('/^[a-z]/', $slug) === 1 ? $slug : 'place-' . $slug; + if (preg_match(MapLandmarkCatalog::ID_PATTERN, $id) !== 1 || MapLandmarkCategory::tryFrom($id) !== null) { + throw new RecordRefusal(sprintf('"%s" cannot name a place category: its id would be %s, which is not a new stable id.', $name, $id)); + } + + return ['id' => $id, 'name' => ['message' => MapCategoryCatalog::getNameMessageKey($id), 'default' => trim($name)], + 'base' => MapLandmarkCategory::cases()[0]->value]; + } + + /** @return list */ + public static function getFields(array $entry): array + { + return [ + // Landmarks name the category by its id: it changes only while none does. + new RecordField('id', 'Stable Id'), + new RecordField('name.default', 'Name'), + new RecordField('name.message', 'Name Translation Key'), + new RecordField('base', 'Specializes', options: array_column(MapLandmarkCategory::cases(), 'value')), + ]; + } + + /** + * Refuses an id the Engine would not accept (a generic category's, or not a stable id) as it is typed, and keeps + * a name translation key that followed the old id following the new one; a key an author chose stays. + */ + public static function checkEdit(array $entry, string $field): array + { + if ($field !== 'id') { return $entry; } + $id = $entry['id'] ?? null; + if (!is_string($id) || preg_match(MapLandmarkCatalog::ID_PATTERN, $id) !== 1 || MapLandmarkCategory::tryFrom($id) !== null) { + throw new RecordRefusal('A place category id is a new stable lowercase id of 1 to 64 characters, not inn, shop, home or organization.'); + } + // A key the Engine derived from an id, read from its own rule, follows the id; any other key stays. + $probe = 'probe'; + $derived = '/\\A' . str_replace($probe, '[a-z][a-z0-9._-]*', preg_quote(MapCategoryCatalog::getNameMessageKey($probe), '/')) . '\\z/'; + $message = $entry['name']['message'] ?? null; + if (is_string($message) && preg_match($derived, $message) === 1) { + $entry['name']['message'] = MapCategoryCatalog::getNameMessageKey($id); + } + + return $entry; + } + + /** Why the Engine would not read the catalogue as a save would write it, or null when it would. */ + public static function checkCatalog(array $whole, string $projectRoot): ?string + { + try { + MapCategoryCatalog::fromArray($whole); + } catch (Throwable $error) { + return $error->getMessage(); + } + + return null; + } +} diff --git a/src/Field/MapLandmarks.php b/src/Field/MapLandmarks.php index 52d6608..5bf0313 100644 --- a/src/Field/MapLandmarks.php +++ b/src/Field/MapLandmarks.php @@ -7,6 +7,7 @@ use Ichiloto\Editor\Events\EventTypeCatalog; use Ichiloto\Editor\ProjectMap; use Ichiloto\Engine\Events\Triggers\TransferPlayerTrigger; +use Ichiloto\Engine\Field\MapCategoryCatalog; use Ichiloto\Engine\Field\MapLandmarkAnchors; use Ichiloto\Engine\Field\MapLandmarkCatalog; use Ichiloto\Engine\Field\MapLandmarkKind; @@ -39,13 +40,14 @@ public static function readMap(ProjectMap $map): self } /** - * What the map's landmarks may anchor to, read by the Engine's own adapter from the map as it stands: placed - * events (an event without cells has nothing to mark), world objects at their anchor cell, and legacy - * transfers. Only a legacy transfer with a stable `id` can be chosen as an anchor. + * The map as the Engine's map readers take it, unsaved edits included: its data with each placed event + * resolved to its marker and cells (an event without cells has nothing to mark), and its composed logical cell + * grid. Landmark anchors and atlas parents are both read from this one view. * + * @return array{data: array, tiles: array>} * @throws InvalidArgumentException When world objects cannot be read. */ - public static function readAnchors(ProjectMap $map): MapLandmarkAnchors + public static function readResolvedSource(ProjectMap $map): array { $events = []; foreach ($map->getEventDefinitions() as $marker => $definition) { @@ -55,8 +57,20 @@ public static function readAnchors(ProjectMap $map): MapLandmarkAnchors } $triggers = $map->getEditableData()['triggers'] ?? []; - return MapLandmarkAnchors::readMap(['events' => $events, 'worldObjects' => WorldObjectAuthoring::readEntries($map), - 'triggers' => is_array($triggers) ? $triggers : []]); + return ['data' => [...$map->getEditableData(), 'id' => $map->mapId, 'events' => $events, + 'worldObjects' => WorldObjectAuthoring::readEntries($map), 'triggers' => is_array($triggers) ? $triggers : []], + 'tiles' => $map->getLayerSet()->getComposedGrid()]; + } + + /** + * What the map's landmarks may anchor to, read by the Engine's own adapter from the map as it stands. Only a + * legacy transfer with a stable `id` can be chosen as an anchor. + * + * @throws InvalidArgumentException When world objects cannot be read. + */ + public static function readAnchors(ProjectMap $map): MapLandmarkAnchors + { + return MapLandmarkAnchors::readMap(self::readResolvedSource($map)['data']); } /** @return list The number of real cells on each row, so ragged maps keep their shape. */ @@ -69,11 +83,12 @@ public static function readRowWidths(ProjectMap $map): array * Checks declarations against the map as it stands, through the Engine's validator. * * @param list> $entries + * @param MapCategoryCatalog|null $categories The project's place categories; null accepts the Engine's generic ones only. * @throws InvalidArgumentException With the Engine's reason when the map would not load them. */ - public static function validateEntries(ProjectMap $map, array $entries): MapLandmarkCatalog + public static function validateEntries(ProjectMap $map, array $entries, ?MapCategoryCatalog $categories = null): MapLandmarkCatalog { - return MapLandmarkCatalog::readDeclarations($entries, self::readAnchors($map), self::readRowWidths($map)); + return MapLandmarkCatalog::readDeclarations($entries, self::readAnchors($map), self::readRowWidths($map), $categories); } /** Whether an event definition moves the player to another map: the only kind of event a route marks. */ @@ -244,6 +259,20 @@ public function setKind(int $index, MapLandmarkKind $kind): array return $entries; } + /** + * Says what a place offers (an inn, a shop, the project's own kinds), or removes the category when none is + * chosen. A category is independent of the kind: an entrance into a shop is a route with the shop category. + * + * @return list> + */ + public function setCategory(int $index, ?string $category): array + { + $entries = $this->entries; + if ($category === null || $category === '') { unset($entries[$index]['category']); } + else { $entries[$index]['category'] = $category; } + return $entries; + } + /** * Names a landmark, or removes its name when the text is blank. A first name takes the Engine's map-scoped * translation key; a name already keyed keeps its key. @@ -360,6 +389,31 @@ public static function describeAnchorReferences(ProjectMap $map, array $anchor): $landmarks->entries[$index]['kind'] ?? 'no kind')]; } + /** + * Where landmarks name a place category: what keeps the category from being deleted. + * + * @param iterable $maps + * @return list + */ + public static function describeCategoryReferences(iterable $maps, string $category): array + { + $found = []; + foreach ($maps as $map) { + try { + $entries = self::readMap($map)->entries; + } catch (InvalidArgumentException $error) { + $found[] = sprintf('%s landmarks: %s', $map->mapId, $error->getMessage()); + continue; + } + foreach ($entries as $entry) { + if (($entry['category'] ?? null) === $category) { + $found[] = sprintf('landmark %s on %s', $entry['id'] ?? '?', $map->mapId); + } + } + } + return $found; + } + /** * The landmark an anchor carries, when one does and its block can be read. * diff --git a/src/Inspector/MapAtlasFields.php b/src/Inspector/MapAtlasFields.php new file mode 100644 index 0000000..e9de67d --- /dev/null +++ b/src/Inspector/MapAtlasFields.php @@ -0,0 +1,120 @@ +> */ + public static function describeMapFields(ProjectMap $map, ReferenceCatalog $references): array + { + try { + $block = MapAtlas::readBlock($map); + } catch (InvalidArgumentException $error) { + return [['label' => 'Atlas', 'value' => '', 'editable' => false], + ['label' => ' ! Read-only', 'value' => $error->getMessage(), 'editable' => false]]; + } + $scope = is_string($block['scope'] ?? null) ? $block['scope'] : MapAtlas::LOCAL; + $choices = [['label' => 'Local', 'value' => MapAtlas::LOCAL, 'description' => 'A place inside the world: a town, a house, a dungeon.'], + ['label' => 'Global', 'value' => MapAtlas::GLOBAL, 'description' => 'The overworld geography itself; it has no parent.']]; + $fields = [ + ['label' => 'Atlas', 'value' => '', 'editable' => false], + // A short enum: picked or stepped through. + ['label' => ' Scope', 'value' => $scope, 'editable' => true, 'choices' => $choices, + 'options' => array_column($choices, 'value'), 'target' => self::TARGET, 'field' => 'scope'], + ]; + if ($scope !== MapAtlas::GLOBAL) { + $parent = is_array($block['parent'] ?? null) ? $block['parent'] : null; + $current = $parent === null ? '' : MapAtlas::encodeParent((string) ($parent['map'] ?? ''), (string) ($parent['landmark'] ?? '')); + $fields[] = ['label' => ' Parent Entry', 'value' => $current, 'editable' => true, 'target' => self::TARGET, 'field' => 'parent', + 'choices' => self::describeParentChoices($map, $references, $current)]; + } + $fields[] = ['label' => ' Global Map', 'value' => self::describeGlobal($map, $block, $references), 'editable' => false]; + return $fields; + } + + /** + * The atlas block an edit to one of these rows leaves, or null when it changes nothing. + * + * @param array $field + * @return array|null + */ + public static function applyValue(ProjectMap $map, array $field, mixed $value): ?array + { + $block = MapAtlas::readBlock($map); + $next = match ((string) ($field['field'] ?? '')) { + 'scope' => MapAtlas::setScope($block, (string) $value), + 'parent' => MapAtlas::setParent($block, ...(MapAtlas::decodeParent((string) $value) ?? [null, null])), + default => $block, + }; + return $next === $block ? null : $next; + } + + /** + * Every declared landmark on every other map, those whose transfer leads to this map first: an entrance here is + * usually the parent entry. A landmark the map names that no longer exists stays listed, said so. + * + * @return list + */ + private static function describeParentChoices(ProjectMap $map, ReferenceCatalog $references, string $current): array + { + $leading = $others = []; + foreach ($references->listMaps() as $other) { + if ($other->mapId === $map->mapId) { continue; } + try { + $entries = MapLandmarks::readMap($other)->entries; + } catch (InvalidArgumentException) { + continue; + } + foreach ($entries as $entry) { + $id = $entry['id'] ?? null; + if (!is_string($id) || preg_match(MapLandmarkCatalog::ID_PATTERN, $id) !== 1) { continue; } + $marker = $entry['anchor']['event'] ?? null; + $leads = $marker !== null + && (($other->getEventDefinition((string) $marker)['data'] ?? [])['destinationMap'] ?? null) === $map->mapId; + $choice = ['label' => sprintf('%s · %s', $other->getDisplayName(), is_string($entry['name']['default'] ?? null) ? $entry['name']['default'] : $id), + 'value' => MapAtlas::encodeParent($other->mapId, $id), + 'description' => sprintf('%s, landmark %s%s', $other->mapId, $id, $leads ? '; its door leads here' : '')]; + $leads ? $leading[] = $choice : $others[] = $choice; + } + } + $choices = [['label' => '(none)', 'value' => '', 'description' => 'No map is above this one.'], ...$leading, ...$others]; + if ($current !== '' && !in_array($current, array_column($choices, 'value'), true)) { + $choices[] = ['label' => $current . ' · no longer declared', 'value' => $current, 'description' => 'Choose an existing entry or none.']; + } + return $choices; + } + + /** Where the chain reaches the global map, as the Engine resolves it, or why it cannot. */ + private static function describeGlobal(ProjectMap $map, array $block, ReferenceCatalog $references): string + { + try { + $global = MapAtlas::resolveGlobal($map, $block, $references->findMap(...), $references->getMapCategoryCatalog()); + } catch (Throwable $error) { + return '! ' . $error->getMessage(); + } + return match (true) { + $global === null => '(none above · set a parent entry)', + $global['landmark'] === null => '(this map)', + default => sprintf('%s at %s', $global['map'], $global['landmark']), + }; + } +} diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index acb91e3..f078f92 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -19,6 +19,7 @@ use Ichiloto\Editor\Events\EventTypeDefinition; use Ichiloto\Editor\Field\MapBgmVariants; use Ichiloto\Editor\Field\MapEncounters; +use Ichiloto\Editor\Field\MapAtlas; use Ichiloto\Editor\Field\MapLandmarks; use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\GenericCommand; @@ -167,7 +168,8 @@ public function getMapFields(ProjectMap $map): array 'editable' => false, ], ...$this->mapRuntimeFields($map), - ...MapLandmarkFields::describeMapFields($map), + ...MapAtlasFields::describeMapFields($map, $this->references), + ...$this->getLandmarkFields()->describeMapFields($map), ]; } @@ -248,7 +250,7 @@ public function getEventFields(ProjectMap $map, string $marker): array $fields = \Ichiloto\Editor\Maps\MapPlacement::describeEventFields($definition, $fields); } - return [...$fields, ...MapLandmarkFields::describeEventFields($map, $marker)]; + return [...$fields, ...$this->getLandmarkFields()->describeEventFields($map, $marker)]; } /** @@ -310,6 +312,7 @@ public function apply(ProjectMap $map, array $field, string $rawValue): ?Command 'event-type' => $this->applyEventType($map, (string) $field['marker'], $rawValue), 'event-bounds' => $this->applyEventBounds($map, (string) $field['marker'], (string) $field['field'], (int) $value), MapLandmarkFields::TARGET, MapLandmarkFields::EVENT_TARGET, MapLandmarkFields::OBJECT_TARGET => $this->applyLandmarkValue($map, $field, $value), + MapAtlasFields::TARGET => $this->applyAtlasValue($map, $field, $value), default => null, }; } @@ -324,7 +327,7 @@ public function apply(ProjectMap $map, array $field, string $rawValue): ?Command private function applyLandmarkValue(ProjectMap $map, array $field, mixed $value): ?Command { try { - $entries = MapLandmarkFields::applyValue($map, $field, $value); + $entries = $this->getLandmarkFields()->applyValue($map, $field, $value); } catch (InvalidArgumentException $error) { throw new InspectorRefusal($error->getMessage(), previous: $error); } @@ -332,6 +335,32 @@ private function applyLandmarkValue(ProjectMap $map, array $field, mixed $value) return $entries === null ? null : $this->writeLandmarks($map, $entries, trim((string) ($field['label'] ?? 'Landmark'))); } + /** The landmark rows, over the project's place categories and maps as this inspector reads them. */ + private function getLandmarkFields(): MapLandmarkFields + { + return new MapLandmarkFields($this->references); + } + + /** + * Applies one atlas row's edit: the block it leaves is checked by the Engine through the parent chain, as the + * project's maps stand, then written as one undo step; an empty block removes the key. + * + * @param array $field + * @throws InspectorRefusal When the edit cannot be made or the atlas would not resolve. + */ + private function applyAtlasValue(ProjectMap $map, array $field, mixed $value): ?Command + { + try { + $block = MapAtlasFields::applyValue($map, $field, $value); + if ($block === null) { return null; } + MapAtlas::resolveGlobal($map, $block, $this->references->findMap(...), $this->references->getMapCategoryCatalog()); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + + return $this->writeMapData($map, [MapAtlas::KEY], $block === [] ? null : $block, 'Atlas ' . strtolower(trim((string) ($field['label'] ?? 'edit')))); + } + /** * Writes a map's landmark declarations once the Engine accepts them; none removes the key. * @@ -340,7 +369,7 @@ private function applyLandmarkValue(ProjectMap $map, array $field, mixed $value) */ private function writeLandmarks(ProjectMap $map, array $entries, string $label): ?Command { - $problem = MapLandmarkFields::findProblem($map, $entries); + $problem = $this->getLandmarkFields()->findCurrentProblem($map, $entries); if ($problem !== null) { throw new InspectorRefusal($problem); } @@ -454,7 +483,7 @@ public function addListEntry(ProjectMap $map, array $field): ?InspectorListEdit } if (($field['target'] ?? null) === MapLandmarkFields::TARGET && is_array($field['landmarkList'] ?? null)) { - return $this->editLandmarkList($map, static fn(): array => MapLandmarkFields::addEntry($map), 'add'); + return $this->editLandmarkList($map, fn(): array => $this->getLandmarkFields()->addEntry($map), 'add'); } if (($field['target'] ?? null) === 'map-bgm-variants' && is_array($field['bgmVariantList'] ?? null)) { @@ -508,7 +537,7 @@ public function removeListEntry(ProjectMap $map, array $field): ?InspectorListEd } if (($field['target'] ?? null) === MapLandmarkFields::TARGET && is_array($field['landmarkList'] ?? null)) { - return $this->editLandmarkList($map, static fn(): array => MapLandmarkFields::removeEntry($map, $field), 'remove'); + return $this->editLandmarkList($map, fn(): array => $this->getLandmarkFields()->removeEntry($map, $field), 'remove'); } if (($field['target'] ?? null) === 'map-bgm-variants' && is_array($field['bgmVariantList'] ?? null)) { diff --git a/src/Inspector/MapLandmarkFields.php b/src/Inspector/MapLandmarkFields.php index 7952c2c..e14d6ed 100644 --- a/src/Inspector/MapLandmarkFields.php +++ b/src/Inspector/MapLandmarkFields.php @@ -5,8 +5,11 @@ namespace Ichiloto\Editor\Inspector; use Ichiloto\Editor\Database\ConditionCodec; +use Ichiloto\Editor\Database\ReferenceCatalog; +use Ichiloto\Editor\Field\MapAtlas; use Ichiloto\Editor\Field\MapLandmarks; use Ichiloto\Editor\ProjectMap; +use Ichiloto\Engine\Field\MapCategoryCatalog; use Ichiloto\Engine\Field\MapLandmarkCatalog; use Ichiloto\Engine\Field\MapLandmarkKind; use InvalidArgumentException; @@ -14,10 +17,11 @@ /** * The inspector rows that author a map's minimap landmarks, the same rows in the Terminal and in a graphical * editor: a list of declarations on the map, and on each placed event the one choice of what it is on the minimap. - * Every anchor and kind is chosen from what the map offers, never typed. Edits return the declarations they leave; - * the inspector checks them through the Engine and records them as one undo step. + * Every anchor, kind and category is chosen from what the map and project offer, never typed. Edits return the + * declarations they leave; the inspector checks them through the Engine and records them as one undo step. The + * project's place categories and its other maps come from the reference catalogue the rows are built over. */ -final class MapLandmarkFields +final readonly class MapLandmarkFields { /** Rows of the map's landmark list. */ public const string TARGET = 'map-landmarks'; @@ -28,10 +32,10 @@ final class MapLandmarkFields /** The event row's value when no declaration describes the event. */ public const string UNDESCRIBED = 'undescribed'; - private function __construct() {} + public function __construct(private ReferenceCatalog $references) {} /** @return list> */ - public static function describeMapFields(ProjectMap $map): array + public function describeMapFields(ProjectMap $map): array { try { $landmarks = MapLandmarks::readMap($map); @@ -50,12 +54,12 @@ public static function describeMapFields(ProjectMap $map): array ['label' => sprintf(' Landmarks · %d', $count), 'value' => '', 'editable' => false, 'target' => self::TARGET, 'landmarkList' => ['index' => max(0, $count - 1)]], ]; - $problem = self::findProblem($map, $landmarks->entries); + $problem = $this->findCurrentProblem($map, $landmarks->entries); if ($problem !== null) { $fields[] = ['label' => ' ! Problem', 'value' => $problem, 'editable' => false]; } foreach ($landmarks->entries as $index => $entry) { - array_push($fields, ...self::describeEntryFields($landmarks, $anchors, $index, $entry)); + array_push($fields, ...$this->describeEntryFields($landmarks, $anchors, $index, $entry)); } return $fields; } @@ -67,9 +71,9 @@ public static function describeMapFields(ProjectMap $map): array * * @return list> */ - public static function describeEventFields(ProjectMap $map, string $marker): array + public function describeEventFields(ProjectMap $map, string $marker): array { - return $map->getEventArea($marker) === null ? [] : self::describeAnchorFields($map, ['event' => $marker]); + return $map->getEventArea($marker) === null ? [] : $this->describeAnchorFields($map, ['event' => $marker]); } /** @@ -77,16 +81,16 @@ public static function describeEventFields(ProjectMap $map, string $marker): arr * * @return list> */ - public static function describeObjectFields(ProjectMap $map, string $id): array + public function describeObjectFields(ProjectMap $map, string $id): array { - return self::describeAnchorFields($map, ['object' => $id]); + return $this->describeAnchorFields($map, ['object' => $id]); } /** * @param array $anchor * @return list> */ - private static function describeAnchorFields(ProjectMap $map, array $anchor): array + private function describeAnchorFields(ProjectMap $map, array $anchor): array { $key = isset($anchor['event']) ? ['target' => self::EVENT_TARGET, 'marker' => $anchor['event']] : ['target' => self::OBJECT_TARGET, 'path' => [$anchor['object']]]; @@ -120,7 +124,7 @@ private static function describeAnchorFields(ProjectMap $map, array $anchor): ar * @return list>|null * @throws InvalidArgumentException When the edit cannot be made as asked. */ - public static function applyValue(ProjectMap $map, array $field, mixed $value): ?array + public function applyValue(ProjectMap $map, array $field, mixed $value): ?array { $landmarks = MapLandmarks::readMap($map); $anchor = match ($field['target'] ?? null) { @@ -130,7 +134,11 @@ public static function applyValue(ProjectMap $map, array $field, mixed $value): }; if ($anchor !== null) { $index = $landmarks->findAnchorIndex($anchor); - if ($value === self::UNDESCRIBED) { return $index === null ? null : $landmarks->removeLandmark($index); } + if ($value === self::UNDESCRIBED) { + if ($index === null) { return null; } + $this->assertUnreferenced($map, $landmarks->entries[$index]); + return $landmarks->removeLandmark($index); + } $kind = self::requireKind($landmarks, $anchor, (string) $value); return $index === null ? $landmarks->addLandmark($anchor, $kind)['entries'] : $landmarks->setKind($index, $kind); } @@ -141,6 +149,7 @@ public static function applyValue(ProjectMap $map, array $field, mixed $value): 'anchor' => $landmarks->setAnchor($index, MapLandmarks::decodeAnchor((string) $value)), 'kind' => $landmarks->setKind($index, self::requireKind($landmarks, $anchor ?? throw new InvalidArgumentException('Choose this landmark\'s anchor first.'), (string) $value)), + 'category' => $landmarks->setCategory($index, $this->requireCategory((string) $value)), 'name' => $landmarks->setName($index, (string) $value), 'nameMessage' => $landmarks->setNameMessage($index, (string) $value), 'secret' => $landmarks->setSecret($index, (bool) $value), @@ -159,7 +168,7 @@ public static function applyValue(ProjectMap $map, array $field, mixed $value): * * @return array{entries: list>, summary: string} */ - public static function addEntry(ProjectMap $map): array + public function addEntry(ProjectMap $map): array { $landmarks = MapLandmarks::readMap($map); $anchors = $landmarks->describeAnchors(); @@ -175,11 +184,12 @@ public static function addEntry(ProjectMap $map): array * @param array $field * @return array{entries: list>, summary: string} */ - public static function removeEntry(ProjectMap $map, array $field): array + public function removeEntry(ProjectMap $map, array $field): array { $landmarks = MapLandmarks::readMap($map); $index = (int) ($field['landmarkList']['index'] ?? -1); $entry = $landmarks->entries[$index] ?? throw new InvalidArgumentException('There is no landmark here to remove.'); + $this->assertUnreferenced($map, $entry); $anchor = MapLandmarks::readAnchor($entry); $route = $anchor !== null && $landmarks->isRouteAnchor($anchor); return ['entries' => $landmarks->removeLandmark($index), 'summary' => sprintf('Removed landmark %s%s.', $entry['id'] ?? $index + 1, @@ -187,25 +197,91 @@ public static function removeEntry(ProjectMap $map, array $field): array } /** - * Why the map's declarations would not load, by the Engine's own check, or null when they would. + * Why the map's declarations would not load, by the Engine's own check against these place categories, or null + * when they would. * * @param list> $entries */ - public static function findProblem(ProjectMap $map, array $entries): ?string + public static function findProblem(ProjectMap $map, array $entries, ?MapCategoryCatalog $categories = null): ?string { try { - MapLandmarks::validateEntries($map, $entries); + MapLandmarks::validateEntries($map, $entries, $categories); return null; } catch (InvalidArgumentException $error) { return $error->getMessage(); } } + /** + * Why the declarations would not load beside the project's place categories as they stand, or null. + * + * @param list> $entries + */ + public function findCurrentProblem(ProjectMap $map, array $entries): ?string + { + try { + $categories = $this->references->getMapCategoryCatalog(); + } catch (InvalidArgumentException $error) { + return 'Place categories cannot be read: ' . $error->getMessage(); + } + return self::findProblem($map, $entries, $categories); + } + + /** + * Refuses to remove a landmark another map names as its atlas entry: that map would lose its place above. + * + * @param array $entry + */ + private function assertUnreferenced(ProjectMap $map, array $entry): void + { + $id = $entry['id'] ?? null; + if (!is_string($id)) { return; } + $references = MapAtlas::describeParentReferences($this->references->listMaps(), $map->mapId, $id); + if ($references !== []) { + throw new InvalidArgumentException(sprintf('Landmark %s is %s; choose another entry there first.', $id, implode(', ', $references))); + } + } + + /** A chosen place category: none, or one the Engine's or the project's catalogue declares. */ + private function requireCategory(string $value): ?string + { + if ($value === '') { return null; } + if (!in_array($value, $this->references->getMapCategoryCatalog()->getIds(), true)) { + throw new InvalidArgumentException(sprintf('%s is not a place category this project declares.', $value)); + } + return $value; + } + + /** + * The place categories a landmark may have, generic ones first, each by its name; a category the landmark names + * that no longer exists stays listed, said so. + * + * @return list + */ + private function describeCategoryChoices(string $current): array + { + $choices = [['label' => '(none)', 'value' => '', 'description' => 'The marker follows the kind.']]; + try { + $catalog = $this->references->getMapCategoryCatalog(); + } catch (InvalidArgumentException $error) { + return [...$choices, ...($current === '' ? [] : [['label' => $current, 'value' => $current, 'description' => $error->getMessage()]])]; + } + foreach ($catalog->getIds() as $id) { + $base = $catalog->getBase($id); + $choices[] = ['label' => $catalog->getName($id), 'value' => $id, 'description' => $base->value === $id + ? 'A place category every game has.' : sprintf('This project\'s own category; shown as %s where it has no marker of its own.', $catalog->getName($base->value))]; + } + if ($current !== '' && !in_array($current, array_column($choices, 'value'), true)) { + $choices[] = ['label' => $current . ' · not declared', 'value' => $current, 'description' => 'Choose a declared category or none.']; + } + return $choices; + } + /** * @param list, value: string, label: string, transfer: bool, landmark: ?string}> $anchors * @return list> */ - private static function describeEntryFields(MapLandmarks $landmarks, array $anchors, int $index, array $entry): array + private function describeEntryFields(MapLandmarks $landmarks, array $anchors, int $index, array $entry): array { $row = static fn(array $field): array => [...$field, 'target' => self::TARGET, 'index' => $index, 'landmarkList' => ['index' => $index]]; $id = is_string($entry['id'] ?? null) ? $entry['id'] : sprintf('(entry %d has no id)', $index + 1); @@ -226,6 +302,8 @@ private static function describeEntryFields(MapLandmarks $landmarks, array $anch 'choices' => array_map(static fn(MapLandmarkKind $kind): array => ['label' => self::describeKind($kind), 'value' => $kind->value, 'description' => self::explainKind($kind)], $kinds), 'options' => array_map(static fn(MapLandmarkKind $kind): string => $kind->value, $kinds)]), + $row(['label' => ' Category', 'value' => is_string($entry['category'] ?? null) ? $entry['category'] : '', 'editable' => true, + 'field' => 'category', 'choices' => $this->describeCategoryChoices(is_string($entry['category'] ?? null) ? $entry['category'] : '')]), $row(['label' => ' Name', 'value' => $named ? (string) ($entry['name']['default'] ?? '') : '(unnamed · the minimap shows no label)', 'control' => new InputControl(InputControlType::TEXT, $named ? (string) ($entry['name']['default'] ?? '') : ''), 'field' => 'name']), ]; diff --git a/src/Maps/MapReferences.php b/src/Maps/MapReferences.php index 4dad57d..0365d06 100644 --- a/src/Maps/MapReferences.php +++ b/src/Maps/MapReferences.php @@ -5,12 +5,14 @@ namespace Ichiloto\Editor\Maps; use Ichiloto\Editor\Cutscenes\CutsceneType; +use Ichiloto\Editor\Field\MapAtlas; use Ichiloto\Editor\ProjectWorkspace; /** * Finding what sends the player to a map by its id: the game's starting - * position, transfer events on every map (unsaved edits included), and - * cinematic `transfer` commands at any depth. Knowing them lets an editor + * position, transfer events on every map (unsaved edits included), maps + * naming it as their atlas parent, and cinematic `transfer` commands at any + * depth. Knowing them lets an editor * warn before deleting a map something still expects; the project validator * reports any that remain dangling. */ @@ -43,6 +45,8 @@ public function describe(string $mapId): array } } } + // A local map that names this one as its atlas parent loses its place above. + array_push($found, ...MapAtlas::describeParentReferences($this->workspace->maps, $mapId)); foreach (CutsceneType::cases() as $type) { foreach ($this->workspace->cutscenes?->assets($type) ?? [] as $asset) { if (self::hasTransferTo($asset->commands(), $mapId)) { diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index ece0203..731ed78 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -61,6 +61,10 @@ public function __construct( $this->getRecordDatabase('quests')?->useIdentityReferences( fn(string $questId): bool => new QuestReferences($this)->exist($questId), ); + // A place category stays while a landmark names it. + $this->getRecordDatabase(\Ichiloto\Editor\Field\MapCategoryFields::CATEGORY)?->useDeletionReferences( + fn(string $category): array => \Ichiloto\Editor\Field\MapLandmarks::describeCategoryReferences($this->maps, $category), + ); $references = new ReferenceCatalog($this); foreach ($this->recordDatabases as $database) { $database->useAuthoringReferences($references); } $this->cutscenes?->useAuthoringReferences($references); diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 237dc66..6133bc9 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -4108,7 +4108,7 @@ public function readWorldObject(string $mapId, string $id): array 'issue' => $issue, 'rows' => [...array_map(static fn($field): array => self::describeRecordRow($records, $index, [], $field, 'world-object'), $fields), // What the object is on the minimap: a map landmark row, applied through the map's inspector. - ...array_map(self::describeRow(...), MapLandmarkFields::describeObjectFields($map, $id))]]; + ...array_map(self::describeRow(...), new MapLandmarkFields(new ReferenceCatalog($this->workspace, $map))->describeObjectFields($map, $id))]]; } public function createWorldObject(string $mapId, int $revision, string $id, int $x, int $y, ?string $resource = null, ?string $text = null): array @@ -4205,7 +4205,7 @@ private function applyWorldObjectLandmark(string $mapId, int $revision, string $ { $map = $this->requireCurrentMap($mapId, $revision); ksort($key); - $field = array_find(MapLandmarkFields::describeObjectFields($map, $id), static function (array $candidate) use ($key): bool { + $field = array_find(new MapLandmarkFields(new ReferenceCatalog($this->workspace, $map))->describeObjectFields($map, $id), static function (array $candidate) use ($key): bool { $candidateKey = MapInspector::describeRowKey($candidate); if ($candidateKey !== null) { ksort($candidateKey); } return $candidateKey === $key; diff --git a/src/Validation/MapValidator.php b/src/Validation/MapValidator.php index dd2e68b..3ac1e49 100644 --- a/src/Validation/MapValidator.php +++ b/src/Validation/MapValidator.php @@ -4,9 +4,11 @@ namespace Ichiloto\Editor\Validation; +use Ichiloto\Editor\Field\MapAtlas; use Ichiloto\Editor\Field\MapLandmarks; use Ichiloto\Editor\Inspector\MapLandmarkFields; use Ichiloto\Editor\ProjectMap; +use Ichiloto\Engine\Field\MapCategoryCatalog; use InvalidArgumentException; /** @@ -36,11 +38,29 @@ public static function validate(ProjectMap $map, array $mapsById): array ...self::findOutOfRangeSpawnPoints($map, $mapsById), ...self::findRetiredData($map), ...self::findLandmarkProblems($map), + ...self::findAtlasProblems($map, $mapsById), ...array_map(static fn(Issue $issue): string => $issue->message, MapGraphicsValidator::validate($map)), ]; } + /** + * Finds an atlas scope or parent chain the engine would refuse, by its own check over the project's maps. + * + * @param array $mapsById + * @return string[] + */ + private static function findAtlasProblems(ProjectMap $map, array $mapsById): array + { + try { + MapAtlas::resolveGlobal($map, MapAtlas::readBlock($map), static fn(string $id): ?ProjectMap => $mapsById[$id] ?? null, + MapCategoryCatalog::load($map->getAssetRoot())); + return []; + } catch (InvalidArgumentException $error) { + return ['Atlas: ' . $error->getMessage()]; + } + } + /** * Finds minimap landmarks the engine would refuse, by its own check. * @@ -49,7 +69,8 @@ public static function validate(ProjectMap $map, array $mapsById): array private static function findLandmarkProblems(ProjectMap $map): array { try { - $problem = MapLandmarkFields::findProblem($map, MapLandmarks::readMap($map)->entries); + $problem = MapLandmarkFields::findProblem($map, MapLandmarks::readMap($map)->entries, + MapCategoryCatalog::load($map->getAssetRoot())); } catch (InvalidArgumentException $error) { $problem = $error->getMessage(); } diff --git a/tests/Support/MapLandmarkFixtures.php b/tests/Support/MapLandmarkFixtures.php index c9bf7b2..3e8ea6b 100644 --- a/tests/Support/MapLandmarkFixtures.php +++ b/tests/Support/MapLandmarkFixtures.php @@ -31,26 +31,47 @@ function createLandmarkProject(string $landmarks = '', bool $ragged = false): ar } /** The map inspector row whose key has these members, read fresh. */ -function findLandmarkRow(EditorSession $session, array $key, ?string $marker = null): array +function findLandmarkRow(EditorSession $session, array $key, ?string $marker = null, string $mapId = 'test-map'): array { - $rows = $session->readInspector('test-map', $marker)['rows']; + $rows = $session->readInspector($mapId, $marker)['rows']; return array_find($rows, static fn(array $row): bool => array_intersect_assoc($key, $row['key'] ?? []) === $key && array_diff_key($key, $row['key'] ?? []) === []) ?? throw new RuntimeException('Missing inspector row ' . json_encode($key)); } -function applyLandmarkRow(EditorSession $session, array $key, string $value, ?string $marker = null): array +function applyLandmarkRow(EditorSession $session, array $key, string $value, ?string $marker = null, string $mapId = 'test-map'): array { - $row = findLandmarkRow($session, $key, $marker); - return $session->applyInspector('test-map', $session->readMap('test-map')['revision'], $row['key'], $value); + $row = findLandmarkRow($session, $key, $marker, $mapId); + return $session->applyInspector($mapId, $session->readMap($mapId)['revision'], $row['key'], $value); } /** The map's declarations as the Editor holds them, unsaved edits included. */ -function readLandmarkEntries(EditorSession $session): array +function readLandmarkEntries(EditorSession $session, string $mapId = 'test-map'): array { - return Ichiloto\Editor\Field\MapLandmarks::readMap(getSessionMap($session))->entries; + return Ichiloto\Editor\Field\MapLandmarks::readMap(getSessionMap($session, $mapId))->entries; } -function getSessionMap(EditorSession $session): Ichiloto\Editor\ProjectMap +function getSessionMap(EditorSession $session, string $mapId = 'test-map'): Ichiloto\Editor\ProjectMap { - return new ReflectionMethod(EditorSession::class, 'requireMap')->invoke($session, 'test-map'); + return new ReflectionMethod(EditorSession::class, 'requireMap')->invoke($session, $mapId); +} + +/** + * The landmark project with a second, global map "world" whose transfer T leads into test-map and is declared as + * the landmark town-gate, and the session over both. + * + * @return array{string, string, EditorSession} + */ +function createAtlasProject(string $testMapAtlas = ''): array +{ + [$root, $file] = createLandmarkProject($testMapAtlas); + $world = $root . '/assets/Maps/world'; + copyDirectoryRecursively($root . '/assets/Maps/test-map', $world); + rename($world . '/test-map.data.php', $world . '/world.data.php'); + rename($world . '/test-map.event.php', $world . '/world.event.php'); + $source = str_replace(["'name' => 'Test Map'", "// Map-owned presentation stays authored.\n"], + ["'name' => 'World', 'atlas' => ['scope' => 'global'], 'landmarks' => [['id' => 'town-gate', 'kind' => 'entrance', 'anchor' => ['event' => 'T']]]", ''], + (string) file_get_contents($world . '/world.data.php')); + file_put_contents($world . '/world.data.php', $source); + + return [$root, $file, EditorSession::open($root)]; } diff --git a/tests/Unit/MapLandmarkTerminalTest.php b/tests/Unit/MapLandmarkTerminalTest.php index dc3e20f..f806d3d 100644 --- a/tests/Unit/MapLandmarkTerminalTest.php +++ b/tests/Unit/MapLandmarkTerminalTest.php @@ -100,3 +100,29 @@ function chooseLandmarkOption(Editor $editor, string $field, string $value): voi ->and($toasts->pendingCount())->toBe(0) ->and(mapDataOf($editor)['landmarks'][0])->not->toHaveKey('approachDistance'); }); + +it('places a map in the atlas from the Terminal: scope stepped, parent entry picked from a list', function () { + [$root] = createAtlasProject(); + $editor = metadataEditor($root); + $select = static function (string $field) use ($editor): void { + foreach (callEditorMethod($editor, 'getInspectorFields') as $index => $row) { + if (($row['target'] ?? null) === 'map-atlas' && ($row['field'] ?? null) === $field) { + setEditorProperty($editor, 'selectedInspectorFieldIndex', $index); + return; + } + } + throw new RuntimeException('No atlas row edits ' . $field); + }; + $select('parent'); + callEditorMethod($editor, 'activateInspectorField'); + $entries = getEditorProperty($editor, 'eventOptionDialogEntries'); + expect(array_column($entries, 'label'))->toBe(['(none)', 'World · town-gate']); + setEditorProperty($editor, 'selectedEventOptionIndex', 1); + callEditorMethod($editor, 'applySelectedEventOption'); + expect(mapDataOf($editor)['atlas'])->toBe(['parent' => ['map' => 'world', 'landmark' => 'town-gate']]) + ->and(inspectorValueOf($editor, 'Global Map'))->toBe('world at town-gate'); + + $select('scope'); + callEditorMethod($editor, 'activateInspectorField'); + expect(mapDataOf($editor)['atlas'])->toBe(['scope' => 'global']); +}); diff --git a/tests/Unit/Session/MapAtlasAuthoringTest.php b/tests/Unit/Session/MapAtlasAuthoringTest.php new file mode 100644 index 0000000..fcbff0f --- /dev/null +++ b/tests/Unit/Session/MapAtlasAuthoringTest.php @@ -0,0 +1,110 @@ + 'map-atlas', 'field' => $field], null, $mapId); +} + +it('places a local map under the global map by an entry chosen from its landmarks, as one undo step', function () { + [, $file, $session] = createAtlasProject(); + $before = file_get_contents($file); + $scope = findAtlasRow($session, 'scope'); + expect($scope['value'])->toBe('local')->and($scope['options'])->toBe(['local', 'global']); + $parent = findAtlasRow($session, 'parent'); + // The world's gate leads here, so it is offered first and said so. + expect($parent['options'])->toBe(['', 'world#town-gate']) + ->and($parent['optionLabels'][1])->toBe('World · town-gate'); + + applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'parent'], 'world#town-gate'); + expect(getSessionMap($session)->getMapDataField(['atlas']))->toBe(['parent' => ['map' => 'world', 'landmark' => 'town-gate']]) + ->and(file_get_contents($file))->toBe($before); + $global = array_find($session->readInspector('test-map')['rows'], static fn(array $row): bool => $row['label'] === 'Global Map'); + expect($global['value'])->toBe('world at town-gate'); + $session->undo(); + expect(getSessionMap($session)->hasMapDataField(['atlas']))->toBeFalse(); + $session->redo(); + $session->saveMap('test-map'); + expect(file_get_contents($file))->toContain('// Map-owned presentation stays authored.', "'atlas' =>", "'town-gate'"); + + // Becoming global drops the parent; becoming local again says nothing, as a map that never chose. + applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'scope'], 'global'); + expect(getSessionMap($session)->getMapDataField(['atlas']))->toBe(['scope' => 'global']); + applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'scope'], 'local'); + expect(getSessionMap($session)->hasMapDataField(['atlas']))->toBeFalse(); +}); + +it('refuses a parent chain the Engine would refuse, and keeps entries other maps name', function () { + [, , $session] = createAtlasProject(); + applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'parent'], 'world#town-gate'); + applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'T'], 'passage', 'T'); + applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'scope'], 'local', null, 'world'); + expect(fn() => applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'parent'], 'test-map#landmark-1', null, 'world')) + ->toThrow(SessionRefusal::class, 'cycle'); + applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'scope'], 'global', null, 'world'); + + // The world's gate is test-map's entry: it stays until test-map chooses another. + expect(fn() => applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'T'], 'undescribed', 'T', 'world')) + ->toThrow(SessionRefusal::class, 'the atlas parent of test-map (entry town-gate)'); + $heading = findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 0], null, 'world'); + expect(fn() => $session->removeInspectorListEntry('world', $session->readMap('world')['revision'], $heading['key'])) + ->toThrow(SessionRefusal::class, 'the atlas parent of test-map'); + expect($session->deleteMap('world')['references'])->toContain('the atlas parent of test-map (entry town-gate)'); + + applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'parent'], ''); + $session->removeInspectorListEntry('world', $session->readMap('world')['revision'], $heading['key']); + expect(readLandmarkEntries($session, 'world'))->toBe([]); +}); + +it('reports an atlas whose parent no longer exists', function () { + [, , $session] = createAtlasProject("'atlas' => ['parent' => ['map' => 'world', 'landmark' => 'old-gate']],"); + $map = getSessionMap($session); + $problems = MapValidator::validate($map, ['test-map' => $map, 'world' => getSessionMap($session, 'world')]); + expect($problems)->toContain('Atlas: Atlas parent landmark does not exist on its referenced map.'); + expect(findAtlasRow($session, 'parent')['optionLabels'])->toContain('world#old-gate · no longer declared'); +}); + +it('chooses a landmark category from the Engine generics and the project\'s own, kept while used', function () { + [$root, , $session] = createAtlasProject(); + applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'E'], 'building', 'E'); + $category = findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'category', 'index' => 0]); + expect($category['options'])->toBe(['', ...array_column(MapLandmarkCategory::cases(), 'value')]); + applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'category', 'index' => 0], 'inn'); + expect(readLandmarkEntries($session)[0]['category'])->toBe('inn'); + + // A project category, created on its own page and usable before it is saved. + $index = $session->createDatabaseRecord(MapCategoryFields::CATEGORY)['index']; + $setCategory = static function (string $field, string $value) use ($session, $index): void { + $row = array_find($session->readDatabaseRecord(MapCategoryFields::CATEGORY, $index)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === $field); + $session->applyDatabaseRecord(MapCategoryFields::CATEGORY, $index, $row['key'], $value); + }; + expect(fn() => $setCategory('id', 'inn'))->toThrow(SessionRefusal::class, 'not inn, shop, home or organization'); + $setCategory('id', 'bard-school'); + $setCategory('name.default', 'Bard School'); + $setCategory('base', 'organization'); + applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'category', 'index' => 0], 'bard-school'); + expect(readLandmarkEntries($session)[0]['category'])->toBe('bard-school'); + expect(fn() => $session->deleteDatabaseRecord(MapCategoryFields::CATEGORY, $index)) + ->toThrow(SessionRefusal::class, 'landmark landmark-1 on test-map'); + expect(fn() => $setCategory('id', 'music-school'))->toThrow(SessionRefusal::class, 'its id stays'); + + $session->saveDatabase(MapCategoryFields::CATEGORY); + $catalog = MapCategoryCatalog::load($root . '/assets'); + expect($catalog->getIds())->toContain('bard-school') + ->and($catalog->getBase('bard-school'))->toBe(MapLandmarkCategory::ORGANIZATION) + ->and($catalog->categories['bard-school']['name'])->toBe(['message' => MapCategoryCatalog::getNameMessageKey('bard-school'), 'default' => 'Bard School']) + ->and((require $root . '/assets/' . MapCategoryCatalog::FILE)['schema'])->toBe(MapCategoryCatalog::SCHEMA); + + applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'category', 'index' => 0], ''); + expect(readLandmarkEntries($session)[0])->not->toHaveKey('category'); + $session->deleteDatabaseRecord(MapCategoryFields::CATEGORY, $index); +}); diff --git a/tests/__snapshots__/main_shell_100x30.ansi b/tests/__snapshots__/main_shell_100x30.ansi index a2eca12..72f4fbc 100644 --- a/tests/__snapshots__/main_shell_100x30.ansi +++ b/tests/__snapshots__/main_shell_100x30.ansi @@ -1 +1 @@ -[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint L:Layer ?:Help─────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Kind: Not set ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ Minimap Landmarks ││ Unnamed Route Markers · 0 ││ Landmarks · 0 ││ > Terrain · visible │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file +[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint L:Layer ?:Help─────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Kind: Not set ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ Atlas ││ Scope: Local ││ Parent Entry: (none) ││ Global Map · (none above · │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file From 1244dfb284cd37b2c525e9e9c94f14dccf5ac436 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 00:25:49 +0200 Subject: [PATCH 295/344] docs(roadmap): record the place category marker binding gap Menu Theme authors only the atlas sizes and frame; binding a game's own map.category. marker (Last Legend's BSA) waits on icon role authoring. The Engine's generic pictogram for a category's base shows until then. --- docs/roadmap.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index fb74425..c59b6ee 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -163,10 +163,15 @@ The graphical Database's Menu Theme category edits the shared menu theme `MenuPresentationSettings`, which shares `ProjectSettingsFile` with `config.php`. It offers only the minimap atlas so far (corner sizes, margin, marker size, frame), with the Engine's defaults and validation. The palette, -metrics, row metrics and artwork, frames, icon roles (including the atlas's -`map.*` markers), cursor, portraits and notifications, and PNG portrait -selection, remain an implementation gap; each joins Menu Theme through that -owner as the Engine exposes its rules. +metrics, row metrics and artwork, frames, icon roles, cursor, portraits and +notifications, and PNG portrait selection, remain an implementation gap; each +joins Menu Theme through that owner as the Engine exposes its rules. Icon roles +include the atlas markers: `map.player`, the route and place kinds (`map.*`), +and a dedicated marker per place category, `map.category.`, for the +Engine's generic categories and each project category on the Place Categories +page (Last Legend's `bsa`). Until a game binds one, the Engine's shared generic +pictogram for the category's base is shown; binding a game's own marker +requires this icon authoring. Acceptance criteria for the presentation-authoring slice: From 2660f9d05707a69ecc83a6ba177c3c044cf71579 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 00:46:08 +0200 Subject: [PATCH 296/344] feat(database): bind menu theme icons, atlas markers by role included Menu Theme now binds icon images by role (menus.php 'icons'): every role the catalogue binds and every atlas marker role the Engine draws, read from MapAtlasPresentation::listIconRoles (Engine bf03419), so map.player, each landmark kind and map.category. for generic and project categories, including Place Categories not yet saved. Each is a project image from the shared PNG picker, validated by MenuIconRegistry and the PNG preflight; none leaves the Engine's pictogram and removes an existing binding. Shared mechanisms extended rather than special-cased: - ProjectSettingsFile lets an owner say where keys begin (splitPath), so roles holding dots are addressed exactly, and opt into removing a key when its value is chosen away (isRemovedWhenEmpty); config.php is unchanged. - A settings file's Database category lists what its owner offers now (ProjectRecordDatabase::syncSettingRecords), keeping each setting's one record, so roles for categories created while open appear at once. - MenuPresentationSettings reads unsaved place categories through the workspace (useCategoryCatalog). The roadmap's presentation gap now names only the parts still missing. --- docs/manual.md | 13 +++- docs/roadmap.md | 20 +++-- src/Database/DatabaseCatalog.php | 2 +- src/Database/MenuThemeFields.php | 13 +++- src/Database/ProjectRecordDatabase.php | 14 ++++ src/MenuPresentationSettings.php | 83 ++++++++++++++++++++- src/ProjectConfig.php | 2 +- src/ProjectSettingsFile.php | 55 ++++++++++---- src/ProjectWorkspace.php | 2 + tests/Unit/Session/MenuThemeSessionTest.php | 46 +++++++++++- 10 files changed, 210 insertions(+), 40 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 6acc6ea..9efb3cc 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1212,7 +1212,7 @@ skill's effects, a quest's rewards) listed under them. | Player Field Appearance | `assets/Data/Entities/player.php` | Not shown: graphical ownership and art | Choose fixed-player, selected party leader, or absent legacy selector; fixed sheet picker with index/layer controls | | Field Resources | `assets/Data/Presentation/field.php` (`resources`) | Not shown: graphical resources | One record per named resource, keyed by its stable id: name, ground contact, an image or a sign board, and an optional occupancy stamp | | Text Roles | `assets/Data/Presentation/text.php` (`roles`) | Not shown: graphical text | One record per text role: the project's fonts, first choice first, a size and a colour | -| Menu Theme | `assets/Data/Presentation/menus.php` (`atlas`) | Not shown: graphical presentation | One row per setting: the minimap atlas's corner sizes, margin, marker size and frame | +| Menu Theme | `assets/Data/Presentation/menus.php` (`atlas`, `icons`) | Not shown: graphical presentation | One row per setting: the minimap atlas's corner sizes, margin, marker size and frame, and an image per icon role | | Actor Battle Art, Enemy Battle Art, Battle Scale | `assets/Data/Presentation/battlers.php` | Not shown: graphical battle art | Editable from the actor's or enemy's own page (see Battle Art) | Player Field Appearance edits only `graphicalSubject` and `sprites2d`, preserving @@ -1234,8 +1234,15 @@ default writes nothing. A value is checked by the Engine with its neighbours, so the larger corner must first be widened before the compact one may exceed it. Only changed leaves are written; comments and settings other pages own are kept. A project without `menus.php` shows the category read-only and says why. -The Terminal's opened map keeps its own layout, so the Terminal editor does not -list this category. +Menu Theme also binds icon images by role (`icons`): every role the catalogue +already binds, and every marker the atlas draws, offered even before one is +bound: `map.player`, each landmark kind, and `map.category.` for each place +category, the project's own included as soon as its Place Categories record +exists. Each is a project image chosen with the shared picker; `(Engine +pictogram)` leaves the Engine's generic marker, and choosing it for a bound +role removes the binding. An image the Engine cannot draw is refused. The +Terminal's maps keep their own symbols, so the Terminal editor does not list +this category. Place Categories name the kinds of place a game marks on its maps beyond the Engine's generic `inn`, `shop`, `home` and `organization`: a school, a temple, diff --git a/docs/roadmap.md b/docs/roadmap.md index c59b6ee..0062962 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -161,17 +161,15 @@ graphical previews are an enhancement, not a prerequisite for authoring a game. The graphical Database's Menu Theme category edits the shared menu theme (`assets/Data/Presentation/menus.php`) through its source-preserving owner `MenuPresentationSettings`, which shares `ProjectSettingsFile` with -`config.php`. It offers only the minimap atlas so far (corner sizes, margin, -marker size, frame), with the Engine's defaults and validation. The palette, -metrics, row metrics and artwork, frames, icon roles, cursor, portraits and -notifications, and PNG portrait selection, remain an implementation gap; each -joins Menu Theme through that owner as the Engine exposes its rules. Icon roles -include the atlas markers: `map.player`, the route and place kinds (`map.*`), -and a dedicated marker per place category, `map.category.`, for the -Engine's generic categories and each project category on the Place Categories -page (Last Legend's `bsa`). Until a game binds one, the Engine's shared generic -pictogram for the category's base is shown; binding a game's own marker -requires this icon authoring. +`config.php`. It offers the minimap atlas (corner sizes, margin, marker size, frame) and icon +bindings, with the Engine's defaults and validation. The palette, metrics, row +metrics and artwork, frames, cursor, portraits and notifications, and PNG +portrait selection, remain an implementation gap; each +joins Menu Theme through that owner as the Engine exposes its rules. Icon +binding is authored for the roles the catalogue already binds and for every +atlas marker role the Engine draws (`map.player`, each kind, `map.category.` +for generic and project categories); adding other new semantic roles waits on +the Engine listing them. Acceptance criteria for the presentation-authoring slice: diff --git a/src/Database/DatabaseCatalog.php b/src/Database/DatabaseCatalog.php index cc8b2cb..2a58c2c 100644 --- a/src/Database/DatabaseCatalog.php +++ b/src/Database/DatabaseCatalog.php @@ -60,7 +60,7 @@ public static function getGraphicalCategories(): array new DatabaseCategoryDefinition(\Ichiloto\Editor\Field\TextRoleFields::CATEGORY, 'Text Roles', 'How text such as a sign\'s label is set: the project\'s fonts, size and colour.', true), new DatabaseCategoryDefinition(MenuThemeFields::CATEGORY, - 'Menu Theme', 'The shared menu theme: the minimap atlas\'s corner sizes, margin, markers and frame.', true)]; + 'Menu Theme', 'The shared menu theme: the minimap atlas\'s corner sizes, margin, markers and frame, and icon images.', true)]; } /** diff --git a/src/Database/MenuThemeFields.php b/src/Database/MenuThemeFields.php index 58781fd..b62ac72 100644 --- a/src/Database/MenuThemeFields.php +++ b/src/Database/MenuThemeFields.php @@ -10,9 +10,10 @@ /** * Menu Theme: the settings of the game's shared menu presentation catalog, one row per setting by its path, as - * Configuration edits config.php. It starts with the minimap atlas: the compact and larger corner sizes, their - * margin, the marker size and the frame the atlas is drawn in. These are graphical presentation (logical pixels of - * the GPUI atlas), so the category is the graphical editor's; the Terminal's opened map keeps its own layout. + * Configuration edits config.php. It offers the minimap atlas (the compact and larger corner sizes, their margin, + * the marker size and the frame the atlas is drawn in) and the icon bindings, among them a marker per atlas role + * and place category. These are graphical presentation, so the category is the graphical editor's; the Terminal's + * maps keep their own symbols. * MenuPresentationSettings owns the file, its defaults and the values the Engine accepts. */ final class MenuThemeFields @@ -34,7 +35,7 @@ public static function getSchema(): RecordSchema ], labelKey: 'path', identityKey: 'path', - configPath: [MenuPresentationSettings::ATLAS], + configPath: [MenuPresentationSettings::ATLAS, MenuPresentationSettings::ICONS], fieldsFor: self::getFields(...), ); } @@ -50,9 +51,13 @@ public static function getFields(array $row): array $default = array_key_exists('default', $row) ? ProjectRecord::stringify($row['default']) : null; $kind = array_key_exists('default', $row) ? $row['default'] : ($row['value'] ?? null); + $path = (string) ($row['path'] ?? ''); return [ new RecordField('path', 'Setting', isReadOnly: true), match (true) { + // An icon is a project image chosen with the shared picker; none leaves the Engine's own treatment. + MenuPresentationSettings::isIcon($path) => RecordField::reference('value', 'Image', 'png_assets', allowsNone: true, + noneLabel: array_key_exists('default', $row) ? '(Engine pictogram)' : '(none)'), ($row['path'] ?? null) === MenuPresentationSettings::ATLAS . '.frameRole' => new RecordField('value', 'Value', options: MapAtlasTheme::FRAME_ROLES, displayDefault: $default), is_int($kind) => new RecordField('value', self::describeRange((string) ($row['path'] ?? '')), diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 6b68c18..a502a7f 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -368,14 +368,28 @@ public static function fromProject(string $projectRoot, RecordSchema $schema, ?P */ public function getRecords(): array { + $this->syncSettingRecords(); return array_values($this->records); } public function getRecordByIndex(int $index): ?ProjectRecord { + $this->syncSettingRecords(); return $this->records[$index] ?? null; } + /** + * A settings file's category lists what its owner offers now: the settings the Engine knows can grow while the + * project is open (a marker role for a place category just created). The owner keeps each setting's one record, + * so pending edits and undo are unaffected. + */ + private function syncSettingRecords(): void + { + if ($this->settings !== null && $this->schema->storage === RecordStorage::CONFIG_SUBTREE && $this->settings->getReadOnlyReason() === null) { + $this->records = $this->settings->getSettingRecords($this->schema->configPath); + } + } + /** * Returns the entry-list labels for this category. * diff --git a/src/MenuPresentationSettings.php b/src/MenuPresentationSettings.php index 7bee73e..1352275 100644 --- a/src/MenuPresentationSettings.php +++ b/src/MenuPresentationSettings.php @@ -4,14 +4,21 @@ namespace Ichiloto\Editor; +use Closure; +use Ichiloto\Engine\Field\MapCategoryCatalog; +use Ichiloto\Engine\Rendering\Sprites\PngAssetPreflight; +use Ichiloto\Engine\UI\Presentation\MapAtlasPresentation; use Ichiloto\Engine\UI\Presentation\MapAtlasTheme; +use Ichiloto\Engine\UI\Presentation\MenuIconRegistry; use Ichiloto\Engine\UI\Presentation\MenuPresentationCatalog; /** * One source owner for the game's menu presentation catalog, the shared theme every menu and HUD draws with - * (Engine MenuPresentationCatalog). Its settings are edited by path like config.php's. The minimap atlas is the - * first group it offers; the palette, frames and other groups join it as the Engine exposes their rules. Defaults - * and the values the Engine accepts are the Engine's own, read from its theme types, never restated here. + * (Engine MenuPresentationCatalog). Its settings are edited by path like config.php's. It offers the minimap atlas + * and the icon bindings: every role the catalogue binds, and every atlas marker role the Engine draws (the player, + * each landmark kind, each place category, the project's own included), each an image or none. The palette, frames + * and other groups join it as the Engine exposes their rules. Defaults and the values the Engine accepts are the + * Engine's own, read from its theme types, never restated here. */ final class MenuPresentationSettings extends ProjectSettingsFile { @@ -19,23 +26,80 @@ final class MenuPresentationSettings extends ProjectSettingsFile public const string RELATIVE_PATH = 'assets/' . MenuPresentationCatalog::FILE; /** The minimap atlas's sizes and frame. */ public const string ATLAS = 'atlas'; + /** Icon bindings by semantic role, whose keys hold dots of their own (map.category.bsa). */ + public const string ICONS = 'icons'; + + /** @var (Closure(): MapCategoryCatalog)|null The project's place categories, unsaved edits included. */ + private ?Closure $categories = null; public function __construct(string $projectRoot) { parent::__construct($projectRoot, self::RELATIVE_PATH); } - /** Every atlas setting with the default the Engine's MapAtlasTheme reads when the catalog leaves it out. */ + /** + * Reads the project's place categories as its Place Categories page holds them, so their marker roles are offered + * before they are saved. Without it the saved catalogue is read. + * + * @param Closure(): MapCategoryCatalog $categories + */ + public function useCategoryCatalog(Closure $categories): void + { + $this->categories = $categories; + } + + /** + * Every atlas setting with the default the Engine's MapAtlasTheme reads when the catalog leaves it out, and every + * atlas marker role, which shows the Engine's own pictogram until the theme binds an image. + */ protected function listEngineSettings(): array { $settings = []; foreach (get_object_vars(new MapAtlasTheme()) as $key => $default) { $settings[self::ATLAS . '.' . $key] = ['default' => $default]; } + foreach ($this->listMarkerRoles() as $role) { + $settings[self::ICONS . '.' . $role] = ['default' => null]; + } return $settings; } + /** An icon binding's path is its group and its whole role, dots included. */ + protected function splitPath(string $path): array + { + return str_starts_with($path, self::ICONS . '.') ? [self::ICONS, substr($path, strlen(self::ICONS) + 1)] : explode('.', $path); + } + + /** An icon bound to no image is no binding: its key goes, and the Engine's own treatment shows. */ + protected function isRemovedWhenEmpty(string $path): bool + { + return str_starts_with($path, self::ICONS . '.'); + } + + /** Whether a setting is an icon binding. */ + public static function isIcon(string $path): bool + { + return str_starts_with($path, self::ICONS . '.'); + } + + /** Whether a setting binds an atlas marker the Engine draws with a pictogram of its own when unbound. */ + public function isMarkerIcon(string $path): bool + { + return self::isIcon($path) && in_array(substr($path, strlen(self::ICONS) + 1), $this->listMarkerRoles(), true); + } + + /** @return list The atlas marker roles, as the Engine names them for the project's place categories. */ + private function listMarkerRoles(): array + { + try { + $categories = $this->categories !== null ? ($this->categories)() : MapCategoryCatalog::load($this->projectRoot . '/assets'); + } catch (\InvalidArgumentException) { + $categories = new MapCategoryCatalog(); + } + return MapAtlasPresentation::listIconRoles($categories); + } + /** * Refuses an atlas value the Engine would not accept beside the atlas settings as they stand: its theme type * checks each value and how they relate (the larger corner at least as large as the compact one). @@ -44,6 +108,17 @@ protected function listEngineSettings(): array */ public function assertAcceptableValue(string $path, mixed $value, bool $complete = true): void { + if (self::isIcon($path)) { + if ($value === null) { return; } + // The registry checks the role and path as the Engine binds them; the image must be one it can draw. + try { + new MenuIconRegistry($this->projectRoot . '/assets', [substr($path, strlen(self::ICONS) + 1) => $value]); + PngAssetPreflight::inspect($this->projectRoot . '/assets', (string) $value); + } catch (\Throwable $error) { + throw new \InvalidArgumentException(sprintf('%s cannot be this icon: %s', (string) $value, $error->getMessage()), previous: $error); + } + return; + } if (! str_starts_with($path, self::ATLAS . '.')) { return; } $atlas = []; foreach (array_keys(get_object_vars(new MapAtlasTheme())) as $key) { diff --git a/src/ProjectConfig.php b/src/ProjectConfig.php index 39168e8..77c6e6c 100644 --- a/src/ProjectConfig.php +++ b/src/ProjectConfig.php @@ -43,7 +43,7 @@ protected function listEngineSettings(): array foreach (EngineMessageTerms::listTerms() as $path => $term) { // A message that inherits the project's own vocabulary wording reads as that wording here, as the game // reads it, taken from this project's file, never from another project's loaded runtime state. - $inherited = isset($term['vocabularyDefault']) ? self::getValue($this->payload, 'vocab.' . $term['vocabularyDefault']) : null; + $inherited = isset($term['vocabularyDefault']) ? $this->readValue($this->payload, 'vocab.' . $term['vocabularyDefault']) : null; if (is_string($inherited)) { $term['default'] = $inherited; } diff --git a/src/ProjectSettingsFile.php b/src/ProjectSettingsFile.php index aa81500..d602a89 100644 --- a/src/ProjectSettingsFile.php +++ b/src/ProjectSettingsFile.php @@ -76,6 +76,23 @@ protected function isWholeValue(string $path): bool return false; } + /** + * The keys a setting's path walks, root first. A path joins them with dots; a file whose keys hold dots of their + * own (icon roles such as map.player) says where a key begins. + * + * @return list + */ + protected function splitPath(string $path): array + { + return explode('.', $path); + } + + /** Whether choosing nothing for a setting removes its key, rather than writing an empty value the Engine refuses. */ + protected function isRemovedWhenEmpty(string $path): bool + { + return false; + } + /** * Keys a whole-value edit renames rather than replaces, so the renamed entries keep their authored comments. * @@ -89,7 +106,7 @@ protected function listKeyRenames(string $path, mixed $old, mixed $new): array public function getRecord(string $path): ProjectRecord { if (! isset($this->records[$path])) { - $value = self::getValue($this->payload, $path); + $value = $this->readValue($this->payload, $path); $this->originalValues[$path] = $value; $setting = $this->listEngineSettings()[$path] ?? null; $this->records[$path] = new ProjectRecord($setting === null @@ -109,12 +126,14 @@ public function getRecord(string $path): ProjectRecord public function getSettingRecords(array $roots): array { $records = []; - $visit = function (array $tree, string $prefix, bool $ambiguous = false) use (&$visit, &$records): void { + $visit = function (array $tree, array $segments, bool $ambiguous = false) use (&$visit, &$records): void { foreach ($tree as $key => $value) { - $path = $prefix . '.' . $key; - $hasDottedKey = $ambiguous || str_contains((string) $key, '.'); + $keys = [...$segments, (string) $key]; + $path = implode('.', $keys); + // A key the path cannot be split back into is ambiguous with a nested path. + $hasDottedKey = $ambiguous || $this->splitPath($path) !== $keys; if ($this->isWholeValue($path) && ! $hasDottedKey) { $records[] = $this->getRecord($path); } - elseif (is_array($value)) { $visit($value, $path, $hasDottedKey); } + elseif (is_array($value)) { $visit($value, $keys, $hasDottedKey); } elseif (! is_object($value) || $value instanceof \UnitEnum) { if ($hasDottedKey) { $this->fieldIssues[$path] = $path . ' contains a dotted literal key, ambiguous with a nested path; it is preserved read-only.'; @@ -124,10 +143,10 @@ public function getSettingRecords(array $roots): array } }; foreach ($roots as $root) { - if (is_array($this->payload[$root] ?? null)) { $visit($this->payload[$root], $root); } + if (is_array($this->payload[$root] ?? null)) { $visit($this->payload[$root], [$root]); } } foreach (array_keys($this->listEngineSettings()) as $path) { - $root = explode('.', $path)[0]; + $root = $this->splitPath($path)[0]; if (in_array($root, $roots, true) && ! in_array($path, array_map(static fn(ProjectRecord $record): mixed => $record->get('path'), $records), true) && $this->issue === null) { $records[] = $this->getRecord($path); } @@ -154,7 +173,7 @@ public function getFieldIssue(string $path): ?string /** Whether the file authors a setting at all, as opposed to leaving it to its default. */ public function holds(string $path): bool { - return $this->document?->nodeAt(explode('.', $path)) !== null; + return $this->document?->nodeAt($this->splitPath($path)) !== null; } public function isDirty(): bool @@ -180,7 +199,7 @@ public function assertSourceAcceptsValue(string $path, mixed $value): void /** The value a setting holds now, pending edits included, or what the file holds where none was read. */ protected function getCurrentValue(string $path): mixed { - return isset($this->records[$path]) ? $this->records[$path]->get('value') : self::getValue($this->payload, $path); + return isset($this->records[$path]) ? $this->records[$path]->get('value') : $this->readValue($this->payload, $path); } private function composeSource(array $overrides = []): string @@ -194,12 +213,18 @@ private function composeSource(array $overrides = []): string $old = $this->payload; $new = $old; $next = &$new; - foreach (explode('.', $path) as $segment) { $next = &$next[$segment]; } + foreach ($this->splitPath($path) as $segment) { $next = &$next[$segment]; } $next = $value; unset($next); $document = ArraySourceWriter::rewrite($document, $old, $new, $this->listKeyRenames($path, $this->originalValues[$path], $value)); continue; } + if ($value === null && $this->isRemovedWhenEmpty($path)) { + // An authored key chosen away is removed, keeping its neighbours and their comments. + $this->createFieldEdit($document, $path, '1'); + $document = $document->withEdits([$document->removeEntryEdit($this->splitPath($path))]); + continue; + } $edit = $this->createFieldEdit($document, $path, PhpValueExporter::export($value)); $document = $document->withEdits([$edit]); } @@ -224,7 +249,7 @@ public function save(?FileSetOperations $files = null): void $evaluated = PhpDataFile::evaluateIsolated($staged[$this->path], $this->projectRoot); foreach ($this->records as $path => $record) { if ($record->get('value') === $this->originalValues[$path]) { continue; } - if (self::getValue(is_array($evaluated) ? $evaluated : [], $path) !== $record->get('value')) { + if ($this->readValue(is_array($evaluated) ? $evaluated : [], $path) !== $record->get('value')) { throw new SourcePreservationRefusal('The staged ' . $this->relativePath . ' does not preserve ' . $path . '.'); } } @@ -240,7 +265,7 @@ public function save(?FileSetOperations $files = null): void private function createFieldEdit(?PhpArraySourceDocument $document, string $path, string $literal, bool $allowArray = false): array { if ($this->issue !== null || $document === null) { throw new SourcePreservationRefusal($this->issue ?? $this->relativePath . ' cannot be preserved.'); } - $segments = explode('.', $path); + $segments = $this->splitPath($path); $walked = []; $node = $document->root(); foreach ($segments as $index => $key) { @@ -259,16 +284,16 @@ private function createFieldEdit(?PhpArraySourceDocument $document, string $path } // An enum case is written as one: replacing it with another case of // its enum keeps the file reading the same kind of value. - if ($node->kind !== SourceNode::SCALAR && ! ($allowArray && $node->kind === SourceNode::ARRAY) && ! self::getValue($this->payload, $path) instanceof \UnitEnum) { + if ($node->kind !== SourceNode::SCALAR && ! ($allowArray && $node->kind === SourceNode::ARRAY) && ! $this->readValue($this->payload, $path) instanceof \UnitEnum) { throw new SourcePreservationRefusal($path . ' is an authored expression, not an editable literal.'); } return $document->replaceValueEdit($walked, $literal); } - protected static function getValue(array $payload, string $path): mixed + protected function readValue(array $payload, string $path): mixed { $value = $payload; - foreach (explode('.', $path) as $key) { $value = is_array($value) ? ($value[$key] ?? null) : null; } + foreach ($this->splitPath($path) as $key) { $value = is_array($value) ? ($value[$key] ?? null) : null; } return $value; } diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index 731ed78..af0cbdc 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -61,6 +61,8 @@ public function __construct( $this->getRecordDatabase('quests')?->useIdentityReferences( fn(string $questId): bool => new QuestReferences($this)->exist($questId), ); + // Marker roles follow the place categories as their page holds them, unsaved edits included. + $this->menuPresentation?->useCategoryCatalog(fn(): \Ichiloto\Engine\Field\MapCategoryCatalog => new ReferenceCatalog($this)->getMapCategoryCatalog()); // A place category stays while a landmark names it. $this->getRecordDatabase(\Ichiloto\Editor\Field\MapCategoryFields::CATEGORY)?->useDeletionReferences( fn(string $category): array => \Ichiloto\Editor\Field\MapLandmarks::describeCategoryReferences($this->maps, $category), diff --git a/tests/Unit/Session/MenuThemeSessionTest.php b/tests/Unit/Session/MenuThemeSessionTest.php index a490447..a6c7010 100644 --- a/tests/Unit/Session/MenuThemeSessionTest.php +++ b/tests/Unit/Session/MenuThemeSessionTest.php @@ -50,7 +50,7 @@ function setThemeSetting(EditorSession $session, string $path, string $value): a $labels = $session->listDatabaseRecords(MenuThemeFields::CATEGORY)['records']; $engine = array_map(static fn(string $key): string => 'atlas.' . $key, array_keys(get_object_vars(new MapAtlasTheme()))); expect($labels[0])->toBe('atlas.margin') - ->and($labels)->toEqualCanonicalizing($engine); + ->and(array_values(array_filter($labels, static fn(string $label): bool => str_starts_with($label, 'atlas.'))))->toEqualCanonicalizing($engine); $frame = array_find($session->readDatabaseRecord(MenuThemeFields::CATEGORY, findThemeSetting($session, 'atlas.frameRole'))['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'value'); expect($frame['options'])->toBe(MapAtlasTheme::FRAME_ROLES); @@ -95,3 +95,47 @@ function setThemeSetting(EditorSession $session, string $path, string $value): a ->and($listing['readOnly'])->toContain(MenuPresentationSettings::RELATIVE_PATH) ->and($listing['records'])->toBe([]); }); + +it('binds atlas marker images by role, the project\'s own categories included, and unbinds them by removing the key', function () { + [$root, $file, $session] = createMenuThemeProject(<<<'PHP' + 'ichiloto.menu/1', + 'icons' => [ + 'notification.save' => 'Graphics/Icons/Save.png', // Bound by another page. + ], +]; +PHP); + writeTilesetTestPng($root . '/assets/Graphics/Icons/Save.png', 8, 8); + writeTilesetTestPng($root . '/assets/Graphics/Icons/School.png', 8, 8); + // A project category, unsaved, already has a marker role to bind. + $index = $session->createDatabaseRecord(Ichiloto\Editor\Field\MapCategoryFields::CATEGORY)['index']; + $id = array_find($session->readDatabaseRecord(Ichiloto\Editor\Field\MapCategoryFields::CATEGORY, $index)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === 'id'); + $session->applyDatabaseRecord(Ichiloto\Editor\Field\MapCategoryFields::CATEGORY, $index, $id['key'], 'bard-school'); + + $labels = $session->listDatabaseRecords(MenuThemeFields::CATEGORY)['records']; + $roles = Ichiloto\Engine\UI\Presentation\MapAtlasPresentation::listIconRoles(Ichiloto\Engine\Field\MapCategoryCatalog::fromArray([ + 'schema' => Ichiloto\Engine\Field\MapCategoryCatalog::SCHEMA, + 'categories' => ['bard-school' => ['name' => ['message' => 'm', 'default' => 'Bard School'], 'base' => 'inn']]])); + expect($labels)->toContain('icons.notification.save') + ->and(array_values(array_filter($labels, static fn(string $label): bool => str_starts_with($label, 'icons.map.')))) + ->toEqualCanonicalizing(array_map(static fn(string $role): string => 'icons.' . $role, $roles)) + ->and($labels)->toContain('icons.map.category.bard-school'); + $marker = array_find($session->readDatabaseRecord(MenuThemeFields::CATEGORY, findThemeSetting($session, 'icons.map.category.bard-school'))['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === 'value'); + expect($marker['kind'])->toBe('reference')->and($marker['reference'])->toBe('png_assets') + ->and($marker['noneLabel'])->toBe('(Engine pictogram)'); + + expect(fn() => setThemeSetting($session, 'icons.map.category.bard-school', 'Graphics/Icons/Missing.png'))->toThrow(SessionRefusal::class); + setThemeSetting($session, 'icons.map.category.bard-school', 'Graphics/Icons/School.png'); + $session->saveDatabase(MenuThemeFields::CATEGORY); + expect((require $file)['icons'])->toBe(['notification.save' => 'Graphics/Icons/Save.png', 'map.category.bard-school' => 'Graphics/Icons/School.png']) + ->and(file_get_contents($file))->toContain('// Bound by another page.'); + + // Choosing none again removes the binding: the Engine's pictogram shows. + setThemeSetting($session, 'icons.map.category.bard-school', ''); + $session->saveDatabase(MenuThemeFields::CATEGORY); + expect((require $file)['icons'])->toBe(['notification.save' => 'Graphics/Icons/Save.png']) + ->and(file_get_contents($file))->toContain('// Bound by another page.'); +}); From 7355ed1bb757413d2b8cb299f76948ac2f8392fe Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 01:11:34 +0200 Subject: [PATCH 297/344] feat(database): author the corner map's opacity in Menu Theme Menu Theme now types decimal settings as numbers, so the Engine's MapAtlasTheme::cornerOpacity (Engine de724d0) is a Float row labelled by what opacity means; its range is checked by MapAtlasTheme::fromArray with the other atlas values. --- docs/manual.md | 4 +++- src/Database/MenuThemeFields.php | 11 +++++++++-- tests/Unit/Session/MenuThemeSessionTest.php | 13 +++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 9efb3cc..7929d11 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1228,7 +1228,9 @@ automated coverage; native visual inspection remains pending. Menu Theme edits the game's shared menu presentation catalog by setting, as Configuration edits `config.php`. It offers the minimap atlas: the compact and larger corner sizes, their margin, the marker size and the frame (`quiet` or -`panel`) the atlas is drawn in, all in logical pixels of the GPUI atlas. Each +`panel`) the atlas is drawn in, all in logical pixels of the GPUI atlas, and +the corner map's opacity, from 0 (clear) to 1 (solid); markers and the opened +map stay solid. Each setting the catalog leaves out shows the Engine's default, and choosing that default writes nothing. A value is checked by the Engine with its neighbours, so the larger corner must first be widened before the compact one may exceed diff --git a/src/Database/MenuThemeFields.php b/src/Database/MenuThemeFields.php index b62ac72..e303098 100644 --- a/src/Database/MenuThemeFields.php +++ b/src/Database/MenuThemeFields.php @@ -11,7 +11,7 @@ /** * Menu Theme: the settings of the game's shared menu presentation catalog, one row per setting by its path, as * Configuration edits config.php. It offers the minimap atlas (the compact and larger corner sizes, their margin, - * the marker size and the frame the atlas is drawn in) and the icon bindings, among them a marker per atlas role + * the marker size, the frame the atlas is drawn in and the corner map's opacity) and the icon bindings, among them a marker per atlas role * and place category. These are graphical presentation, so the category is the graphical editor's; the Terminal's * maps keep their own symbols. * MenuPresentationSettings owns the file, its defaults and the values the Engine accepts. @@ -62,6 +62,8 @@ public static function getFields(array $row): array => new RecordField('value', 'Value', options: MapAtlasTheme::FRAME_ROLES, displayDefault: $default), is_int($kind) => new RecordField('value', self::describeRange((string) ($row['path'] ?? '')), InputControlType::INTEGER, displayDefault: $default), + // A fraction such as the corner map's opacity: a number, checked by the Engine with its neighbours. + is_float($kind) => new RecordField('value', self::describeRange($path), InputControlType::FLOAT, displayDefault: $default), default => new RecordField('value', 'Value', displayDefault: $default), }, ]; @@ -78,6 +80,11 @@ private static function describeRange(string $path): string default => null, }; - return $range === null ? 'Value' : sprintf('Pixels (%d..%d)', ...$range); + return match (true) { + $range !== null => sprintf('Pixels (%d..%d)', ...$range), + // Opacity's own meaning: how much of what lies beneath shows through. + $path === 'atlas.cornerOpacity' => 'Opacity (0 clear, 1 solid)', + default => 'Value', + }; } } diff --git a/tests/Unit/Session/MenuThemeSessionTest.php b/tests/Unit/Session/MenuThemeSessionTest.php index a6c7010..52b602f 100644 --- a/tests/Unit/Session/MenuThemeSessionTest.php +++ b/tests/Unit/Session/MenuThemeSessionTest.php @@ -139,3 +139,16 @@ function setThemeSetting(EditorSession $session, string $path, string $value): a expect((require $file)['icons'])->toBe(['notification.save' => 'Graphics/Icons/Save.png']) ->and(file_get_contents($file))->toContain('// Bound by another page.'); }); + +it('sets the corner map\'s opacity as a number the Engine checks, and writes it as one', function () { + [, $file, $session] = createMenuThemeProject(); + $row = array_find($session->readDatabaseRecord(MenuThemeFields::CATEGORY, findThemeSetting($session, 'atlas.cornerOpacity'))['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === 'value'); + expect($row['kind'])->toBe('float')->and($row['label'])->toBe('Opacity (0 clear, 1 solid)'); + expect(fn() => setThemeSetting($session, 'atlas.cornerOpacity', '1.5'))->toThrow(SessionRefusal::class); + setThemeSetting($session, 'atlas.cornerOpacity', '0.5'); + $session->saveDatabase(MenuThemeFields::CATEGORY); + expect((require $file)['atlas']['cornerOpacity'])->toBe(0.5) + ->and(MapAtlasTheme::fromArray((require $file)['atlas'])->cornerOpacity)->toBe(0.5) + ->and(file_get_contents($file))->toContain('// Breathing room.'); +}); From a3a9371a19e5c6b64d8b9e049de6fbc636b2c5e0 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 01:26:03 +0200 Subject: [PATCH 298/344] feat(map): choose how the Terminal draws a map in its atlas rows The Atlas rows gain Terminal View, chosen from MapAtlasDefinition::TERMINAL_VIEWS (Engine 452eb39): places for outdoors or a floorplan for interiors. The row shows what the Engine reads for the map's scope, and choosing that again removes the key. The golden shell snapshot gains the row. --- docs/manual.md | 7 ++++- src/Field/MapAtlas.php | 29 ++++++++++++++++++++ src/Inspector/MapAtlasFields.php | 22 +++++++++++++-- tests/Unit/Session/MapAtlasAuthoringTest.php | 16 +++++++++++ tests/__snapshots__/main_shell_100x30.ansi | 2 +- 5 files changed, 72 insertions(+), 4 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 7929d11..a0b28bb 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1038,7 +1038,12 @@ landmarks the Engine would refuse. `Atlas` places the map in the atlas (Engine `docs/maps.md`). `Scope` is `Local` (a town, a house, a dungeon; what a map is when it says nothing) or -`Global`, the overworld geography itself. A local map's `Parent Entry` is the +`Global`, the overworld geography itself. `Terminal View` is how the +Terminal's map draws it: `Places` (blank ground, a square for each location and +a chevron for each route, for outdoors) or `Floorplan` (rooms and corridors as +simple outlines, for interiors). It shows what the Engine reads for the map's +scope until an author chooses otherwise, and choosing that again says nothing; +graphical maps are unaffected. A local map's `Parent Entry` is the landmark on another map that is its entrance there: a house's on its town, the town's on the world. It is picked from the landmarks other maps declare, those whose door leads to this map first; a global map has no parent. `Global Map` diff --git a/src/Field/MapAtlas.php b/src/Field/MapAtlas.php index 1838ea4..0f45171 100644 --- a/src/Field/MapAtlas.php +++ b/src/Field/MapAtlas.php @@ -55,6 +55,35 @@ public static function setScope(array $block, string $scope): array return [...$block, 'scope' => $scope]; } + /** + * How the Terminal draws the map, as the Engine reads the block: the authored choice, or its default for the + * map's scope. + * + * @param array $block + * @throws InvalidArgumentException When the block is invalid. + */ + public static function readTerminalView(array $block): string + { + return MapAtlasDefinition::readMap($block === [] ? [] : [self::KEY => $block])->terminalView; + } + + /** + * Sets how the Terminal draws the map: places (squares for locations, chevrons for routes) or a floorplan of + * rooms and corridors. Choosing what the Engine would read anyway for the map's scope says nothing. + * + * @param array $block + * @return array + */ + public static function setTerminalView(array $block, string $view): array + { + if (!in_array($view, MapAtlasDefinition::TERMINAL_VIEWS, true)) { + throw new InvalidArgumentException('Choose a listed Terminal view.'); + } + $without = $block; + unset($without['terminalView']); + return $view === self::readTerminalView($without) ? $without : [...$block, 'terminalView' => $view]; + } + /** * Sets or clears the parent entry: a stable landmark on the parent map. * diff --git a/src/Inspector/MapAtlasFields.php b/src/Inspector/MapAtlasFields.php index e9de67d..68c22e0 100644 --- a/src/Inspector/MapAtlasFields.php +++ b/src/Inspector/MapAtlasFields.php @@ -8,14 +8,16 @@ use Ichiloto\Editor\Field\MapAtlas; use Ichiloto\Editor\Field\MapLandmarks; use Ichiloto\Editor\ProjectMap; +use Ichiloto\Engine\Field\MapAtlasDefinition; use Ichiloto\Engine\Field\MapLandmarkCatalog; use InvalidArgumentException; use Throwable; /** * The inspector rows that place a map in the atlas, the same in the Terminal and a graphical editor: its scope - * (local, or the global overworld geography), and for a local map its entry on the parent map, chosen from the - * landmarks other maps declare. The global map the chain reaches is shown as the Engine resolves it. + * (local, or the global overworld geography), how the Terminal draws it (places or a floorplan), and for a local + * map its entry on the parent map, chosen from the landmarks other maps declare. The global map the chain reaches + * is shown as the Engine resolves it. */ final class MapAtlasFields { @@ -41,6 +43,21 @@ public static function describeMapFields(ProjectMap $map, ReferenceCatalog $refe ['label' => ' Scope', 'value' => $scope, 'editable' => true, 'choices' => $choices, 'options' => array_column($choices, 'value'), 'target' => self::TARGET, 'field' => 'scope'], ]; + try { + $view = MapAtlas::readTerminalView($block); + } catch (InvalidArgumentException $error) { + $view = null; + $fields[] = ['label' => ' ! Problem', 'value' => $error->getMessage(), 'editable' => false]; + } + if ($view !== null) { + $explained = ['places' => 'Blank ground, a square for each location and a chevron for each route.', + 'floorplan' => 'Rooms and corridors drawn as simple outlines, for interiors.']; + $known = array_map(static fn(string $value): array => ['label' => ucfirst($value), 'value' => $value, + 'description' => $explained[$value] ?? ''], MapAtlasDefinition::TERMINAL_VIEWS); + // A short enum: picked or stepped through. What the Terminal draws; graphical maps are unaffected. + $fields[] = ['label' => ' Terminal View', 'value' => $view, 'editable' => true, 'choices' => $known, + 'options' => array_column($known, 'value'), 'target' => self::TARGET, 'field' => 'terminalView']; + } if ($scope !== MapAtlas::GLOBAL) { $parent = is_array($block['parent'] ?? null) ? $block['parent'] : null; $current = $parent === null ? '' : MapAtlas::encodeParent((string) ($parent['map'] ?? ''), (string) ($parent['landmark'] ?? '')); @@ -62,6 +79,7 @@ public static function applyValue(ProjectMap $map, array $field, mixed $value): $block = MapAtlas::readBlock($map); $next = match ((string) ($field['field'] ?? '')) { 'scope' => MapAtlas::setScope($block, (string) $value), + 'terminalView' => MapAtlas::setTerminalView($block, (string) $value), 'parent' => MapAtlas::setParent($block, ...(MapAtlas::decodeParent((string) $value) ?? [null, null])), default => $block, }; diff --git a/tests/Unit/Session/MapAtlasAuthoringTest.php b/tests/Unit/Session/MapAtlasAuthoringTest.php index fcbff0f..3c446f7 100644 --- a/tests/Unit/Session/MapAtlasAuthoringTest.php +++ b/tests/Unit/Session/MapAtlasAuthoringTest.php @@ -108,3 +108,19 @@ function findAtlasRow($session, string $field, string $mapId = 'test-map'): arra expect(readLandmarkEntries($session)[0])->not->toHaveKey('category'); $session->deleteDatabaseRecord(MapCategoryFields::CATEGORY, $index); }); + +it('chooses how the Terminal draws a map, saying nothing when it is the Engine\'s default for the scope', function () { + [, , $session] = createAtlasProject(); + $view = findAtlasRow($session, 'terminalView'); + expect($view['options'])->toBe(Ichiloto\Engine\Field\MapAtlasDefinition::TERMINAL_VIEWS) + ->and($view['value'])->toBe(Ichiloto\Editor\Field\MapAtlas::readTerminalView([])); + $other = array_values(array_diff(Ichiloto\Engine\Field\MapAtlasDefinition::TERMINAL_VIEWS, [$view['value']]))[0]; + applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'terminalView'], $other); + expect(getSessionMap($session)->getMapDataField(['atlas']))->toBe(['terminalView' => $other]); + applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'terminalView'], $view['value']); + expect(getSessionMap($session)->hasMapDataField(['atlas']))->toBeFalse(); + // A global map reads the Engine's own default for global maps. + expect(findAtlasRow($session, 'terminalView', 'world')['value'])->toBe(Ichiloto\Editor\Field\MapAtlas::readTerminalView(['scope' => 'global'])); + $session->undo(); + expect(getSessionMap($session)->getMapDataField(['atlas']))->toBe(['terminalView' => $other]); +}); diff --git a/tests/__snapshots__/main_shell_100x30.ansi b/tests/__snapshots__/main_shell_100x30.ansi index 72f4fbc..9852548 100644 --- a/tests/__snapshots__/main_shell_100x30.ansi +++ b/tests/__snapshots__/main_shell_100x30.ansi @@ -1 +1 @@ -[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint L:Layer ?:Help─────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Kind: Not set ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ Atlas ││ Scope: Local ││ Parent Entry: (none) ││ Global Map · (none above · │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file +[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project │└─?:Help Ctrl+P:Palette Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps ││ > test-map ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─/:Filter Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map ││ Test Map | | 12 x 5 | vie ││ ############ ││ # ~~~ # ││ # # ││ # # ││ ############ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└─i:Paint L:Layer ?:Help─────┘┌─Inspector──────────────────────┐│ Name: Test Map ││ Region: ││ Kind: Not set ││ Description: A tiny fixture ││ map. ││ Size ││ X: 12 ││ Y: 5 ││ Events · 1 ││ Triggers · 0 ││ Audio ││ Background Music: (None) ││ Music Variants · None ││ Encounters · off ││ Troops · 0 ││ Atlas ││ Scope: Local ││ Terminal View: Floorplan ││ Parent Entry: (none) │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │└─Ctrl+P:Palette Tab:Pane Ctrl+S:Save Ctrl+A:Save All Ctrl+Z:Undo Ctrl+Y:Redo Ctrl+Q:Quit──┘[?25l[?25l \ No newline at end of file From b052293dcb7ce4d55c1d9d2c74b227fcfe6cd6d8 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 02:14:05 +0200 Subject: [PATCH 299/344] feat(map): classify a map's own layer glyphs as atlas terrain A Terrain heading in the shared map inspector (both editors) authors the Engine's atlas.terrain: per source layer in compose order, a role for its other non-blank glyphs and one row per glyph the layer holds (blank ground included, with cell counts), each an Engine MapAtlasTerrain role or unclassified. Classifying a glyph binds the layer; clearing the last one removes the binding and an empty atlas its key. Only glyphs are named, never a grid, coordinates or collision. Every edit is checked by MapAtlasDefinition::readMap and MapAtlasTerrain::readMap against the map's layers as they stand and is one undo step; terrain naming a layer the map lacks is shown with a way to clear it, and MapValidator reports it. --- docs/manual.md | 15 +++ src/Field/MapAtlas.php | 52 ++++++++ src/Inspector/MapInspector.php | 23 ++++ src/Inspector/MapTerrainFields.php | 120 +++++++++++++++++++ src/Validation/MapValidator.php | 9 +- tests/Unit/Session/MapAtlasAuthoringTest.php | 51 ++++++++ 6 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 src/Inspector/MapTerrainFields.php diff --git a/docs/manual.md b/docs/manual.md index a0b28bb..da2c70d 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1102,6 +1102,21 @@ edited even after the selection has moved on. Current behavior: deleting an entry takes effect in memory immediately, and the file changes on the next save. Undo before saving costs nothing. +### Terrain + +`Terrain` classifies the map's own cells for its atlas, so the Terminal's map +can show simplified ground, roads, water, vegetation, highland, buildings and +boundaries around its place markers (Engine `MapAtlasTerrain`). It lists each +source layer in the order the map composes them. `Other Glyphs` gives every +non-blank glyph of that layer one role; each glyph the layer holds, blank +ground included, has a row of its own with its cell count, and a glyph's own +role outranks the layer's. `(unclassified)` draws nothing. Classifying any +glyph binds the layer; clearing the last one removes the binding, and an atlas +with nothing left removes its key. Only the glyphs are named: no grid, +coordinate or collision is copied, so editing the map changes what the atlas +shows. A layer the map no longer has stays listed with a way to clear it, and +validation reports terrain the Engine would refuse. + ### Actors An actor's Inspector opens with **Identity**: a `Definition Id`, which is diff --git a/src/Field/MapAtlas.php b/src/Field/MapAtlas.php index 0f45171..5964e3c 100644 --- a/src/Field/MapAtlas.php +++ b/src/Field/MapAtlas.php @@ -84,6 +84,58 @@ public static function setTerminalView(array $block, string $view): array return $view === self::readTerminalView($without) ? $without : [...$block, 'terminalView' => $view]; } + /** + * The map's terrain classifications as authored: per source layer, plain glyphs mapped to Engine terrain roles + * and an optional role for the layer's other non-blank glyphs. + * + * @param array $block + * @return array> + */ + public static function readTerrain(array $block): array + { + $terrain = $block['terrain'] ?? []; + return is_array($terrain) ? $terrain : []; + } + + /** + * Classifies one plain glyph of a source layer, or leaves it unclassified when the role is null. A layer + * classifying nothing says nothing, and a map with no classifications has no terrain key. + * + * @param array $block + * @return array + */ + public static function setTerrainSymbol(array $block, string $layer, string $glyph, ?string $role): array + { + $terrain = self::readTerrain($block); + $symbols = is_array($terrain[$layer]['symbols'] ?? null) ? $terrain[$layer]['symbols'] : []; + if ($role === null) { unset($symbols[$glyph]); } else { $symbols[$glyph] = $role; } + $binding = is_array($terrain[$layer] ?? null) ? $terrain[$layer] : []; + if ($symbols === []) { unset($binding['symbols']); } else { $binding['symbols'] = $symbols; } + return self::withTerrainBinding($block, $terrain, $layer, $binding); + } + + /** + * Sets the role of a source layer's other non-blank glyphs, or removes it when the role is null. + * + * @param array $block + * @return array + */ + public static function setTerrainDefault(array $block, string $layer, ?string $role): array + { + $terrain = self::readTerrain($block); + $binding = is_array($terrain[$layer] ?? null) ? $terrain[$layer] : []; + if ($role === null) { unset($binding['default']); } else { $binding['default'] = $role; } + return self::withTerrainBinding($block, $terrain, $layer, $binding); + } + + /** @return array */ + private static function withTerrainBinding(array $block, array $terrain, string $layer, array $binding): array + { + if ($binding === []) { unset($terrain[$layer]); } else { $terrain[$layer] = $binding; } + if ($terrain === []) { unset($block['terrain']); } else { $block['terrain'] = $terrain; } + return $block; + } + /** * Sets or clears the parent entry: a stable landmark on the parent map. * diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index f078f92..dd35f01 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -169,6 +169,7 @@ public function getMapFields(ProjectMap $map): array ], ...$this->mapRuntimeFields($map), ...MapAtlasFields::describeMapFields($map, $this->references), + ...MapTerrainFields::describeMapFields($map), ...$this->getLandmarkFields()->describeMapFields($map), ]; } @@ -313,6 +314,7 @@ public function apply(ProjectMap $map, array $field, string $rawValue): ?Command 'event-bounds' => $this->applyEventBounds($map, (string) $field['marker'], (string) $field['field'], (int) $value), MapLandmarkFields::TARGET, MapLandmarkFields::EVENT_TARGET, MapLandmarkFields::OBJECT_TARGET => $this->applyLandmarkValue($map, $field, $value), MapAtlasFields::TARGET => $this->applyAtlasValue($map, $field, $value), + MapTerrainFields::TARGET => $this->applyTerrainValue($map, $field, $value), default => null, }; } @@ -335,6 +337,27 @@ private function applyLandmarkValue(ProjectMap $map, array $field, mixed $value) return $entries === null ? null : $this->writeLandmarks($map, $entries, trim((string) ($field['label'] ?? 'Landmark'))); } + /** + * Applies one terrain row's edit: the atlas block it leaves is checked by the Engine against the map's source + * layers as they stand, then written as one undo step; an empty block removes the key. + * + * @param array $field + * @throws InspectorRefusal When the edit cannot be made or the Engine would refuse the terrain. + */ + private function applyTerrainValue(ProjectMap $map, array $field, mixed $value): ?Command + { + try { + $block = MapTerrainFields::applyValue($map, $field, $value); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + if ($block === null) { return null; } + $problem = MapTerrainFields::findProblem($map, $block); + if ($problem !== null) { throw new InspectorRefusal($problem); } + + return $this->writeMapData($map, [MapAtlas::KEY], $block === [] ? null : $block, 'Atlas terrain'); + } + /** The landmark rows, over the project's place categories and maps as this inspector reads them. */ private function getLandmarkFields(): MapLandmarkFields { diff --git a/src/Inspector/MapTerrainFields.php b/src/Inspector/MapTerrainFields.php new file mode 100644 index 0000000..70c7887 --- /dev/null +++ b/src/Inspector/MapTerrainFields.php @@ -0,0 +1,120 @@ +> */ + public static function describeMapFields(ProjectMap $map): array + { + try { + $block = MapAtlas::readBlock($map); + $layers = $map->getLayerSet()->layers; + } catch (Throwable $error) { + return [['label' => 'Terrain', 'value' => '', 'editable' => false], + ['label' => ' ! Read-only', 'value' => $error->getMessage(), 'editable' => false]]; + } + $terrain = MapAtlas::readTerrain($block); + $fields = [['label' => 'Terrain', 'value' => '', 'editable' => false]]; + $problem = self::findProblem($map, $block); + if ($problem !== null) { + $fields[] = ['label' => ' ! Problem', 'value' => $problem, 'editable' => false]; + } + $roles = [['label' => '(unclassified)', 'value' => '', 'description' => 'The atlas draws nothing for it.'], + ...array_map(static fn(string $role): array => ['label' => ucfirst($role), 'value' => $role, 'description' => ''], MapAtlasTerrain::ROLES)]; + $known = []; + foreach ($layers as $layer) { + $known[$layer->name] = true; + $binding = is_array($terrain[$layer->name] ?? null) ? $terrain[$layer->name] : []; + $symbols = is_array($binding['symbols'] ?? null) ? $binding['symbols'] : []; + $fields[] = ['label' => sprintf(' Layer %s', $layer->name), 'value' => $layer->decoration ? 'decoration' : 'gameplay', 'editable' => false]; + $fields[] = ['label' => ' Other Glyphs', 'value' => is_string($binding['default'] ?? null) ? $binding['default'] : '', + 'editable' => true, 'choices' => $roles, 'options' => array_column($roles, 'value'), + 'target' => self::TARGET, 'field' => 'default', 'path' => [$layer->name]]; + $counts = []; + foreach ($layer->glyphs as $row) { + foreach ($row as $glyph) { $counts[$glyph] = ($counts[$glyph] ?? 0) + 1; } + } + // A glyph classified here that the layer no longer holds stays listed, so it can be cleared. + foreach (array_keys($symbols) as $glyph) { $counts[(string) $glyph] ??= 0; } + foreach ($counts as $glyph => $count) { + $glyph = (string) $glyph; + $fields[] = ['label' => sprintf(' %s · %s', $glyph === ' ' ? '(blank)' : "'" . $glyph . "'", + $count === 0 ? 'not in this layer' : sprintf('%d %s', $count, $count === 1 ? 'cell' : 'cells')), + 'value' => is_string($symbols[$glyph] ?? null) ? $symbols[$glyph] : '', 'editable' => true, + 'choices' => $roles, 'options' => array_column($roles, 'value'), + 'target' => self::TARGET, 'field' => 'symbol', 'path' => [$layer->name, $glyph]]; + } + } + // A layer classified here that the map no longer has stays listed, so it can be cleared. + foreach (array_keys($terrain) as $name) { + if (!isset($known[(string) $name])) { + $fields[] = ['label' => sprintf(' Layer %s · not in this map', $name), 'value' => '', 'editable' => true, + 'choices' => [['label' => 'Remove its classifications', 'value' => '', 'description' => '']], 'options' => [''], + 'target' => self::TARGET, 'field' => 'layer', 'path' => [(string) $name]]; + } + } + return $fields; + } + + /** + * The atlas block an edit to one of these rows leaves, or null when it changes nothing. + * + * @param array $field + * @return array|null + */ + public static function applyValue(ProjectMap $map, array $field, mixed $value): ?array + { + $block = MapAtlas::readBlock($map); + $path = array_values(array_map('strval', (array) ($field['path'] ?? []))); + $role = (string) $value === '' ? null : (string) $value; + if ($role !== null && !in_array($role, MapAtlasTerrain::ROLES, true)) { + throw new InvalidArgumentException('Choose a listed terrain role.'); + } + $next = match ((string) ($field['field'] ?? '')) { + 'symbol' => MapAtlas::setTerrainSymbol($block, $path[0] ?? '', $path[1] ?? '', $role), + 'default' => MapAtlas::setTerrainDefault($block, $path[0] ?? '', $role), + 'layer' => MapAtlas::setTerrainDefault(array_reduce(array_keys(MapAtlas::readTerrain($block)[$path[0] ?? '']['symbols'] ?? []), + static fn(array $carry, int|string $glyph): array => MapAtlas::setTerrainSymbol($carry, $path[0] ?? '', (string) $glyph, null), $block), + $path[0] ?? '', null), + default => $block, + }; + return $next === $block ? null : $next; + } + + /** + * Why the Engine would refuse the atlas's terrain beside the map's source layers as they stand, or null. + * + * @param array $block + */ + public static function findProblem(ProjectMap $map, array $block): ?string + { + try { + $data = $block === [] ? [] : [MapAtlas::KEY => $block]; + MapAtlasDefinition::readMap($data); + MapAtlasTerrain::readMap($data, $map->getLayerSet()); + return null; + } catch (InvalidArgumentException $error) { + return $error->getMessage(); + } + } +} diff --git a/src/Validation/MapValidator.php b/src/Validation/MapValidator.php index 3ac1e49..5adb9d0 100644 --- a/src/Validation/MapValidator.php +++ b/src/Validation/MapValidator.php @@ -7,6 +7,7 @@ use Ichiloto\Editor\Field\MapAtlas; use Ichiloto\Editor\Field\MapLandmarks; use Ichiloto\Editor\Inspector\MapLandmarkFields; +use Ichiloto\Editor\Inspector\MapTerrainFields; use Ichiloto\Editor\ProjectMap; use Ichiloto\Engine\Field\MapCategoryCatalog; use InvalidArgumentException; @@ -53,12 +54,16 @@ public static function validate(ProjectMap $map, array $mapsById): array private static function findAtlasProblems(ProjectMap $map, array $mapsById): array { try { - MapAtlas::resolveGlobal($map, MapAtlas::readBlock($map), static fn(string $id): ?ProjectMap => $mapsById[$id] ?? null, + $block = MapAtlas::readBlock($map); + MapAtlas::resolveGlobal($map, $block, static fn(string $id): ?ProjectMap => $mapsById[$id] ?? null, MapCategoryCatalog::load($map->getAssetRoot())); - return []; } catch (InvalidArgumentException $error) { return ['Atlas: ' . $error->getMessage()]; } + // Terrain names the map's own source layers, which the parent chain does not read. + $terrain = MapTerrainFields::findProblem($map, $block); + + return $terrain === null ? [] : ['Atlas terrain: ' . $terrain]; } /** diff --git a/tests/Unit/Session/MapAtlasAuthoringTest.php b/tests/Unit/Session/MapAtlasAuthoringTest.php index 3c446f7..dee1c08 100644 --- a/tests/Unit/Session/MapAtlasAuthoringTest.php +++ b/tests/Unit/Session/MapAtlasAuthoringTest.php @@ -124,3 +124,54 @@ function findAtlasRow($session, string $field, string $mapId = 'test-map'): arra $session->undo(); expect(getSessionMap($session)->getMapDataField(['atlas']))->toBe(['terminalView' => $other]); }); + +/** The terrain row editing this field at exactly this layer and glyph path. */ +function findTerrainRow($session, string $field, array $path): array +{ + return array_find($session->readInspector('test-map')['rows'], static fn(array $row): bool => ($row['key'] ?? null) + == ['target' => 'map-terrain', 'field' => $field, 'path' => $path]) ?? throw new RuntimeException('No terrain row ' . json_encode($path)); +} + +it('classifies a map\'s own layer glyphs as terrain for its atlas, never copying a grid', function () { + [, $file, $session] = createAtlasProject(); + $terrainRow = static fn(string $field, array $path): array => findTerrainRow($session, $field, $path); + $apply = static fn(string $field, array $path, string $value): array => $session->applyInspector('test-map', + $session->readMap('test-map')['revision'], findTerrainRow($session, $field, $path)['key'], $value); + + $dots = $terrainRow('symbol', ['terrain', '.']); + expect($dots['label'])->toBe("'.' · 8 cells") + ->and($dots['options'])->toBe(['', ...Ichiloto\Engine\Field\MapAtlasTerrain::ROLES]); + expect($terrainRow('symbol', ['buildings', ' '])['label'])->toBe('(blank) · 5 cells'); + + $apply('symbol', ['terrain', '.'], 'ground'); + $apply('default', ['buildings'], 'building'); + $apply('symbol', ['buildings', '/'], 'boundary'); + expect(getSessionMap($session)->getMapDataField(['atlas', 'terrain']))->toBe([ + 'terrain' => ['symbols' => ['.' => 'ground']], + 'buildings' => ['default' => 'building', 'symbols' => ['/' => 'boundary']], + ]); + expect(fn() => $apply('symbol', ['terrain', '.'], 'lava'))->toThrow(SessionRefusal::class); + + $session->saveMap('test-map'); + $map = getSessionMap($session); + $catalog = Ichiloto\Engine\Field\MapAtlasTerrain::readMap(require $file, $map->getLayerSet()); + expect($catalog->getRole(1, 1))->toBe('building')->and($catalog->getRole(1, 0))->toBe('boundary') + ->and(file_get_contents($file))->toContain('// Map-owned presentation stays authored.'); + + // Clearing every classification removes the terrain, then the empty atlas. + $apply('symbol', ['buildings', '/'], ''); + $apply('default', ['buildings'], ''); + $apply('symbol', ['terrain', '.'], ''); + expect(getSessionMap($session)->hasMapDataField(['atlas']))->toBeFalse(); + $session->undo(); + expect(getSessionMap($session)->getMapDataField(['atlas', 'terrain']))->toBe(['terrain' => ['symbols' => ['.' => 'ground']]]); +}); + +it('shows terrain bound to a layer the map no longer has, and clears it', function () { + [, , $session] = createAtlasProject("'atlas' => ['terrain' => ['old' => ['default' => 'water']]],"); + $rows = $session->readInspector('test-map')['rows']; + expect(array_column(array_filter($rows, static fn(array $row): bool => $row['label'] === '! Problem'), 'value')) + ->toContain('Atlas terrain references missing source layer old.'); + $session->applyInspector('test-map', $session->readMap('test-map')['revision'], findTerrainRow($session, 'layer', ['old'])['key'], ''); + expect(getSessionMap($session)->hasMapDataField(['atlas']))->toBeFalse(); +}); From 74a3720d72a1253832277e3b531e47a8248cb565 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 02:22:05 +0200 Subject: [PATCH 300/344] docs(map): describe the places view as terrain and buildings with markers The Terminal View row and manual now say places draws simplified terrain and buildings from the map's Terrain classifications under its location squares and route chevrons (Engine 6a9275a), and list the Engine's roles, obstacle included. --- docs/manual.md | 12 +++++++----- src/Inspector/MapAtlasFields.php | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index da2c70d..6d5cf6c 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1039,9 +1039,10 @@ landmarks the Engine would refuse. `Atlas` places the map in the atlas (Engine `docs/maps.md`). `Scope` is `Local` (a town, a house, a dungeon; what a map is when it says nothing) or `Global`, the overworld geography itself. `Terminal View` is how the -Terminal's map draws it: `Places` (blank ground, a square for each location and -a chevron for each route, for outdoors) or `Floorplan` (rooms and corridors as -simple outlines, for interiors). It shows what the Engine reads for the map's +Terminal's map draws it: `Places` (simplified terrain and buildings from the +map's Terrain classifications, with a square for each location and a chevron +for each route, for outdoors) or `Floorplan` (rooms and corridors as simple +outlines, for interiors). It shows what the Engine reads for the map's scope until an author chooses otherwise, and choosing that again says nothing; graphical maps are unaffected. A local map's `Parent Entry` is the landmark on another map that is its entrance there: a house's on its town, the @@ -1105,8 +1106,9 @@ file changes on the next save. Undo before saving costs nothing. ### Terrain `Terrain` classifies the map's own cells for its atlas, so the Terminal's map -can show simplified ground, roads, water, vegetation, highland, buildings and -boundaries around its place markers (Engine `MapAtlasTerrain`). It lists each +can show simplified terrain (the Engine's `MapAtlasTerrain` roles, such as +ground, roads, water, vegetation, obstacles, highland, buildings and +boundaries) around its place markers. It lists each source layer in the order the map composes them. `Other Glyphs` gives every non-blank glyph of that layer one role; each glyph the layer holds, blank ground included, has a row of its own with its cell count, and a glyph's own diff --git a/src/Inspector/MapAtlasFields.php b/src/Inspector/MapAtlasFields.php index 68c22e0..8913bb1 100644 --- a/src/Inspector/MapAtlasFields.php +++ b/src/Inspector/MapAtlasFields.php @@ -50,7 +50,7 @@ public static function describeMapFields(ProjectMap $map, ReferenceCatalog $refe $fields[] = ['label' => ' ! Problem', 'value' => $error->getMessage(), 'editable' => false]; } if ($view !== null) { - $explained = ['places' => 'Blank ground, a square for each location and a chevron for each route.', + $explained = ['places' => 'Simplified terrain and buildings from the map\'s Terrain classifications, with a square for each location and a chevron for each route.', 'floorplan' => 'Rooms and corridors drawn as simple outlines, for interiors.']; $known = array_map(static fn(string $value): array => ['label' => ucfirst($value), 'value' => $value, 'description' => $explained[$value] ?? ''], MapAtlasDefinition::TERMINAL_VIEWS); From d440b57fa3f72a6aec61cfca25b6a7517e137ab5 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 13:23:35 +0200 Subject: [PATCH 301/344] feat(dialogue): author dialogue portraits, speaker names and line emotions The Engine's dialogue catalogue (assets/Data/Presentation/dialogue.php) is now authored in the Database, in the terminal and through the session: - Dialogue Portraits: one page over `actors` and `resources` (a grouped dictionary projection), each identity with its dialogue portrait, skit bust and keyed emotions, images chosen with the shared PNG picker and stored asset-relative. Speaks As moves an identity between the two. - Speaker Names: the `speakers` label map (keyed dictionary projection of plain values), each label bound to an identity picked from the catalogue. - Every line the Engine reads `emotion` from (event and cinematic text commands, NPC lines, skit beats, map Dialogue and Shop event pages) has an Emotion row offering the emotions of the identity its speaker resolves to through the Engine's resolveSpeakerId, unsaved catalogue edits included. An emotion the identity lacks is kept and followed by a warning row (tone `warning` in session rows). - DialoguePresentationValidator reports speaker names bound to no identity, artwork the game cannot draw, and line emotions their speaker lacks. - Saves are admitted by the Engine's DialoguePresentationCatalog. Narrowed behaviour: a keyed dictionary or keyed list category whose owned key the file builds in a variable with its own code (a loop, a require) is now read-only, saying why, where a save used to replace that code's result with a literal. Last Legend's dialogue.php builds its identities this way, so its Dialogue Portraits page is read-only until the Game writes them as data; its speaker names stay editable. The terminal NPC inspector now receives the project's reference catalogue, as the session's does, so its reference checks and emotion choices match. --- docs/manual.md | 43 ++- docs/roadmap.md | 28 ++ src/Database/DatabaseCatalog.php | 2 + src/Database/OwnsFileKeys.php | 20 ++ src/Database/ProjectRecordDatabase.php | 88 +++++- .../GroupedDictionaryProjection.php | 118 ++++++++ .../Projections/KeyedDictionaryProjection.php | 29 +- .../Projections/KeyedListProjection.php | 9 +- src/Database/RecordField.php | 17 ++ src/Database/RecordSchemaCatalog.php | 8 + src/Database/ReferenceCatalog.php | 94 +++++++ src/Dialogue/DialogueCatalogFile.php | 93 +++++++ src/Dialogue/DialogueEmotionFields.php | 101 +++++++ src/Dialogue/DialogueLineSpeaker.php | 77 ++++++ src/Dialogue/DialoguePortraitFields.php | 122 +++++++++ src/Dialogue/DialogueSpeakerFields.php | 86 ++++++ src/Editor.php | 4 +- src/Inspector/MapInspector.php | 89 ++++++- src/Session/EditorSession.php | 2 + .../DialoguePresentationValidator.php | 181 +++++++++++++ src/Validation/ProjectValidator.php | 1 + tests/Pest.php | 2 + tests/Support/DialogueFixtures.php | 51 ++++ tests/Unit/DialoguePortraitsTerminalTest.php | 54 ++++ tests/Unit/NpcAuthoringTest.php | 10 +- .../Session/DialoguePortraitsSessionTest.php | 252 ++++++++++++++++++ 26 files changed, 1559 insertions(+), 22 deletions(-) create mode 100644 src/Database/OwnsFileKeys.php create mode 100644 src/Database/Projections/GroupedDictionaryProjection.php create mode 100644 src/Dialogue/DialogueCatalogFile.php create mode 100644 src/Dialogue/DialogueEmotionFields.php create mode 100644 src/Dialogue/DialogueLineSpeaker.php create mode 100644 src/Dialogue/DialoguePortraitFields.php create mode 100644 src/Dialogue/DialogueSpeakerFields.php create mode 100644 src/Validation/DialoguePresentationValidator.php create mode 100644 tests/Support/DialogueFixtures.php create mode 100644 tests/Unit/DialoguePortraitsTerminalTest.php create mode 100644 tests/Unit/Session/DialoguePortraitsSessionTest.php diff --git a/docs/manual.md b/docs/manual.md index 6d5cf6c..c29c233 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1230,6 +1230,8 @@ skill's effects, a quest's rewards) listed under them. | Configuration | `config.php` (`save`, `accessibility`, `ui`, `graphics`, `audio`, `inn`) | Editable - one row per setting, typed by what it holds; field zoom is kept from 1 to 8 | Same | | Types | `assets/Data/system.php` | Editable - the project's elements, one per row; an empty list means the Engine's defaults. Weapon, armor and equipment types are the Engine's own | Same | | Terms | `config.php` (`vocab`, `messages`) | Literal terms editable; comments and unrelated expressions preserved | Same | +| Dialogue Portraits | `assets/Data/Presentation/dialogue.php` (`actors`, `resources`) | Editable - one record per speaker identity: what it speaks as (an actor, chosen, or a non-actor character with a stable id), its dialogue portrait and skit bust, and its emotions, each a name with its own portrait and bust, chosen as project images | Same, with each image shown beside its path | +| Speaker Names | `assets/Data/Presentation/dialogue.php` (`speakers`) | Editable - one record per label lines are spoken under, and the dialogue portrait it shows | Same | | Place Categories | `assets/Data/map-categories.php` (`categories`) | Editable - one record per category: stable id, name and translation key, and the generic category it specializes | Same | | Player Field Appearance | `assets/Data/Entities/player.php` | Not shown: graphical ownership and art | Choose fixed-player, selected party leader, or absent legacy selector; fixed sheet picker with index/layer controls | | Field Resources | `assets/Data/Presentation/field.php` (`resources`) | Not shown: graphical resources | One record per named resource, keyed by its stable id: name, ground contact, an image or a sign board, and an optional occupancy stamp | @@ -1845,7 +1847,9 @@ scans the directory and keys skits by their `id` field, not by filename, so renaming an `Id` in the editor does not require renaming the file. Fields: `Id`, `Title`, `Where (map id)`, `Conditions`, `Speed (chars/sec)`, -then one pair of `Beat N Speaker` / `Beat N Text` rows per beat. +then each beat's `Actor` or `Non-actor Speaker`, its `Emotion` and its `Text`. +The emotion is chosen among the speaker's emotions (see +[Dialogue Portraits and Emotions](#dialogue-portraits-and-emotions)). - `Where` gates the skit to one map id, matched case-insensitively. Leave it empty to make the skit available anywhere. @@ -1858,6 +1862,43 @@ then one pair of `Beat N Speaker` / `Beat N Text` rows per beat. Current behavior: a skit plays at most once per save file, and the engine announces availability rather than interrupting - the player presses `T`. +### Dialogue Portraits and Emotions + +The game's dialogue catalogue, `assets/Data/Presentation/dialogue.php`, says +whose artwork a line shows. Two pages edit it. + +**Dialogue Portraits** holds one record per speaker identity. `Speaks As` +says whether it is an actor's identity, chosen among the project's actors +by stable id, or a non-actor character (a parent, a shopkeeper) with a +stable id of its own. Each identity has a `Dialogue Portrait` (the dialogue +box's) and a `Skit Bust` (the skit stage's), and an `Emotions` list: each +emotion has a `Name` and its own portrait and bust. Images are chosen with +the shared project image picker and stored as paths under `assets`; a missing +emotion image falls back to Neutral's and then the identity's own, as the +game draws it. `Shift+O` and `Shift+X` add and remove emotions. + +**Speaker Names** binds the labels lines are spoken under ("Mom", "Fira Nel") +to an identity. A line spoken under a label shows that identity's artwork; a +label the page does not list shows none. Several labels may share one +identity. + +Every line the game reads an emotion for has an `Emotion` row: an event or +cinematic `text` command, an NPC's dialogue line, a skit beat, and a map +Dialogue or Shop event's page. It is chosen among the emotions of the line's +speaker: an actor id names its identity directly, and a label is resolved +through Speaker Names (an NPC line with no speaker of its own speaks under +the NPC's name). `Neutral` is always offered and is what a line without an +emotion shows. A line that holds an emotion its speaker lacks keeps it, and +an `Emotion Warning` row under it says so; the game shows the identity's +Neutral artwork until that emotion exists. + +A catalogue that builds its identities with PHP code (a loop over +directories) is shown read-only on Dialogue Portraits, saying so, since a +save would replace that code with its result; Speaker Names stays editable +when its labels are plain data. Validation reports a speaker name bound to +no identity, an image the game cannot draw, and a line emotion its speaker +lacks. + ### Common Events Cutscene and event command lists, one file per script under `assets/Events`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 0062962..844275b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -193,6 +193,34 @@ Acceptance criteria for the presentation-authoring slice: stable IDs, same-path replacement PNGs, missing/invalid/out-of-root assets, unsupported PHP and failed writes. Unrelated data must remain unchanged. +Dialogue portraits and emotions (status, 2026-10-18): the Engine's dialogue +catalogue (`assets/Data/Presentation/dialogue.php`, `ichiloto.dialogue/1`) is +authored in both editors. Dialogue Portraits edits its identities (`actors` +keyed by stable actor id, `resources` for non-actor characters) as one page, +each with a dialogue portrait, a skit bust and keyed emotions with their own +pictures, chosen with the shared PNG picker and stored asset-relative. Speaker +Names edits `speakers`. Every line the Engine reads `emotion` from (event and +cinematic `text`, NPC lines, skit beats, Dialogue and Shop event pages) offers +the emotions of the identity its speaker resolves to through the Engine's own +`resolveSpeakerId`; an emotion the identity lacks is kept and warned about, in +the row and in validation. A catalogue that builds its identities with PHP code +is read-only on Dialogue Portraits, saying so (Last Legend's builds them in a +loop over directories, so its identities are authored there once the Game +writes them as data); its literal speaker names stay editable. The GUI shows an +identity's pictures and its emotions as a grid to choose from. Remaining: +- Character forms (an identity's vampiric or transformed artwork beside its + usual, chosen from game state): waits on the Engine contract requested on + 2026-10-18. A form will be another artwork set under the identity, reusing + `DialoguePortraitFields::getArtworkFields()` and the emotions list, and each + line's emotion choices will come from the identity's form as the Engine + resolves it. +- The Engine's skit beat check reads emotions from `actors` only, so a + non-actor skit speaker shows Neutral whatever the beat asks for; reported to + the Engine. +- The Engine states the catalogue's schema id and keys only inside + `DialoguePresentationCatalog::load()`; `DialogueCatalogFile` names them once + until the Engine exports them. + Effect timelines (planned, follows the Engine's effect presentation contract in its docs/effect-animation.md): an effect is either one flat sequence, still supported, or `presentations` with two complete, independent sequences, diff --git a/src/Database/DatabaseCatalog.php b/src/Database/DatabaseCatalog.php index 2a58c2c..1505b5d 100644 --- a/src/Database/DatabaseCatalog.php +++ b/src/Database/DatabaseCatalog.php @@ -35,6 +35,8 @@ public static function all(): array new DatabaseCategoryDefinition('common_events', 'Common Events', 'Author cutscene and event command lists.', true), new DatabaseCategoryDefinition('quests', 'Quests', 'Author quests, objectives, and rewards.', true), new DatabaseCategoryDefinition('skits', 'Skits', 'Write optional party banter scenes.', true), + new DatabaseCategoryDefinition(\Ichiloto\Editor\Dialogue\DialoguePortraitFields::CATEGORY, 'Dialogue Portraits', 'Choose the portraits, skit busts and emotions dialogue shows for each speaker.', true), + new DatabaseCategoryDefinition(\Ichiloto\Editor\Dialogue\DialogueSpeakerFields::CATEGORY, 'Speaker Names', 'Bind the names lines are spoken under to the portraits they show.', true), new DatabaseCategoryDefinition('knowledge_subjects', 'Knowledge', 'Author what the party can come to know.', true), new DatabaseCategoryDefinition('knowledge_reports', 'Knowledge Reports', 'Author claims about knowledge subjects.', true), new DatabaseCategoryDefinition('knowledge_record_types', 'Knowledge Types', 'Declare the kinds of record a subject can be.', true), diff --git a/src/Database/OwnsFileKeys.php b/src/Database/OwnsFileKeys.php new file mode 100644 index 0000000..bdc1a45 --- /dev/null +++ b/src/Database/OwnsFileKeys.php @@ -0,0 +1,20 @@ + + */ + public function listOwnedKeys(): array; +} diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index a502a7f..2272b77 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -7,6 +7,10 @@ use Ichiloto\Editor\Field\PlayerPresentationFields; use Ichiloto\Editor\IO\AtomicFile; +use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; +use Ichiloto\Editor\Cutscenes\Source\SourceNode; +use Ichiloto\Editor\Cutscenes\Source\SourceUnreadable; +use Ichiloto\Editor\Dialogue\DialogueEmotionFields; use BackedEnum; use Throwable; @@ -533,6 +537,25 @@ public function getBackupPaths(): array return $paths; } + /** + * The rows one field of a list entry shows: its own, and for a dialogue line's emotion the speaker's catalogue + * emotions as its choices, with a warning row when the line holds one the speaker lacks. + * + * @param array $row The field's row. + * @param array $entry The entry it belongs to. + * @param array $owner The record holding the entry. + * @return list> + */ + private function describeEntryField(RecordField $field, array $row, array $entry, array $owner): array + { + if ($field->dialogueSpeaker === null || $this->authoringReferences === null) { + return [$row]; + } + + return DialogueEmotionFields::describeRows($row, self::readNested($entry, $field->key), + $this->authoringReferences->describeDialogueEmotions($field->dialogueSpeaker, $entry, $owner)); + } + /** * Builds the settings-pane field descriptors for one record. * @@ -590,7 +613,7 @@ public function getSettingsFields(int $index): array } foreach ($record->getEntries($subList) as $entryIndex => $entry) { - $fields = [...$fields, ...$this->describeSubEntryFields($subList, $entryIndex, $entry, $isEditable, [])]; + $fields = [...$fields, ...$this->describeSubEntryFields($subList, $entryIndex, $entry, $isEditable, [], (array) $record->toArray())]; } } @@ -608,23 +631,24 @@ public function getSettingsFields(int $index): array * @param array $entry The entry payload. * @param bool $isEditable Whether edits are accepted. * @param array $basePath The frame path this entry's list lives at. + * @param array $owner The record the entry belongs to, whose own name an NPC's line speaks under. * @return array> The field descriptors. */ - private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, array $entry, bool $isEditable, array $basePath): array + private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, array $entry, bool $isEditable, array $basePath, array $owner = []): array { $fields = []; // Each row names the entry it belongs to and its own name within it, so an // interface can show one selected entry's rows, labelled as that entry's. foreach ($subList->fieldsFor($entry) as $field) { - $fields[] = [...self::describeField( + $fields = [...$fields, ...$this->describeEntryField($field, [...self::describeField( $field, self::displayValue($field, self::readNested($entry, $field->key)), self::subFieldId($subList->prefix, $entryIndex, $field->key), $isEditable, sprintf('%s %d %s', ucfirst($subList->singular), $entryIndex + 1, $field->label), ), 'entry' => [$entryIndex], 'entryLabel' => sprintf('%s %d', ucfirst($subList->singular), $entryIndex + 1), 'name' => $field->label, 'sourceKey' => $field->key, - 'dataPath' => [...($basePath === [] ? explode('.', $subList->key) : $basePath), $entryIndex, ...explode('.', $field->key)]]; + 'dataPath' => [...($basePath === [] ? explode('.', $subList->key) : $basePath), $entryIndex, ...explode('.', $field->key)]], $entry, $owner)]; } $variant = $subList->variantKey !== null ? strval($entry[$subList->variantKey] ?? '') : ''; @@ -705,7 +729,7 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, } foreach ($nestedList->fieldsFor($nestedEntry) as $field) { - $fields[] = ['entry' => [$entryIndex, $nestedIndex], 'entryLabel' => sprintf('%s %d', ucfirst($nestedList->singular), $nestedIndex + 1), + $fields = [...$fields, ...$this->describeEntryField($field, ['entry' => [$entryIndex, $nestedIndex], 'entryLabel' => sprintf('%s %d', ucfirst($nestedList->singular), $nestedIndex + 1), 'name' => $field->label, 'sourceKey' => $field->key, 'dataPath' => [...($basePath === [] ? explode('.', $subList->key) : $basePath), $entryIndex, ...explode('.', $nestedList->key), $nestedIndex, ...explode('.', $field->key)]] + self::describeField( $field, @@ -726,7 +750,7 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, $nestedIndex + 1, $field->label, ), - ); + ), $nestedEntry, $owner)]; } foreach ($nestedList->armsFor($nestedEntry) as $armKey => $armLabel) { @@ -1867,7 +1891,8 @@ private static function resolveReadOnlyReason(RecordSchema $schema, PhpDataFile } if ($schema->projection !== null && $file->exists) { - $projectionIssue = self::projectionPreservationIssueFor($schema->projection, $file->payload); + $projectionIssue = self::projectionPreservationIssueFor($schema->projection, $file->payload) + ?? self::findOwnedKeySourceIssue($schema->projection, $file); if ($projectionIssue !== null) { return sprintf('%s %s', basename($file->path), $projectionIssue); @@ -1885,6 +1910,53 @@ private static function resolveReadOnlyReason(RecordSchema $schema, PhpDataFile return null; } + /** + * Why a projection's own keys cannot be written as data in the file's source: the file builds one, or an array + * inside one, in a variable with its own code (a loop, a required file), which a save would replace with a + * literal of what the code produced while the code went on running for nothing. + */ + private static function findOwnedKeySourceIssue(RecordProjection $projection, PhpDataFile $file): ?string + { + if (! $projection instanceof OwnsFileKeys || ! is_file($file->path) || ! is_array($file->payload)) { + return null; + } + try { + $document = PhpArraySourceDocument::parse((string) file_get_contents($file->path)); + } catch (SourceUnreadable) { + // A file that is not one returned array is saved by regeneration, which PhpDataFile already guards. + return null; + } + foreach ($projection->listOwnedKeys() as $key) { + $node = $document->nodeAt([$key]); + $variable = $node === null ? null : self::findArrayVariable($node, $file->payload[$key] ?? null); + if ($variable !== null) { + return sprintf('builds "%s" with its own PHP code in $%s, which the editor will not replace with plain data; write it as data to edit it here', $key, $variable); + } + } + + return null; + } + + /** The first variable under a source node that holds an array in the evaluated value, or null. */ + private static function findArrayVariable(SourceNode $node, mixed $value): ?string + { + if ($node->kind === SourceNode::VARIABLE) { + return is_array($value) ? ($node->variable ?? '') : null; + } + if ($node->kind !== SourceNode::ARRAY || ! is_array($value)) { + return null; + } + foreach ($node->entries as $position => $entry) { + $key = $node->isList ? $position : $entry->key; + $found = (is_int($key) || is_string($key)) && array_key_exists($key, $value) ? self::findArrayVariable($entry->value, $value[$key]) : null; + if ($found !== null) { + return $found; + } + } + + return null; + } + /** * Checks both projection-specific malformed shapes and the exact * read/write round trip. The latter is the final guard against a new @@ -3989,7 +4061,7 @@ public function getFrameSettingsFields(int $recordIndex, array $framePath): arra foreach ($commands as $entryIndex => $entry) { if (is_array($entry)) { - $fields = [...$fields, ...$this->describeSubEntryFields($subList, $entryIndex, $entry, $isEditable, $framePath)]; + $fields = [...$fields, ...$this->describeSubEntryFields($subList, $entryIndex, $entry, $isEditable, $framePath, (array) $record->toArray())]; } } diff --git a/src/Database/Projections/GroupedDictionaryProjection.php b/src/Database/Projections/GroupedDictionaryProjection.php new file mode 100644 index 0000000..60b3b4a --- /dev/null +++ b/src/Database/Projections/GroupedDictionaryProjection.php @@ -0,0 +1,118 @@ + $groups Each file key holding a dictionary, with the group value its records carry. + * @param string $identityKey The record field the dictionary key is read into and written from. + * @param string $groupKey The record field naming the group a record belongs to. + * @param array $fileKeys Keys a file must hold that its records do not, such as a schema id, + * written when the file has none of its own; an authored value is never replaced. + */ + public function __construct(private array $groups, private string $identityKey = 'id', private string $groupKey = 'group', + private array $fileKeys = []) + { + } + + /** @inheritDoc */ + public function listOwnedKeys(): array + { + return array_keys($this->groups); + } + + /** @inheritDoc */ + public function read(array $whole): array + { + $rows = []; + foreach ($this->groups as $key => $group) { + foreach (is_array($whole[$key] ?? null) ? $whole[$key] : [] as $id => $entry) { + $rows[] = [$this->identityKey => (string) $id, $this->groupKey => $group, ...(is_array($entry) ? $entry : [])]; + } + } + + return $rows; + } + + /** @inheritDoc */ + public function write(array $whole, array $rows): array + { + $dictionaries = array_fill_keys(array_keys($this->groups), []); + foreach ($rows as $row) { + $key = array_search($row[$this->groupKey] ?? null, $this->groups, true); + $key = is_string($key) ? $key : array_key_first($this->groups); + $id = (string) ($row[$this->identityKey] ?? ''); + unset($row[$this->identityKey], $row[$this->groupKey]); + $dictionaries[$key][$id] = $row; + } + $written = $whole; + foreach ($dictionaries as $key => $dictionary) { + if ($dictionary !== []) { + $written[$key] = $dictionary; + } elseif (is_array($whole[$key] ?? null) && $whole[$key] !== []) { + // A group this write empties goes, so moving its last record away and back leaves the file as it was; + // one the file holds empty stays as authored. + unset($written[$key]); + } + } + if ($written === $whole) { + return $whole; + } + + return [...$this->fileKeys, ...$written]; + } + + /** @inheritDoc */ + public function preservationIssue(mixed $whole): ?string + { + if (! is_array($whole)) { + return sprintf('returns %s, not an array', get_debug_type($whole)); + } + $seen = []; + foreach (array_keys($this->groups) as $key) { + if (! array_key_exists($key, $whole)) { + continue; + } + $dictionary = $whole[$key]; + if (! is_array($dictionary) || ($dictionary !== [] && array_is_list($dictionary))) { + return sprintf('field "%s" is %s, not a dictionary keyed by id', $key, is_array($dictionary) ? 'an ordered list' : get_debug_type($dictionary)); + } + foreach ($dictionary as $id => $entry) { + if (! is_string($id) || ! is_array($entry) || array_key_exists($this->identityKey, $entry) || array_key_exists($this->groupKey, $entry)) { + return sprintf('field "%s" entry "%s" is not a record the editor can preserve under its key', $key, $id); + } + if (isset($seen[$id])) { + return sprintf('names "%s" in both "%s" and "%s"', $id, $seen[$id], $key); + } + $seen[$id] = $key; + } + } + + return null; + } + + /** + * @inheritDoc + * + * Records are written grouped by their dictionary, so an order across groups is not the file's. + */ + public function ordersRecords(): bool + { + return false; + } +} diff --git a/src/Database/Projections/KeyedDictionaryProjection.php b/src/Database/Projections/KeyedDictionaryProjection.php index 89e11a0..c0ee00e 100644 --- a/src/Database/Projections/KeyedDictionaryProjection.php +++ b/src/Database/Projections/KeyedDictionaryProjection.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor\Database\Projections; +use Ichiloto\Editor\Database\OwnsFileKeys; use Ichiloto\Editor\Database\RecordProjection; /** @@ -13,22 +14,32 @@ * The field presentation catalogue keys its named resources by id beside its * cues and action prompt. Each record reads with its key as its identity * field, and writes back under that key, in record order; the keys this - * category does not own are written back exactly as they were read. + * category does not own are written back exactly as they were read. A + * dictionary of plain values (a catalogue's speaker names, each naming an + * identity) reads each value into one record field. * * @package Ichiloto\Editor\Database\Projections */ -final readonly class KeyedDictionaryProjection implements RecordProjection +final readonly class KeyedDictionaryProjection implements RecordProjection, OwnsFileKeys { /** * @param string $key The key holding this category's dictionary. * @param string $identityKey The record field the dictionary key is read into and written from. * @param array $fileKeys Keys a file must hold that its records do not, such as a schema id, * written when the file has none of its own; an authored value is never replaced. + * @param string|null $valueKey For a dictionary of plain values, the record field each value is read into. */ - public function __construct(private string $key, private string $identityKey = 'id', private array $fileKeys = []) + public function __construct(private string $key, private string $identityKey = 'id', private array $fileKeys = [], + private ?string $valueKey = null) { } + /** @inheritDoc */ + public function listOwnedKeys(): array + { + return [$this->key]; + } + /** * @inheritDoc */ @@ -41,7 +52,9 @@ public function read(array $whole): array $rows = []; foreach ($dictionary as $id => $entry) { - $rows[] = [$this->identityKey => (string) $id, ...(is_array($entry) ? $entry : [])]; + $rows[] = $this->valueKey !== null + ? [$this->identityKey => (string) $id, $this->valueKey => $entry] + : [$this->identityKey => (string) $id, ...(is_array($entry) ? $entry : [])]; } return $rows; @@ -56,7 +69,7 @@ public function write(array $whole, array $rows): array foreach ($rows as $row) { $id = (string) ($row[$this->identityKey] ?? ''); unset($row[$this->identityKey]); - $dictionary[$id] = $row; + $dictionary[$id] = $this->valueKey !== null ? ($row[$this->valueKey] ?? null) : $row; } if ($dictionary === [] && ! array_key_exists($this->key, $whole)) { return $whole; @@ -84,6 +97,12 @@ public function preservationIssue(mixed $whole): ?string } foreach ($dictionary as $id => $entry) { + if ($this->valueKey !== null) { + if (! is_string($id) || ! is_scalar($entry)) { + return sprintf('field "%s" entry "%s" is not a plain value the editor can preserve under its key', $this->key, $id); + } + continue; + } if (! is_string($id) || ! is_array($entry) || array_key_exists($this->identityKey, $entry)) { return sprintf('field "%s" entry "%s" is not a record the editor can preserve under its key', $this->key, $id); } diff --git a/src/Database/Projections/KeyedListProjection.php b/src/Database/Projections/KeyedListProjection.php index 7acaf4d..ef82f9f 100644 --- a/src/Database/Projections/KeyedListProjection.php +++ b/src/Database/Projections/KeyedListProjection.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor\Database\Projections; +use Ichiloto\Editor\Database\OwnsFileKeys; use Ichiloto\Editor\Database\RecordProjection; /** @@ -16,7 +17,7 @@ * * @package Ichiloto\Editor\Database\Projections */ -final readonly class KeyedListProjection implements RecordProjection +final readonly class KeyedListProjection implements RecordProjection, OwnsFileKeys { /** * @param string $key The key holding this category's list. @@ -25,6 +26,12 @@ public function __construct(private string $key) { } + /** @inheritDoc */ + public function listOwnedKeys(): array + { + return [$this->key]; + } + /** * @inheritDoc */ diff --git a/src/Database/RecordField.php b/src/Database/RecordField.php index 0704790..3abfa58 100644 --- a/src/Database/RecordField.php +++ b/src/Database/RecordField.php @@ -4,7 +4,9 @@ namespace Ichiloto\Editor\Database; +use Ichiloto\Editor\Dialogue\DialogueLineSpeaker; use Ichiloto\Editor\Inspector\InputControlType; +use Ichiloto\Engine\Messaging\Dialogue\Presentation\DialogueContext; /** * One editable field on a Database record. @@ -60,9 +62,23 @@ public function __construct( public ?string $blankLabel = null, public ?array $writeTypes = null, public bool $uniqueAcrossRecords = false, + /** For a line's emotion, how the line names its speaker, whose catalogue emotions it is chosen from. */ + public ?DialogueLineSpeaker $dialogueSpeaker = null, ) { } + /** + * Returns a dialogue line's emotion: chosen among the emotions the line's speaker has in the game's dialogue + * catalogue, as the rule says who speaks. Absent, the Engine shows Neutral. + * + * @param DialogueLineSpeaker $speaker How the line names its speaker. + * @return self The field. + */ + public static function emotion(DialogueLineSpeaker $speaker): self + { + return new self('emotion', 'Emotion', removeWhenEmpty: true, displayDefault: DialogueContext::DEFAULT_EMOTION, dialogueSpeaker: $speaker); + } + /** * Returns a field that names another resource. * @@ -126,6 +142,7 @@ public function asReadOnly(): self $this->displayDefault, $this->blankLabel, $this->writeTypes, + dialogueSpeaker: $this->dialogueSpeaker, ); } diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index fe6ec21..cd3d4a6 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -4,6 +4,9 @@ namespace Ichiloto\Editor\Database; +use Ichiloto\Editor\Dialogue\DialogueLineSpeaker; +use Ichiloto\Editor\Dialogue\DialoguePortraitFields; +use Ichiloto\Editor\Dialogue\DialogueSpeakerFields; use Ichiloto\Editor\Field\PlayerPresentationFields; use Closure; @@ -130,6 +133,8 @@ public static function all(bool $graphical = false): array self::commonEvents($graphical), self::terms(), \Ichiloto\Editor\Field\MapCategoryFields::getSchema(), + DialoguePortraitFields::getSchema(), + DialogueSpeakerFields::getSchema(), self::configuration($graphical), self::types(), self::tilesets($graphical), @@ -1409,6 +1414,7 @@ private static function skits(): RecordSchema fields: [ RecordField::reference('actor', 'Actor', 'actor_ids', allowsNone: true, noneLabel: '(Non-actor speaker)'), new RecordField('speaker', 'Non-actor Speaker', removeWhenEmpty: true), + RecordField::emotion(DialogueLineSpeaker::forSkitBeat()), new RecordField('text', 'Text'), ], blank: ['actor' => '', 'text' => 'Say something.'], @@ -2025,6 +2031,7 @@ public static function mapNpcs(bool $graphical = false): RecordSchema noneLabel: "(the NPC's name)", blankLabel: '(No speaker)', ), + RecordField::emotion(DialogueLineSpeaker::forNpcLine()), new RecordField('text', 'Text'), ], blank: ['text' => 'Something to say.'], @@ -2132,6 +2139,7 @@ public static function eventCommandVariants(bool $graphical = false): array return [ 'text' => [ new RecordField('name', 'Speaker', removeWhenEmpty: false), + RecordField::emotion(DialogueLineSpeaker::forText()), new RecordField('text', 'Text'), ], 'choice' => [ diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 006939f..6953db2 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -11,6 +11,12 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Cutscenes\CutsceneType; +use Ichiloto\Editor\Dialogue\DialogueCatalogFile; +use Ichiloto\Editor\Dialogue\DialogueEmotionFields; +use Ichiloto\Editor\Dialogue\DialogueLineSpeaker; +use Ichiloto\Editor\Dialogue\DialoguePortraitFields; +use Ichiloto\Editor\Dialogue\DialogueSpeakerFields; +use Ichiloto\Engine\Messaging\Dialogue\Presentation\DialoguePresentationCatalog; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Animations\Field\FieldPresentationCatalog; use Ichiloto\Engine\Rendering\Presentation\Text\ProjectTextCatalog; @@ -109,6 +115,8 @@ final class ReferenceCatalog 'map_regions', 'animation_roles', 'battle_arenas', + // The dialogue catalogue's identities, actor and non-actor, whose artwork lines show. + 'dialogue_identities', ]; /** @@ -303,6 +311,7 @@ public function valuesFor(string $category): array 'field_resources' => array_map(strval(...), array_keys($this->loadFieldResourceNames())), 'text_roles' => $this->loadTextRoleIds(), 'font_assets' => self::getFontAssets($this->workspace->projectRoot), + 'dialogue_identities' => array_column($this->listDialogueIdentities(), 'id'), default => $this->recordValues($category), }; } @@ -721,6 +730,15 @@ public function labelsFor(string $category): array return $labels; } + if ($category === 'dialogue_identities') { + $labels = []; + foreach ($this->listDialogueIdentities() as $identity) { + $labels[$identity['id']] = $identity['label']; + } + + return $labels; + } + if ($category === 'animation_ids') { $labels = []; foreach ($this->animationRecords() as $animation) { @@ -1104,6 +1122,82 @@ public function listMaps(): array * * @throws \InvalidArgumentException When the project's catalogue cannot be read. */ + /** + * The project's dialogue catalogue as the Engine makes it, from its Dialogue Portraits and Speaker Names pages + * with their unsaved edits, or from the file when those pages are not open. + * + * @throws \InvalidArgumentException|\RuntimeException When the Engine would refuse the catalogue. + */ + public function getDialoguePresentationCatalog(): DialoguePresentationCatalog + { + $whole = $this->getDialogueCatalogPayload(); + + return $whole === null ? DialoguePresentationCatalog::load($this->getAssetRoot()) : DialogueCatalogFile::createCatalog($whole); + } + + /** + * The identities and speaker names of the project's dialogue catalogue as its Dialogue Portraits and Speaker + * Names pages hold them, unsaved edits included, in the catalogue's own shape; null when those pages are not + * open, where the file is the Engine's to read. + * + * @return array|null + */ + public function getDialogueCatalogPayload(): ?array + { + $portraits = $this->workspace->getRecordDatabase(DialoguePortraitFields::CATEGORY); + $speakers = $this->workspace->getRecordDatabase(DialogueSpeakerFields::CATEGORY); + if ($portraits?->schema->projection === null || $speakers?->schema->projection === null) { + return null; + } + $rows = static fn(ProjectRecordDatabase $database): array => array_map( + static fn(ProjectRecord $record): array => (array) $record->toArray(), $database->getRecords()); + $whole = $portraits->schema->projection->write(['schema' => DialogueCatalogFile::SCHEMA], $rows($portraits)); + + return $speakers->schema->projection->write($whole, $rows($speakers)); + } + + /** + * The catalogue's identities, actors first, each with how a picker shows it. + * + * @return list + */ + private function listDialogueIdentities(): array + { + try { + $catalogue = $this->getDialoguePresentationCatalog(); + } catch (Throwable) { + // Validation reports a catalogue that cannot be read; nothing can be chosen from it. + return []; + } + $identities = []; + foreach (array_keys($catalogue->actors) as $id) { + $identities[] = ['id' => (string) $id, 'label' => sprintf('%s (actor)', $id)]; + } + foreach (array_keys($catalogue->resources) as $id) { + $identities[] = ['id' => (string) $id, 'label' => sprintf('%s (character)', $id)]; + } + + return $identities; + } + + /** + * The emotions one line may show, as its speaker's catalogue identity names them: who speaks, the identity + * whose artwork shows (null when the speaker has none), the emotions offered, and why none beyond Neutral are + * offered when the catalogue cannot be read. + * + * @return array{speaker: string, identity: ?string, emotions: list, issue: ?string} + */ + public function describeDialogueEmotions(DialogueLineSpeaker $rule, array $line, array $owner = []): array + { + try { + $catalogue = $this->getDialoguePresentationCatalog(); + } catch (Throwable $error) { + return DialogueEmotionFields::describeUnreadableCatalogue($rule, $line, $owner, $error); + } + + return DialogueEmotionFields::describeChoices($rule, $line, $owner, $catalogue); + } + public function getMapCategoryCatalog(): \Ichiloto\Engine\Field\MapCategoryCatalog { // The project's own categories as the Place Categories page holds them, unsaved edits included. diff --git a/src/Dialogue/DialogueCatalogFile.php b/src/Dialogue/DialogueCatalogFile.php new file mode 100644 index 0000000..c037ca2 --- /dev/null +++ b/src/Dialogue/DialogueCatalogFile.php @@ -0,0 +1,93 @@ +getMessage(); + } + + return null; + } + + /** The record an identity has in the catalogue, actor or not, or null when it has none. */ + public static function findIdentity(DialoguePresentationCatalog $catalogue, ?string $identity): ?array + { + if ($identity === null) { + return null; + } + $record = $catalogue->actors[$identity] ?? $catalogue->resources[$identity] ?? null; + + return is_array($record) ? $record : null; + } + + /** + * The emotions a line spoken by an identity may show: Neutral, which the Engine always accepts and falls back + * to, then every emotion the identity names, in the catalogue's order. + * + * @return list + */ + public static function listEmotions(DialoguePresentationCatalog $catalogue, ?string $identity): array + { + $emotions = self::findIdentity($catalogue, $identity)['emotions'] ?? []; + $emotions = is_array($emotions) ? array_keys($emotions) : []; + + return array_values(array_unique([DialogueContext::DEFAULT_EMOTION, ...array_map(strval(...), $emotions)])); + } +} diff --git a/src/Dialogue/DialogueEmotionFields.php b/src/Dialogue/DialogueEmotionFields.php new file mode 100644 index 0000000..d25fe21 --- /dev/null +++ b/src/Dialogue/DialogueEmotionFields.php @@ -0,0 +1,101 @@ +, issue: ?string} + */ + public static function describeChoices(DialogueLineSpeaker $rule, array $line, array $owner, DialoguePresentationCatalog $catalogue): array + { + $identity = $rule->resolveIdentity($line, $owner, $catalogue); + $identity = DialogueCatalogFile::findIdentity($catalogue, $identity) === null ? null : $identity; + + return ['speaker' => $rule->describeSpeaker($line, $owner), 'identity' => $identity, + 'emotions' => DialogueCatalogFile::listEmotions($catalogue, $identity), 'issue' => null]; + } + + /** + * A line's choices while the catalogue cannot be read: Neutral, which the Engine always shows, and why. + * + * @return array{speaker: string, identity: ?string, emotions: list, issue: ?string} + */ + public static function describeUnreadableCatalogue(DialogueLineSpeaker $rule, array $line, array $owner, Throwable $error): array + { + return ['speaker' => $rule->describeSpeaker($line, $owner), 'identity' => null, + 'emotions' => [DialogueContext::DEFAULT_EMOTION], 'issue' => 'The dialogue catalogue cannot be read: ' . $error->getMessage()]; + } + + /** + * The emotion row with its choices, and the warning row after it when the value is not one of them. + * + * @param array $row The emotion row as the record describes it. + * @param mixed $current The line's emotion as authored, null when it has none. + * @param array{speaker: string, identity: ?string, emotions: list, issue: ?string} $emotions + * @return list> + */ + public static function describeRows(array $row, mixed $current, array $emotions): array + { + if (($row['editable'] ?? true) !== false) { + // Chosen, never typed: the speaker's emotions are the choices. + unset($row['control']); + $row['options'] = $emotions['emotions']; + $row['choices'] = array_map(static fn(string $emotion): array => ['value' => $emotion, 'label' => $emotion], $emotions['emotions']); + } + $warning = self::describeWarning($current, $emotions); + if ($warning === null) { + return [$row]; + } + + return [$row, array_filter([ + 'label' => preg_replace('/Emotion$/', 'Emotion Warning', (string) ($row['label'] ?? 'Emotion')), + 'value' => $warning, + 'editable' => false, + 'tone' => 'warning', + 'entry' => $row['entry'] ?? null, + 'entryLabel' => $row['entryLabel'] ?? null, + 'name' => 'Emotion Warning', + ], static fn(mixed $value): bool => $value !== null)]; + } + + /** + * Why a line's emotion is not one its speaker's identity offers, or null when it is. + * + * @param array{speaker: string, identity: ?string, emotions: list, issue: ?string} $emotions + */ + public static function describeWarning(mixed $current, array $emotions): ?string + { + if ($current === null || $current === '' || $current === DialogueContext::DEFAULT_EMOTION || in_array($current, $emotions['emotions'], true)) { + return null; + } + $emotion = is_scalar($current) ? (string) $current : get_debug_type($current); + if ($emotions['issue'] !== null) { + return $emotions['issue']; + } + if ($emotions['identity'] === null) { + return $emotions['speaker'] === '' + ? sprintf('The line has no speaker, so "%s" shows no portrait.', $emotion) + : sprintf('%s has no dialogue portrait, so "%s" shows none. Bind the name under Speaker Names.', $emotions['speaker'], $emotion); + } + + return sprintf('%s has no "%s" emotion; the game shows its Neutral artwork until one is added under Dialogue Portraits.', + $emotions['identity'], $emotion); + } +} diff --git a/src/Dialogue/DialogueLineSpeaker.php b/src/Dialogue/DialogueLineSpeaker.php new file mode 100644 index 0000000..f8849bf --- /dev/null +++ b/src/Dialogue/DialogueLineSpeaker.php @@ -0,0 +1,77 @@ +actorKey === null ? null : ($line[$this->actorKey] ?? null); + + return is_string($actor) && $actor !== '' ? $actor : null; + } + + /** The label a line is spoken under, as the Engine shows it. */ + public function getSpeakerName(array $line, array $owner = []): string + { + if (array_key_exists($this->nameKey, $line) || $this->ownerNameKey === null) { + return is_scalar($line[$this->nameKey] ?? null) ? (string) $line[$this->nameKey] : ''; + } + + return is_scalar($owner[$this->ownerNameKey] ?? null) ? (string) $owner[$this->ownerNameKey] : ''; + } + + /** Who a line names, for an author: its actor id or its label. */ + public function describeSpeaker(array $line, array $owner = []): string + { + return $this->getActorId($line) ?? $this->getSpeakerName($line, $owner); + } + + /** The catalogue identity whose artwork a line shows, or null when its speaker has none. */ + public function resolveIdentity(array $line, array $owner, DialoguePresentationCatalog $catalogue): ?string + { + $name = $this->getSpeakerName($line, $owner); + + return $catalogue->resolveSpeakerId($this->getActorId($line), $name); + } +} diff --git a/src/Dialogue/DialoguePortraitFields.php b/src/Dialogue/DialoguePortraitFields.php new file mode 100644 index 0000000..357ca3e --- /dev/null +++ b/src/Dialogue/DialoguePortraitFields.php @@ -0,0 +1,122 @@ + self::ACTOR, 'resources' => self::CHARACTER], 'id', self::KIND, + fileKeys: ['schema' => DialogueCatalogFile::SCHEMA]), + fieldsFor: self::getFields(...), + labelFor: self::describeLabel(...), + subLists: [self::getEmotionList()], + saveCheck: DialogueCatalogFile::checkCatalog(...), + prepareEdit: self::checkEdit(...), + ); + } + + /** + * A new identity: a character under the given name as its stable id, with no artwork yet for the author to + * choose. Speaks As turns it into an actor's identity, chosen among the project's actors. + */ + public static function makeBlank(string $name, string $projectRoot): array + { + if (trim($name) === '') { + throw new RecordRefusal('A dialogue portrait needs a stable id.'); + } + + return ['id' => trim($name), self::KIND => self::CHARACTER]; + } + + /** @return list */ + public static function getFields(array $entry): array + { + $actor = ($entry[self::KIND] ?? null) === self::ACTOR; + + return [ + new RecordField(self::KIND, 'Speaks As', options: [self::ACTOR, self::CHARACTER]), + // An actor's identity is its stable id, chosen; a character's is typed once and named by speaker names. + $actor ? RecordField::reference('id', 'Actor', 'actor_ids') : new RecordField('id', 'Stable Id'), + ...self::getArtworkFields(), + ]; + } + + /** + * One artwork set: the dialogue box's portrait and the skit stage's bust, each a project image or none. + * + * @return list + */ + public static function getArtworkFields(): array + { + return [ + new RecordField('portrait', 'Dialogue Portrait', reference: 'png_assets', removeWhenEmpty: true, allowsNone: true, displayDefault: '(none)'), + new RecordField('bust', 'Skit Bust', reference: 'png_assets', removeWhenEmpty: true, allowsNone: true, displayDefault: '(none)'), + ]; + } + + /** + * An identity's emotions, keyed by name: what a line asks for by its `emotion`. A missing emotion's image falls + * back to Neutral's and then the identity's own, as the Engine draws it. An emotion written as a bare image is + * its portrait. + */ + public static function getEmotionList(): RecordSubList + { + return new RecordSubList(key: 'emotions', prefix: 'emotion', singular: 'emotion', + fields: [new RecordField('name', 'Name'), ...self::getArtworkFields()], + blank: ['name' => 'New Emotion'], heading: 'Emotions', scalarKey: 'portrait', removeWhenEmpty: true, keyField: 'name'); + } + + /** An identity reads as its id and what it is. */ + public static function describeLabel(array $entry): string + { + return sprintf('%s (%s)', (string) ($entry['id'] ?? ''), ($entry[self::KIND] ?? null) === self::ACTOR ? 'actor' : 'character'); + } + + /** Refuses an id the Engine could not key the identity by, as it is typed: it is never empty. */ + public static function checkEdit(array $entry, string $field): array + { + if ($field === 'id' && (! is_string($entry['id'] ?? null) || trim($entry['id']) === '')) { + throw new RecordRefusal('A dialogue portrait needs a stable id.'); + } + + return $entry; + } +} diff --git a/src/Dialogue/DialogueSpeakerFields.php b/src/Dialogue/DialogueSpeakerFields.php new file mode 100644 index 0000000..7ac00f4 --- /dev/null +++ b/src/Dialogue/DialogueSpeakerFields.php @@ -0,0 +1,86 @@ + DialogueCatalogFile::SCHEMA], valueKey: 'identity'), + labelFor: self::describeLabel(...), + saveCheck: DialogueCatalogFile::checkCatalog(...), + prepareEdit: self::checkEdit(...), + ); + } + + /** + * A new label, which saves at once as the Engine admits it: bound to the identity of that id when there is one, + * else to the catalogue's first identity for the author to change, as a new text role starts on the project's + * first font. + */ + public static function makeBlank(string $name, string $projectRoot): array + { + if (trim($name) === '') { + throw new RecordRefusal('A speaker name is the label lines are spoken under; it cannot be empty.'); + } + try { + $catalogue = DialoguePresentationCatalog::load(rtrim($projectRoot, '/') . '/assets'); + } catch (Throwable $error) { + throw new RecordRefusal('The dialogue catalogue cannot be read: ' . $error->getMessage(), previous: $error); + } + $identities = array_map(strval(...), [...array_keys($catalogue->actors), ...array_keys($catalogue->resources)]); + if ($identities === []) { + throw new RecordRefusal('A speaker name shows a dialogue portrait. Add one under Dialogue Portraits and save it first.'); + } + + return ['name' => $name, 'identity' => in_array($name, $identities, true) ? $name : $identities[0]]; + } + + /** A label reads as itself and whose portrait it shows. */ + public static function describeLabel(array $entry): string + { + return sprintf('%s -> %s', (string) ($entry['name'] ?? ''), (string) ($entry['identity'] ?? '')); + } + + /** A label is matched exactly by the Engine, so it is never empty. */ + public static function checkEdit(array $entry, string $field): array + { + if ($field === 'name' && trim((string) ($entry['name'] ?? '')) === '') { + throw new RecordRefusal('A speaker name is the label lines are spoken under; it cannot be empty.'); + } + + return $entry; + } +} diff --git a/src/Editor.php b/src/Editor.php index 4773be4..e73d8a0 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -2441,7 +2441,9 @@ private function previewedNpcSprite(): ?string private function refreshNpcInspector(): void { $map = $this->getSelectedMap(); - $this->npcInspector = $map instanceof ProjectMap ? new NpcInspector($map) : null; + // The references the session's NPC pane uses: choices that depend on the project, such as a line's emotions. + $this->npcInspector = $map instanceof ProjectMap && $this->workspace instanceof ProjectWorkspace + ? new NpcInspector($map, references: $this->referenceCatalog()) : null; if ($map instanceof ProjectMap && $this->selectedNpcIndex !== null && $map->getNpcs()->get($this->selectedNpcIndex) === null) { $this->selectedNpcIndex = $map->getNpcs()->count() > 0 ? min($this->selectedNpcIndex, $map->getNpcs()->count() - 1) : null; diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index dd35f01..ffa28cf 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -5,6 +5,8 @@ namespace Ichiloto\Editor\Inspector; use Ichiloto\Editor\Database\ConditionCodec; +use Ichiloto\Editor\Dialogue\DialogueEmotionFields; +use Ichiloto\Editor\Dialogue\DialogueLineSpeaker; use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Database\InnPresentationFields; use Ichiloto\Editor\Database\MovementRouteFields; @@ -28,7 +30,11 @@ use Ichiloto\Engine\Events\Enumerations\ChestType; use Ichiloto\Engine\Events\Enumerations\EventEngagement; use Ichiloto\Engine\Events\Enumerations\LootType; +use Ichiloto\Engine\Events\Triggers\DialogueEventTrigger; use Ichiloto\Engine\Events\Triggers\EventCueKind; +use Ichiloto\Engine\Events\Triggers\ShopEventTrigger; +use Ichiloto\Engine\Messaging\Dialogue\ConditionalDialogue; +use Ichiloto\Engine\Messaging\Dialogue\Presentation\DialogueContext; use Ichiloto\Engine\Field\MapGraphics; use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandReference; @@ -1401,6 +1407,7 @@ public function buildEventDataFields(string $marker, array $definition): array $hasPresentation = array_key_exists('presentation', $optional) || array_key_exists('presentation', $eventData); $presentation = $eventData['presentation'] ?? null; if ($hasPresentation) { unset($eventData['presentation'], $optional['presentation']); } + $pages = self::readDialoguePages($definition); $fields = array_map(static function (array $field) use ($optional): array { $path = array_values((array) ($field['path'] ?? [])); if (count($path) === 2 && array_key_exists((string) $path[1], $optional)) { @@ -1410,8 +1417,9 @@ public function buildEventDataFields(string $marker, array $definition): array return $field; }, $this->decorateEventInspectorFields( $marker, - $this->flattenInspectorFields([...$eventData, ...array_diff_key($optional, $eventData)], ['data']), + $this->flattenInspectorFields([...self::withPageEmotions($eventData, $pages), ...array_diff_key($optional, $eventData)], ['data']), )); + $fields = $this->describePageEmotions($fields, $pages); if ($hasPresentation) { $fields = [...$fields, ...$this->buildInnPresentationFields($marker, $presentation)]; } } @@ -1675,6 +1683,85 @@ private function writeInnPresentation(ProjectMap $map, string $marker, mixed $va return new GenericCommand($label, $apply, $undo); } + /** + * The dialogue pages an event shows as the Engine reads them (a Dialogue event's, a shop's merchant lines), by + * their path under the event: each a line whose `name` speaks it and whose `emotion` picks the speaker's artwork. + * A list of conditional variants holds its pages under each variant's `lines`. + * + * @return array, page: array}> By the emotion row's path. + */ + private static function readDialoguePages(array $definition): array + { + $class = ltrim((string) ($definition['class'] ?? ''), '\\'); + $dialogue = $definition['data']['dialogue'] ?? null; + if (! in_array($class, [DialogueEventTrigger::class, ShopEventTrigger::class], true) || ! is_array($dialogue)) { + return []; + } + $pages = []; + $variants = ConditionalDialogue::isVariantList($dialogue); + foreach ($dialogue as $index => $entry) { + $lines = $variants ? ($entry['lines'] ?? null) : [$entry]; + foreach (is_array($lines) ? $lines : [] as $line => $page) { + if (! is_array($page)) { continue; } + $path = $variants ? ['data', 'dialogue', (string) $index, 'lines', (string) $line] : ['data', 'dialogue', (string) $index]; + $pages[implode('/', [...$path, 'emotion'])] = ['path' => $path, 'page' => $page]; + } + } + + return $pages; + } + + /** + * The event data as its rows show it: each dialogue page offers its emotion, Neutral where it names none, which + * stays out of the stored event until an author chooses one. + */ + private static function withPageEmotions(array $eventData, array $pages): array + { + $data = ['data' => $eventData]; + foreach ($pages as $page) { + $entry = &$data; + foreach ($page['path'] as $segment) { $entry = &$entry[$segment]; } + if (! array_key_exists('emotion', $entry)) { + // Shown after the speaker it is chosen for. + $position = array_search('name', array_keys($entry), true); + $at = $position === false ? 0 : $position + 1; + $entry = [...array_slice($entry, 0, $at, true), 'emotion' => DialogueContext::DEFAULT_EMOTION, ...array_slice($entry, $at, null, true)]; + } + unset($entry); + } + + return $data['data']; + } + + /** + * A dialogue page's emotion row offers the emotions its speaker's identity has, and says so when the page holds + * one that identity lacks; one the page leaves out is optional, removed again when chosen away. + * + * @param list> $fields + * @param array, page: array}> $pages + * @return list> + */ + private function describePageEmotions(array $fields, array $pages): array + { + if ($pages === []) { + return $fields; + } + $described = []; + foreach ($fields as $field) { + $page = $pages[implode('/', array_map(strval(...), (array) ($field['path'] ?? [])))] ?? null; + if ($page === null) { + $described[] = $field; + continue; + } + $emotion = $page['page']['emotion'] ?? null; + $field['optional'] = ! array_key_exists('emotion', $page['page']); + array_push($described, ...DialogueEmotionFields::describeRows($field, $emotion, + $this->references->describeDialogueEmotions(DialogueLineSpeaker::forText(), $page['page']))); + } + + return $described; + } + /** * Adds event context, pickers, and enum controls to flattened fields. * diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 6133bc9..66d8812 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -5236,6 +5236,8 @@ private static function describeRow(array $field): array 'writeTypes' => $kind === 'writes' && is_array($field['writeTypes'] ?? null) ? array_values($field['writeTypes']) : null, // One axis of a coordinate pair under the heading before it. 'axis' => in_array($field['axis'] ?? null, ['x', 'y'], true) ? $field['axis'] : null, + // A row that warns about the rows before it, such as a line's emotion its speaker lacks. + 'tone' => ($field['tone'] ?? null) === 'warning' ? 'warning' : null, 'key' => $key, ], static fn(mixed $value): bool => $value !== null); } diff --git a/src/Validation/DialoguePresentationValidator.php b/src/Validation/DialoguePresentationValidator.php new file mode 100644 index 0000000..75acaf3 --- /dev/null +++ b/src/Validation/DialoguePresentationValidator.php @@ -0,0 +1,181 @@ +getDialogueCatalogPayload(); + $issues = $whole === null ? [] : $this->checkSpeakerNames($whole); + try { + $catalogue = $references->getDialoguePresentationCatalog(); + } catch (Throwable $error) { + // A speaker name naming nothing is said above, by name. + return $issues !== [] ? $issues : [Issue::error(self::WHERE, 'The game refuses the dialogue catalogue: ' . $error->getMessage(), + 'Fix it under Dialogue Portraits and Speaker Names; until then no line shows a portrait.')]; + } + + return [...$issues, ...$this->checkArtwork($catalogue, $references->getAssetRoot()), ...$this->checkLines($workspace, $catalogue)]; + } + + /** @return Issue[] Every speaker name that binds no identity the catalogue holds. */ + private function checkSpeakerNames(array $whole): array + { + $identities = [...array_keys((array) ($whole['actors'] ?? [])), ...array_keys((array) ($whole['resources'] ?? []))]; + $issues = []; + foreach ((array) ($whole['speakers'] ?? []) as $name => $identity) { + if (! is_string($identity) || ! in_array($identity, array_map(strval(...), $identities), true)) { + $issues[] = Issue::error(self::WHERE . ':speakers.' . $name, + sprintf('Speaker name "%s" shows %s, which is no dialogue portrait.', $name, is_scalar($identity) ? '"' . $identity . '"' : 'nothing'), + 'Choose one of the Dialogue Portraits for it under Speaker Names, or remove the name. The game refuses the catalogue until then.'); + } + } + + return $issues; + } + + /** @return Issue[] Every portrait and bust the game cannot draw, base and emotion alike. */ + private function checkArtwork(DialoguePresentationCatalog $catalogue, string $assetRoot): array + { + $issues = []; + foreach ([...$catalogue->actors, ...$catalogue->resources] as $identity => $record) { + $sets = ['' => $record]; + foreach ((array) ($record['emotions'] ?? []) as $emotion => $roles) { + $sets[(string) $emotion] = is_string($roles) ? ['portrait' => $roles] : (array) $roles; + } + foreach ($sets as $emotion => $roles) { + foreach (['portrait' => 'dialogue portrait', 'bust' => 'skit bust'] as $role => $noun) { + $asset = $roles[$role] ?? null; + if (! is_string($asset)) { continue; } + try { + PngAssetPreflight::inspect($assetRoot, $asset); + } catch (Throwable $error) { + $issues[] = Issue::warning(self::WHERE . ':' . $identity . ($emotion === '' ? '' : '.' . $emotion) . '.' . $role, + sprintf('%s%s %s %s cannot be drawn: %s', $identity, $emotion === '' ? '\'s' : ' ' . $emotion . '\'s', $noun, $asset, $error->getMessage()), + 'Choose an image under Dialogue Portraits. Until then the game shows the Neutral or base artwork, or none.'); + } + } + } + } + + return $issues; + } + + /** @return Issue[] Every line whose emotion its speaker's identity does not have. */ + private function checkLines(ProjectWorkspace $workspace, DialoguePresentationCatalog $catalogue): array + { + $issues = []; + $check = function (string $where, DialogueLineSpeaker $rule, array $line, array $owner = []) use (&$issues, $catalogue): void { + if (! array_key_exists('emotion', $line)) { return; } + $warning = DialogueEmotionFields::describeWarning($line['emotion'], DialogueEmotionFields::describeChoices($rule, $line, $owner, $catalogue)); + if ($warning !== null) { + $issues[] = Issue::warning($where, $warning); + } + }; + $root = rtrim($workspace->projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $relative = static fn(string $path): string => str_starts_with($path, $root) ? substr($path, strlen($root)) : $path; + + foreach ($workspace->getRecordDatabase('skits')?->getRecords() ?? [] as $record) { + $skit = (array) $record->toArray(); + $where = $relative($record->sourcePath ?? 'assets/Data/Skits/' . $record->recordId . '.php'); + foreach (array_values((array) ($skit['beats'] ?? [])) as $index => $beat) { + if (is_array($beat)) { $check(sprintf('%s:beat %d', $where, $index + 1), DialogueLineSpeaker::forSkitBeat(), $beat); } + } + } + foreach ($workspace->getRecordDatabase('common_events')?->getRecords() ?? [] as $record) { + $this->visitTextCommands((array) $record->toArray(), $relative($record->sourcePath ?? 'assets/Events/' . $record->recordId . '.php'), $check); + } + foreach ($workspace->maps as $map) { + $data = $map->getEditableData(); + $where = $relative($map->dataPath); + foreach (array_values((array) ($data['npcs'] ?? [])) as $index => $npc) { + if (! is_array($npc)) { continue; } + $npcWhere = sprintf('%s:NPC %s', $where, (string) ($npc['id'] ?? $npc['name'] ?? $index + 1)); + foreach ($this->listPages((array) ($npc['dialogue'] ?? [])) as $line => $page) { + $check(sprintf('%s line %d', $npcWhere, $line + 1), DialogueLineSpeaker::forNpcLine(), $page, $npc); + } + $this->visitTextCommands($npc, $npcWhere, $check); + } + foreach ((array) ($data['events'] ?? []) as $marker => $event) { + if (! is_array($event)) { continue; } + $eventWhere = sprintf('%s:event %s', $where, $marker); + $class = ltrim((string) ($event['class'] ?? ''), '\\'); + if (in_array($class, [DialogueEventTrigger::class, ShopEventTrigger::class], true)) { + foreach ($this->listPages((array) ($event['data']['dialogue'] ?? [])) as $line => $page) { + $check(sprintf('%s page %d', $eventWhere, $line + 1), DialogueLineSpeaker::forText(), $page); + } + } + $this->visitTextCommands($event, $eventWhere, $check); + } + } + foreach ($workspace->cutscenes?->assets(CutsceneType::CINEMATIC) ?? [] as $cinematic) { + if (! $cinematic->isDeleted()) { + $this->visitTextCommands($cinematic->payload(), $relative($cinematic->dataPath()), $check); + } + } + + return $issues; + } + + /** + * Every page a dialogue list shows: its pages, or each conditional variant's lines, in order. + * + * @return list> + */ + private function listPages(array $dialogue): array + { + if (! ConditionalDialogue::isVariantList($dialogue)) { + return array_values(array_filter($dialogue, 'is_array')); + } + $pages = []; + foreach ($dialogue as $variant) { + foreach (is_array($variant) ? (array) ($variant['lines'] ?? []) : [] as $page) { + if (is_array($page)) { $pages[] = $page; } + } + } + + return $pages; + } + + /** + * Visits every `text` command anywhere under a payload: a script's, a branch's arms, a choice's options, a + * cinematic's blocks. + * + * @param callable(string, DialogueLineSpeaker, array): void $check + */ + private function visitTextCommands(array $payload, string $where, callable $check): void + { + if (($payload['type'] ?? null) === 'text') { + $check(sprintf('%s:text "%s"', $where, mb_strimwidth((string) ($payload['text'] ?? ''), 0, 40, '...')), DialogueLineSpeaker::forText(), $payload); + } + foreach ($payload as $value) { + if (is_array($value)) { $this->visitTextCommands($value, $where, $check); } + } + } +} diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 8d35f0e..36afe0e 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -152,6 +152,7 @@ public function validate(ProjectWorkspace $workspace): array ...$this->checkDefinitionIdentities($workspace), ...$this->checkActorDefinitions($workspace), ...new ActorReferenceValidator()->validate($workspace), + ...new DialoguePresentationValidator()->validate($workspace), ...new RecordReferenceValidator()->validate($workspace), ...$this->checkKnowledgeCatalog($workspace), ...$this->checkSpecialProperties($workspace), diff --git a/tests/Pest.php b/tests/Pest.php index 8d20682..d5dec15 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -392,6 +392,8 @@ function plainTextOfFrame(string $frame, int $width, int $height): string require_once __DIR__ . '/Support/BattleFormationFixtures.php'; +require_once __DIR__ . '/Support/DialogueFixtures.php'; + /** * Authors a project's skill catalogue as record files, one per skill and * numbered in the given order, replacing any records already there: the diff --git a/tests/Support/DialogueFixtures.php b/tests/Support/DialogueFixtures.php new file mode 100644 index 0000000..4f13ec5 --- /dev/null +++ b/tests/Support/DialogueFixtures.php @@ -0,0 +1,51 @@ + 'ichiloto.dialogue/1', + 'actors' => [ + 'Kaelion' => [ + 'portrait' => 'Graphics/Faces/Hero.png', + 'emotions' => [ + 'Angry' => ['portrait' => 'Graphics/Faces/HeroAngry.png', 'bust' => 'Graphics/Faces/HeroAngry.png'], + ], + ], + ], + 'resources' => [ + 'Mira' => [ + 'portrait' => 'Graphics/Faces/Mira.png', // The innkeeper. + 'emotions' => ['Worried' => 'Graphics/Faces/MiraWorried.png'], + ], + ], + 'speakers' => [ + 'Kaelion' => 'Kaelion', + 'Mom' => 'Mira', + ], +]; +PHP; +} + +/** A project holding a dialogue catalogue and its artwork, opened in a session. */ +function createDialogueProject(?string $catalogue = null): array +{ + $root = makeTemporaryProject('editor-dialogue-portraits-'); + $file = $root . '/' . DialogueCatalogFile::RELATIVE_PATH; + mkdir(dirname($file), 0o777, true); + file_put_contents($file, $catalogue ?? syntheticDialogueCatalogue()); + foreach (['Hero', 'HeroAngry', 'HeroSad', 'Mira', 'MiraWorried'] as $face) { + writeTilesetTestPng($root . '/assets/Graphics/Faces/' . $face . '.png', 8, 8); + } + + return [$root, $file, EditorSession::open($root)]; +} diff --git a/tests/Unit/DialoguePortraitsTerminalTest.php b/tests/Unit/DialoguePortraitsTerminalTest.php new file mode 100644 index 0000000..b9d1fed --- /dev/null +++ b/tests/Unit/DialoguePortraitsTerminalTest.php @@ -0,0 +1,54 @@ + 'breakfast-banter', + 'title' => 'Breakfast Banter', + 'beats' => [ + ['actor' => 'Kaelion', 'text' => 'Again?'], + ['speaker' => 'Mom', 'text' => 'Eat first.', 'emotion' => 'Furious'], + ], +]; +PHP); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'skits'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); + $emotion = array_search('beat0Emotion', array_column($fields, 'field'), true); + expect($fields[$emotion]['options'])->toBe(['Neutral', 'Angry'])->and($fields[$emotion]['value'])->toBe('Neutral') + ->and(array_column($fields, 'label'))->toContain('Beat 2 Emotion Warning'); + setEditorProperty($editor, 'databaseSelectedSettingIndex', $emotion); + callEditorMethod($editor, 'adjustDatabaseOptionField', 1); + expect(getEditorProperty($editor, 'workspace')->getRecordDatabase('skits')->getRecordByIndex(0)?->get('beats')[0]['emotion'])->toBe('Angry'); +}); + +it('adds an emotion to an identity and picks its portrait among the project images', function () { + [$root, $file] = createDialogueProject(); + $editor = deletionEditor($root); + openDatabaseCategory($editor, DialoguePortraitFields::CATEGORY); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); + setEditorProperty($editor, 'databaseFocus', 'database_settings'); + setEditorProperty($editor, 'databaseSelectedSettingIndex', array_search('emotionList', array_column($fields, 'field'), true)); + callEditorMethod($editor, 'addDatabaseRecordSubItem'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); + $portrait = $fields[array_search('emotion0Portrait', array_column($fields, 'field'), true)]; + expect($portrait['reference'])->toBe('png_assets'); + expect(callEditorMethod($editor, 'openReferencePicker', $portrait))->toBeTrue(); + $picker = getEditorProperty($editor, 'referencePicker'); + expect($picker->isOpen())->toBeTrue(); + + $database = getEditorProperty($editor, 'workspace')->getRecordDatabase(DialoguePortraitFields::CATEGORY); + expect(array_column($database->getRecordByIndex(0)?->getEntries($database->schema->findInlineSubList('emotions')), 'name'))->toBe(['New Emotion', 'Angry']); +}); diff --git a/tests/Unit/NpcAuthoringTest.php b/tests/Unit/NpcAuthoringTest.php index 70432f4..4ffc928 100644 --- a/tests/Unit/NpcAuthoringTest.php +++ b/tests/Unit/NpcAuthoringTest.php @@ -1378,15 +1378,15 @@ public function __construct() expect($rows[0])->toBe(['label' => 'Dialogue variant 1', 'field' => null, 'editable' => false]) ->and(callEditorMethod($editor, 'getInspectorFields')[array_search('Dialogue variant 1', array_column(callEditorMethod($editor, 'getInspectorFields'), 'label'), true)]['value'])->toBe('') - ->and(array_column(array_slice($rows, 1, 5), 'label'))->toBe(['When', 'Then Set', 'Script Commands', 'Line 1 Speaker', 'Line 1 Text']) - ->and(array_column(array_slice($rows, 1, 5), 'field'))->toBe(['variant0Conditions', 'variant0Sets', 'variant0Script', 'variant0Line0Name', 'variant0Line0Text']) + ->and(array_column(array_slice($rows, 1, 6), 'label'))->toBe(['When', 'Then Set', 'Script Commands', 'Line 1 Speaker', 'Line 1 Emotion', 'Line 1 Text']) + ->and(array_column(array_slice($rows, 1, 6), 'field'))->toBe(['variant0Conditions', 'variant0Sets', 'variant0Script', 'variant0Line0Name', 'variant0Line0Emotion', 'variant0Line0Text']) // The second variant's heading carries its condition line as its // value: "Dialogue variant 2 · when switch:gate_open" on a wide pane, // the "when …" wrapped under the heading on a narrow one. - ->and($rows[6]['label'])->toBe('Dialogue variant 2') - ->and($rows[6]['editable'])->toBeFalse() + ->and($rows[7]['label'])->toBe('Dialogue variant 2') + ->and($rows[7]['editable'])->toBeFalse() ->and(preg_replace('/\s+/', ' ', implode(' ', callEditorMethod($editor, 'getDatabaseSettingsLines'))))->toContain('Dialogue variant 2 when switch:gate_open') - ->and(array_column(array_slice($rows, 7, 5), 'field'))->toBe(['variant1Conditions', 'variant1Sets', 'variant1Script', 'variant1Line0Name', 'variant1Line0Text']); + ->and(array_column(array_slice($rows, 8, 6), 'field'))->toBe(['variant1Conditions', 'variant1Sets', 'variant1Script', 'variant1Line0Name', 'variant1Line0Emotion', 'variant1Line0Text']); }); it('wraps a long dialogue line in the hosted Inspector, keeps the edited row single, and maps rows to fields', function () { diff --git a/tests/Unit/Session/DialoguePortraitsSessionTest.php b/tests/Unit/Session/DialoguePortraitsSessionTest.php new file mode 100644 index 0000000..65980c9 --- /dev/null +++ b/tests/Unit/Session/DialoguePortraitsSessionTest.php @@ -0,0 +1,252 @@ +readDatabaseRecord($category, $index, $frame)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === $field); + expect($row)->not->toBeNull("{$category} {$index} has a {$field} row"); + + return $row; +} + +it('lists actor and character identities as one page and edits their artwork in the file\'s own source', function () { + [$root, $file, $session] = createDialogueProject(); + $listing = $session->listDatabaseRecords(DialoguePortraitFields::CATEGORY); + expect($listing['editable'])->toBeTrue() + ->and($listing['records'])->toBe(['Kaelion (actor)', 'Mira (character)']); + + $portrait = findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'portrait'); + expect($portrait['kind'])->toBe('reference')->and($portrait['reference'])->toBe('png_assets') + ->and($portrait['media'])->toBe(['kind' => 'image', 'root' => 'assets']); + $id = findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'id'); + expect($id['reference'])->toBe('actor_ids'); + + // A bust for the actor and a new emotion, chosen as project images. + $session->applyDatabaseRecord(DialoguePortraitFields::CATEGORY, 0, findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'bust')['key'], 'Graphics/Faces/Hero.png'); + $heading = array_find($session->readDatabaseRecord(DialoguePortraitFields::CATEGORY, 0)['rows'], + static fn(array $row): bool => ($row['label'] ?? null) === 'Emotions'); + $session->addDatabaseItem(DialoguePortraitFields::CATEGORY, 0, $heading['key']); + $session->applyDatabaseRecord(DialoguePortraitFields::CATEGORY, 0, findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'emotion0Name')['key'], 'Sad'); + $session->applyDatabaseRecord(DialoguePortraitFields::CATEGORY, 0, findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'emotion0Portrait')['key'], 'Graphics/Faces/HeroSad.png'); + $session->saveDatabase(DialoguePortraitFields::CATEGORY); + + $source = (string) file_get_contents($file); + expect($source)->toContain('// Synthetic dialogue artwork for editor tests.', '// The innkeeper.') + ->and($source)->not->toContain('base64'); + $catalogue = DialoguePresentationCatalog::load($root . '/assets'); + expect($catalogue->actors['Kaelion']['bust'])->toBe('Graphics/Faces/Hero.png') + ->and($catalogue->actors['Kaelion']['emotions']['Sad'])->toBe('Graphics/Faces/HeroSad.png') + // Untouched entries keep their authored shape. + ->and($catalogue->resources['Mira']['emotions'])->toBe(['Worried' => 'Graphics/Faces/MiraWorried.png']); +}); + +it('moves an identity between actors and characters by what it speaks as', function () { + [$root, $file, $session] = createDialogueProject(); + $session->applyDatabaseRecord(DialoguePortraitFields::CATEGORY, 0, findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'kind')['key'], DialoguePortraitFields::CHARACTER); + // A character's identity is typed; an actor's is chosen among the actors. + expect(findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'id')['kind'])->toBe('text'); + $session->saveDatabase(DialoguePortraitFields::CATEGORY); + $catalogue = DialoguePresentationCatalog::load($root . '/assets'); + expect($catalogue->actors)->toBe([]) + ->and(array_keys($catalogue->resources))->toBe(['Kaelion', 'Mira']) + ->and($catalogue->resources['Kaelion']['emotions']['Angry']['portrait'])->toBe('Graphics/Faces/HeroAngry.png') + ->and((string) file_get_contents($file))->toContain('// The innkeeper.'); +}); + +it('binds speaker names to identities chosen from the catalogue, its unsaved identities included', function () { + [$root, , $session] = createDialogueProject(); + expect($session->listDatabaseRecords(DialogueSpeakerFields::CATEGORY)['records'])->toBe(['Kaelion -> Kaelion', 'Mom -> Mira']); + $identity = findDialogueRow($session, DialogueSpeakerFields::CATEGORY, 1, 'identity'); + expect($identity['reference'])->toBe('dialogue_identities'); + + $created = $session->createDatabaseRecord(DialoguePortraitFields::CATEGORY, 'Elder Tov'); + $choices = $session->listReferences(null, 'dialogue_identities'); + expect(array_column($choices, 'value'))->toBe(['Kaelion', 'Mira', 'Elder Tov']) + ->and(array_column($choices, 'label'))->toBe(['Kaelion (actor)', 'Mira (character)', 'Elder Tov (character)']); + + $speaker = $session->createDatabaseRecord(DialogueSpeakerFields::CATEGORY, 'The Elder')['index']; + $session->applyDatabaseRecord(DialogueSpeakerFields::CATEGORY, $speaker, findDialogueRow($session, DialogueSpeakerFields::CATEGORY, $speaker, 'identity')['key'], 'Elder Tov'); + $session->saveAll(); + $catalogue = DialoguePresentationCatalog::load($root . '/assets'); + expect($catalogue->speakers)->toBe(['Kaelion' => 'Kaelion', 'Mom' => 'Mira', 'The Elder' => 'Elder Tov']) + ->and(array_keys($catalogue->resources))->toBe(['Mira', 'Elder Tov']) + ->and($created['index'])->toBe(2); +}); + +it('leaves identities the file builds with code read-only, saying why, while its literal speaker names stay editable', function () { + [, $file, $session] = createDialogueProject(<<<'PHP' + 'Graphics/Faces/Hero.png']; +} + +return [ + 'schema' => 'ichiloto.dialogue/1', + 'actors' => $actors, + 'speakers' => ['Kaelion' => 'Kaelion'], +]; +PHP); + $before = (string) file_get_contents($file); + $portraits = $session->listDatabaseRecords(DialoguePortraitFields::CATEGORY); + expect($portraits['editable'])->toBeFalse() + ->and($portraits['readOnly'])->toContain('builds "actors" with its own PHP code in $actors') + ->and($portraits['records'])->toBe(['Kaelion (actor)']); + expect(fn() => $session->applyDatabaseRecord(DialoguePortraitFields::CATEGORY, 0, + findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'bust')['key'], 'Graphics/Faces/Hero.png'))->toThrow(SessionRefusal::class); + + expect($session->listDatabaseRecords(DialogueSpeakerFields::CATEGORY)['editable'])->toBeTrue(); + $session->applyDatabaseRecord(DialogueSpeakerFields::CATEGORY, 0, findDialogueRow($session, DialogueSpeakerFields::CATEGORY, 0, 'name')['key'], 'Kael'); + $session->saveDatabase(DialogueSpeakerFields::CATEGORY); + expect((string) file_get_contents($file))->toBe(str_replace("'speakers' => ['Kaelion' => 'Kaelion']", "'speakers' => ['Kael' => 'Kaelion']", $before)); +}); + + +it('offers a line the emotions its speaker\'s identity has, through actor ids and speaker names alike', function () { + [$root, , $session] = createDialogueProject(); + file_put_contents($root . '/assets/Data/Skits/breakfast-banter.php', <<<'PHP' + 'breakfast-banter', + 'title' => 'Breakfast Banter', + 'where' => 'happyville/town-center', + 'beats' => [ + ['actor' => 'Kaelion', 'text' => 'Again?', 'emotion' => 'Angry'], + ['speaker' => 'Mom', 'text' => 'Eat first.', 'emotion' => 'Furious'], + ['speaker' => 'A Stranger', 'text' => 'Hm.'], + ], +]; +PHP); + $session = EditorSession::open($root); + $actorLine = findDialogueRow($session, 'skits', 0, 'beat0Emotion'); + expect($actorLine['kind'])->toBe('options')->and($actorLine['options'])->toBe(['Neutral', 'Angry'])->and($actorLine['value'])->toBe('Angry'); + $rows = $session->readDatabaseRecord('skits', 0)['rows']; + $momLine = findDialogueRow($session, 'skits', 0, 'beat1Emotion'); + expect($momLine['options'])->toBe(['Neutral', 'Worried']); + // Kept as written, and said: the identity has no such emotion. + $warning = array_values(array_filter($rows, static fn(array $row): bool => ($row['label'] ?? '') === 'Beat 2 Emotion Warning')); + expect($warning)->toHaveCount(1)->and($warning[0]['kind'])->toBe('info')->and($warning[0]['tone'])->toBe('warning') + ->and($warning[0]['value'])->toContain('Mira has no "Furious" emotion'); + // A speaker the catalogue does not bind shows no portrait: Neutral is all there is. + expect(findDialogueRow($session, 'skits', 0, 'beat2Emotion')['options'])->toBe(['Neutral']) + ->and(findDialogueRow($session, 'skits', 0, 'beat2Emotion')['value'])->toBe('Neutral'); + + $session->applyDatabaseRecord('skits', 0, $momLine['key'], 'Worried'); + $session->saveDatabase('skits'); + expect((require $root . '/assets/Data/Skits/breakfast-banter.php')['beats'][1]['emotion'])->toBe('Worried'); +}); + +it('chooses a text command\'s emotion for the speaker its name resolves to, following a speaker change', function () { + [$root, , $session] = createDialogueProject(); + $command = findDialogueRow($session, 'common_events', 0, 'command0Emotion'); + // The dresser note is read by nobody, so it shows no portrait. + expect($command['options'])->toBe(['Neutral']); + $session->applyDatabaseRecord('common_events', 0, findDialogueRow($session, 'common_events', 0, 'command0Name')['key'], 'Mom'); + $command = findDialogueRow($session, 'common_events', 0, 'command0Emotion'); + expect($command['options'])->toBe(['Neutral', 'Worried']); + $session->applyDatabaseRecord('common_events', 0, $command['key'], 'Worried'); + $session->saveDatabase('common_events'); + expect((require $root . '/assets/Events/dresser-note.php')[0])->toBe(['type' => 'text', 'name' => 'Mom', 'text' => 'A note is tucked under the lamp.', 'emotion' => 'Worried']); + + // The branch's own text command, in its frame, follows the same rule. + $nested = findDialogueRow($session, 'common_events', 0, 'command0Emotion', [3, 'then']); + expect($nested['options'])->toBe(['Neutral']); +}); + +it('chooses an NPC line\'s emotion for its own speaker, or the NPC\'s name when the line names none', function () { + [, , $session] = createDialogueProject(); + $revision = $session->readMap('test-map')['revision']; + $index = $session->createNpc('test-map', $revision, 1, 1, 'Mom')['index']; + $emotion = array_find($session->readNpc('test-map', $index)['rows'], static fn(array $row): bool => str_ends_with((string) ($row['key']['field'] ?? ''), 'Emotion')); + expect($emotion)->not->toBeNull() + ->and($emotion['options'])->toBe(['Neutral', 'Worried']); + $session->applyNpc('test-map', $session->readMap('test-map')['revision'], $index, $emotion['key'], 'Worried'); + $line = getSessionMap($session)->getNpcs()->toMapData()[$index]['dialogue'][0]; + expect($line['emotion'])->toBe('Worried'); +}); + +it('offers each page of a map Dialogue event the emotions of its speaker, kept out of the event until chosen', function () { + [$root] = createDialogueProject(); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace(" 'events' => [\n", <<<'PHP' + 'events' => [ + 'D' => [ + 'class' => 'Ichiloto\Engine\Events\Triggers\DialogueEventTrigger', + 'data' => [ + 'dialogue' => [ + ['name' => 'Mom', 'text' => 'Supper is ready.'], + ['name' => 'Kaelion', 'text' => 'Coming!', 'emotion' => 'Sulky'], + ], + ], + ], + +PHP, (string) file_get_contents($data))); + $session = EditorSession::open($root); + $rows = $session->readInspector('test-map', 'D')['rows']; + $first = array_find($rows, static fn(array $row): bool => ($row['label'] ?? '') === 'Dialogue #1 Emotion'); + expect($first['kind'])->toBe('options')->and($first['options'])->toBe(['Neutral', 'Worried'])->and($first['value'])->toBe('Neutral'); + $warning = array_find($rows, static fn(array $row): bool => ($row['label'] ?? '') === 'Dialogue #2 Emotion Warning'); + expect($warning['value'])->toContain('Kaelion has no "Sulky" emotion'); + expect(getSessionMap($session)->getEventDefinition('D')['data']['dialogue'][0])->not->toHaveKey('emotion'); + + $session->applyInspector('test-map', $session->readMap('test-map')['revision'], $first['key'], 'Worried'); + expect(getSessionMap($session)->getEventDefinition('D')['data']['dialogue'][0])->toBe(['name' => 'Mom', 'text' => 'Supper is ready.', 'emotion' => 'Worried']); +}); + +it('reports dangling speaker names, artwork the game cannot draw, and line emotions their speaker lacks', function () { + [$root] = createDialogueProject(); + unlink($root . '/assets/Graphics/Faces/MiraWorried.png'); + file_put_contents($root . '/assets/Data/Skits/breakfast-banter.php', <<<'PHP' + 'breakfast-banter', + 'title' => 'Breakfast Banter', + 'where' => 'happyville/town-center', + 'beats' => [ + ['actor' => 'Kaelion', 'text' => 'Again?', 'emotion' => 'Angry'], + ['speaker' => 'Mom', 'text' => 'Eat first.', 'emotion' => 'Furious'], + ], +]; +PHP); + $workspace = Ichiloto\Editor\ProjectWorkspace::fromProject($root); + $speaker = $workspace->getRecordDatabase(DialogueSpeakerFields::CATEGORY)->requireNewRecord('Guard'); + $workspace->getRecordDatabase(DialogueSpeakerFields::CATEGORY)->getRecordByIndex($speaker)->set('identity', 'Nobody'); + + $issues = new Ichiloto\Editor\Validation\DialoguePresentationValidator()->validate($workspace); + $messages = array_map(static fn($issue): string => $issue->where . ' | ' . $issue->message, $issues); + expect($messages)->toHaveCount(1)->and($messages[0])->toContain('speakers.Guard', 'Speaker name "Guard" shows "Nobody"'); + + $workspace->getRecordDatabase(DialogueSpeakerFields::CATEGORY)->getRecordByIndex($speaker)->set('identity', 'Mira'); + $messages = array_map(static fn($issue): string => $issue->where . ' | ' . $issue->message, + new Ichiloto\Editor\Validation\DialoguePresentationValidator()->validate($workspace)); + expect($messages)->toHaveCount(2) + ->and($messages[0])->toContain('Mira Worried\'s dialogue portrait Graphics/Faces/MiraWorried.png cannot be drawn') + ->and($messages[1])->toContain('assets/Data/Skits/breakfast-banter.php:beat 2', 'Mira has no "Furious" emotion'); +}); + +it('chooses a cinematic text line\'s emotion as an event text command\'s', function () { + $root = cutsceneProject(); + mkdir($root . '/assets/Data/Presentation', 0o777, true); + file_put_contents($root . '/' . Ichiloto\Editor\Dialogue\DialogueCatalogFile::RELATIVE_PATH, syntheticDialogueCatalogue()); + $script = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($script, str_replace(" ['type' => 'title_card'", " ['type' => 'text', 'name' => 'Mom', 'text' => 'Look at them.'],\n ['type' => 'title_card'", (string) file_get_contents($script))); + $session = EditorSession::open($root); + $category = Ichiloto\Editor\Cutscenes\CutsceneType::CINEMATIC->getRecordCategory(); + $emotion = findDialogueRow($session, $category, 0, 'command4Emotion', ['commands']); + expect($emotion['options'])->toBe(['Neutral', 'Worried']); + $session->applyDatabaseRecord($category, 0, $emotion['key'], 'Worried'); + $session->saveAll(); + expect((require $script)[4])->toBe(['type' => 'text', 'name' => 'Mom', 'text' => 'Look at them.', 'emotion' => 'Worried']); +}); From 2f476bda13abd9e22fec96c26051b67584026055 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 13:57:11 +0200 Subject: [PATCH 302/344] fix(source): refuse edits under code-built variables instead of flattening them The shared ArraySourceWriter replaced a variable reference with a literal of the evaluated value whenever an edit reached it. A file that builds data in code (Last Legend's equipment-optimization.php fills $roleSlots in a loop) had that code silently replaced by its result on save, with the loop left running for nothing. Removed behaviour: the writer no longer replaces a reference to a variable the file builds or uses in code (a require, a call, a loop that fills it, an interpolating heredoc, a literal changed or read later) with a literal. The edit is refused before anything is written, naming the path and the variable. MapSaveIntegrityTest pinned the old behaviour ("replaces a variable-backed value with plain data while the shared variable survives"); it now asserts the refusal, that the map stays clean, and that nothing is written. Computed text variables are no longer copied into a fresh nowdoc either; they are refused the same way. PhpArraySourceDocument now classifies top-level variables: one assigned once by a literal and named nowhere else but by plain references is data; any other is built in code. A data variable only one entry names is edited where it is assigned (comments and the reference kept), so a path through that reference reaches its literal; a data variable several entries share gives the edited entry its own copy when the value is plain data, and is refused when a copy would lose comments, variables or expressions. Variable definitions a new nowdoc needs inside such a literal are placed ahead of it. A replacement in an entry that shares its line stays on that line when it fits. The owned-key read-only check from the dialogue portraits work now asks the document's classification, so a key held in a data variable is editable; one built in code stays read-only with its reason. ActorIdentityMigration no longer refuses actor data written as a plain literal variable: it is migrated in place. Refusing actor data built in code is still tested. --- docs/manual.md | 17 +- docs/roadmap.md | 9 +- src/Cutscenes/Source/ArraySourceWriter.php | 106 ++++++++-- .../Source/PhpArraySourceDocument.php | 196 +++++++++++++++--- src/Cutscenes/Source/SourceVariable.php | 9 + src/Database/ProjectRecordDatabase.php | 17 +- tests/Unit/ActorIdentityMigrationTest.php | 15 +- .../Unit/Cutscenes/ArraySourceWriterTest.php | 146 +++++++++++++ tests/Unit/MapSaveIntegrityTest.php | 31 ++- .../Session/ComputedSourceRefusalTest.php | 92 ++++++++ 10 files changed, 569 insertions(+), 69 deletions(-) create mode 100644 tests/Unit/Session/ComputedSourceRefusalTest.php diff --git a/docs/manual.md b/docs/manual.md index c29c233..187595e 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -2148,7 +2148,9 @@ backslashes, trailing spaces and wide glyphs all survive, a data-only edit leaves the script or timeline file byte-identical, a no-op save writes nothing, and multi-line text is written as a nowdoc. A value shared through one source variable is edited in place when only the selected entry uses it, -and otherwise retargeted to a new variable so nothing else changes. +and otherwise retargeted to a new variable so nothing else changes. A +variable the file builds with its own code is never replaced by a copy of +its result: an edit under it is refused, naming the variable. A save is one transaction over the pair, and so is a first save, an overwrite and a deletion. Each proposed file is staged beside its @@ -2659,9 +2661,16 @@ the map (validation warns when it is not). What the editor cannot rewrite reversibly it refuses, precisely: a value written as an expression - `'script' => require …` in place - cannot take an edit without destroying the expression, so that one edit is refused with the -map, file and path named, and nothing changes. The refusal is that narrow: a -value written as a *variable reference* can be repointed (the entry gets the -literal; the shared variable stays for its other users), an opaque sibling +map, file and path named, and nothing changes. A value written as a +*variable* follows how the file writes that variable. One written as data +(assigned once, by a literal, and named nowhere else but by plain +references) is edited where it is assigned when only this entry names it; +when several entries share it, the edited entry gets its own copy and the +variable stays for the others, unless the shared value holds comments, +variables or expressions a copy would lose, which is refused. A variable the +file builds with its own code (a `require`, a call, a loop that fills it) is +refused the same way as an expression: its code is never swapped for a +literal of what it produced. The refusal is that narrow: an opaque sibling never blocks an edit beside it, and browsing, previewing and validation always work. Only a data file whose whole source cannot be parsed for preservation makes the map's *data* read-only - the grids stay editable, and diff --git a/docs/roadmap.md b/docs/roadmap.md index 844275b..d341b9c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1216,10 +1216,11 @@ folder, two folders differing only by case), hands each type to **Source preservation, at the token level.** `PhpArraySourceDocument` maps a returned-array file to byte spans — prelude variables, nowdocs and heredocs included — and `ArraySourceWriter` turns an array diff into the fewest -edits: moves keep their bytes, a single-reference nowdoc is edited in place, -a shared one is retargeted to a new variable, multi-line text is written as a -nowdoc, and an expression it cannot express is refused by path rather than -flattened. Every rewrite is evaluated and compared before it is written. +edits: moves keep their bytes, a single-reference nowdoc or literal variable +is edited where it is assigned, a shared one is retargeted (a new variable +for text, a copy of plain data otherwise), multi-line text is written as a +nowdoc, and an expression or a variable the file builds with its own code is +refused by path rather than flattened. Every rewrite is evaluated and compared before it is written. **The tree is the truth; the rest are projections.** The command tree is edited through the record pane's frames (the same frames Common Events use, diff --git a/src/Cutscenes/Source/ArraySourceWriter.php b/src/Cutscenes/Source/ArraySourceWriter.php index 73d40b9..086b94b 100644 --- a/src/Cutscenes/Source/ArraySourceWriter.php +++ b/src/Cutscenes/Source/ArraySourceWriter.php @@ -18,15 +18,19 @@ * is cut with its heading, and a list is aligned so that an entry moved * keeps its bytes and an entry edited keeps everything but the changed * field. A value written as a variable is retargeted rather than expanded: - * a nowdoc referenced once is edited between its markers, and one shared by - * several entries is left alone while the edited entry gets a new, safely - * named variable of its own. Text with line breaks is always written as a - * nowdoc, so ASCII art stays readable and exact -- trailing spaces, - * backslashes, Unicode and all. + * a nowdoc referenced once is edited between its markers, and a literal + * named once is edited where it is assigned, as the data it is. One shared + * by several entries is left alone for them while the edited entry gets a + * value of its own -- a new, safely named variable for text -- when the + * shared value is plain data a copy holds in full. Text with line breaks + * is always written as a nowdoc, so ASCII art stays readable and exact -- + * trailing spaces, backslashes, Unicode and all. * * What it cannot express -- a change under an expression, under a key it - * could not read, a list turned into a map -- it refuses by path, so nothing - * is written that a reader would not have written by hand. + * could not read, a list turned into a map, under a variable the file builds + * with its own code -- it refuses by path, so nothing is written that a + * reader would not have written by hand. A variable built in code is never + * replaced by a literal of what the code produced. * * @package Ichiloto\Editor\Cutscenes\Source */ @@ -76,6 +80,9 @@ final class ArraySourceWriter /** @var array */ private array $edits = []; + /** @var list The variables written as data the diff is inside, innermost last. */ + private array $hosts = []; + /** @var array Variable names claimed by this plan. */ private array $claimedVariables = []; @@ -137,6 +144,12 @@ private function diff(SourceNode $node, mixed $old, mixed $new, array $path): vo return; } + if ($node->kind === SourceNode::VARIABLE) { + $this->diffVariable($node, $old, $new, $path); + + return; + } + if (is_array($new)) { $this->diffArray($node, is_array($old) ? $old : null, $new, $path); @@ -167,7 +180,6 @@ private function diff(SourceNode $node, mixed $old, mixed $new, array $path): vo 'The value at %s is an array in the file and cannot be replaced by a single value without rewriting it.', PhpArraySourceDocument::describePath($path), )), - SourceNode::VARIABLE => $this->retargetVariable($node, $path, $new), SourceNode::EXPRESSION => throw new SourcePreservationRefusal(sprintf( 'The value at %s is written as an expression the editor cannot rewrite; change it in the file, or write it as plain data.', PhpArraySourceDocument::describePath($path), @@ -209,9 +221,7 @@ private function diffArray(SourceNode $node, ?array $old, array $new, array $pat )); } - // A scalar or a variable holding what is now an array: the - // reference is replaced by the literal, and any variable stays - // for whatever else names it. + // A scalar holding what is now an array: the literal takes its place. $this->edits[] = $this->replacementFor($node, $path, $new); return; @@ -952,16 +962,71 @@ private function getClassImports(): array */ private function replacementFor(SourceNode $node, array $path, mixed $value): array { - return $this->document->replaceValueEdit($path, $this->literalFor($value, $path)); + // An entry sharing its line with its neighbours keeps a value that fits there on that line. + $entry = $this->document->entryAt($path); + $inline = $entry !== null && $this->document->lineIndentBefore($entry->start) === null && $this->fitsOnOneLine($value); + + return $this->document->replaceValueEdit($path, $this->literalFor($value, $path, $inline)); + } + + /** + * Changes a value written as a variable reference. + * + * A variable the file builds with its own code is refused: replacing the + * reference with a literal of its result would leave the code running + * for nothing. A literal only this entry names is diffed where it is + * assigned. Otherwise the variable is left for whatever else names it + * and this entry is retargeted. + * + * @param array $path + */ + private function diffVariable(SourceNode $node, mixed $old, mixed $new, array $path): void + { + $name = $node->variable ?? ''; + + if ($this->document->isBuiltInCode($node)) { + throw new SourcePreservationRefusal(sprintf( + 'The value at %s is $%s, which the file\'s own code builds or uses, so the editor will not replace it with a copy of its value. Change it in the file, or write it as plain data.', + PhpArraySourceDocument::describePath($path), + $name, + )); + } + + $written = $this->document->resolveNode($node); + + if ($written !== $node) { + $this->hosts[] = $name; + + try { + $this->diff($written, $old, $new, $path); + } finally { + array_pop($this->hosts); + } + + return; + } + + $variable = $this->document->variable($name); + + if ($variable?->value !== null && ! $this->isPlainData($variable->value)) { + throw new SourcePreservationRefusal(sprintf( + 'The value at %s is $%s, which %d values share and which holds comments, variables or expressions a copy of it would lose. Change it in the file.', + PhpArraySourceDocument::describePath($path), + $name, + $this->document->referenceCount($name), + )); + } + + $this->retargetVariable($node, $path, $new); } /** - * Retargets a value written as a variable reference. + * Retargets a value written as a variable reference to data. * * A string edited under a nowdoc named nowhere else is edited between * the nowdoc's markers. Anything else leaves the variable alone -- other * entries name it -- and this entry gets a variable of its own for - * text, or the literal itself for a number or flag. + * text, or the literal itself for a number, flag or array. * * @param array $path */ @@ -982,7 +1047,7 @@ private function retargetVariable(SourceNode $node, array $path, mixed $new): vo } $fresh = $this->claimVariable($name); - $this->edits[] = $this->document->defineVariableEdit($fresh, $new); + $this->edits[] = $this->document->defineVariableEdit($fresh, $new, $this->findHost()); $this->edits[] = $this->document->replaceValueEdit($path, '$' . $fresh); return; @@ -1001,7 +1066,7 @@ private function literalFor(mixed $value, array $path, bool $inline = false): st { if (is_string($value) && str_contains($value, "\n")) { $fresh = $this->claimVariable(self::stemFor($path)); - $this->edits[] = $this->document->defineVariableEdit($fresh, $value); + $this->edits[] = $this->document->defineVariableEdit($fresh, $value, $this->findHost()); return '$' . $fresh; } @@ -1083,6 +1148,15 @@ private static function indentContinuation(string $literal): string )); } + /** + * Returns the variable written as data the diff is inside, which any + * variable it defines must precede. + */ + private function findHost(): ?string + { + return $this->hosts === [] ? null : $this->hosts[count($this->hosts) - 1]; + } + /** * Returns a variable name no other assignment or planned variable uses. */ diff --git a/src/Cutscenes/Source/PhpArraySourceDocument.php b/src/Cutscenes/Source/PhpArraySourceDocument.php index a6d5a00..7904d6f 100644 --- a/src/Cutscenes/Source/PhpArraySourceDocument.php +++ b/src/Cutscenes/Source/PhpArraySourceDocument.php @@ -20,6 +20,12 @@ * new entry on lines of its own in the array's indentation, a nowdoc's * content between its markers, a new variable before the `return`. * + * A variable written as data -- assigned once, by a literal, and named + * nowhere else but by plain references -- is part of the data: when one + * entry names it, a path through that entry reaches the variable's own + * literal, so it is edited where it is written. A variable the file builds + * with its own code is not data; it is kept and never rewritten. + * * It is deliberately narrow about what it reads. A file that is not a * header, top-level assignments and one returned array literal is refused * as unreadable, and a value it cannot classify -- a call, an arithmetic @@ -39,6 +45,8 @@ final class PhpArraySourceDocument * @param array $variables Top-level assignments by name. * @param int $returnStart The offset of the `return` keyword. * @param int $statementEnd The offset just past the returned statement's `;`. + * @param array $references How many values name each variable, in the returned array and in + * the literals of other variables. */ private function __construct( public readonly string $source, @@ -46,6 +54,7 @@ private function __construct( private readonly array $variables, private readonly int $returnStart, private readonly int $statementEnd, + private readonly array $references, ) { } @@ -137,15 +146,121 @@ public static function parse(string $source): self } } + $references = []; + self::countReferences($rootParse['node'], $references); + + foreach ($variables as $variable) { + if ($variable->value !== null) { + self::countReferences($variable->value, $references); + } + } + return new self( $source, $rootParse['node'], - $variables, + self::classifyVariables($tokens, $variables, $references), $tokens[$returnAt]->pos, $tokens[$after]->pos + 1, + $references, ); } + /** + * Keeps a text or literal variable as data only while the file names it + * nowhere but in its one assignment and in plain references: a variable + * code also names (`$slots[$role] = ...`, `foreach ($slots as ...)`, + * `"{$slots}"`) is one that code may build or read, so it becomes + * `OTHER`, as does a literal naming itself. + * + * @param PhpToken[] $tokens + * @param array $variables + * @param array $references + * @return array + */ + private static function classifyVariables(array $tokens, array $variables, array $references): array + { + $occurrences = []; + + foreach ($tokens as $token) { + if ($token->is(T_VARIABLE)) { + $name = substr($token->text, 1); + $occurrences[$name] = ($occurrences[$name] ?? 0) + 1; + } + } + + foreach ($variables as $name => $variable) { + if ($variable->kind === SourceVariable::OTHER) { + continue; + } + + $own = []; + + if ($variable->value !== null) { + self::countReferences($variable->value, $own); + } + + if (isset($own[$name]) || ($occurrences[$name] ?? 0) !== 1 + ($references[$name] ?? 0)) { + $variables[$name] = new SourceVariable($name, SourceVariable::OTHER, $variable->start, $variable->end); + } + } + + return $variables; + } + + /** + * Adds up the plain references to each variable under a node. + * + * @param array $counts + */ + private static function countReferences(SourceNode $node, array &$counts): void + { + if ($node->kind === SourceNode::VARIABLE) { + $counts[$node->variable ?? ''] = ($counts[$node->variable ?? ''] ?? 0) + 1; + + return; + } + + foreach ($node->entries as $entry) { + self::countReferences($entry->value, $counts); + } + } + + /** + * Returns the literal a reference stands for when the file writes it as + * data that this reference alone names, so a change under it is made + * where it is written; any other node is itself. + */ + public function resolveNode(SourceNode $node): SourceNode + { + if ($node->kind !== SourceNode::VARIABLE) { + return $node; + } + + $variable = $this->variables[$node->variable ?? ''] ?? null; + + if ($variable === null || $variable->kind !== SourceVariable::LITERAL || $variable->value === null || $this->referenceCount($variable->name) !== 1) { + return $node; + } + + return $variable->value; + } + + /** + * Returns whether a reference names a value the file builds with its own + * code -- or a variable it never assigns at the top level -- rather than + * one it writes as data. + */ + public function isBuiltInCode(SourceNode $reference): bool + { + if ($reference->kind !== SourceNode::VARIABLE) { + return false; + } + + $variable = $this->variables[$reference->variable ?? ''] ?? null; + + return $variable === null || $variable->kind === SourceVariable::OTHER; + } + /** * Returns the returned array. */ @@ -189,6 +304,8 @@ public function entryAt(array $path): ?SourceEntry $entry = null; foreach ($path as $step) { + $node = $this->resolveNode($node); + if ($node->kind !== SourceNode::ARRAY) { return null; } @@ -206,7 +323,8 @@ public function entryAt(array $path): ?SourceEntry } /** - * Returns the node a path addresses; the root for an empty path. + * Returns the node a path addresses; the root for an empty path. A + * reference to data only it names is the literal it stands for. * * @param array $path The path. * @return SourceNode|null The node. @@ -217,30 +335,18 @@ public function nodeAt(array $path): ?SourceNode return $this->root; } - return $this->entryAt($path)?->value; + $entry = $this->entryAt($path); + + return $entry === null ? null : $this->resolveNode($entry->value); } /** - * Returns how many values in the returned array name a variable. + * Returns how many values name a variable: in the returned array and in + * the literals other variables are written as. */ public function referenceCount(string $name): int { - return $this->countReferences($this->root, $name); - } - - private function countReferences(SourceNode $node, string $name): int - { - if ($node->kind === SourceNode::VARIABLE) { - return $node->variable === $name ? 1 : 0; - } - - $count = 0; - - foreach ($node->entries as $entry) { - $count += $this->countReferences($entry->value, $name); - } - - return $count; + return $this->references[$name] ?? 0; } /** @@ -314,7 +420,7 @@ private function getLineIndent(int $offset): string */ private function getIndentStep(SourceNode $array): string { - foreach (array_reverse($this->findAncestors($this->root, $array) ?? []) as $ancestor) { + foreach (array_reverse($this->findAncestorsAnywhere($array)) as $ancestor) { $opening = $this->getLineIndent($ancestor->start); foreach ($ancestor->entries as $entry) { @@ -335,6 +441,23 @@ private function getIndentStep(SourceNode $array): string return ' '; } + /** + * Returns the arrays enclosing a node in the returned array or in the + * literal a variable is written as, outermost first. + * + * @return array + */ + private function findAncestorsAnywhere(SourceNode $target): array + { + foreach ($this->variables as $variable) { + if ($variable->value !== null && ($ancestors = $this->findAncestors($variable->value, $target)) !== null) { + return $ancestors; + } + } + + return $this->findAncestors($this->root, $target) ?? []; + } + /** * Returns the arrays enclosing a node, outermost first, or null when the * node is not below the one searched. @@ -469,6 +592,13 @@ public function replaceValueEdit(array $path, string $literal): array throw new RuntimeException(sprintf('No entry at %s to replace.', self::describePath($path))); } + $value = $this->resolveNode($entry->value); + + if ($value !== $entry->value) { + // Data only this entry names is replaced where it is written. + return [$value->start, $value->end, self::reindent($literal, $this->getLineIndent($value->start))]; + } + return [$entry->value->start, $entry->value->end, self::reindent($literal, $this->lineIndentBefore($entry->start) ?? '')]; } @@ -830,14 +960,24 @@ public function removeEntryEdit(array $path): array /** * Plans a nowdoc assignment of a variable placed before the `return`, - * after the last existing assignment when there is one. + * after the last existing assignment when there is one, or ahead of the + * variable whose literal will name it. * * @param string $name The variable name, without `$`. * @param string $content The exact content. + * @param string|null $before The variable written as data whose literal will name it. * @return array{0: int, 1: int, 2: string} The edit. */ - public function defineVariableEdit(string $name, string $content): array + public function defineVariableEdit(string $name, string $content, ?string $before = null): array { + $host = $before === null ? null : ($this->variables[$before] ?? null); + + if ($host !== null) { + $at = $this->lineStartOf($host->start); + + return [$at, $at, self::nowdocAssignment($name, $content) . "\n"]; + } + $at = $this->lineStartOf($this->returnStart); $lastVariable = null; @@ -1086,14 +1226,22 @@ private static function readAssignment(array $tokens, int $index, string $source return null; } + $value = null; + if ($significant === 1 && $token->is(T_CONSTANT_ENCAPSED_STRING)) { $kind = SourceVariable::STRING; $contentStart = $token->pos; $contentEnd = $token->pos + strlen($token->text); + } elseif (($literal = self::readValue($tokens, $valueAt, $source, [';'])) !== null + && in_array($literal['node']->kind, [SourceNode::ARRAY, SourceNode::SCALAR], true) + && self::nextSignificant($tokens, $literal['next']) === $cursor + ) { + $kind = SourceVariable::LITERAL; + $value = $literal['node']; } return [ - new SourceVariable($name, $kind, $start, $tokens[$cursor]->pos + 1, $contentStart, $contentEnd), + new SourceVariable($name, $kind, $start, $tokens[$cursor]->pos + 1, $contentStart, $contentEnd, value: $value), $cursor + 1, ]; } diff --git a/src/Cutscenes/Source/SourceVariable.php b/src/Cutscenes/Source/SourceVariable.php index ac123c9..fec6d32 100644 --- a/src/Cutscenes/Source/SourceVariable.php +++ b/src/Cutscenes/Source/SourceVariable.php @@ -9,6 +9,11 @@ * `return`: the ASCII block a keyframe shows, the shared value several * entries name. * + * Text and literal kinds are written as data: assigned once and named + * nowhere else in the file but by plain references. Anything else is + * `OTHER`, a value the file builds with its own code (a call, a `require`, + * a loop that fills it), which the editor reads but never rewrites. + * * @package Ichiloto\Editor\Cutscenes\Source */ final readonly class SourceVariable @@ -16,6 +21,8 @@ public const string NOWDOC = 'nowdoc'; public const string HEREDOC = 'heredoc'; public const string STRING = 'string'; + /** An array or non-text scalar literal, with its parsed value. */ + public const string LITERAL = 'literal'; public const string OTHER = 'other'; /** @@ -27,6 +34,7 @@ * @param int|null $contentEnd The offset just past the last content byte (before the newline that precedes the closing label). * @param string $closingIndent The whitespace before the closing label, which PHP strips from every content line. * @param string|null $label The nowdoc or heredoc label. + * @param SourceNode|null $value The assigned array or scalar literal, for a literal variable. */ public function __construct( public string $name, @@ -37,6 +45,7 @@ public function __construct( public ?int $contentEnd = null, public string $closingIndent = '', public ?string $label = null, + public ?SourceNode $value = null, ) { } } diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 2272b77..dfe8d44 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -1912,8 +1912,8 @@ private static function resolveReadOnlyReason(RecordSchema $schema, PhpDataFile /** * Why a projection's own keys cannot be written as data in the file's source: the file builds one, or an array - * inside one, in a variable with its own code (a loop, a required file), which a save would replace with a - * literal of what the code produced while the code went on running for nothing. + * inside one, in a variable with its own code (a loop, a required file), which the source writer refuses to + * replace with a literal of what the code produced. Variables written as data are edited where they are written. */ private static function findOwnedKeySourceIssue(RecordProjection $projection, PhpDataFile $file): ?string { @@ -1927,8 +1927,8 @@ private static function findOwnedKeySourceIssue(RecordProjection $projection, Ph return null; } foreach ($projection->listOwnedKeys() as $key) { - $node = $document->nodeAt([$key]); - $variable = $node === null ? null : self::findArrayVariable($node, $file->payload[$key] ?? null); + $entry = $document->entryAt([$key]); + $variable = $entry === null ? null : self::findArrayVariable($document, $entry->value, $file->payload[$key] ?? null); if ($variable !== null) { return sprintf('builds "%s" with its own PHP code in $%s, which the editor will not replace with plain data; write it as data to edit it here', $key, $variable); } @@ -1937,18 +1937,19 @@ private static function findOwnedKeySourceIssue(RecordProjection $projection, Ph return null; } - /** The first variable under a source node that holds an array in the evaluated value, or null. */ - private static function findArrayVariable(SourceNode $node, mixed $value): ?string + /** The first variable under a source node that the file builds in code and that holds an array, or null. */ + private static function findArrayVariable(PhpArraySourceDocument $document, SourceNode $node, mixed $value): ?string { - if ($node->kind === SourceNode::VARIABLE) { + if ($document->isBuiltInCode($node)) { return is_array($value) ? ($node->variable ?? '') : null; } + $node = $document->resolveNode($node); if ($node->kind !== SourceNode::ARRAY || ! is_array($value)) { return null; } foreach ($node->entries as $position => $entry) { $key = $node->isList ? $position : $entry->key; - $found = (is_int($key) || is_string($key)) && array_key_exists($key, $value) ? self::findArrayVariable($entry->value, $value[$key]) : null; + $found = (is_int($key) || is_string($key)) && array_key_exists($key, $value) ? self::findArrayVariable($document, $entry->value, $value[$key]) : null; if ($found !== null) { return $found; } diff --git a/tests/Unit/ActorIdentityMigrationTest.php b/tests/Unit/ActorIdentityMigrationTest.php index 126cd13..ebe571c 100644 --- a/tests/Unit/ActorIdentityMigrationTest.php +++ b/tests/Unit/ActorIdentityMigrationTest.php @@ -84,13 +84,13 @@ function createLegacyActorProject(): array expect(sourceHashTree($root))->toBe($before); }); -it('refuses a variable backed data block without flattening it', function () { +it('refuses a data block the file builds in code without flattening it', function () { [$root, $path] = createLegacyActorProject(); - file_put_contents($path, " 'Kaelion']; return ['data' => \$data];"); + file_put_contents($path, " 'Kaelion'], []); return ['data' => \$data];"); $before = sourceHashTree($root); try { ActorIdentityMigration::migrateProject($root); - $this->fail('Variable-backed actor data must be refused.'); + $this->fail('Actor data built in code must be refused.'); } catch (RuntimeException $failure) { expect($failure->getPrevious())->toBeInstanceOf(\Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal::class) ->and($failure->getMessage())->toContain('refusing to flatten') @@ -99,6 +99,15 @@ function createLegacyActorProject(): array expect(sourceHashTree($root))->toBe($before); }); +it('migrates a data block written in a variable as data where the variable is written', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " 'Kaelion']; return ['data' => \$data];"); + ActorIdentityMigration::migrateProject($root); + + expect((string) file_get_contents($path))->toContain("return ['data' => \$data];") + ->and((require $path)['data'])->toBe(['name' => 'Kaelion', 'id' => 'Kaelion']); +}); + it('rolls back all staged actors when readback cannot reproduce the authored value', function () { [$root, $path] = createLegacyActorProject(); file_put_contents(dirname($path) . '/Other.php', " ['name' => 'Other', 'description' => __FILE__]];"); diff --git a/tests/Unit/Cutscenes/ArraySourceWriterTest.php b/tests/Unit/Cutscenes/ArraySourceWriterTest.php index b52b11d..d9188f3 100644 --- a/tests/Unit/Cutscenes/ArraySourceWriterTest.php +++ b/tests/Unit/Cutscenes/ArraySourceWriterTest.php @@ -608,3 +608,149 @@ function rewriteAuthored(callable $change): string expect(fn() => ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($commented), $old, ['terminal' => null])) ->toThrow(SourcePreservationRefusal::class, 'cannot be replaced by a single value'); }); + +// -- Variables: written as data, or built in code ------------------------------ + +/** + * Rewrites a source so it evaluates to a changed array, proving it does. + * + * @return string The rewritten source. + */ +function rewriteSource(string $source, callable $change): string +{ + $old = evaluateSource($source); + $new = $old; + $change($new); + $rewritten = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source; + + expect(evaluateSource($rewritten))->toBe($new); + + return $rewritten; +} + +it('refuses a change under a variable the file builds in code, naming it, and keeps every change beside it', function (string $prelude, string $variable, array $path, mixed $value) { + $source = " \${$variable},\n 'plain' => 1,\n];\n"; + $document = PhpArraySourceDocument::parse($source); + $old = evaluateSource($source); + + $new = $old; + $target = &$new; + foreach ($path as $step) { + $target = &$target[$step]; + } + $target = $value; + unset($target); + + expect(static fn() => ArraySourceWriter::rewrite($document, $old, $new)) + ->toThrow(SourcePreservationRefusal::class, '$' . $variable . ', which the file\'s own code builds or uses'); + + // Nothing under it was flattened by a no-op, and the plain entry beside it still edits. + expect(ArraySourceWriter::rewrite($document, $old, $old)->source)->toBe($source); + $new = $old; + $new['plain'] = 2; + expect(ArraySourceWriter::rewrite($document, $old, $new)->source)->toBe(str_replace("'plain' => 1", "'plain' => 2", $source)); +})->with([ + 'filled by a loop' => ["\$slots = [];\nforeach (['a', 'b'] as \$role) {\n \$slots[\$role] = ['weapon' => ['attack' => 1]];\n}", 'slots', ['built', 'a', 'weapon', 'attack'], 3], + 'a literal changed later' => ["\$slots = ['a' => ['attack' => 1]];\n\$slots['b'] = ['attack' => 2];", 'slots', ['built', 'a', 'attack'], 3], + 'a literal code reads' => ["\$slots = ['a' => 1];\n\$count = count(\$slots);", 'slots', ['built', 'a'], 2], + 'a call' => ["\$slots = array_fill_keys(['a', 'b'], 1);", 'slots', ['built', 'a'], 2], + 'computed text' => ["\$slots = strtoupper('quiet');", 'slots', ['built'], 'LOUD'], + 'interpolated text' => ["\$name = 'Mira';\n\$slots = << ["\$slots = [];\nforeach (['a'] as \$role) {\n \$slots[\$role] = 1;\n}", 'slots', ['built'], ['b' => 2]], +]); + +it('refuses a variable the file never assigns at the top level', function () { + $source = " PhpArraySourceDocument::parse($source))->toThrow(SourceUnreadable::class); + + $source = " \$undefined ?? [],\n];\n"; + $document = PhpArraySourceDocument::parse($source); + expect(static fn() => ArraySourceWriter::rewrite($document, ['built' => []], ['built' => ['a' => 1]])) + ->toThrow(SourcePreservationRefusal::class, 'expression'); +}); + +it('edits a literal only one entry names where it is assigned, comments and reference intact', function () { + $source = <<<'PHP_SOURCE' + 25, + 'lengthFrames' => 18, + 'tracks' => [[ + 'id' => 'aura', + 'keyframes' => [ + ['frame' => 0, 'duration' => 3, 'content' => '* *'], + ], + ]], + // No sound cues yet. + 'cues' => [], +]; + +return [ + 'presentations' => [ + 'terminal' => $terminal, + ], +]; +PHP_SOURCE; + + $rewritten = rewriteSource($source, static function (array &$new): void { + $new['presentations']['terminal']['fps'] = 12; + $new['presentations']['terminal']['playback'] = 'once'; + $new['presentations']['terminal']['tracks'][0]['keyframes'][] = ['frame' => 3, 'duration' => 3, 'content' => "+\n+"]; + unset($new['presentations']['terminal']['lengthFrames']); + }); + + expect($rewritten)->toContain(" 'terminal' => \$terminal,\n") + ->and($rewritten)->toContain("// Field cadence, kept apart for reading.\n") + ->and($rewritten)->toContain(" // No sound cues yet.\n 'cues' => [],\n") + ->and($rewritten)->toContain(" 'fps' => 12,\n") + ->and($rewritten)->toContain(" 'playback' => 'once',\n") + ->and($rewritten)->not->toContain('lengthFrames') + // New art is a nowdoc defined ahead of the literal that names it. + ->and(strpos($rewritten, "\$keyframes_1_content = <<<'ART'\n+\n+\nART;"))->toBeLessThan(strpos($rewritten, '$terminal = [')) + ->and($rewritten)->toContain("['frame' => 3, 'duration' => 3, 'content' => \$keyframes_1_content],"); +}); + +it('edits a scalar literal only one entry names where it is assigned', function () { + $source = " \$speed,\n];\n"; + expect(rewriteSource($source, static function (array &$new): void { $new['speed'] = 4; })) + ->toBe(str_replace('$speed = 3;', '$speed = 4;', $source)); +}); + +it('gives one entry its own copy of plain data several entries share, and refuses a copy that would lose source', function () { + $shared = " 'event', 'name' => 'cards_found'];\n\nreturn [\n 'brann' => ['conditions' => [\$found]],\n 'vale' => ['conditions' => [\$found]],\n];\n"; + $rewritten = rewriteSource($shared, static function (array &$new): void { + $new['brann']['conditions'][0]['name'] = 'brann_found'; + }); + + expect($rewritten)->toContain("\$found = ['type' => 'event', 'name' => 'cards_found'];") + ->and($rewritten)->toContain("'vale' => ['conditions' => [\$found]],") + ->and($rewritten)->toContain("'brann' => ['conditions' => [['type' => 'event', 'name' => 'brann_found']]],"); + + $commented = str_replace("['type' => 'event', 'name' => 'cards_found'];", "[\n // The shared gate.\n 'type' => 'event',\n 'name' => 'cards_found',\n];", $shared); + $document = PhpArraySourceDocument::parse($commented); + $old = evaluateSource($commented); + $new = $old; + $new['brann']['conditions'][0]['name'] = 'brann_found'; + + expect(static fn() => ArraySourceWriter::rewrite($document, $old, $new)) + ->toThrow(SourcePreservationRefusal::class, '$found, which 2 values share'); +}); + +it('counts a literal named inside another literal as shared, so neither edit changes the other', function () { + $source = " 'event', 'name' => 'met'];\n\$scene = ['when' => \$gate, 'line' => 'Hello'];\n\nreturn [\n 'scene' => \$scene,\n 'gate' => \$gate,\n];\n"; + $document = PhpArraySourceDocument::parse($source); + + expect($document->referenceCount('gate'))->toBe(2) + ->and($document->variable('scene')?->kind)->toBe(SourceVariable::LITERAL); + + $rewritten = rewriteSource($source, static function (array &$new): void { + $new['scene']['when']['name'] = 'left'; + $new['scene']['line'] = 'Goodbye'; + }); + + expect($rewritten)->toContain("\$gate = ['type' => 'event', 'name' => 'met'];") + ->and($rewritten)->toContain("\$scene = ['when' => ['type' => 'event', 'name' => 'left'], 'line' => 'Goodbye'];") + ->and($rewritten)->toContain(" 'gate' => \$gate,\n"); +}); diff --git a/tests/Unit/MapSaveIntegrityTest.php b/tests/Unit/MapSaveIntegrityTest.php index 8246212..a21b230 100644 --- a/tests/Unit/MapSaveIntegrityTest.php +++ b/tests/Unit/MapSaveIntegrityTest.php @@ -250,20 +250,31 @@ ->and(tripletState($map))->toBe($before, 'and wrote nothing'); }); -it('replaces a variable-backed value with plain data while the shared variable survives for its other users', function () { +it('refuses an edit to a value the file builds in code, naming the variable, and writes nothing', function () { $root = authoredMapProject(); $map = authoredMap($root); + $before = tripletState($map); + $script = $map->getEventField('W', ['data', 'script']); - // W's script is written as `$watchScript`. Replacing the value is safe: - // the entry gets the literal, the prelude variable stays untouched for - // anything else that names it. - $map->setEventField('W', ['data', 'script'], [['type' => 'text', 'name' => 'X', 'text' => 'Rewritten.']]); - $map->save(); - $data = (string) file_get_contents($map->dataPath); + // W's script is `$watchScript`, which the file fills with a `require`. + // Writing the edited script as a literal would leave that code running + // for nothing, so the edit is refused before anything is written. + $refusal = null; + + try { + $map->setEventField('W', ['data', 'script'], [['type' => 'text', 'name' => 'X', 'text' => 'Rewritten.']]); + } catch (MapSourceRefusal $thrown) { + $refusal = $thrown; + } - expect($data)->toContain("\$watchScript = require dirname(__DIR__, 2) . '/Events/harbour-watch.php';") - ->and($data)->toContain('Rewritten.') - ->and($map->getEventField('W', ['data', 'script']))->toBe([['type' => 'text', 'name' => 'X', 'text' => 'Rewritten.']]); + expect($refusal)->not->toBeNull() + ->and($refusal->getMessage())->toContain('test-map.data.php') + ->and($refusal->getMessage())->toContain('events.W.data.script') + ->and($refusal->getMessage())->toContain('$watchScript') + ->and($refusal->getMessage())->toContain('own code builds or uses') + ->and($map->getEventField('W', ['data', 'script']))->toBe($script) + ->and($map->isDirty())->toBeFalse() + ->and(tripletState($map))->toBe($before, 'and wrote nothing'); }); it('keeps opaque siblings byte-identical while a safe edit lands beside them', function () { diff --git a/tests/Unit/Session/ComputedSourceRefusalTest.php b/tests/Unit/Session/ComputedSourceRefusalTest.php new file mode 100644 index 0000000..c83cb38 --- /dev/null +++ b/tests/Unit/Session/ComputedSourceRefusalTest.php @@ -0,0 +1,92 @@ +readDatabaseRecord('optimize_weights', $index)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === $field); + expect($row)->not->toBeNull("weights {$index} has a {$field} row"); + + return $row; +} + +it('refuses a weight the file builds in a loop when it is edited, saying why, and writes nothing', function () { + [$path, $session] = openOptimizePolicy(<<<'PHP' + 5, 'speed' => 1]; +$roleSlots = []; +foreach (['vanguard', 'oracle'] as $role) { + $roleSlots[$role] = ['weapon' => $weapon]; +} + +return [ + 'roleSlotStatWeights' => $roleSlots, + 'elementOutcomeWeights' => ['defence:*:absorb' => 16], +]; +PHP); + $before = (string) file_get_contents($path); + $labels = $session->listDatabaseRecords('optimize_weights')['records']; + $slot = array_search('vanguard · weapon', $labels, true); + expect($slot)->toBeInt(); + + $refusal = null; + try { + $session->applyDatabaseRecord('optimize_weights', $slot, findWeightRow($session, $slot, 'weights.attack')['key'], '9'); + } catch (SessionRefusal $thrown) { + $refusal = $thrown; + } + + expect($refusal)->not->toBeNull() + ->and($refusal->getMessage())->toContain('$roleSlots, which the file\'s own code builds or uses') + ->and(findWeightRow($session, $slot, 'weights.attack')['value'])->toBe('5', 'the record kept its value') + ->and((string) file_get_contents($path))->toBe($before); +}); + +it('edits a weight written in a variable as data where the variable is written', function () { + [$path, $session] = openOptimizePolicy(<<<'PHP' + ['weapon' => ['attack' => 5, 'speed' => 1]], +]; + +return [ + 'roleSlotStatWeights' => $roleSlots, +]; +PHP); + $before = (string) file_get_contents($path); + $slot = array_search('vanguard · weapon', $session->listDatabaseRecords('optimize_weights')['records'], true); + expect($slot)->toBeInt(); + + $session->applyDatabaseRecord('optimize_weights', $slot, findWeightRow($session, $slot, 'weights.attack')['key'], '9'); + $session->saveDatabase('optimize_weights'); + + expect((string) file_get_contents($path))->toBe(str_replace("'attack' => 5", "'attack' => 9", $before)); +}); From de96635746e6afeed06f49db89b07a33daf25e72 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 21:07:45 +0200 Subject: [PATCH 303/344] fix(validation): diagnose every chest reward the Engine cannot grant A chest with no item chosen passed validation and crashed in play (Inventory reference "" in ChestOpeningAction). The validator exempted blank loot as unfinished, skipped skill and spell loot, and read loot types more loosely than the Engine. One shared check, ChestRewardValidator, now reports as errors every chest whose reward the Engine cannot grant: blank, null or whitespace item loot; an inventory reference the Engine's id, name and alias rules do not resolve or find ambiguous; a loot type the Engine's chest does not give (skill, spell, experience); a loot type the Engine does not know, read exactly as LootType::tryFrom() reads it; gold or a quantity that gives nothing; a quantity that is not a whole number. Gold with empty loot and a positive quantity stays valid, and a LootType case and its string value are the same declaration. ChestLoot holds the Editor's one statement of which kinds the Engine's chest grants, mirrored from ChestOpeningAction until the Engine publishes that contract. The check feeds the project report, a Reward Error row on the chest in the inspector (both editors, tone "error" in the session rows), and the map save warnings of the terminal editor, the session host and Save All. Saving is never blocked and no reward is invented: an unfinished chest saves as authored. Behaviour changes, named plainly: - Blank or missing item loot is now an error; it was silently accepted. - Skill, spell and experience chests are now errors, and the loot type choices no longer offer them, since the Engine cannot grant them. - A miscased loot type such as "Gold" is now an error; the Engine reads it as an item. - Chest loot is checked on chest events only (by trigger class), so a chest with no data is checked and a non-chest event's "loot" key is not. - MapValidator::validate() takes the project's inventory as a third argument; ReferenceCatalog::getInventoryCatalog() is now public. --- src/Database/ReferenceCatalog.php | 14 +- src/Editor.php | 4 +- src/Events/ChestLoot.php | 101 +++++++ src/Inspector/MapInspector.php | 81 +++++- src/Session/EditorSession.php | 7 +- src/Storage/WorkspaceSave.php | 4 +- src/Validation/ChestRewardValidator.php | 213 +++++++++++++++ src/Validation/MapValidator.php | 52 +++- src/Validation/ProjectValidator.php | 94 ++----- tests/Pest.php | 13 + tests/Unit/ChestRewardValidationTest.php | 252 ++++++++++++++++++ tests/Unit/InventoryIdentityTest.php | 12 +- tests/Unit/MapGraphicsTest.php | 4 +- tests/Unit/MapValidatorTest.php | 16 +- tests/Unit/Session/MapAtlasAuthoringTest.php | 2 +- .../Unit/Session/MapLandmarkAuthoringTest.php | 4 +- 16 files changed, 757 insertions(+), 116 deletions(-) create mode 100644 src/Events/ChestLoot.php create mode 100644 src/Validation/ChestRewardValidator.php create mode 100644 tests/Unit/ChestRewardValidationTest.php diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 6953db2..33a24f7 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -237,10 +237,10 @@ public function valuesFor(string $category): array // entitled to sell. What is offered -- and stored -- is the // stable definition id, which is the identity the runtime // resolves and the one thing a rename does not change. - 'inventory' => $this->inventoryCatalog()->ids(), - 'items' => $this->inventoryCatalog()->idsIn('items'), - 'weapons' => $this->inventoryCatalog()->idsIn('weapons'), - 'armors' => $this->inventoryCatalog()->idsIn('armors'), + 'inventory' => $this->getInventoryCatalog()->ids(), + 'items' => $this->getInventoryCatalog()->idsIn('items'), + 'weapons' => $this->getInventoryCatalog()->idsIn('weapons'), + 'armors' => $this->getInventoryCatalog()->idsIn('armors'), 'bgm', 'sfx' => $this->audioValues($category), // The engine loads an enemy's image as // Graphics/Enemies/.txt, appending the extension itself, @@ -811,8 +811,8 @@ public function labelsFor(string $category): array $labels = []; - foreach ($this->inventoryCatalog()->definitions() as $id => $definition) { - $labels[$id] = $this->inventoryCatalog()->describe($id); + foreach ($this->getInventoryCatalog()->definitions() as $id => $definition) { + $labels[$id] = $this->getInventoryCatalog()->describe($id); } return $labels; @@ -823,7 +823,7 @@ public function labelsFor(string $category): array * * @return InventoryCatalog The catalogue. */ - private function inventoryCatalog(): InventoryCatalog + public function getInventoryCatalog(): InventoryCatalog { return $this->inventoryCatalog ??= InventoryCatalog::fromWorkspace($this->workspace); } diff --git a/src/Editor.php b/src/Editor.php index e73d8a0..36bcd3e 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -6670,7 +6670,7 @@ private function performSaveSelectedMap(?SaveRelocationPlan $reviewed = null): v return; } - $warnings = MapValidator::validate($selectedMap, $this->getMapsById()); + $warnings = MapValidator::validate($selectedMap, $this->getMapsById(), MapValidator::readInventoryCatalog($this->workspace)); $step = null; try { @@ -12712,7 +12712,7 @@ private function getLootTypeLabel(LootType $lootType, bool $plural = false): str private function getLootTypeOptionEntries(): array { - return MapInspector::LOOT_TYPE_CHOICES; + return MapInspector::listLootTypeChoices(); } private function resolveEventOptionSelectionIndex(string $currentValue): int { diff --git a/src/Events/ChestLoot.php b/src/Events/ChestLoot.php new file mode 100644 index 0000000..b971673 --- /dev/null +++ b/src/Events/ChestLoot.php @@ -0,0 +1,101 @@ + $definition The event definition. + */ + public static function isChest(array $definition): bool + { + return str_contains(strval($definition['class'] ?? ''), self::TRIGGER); + } + + /** + * Reads a declared loot type the way the Engine does. + * + * A missing or blank type is an item, as the Engine reads it. A value the + * Engine does not know returns null: the Engine would read it as an item + * too, but silently, so the author has to be told. + * + * @param mixed $declared The `lootType` data value: a case, its value, or nothing. + * @return LootType|null The type the chest holds, or null when the Engine does not know it. + */ + public static function readLootType(mixed $declared): ?LootType + { + if ($declared instanceof LootType) { + return $declared; + } + + if ($declared instanceof BackedEnum) { + $declared = $declared->value; + } + + if ($declared === null || $declared === '') { + return LootType::ITEM; + } + + return is_string($declared) || is_int($declared) ? LootType::tryFrom(strval($declared)) : null; + } + + /** + * Tells whether the Engine's chest can give this kind of loot. + */ + public static function isGrantable(LootType $type): bool + { + return in_array($type, self::GRANTABLE, true); + } + + /** + * Tells whether the Engine's chest gives this kind from the item store. + */ + public static function isInventory(LootType $type): bool + { + return $type !== LootType::GOLD && self::isGrantable($type); + } +} diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index ffa28cf..ae2bb0f 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -15,6 +15,7 @@ use Ichiloto\Editor\Database\RecordField; use Ichiloto\Editor\Database\RecordFieldCodec; use Ichiloto\Editor\Database\RecordSchemaCatalog; +use Ichiloto\Editor\Events\ChestLoot; use Ichiloto\Editor\Events\EventAuthoring; use Ichiloto\Editor\Events\EventRefusal; use Ichiloto\Editor\Events\EventTypeCatalog; @@ -26,6 +27,8 @@ use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\ProjectMap; +use Ichiloto\Editor\Validation\ChestRewardValidator; +use Ichiloto\Editor\Validation\Issue; use Ichiloto\Engine\Core\Enumerations\MovementHeading; use Ichiloto\Engine\Events\Enumerations\ChestType; use Ichiloto\Engine\Events\Enumerations\EventEngagement; @@ -90,8 +93,11 @@ ['label' => 'Legendary', 'value' => ChestType::LEGENDARY->value, 'description' => 'Reserved for the most special chest rewards.'], ]; - /** What a chest event can give, as an author picks one. */ - public const array LOOT_TYPE_CHOICES = [ + /** + * Every loot type as an author reads it. Only the ones the Engine's chest + * can give are offered ({@see listLootTypeChoices()}). + */ + private const array LOOT_TYPE_CHOICES = [ ['label' => 'Item', 'value' => LootType::ITEM->value, 'description' => 'Rewards an item from the project item database.'], ['label' => 'Gold', 'value' => LootType::GOLD->value, 'description' => 'Awards currency directly when the chest is opened.'], ['label' => 'Experience', 'value' => LootType::EXPERIENCE->value, 'description' => 'Awards experience directly when claimed.'], @@ -102,6 +108,20 @@ ['label' => 'Accessory', 'value' => LootType::ACCESSORY->value, 'description' => 'Rewards an accessory identifier.'], ]; + /** + * The loot types an author picks a chest's from: the kinds the Engine's + * chest can give, so a new choice never makes a chest that fails to open. + * + * @return list + */ + public static function listLootTypeChoices(): array + { + return array_values(array_filter( + self::LOOT_TYPE_CHOICES, + static fn(array $choice): bool => ChestLoot::isGrantable(LootType::from($choice['value'])), + )); + } + /** * @param ReferenceCatalog $references The project's references as the map sees them. * @param bool $graphical Whether the editor shows graphical presentation, and so offers it. @@ -254,6 +274,7 @@ public function getEventFields(ProjectMap $map, string $marker): array if ($definition !== null) { $fields = [...$fields, ...$this->buildEventDataFields($marker, $definition)]; + $fields = $this->describeChestRewardFields($map, $marker, $definition, $fields); $fields = \Ichiloto\Editor\Maps\MapPlacement::describeEventFields($definition, $fields); } @@ -708,6 +729,56 @@ public function applyMapBgmVariantValue(ProjectMap $map, array $field, mixed $va return $block === null ? null : $this->writeMapData($map, [MapBgmVariants::KEY], $block, sprintf('Music variant %d', $index + 1)); } + /** + * Places a chest's reward errors under the rows they are about. + * + * A chest whose reward the Engine cannot grant fails when opened, so the + * error is shown on the chest itself, from the same check the project + * report and the save warnings read. The chest stays editable and + * saveable: an unfinished chest is the author's draft. + * + * @param array $definition The event definition. + * @param array> $fields The event's rows so far. + * @return array> The rows, with any reward errors after the last loot row. + */ + private function describeChestRewardFields(ProjectMap $map, string $marker, array $definition, array $fields): array + { + if (! ChestLoot::isChest($definition)) { + return $fields; + } + + try { + $messages = array_map( + static fn(Issue $issue): string => trim($issue->message . ' ' . $issue->hint), + new ChestRewardValidator($this->references->getInventoryCatalog()) + ->validate((array) ($definition['data'] ?? []), sprintf('%s event %s', $map->mapId, $marker)), + ); + } catch (Throwable $throwable) { + $messages = [sprintf('The inventory could not be read, so the reward cannot be checked: %s', $throwable->getMessage())]; + } + + if ($messages === []) { + return $fields; + } + + $rows = array_map(static fn(string $message): array => [ + 'label' => 'Reward Error', + 'value' => $message, + 'editable' => false, + 'tone' => 'error', + 'name' => 'Reward Error', + ], $messages); + $lootRows = array_keys(array_filter($fields, static fn(array $field): bool => in_array( + array_values((array) ($field['path'] ?? [])), + [['data', 'lootType'], ['data', 'loot'], ['data', 'quantity']], + true, + ) && ($field['target'] ?? null) === 'event')); + $after = $lootRows === [] ? count($fields) : max($lootRows) + 1; + array_splice($fields, $after, 0, $rows); + + return $fields; + } + /** * The picker an event data row is chosen from, when its value names * another resource. @@ -1772,8 +1843,8 @@ private function decorateEventInspectorFields(string $marker, array $fields): ar { $decorated = []; // A chest's loot is chosen from the database its loot type names, as the runtime reads an unset type: an item. - $lootType = LootType::tryFrom(strval(array_find($fields, - static fn(array $field): bool => array_values((array) ($field['path'] ?? [])) === ['data', 'lootType'])['value'] ?? '')) ?? LootType::ITEM; + $lootType = ChestLoot::readLootType(array_find($fields, + static fn(array $field): bool => array_values((array) ($field['path'] ?? [])) === ['data', 'lootType'])['value'] ?? null) ?? LootType::ITEM; foreach ($fields as $field) { $field['marker'] = $marker; @@ -1790,7 +1861,7 @@ private function decorateEventInspectorFields(string $marker, array $fields): ar ['cue', 'kind'] => self::EVENT_CUE_KIND_CHOICES, ['engagement'] => self::EVENT_ENGAGEMENT_CHOICES, ['data', 'chestType'] => self::CHEST_TYPE_CHOICES, - ['data', 'lootType'] => self::LOOT_TYPE_CHOICES, + ['data', 'lootType'] => self::listLootTypeChoices(), default => null, }; diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 66d8812..e9e74ab 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -4609,7 +4609,7 @@ public function saveMap(string $mapId, ?string $answer = null, ?string $confirm throw new SessionRefusal('The map or the save manifest changed since the save relocations were shown, so nothing was written. Review them again.'); } } - $warnings = MapValidator::validate($map, $this->getMapsById()); + $warnings = MapValidator::validate($map, $this->getMapsById(), MapValidator::readInventoryCatalog($this->workspace)); $failures = []; $step = $relocation?->apply(); @@ -5236,8 +5236,9 @@ private static function describeRow(array $field): array 'writeTypes' => $kind === 'writes' && is_array($field['writeTypes'] ?? null) ? array_values($field['writeTypes']) : null, // One axis of a coordinate pair under the heading before it. 'axis' => in_array($field['axis'] ?? null, ['x', 'y'], true) ? $field['axis'] : null, - // A row that warns about the rows before it, such as a line's emotion its speaker lacks. - 'tone' => ($field['tone'] ?? null) === 'warning' ? 'warning' : null, + // A row about the rows before it: a warning (a line's emotion its speaker lacks) or an + // error (a chest whose reward the Engine cannot give). + 'tone' => in_array($field['tone'] ?? null, ['warning', 'error'], true) ? $field['tone'] : null, 'key' => $key, ], static fn(mixed $value): bool => $value !== null); } diff --git a/src/Storage/WorkspaceSave.php b/src/Storage/WorkspaceSave.php index 91af33a..74f56ee 100644 --- a/src/Storage/WorkspaceSave.php +++ b/src/Storage/WorkspaceSave.php @@ -41,6 +41,8 @@ public static function saveAll(ProjectWorkspace $workspace, BackupWriter $backup $mapsById[$map->mapId] = $map; } + $inventory = MapValidator::readInventoryCatalog($workspace); + foreach ($workspace->maps as $map) { if (! $map->isDirty()) { continue; @@ -63,7 +65,7 @@ public static function saveAll(ProjectWorkspace $workspace, BackupWriter $backup continue; } - foreach (MapValidator::validate($map, $mapsById) as $warning) { + foreach (MapValidator::validate($map, $mapsById, $inventory) as $warning) { $warnings[] = sprintf('%s: %s', $map->mapId, $warning); } diff --git a/src/Validation/ChestRewardValidator.php b/src/Validation/ChestRewardValidator.php new file mode 100644 index 0000000..f305b8d --- /dev/null +++ b/src/Validation/ChestRewardValidator.php @@ -0,0 +1,213 @@ + $data The chest event's `data`. + * @param string $where Where the chest lives. + * @return Issue[] The issues found. + */ + public function validate(array $data, string $where): array + { + $declaredType = $data['lootType'] ?? null; + $lootType = ChestLoot::readLootType($declaredType); + + if (! $lootType instanceof LootType) { + return [$this->createError( + $where, + sprintf('Its loot type "%s" is not one the Engine knows, so the game reads it as an item.', self::describe($declaredType)), + 'Choose the loot type from the picker.', + )]; + } + + if (! ChestLoot::isGrantable($lootType)) { + return [$this->createError( + $where, + sprintf('It gives %s, which the Engine\'s chests cannot give, so it fails when opened.', self::describeKind($lootType)), + 'Choose an item or gold. Chests need Engine support before they can give this.', + )]; + } + + $quantity = $data['quantity'] ?? 1; + + if (! self::isWholeNumber($quantity)) { + return [$this->createError( + $where, + sprintf('Its quantity "%s" is not a whole number, so the game cannot read the chest.', self::describe($quantity)), + 'Write a whole number above zero.', + )]; + } + + $quantity = intval($quantity); + $loot = $data['loot'] ?? null; + + if ($loot instanceof BackedEnum) { + $loot = $loot->value; + } + + return $lootType === LootType::GOLD + ? $this->validateGold($loot, $quantity, $where) + : $this->validateInventory($loot, $quantity, $lootType, $where); + } + + /** + * Checks a gold chest: its loot is the amount, or its quantity when the loot is not a number. + * + * @return Issue[] + */ + private function validateGold(mixed $loot, int $quantity, string $where): array + { + $isBlank = $loot === null || (is_string($loot) && trim($loot) === ''); + + if (! $isBlank && ! is_numeric($loot)) { + return [$this->createError( + $where, + sprintf('It gives gold "%s", which is not an amount.', self::describe($loot)), + 'Write a whole number.', + )]; + } + + $amount = $isBlank ? $quantity : intval($loot); + + if ($amount >= 1) { + return []; + } + + return [$this->createError( + $where, + $amount === 0 + ? 'It gives 0 gold, so opening it gives nothing.' + : sprintf('It gives %d gold, which opening it takes from the party.', $amount), + 'Write the amount above zero in its loot, or leave the loot empty and set its quantity.', + )]; + } + + /** + * Checks a chest the Engine fills from its item store. + * + * @return Issue[] + */ + private function validateInventory(mixed $loot, int $quantity, LootType $lootType, string $where): array + { + $noun = self::describeKind($lootType, article: false); + + if ($loot === null || (is_string($loot) && trim($loot) === '')) { + return [$this->createError( + $where, + sprintf('It has no %s chosen, so it fails when opened.', $noun), + sprintf('Choose the %s from the picker. The chest saves as it is until then.', $noun), + )]; + } + + if (! is_scalar($loot)) { + return [$this->createError( + $where, + sprintf('Its loot is %s, not a reference.', get_debug_type($loot)), + 'Choose the loot from the picker.', + )]; + } + + $reference = trim(strval($loot)); + $issues = []; + + if ($this->inventory->isAmbiguous($reference)) { + $issues[] = $this->createError( + $where, + sprintf('It gives the %s "%s", which more than one definition answers to, so it fails when opened.', $noun, $reference), + 'The Engine refuses an ambiguous reference. Give one of them a distinct name or alias.', + ); + } elseif (! $this->inventory->has($reference)) { + $issues[] = $this->createError( + $where, + sprintf('It gives the %s "%s", which does not exist, so it fails when opened.', $noun, $reference), + 'Choose it from the picker, which stores the stable id.', + ); + } + + if ($quantity < 1) { + $issues[] = $this->createError( + $where, + sprintf('Its quantity is %d, so opening it gives nothing.', $quantity), + 'Set the quantity above zero.', + ); + } + + return $issues; + } + + private function createError(string $where, string $message, string $hint): Issue + { + return Issue::error($where, $message, $hint, self::CODE); + } + + /** Tells whether a quantity reads as a whole number, the way the Engine's integer property takes it. */ + private static function isWholeNumber(mixed $value): bool + { + return is_int($value) + || (is_float($value) && floor($value) === $value) + || (is_string($value) && filter_var(trim($value), FILTER_VALIDATE_INT) !== false); + } + + private static function describe(mixed $value): string + { + if ($value instanceof BackedEnum) { + return strval($value->value); + } + + return is_scalar($value) ? strval($value) : get_debug_type($value); + } + + private static function describeKind(LootType $type, bool $article = true): string + { + $noun = match ($type) { + LootType::ITEM => 'item', + LootType::GOLD => 'gold', + LootType::EXPERIENCE => 'experience', + LootType::SKILL => 'skill', + LootType::SPELL => 'spell', + LootType::WEAPON => 'weapon', + LootType::ARMOR => 'armor', + LootType::ACCESSORY => 'accessory', + }; + + if (! $article || in_array($type, [LootType::GOLD, LootType::EXPERIENCE, LootType::ARMOR], true)) { + return $noun; + } + + return (in_array($noun[0], ['a', 'e', 'i', 'o', 'u'], true) ? 'an ' : 'a ') . $noun; + } +} diff --git a/src/Validation/MapValidator.php b/src/Validation/MapValidator.php index 5adb9d0..abe85d8 100644 --- a/src/Validation/MapValidator.php +++ b/src/Validation/MapValidator.php @@ -4,13 +4,17 @@ namespace Ichiloto\Editor\Validation; +use Ichiloto\Editor\Database\InventoryCatalog; +use Ichiloto\Editor\Events\ChestLoot; use Ichiloto\Editor\Field\MapAtlas; use Ichiloto\Editor\Field\MapLandmarks; use Ichiloto\Editor\Inspector\MapLandmarkFields; use Ichiloto\Editor\Inspector\MapTerrainFields; use Ichiloto\Editor\ProjectMap; +use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Field\MapCategoryCatalog; use InvalidArgumentException; +use Throwable; /** * Runs the advisory pre-save checks over a map. @@ -29,12 +33,15 @@ private function __construct() * * @param ProjectMap $map The map being saved. * @param array $mapsById Every workspace map keyed by map id. + * @param InventoryCatalog|null $inventory The project's inventory, for what its chests give; null when it + * could not be read ({@see readInventoryCatalog()}), which is itself reported. * @return string[] Human-readable warnings; empty when the map is clean. */ - public static function validate(ProjectMap $map, array $mapsById): array + public static function validate(ProjectMap $map, array $mapsById, ?InventoryCatalog $inventory): array { return [ ...self::findMarkersWithoutDefinitions($map), + ...self::findChestRewardProblems($map, $inventory), ...self::findDanglingDestinations($map, $mapsById), ...self::findOutOfRangeSpawnPoints($map, $mapsById), ...self::findRetiredData($map), @@ -95,6 +102,49 @@ private static function findRetiredData(ProjectMap $map): array : []; } + /** + * Reads the project's inventory for {@see validate()}, or null when it + * cannot be read: a save is never blocked by it. + */ + public static function readInventoryCatalog(ProjectWorkspace $workspace): ?InventoryCatalog + { + try { + return InventoryCatalog::fromWorkspace($workspace); + } catch (Throwable) { + return null; + } + } + + /** + * Finds chests whose reward the Engine cannot grant, by the shared chest + * check the inspectors and the project report also read. + * + * @return string[] + */ + private static function findChestRewardProblems(ProjectMap $map, ?InventoryCatalog $inventory): array + { + $chests = array_filter(self::getEventDefinitions($map), ChestLoot::isChest(...)); + + if ($chests === []) { + return []; + } + + if (! $inventory instanceof InventoryCatalog) { + return ['The inventory could not be read, so what the chests give was not checked.']; + } + + $validator = new ChestRewardValidator($inventory); + $warnings = []; + + foreach ($chests as $marker => $definition) { + foreach ($validator->validate((array) ($definition['data'] ?? []), sprintf('%s event %s', $map->mapId, $marker)) as $issue) { + $warnings[] = sprintf('Chest %s: %s', $marker, $issue->message); + } + } + + return $warnings; + } + /** * Finds event markers painted on the grid with no event definition. * diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 36afe0e..88ee13b 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -14,6 +14,7 @@ use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Database\SummonAssignmentDiagnostics; +use Ichiloto\Editor\Events\ChestLoot; use Ichiloto\Editor\Events\CommandMapContext; use Ichiloto\Editor\Events\ProjectScriptCommands; use Ichiloto\Editor\Field\MapEncounters; @@ -3868,7 +3869,9 @@ protected function checkMapReferences(ProjectWorkspace $workspace, array $known) } } - $issues = [...$issues, ...$this->checkChestLoot($data, $where, $known)]; + if (ChestLoot::isChest($definition)) { + $issues = [...$issues, ...$this->checkChestLoot($data, $where)]; + } } foreach ((array) ($map->data['npcs'] ?? []) as $npc) { @@ -3897,19 +3900,6 @@ protected function checkMapReferences(ProjectWorkspace $workspace, array $known) return $issues; } - /** - * Checks what a chest gives out. - * - * A chest names its loot by kind: an inventory definition, a skill, or an - * amount of gold or experience. An inventory reference that resolves to - * nothing opens a chest that gives nothing, which looks exactly like a - * chest an author meant to leave empty. - * - * @param array $data The trigger's data. - * @param string $where Where it lives. - * @param array $known What the project defines. - * @return Issue[] The issues found. - */ /** * Checks a map's conditional music the way the engine reads it: an * ordered list whose first matching variant selects the track. @@ -4028,71 +4018,19 @@ protected function checkBgmVariants(ProjectMap $map, array $known): array return $issues; } - protected function checkChestLoot(array $data, string $where, array $known): array + /** + * Checks that a chest can give what it holds, by the shared chest check + * the inspectors and the play preflight also read. + * + * @param array $data The chest's data. + * @param string $where Where it lives. + * @return Issue[] The issues found. + */ + protected function checkChestLoot(array $data, string $where): array { - if (! array_key_exists('lootType', $data) && ! array_key_exists('loot', $data)) { - return []; - } - - // A chest may declare its kind as the runtime enum or as its value. - $rawType = $data['lootType'] ?? ''; - $lootType = strtolower(trim($rawType instanceof \BackedEnum ? strval($rawType->value) : (is_scalar($rawType) ? strval($rawType) : ''))); - $loot = $data['loot'] ?? null; - $issues = []; - $kinds = ['item', 'gold', 'experience', 'skill', 'spell', 'weapon', 'armor', 'accessory']; - - if ($lootType !== '' && ! in_array($lootType, $kinds, true)) { - $issues[] = Issue::error( - $where, - sprintf('Its loot type "%s" is not one the game knows.', $lootType), - 'Choose item, gold, experience, skill, spell, weapon, armor, or accessory.' - ); - - return $issues; - } - - if ($loot === null || (is_string($loot) && trim($loot) === '')) { - // An unconfigured chest is a chest an author has not finished, not a - // broken reference. - return $issues; - } - - if (in_array($lootType, ['gold', 'experience'], true)) { - if (! is_numeric($loot)) { - $issues[] = Issue::error( - $where, - sprintf('It gives %s "%s", which is not an amount.', $lootType, strval(is_scalar($loot) ? $loot : get_debug_type($loot))), - 'Write a whole number.' - ); - } - - return $issues; - } - - if ($loot instanceof \BackedEnum) { - $loot = $loot->value; - } - - if (! is_scalar($loot)) { - $issues[] = Issue::error( - $where, - sprintf('Its loot is %s, not a reference.', get_debug_type($loot)), - 'Choose the loot from the picker.' - ); - - return $issues; - } - - // Only inventory loot is resolved here. A chest may also give a skill - // or a spell, and the same check would apply, but skill loot is not this - // boundary's subject and enabling it now would report existing project - // content that this work is not permitted to change. The gap is recorded - // for whoever owns that content rather than left to be rediscovered. - return [...$issues, ...match ($lootType) { - 'skill', 'spell' => [], - // No kind declared: the runtime reads the loot as an item. - default => $this->checkReference(strval($loot), 'inventory', 'item', $where, $known), - }]; + return $this->inventoryCatalog instanceof InventoryCatalog + ? new ChestRewardValidator($this->inventoryCatalog)->validate($data, $where) + : []; } /** diff --git a/tests/Pest.php b/tests/Pest.php index d5dec15..4469714 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -480,3 +480,16 @@ function itemsFromSource(string $source): array unlink($path); } } + +/** + * The synthetic sample project's inventory, read once: what map save + * warnings check a chest's reward against. + */ +function mapValidatorInventory(): \Ichiloto\Editor\Database\InventoryCatalog +{ + static $catalog = null; + + return $catalog ??= \Ichiloto\Editor\Database\InventoryCatalog::fromWorkspace( + \Ichiloto\Editor\ProjectWorkspace::fromProject(makeTemporaryProject('ichiloto-map-validator-')), + ); +} diff --git a/tests/Unit/ChestRewardValidationTest.php b/tests/Unit/ChestRewardValidationTest.php new file mode 100644 index 0000000..b5e64e0 --- /dev/null +++ b/tests/Unit/ChestRewardValidationTest.php @@ -0,0 +1,252 @@ + $data The chest's data. + * @return Issue[] + */ +function checkChestReward(array $data): array +{ + static $catalog = null; + $catalog ??= InventoryCatalog::fromWorkspace(ProjectWorkspace::fromProject(chestRewardProject())); + + return new ChestRewardValidator($catalog)->validate($data, 'test-map event C'); +} + +/** + * Writes a synthetic map whose events are the given definitions. + * + * @param array> $events Event definitions by marker. + */ +function writeChestRewardMap(string $root, array $events): void +{ + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $map = require $path; + $map['events'] = $events; + file_put_contents($path, "validate(ProjectWorkspace::fromProject($root)), + static fn(Issue $issue): bool => $issue->code === ChestRewardValidator::CODE, + )); +} + +it('reports an item chest with no item chosen as an error', function (mixed $loot) { + $issues = checkChestReward(['lootType' => 'item', 'loot' => $loot, 'quantity' => 1]); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::ERROR) + ->and($issues[0]->code)->toBe(ChestRewardValidator::CODE) + ->and($issues[0]->message)->toBe('It has no item chosen, so it fails when opened.'); +})->with([ + 'blank' => [''], + 'null' => [null], + 'whitespace' => [' '], +]); + +it('reports a chest with no loot at all, which the Engine reads as an empty item', function () { + expect(checkChestReward([]))->toHaveCount(1) + ->and(checkChestReward([])[0]->message)->toBe('It has no item chosen, so it fails when opened.'); +}); + +it('resolves item loot by the Engine\'s id, name and alias rules', function (string $loot) { + expect(checkChestReward(['lootType' => 'item', 'loot' => $loot]))->toBe([]); +})->with([ + 'stable id' => ['item.tonic'], + 'display name' => ['Tonic'], + 'alias' => ['Old Tonic'], + 'any case, padded' => [' old tonic '], +]); + +it('reports item loot the item store cannot resolve', function () { + $missing = checkChestReward(['lootType' => 'item', 'loot' => 'item.absent']); + $ambiguous = checkChestReward(['lootType' => 'item', 'loot' => 'Charm']); + + expect($missing)->toHaveCount(1) + ->and($missing[0]->severity)->toBe(Severity::ERROR) + ->and($missing[0]->message)->toBe('It gives the item "item.absent", which does not exist, so it fails when opened.') + ->and($ambiguous)->toHaveCount(1) + ->and($ambiguous[0]->message)->toContain('which more than one definition answers to'); +}); + +it('reads a chest with no loot type as holding an item, as the Engine does', function () { + expect(checkChestReward(['loot' => 'Tonic']))->toBe([]) + ->and(checkChestReward(['loot' => 'item.absent']))->toHaveCount(1); +}); + +it('accepts gold whose amount comes from its quantity when the loot is empty', function (array $data) { + expect(checkChestReward($data))->toBe([]); +})->with([ + 'null loot' => [['lootType' => 'gold', 'loot' => null, 'quantity' => 30]], + 'blank loot' => [['lootType' => 'gold', 'loot' => '', 'quantity' => 30]], + 'no loot' => [['lootType' => 'gold', 'quantity' => 30]], + 'amount as loot' => [['lootType' => 'gold', 'loot' => 50]], + 'amount as text' => [['lootType' => 'gold', 'loot' => '50']], +]); + +it('reports gold that gives nothing or takes money', function (array $data, string $message) { + $issues = checkChestReward($data); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::ERROR) + ->and($issues[0]->message)->toBe($message); +})->with([ + 'no amount' => [['lootType' => 'gold', 'loot' => null, 'quantity' => 0], 'It gives 0 gold, so opening it gives nothing.'], + 'negative' => [['lootType' => 'gold', 'loot' => -5], 'It gives -5 gold, which opening it takes from the party.'], + 'not a number' => [['lootType' => 'gold', 'loot' => 'lots'], 'It gives gold "lots", which is not an amount.'], +]); + +it('treats a loot type case and its string value as the same declaration', function (LootType $type, array $data) { + $asCase = checkChestReward(['lootType' => $type, ...$data]); + $asString = checkChestReward(['lootType' => $type->value, ...$data]); + + expect(array_map(static fn(Issue $issue): string => $issue->message, $asCase)) + ->toBe(array_map(static fn(Issue $issue): string => $issue->message, $asString)); +})->with([ + 'gold' => [LootType::GOLD, ['loot' => null, 'quantity' => 10]], + 'item' => [LootType::ITEM, ['loot' => 'Tonic']], + 'blank item' => [LootType::ITEM, ['loot' => '']], + 'skill' => [LootType::SKILL, ['loot' => 'Twin Cut']], +]); + +it('reports a loot type the Engine\'s chest cannot give', function (string $type, string $kind) { + $issues = checkChestReward(['lootType' => $type, 'loot' => 'Twin Cut', 'quantity' => 1]); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::ERROR) + ->and($issues[0]->message)->toBe(sprintf('It gives %s, which the Engine\'s chests cannot give, so it fails when opened.', $kind)); +})->with([ + 'skill' => ['skill', 'a skill'], + 'spell' => ['spell', 'a spell'], + 'experience' => ['experience', 'experience'], +]); + +it('reports a loot type the Engine does not know, exactly as the Engine spells it', function (string $type) { + $issues = checkChestReward(['lootType' => $type, 'loot' => 'Tonic']); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->message)->toBe(sprintf('Its loot type "%s" is not one the Engine knows, so the game reads it as an item.', $type)); +})->with([ + 'unknown' => ['treasure'], + // LootType::tryFrom() is case-sensitive: the Engine reads this as an item. + 'miscased' => ['Gold'], +]); + +it('reports a quantity that gives nothing or cannot be read', function () { + expect(checkChestReward(['lootType' => 'item', 'loot' => 'Tonic', 'quantity' => 0])[0]->message ?? null) + ->toBe('Its quantity is 0, so opening it gives nothing.') + ->and(checkChestReward(['lootType' => 'item', 'loot' => 'Tonic', 'quantity' => 'many'])[0]->message ?? null) + ->toBe('Its quantity "many" is not a whole number, so the game cannot read the chest.') + ->and(checkChestReward(['lootType' => 'item', 'loot' => 'Tonic', 'quantity' => '3']))->toBe([]); +}); + +it('offers only the loot types the Engine\'s chest can give', function () { + $offered = array_column(MapInspector::listLootTypeChoices(), 'value'); + + expect($offered)->toBe(['item', 'gold', 'weapon', 'armor', 'accessory']); + + foreach (LootType::cases() as $type) { + expect(in_array($type->value, $offered, true))->toBe(ChestLoot::isGrantable($type)); + } +}); + +it('reports every chest in the project report and leaves other events alone', function () { + $root = chestRewardProject(); + $chest = 'Ichiloto\Engine\Events\Triggers\ChestEventTrigger'; + writeChestRewardMap($root, [ + 'A' => ['class' => $chest, 'data' => ['lootType' => 'item', 'loot' => '', 'quantity' => 1]], + 'B' => ['class' => $chest, 'data' => ['lootType' => 'skill', 'loot' => 'Twin Cut', 'quantity' => 1]], + 'C' => ['class' => $chest, 'data' => ['lootType' => 'gold', 'loot' => null, 'quantity' => 25]], + 'D' => ['class' => $chest, 'data' => ['lootType' => 'item', 'loot' => 'Old Tonic']], + 'E' => ['class' => $chest], + // Not a chest: what it calls loot means nothing to the Engine. + 'F' => ['class' => 'Ichiloto\Engine\Events\Triggers\DialogueEventTrigger', 'data' => ['loot' => '', 'dialogue' => []]], + ]); + + $where = array_map(static fn(Issue $issue): string => $issue->where, listChestRewardIssues($root)); + + expect($where)->toBe(['test-map event A', 'test-map event B', 'test-map event E']); +}); + +it('diagnoses a new chest on its inspector row and in the save warnings, and still saves it as authored', function () { + $root = chestRewardProject(); + $session = Ichiloto\Editor\Session\EditorSession::open($root); + $map = $session->readMap('test-map'); + $layer = array_find($map['layers'], static fn(array $layer): bool => $layer['event']); + $cell = null; + foreach ($layer['rows'] as $y => $row) { + $x = array_find_key($row, static fn(string $symbol): bool => trim($symbol) === ''); + if ($x !== null) { + $cell = [$x, $y]; + break; + } + } + $created = $session->createEvent('test-map', $map['revision'], [$cell], 'Chest'); + $marker = $created['marker']; + $rows = $session->readInspector('test-map', $marker)['rows']; + $labels = array_map(static fn(array $row): string => trim($row['label']), $rows); + $error = array_find($rows, static fn(array $row): bool => trim($row['label']) === 'Reward Error'); + + // A new chest starts visibly incomplete: no reward is invented for it. + expect($error)->toMatchArray([ + 'kind' => 'info', + 'tone' => 'error', + 'value' => 'It has no item chosen, so it fails when opened. Choose the item from the picker. The chest saves as it is until then.', + ]) + // Under the rows it is about. + ->and(array_search('Reward Error', $labels, true))->toBeGreaterThan(array_search('Loot', $labels, true)) + // Only kinds the Engine's chest can give are offered. + ->and(array_find($rows, static fn(array $row): bool => trim($row['label']) === 'Loot Type')['options']) + ->toBe(['item', 'gold', 'weapon', 'armor', 'accessory']); + + $saved = $session->saveMap('test-map'); + + expect($saved['saved'])->toBe('test-map') + ->and($saved['warnings'])->toContain(sprintf('Chest %s: It has no item chosen, so it fails when opened.', $marker)) + ->and((require $root . '/assets/Maps/test-map/test-map.data.php')['events'][$marker]['data']['loot'])->toBe(''); + + $loot = array_find($session->readInspector('test-map', $marker)['rows'], static fn(array $row): bool => trim($row['label']) === 'Loot'); + $session->applyInspector('test-map', $session->readMap('test-map')['revision'], $loot['key'], 'item.tonic'); + + expect(array_column($session->readInspector('test-map', $marker)['rows'], 'label'))->not->toContain('Reward Error') + ->and(array_filter($session->saveMap('test-map')['warnings'], + static fn(string $warning): bool => str_starts_with($warning, sprintf('Chest %s:', $marker))))->toBe([]); +}); diff --git a/tests/Unit/InventoryIdentityTest.php b/tests/Unit/InventoryIdentityTest.php index 1cb1e8c..4df133a 100644 --- a/tests/Unit/InventoryIdentityTest.php +++ b/tests/Unit/InventoryIdentityTest.php @@ -373,12 +373,12 @@ function writeCompatibilityManifest(string $root, array $manifest): void $lines = implode("\n", issueLines($root)); - expect($lines)->toContain('It names the item "item.absent", which does not exist.') - ->and($lines)->toContain('It gives gold "lots", which is not an amount.') - ->and($lines)->toContain('Its loot type "treasure" is not one the game knows.') - // An unconfigured chest is unfinished authoring, not a broken - // reference. - ->and($lines)->not->toContain('event I'); + expect($lines)->toContain('test-map event E: It gives the item "item.absent", which does not exist, so it fails when opened.') + ->and($lines)->toContain('test-map event G: It gives gold "lots", which is not an amount.') + ->and($lines)->toContain('test-map event H: Its loot type "treasure" is not one the Engine knows, so the game reads it as an item.') + // A chest with no item chosen fails when opened, so it is reported + // too; it still saves as the author left it. + ->and($lines)->toContain('test-map event I: It has no item chosen, so it fails when opened.'); }); it('keeps a key item\'s quantity through the condition line, both ways', function () { diff --git a/tests/Unit/MapGraphicsTest.php b/tests/Unit/MapGraphicsTest.php index 1c3177e..dd0a676 100644 --- a/tests/Unit/MapGraphicsTest.php +++ b/tests/Unit/MapGraphicsTest.php @@ -419,7 +419,7 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array $map = loadLayeredMap($root); $map->setMapDataField(['tileset'], 'missing'); - expect(MapValidator::validate($map, ['test-map' => $map]))->toContain('Tileset missing was not found at Data/Tilesets/missing.php.'); + expect(MapValidator::validate($map, ['test-map' => $map], mapValidatorInventory()))->toContain('Tileset missing was not found at Data/Tilesets/missing.php.'); $map->setMapDataField(['tileset'], 'home'); $map->resize(6, 3); @@ -430,7 +430,7 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array $map = loadLayeredMap($root); $coverage = array_map(static fn(Issue $issue): string => $issue->message, MapGraphicsValidator::validateCoverage($map)); expect($coverage)->not->toBe([]) - ->and(array_intersect(MapValidator::validate($map, ['test-map' => $map]), $coverage))->toBe([]); + ->and(array_intersect(MapValidator::validate($map, ['test-map' => $map], mapValidatorInventory()), $coverage))->toBe([]); }); it('warns about a map without a kind only in a project that has tilesets', function () { diff --git a/tests/Unit/MapValidatorTest.php b/tests/Unit/MapValidatorTest.php index a4d1777..85cf200 100644 --- a/tests/Unit/MapValidatorTest.php +++ b/tests/Unit/MapValidatorTest.php @@ -30,13 +30,13 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ], ], ['E ', ' ']); - expect(MapValidator::validate($map, ['town' => $map]))->toBe([]); + expect(MapValidator::validate($map, ['town' => $map], mapValidatorInventory()))->toBe([]); }); it('warns about event markers without definitions', function () { $map = validatorMap('town', ['name' => 'Town', 'events' => []], ['E ', ' ']); - $warnings = MapValidator::validate($map, ['town' => $map]); + $warnings = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); expect($warnings)->toHaveCount(1) ->and($warnings[0])->toContain('marker E') @@ -51,7 +51,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ], ], [' E E', 'EE ']); - expect(MapValidator::validate($map, ['town' => $map]))->toBe([]); + expect(MapValidator::validate($map, ['town' => $map], mapValidatorInventory()))->toBe([]); }); it('warns about dangling transfer destinations', function () { @@ -62,7 +62,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ], ], ['T ', ' ']); - $warnings = MapValidator::validate($map, ['town' => $map]); + $warnings = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); expect($warnings)->toHaveCount(1) ->and($warnings[0])->toContain('vanished-map') @@ -84,7 +84,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ], ], ['T ', ' ']); - $warnings = MapValidator::validate($map, ['town' => $map, 'cave' => $destination]); + $warnings = MapValidator::validate($map, ['town' => $map, 'cave' => $destination], mapValidatorInventory()); expect($warnings)->toHaveCount(1) ->and($warnings[0])->toContain('spawn point (40, 1)') @@ -100,7 +100,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ], ], ['SB ', ' ']); - $warnings = MapValidator::validate($map, ['town' => $map]); + $warnings = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); expect($warnings)->toHaveCount(1) ->and($warnings[0])->toContain('B:') @@ -115,7 +115,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ], ], ['TX ', ' ']); - $warnings = MapValidator::validate($map, ['town' => $map]); + $warnings = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); expect($warnings)->toHaveCount(2); }); @@ -127,6 +127,6 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' 'tiles2d' => ['asset' => 'Graphics/Tilesets/shared.png', 'symbols' => []], ]); - expect(MapValidator::validate($map, ['town' => $map])) + expect(MapValidator::validate($map, ['town' => $map], mapValidatorInventory())) ->toBe(['tiles2d is no longer read; the map shows its terminal glyphs until it has a tileset.']); }); diff --git a/tests/Unit/Session/MapAtlasAuthoringTest.php b/tests/Unit/Session/MapAtlasAuthoringTest.php index dee1c08..b9258b4 100644 --- a/tests/Unit/Session/MapAtlasAuthoringTest.php +++ b/tests/Unit/Session/MapAtlasAuthoringTest.php @@ -67,7 +67,7 @@ function findAtlasRow($session, string $field, string $mapId = 'test-map'): arra it('reports an atlas whose parent no longer exists', function () { [, , $session] = createAtlasProject("'atlas' => ['parent' => ['map' => 'world', 'landmark' => 'old-gate']],"); $map = getSessionMap($session); - $problems = MapValidator::validate($map, ['test-map' => $map, 'world' => getSessionMap($session, 'world')]); + $problems = MapValidator::validate($map, ['test-map' => $map, 'world' => getSessionMap($session, 'world')], mapValidatorInventory()); expect($problems)->toContain('Atlas: Atlas parent landmark does not exist on its referenced map.'); expect(findAtlasRow($session, 'parent')['optionLabels'])->toContain('world#old-gate · no longer declared'); }); diff --git a/tests/Unit/Session/MapLandmarkAuthoringTest.php b/tests/Unit/Session/MapLandmarkAuthoringTest.php index df258d7..7eb3f59 100644 --- a/tests/Unit/Session/MapLandmarkAuthoringTest.php +++ b/tests/Unit/Session/MapLandmarkAuthoringTest.php @@ -148,10 +148,10 @@ it('reports declarations the Engine refuses, and still lets an author repair them', function () { [, , $session] = createLandmarkProject("'landmarks' => [['id' => 'gate', 'kind' => 'building', 'anchor' => ['event' => 'T']]],"); $map = getSessionMap($session); - expect(MapValidator::validate($map, ['test-map' => $map]))->toContain('Minimap landmarks: A transfer landmark must use passage, entrance or exit so ordinary routes remain visible.'); + expect(MapValidator::validate($map, ['test-map' => $map], mapValidatorInventory()))->toContain('Minimap landmarks: A transfer landmark must use passage, entrance or exit so ordinary routes remain visible.'); expect(findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 0])['options'])->toBe(['passage', 'entrance', 'exit']); applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 0], 'entrance'); - expect(MapValidator::validate($map, ['test-map' => $map]))->not->toContain('Minimap landmarks: A transfer landmark must use passage, entrance or exit so ordinary routes remain visible.'); + expect(MapValidator::validate($map, ['test-map' => $map], mapValidatorInventory()))->not->toContain('Minimap landmarks: A transfer landmark must use passage, entrance or exit so ordinary routes remain visible.'); }); it('describes a selected world object from its own inspector, through the same rows', function () { From d924b36035a5d4e554a7803a19931ce9946888ca Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 21:19:16 +0200 Subject: [PATCH 304/344] refactor(validation): ask the Engine which loot types a chest grants ChestLoot::isGrantable() now asks ChestOpeningAction::supportsLootType(), the Engine's own answer, and the Editor's mirrored list of grantable kinds is removed, so the validator, the loot type choices and the GUI editor follow the Engine as it adds reward kinds. The unused isInventory() helper is removed. Requires Engine e95a099 (fix(chests): reject unsupported loot and expose grant capabilities) or later; do not integrate before that Engine source is live. --- src/Events/ChestLoot.php | 37 +++++------------------- tests/Unit/ChestRewardValidationTest.php | 5 +++- 2 files changed, 12 insertions(+), 30 deletions(-) diff --git a/src/Events/ChestLoot.php b/src/Events/ChestLoot.php index b971673..e323fdb 100644 --- a/src/Events/ChestLoot.php +++ b/src/Events/ChestLoot.php @@ -5,26 +5,22 @@ namespace Ichiloto\Editor\Events; use BackedEnum; +use Ichiloto\Engine\Entities\Actions\ChestOpeningAction; use Ichiloto\Engine\Events\Enumerations\LootType; /** * What a chest event's loot means to the Engine, for every editor surface * that authors or checks one. * - * The rules are the Engine's, mirrored rather than reinvented. + * The rules are the Engine's, read from it rather than reinvented. * `ChestEventTrigger::configure()` reads the trigger data after a JSON round * trip, so a `LootType` case and its string value are the same declaration; * it reads the type with `LootType::tryFrom()`, exactly and case-sensitively, - * and reads a missing, blank or unknown type as an item. `ChestOpeningAction` - * then grants gold as an amount and resolves every other kind through the - * `ItemStore`, so only gold and inventory kinds can be given. A skill, spell - * or experience chest names something the item store does not hold. - * - * The Engine does not yet publish which kinds its chest grants, so the set - * below mirrors `ChestOpeningAction`. It is the one place the Editor states - * it: the validator, the inspector's loot type choices and the GUI editor - * all read it from here, and it is replaced by the Engine's own answer once - * that contract exists. + * and reads a missing, blank or unknown type as an item. Which kinds a chest + * can give is the Engine's answer, `ChestOpeningAction::supportsLootType()`: + * gold as an amount and the inventory kinds from the `ItemStore`. The + * validator, the inspector's loot type choices and the GUI editor all ask + * through here, so they follow the Engine as it grows. * * @package Ichiloto\Editor\Events */ @@ -33,15 +29,6 @@ final class ChestLoot /** The trigger class a chest event declares, matched the way the validator matches trigger classes. */ public const string TRIGGER = 'ChestEventTrigger'; - /** The kinds `ChestOpeningAction` grants: gold as an amount, the rest from the item store. */ - private const array GRANTABLE = [ - LootType::GOLD, - LootType::ITEM, - LootType::WEAPON, - LootType::ARMOR, - LootType::ACCESSORY, - ]; - private function __construct() { } @@ -88,14 +75,6 @@ public static function readLootType(mixed $declared): ?LootType */ public static function isGrantable(LootType $type): bool { - return in_array($type, self::GRANTABLE, true); - } - - /** - * Tells whether the Engine's chest gives this kind from the item store. - */ - public static function isInventory(LootType $type): bool - { - return $type !== LootType::GOLD && self::isGrantable($type); + return ChestOpeningAction::supportsLootType($type); } } diff --git a/tests/Unit/ChestRewardValidationTest.php b/tests/Unit/ChestRewardValidationTest.php index b5e64e0..6a9dea5 100644 --- a/tests/Unit/ChestRewardValidationTest.php +++ b/tests/Unit/ChestRewardValidationTest.php @@ -10,6 +10,7 @@ use Ichiloto\Editor\Validation\Issue; use Ichiloto\Editor\Validation\ProjectValidator; use Ichiloto\Editor\Validation\Severity; +use Ichiloto\Engine\Entities\Actions\ChestOpeningAction; use Ichiloto\Engine\Entities\Inventory\Items\Item; use Ichiloto\Engine\Events\Enumerations\LootType; @@ -184,7 +185,9 @@ function listChestRewardIssues(string $root): array expect($offered)->toBe(['item', 'gold', 'weapon', 'armor', 'accessory']); foreach (LootType::cases() as $type) { - expect(in_array($type->value, $offered, true))->toBe(ChestLoot::isGrantable($type)); + // The Engine answers which kinds its chest gives; the Editor asks it rather than keeping its own list. + expect(in_array($type->value, $offered, true))->toBe(ChestOpeningAction::supportsLootType($type)) + ->and(ChestLoot::isGrantable($type))->toBe(ChestOpeningAction::supportsLootType($type)); } }); From 86ceb6c5dffcf5275d38fcdfe74b722268581bfc Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 21:29:35 +0200 Subject: [PATCH 305/344] feat(world-objects): author when a placement is visible Every world object, a resource placement or inline art, gains Visible When: the saved world conditions under which the Engine shows it (the Engine's new top-level worldObjects conditions, candidate d6dc6b3). It uses the shared conditions control and source-preserving record path; an empty list removes the key, which the Engine reads as always shown, and a predicate the Engine cannot evaluate is refused before the map changes. The GUI editor shows the row through its generic conditions control; the Terminal editor gains no graphical authoring. --- docs/manual.md | 7 +++++- src/Field/WorldObjectAuthoring.php | 3 +++ .../Unit/Session/WorldObjectAuthoringTest.php | 22 +++++++++++++++++++ .../Unit/Session/WorldObjectResourceTest.php | 19 ++++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/manual.md b/docs/manual.md index 187595e..4ae7e2e 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1294,7 +1294,12 @@ Resource picker in place of its own pivot and art, which the resource owns. Every world object offers that picker, a new one included: choosing a resource makes the object a placement of it, dropping its own pivot, art and variants, and choosing none gives it back an art of its own, starting empty -on the ground, as a newly created object does. +on the ground, as a newly created object does. Every world object, a placement +of a resource or one with its own art, also has Visible When: the saved world +conditions under which the game shows it, all of which must hold. None means +always shown; clearing the list removes it from the map. Hiding an object +changes only what is drawn: its cells, collision and events stay as authored, +and the map's own glyphs show where it stood. A resource shows an image or a sign board (Shows). A sign board names its board image, cut into fixed left and right caps and a centre that repeats so diff --git a/src/Field/WorldObjectAuthoring.php b/src/Field/WorldObjectAuthoring.php index 5def193..a32c2cb 100644 --- a/src/Field/WorldObjectAuthoring.php +++ b/src/Field/WorldObjectAuthoring.php @@ -178,6 +178,9 @@ public function getRecords(): ProjectRecordDatabase $fields = [new RecordField('id', 'Stable Map-local Id', isReadOnly: true), new RecordField('anchor.x', 'Anchor X', InputControlType::INTEGER), new RecordField('anchor.y', 'Anchor Y', InputControlType::INTEGER), + // Every placement, resource or inline, may show only while saved world state holds; none means always. + new RecordField('conditions', 'Visible When (all conditions)', removeWhenEmpty: true, + codec: RecordFieldCodec::CONDITIONS, displayDefault: '(always)'), ...$presentation, ...self::getTerminalFields($entry, false), new RecordField('covers.layer', 'Covered Gameplay Layer', options: ['', ...array_column(array_filter($this->map->getLayers(), diff --git a/tests/Unit/Session/WorldObjectAuthoringTest.php b/tests/Unit/Session/WorldObjectAuthoringTest.php index e5b1f7b..d894703 100644 --- a/tests/Unit/Session/WorldObjectAuthoringTest.php +++ b/tests/Unit/Session/WorldObjectAuthoringTest.php @@ -392,3 +392,25 @@ function readWorldObjectRow(EditorSession $session, string $field): ?array applyWorldObjectRow($session, 'variant3Terminalmode', 'its covered glyphs recoloured'); expect($session->readMap('test-map')['worldObjects'][0]['variants'][3]['terminal'])->toBe(['color' => 'white']); }); + +it('authors an inline object\'s placement conditions beside its variants, keeping the rest of the source', function () { + [$root, $file, $session] = createWorldObjectProject(); + applyWorldObjectRow($session, 'conditions', 'switch:existing-condition'); + applyWorldObjectRow($session, 'anchor.x', '2'); + $object = $session->readMap('test-map')['worldObjects'][0]; + expect($object['conditions'])->toBe([['type' => 'switch', 'name' => 'existing-condition']]) + ->and($object['anchor'])->toBe(['x' => 2, 'y' => 1]); + $session->saveMap('test-map'); + expect(file_get_contents($file))->toContain('/* map-owned identity */', "'preserved' => abs(7)") + ->and(EditorSession::open($root)->readMap('test-map')['worldObjects'][0]['conditions']) + ->toBe([['type' => 'switch', 'name' => 'existing-condition']]); +}); + +it('refuses a placement condition the Engine cannot evaluate before changing the map', function () { + [, $file, $session] = createWorldObjectProject(); + $before = $session->readMap('test-map')['worldObjects']; + $source = file_get_contents($file); + expect(fn() => applyWorldObjectRow($session, 'conditions', 'no-such-predicate:anything'))->toThrow(SessionRefusal::class); + expect($session->readMap('test-map')['worldObjects'])->toBe($before) + ->and(file_get_contents($file))->toBe($source); +}); diff --git a/tests/Unit/Session/WorldObjectResourceTest.php b/tests/Unit/Session/WorldObjectResourceTest.php index 3c70cd7..bd3aaf9 100644 --- a/tests/Unit/Session/WorldObjectResourceTest.php +++ b/tests/Unit/Session/WorldObjectResourceTest.php @@ -84,3 +84,22 @@ function findPlacementRow(EditorSession $session, string $field): ?array ->and($session->readMap('test-map'))->toBe($map) ->and((string) file_get_contents($file))->toBe($before); }); + +it('shows a resource placement only while its authored conditions hold, and always when it has none', function () { + [$root, $file, $session] = createResourcePlacementProject(); + expect(findPlacementRow($session, 'conditions'))->toMatchArray(['kind' => 'conditions']); + $row = findPlacementRow($session, 'conditions'); + $session->applyWorldObject('test-map', $session->readWorldObject('test-map', 'grove-001')['revision'], 'grove-001', $row['key'], 'switch:grove-grown'); + + expect($session->readMap('test-map')['worldObjects'][0]['conditions'])->toBe([['type' => 'switch', 'name' => 'grove-grown']]); + $session->saveMap('test-map'); + expect((string) file_get_contents($file))->toContain("'preserved' => abs(7)") + ->and(EditorSession::open($root)->readMap('test-map')['worldObjects'][0]['conditions'])->toBe([['type' => 'switch', 'name' => 'grove-grown']]); + + // Clearing the list removes the key, which the Engine reads as always visible; it never writes an empty or null list. + $row = findPlacementRow($session, 'conditions'); + $session->applyWorldObject('test-map', $session->readWorldObject('test-map', 'grove-001')['revision'], 'grove-001', $row['key'], ''); + expect($session->readMap('test-map')['worldObjects'][0])->not->toHaveKey('conditions'); + $session->undo(); + expect($session->readMap('test-map')['worldObjects'][0]['conditions'])->toBe([['type' => 'switch', 'name' => 'grove-grown']]); +}); From c7bf0072f477881327fba3f1e618566ca1444007 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:06:48 +0200 Subject: [PATCH 306/344] refactor(validation): check chest rewards with the Engine's own rule ChestRewardValidator no longer holds chest rules. It calls the Engine's MapCompletenessValidator::validateChestReward(), the check the Engine's chest runs before it opens, and adapts it for the Editor: item references resolve through the Editor's InventoryCatalog (stable id, null when nothing answers, a failure when a reference is ambiguous), and each MapValidationIssue becomes an Editor error on " event " with the Engine's rule identity (chest.reward) as its code. The project report, the inspector Reward Error rows and the map save warnings all read it, and drafts still save. Behaviour changes, named plainly: - Finding wording is now the Engine's. - An inventory reference written as a number, and a fractional gold amount, are now reported, because the Engine rejects them. - validate() takes the map id, marker and data. Requires Engine c8ebc3b (feat(field): bind event subjects and share map completeness errors) or later; do not integrate before that Engine source is live. --- src/Inspector/MapInspector.php | 2 +- src/Validation/ChestRewardValidator.php | 211 ++++------------------- src/Validation/MapValidator.php | 6 +- src/Validation/ProjectValidator.php | 13 +- tests/Unit/ChestRewardValidationTest.php | 132 +++++--------- tests/Unit/InventoryIdentityTest.php | 18 +- 6 files changed, 103 insertions(+), 279 deletions(-) diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index ae2bb0f..6b65c7e 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -751,7 +751,7 @@ private function describeChestRewardFields(ProjectMap $map, string $marker, arra $messages = array_map( static fn(Issue $issue): string => trim($issue->message . ' ' . $issue->hint), new ChestRewardValidator($this->references->getInventoryCatalog()) - ->validate((array) ($definition['data'] ?? []), sprintf('%s event %s', $map->mapId, $marker)), + ->validate($map->mapId, $marker, (array) ($definition['data'] ?? [])), ); } catch (Throwable $throwable) { $messages = [sprintf('The inventory could not be read, so the reward cannot be checked: %s', $throwable->getMessage())]; diff --git a/src/Validation/ChestRewardValidator.php b/src/Validation/ChestRewardValidator.php index f305b8d..e69a4f8 100644 --- a/src/Validation/ChestRewardValidator.php +++ b/src/Validation/ChestRewardValidator.php @@ -4,27 +4,28 @@ namespace Ichiloto\Editor\Validation; -use BackedEnum; use Ichiloto\Editor\Database\InventoryCatalog; -use Ichiloto\Editor\Events\ChestLoot; -use Ichiloto\Engine\Events\Enumerations\LootType; +use Ichiloto\Engine\Field\Validation\MapCompletenessValidator; +use Ichiloto\Engine\Field\Validation\MapValidationIssue; /** - * Checks that a chest can give what it holds. + * Checks that a chest can give what it holds, by the Engine's own rule. * - * A chest whose reward the Engine cannot grant fails when the player opens - * it, so every such chest is an error: no item chosen, an item reference the - * Engine's item store does not resolve, a kind the Engine's chest does not - * give, a gold amount or quantity that gives nothing. An unfinished chest is - * still saved as the author left it; the error is how the Editor says it is - * not ready to play. One check serves the project report, the inspectors of - * both editors and the play preflight, so they always agree. + * The rule is `MapCompletenessValidator::validateChestReward()`, the same + * check the Engine's chest runs before it opens, so the Editor never keeps a + * second copy of it. This adapter supplies what the Engine asks of an + * authoring tool: the project's inventory identity, from the Editor's + * catalogue of the current records, and where each finding belongs. Every + * finding is an error, because the chest fails when opened; an unfinished + * chest is still saved as the author left it. One adapter serves the project + * report, the inspectors of both editors and the map save warnings, so they + * always agree. * * @package Ichiloto\Editor\Validation */ final readonly class ChestRewardValidator { - /** Stable identity for consumers that place chest findings, never derived from message prose. */ + /** The Engine's rule identity for a chest reward finding, carried as the issue code. */ public const string CODE = 'chest.reward'; /** @@ -37,177 +38,35 @@ public function __construct(private InventoryCatalog $inventory) /** * Checks a chest's data. * - * @param array $data The chest event's `data`. - * @param string $where Where the chest lives. + * @param string $mapId The map the chest is on. + * @param string $marker The chest event's marker. + * @param array $data The chest event's `data`, as currently authored. * @return Issue[] The issues found. */ - public function validate(array $data, string $where): array + public function validate(string $mapId, string $marker, array $data): array { - $declaredType = $data['lootType'] ?? null; - $lootType = ChestLoot::readLootType($declaredType); - - if (! $lootType instanceof LootType) { - return [$this->createError( - $where, - sprintf('Its loot type "%s" is not one the Engine knows, so the game reads it as an item.', self::describe($declaredType)), - 'Choose the loot type from the picker.', - )]; - } - - if (! ChestLoot::isGrantable($lootType)) { - return [$this->createError( - $where, - sprintf('It gives %s, which the Engine\'s chests cannot give, so it fails when opened.', self::describeKind($lootType)), - 'Choose an item or gold. Chests need Engine support before they can give this.', - )]; - } - - $quantity = $data['quantity'] ?? 1; - - if (! self::isWholeNumber($quantity)) { - return [$this->createError( - $where, - sprintf('Its quantity "%s" is not a whole number, so the game cannot read the chest.', self::describe($quantity)), - 'Write a whole number above zero.', - )]; - } - - $quantity = intval($quantity); - $loot = $data['loot'] ?? null; - - if ($loot instanceof BackedEnum) { - $loot = $loot->value; - } - - return $lootType === LootType::GOLD - ? $this->validateGold($loot, $quantity, $where) - : $this->validateInventory($loot, $quantity, $lootType, $where); - } - - /** - * Checks a gold chest: its loot is the amount, or its quantity when the loot is not a number. - * - * @return Issue[] - */ - private function validateGold(mixed $loot, int $quantity, string $where): array - { - $isBlank = $loot === null || (is_string($loot) && trim($loot) === ''); - - if (! $isBlank && ! is_numeric($loot)) { - return [$this->createError( - $where, - sprintf('It gives gold "%s", which is not an amount.', self::describe($loot)), - 'Write a whole number.', - )]; - } - - $amount = $isBlank ? $quantity : intval($loot); - - if ($amount >= 1) { - return []; - } - - return [$this->createError( - $where, - $amount === 0 - ? 'It gives 0 gold, so opening it gives nothing.' - : sprintf('It gives %d gold, which opening it takes from the party.', $amount), - 'Write the amount above zero in its loot, or leave the loot empty and set its quantity.', - )]; + $where = sprintf('%s event %s', $mapId, $marker); + + return array_map( + static fn(MapValidationIssue $issue): Issue => Issue::error($where, $issue->message, $issue->hint, $issue->rule), + MapCompletenessValidator::validateChestReward( + $data, + $mapId, + $this->resolveInventoryReference(...), + $marker, + ['events', $marker, 'data'], + ), + ); } /** - * Checks a chest the Engine fills from its item store. - * - * @return Issue[] + * Resolves an inventory reference for the Engine's check: the stable id, + * null when nothing answers to it, and a failure when more than one does. */ - private function validateInventory(mixed $loot, int $quantity, LootType $lootType, string $where): array + private function resolveInventoryReference(string $reference): ?string { - $noun = self::describeKind($lootType, article: false); - - if ($loot === null || (is_string($loot) && trim($loot) === '')) { - return [$this->createError( - $where, - sprintf('It has no %s chosen, so it fails when opened.', $noun), - sprintf('Choose the %s from the picker. The chest saves as it is until then.', $noun), - )]; - } - - if (! is_scalar($loot)) { - return [$this->createError( - $where, - sprintf('Its loot is %s, not a reference.', get_debug_type($loot)), - 'Choose the loot from the picker.', - )]; - } - - $reference = trim(strval($loot)); - $issues = []; - - if ($this->inventory->isAmbiguous($reference)) { - $issues[] = $this->createError( - $where, - sprintf('It gives the %s "%s", which more than one definition answers to, so it fails when opened.', $noun, $reference), - 'The Engine refuses an ambiguous reference. Give one of them a distinct name or alias.', - ); - } elseif (! $this->inventory->has($reference)) { - $issues[] = $this->createError( - $where, - sprintf('It gives the %s "%s", which does not exist, so it fails when opened.', $noun, $reference), - 'Choose it from the picker, which stores the stable id.', - ); - } - - if ($quantity < 1) { - $issues[] = $this->createError( - $where, - sprintf('Its quantity is %d, so opening it gives nothing.', $quantity), - 'Set the quantity above zero.', - ); - } - - return $issues; - } - - private function createError(string $where, string $message, string $hint): Issue - { - return Issue::error($where, $message, $hint, self::CODE); - } - - /** Tells whether a quantity reads as a whole number, the way the Engine's integer property takes it. */ - private static function isWholeNumber(mixed $value): bool - { - return is_int($value) - || (is_float($value) && floor($value) === $value) - || (is_string($value) && filter_var(trim($value), FILTER_VALIDATE_INT) !== false); - } - - private static function describe(mixed $value): string - { - if ($value instanceof BackedEnum) { - return strval($value->value); - } - - return is_scalar($value) ? strval($value) : get_debug_type($value); - } - - private static function describeKind(LootType $type, bool $article = true): string - { - $noun = match ($type) { - LootType::ITEM => 'item', - LootType::GOLD => 'gold', - LootType::EXPERIENCE => 'experience', - LootType::SKILL => 'skill', - LootType::SPELL => 'spell', - LootType::WEAPON => 'weapon', - LootType::ARMOR => 'armor', - LootType::ACCESSORY => 'accessory', - }; - - if (! $article || in_array($type, [LootType::GOLD, LootType::EXPERIENCE, LootType::ARMOR], true)) { - return $noun; - } - - return (in_array($noun[0], ['a', 'e', 'i', 'o', 'u'], true) ? 'an ' : 'a ') . $noun; + return $this->inventory->isAmbiguous($reference) + ? $this->inventory->requireDefinitionId($reference, 'validating chest reward') + : $this->inventory->definitionIdFor($reference); } } diff --git a/src/Validation/MapValidator.php b/src/Validation/MapValidator.php index abe85d8..400f8f0 100644 --- a/src/Validation/MapValidator.php +++ b/src/Validation/MapValidator.php @@ -116,8 +116,8 @@ public static function readInventoryCatalog(ProjectWorkspace $workspace): ?Inven } /** - * Finds chests whose reward the Engine cannot grant, by the shared chest - * check the inspectors and the project report also read. + * Finds chests whose reward the Engine cannot grant, by the Engine's chest + * rule the inspectors and the project report also read. * * @return string[] */ @@ -137,7 +137,7 @@ private static function findChestRewardProblems(ProjectMap $map, ?InventoryCatal $warnings = []; foreach ($chests as $marker => $definition) { - foreach ($validator->validate((array) ($definition['data'] ?? []), sprintf('%s event %s', $map->mapId, $marker)) as $issue) { + foreach ($validator->validate($map->mapId, strval($marker), (array) ($definition['data'] ?? [])) as $issue) { $warnings[] = sprintf('Chest %s: %s', $marker, $issue->message); } } diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 88ee13b..a0eeb06 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -3870,7 +3870,7 @@ protected function checkMapReferences(ProjectWorkspace $workspace, array $known) } if (ChestLoot::isChest($definition)) { - $issues = [...$issues, ...$this->checkChestLoot($data, $where)]; + $issues = [...$issues, ...$this->checkChestLoot($map->mapId, strval($marker), $data)]; } } @@ -4019,17 +4019,18 @@ protected function checkBgmVariants(ProjectMap $map, array $known): array } /** - * Checks that a chest can give what it holds, by the shared chest check - * the inspectors and the play preflight also read. + * Checks that a chest can give what it holds, by the Engine's chest rule + * the inspectors and the map save warnings also read. * + * @param string $mapId The map the chest is on. + * @param string $marker The chest event's marker. * @param array $data The chest's data. - * @param string $where Where it lives. * @return Issue[] The issues found. */ - protected function checkChestLoot(array $data, string $where): array + protected function checkChestLoot(string $mapId, string $marker, array $data): array { return $this->inventoryCatalog instanceof InventoryCatalog - ? new ChestRewardValidator($this->inventoryCatalog)->validate($data, $where) + ? new ChestRewardValidator($this->inventoryCatalog)->validate($mapId, $marker, $data) : []; } diff --git a/tests/Unit/ChestRewardValidationTest.php b/tests/Unit/ChestRewardValidationTest.php index 6a9dea5..a4d32b6 100644 --- a/tests/Unit/ChestRewardValidationTest.php +++ b/tests/Unit/ChestRewardValidationTest.php @@ -41,7 +41,7 @@ function checkChestReward(array $data): array static $catalog = null; $catalog ??= InventoryCatalog::fromWorkspace(ProjectWorkspace::fromProject(chestRewardProject())); - return new ChestRewardValidator($catalog)->validate($data, 'test-map event C'); + return new ChestRewardValidator($catalog)->validate('test-map', 'C', $data); } /** @@ -70,25 +70,41 @@ function listChestRewardIssues(string $root): array )); } -it('reports an item chest with no item chosen as an error', function (mixed $loot) { - $issues = checkChestReward(['lootType' => 'item', 'loot' => $loot, 'quantity' => 1]); +// The rules are the Engine's (MapCompletenessValidator::validateChestReward); these tests pin what the +// Editor adds: its inventory identity, where each finding belongs, and that every finding is an error. - expect($issues)->toHaveCount(1) - ->and($issues[0]->severity)->toBe(Severity::ERROR) - ->and($issues[0]->code)->toBe(ChestRewardValidator::CODE) - ->and($issues[0]->message)->toBe('It has no item chosen, so it fails when opened.'); +it('reports every chest the Engine cannot grant as an error on that chest', function (array $data) { + $issues = checkChestReward($data); + + expect($issues)->not->toBeEmpty(); + foreach ($issues as $issue) { + expect($issue->severity)->toBe(Severity::ERROR) + ->and($issue->code)->toBe(ChestRewardValidator::CODE) + ->and($issue->where)->toBe('test-map event C') + ->and($issue->message)->not->toBe(''); + } })->with([ - 'blank' => [''], - 'null' => [null], - 'whitespace' => [' '], + 'blank item' => [['lootType' => 'item', 'loot' => '', 'quantity' => 1]], + 'null item' => [['lootType' => 'item', 'loot' => null, 'quantity' => 1]], + 'whitespace item' => [['lootType' => 'item', 'loot' => ' ', 'quantity' => 1]], + // The Engine reads a chest with no data as an empty item. + 'no data' => [[]], + 'unknown item' => [['lootType' => 'item', 'loot' => 'item.absent']], + 'ambiguous item' => [['lootType' => 'item', 'loot' => 'Charm']], + 'skill' => [['lootType' => 'skill', 'loot' => 'Twin Cut']], + 'spell' => [['lootType' => 'spell', 'loot' => 'Twin Cut']], + 'experience' => [['lootType' => 'experience', 'loot' => 10]], + 'unknown type' => [['lootType' => 'treasure', 'loot' => 'Tonic']], + // LootType::tryFrom() is case-sensitive, so the Engine does not know this spelling. + 'miscased type' => [['lootType' => 'Gold', 'loot' => 10]], + 'no gold' => [['lootType' => 'gold', 'loot' => null, 'quantity' => 0]], + 'negative gold' => [['lootType' => 'gold', 'loot' => -5]], + 'gold not a number' => [['lootType' => 'gold', 'loot' => 'lots']], + 'no items' => [['lootType' => 'item', 'loot' => 'Tonic', 'quantity' => 0]], + 'quantity not a number' => [['lootType' => 'item', 'loot' => 'Tonic', 'quantity' => 'many']], ]); -it('reports a chest with no loot at all, which the Engine reads as an empty item', function () { - expect(checkChestReward([]))->toHaveCount(1) - ->and(checkChestReward([])[0]->message)->toBe('It has no item chosen, so it fails when opened.'); -}); - -it('resolves item loot by the Engine\'s id, name and alias rules', function (string $loot) { +it('resolves item loot by the Editor\'s catalogue of the Engine\'s id, name and alias rules', function (string $loot) { expect(checkChestReward(['lootType' => 'item', 'loot' => $loot]))->toBe([]); })->with([ 'stable id' => ['item.tonic'], @@ -97,50 +113,23 @@ function listChestRewardIssues(string $root): array 'any case, padded' => [' old tonic '], ]); -it('reports item loot the item store cannot resolve', function () { - $missing = checkChestReward(['lootType' => 'item', 'loot' => 'item.absent']); - $ambiguous = checkChestReward(['lootType' => 'item', 'loot' => 'Charm']); - - expect($missing)->toHaveCount(1) - ->and($missing[0]->severity)->toBe(Severity::ERROR) - ->and($missing[0]->message)->toBe('It gives the item "item.absent", which does not exist, so it fails when opened.') - ->and($ambiguous)->toHaveCount(1) - ->and($ambiguous[0]->message)->toContain('which more than one definition answers to'); -}); - -it('reads a chest with no loot type as holding an item, as the Engine does', function () { - expect(checkChestReward(['loot' => 'Tonic']))->toBe([]) - ->and(checkChestReward(['loot' => 'item.absent']))->toHaveCount(1); -}); - -it('accepts gold whose amount comes from its quantity when the loot is empty', function (array $data) { +it('accepts what the Engine grants, including gold whose amount is its quantity', function (array $data) { expect(checkChestReward($data))->toBe([]); })->with([ - 'null loot' => [['lootType' => 'gold', 'loot' => null, 'quantity' => 30]], - 'blank loot' => [['lootType' => 'gold', 'loot' => '', 'quantity' => 30]], - 'no loot' => [['lootType' => 'gold', 'quantity' => 30]], - 'amount as loot' => [['lootType' => 'gold', 'loot' => 50]], - 'amount as text' => [['lootType' => 'gold', 'loot' => '50']], -]); - -it('reports gold that gives nothing or takes money', function (array $data, string $message) { - $issues = checkChestReward($data); - - expect($issues)->toHaveCount(1) - ->and($issues[0]->severity)->toBe(Severity::ERROR) - ->and($issues[0]->message)->toBe($message); -})->with([ - 'no amount' => [['lootType' => 'gold', 'loot' => null, 'quantity' => 0], 'It gives 0 gold, so opening it gives nothing.'], - 'negative' => [['lootType' => 'gold', 'loot' => -5], 'It gives -5 gold, which opening it takes from the party.'], - 'not a number' => [['lootType' => 'gold', 'loot' => 'lots'], 'It gives gold "lots", which is not an amount.'], + 'no loot type' => [['loot' => 'Tonic']], + 'quantity as text' => [['lootType' => 'item', 'loot' => 'Tonic', 'quantity' => '3']], + 'gold, null loot' => [['lootType' => 'gold', 'loot' => null, 'quantity' => 30]], + 'gold, blank loot' => [['lootType' => 'gold', 'loot' => '', 'quantity' => 30]], + 'gold, no loot' => [['lootType' => 'gold', 'quantity' => 30]], + 'gold amount' => [['lootType' => 'gold', 'loot' => 50]], + 'gold amount as text' => [['lootType' => 'gold', 'loot' => '50']], ]); it('treats a loot type case and its string value as the same declaration', function (LootType $type, array $data) { - $asCase = checkChestReward(['lootType' => $type, ...$data]); - $asString = checkChestReward(['lootType' => $type->value, ...$data]); + $describe = static fn(array $issues): array => array_map(static fn(Issue $issue): string => $issue->code . ' ' . $issue->message, $issues); - expect(array_map(static fn(Issue $issue): string => $issue->message, $asCase)) - ->toBe(array_map(static fn(Issue $issue): string => $issue->message, $asString)); + expect($describe(checkChestReward(['lootType' => $type, ...$data]))) + ->toBe($describe(checkChestReward(['lootType' => $type->value, ...$data]))); })->with([ 'gold' => [LootType::GOLD, ['loot' => null, 'quantity' => 10]], 'item' => [LootType::ITEM, ['loot' => 'Tonic']], @@ -148,37 +137,6 @@ function listChestRewardIssues(string $root): array 'skill' => [LootType::SKILL, ['loot' => 'Twin Cut']], ]); -it('reports a loot type the Engine\'s chest cannot give', function (string $type, string $kind) { - $issues = checkChestReward(['lootType' => $type, 'loot' => 'Twin Cut', 'quantity' => 1]); - - expect($issues)->toHaveCount(1) - ->and($issues[0]->severity)->toBe(Severity::ERROR) - ->and($issues[0]->message)->toBe(sprintf('It gives %s, which the Engine\'s chests cannot give, so it fails when opened.', $kind)); -})->with([ - 'skill' => ['skill', 'a skill'], - 'spell' => ['spell', 'a spell'], - 'experience' => ['experience', 'experience'], -]); - -it('reports a loot type the Engine does not know, exactly as the Engine spells it', function (string $type) { - $issues = checkChestReward(['lootType' => $type, 'loot' => 'Tonic']); - - expect($issues)->toHaveCount(1) - ->and($issues[0]->message)->toBe(sprintf('Its loot type "%s" is not one the Engine knows, so the game reads it as an item.', $type)); -})->with([ - 'unknown' => ['treasure'], - // LootType::tryFrom() is case-sensitive: the Engine reads this as an item. - 'miscased' => ['Gold'], -]); - -it('reports a quantity that gives nothing or cannot be read', function () { - expect(checkChestReward(['lootType' => 'item', 'loot' => 'Tonic', 'quantity' => 0])[0]->message ?? null) - ->toBe('Its quantity is 0, so opening it gives nothing.') - ->and(checkChestReward(['lootType' => 'item', 'loot' => 'Tonic', 'quantity' => 'many'])[0]->message ?? null) - ->toBe('Its quantity "many" is not a whole number, so the game cannot read the chest.') - ->and(checkChestReward(['lootType' => 'item', 'loot' => 'Tonic', 'quantity' => '3']))->toBe([]); -}); - it('offers only the loot types the Engine\'s chest can give', function () { $offered = array_column(MapInspector::listLootTypeChoices(), 'value'); @@ -232,8 +190,8 @@ function listChestRewardIssues(string $root): array expect($error)->toMatchArray([ 'kind' => 'info', 'tone' => 'error', - 'value' => 'It has no item chosen, so it fails when opened. Choose the item from the picker. The chest saves as it is until then.', ]) + ->and($error['value'] ?? '')->not->toBe('') // Under the rows it is about. ->and(array_search('Reward Error', $labels, true))->toBeGreaterThan(array_search('Loot', $labels, true)) // Only kinds the Engine's chest can give are offered. @@ -243,7 +201,7 @@ function listChestRewardIssues(string $root): array $saved = $session->saveMap('test-map'); expect($saved['saved'])->toBe('test-map') - ->and($saved['warnings'])->toContain(sprintf('Chest %s: It has no item chosen, so it fails when opened.', $marker)) + ->and(array_filter($saved['warnings'], static fn(string $warning): bool => str_starts_with($warning, sprintf('Chest %s: ', $marker))))->toHaveCount(1) ->and((require $root . '/assets/Maps/test-map/test-map.data.php')['events'][$marker]['data']['loot'])->toBe(''); $loot = array_find($session->readInspector('test-map', $marker)['rows'], static fn(array $row): bool => trim($row['label']) === 'Loot'); diff --git a/tests/Unit/InventoryIdentityTest.php b/tests/Unit/InventoryIdentityTest.php index 4df133a..fb78b1a 100644 --- a/tests/Unit/InventoryIdentityTest.php +++ b/tests/Unit/InventoryIdentityTest.php @@ -373,12 +373,18 @@ function writeCompatibilityManifest(string $root, array $manifest): void $lines = implode("\n", issueLines($root)); - expect($lines)->toContain('test-map event E: It gives the item "item.absent", which does not exist, so it fails when opened.') - ->and($lines)->toContain('test-map event G: It gives gold "lots", which is not an amount.') - ->and($lines)->toContain('test-map event H: Its loot type "treasure" is not one the Engine knows, so the game reads it as an item.') - // A chest with no item chosen fails when opened, so it is reported - // too; it still saves as the author left it. - ->and($lines)->toContain('test-map event I: It has no item chosen, so it fails when opened.'); + // The Engine's chest rule reports each of them on its own chest, under its rule identity; a chest + // with no item chosen fails when opened, so it is reported too and still saves as authored. + $chests = array_values(array_unique(array_map( + static fn($issue): string => $issue->where, + array_filter(new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), + static fn($issue): bool => $issue->code === \Ichiloto\Editor\Validation\ChestRewardValidator::CODE + && $issue->severity === Severity::ERROR), + ))); + sort($chests); + + expect($lines)->not->toBe('') + ->and($chests)->toBe(['test-map event E', 'test-map event G', 'test-map event H', 'test-map event I']); }); it('keeps a key item\'s quantity through the condition line, both ways', function () { From ada318680272ba4643c65dfd8fb0327a83d524ba Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:11:42 +0200 Subject: [PATCH 307/344] feat(session): validated change sets for map writes, and player-visible map defects as errors A change set (changes.begin / changes.commit / changes.cancel) holds edits until the project they leave is valid: a changed map with any error, or a new error anywhere, refuses it, writes nothing and undoes what it wrote at once. ChangeBatch applies a list of session requests as one change set, for agents and scripts changing maps without a window. Raw glyphs shown in the graphical field (uncovered cells, NPCs without a field sprite or standing on their own glyph, a map without a kind in a project with tilesets) and reachable floor with no tile are now errors. Pre-save checks return issues with severity and include coverage; saves still write drafts and report errors with their fix. Reachability reads unsaved maps through a disposable stage written by each map's own writer, so session validation judges what saving would leave. Issues name the map they are about. --- src/Editor.php | 23 +- src/History/CommandHistory.php | 13 + src/ProjectMap.php | 40 +++ src/Session/ChangeBatch.php | 152 +++++++++++ src/Session/EditorSession.php | 251 +++++++++++++++++- src/Session/SessionHost.php | 14 + src/Storage/WorkspaceSave.php | 9 +- src/Storage/WorkspaceSaveResult.php | 3 + src/Validation/ActorReferenceValidator.php | 6 +- .../DialoguePresentationValidator.php | 2 + src/Validation/EffectValidator.php | 4 +- src/Validation/Issue.php | 28 ++ src/Validation/MapGraphicsValidator.php | 36 +-- src/Validation/MapValidator.php | 94 ++++--- src/Validation/ProjectValidator.php | 171 ++++++------ src/Validation/ReachabilityValidator.php | 52 ++-- src/Validation/UnsavedMapStage.php | 103 +++++++ tests/Pest.php | 12 + tests/Unit/ChestRewardValidationTest.php | 8 +- tests/Unit/MapGraphicsTest.php | 40 +-- tests/Unit/MapValidatorTest.php | 55 ++-- tests/Unit/ProjectValidatorTest.php | 6 +- tests/Unit/Session/ChangeSetTest.php | 211 +++++++++++++++ tests/Unit/Session/MapAtlasAuthoringTest.php | 3 +- .../Unit/Session/MapLandmarkAuthoringTest.php | 5 +- tests/Unit/UnsavedMapStageTest.php | 67 +++++ 26 files changed, 1196 insertions(+), 212 deletions(-) create mode 100644 src/Session/ChangeBatch.php create mode 100644 src/Validation/UnsavedMapStage.php create mode 100644 tests/Unit/Session/ChangeSetTest.php create mode 100644 tests/Unit/UnsavedMapStageTest.php diff --git a/src/Editor.php b/src/Editor.php index 36bcd3e..f4f7509 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -103,6 +103,7 @@ use Ichiloto\Editor\UI\TextFieldKeyResult; use Ichiloto\Editor\Storage\WorkspaceSave; use Ichiloto\Editor\Validation\MapValidator; +use Ichiloto\Editor\Validation\Severity; use Ichiloto\Engine\Entities\Enumerations\ItemScopeNumber; use Ichiloto\Engine\Entities\Enumerations\ItemScopeSide; use Ichiloto\Engine\Entities\Enumerations\ItemScopeStatus; @@ -6451,12 +6452,20 @@ private function saveAllAssets(): void $detailLines = [...$detailLines, 'Backups failed (the save still ran):', ...array_map(static fn(string $path): string => ' ' . $path, $result->backupFailures), '']; } + if ($result->errors !== []) { + $detailLines = [...$detailLines, 'Errors the player would see (saved as drafts; validation fails until fixed):', + ...array_map(static fn(string $error): string => ' ' . $error, $result->errors), '']; + } + if ($result->warnings !== []) { $detailLines = [...$detailLines, 'Validation warnings:', ...array_map(static fn(string $warning): string => ' ' . $warning, $result->warnings)]; } if ($result->failures !== []) { $this->setStatus($result->summary . sprintf(' %d failed (Ctrl+E for details).', count($result->failures)), StatusLevel::ERROR, $detailLines); + } elseif ($result->errors !== []) { + $this->setStatus($result->summary . sprintf(' %d error%s the player would see (Ctrl+E for details).', count($result->errors), + count($result->errors) === 1 ? '' : 's'), StatusLevel::ERROR, $detailLines); } elseif ($result->skippedRenames !== [] || $result->skippedRelocations !== [] || $result->warnings !== [] || $result->backupFailures !== []) { $this->setStatus($result->summary . ' See Ctrl+E for skipped saves and warnings.', StatusLevel::WARN, $detailLines); } else { @@ -6670,7 +6679,9 @@ private function performSaveSelectedMap(?SaveRelocationPlan $reviewed = null): v return; } - $warnings = MapValidator::validate($selectedMap, $this->getMapsById(), MapValidator::readInventoryCatalog($this->workspace)); + $issues = MapValidator::validate($selectedMap, $this->getMapsById(), MapValidator::readInventoryCatalog($this->workspace)); + $errors = MapValidator::describeIssues(MapValidator::selectSeverity($issues, Severity::ERROR)); + $warnings = MapValidator::describeIssues(MapValidator::selectSeverity($issues, Severity::WARNING)); $step = null; try { @@ -6711,7 +6722,15 @@ private function performSaveSelectedMap(?SaveRelocationPlan $reviewed = null): v $moved = $step === null ? '' : sprintf(' Saves on %d cells it makes solid now move to open floor (save content version %d).', array_sum(array_map(static fn(array $relocation): int => count($relocation['cells']), $relocation->relocations)), $relocation->contentVersion); - if ($warnings === []) { + if ($errors !== []) { + // Saved as a draft: the player would meet these, so validation fails until they are fixed. + $this->setStatus( + sprintf('Saved %s as a draft with %d error%s the player would see (Ctrl+E for details).%s', $savedMapId, count($errors), + count($errors) === 1 ? '' : 's', $moved), + StatusLevel::ERROR, + [...$errors, ...($warnings === [] ? [] : ['', 'Warnings:', ...$warnings])], + ); + } elseif ($warnings === []) { $this->setStatus(sprintf('Saved %s.%s', $savedMapId, $moved), StatusLevel::SUCCESS); } else { $this->setStatus( diff --git a/src/History/CommandHistory.php b/src/History/CommandHistory.php index 836903a..94f7c5b 100644 --- a/src/History/CommandHistory.php +++ b/src/History/CommandHistory.php @@ -23,6 +23,9 @@ final class CommandHistory */ private array $redoStack = []; + /** How many of the oldest steps were dropped to keep within the capacity. */ + private int $dropped = 0; + /** * @param int $capacity The maximum number of retained undo entries. */ @@ -43,6 +46,7 @@ public function record(Command $command): void if (count($this->undoStack) > max(1, $this->capacity)) { array_shift($this->undoStack); + $this->dropped++; } } @@ -116,6 +120,15 @@ public function count(): int return count($this->undoStack); } + /** + * Counts the oldest steps dropped so far to keep within the capacity, so a + * caller undoing back to a point can tell whether every step since is still here. + */ + public function countDropped(): int + { + return $this->dropped; + } + /** * Empties both stacks (workspace reloads, map identity changes). * diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 3a14301..b547eb2 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -2576,6 +2576,46 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) return $target['mapId']; } + /** + * Writes the sources saving would write into the map's folder under + * another maps root, replacing that folder's own files there, so a + * reader of map folders (the Engine's reachability analysis) sees the + * map as saving would leave it. This map's folder and its saved + * baseline are untouched; it stays as dirty as it was. + * + * @param string $mapsRoot The other maps root, holding a copy of the project's maps. + * @return string The folder written. + */ + public function exportSources(string $mapsRoot): string + { + $this->assertEditable(); + $this->assertGridSourcesCanonical(); + $this->validateLayerContracts(); + $target = $this->resolveSaveTarget(); + $directory = rtrim($mapsRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $target['mapId']); + $baseName = basename($directory); + $sources = [ + $directory . DIRECTORY_SEPARATOR . basename($target['dataPath']) => $this->dataDocument === null + ? (string) $this->unparsedDataSource : $this->proposedDataSource(), + ...$this->layers->getSources($directory, $baseName), + ]; + $transaction = new FileSetTransaction($directory, new FilesystemFileSetOperations()); + foreach ($sources as $path => $source) { + $transaction->write($path, $source); + } + // The folder's other member files are the copy's old ones: a layer this map no longer has. + foreach ([...glob($directory . DIRECTORY_SEPARATOR . $baseName . '.*.php') ?: [], + ...glob($directory . DIRECTORY_SEPARATOR . 'layers' . DIRECTORY_SEPARATOR . '*.php') ?: [], + ...glob($directory . DIRECTORY_SEPARATOR . MapGraphics::DIRECTORY . DIRECTORY_SEPARATOR . '*.php') ?: []] as $stale) { + if (! isset($sources[$stale])) { + $transaction->remove($stale); + } + } + $transaction->commit(); + + return $directory; + } + /** * Adopts what a save (or a proven no-op) established as the new baseline. */ diff --git a/src/Session/ChangeBatch.php b/src/Session/ChangeBatch.php new file mode 100644 index 0000000..7d6f833 --- /dev/null +++ b/src/Session/ChangeBatch.php @@ -0,0 +1,152 @@ +> + * @throws InvalidRequest When the file is not a batch. + */ + public static function readFile(string $path): array + { + $json = is_file($path) ? file_get_contents($path) : false; + if ($json === false) { + throw new InvalidRequest(sprintf('%s cannot be read.', $path)); + } + try { + $batch = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + throw new InvalidRequest(sprintf('%s is not JSON: %s', $path, $error->getMessage()), previous: $error); + } + $changes = is_array($batch) && array_key_exists('changes', $batch) ? $batch['changes'] : $batch; + if (! is_array($changes) || ! array_is_list($changes)) { + throw new InvalidRequest(sprintf('%s must hold a list of changes, or {"changes": [...]}.', $path)); + } + + return $changes; + } + + /** + * Applies changes to a project and commits them when the result is valid. + * + * @param list $changes The session requests, in order. + * @param bool $relocateSaves Whether saves standing where a changed map is now solid may move to open floor. + * @param bool $checkOnly Whether to only report what committing would find, writing nothing. + * @return array The commit's outcome ({@see EditorSession::commitChanges()}), or + * `{status: 'failed', step, method, message}` when a change was refused and nothing was written. + */ + public static function apply(string $projectRoot, array $changes, bool $relocateSaves = false, bool $checkOnly = false): array + { + $memory = fopen('php://memory', 'r+'); + $batch = new self(new SessionHost($memory, $memory, STDERR)); + + try { + $batch->request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $projectRoot]); + $batch->request('changes.begin'); + foreach ($changes as $index => $change) { + $refusal = $batch->applyChange($change); + if ($refusal !== null) { + $batch->request('changes.cancel'); + + return ['status' => 'failed', 'step' => $index + 1, ...$refusal]; + } + } + + return $batch->request('changes.commit', ['relocateSaves' => $relocateSaves, 'check' => $checkOnly]); + } catch (SessionRefusal $refusal) { + return ['status' => 'failed', 'step' => null, 'method' => null, 'message' => $refusal->getMessage()]; + } finally { + $batch->host->close(); + } + } + + /** + * Applies one change. + * + * @return array{method: ?string, message: string}|null Why it was refused; null once applied. + */ + private function applyChange(mixed $change): ?array + { + $method = is_array($change) && is_string($change['method'] ?? null) ? $change['method'] : null; + $params = is_array($change) ? ($change['params'] ?? []) : null; + if ($method === null || ! is_array($params)) { + return ['method' => $method, 'message' => 'A change is {"method": "...", "params": {...}}.']; + } + if (in_array($method, self::RESERVED, true)) { + return ['method' => $method, 'message' => sprintf('%s is made by the batch itself, never as one of its changes.', $method)]; + } + if (is_string($params['map'] ?? null) && ! array_key_exists('revision', $params)) { + $map = array_find($this->request('maps.list'), static fn(array $map): bool => $map['id'] === $params['map']); + if ($map !== null) { + $params['revision'] = $map['revision']; + } + } + + try { + $result = $this->request($method, $params); + if (($result['status'] ?? null) === 'question') { + $answer = is_array($change) ? ($change['answer'] ?? null) : null; + if (! is_string($answer)) { + return ['method' => $method, 'message' => sprintf('It asks "%s"; give the change an "answer".', + is_string($result['question'] ?? null) ? $result['question'] : 'a question')]; + } + $result = $this->request($method, [...$params, 'answer' => $answer, + ...(is_string($result['confirm'] ?? null) ? ['confirm' => $result['confirm']] : [])]); + if (($result['status'] ?? null) === 'question') { + return ['method' => $method, 'message' => sprintf('"%s" did not answer its question.', $answer)]; + } + } + } catch (SessionRefusal $refusal) { + return ['method' => $method, 'message' => $refusal->getMessage()]; + } + + return null; + } + + /** + * Makes one request of the host, as a client would. + * + * @param array $params + * @return array + * @throws SessionRefusal When the host refuses it or cannot make it. + */ + private function request(string $method, array $params = []): array + { + $response = $this->host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => (object) $params], JSON_THROW_ON_ERROR)); + if (isset($response['error'])) { + throw new SessionRefusal(sprintf('%s: %s', $response['error']['kind'], $response['error']['message'])); + } + + return $response['result']; + } +} diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index e9e74ab..7a194be 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -92,7 +92,10 @@ use Ichiloto\Editor\Storage\FileSetTransactionFailure; use Ichiloto\Editor\Storage\WorkspaceSave; use Ichiloto\Editor\Validation\MapGraphicsValidator; +use Ichiloto\Editor\Validation\Issue; use Ichiloto\Editor\Validation\MapValidator; +use Ichiloto\Editor\Validation\ProjectValidator; +use Ichiloto\Editor\Validation\Severity; use Ichiloto\Engine\IO\Console\TerminalPresentationComposer; use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; use Ichiloto\Engine\Rendering\Sprites\CharacterSheet; @@ -217,6 +220,16 @@ final class EditorSession * @var array, base: int, last: int}> */ private array $mapRevisions = []; + + /** + * The open change set ({@see beginChanges()}): what validation reported + * when it began, each map's revision then, and how many undo steps came + * before it. Null when none is open. + * + * @var array{baseline: list, revisions: array, steps: int, dropped: int}|null + */ + private ?array $changeSet = null; + private function __construct( ProjectWorkspace $workspace, private readonly CommandHistory $history, @@ -421,6 +434,7 @@ public function duplicateMap(string $mapId, int $revision): array */ public function moveMap(string $mapId, int $revision, ?string $destination = null, bool $confirmed = false): array { + $this->refuseDuringChangeSet('Moving a map clears the undo history'); $map = $this->requireCurrentMap($mapId, $revision); if ($map->getGridSourceIssue() !== null) { throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); @@ -533,6 +547,7 @@ private function editMapLines(string $mapId, int $revision, ?string $answer, ?st */ public function deleteMap(string $mapId, bool $confirmed = false): array { + $this->refuseDuringChangeSet('Deleting a map clears the undo history'); $this->requireMap($mapId); $references = new MapReferences($this->workspace)->describe($mapId); if ($references !== [] && ! $confirmed) { @@ -3925,6 +3940,7 @@ public function moveDatabaseRecord(string $category, int $index, string $directi */ public function saveDatabase(string $category): array { + $this->refuseDuringChangeSet('Saving a database'); $database = $this->requireCategory($category); if (! $database->isEditable()) { throw new SessionRefusal(sprintf('Read-only: %s.', $database->getReadOnlyReason() ?? 'this category cannot be written')); @@ -4552,6 +4568,10 @@ private static function requireNpcFrame(mixed $frame): array */ public function undo(): array { + if ($this->changeSet !== null && $this->history->count() <= $this->changeSet['steps']) { + throw new SessionRefusal('Undo would reach back before the change set began; cancel the change set instead.'); + } + return $this->traverseHistory(fn(): ?Command => $this->history->undo()); } @@ -4566,13 +4586,16 @@ public function redo(): array } /** - * Saves one map through its transaction, after the warnings validation - * has for it, which never block a save. When the save would take cells - * existing saves may stand on, nothing is written until the author has - * seen where those saves move: the first call asks, and `write` with the - * question's `confirm` saves the map and the relocation step together. + * Saves one map through its transaction, after the checks validation + * has for it, which never block this save: a map with errors (defects + * the player would meet) saves as a draft, and validation fails until + * they are fixed. {@see commitChanges()} is the write that refuses them. + * When the save would take cells existing saves may stand on, nothing is + * written until the author has seen where those saves move: the first + * call asks, and `write` with the question's `confirm` saves the map and + * the relocation step together. * - * @return array{saved: string, warnings: list, backupFailures: list, relocated: list} + * @return array{saved: string, warnings: list, errors: list>, backupFailures: list, relocated: list} * |array{status: 'question', map: string, question: string, answers: list, * relocations: list, to: array{int, int}}>, moves: list, * contentVersion: int, confirm: string} @@ -4582,6 +4605,7 @@ public function redo(): array */ public function saveMap(string $mapId, ?string $answer = null, ?string $confirm = null): array { + $this->refuseDuringChangeSet('Saving a map'); $map = $this->requireMap($mapId); try { $relocation = SaveRelocationPlanner::plan($map, $this->workspace->projectRoot); @@ -4609,7 +4633,7 @@ public function saveMap(string $mapId, ?string $answer = null, ?string $confirm throw new SessionRefusal('The map or the save manifest changed since the save relocations were shown, so nothing was written. Review them again.'); } } - $warnings = MapValidator::validate($map, $this->getMapsById(), MapValidator::readInventoryCatalog($this->workspace)); + $issues = MapValidator::validate($map, $this->getMapsById(), MapValidator::readInventoryCatalog($this->workspace)); $failures = []; $step = $relocation?->apply(); @@ -4628,8 +4652,9 @@ public function saveMap(string $mapId, ?string $answer = null, ?string $confirm $this->history->record($step); } - return ['saved' => $saved, 'warnings' => array_values($warnings), 'backupFailures' => array_values($failures), - 'relocated' => $relocation?->describeMoves() ?? []]; + return ['saved' => $saved, 'warnings' => MapValidator::describeIssues(MapValidator::selectSeverity($issues, Severity::WARNING)), + 'errors' => array_map(static fn(Issue $issue): array => $issue->describe(), MapValidator::selectSeverity($issues, Severity::ERROR)), + 'backupFailures' => array_values($failures), 'relocated' => $relocation?->describeMoves() ?? []]; } /** @@ -5000,6 +5025,10 @@ private function listBattleUnsavedChanges(): array /** Ends what the session started that would outlive it: a running playtest or battle test. */ public function close(): void { + // Changes never committed leave the project as it was. + if ($this->changeSet !== null) { + $this->cancelChanges(); + } foreach (array_keys($this->authoringCanvases) as $mapId) { $this->closeAuthoringCanvas((string) $mapId); } @@ -5078,6 +5107,7 @@ public function listUnsavedChanges(): array */ public function saveAll(): array { + $this->refuseDuringChangeSet('Saving everything'); $result = WorkspaceSave::saveAll($this->workspace, $this->backups); return [ @@ -5086,11 +5116,214 @@ public function saveAll(): array 'skippedRelocations' => $result->skippedRelocations, 'failures' => $result->failures, 'warnings' => $result->warnings, + 'errors' => $result->errors, 'backupFailures' => $result->backupFailures, 'unsaved' => $this->workspace->listUnsavedChanges(), ]; } + /** + * Validates the project as it stands in this session, unsaved edits + * included, by the same checks as `ichiloto validate`. + * + * @param list|null $mapIds Only the issues about these maps; null for every issue. + * @return array{issues: list>, errors: int, warnings: int} + */ + public function validateProject(?array $mapIds = null): array + { + $issues = new ProjectValidator()->validate($this->workspace); + if ($mapIds !== null) { + $issues = array_values(array_filter($issues, static fn(Issue $issue): bool => in_array($issue->map, $mapIds, true))); + } + + return [ + 'issues' => array_map(static fn(Issue $issue): array => $issue->describe(), $issues), + 'errors' => count(MapValidator::selectSeverity($issues, Severity::ERROR)), + 'warnings' => count(MapValidator::selectSeverity($issues, Severity::WARNING)), + ]; + } + + /** + * Opens a change set: edits made from here on are written only by + * {@see commitChanges()}, and only when the project they leave is valid. + * This is the write path for anyone changing maps without an author + * reviewing each save (another agent, a script): what the player would + * meet is refused before it is written. An author's own saves stay + * drafts ({@see saveMap()}). + * + * Edits that write files at once (creating or duplicating a map, + * inserting or removing rows) may run inside it; a refused or cancelled + * change set undoes them. Saving, moving or deleting a map is refused + * while it is open. + * + * @return array{status: 'open', errors: int, warnings: int} + * @throws SessionRefusal When a change set is already open or edits are unsaved. + */ + public function beginChanges(): array + { + if ($this->changeSet !== null) { + throw new SessionRefusal('A change set is already open; commit or cancel it first.'); + } + if ($this->workspace->hasUnsavedChanges()) { + throw new SessionRefusal('Save or undo pending edits before a change set begins, so it holds only its own changes.'); + } + $baseline = new ProjectValidator()->validate($this->workspace); + $this->changeSet = [ + 'baseline' => $baseline, + 'revisions' => array_map($this->getMapRevision(...), $this->getMapsById()), + 'steps' => $this->history->count(), + 'dropped' => $this->history->countDropped(), + ]; + + return ['status' => 'open', 'errors' => count(MapValidator::selectSeverity($baseline, Severity::ERROR)), + 'warnings' => count(MapValidator::selectSeverity($baseline, Severity::WARNING))]; + } + + /** + * Writes the open change set when the project it leaves is valid, and + * otherwise writes nothing and undoes it. + * + * Validation runs over the project as saving would leave it. The change + * set is refused when a map it changed has any error (a defect the player + * would meet), whether the change set made it or found it there, or when + * it leaves a new error anywhere else (a door on another map now leading + * nowhere). Warnings never refuse it. Its maps are saved through their + * own transactions, then any other unsaved change it made. + * + * @param bool $relocateSaves Whether saves standing where a changed map is now solid may move to open floor; + * without it such a change set is refused, naming where they would move. + * @param bool $checkOnly Whether to only report what committing would find, then undo the change set. + * @return array{status: 'saved'|'refused'|'checked', maps: list, saved: list, issues: list>, + * blocking: int, relocated: list, reason?: ?string, unrestored?: int} + * @throws SessionRefusal When no change set is open, or a save fails part way (the message names what was written). + */ + public function commitChanges(bool $relocateSaves = false, bool $checkOnly = false): array + { + $changes = $this->changeSet ?? throw new SessionRefusal('No change set is open; begin one first.'); + $changed = $this->listChangedMaps($changes); + $issues = new ProjectValidator()->validate($this->workspace); + $before = array_fill_keys(array_map(self::identifyIssue(...), $changes['baseline']), true); + $blocking = array_values(array_filter($issues, static fn(Issue $issue): bool => $issue->severity === Severity::ERROR + && (in_array($issue->map, $changed, true) || ! isset($before[self::identifyIssue($issue)])))); + $reported = array_values(array_filter($issues, static fn(Issue $issue): bool => in_array($issue->map, $changed, true) + || in_array($issue, $blocking, true))); + $outcome = ['maps' => $changed, 'saved' => [], 'issues' => array_map(static fn(Issue $issue): array => $issue->describe(), $reported), + 'blocking' => count($blocking), 'relocated' => []]; + + $relocations = []; + foreach ($changed as $mapId) { + $map = $this->getMapsById()[$mapId] ?? null; + $plan = $map instanceof ProjectMap && $map->isDirty() ? SaveRelocationPlanner::plan($map, $this->workspace->projectRoot) : null; + if ($plan !== null) { + $relocations = [...$relocations, ...$plan->describeMoves()]; + } + } + if ($blocking !== [] || $checkOnly || ($relocations !== [] && ! $relocateSaves)) { + $unrestored = $this->rollBackChanges($changes); + + return ['status' => $blocking === [] && $checkOnly ? 'checked' : 'refused', ...$outcome, 'relocated' => $relocations, + 'reason' => match (true) { + $blocking !== [] => sprintf('%d error%s the player would meet.', count($blocking), count($blocking) === 1 ? '' : 's'), + $checkOnly => null, + default => 'Saves stand where a changed map is now solid; allow them to move to open floor, or change the map.', + }, ...($unrestored > 0 ? ['unrestored' => $unrestored] : [])]; + } + + $this->changeSet = null; + foreach ($changed as $mapId) { + if (! ($this->getMapsById()[$mapId] ?? null)?->isDirty()) { + continue; + } + try { + $result = $this->saveMap($mapId); + if (($result['status'] ?? null) === 'question') { + $result = $this->saveMap($mapId, 'write', $result['confirm']); + $outcome['relocated'] = [...$outcome['relocated'], ...($result['relocated'] ?? [])]; + } + } catch (SessionRefusal $refusal) { + throw new SessionRefusal(sprintf('%s was not saved: %s%s', $mapId, $refusal->getMessage(), $outcome['saved'] === [] + ? ' Nothing was written.' : ' Already saved: ' . implode(', ', $outcome['saved']) . '.'), previous: $refusal); + } + $outcome['saved'][] = $mapId; + } + if ($this->workspace->hasUnsavedChanges()) { + $rest = $this->saveAll(); + if ($rest['failures'] !== []) { + throw new SessionRefusal('The maps were saved, but not everything else: ' . implode('; ', $rest['failures'])); + } + } + + return ['status' => 'saved', ...$outcome]; + } + + /** + * Cancels the open change set, undoing every edit it made, those that + * wrote files at once included. + * + * @return array{status: 'cancelled', unrestored?: int} + * @throws SessionRefusal When no change set is open. + */ + public function cancelChanges(): array + { + $unrestored = $this->rollBackChanges($this->changeSet ?? throw new SessionRefusal('No change set is open.')); + + return ['status' => 'cancelled', ...($unrestored > 0 ? ['unrestored' => $unrestored] : [])]; + } + + /** + * The maps a change set changed: every map whose revision moved since it + * began, and every map it made. + * + * @param array{revisions: array} $changes + * @return list + */ + private function listChangedMaps(array $changes): array + { + $changed = []; + foreach ($this->getMapsById() as $mapId => $map) { + if (($changes['revisions'][$mapId] ?? null) !== $this->getMapRevision($map)) { + $changed[] = (string) $mapId; + } + } + + return $changed; + } + + /** + * Undoes every step of a change set and removes the maps it made, then + * closes it. + * + * @param array{revisions: array, steps: int, dropped: int} $changes + * @return int How many of its earliest steps could no longer be undone, dropped from a full undo history. + */ + private function rollBackChanges(array $changes): int + { + $unrestored = $this->history->countDropped() - $changes['dropped']; + $this->changeSet = null; + while ($this->history->count() > max(0, $changes['steps'] - $unrestored)) { + $this->undo(); + } + foreach (array_diff(array_keys($this->getMapsById()), array_keys($changes['revisions'])) as $made) { + $this->deleteMap((string) $made, confirmed: true); + } + + return $unrestored; + } + + /** An issue's identity, to tell an issue found before a change set from one it made. */ + private static function identifyIssue(Issue $issue): string + { + return implode("\0", [$issue->severity->value, $issue->where, $issue->message, $issue->code ?? '', $issue->map ?? '']); + } + + /** Refuses what a change set cannot hold or undo while one is open. */ + private function refuseDuringChangeSet(string $doing): void + { + if ($this->changeSet !== null) { + throw new SessionRefusal(sprintf('%s is not part of a change set; commit or cancel it first.', $doing)); + } + } + /** * Runs one history step and names the maps and database categories it * changed, so an interface reloads what it shows of them. diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 579c7dc..e45b85d 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -50,7 +50,14 @@ public function run(): void $this->write($this->handle($line)); } // The editor has gone: nothing it started may outlive it. + $this->close(); + } + + /** Closes the session: nothing it started outlives it, and a change set it left open is undone. */ + public function close(): void + { $this->session?->close(); + $this->session = null; } /** @@ -585,6 +592,13 @@ private function dispatch(string $method, array $params): array 'history.redo' => $session->redo(), 'project.dirty' => ['dirty' => $session->hasUnsavedChanges(), 'unsaved' => $session->listUnsavedChanges()], 'project.saveAll' => $session->saveAll(), + 'project.validate' => $session->validateProject(array_key_exists('maps', $params) ? self::requireLines($params, 'maps') : null), + 'changes.begin' => $session->beginChanges(), + 'changes.commit' => $session->commitChanges( + is_bool($params['relocateSaves'] ?? false) ? ($params['relocateSaves'] ?? false) : throw new InvalidRequest('"relocateSaves" must be a boolean.'), + is_bool($params['check'] ?? false) ? ($params['check'] ?? false) : throw new InvalidRequest('"check" must be a boolean.'), + ), + 'changes.cancel' => $session->cancelChanges(), 'playtest.start' => self::startPlaytest($session, $params), 'playtest.options' => $session->describePlaytestOptions(), 'playtest.status' => $session->describePlaytest(), diff --git a/src/Storage/WorkspaceSave.php b/src/Storage/WorkspaceSave.php index 74f56ee..cba73fe 100644 --- a/src/Storage/WorkspaceSave.php +++ b/src/Storage/WorkspaceSave.php @@ -10,6 +10,7 @@ use Ichiloto\Editor\Maps\SaveRelocationPlanner; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Validation\MapValidator; +use Ichiloto\Editor\Validation\Severity; use Throwable; /** @@ -35,6 +36,7 @@ public static function saveAll(ProjectWorkspace $workspace, BackupWriter $backup $skippedRelocations = []; $failures = []; $warnings = []; + $errors = []; $mapsById = []; foreach ($workspace->maps as $map) { @@ -65,9 +67,13 @@ public static function saveAll(ProjectWorkspace $workspace, BackupWriter $backup continue; } - foreach (MapValidator::validate($map, $mapsById, $inventory) as $warning) { + $issues = MapValidator::validate($map, $mapsById, $inventory); + foreach (MapValidator::describeIssues(MapValidator::selectSeverity($issues, Severity::WARNING)) as $warning) { $warnings[] = sprintf('%s: %s', $map->mapId, $warning); } + foreach (MapValidator::describeIssues(MapValidator::selectSeverity($issues, Severity::ERROR)) as $error) { + $errors[] = sprintf('%s: %s', $map->mapId, $error); + } try { $map->save($backup); @@ -127,6 +133,7 @@ public static function saveAll(ProjectWorkspace $workspace, BackupWriter $backup failures: $failures, warnings: $warnings, backupFailures: array_values(array_unique($backupFailures)), + errors: $errors, ); } } diff --git a/src/Storage/WorkspaceSaveResult.php b/src/Storage/WorkspaceSaveResult.php index 91a6784..2236c41 100644 --- a/src/Storage/WorkspaceSaveResult.php +++ b/src/Storage/WorkspaceSaveResult.php @@ -26,6 +26,8 @@ final class WorkspaceSaveResult * @param list $failures Each document that could not be written, with why. * @param list $warnings What validation noticed in the maps written, which never blocks a save. * @param list $backupFailures Files whose backup failed; their save still ran. + * @param list $errors Defects the player would meet in the maps written, each with its fix: the maps + * saved as drafts, and validation fails until they are fixed. */ public function __construct( public readonly int $savedMaps, @@ -36,6 +38,7 @@ public function __construct( public readonly array $failures, public readonly array $warnings, public readonly array $backupFailures, + public readonly array $errors = [], ) { } } diff --git a/src/Validation/ActorReferenceValidator.php b/src/Validation/ActorReferenceValidator.php index 0766f3a..f417746 100644 --- a/src/Validation/ActorReferenceValidator.php +++ b/src/Validation/ActorReferenceValidator.php @@ -35,8 +35,10 @@ public function validate(ProjectWorkspace $workspace): array $payloads[$path] = $record->toArray(); } } + $mapIds = []; foreach ($workspace->maps as $map) { $payloads[$map->dataPath] = $map->getEditableData(); + $mapIds[$map->dataPath] = $map->mapId; } // Summons as they stand, unsaved edits included; their wielders name actors. $root = rtrim($workspace->projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; @@ -66,10 +68,10 @@ public function validate(ProjectWorkspace $workspace): array $known = array_map(static fn(string $id): string => strtolower(trim($id)), $ids); } if (! is_string($value) || ! in_array($value, $known, true)) { - $issues[] = Issue::error($where . ':' . implode('.', $reference['path']), + $issues[] = new Issue(Severity::ERROR, $where . ':' . implode('.', $reference['path']), 'Unresolved actor reference ' . var_export($reference['reference'], true) . '; use an explicit stable actor id.', 'Review and confirm the project actor identity migration, or select an existing actor.', - self::UNRESOLVED_ACTOR_REFERENCE); + self::UNRESOLVED_ACTOR_REFERENCE, $mapIds[$where] ?? null); } } } diff --git a/src/Validation/DialoguePresentationValidator.php b/src/Validation/DialoguePresentationValidator.php index 75acaf3..1e5fdf7 100644 --- a/src/Validation/DialoguePresentationValidator.php +++ b/src/Validation/DialoguePresentationValidator.php @@ -112,6 +112,7 @@ private function checkLines(ProjectWorkspace $workspace, DialoguePresentationCat $this->visitTextCommands((array) $record->toArray(), $relative($record->sourcePath ?? 'assets/Events/' . $record->recordId . '.php'), $check); } foreach ($workspace->maps as $map) { + $found = count($issues); $data = $map->getEditableData(); $where = $relative($map->dataPath); foreach (array_values((array) ($data['npcs'] ?? [])) as $index => $npc) { @@ -133,6 +134,7 @@ private function checkLines(ProjectWorkspace $workspace, DialoguePresentationCat } $this->visitTextCommands($event, $eventWhere, $check); } + array_splice($issues, $found, count($issues) - $found, Issue::attributeToMap(array_slice($issues, $found), $map->mapId)); } foreach ($workspace->cutscenes?->assets(CutsceneType::CINEMATIC) ?? [] as $cinematic) { if (! $cinematic->isDeleted()) { diff --git a/src/Validation/EffectValidator.php b/src/Validation/EffectValidator.php index 62136bc..fc9acd7 100644 --- a/src/Validation/EffectValidator.php +++ b/src/Validation/EffectValidator.php @@ -159,8 +159,8 @@ public static function findUses(ProjectWorkspace $workspace, array &$issues = [] $use($declaration['effect'], false, sprintf('%s field effect %s', $map->mapId, $declaration['id'])); } } catch (Throwable $failure) { - $issues[] = Issue::error($map->mapId, 'Its fieldEffects cannot be read: ' . $failure->getMessage(), - 'Until this is fixed, the map shows none of its field effects; gameplay is unchanged.'); + $issues[] = new Issue(Severity::ERROR, $map->mapId, 'Its fieldEffects cannot be read: ' . $failure->getMessage(), + 'Until this is fixed, the map shows none of its field effects; gameplay is unchanged.', map: $map->mapId); } } diff --git a/src/Validation/Issue.php b/src/Validation/Issue.php index 3ef0b41..02d6ef8 100644 --- a/src/Validation/Issue.php +++ b/src/Validation/Issue.php @@ -15,6 +15,8 @@ * @param string $message What is wrong. * @param string $hint What to do about it. * @param string|null $code Optional stable identity for consumers; never derived from message prose. + * @param string|null $map The id of the map it is about, when it is about one, so a check of some maps + * can select its issues without reading $where. */ public function __construct( public Severity $severity, @@ -22,10 +24,36 @@ public function __construct( public string $message, public string $hint = '', public ?string $code = null, + public ?string $map = null, ) { } + /** + * Attributes issues to the map they were found on, keeping a map an issue + * already names. + * + * @param list $issues The issues found on the map. + * @param string $mapId The map's id. + * @return list The same issues, each naming its map. + */ + public static function attributeToMap(array $issues, string $mapId): array + { + return array_map(static fn(self $issue): self => $issue->map !== null ? $issue + : new self($issue->severity, $issue->where, $issue->message, $issue->hint, $issue->code, $mapId), array_values($issues)); + } + + /** + * Describes the issue for a client of the editor session. + * + * @return array{severity: string, where: string, message: string, hint: string, code: ?string, map: ?string} + */ + public function describe(): array + { + return ['severity' => $this->severity->value, 'where' => $this->where, 'message' => $this->message, + 'hint' => $this->hint, 'code' => $this->code, 'map' => $this->map]; + } + /** * Builds an error. * diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php index 184e681..11405bf 100644 --- a/src/Validation/MapGraphicsValidator.php +++ b/src/Validation/MapGraphicsValidator.php @@ -28,8 +28,8 @@ /** * Checks a map's graphics as the Engine reads them: its kind (the tileset it * names) and the tile layers in `graphics/`, unsaved resizes included. In a - * project with tilesets every map should have a kind, so one without is - * warned about. + * project with tilesets every map needs a kind: one without shows its raw + * terminal glyphs in the graphical field, an error. * * Unusable graphics never stop a map loading; the game shows its terminal * glyphs instead. Every file is checked, so one report names every problem @@ -60,7 +60,7 @@ public static function validate(ProjectMap $map): array $tilesetId = $map->getMapDataField(['tileset']); if ($tilesetId === null && $sources === [] && ! is_dir($map->directory . '/' . MapGraphics::DIRECTORY)) { - return glob($map->getAssetRoot() . '/' . Tileset::DIRECTORY . '/*.php') === [] ? [] : [Issue::warning( + return glob($map->getAssetRoot() . '/' . Tileset::DIRECTORY . '/*.php') === [] ? [] : [Issue::error( $map->mapId, 'It has no kind, so it has no tiles or pieces.', sprintf('Set its Kind in the Inspector, one of the tilesets in assets/%s. %s', Tileset::DIRECTORY, self::GLYPH_FALLBACK), @@ -84,15 +84,19 @@ public static function validate(ProjectMap $map): array } /** - * Warns for what a map with a kind still shows as terminal glyphs in the - * graphical field, by the Engine's glyph fallback rule: cells no tile - * covers, NPCs without a field sprite, copies of an NPC's glyph in the - * map showing under its sprite, tiles a tileset piece draws whose glyph - * is no longer there, and cells showing the tileset's missing-art - * placeholder. These are art still to do, not faults: - * the game plays the same, so project validation reports them and the - * pre-save checks do not. Graphics the Engine refuses are - * {@see validate()}'s to report; only the readable tile layers count here. + * Reports what a map with a kind still shows as terminal glyphs in the + * graphical field, by the Engine's glyph fallback rule, and what its art + * still lacks. + * + * A raw gameplay glyph the player sees in the graphical field is an + * error: a cell no tile covers, an NPC without a field sprite, and a copy + * of an NPC's glyph showing under its sprite. The map still saves as a + * draft; validation fails until it is presented. Art still to finish is a + * warning: tiles a tileset piece draws whose glyph is no longer there, + * cells showing the tileset's missing-art placeholder the author chose, + * and ground that can show the background through see-through edges. + * Graphics the Engine refuses are {@see validate()}'s to report; only the + * readable tile layers count here. * * @return list */ @@ -115,7 +119,7 @@ public static function validateCoverage(ProjectMap $map): array if (! array_key_exists('sprites2d', $npc->toArray())) { // An NPC without a terminal sprite draws nothing; its map glyph, if any, is the cell's. if ($npc->getVisibleSprite() !== '') { - $issues[] = Issue::warning($map->mapId, + $issues[] = Issue::error($map->mapId, sprintf('NPC %s has no field sprite, so its glyph %s shows in the graphical field.', $name, $npc->getVisibleSprite()), 'Give it an RPG Maker character sheet (sprites2d).'); } @@ -128,7 +132,7 @@ public static function validateCoverage(ProjectMap $map): array $cell = $shown[$npc->getY()][$npc->getX()] ?? null; if ($cell !== null && $cell['glyph'] === $npc->getVisibleSprite()) { unset($shown[$npc->getY()][$npc->getX()]); - $issues[] = Issue::warning($map->mapId, + $issues[] = Issue::error($map->mapId, sprintf('NPC %s stands on a copy of its glyph %s on the %s layer, which shows under its sprite.', $name, $cell['glyph'], $cell['layer']), 'Remove the copy from the map if the NPC is always there; otherwise give the cell its tile.'); @@ -141,7 +145,7 @@ public static function validateCoverage(ProjectMap $map): array } if ($layers === []) { - return [...$issues, Issue::warning($map->mapId, + return [...$issues, Issue::error($map->mapId, sprintf('It has no tiles yet, so all %d of its glyph cells show in the graphical field.', count($cells)), 'Stamp tileset pieces with P, draw the tiles for glyphs already on a layer with T, or paint tiles in the GUI editor.')]; } @@ -155,7 +159,7 @@ public static function validateCoverage(ProjectMap $map): array foreach ($groups as $group) { $named = array_map(static fn(array $cell): string => "({$cell['x']}, {$cell['y']})", array_slice($group, 0, self::CELLS_NAMED)); $rest = count($group) - count($named); - $issues[] = Issue::warning($map->mapId, sprintf('%d %s of %s on the %s layer %s no tile, so the glyph shows in the graphical field: %s%s.', + $issues[] = Issue::error($map->mapId, sprintf('%d %s of %s on the %s layer %s no tile, so the glyph shows in the graphical field: %s%s.', count($group), count($group) === 1 ? 'cell' : 'cells', $group[0]['glyph'], $group[0]['layer'], count($group) === 1 ? 'has' : 'have', implode(', ', $named), $rest > 0 ? " and {$rest} more" : ''), $hint); } diff --git a/src/Validation/MapValidator.php b/src/Validation/MapValidator.php index abe85d8..43988d0 100644 --- a/src/Validation/MapValidator.php +++ b/src/Validation/MapValidator.php @@ -17,10 +17,14 @@ use Throwable; /** - * Runs the advisory pre-save checks over a map. + * Runs the pre-save checks over a map. * - * Findings are warnings only — a save is never blocked, the author is just - * told what will misbehave in the engine. + * A save is never blocked: the author's map saves as a draft and is told + * what is wrong. An error is a defect the player would meet (a raw glyph in + * the graphical field, a chest that gives nothing, a door to nowhere); a + * warning is something that works differently than written. Validation + * ({@see ProjectValidator}, `ichiloto validate`) fails on the errors until + * they are fixed. */ final class MapValidator { @@ -35,11 +39,11 @@ private function __construct() * @param array $mapsById Every workspace map keyed by map id. * @param InventoryCatalog|null $inventory The project's inventory, for what its chests give; null when it * could not be read ({@see readInventoryCatalog()}), which is itself reported. - * @return string[] Human-readable warnings; empty when the map is clean. + * @return list What is wrong, each naming the map; empty when the map is clean. */ public static function validate(ProjectMap $map, array $mapsById, ?InventoryCatalog $inventory): array { - return [ + return Issue::attributeToMap([ ...self::findMarkersWithoutDefinitions($map), ...self::findChestRewardProblems($map, $inventory), ...self::findDanglingDestinations($map, $mapsById), @@ -47,8 +51,32 @@ public static function validate(ProjectMap $map, array $mapsById, ?InventoryCata ...self::findRetiredData($map), ...self::findLandmarkProblems($map), ...self::findAtlasProblems($map, $mapsById), - ...array_map(static fn(Issue $issue): string => $issue->message, MapGraphicsValidator::validate($map)), - ]; + ...MapGraphicsValidator::validate($map), + ...MapGraphicsValidator::validateCoverage($map), + ], $map->mapId); + } + + /** + * Describes issues as one line each, the hint after the message. + * + * @param list $issues + * @return list + */ + public static function describeIssues(array $issues): array + { + return array_map(static fn(Issue $issue): string => $issue->hint === '' ? $issue->message : "{$issue->message} {$issue->hint}", + array_values($issues)); + } + + /** + * Selects the issues of one severity. + * + * @param list $issues + * @return list + */ + public static function selectSeverity(array $issues, Severity $severity): array + { + return array_values(array_filter($issues, static fn(Issue $issue): bool => $issue->severity === $severity)); } @@ -56,7 +84,7 @@ public static function validate(ProjectMap $map, array $mapsById, ?InventoryCata * Finds an atlas scope or parent chain the engine would refuse, by its own check over the project's maps. * * @param array $mapsById - * @return string[] + * @return list */ private static function findAtlasProblems(ProjectMap $map, array $mapsById): array { @@ -65,18 +93,18 @@ private static function findAtlasProblems(ProjectMap $map, array $mapsById): arr MapAtlas::resolveGlobal($map, $block, static fn(string $id): ?ProjectMap => $mapsById[$id] ?? null, MapCategoryCatalog::load($map->getAssetRoot())); } catch (InvalidArgumentException $error) { - return ['Atlas: ' . $error->getMessage()]; + return [Issue::warning($map->mapId, 'Atlas: ' . $error->getMessage())]; } // Terrain names the map's own source layers, which the parent chain does not read. $terrain = MapTerrainFields::findProblem($map, $block); - return $terrain === null ? [] : ['Atlas terrain: ' . $terrain]; + return $terrain === null ? [] : [Issue::warning($map->mapId, 'Atlas terrain: ' . $terrain)]; } /** * Finds minimap landmarks the engine would refuse, by its own check. * - * @return string[] + * @return list */ private static function findLandmarkProblems(ProjectMap $map): array { @@ -87,18 +115,18 @@ private static function findLandmarkProblems(ProjectMap $map): array $problem = $error->getMessage(); } - return $problem === null ? [] : ['Minimap landmarks: ' . $problem]; + return $problem === null ? [] : [Issue::warning($map->mapId, 'Minimap landmarks: ' . $problem)]; } /** * Finds map data the engine no longer reads. * - * @return string[] + * @return list */ private static function findRetiredData(ProjectMap $map): array { return array_key_exists('tiles2d', $map->getEditableData()) - ? ['tiles2d is no longer read; the map shows its terminal glyphs until it has a tileset.'] + ? [Issue::warning($map->mapId, 'tiles2d is no longer read; the map shows its terminal glyphs until it has a tileset.')] : []; } @@ -119,7 +147,7 @@ public static function readInventoryCatalog(ProjectWorkspace $workspace): ?Inven * Finds chests whose reward the Engine cannot grant, by the shared chest * check the inspectors and the project report also read. * - * @return string[] + * @return list */ private static function findChestRewardProblems(ProjectMap $map, ?InventoryCatalog $inventory): array { @@ -130,38 +158,39 @@ private static function findChestRewardProblems(ProjectMap $map, ?InventoryCatal } if (! $inventory instanceof InventoryCatalog) { - return ['The inventory could not be read, so what the chests give was not checked.']; + return [Issue::warning($map->mapId, 'The inventory could not be read, so what the chests give was not checked.')]; } $validator = new ChestRewardValidator($inventory); - $warnings = []; + $issues = []; foreach ($chests as $marker => $definition) { foreach ($validator->validate((array) ($definition['data'] ?? []), sprintf('%s event %s', $map->mapId, $marker)) as $issue) { - $warnings[] = sprintf('Chest %s: %s', $marker, $issue->message); + $issues[] = new Issue($issue->severity, $issue->where, sprintf('Chest %s: %s', $marker, $issue->message), $issue->hint, $issue->code); } } - return $warnings; + return $issues; } /** * Finds event markers painted on the grid with no event definition. * * @param ProjectMap $map The map being saved. - * @return string[] + * @return list */ private static function findMarkersWithoutDefinitions(ProjectMap $map): array { - $warnings = []; + $issues = []; foreach ($map->getPlacedEventMarkers() as $marker) { if ($map->getEventDefinition($marker) === null) { - $warnings[] = sprintf('Event marker %s has no definition.', $marker); + $issues[] = Issue::error($map->mapId, sprintf('Event marker %s has no definition.', $marker), + 'The game refuses to load the map. Define the event, or clear the marker.'); } } - return $warnings; + return $issues; } /** @@ -169,11 +198,11 @@ private static function findMarkersWithoutDefinitions(ProjectMap $map): array * * @param ProjectMap $map The map being saved. * @param array $mapsById Every workspace map keyed by map id. - * @return string[] + * @return list */ private static function findDanglingDestinations(ProjectMap $map, array $mapsById): array { - $warnings = []; + $issues = []; foreach (self::getEventDefinitions($map) as $marker => $definition) { $destination = $definition['data']['destinationMap'] ?? null; @@ -183,11 +212,12 @@ private static function findDanglingDestinations(ProjectMap $map, array $mapsByI } if (! isset($mapsById[$destination])) { - $warnings[] = sprintf('%s: destination map %s does not exist.', $marker, $destination); + $issues[] = Issue::error($map->mapId, sprintf('%s: destination map %s does not exist.', $marker, $destination), + 'Walking through it crashes the game. Choose a map the project has.'); } } - return $warnings; + return $issues; } /** @@ -195,11 +225,11 @@ private static function findDanglingDestinations(ProjectMap $map, array $mapsByI * * @param ProjectMap $map The map being saved. * @param array $mapsById Every workspace map keyed by map id. - * @return string[] + * @return list */ private static function findOutOfRangeSpawnPoints(ProjectMap $map, array $mapsById): array { - $warnings = []; + $issues = []; foreach (self::getEventDefinitions($map) as $marker => $definition) { $spawnPoint = $definition['data']['spawnPoint'] ?? null; @@ -219,7 +249,7 @@ private static function findOutOfRangeSpawnPoints(ProjectMap $map, array $mapsBy : $map; if ($x < 0 || $y < 0 || $x >= $targetMap->getWidth() || $y >= $targetMap->getHeight()) { - $warnings[] = sprintf( + $issues[] = Issue::error($map->mapId, sprintf( '%s: spawn point (%d, %d) is outside %s (%d x %d).', $marker, $x, @@ -227,11 +257,11 @@ private static function findOutOfRangeSpawnPoints(ProjectMap $map, array $mapsBy $targetMap === $map ? 'this map' : $targetMap->mapId, $targetMap->getWidth(), $targetMap->getHeight(), - ); + ), 'The player would arrive off the map. Choose a cell inside it.'); } } - return $warnings; + return $issues; } /** diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 88ee13b..774e16e 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -1646,26 +1646,29 @@ protected function checkMaps(ProjectWorkspace $workspace): array foreach ($workspace->maps as $map) { if (($sourceIssue = $map->getGridSourceIssue()) !== null) { - $issues[] = Issue::error( + $issues[] = new Issue(Severity::ERROR, $map->mapId, "Map source is read-only: {$sourceIssue}", 'Repair the source by hand, then reopen the project. Other maps remain editable.', + map: $map->mapId, ); continue; } $issues = [ ...$issues, - ...$this->checkLayers($map), - ...MapGraphicsValidator::validate($map), - ...MapGraphicsValidator::validateCoverage($map), - ...$this->checkRetiredMapData($map), - ...$this->checkEventMarkers($map), - ...$this->checkDoors($map, $workspace->mapIds), - ...$this->checkEncounters($map, $troops), - ...$this->checkNpcs($map, $workspace), - ...$this->checkDuplicateKeys($map), - ...$this->checkMapSource($map), + ...Issue::attributeToMap([ + ...$this->checkLayers($map), + ...MapGraphicsValidator::validate($map), + ...MapGraphicsValidator::validateCoverage($map), + ...$this->checkRetiredMapData($map), + ...$this->checkEventMarkers($map), + ...$this->checkDoors($map, $workspace->mapIds), + ...$this->checkEncounters($map, $troops), + ...$this->checkNpcs($map, $workspace), + ...$this->checkDuplicateKeys($map), + ...$this->checkMapSource($map), + ], $map->mapId), ]; } @@ -3806,95 +3809,109 @@ protected function checkMapReferences(ProjectWorkspace $workspace, array $known) $eventScripts = $this->commonEventScripts; foreach ($workspace->maps as $map) { + $issues = [...$issues, ...Issue::attributeToMap($this->checkMapContentReferences($map, $known, $npcIdsByMap, $eventScripts), $map->mapId)]; + } + + return $issues; + } + + /** + * Checks what one map's music, events and NPCs name. + * + * @param array $known The known reference values. + * @param array $npcIdsByMap Stable NPC ids by map id. + * @param array> $eventScripts Script payloads by id. + * @return Issue[] The issues found. + */ + protected function checkMapContentReferences(ProjectMap $map, array $known, array $npcIdsByMap, array $eventScripts): array + { + $issues = [ + ...$this->checkReference(strval($map->data['bgm'] ?? ''), 'bgm', 'track', $map->mapId, $known), + ...$this->checkBgmVariants($map, $known), + ]; + + foreach ((array) ($map->data['events'] ?? []) as $marker => $definition) { + if (! is_array($definition)) { + continue; + } + + $where = sprintf('%s event %s', $map->mapId, strval($marker)); + $data = (array) ($definition['data'] ?? []); + $class = strval($definition['class'] ?? ''); + $currentNpcIds = $npcIdsByMap[$map->mapId] ?? []; + $issues = [ ...$issues, - ...$this->checkReference(strval($map->data['bgm'] ?? ''), 'bgm', 'track', $map->mapId, $known), - ...$this->checkBgmVariants($map, $known), + ...$this->checkEventCue($definition['cue'] ?? null, $where, $known), + ...$this->checkEventFacing($definition, $where), + ...$this->checkEventEngagement($definition, $where), + ...$this->checkConditions((array) ($definition['conditions'] ?? []), $where, $known), + ...$this->checkDialogueVariants((array) ($data['dialogue'] ?? []), $where, $known), + ...$this->checkCommands( + (array) ($data['script'] ?? []), + $where, + $known, + $currentNpcIds, + $map->mapId, + $npcIdsByMap, + ), + ...$this->checkReference(strval($data['bgm'] ?? ''), 'bgm', 'track', $where, $known), + ...$this->checkReference(strval($data['sfx'] ?? ''), 'sfx', 'sound', $where, $known), ]; - foreach ((array) ($map->data['events'] ?? []) as $marker => $definition) { - if (! is_array($definition)) { - continue; - } - - $where = sprintf('%s event %s', $map->mapId, strval($marker)); - $data = (array) ($definition['data'] ?? []); - $class = strval($definition['class'] ?? ''); - $currentNpcIds = $npcIdsByMap[$map->mapId] ?? []; + if (str_contains($class, 'CinematicEventTrigger')) { + $issues = [...$issues, ...$this->checkCinematicTrigger($definition, $where, $known)]; + } + if (str_contains($class, self::SCRIPT_TRIGGER)) { $issues = [ ...$issues, - ...$this->checkEventCue($definition['cue'] ?? null, $where, $known), - ...$this->checkEventFacing($definition, $where), - ...$this->checkEventEngagement($definition, $where), - ...$this->checkConditions((array) ($definition['conditions'] ?? []), $where, $known), - ...$this->checkDialogueVariants((array) ($data['dialogue'] ?? []), $where, $known), - ...$this->checkCommands( - (array) ($data['script'] ?? []), + ...$this->checkScriptTrigger( + $definition, $where, $known, + $eventScripts, $currentNpcIds, $map->mapId, $npcIdsByMap, ), - ...$this->checkReference(strval($data['bgm'] ?? ''), 'bgm', 'track', $where, $known), - ...$this->checkReference(strval($data['sfx'] ?? ''), 'sfx', 'sound', $where, $known), ]; + } - if (str_contains($class, 'CinematicEventTrigger')) { - $issues = [...$issues, ...$this->checkCinematicTrigger($definition, $where, $known)]; - } - - if (str_contains($class, self::SCRIPT_TRIGGER)) { + foreach ((array) ($data['items'] ?? []) as $stock) { + if (is_array($stock)) { $issues = [ ...$issues, - ...$this->checkScriptTrigger( - $definition, - $where, - $known, - $eventScripts, - $currentNpcIds, - $map->mapId, - $npcIdsByMap, - ), + ...$this->checkReference(strval($stock['item'] ?? ''), 'inventory', 'item', $where, $known), ]; } - - foreach ((array) ($data['items'] ?? []) as $stock) { - if (is_array($stock)) { - $issues = [ - ...$issues, - ...$this->checkReference(strval($stock['item'] ?? ''), 'inventory', 'item', $where, $known), - ]; - } - } - - if (ChestLoot::isChest($definition)) { - $issues = [...$issues, ...$this->checkChestLoot($data, $where)]; - } } - foreach ((array) ($map->data['npcs'] ?? []) as $npc) { - if (! is_array($npc)) { - continue; - } + if (ChestLoot::isChest($definition)) { + $issues = [...$issues, ...$this->checkChestLoot($data, $where)]; + } + } - $where = sprintf('%s NPC %s', $map->mapId, strval($npc['name'] ?? '(unnamed)')); - $issues = [ - ...$issues, - ...$this->checkConditions((array) ($npc['conditions'] ?? []), $where, $known), - ...$this->checkDialogueVariants((array) ($npc['dialogue'] ?? []), $where, $known), - ...$this->checkCommands( - (array) ($npc['script'] ?? []), - $where, - $known, - $npcIdsByMap[$map->mapId] ?? [], - $map->mapId, - $npcIdsByMap, - ), - ...$this->checkStateWrites((array) ($npc['sets'] ?? []), $where), - ]; + foreach ((array) ($map->data['npcs'] ?? []) as $npc) { + if (! is_array($npc)) { + continue; } + + $where = sprintf('%s NPC %s', $map->mapId, strval($npc['name'] ?? '(unnamed)')); + $issues = [ + ...$issues, + ...$this->checkConditions((array) ($npc['conditions'] ?? []), $where, $known), + ...$this->checkDialogueVariants((array) ($npc['dialogue'] ?? []), $where, $known), + ...$this->checkCommands( + (array) ($npc['script'] ?? []), + $where, + $known, + $npcIdsByMap[$map->mapId] ?? [], + $map->mapId, + $npcIdsByMap, + ), + ...$this->checkStateWrites((array) ($npc['sets'] ?? []), $where), + ]; } return $issues; diff --git a/src/Validation/ReachabilityValidator.php b/src/Validation/ReachabilityValidator.php index 5e7ac76..d5989bd 100644 --- a/src/Validation/ReachabilityValidator.php +++ b/src/Validation/ReachabilityValidator.php @@ -12,6 +12,7 @@ use Ichiloto\Engine\Field\Reachability\ReachabilityProblem; use Ichiloto\Engine\Field\Reachability\ReachabilityProblemKind; use Ichiloto\Engine\Rendering\Tilesets\TileId; +use RuntimeException; use Throwable; /** @@ -21,17 +22,32 @@ * Where content stands is the author's; only what a player would meet is * reported. Unknown destinations and unreadable maps are left to the checks * that own them (doors, references, map reading), so nothing is said twice. + * + * The analysis reads map folders, so while maps have unsaved changes it + * reads them as saving would leave them ({@see UnsavedMapStage}), as every + * other check reads the workspace. */ final class ReachabilityValidator { /** @return Issue[] */ public function validate(ProjectWorkspace $workspace): array { - $assetRoot = $workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; - $project = ProjectReachability::analyze($assetRoot); - $untiled = []; - foreach ($project->reports as $mapId => $report) { - $untiled[] = self::checkUntiledFloor($assetRoot, (string) $mapId, $report); + try { + $stage = UnsavedMapStage::prepare($workspace); + } catch (RuntimeException $failure) { + return [Issue::error('maps', 'Reachability could not be checked: ' . $failure->getMessage(), + 'Save or undo the unsaved maps, then validate again.')]; + } + + try { + $assetRoot = $stage?->assetRoot ?? $workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; + $project = ProjectReachability::analyze($assetRoot); + $untiled = []; + foreach ($project->reports as $mapId => $report) { + $untiled[] = self::checkUntiledFloor($assetRoot, (string) $mapId, $report); + } + } finally { + $stage?->dispose(); } return array_values(array_filter([...array_map( @@ -44,7 +60,7 @@ public function validate(ProjectWorkspace $workspace): array private const int CELLS_NAMED = 3; /** - * Warns for cells the player can reach that no tile covers in a map with graphics: the field draws nothing + * Reports cells the player can reach that no tile covers in a map with graphics: the field draws nothing * there, so the player walks on the dark background, as on a map whose floor was never painted. A map without * a kind, or whose graphics cannot be read, is left to the checks that own it. */ @@ -86,24 +102,24 @@ private static function checkUntiledFloor(string $assetRoot, string $mapId, MapR $named = array_slice($cells, 0, self::CELLS_NAMED); $rest = count($cells) - count($named); - return Issue::warning($mapId, sprintf('%d %s the player can reach %s no tile, so the field shows the background there: %s%s.', + // The player walks on the dark background there: a defect they see, so an error. + return new Issue(Severity::ERROR, $mapId, sprintf('%d %s the player can reach %s no tile, so the field shows the background there: %s%s.', count($cells), count($cells) === 1 ? 'cell' : 'cells', count($cells) === 1 ? 'has' : 'have', implode(', ', $named), $rest > 0 ? " and {$rest} more" : ''), - 'Paint floor under them on a tile layer, or close them off if the player should not walk there.'); + 'Paint floor under them on a tile layer, or close them off if the player should not walk there.', map: $mapId); } private static function toIssue(ReachabilityProblem $problem): ?Issue { - return match ($problem->kind) { - ReachabilityProblemKind::UNREADABLE_MAP, ReachabilityProblemKind::UNKNOWN_DESTINATION => null, - ReachabilityProblemKind::UNREACHABLE_EVENT => Issue::error($problem->mapId, $problem->message, - 'Open a way to it, or move the event where the player can stand.'), - ReachabilityProblemKind::BLOCKED_ENTRANCE => Issue::error($problem->mapId, $problem->message, - 'Point the arrival at open floor the player can stand on.'), - ReachabilityProblemKind::UNREACHABLE_NPC => Issue::warning($problem->mapId, $problem->message, - 'Leave a free cell beside it, or move what stands in the way.'), - ReachabilityProblemKind::NO_ENTRANCE => Issue::warning($problem->mapId, $problem->message, - 'Fine for a map kept for content still to come; connect it when it is ready.'), + [$severity, $hint] = match ($problem->kind) { + ReachabilityProblemKind::UNREADABLE_MAP, ReachabilityProblemKind::UNKNOWN_DESTINATION => [null, ''], + ReachabilityProblemKind::UNREACHABLE_EVENT => [Severity::ERROR, 'Open a way to it, or move the event where the player can stand.'], + ReachabilityProblemKind::BLOCKED_ENTRANCE => [Severity::ERROR, 'Point the arrival at open floor the player can stand on.'], + ReachabilityProblemKind::UNREACHABLE_NPC => [Severity::WARNING, 'Leave a free cell beside it, or move what stands in the way.'], + ReachabilityProblemKind::NO_ENTRANCE => [Severity::WARNING, 'Fine for a map kept for content still to come; connect it when it is ready.'], }; + + return $severity === null ? null + : new Issue($severity, $problem->mapId, $problem->message, $hint, map: $problem->mapId === '' ? null : $problem->mapId); } } diff --git a/src/Validation/UnsavedMapStage.php b/src/Validation/UnsavedMapStage.php new file mode 100644 index 0000000..a1d19d2 --- /dev/null +++ b/src/Validation/UnsavedMapStage.php @@ -0,0 +1,103 @@ +maps, static fn(ProjectMap $map): bool => $map->isDirty()); + if ($unsaved === []) { + return null; + } + + $assets = $workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; + $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ichiloto-unsaved-maps-' . bin2hex(random_bytes(6)); + $stage = new self($root . DIRECTORY_SEPARATOR . 'assets', $root); + + try { + if (! @mkdir($stage->assetRoot, 0o700, true)) { + throw new RuntimeException('Its folder could not be made.'); + } + foreach (array_diff(scandir($assets) ?: [], ['.', '..', 'Maps']) as $entry) { + if (! @symlink($assets . DIRECTORY_SEPARATOR . $entry, $stage->assetRoot . DIRECTORY_SEPARATOR . $entry)) { + throw new RuntimeException(sprintf('assets/%s could not be linked.', $entry)); + } + } + self::copyFolder($workspace->getMapsRoot(), $stage->assetRoot . DIRECTORY_SEPARATOR . 'Maps'); + foreach ($unsaved as $map) { + $map->exportSources($stage->assetRoot . DIRECTORY_SEPARATOR . 'Maps'); + } + } catch (\Throwable $failure) { + $stage->dispose(); + + throw new RuntimeException('The unsaved maps could not be staged: ' . $failure->getMessage(), previous: $failure); + } + + return $stage; + } + + /** Removes the stage: the copied maps, and the links without what they point at. */ + public function dispose(): void + { + self::removeFolder($this->root); + } + + private static function copyFolder(string $from, string $to): void + { + if (! is_dir($to) && ! @mkdir($to, 0o700, true)) { + throw new RuntimeException(sprintf('%s could not be made.', $to)); + } + foreach (array_diff(scandir($from) ?: [], ['.', '..']) as $entry) { + $source = $from . DIRECTORY_SEPARATOR . $entry; + $target = $to . DIRECTORY_SEPARATOR . $entry; + if (is_dir($source) && ! is_link($source)) { + self::copyFolder($source, $target); + } elseif (! @copy($source, $target)) { + throw new RuntimeException(sprintf('%s could not be copied.', $source)); + } + } + } + + private static function removeFolder(string $folder): void + { + if (! is_dir($folder) || is_link($folder)) { + return; + } + foreach (array_diff(scandir($folder) ?: [], ['.', '..']) as $entry) { + $path = $folder . DIRECTORY_SEPARATOR . $entry; + if (is_link($path) || ! is_dir($path)) { + @unlink($path); + } else { + self::removeFolder($path); + } + } + @rmdir($folder); + } +} diff --git a/tests/Pest.php b/tests/Pest.php index 4469714..3c891a1 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -485,6 +485,18 @@ function itemsFromSource(string $source): array * The synthetic sample project's inventory, read once: what map save * warnings check a chest's reward against. */ +/** + * The messages of the pre-save checks of a map, whatever their severity. + * + * @param array $mapsById + * @return list + */ +function mapValidatorMessages(Ichiloto\Editor\ProjectMap $map, array $mapsById): array +{ + return array_map(static fn(Ichiloto\Editor\Validation\Issue $issue): string => $issue->message, + Ichiloto\Editor\Validation\MapValidator::validate($map, $mapsById, mapValidatorInventory())); +} + function mapValidatorInventory(): \Ichiloto\Editor\Database\InventoryCatalog { static $catalog = null; diff --git a/tests/Unit/ChestRewardValidationTest.php b/tests/Unit/ChestRewardValidationTest.php index 6a9dea5..7342170 100644 --- a/tests/Unit/ChestRewardValidationTest.php +++ b/tests/Unit/ChestRewardValidationTest.php @@ -209,7 +209,7 @@ function listChestRewardIssues(string $root): array expect($where)->toBe(['test-map event A', 'test-map event B', 'test-map event E']); }); -it('diagnoses a new chest on its inspector row and in the save warnings, and still saves it as authored', function () { +it('diagnoses a new chest on its inspector row and as a save error, and still saves it as authored', function () { $root = chestRewardProject(); $session = Ichiloto\Editor\Session\EditorSession::open($root); $map = $session->readMap('test-map'); @@ -243,13 +243,13 @@ function listChestRewardIssues(string $root): array $saved = $session->saveMap('test-map'); expect($saved['saved'])->toBe('test-map') - ->and($saved['warnings'])->toContain(sprintf('Chest %s: It has no item chosen, so it fails when opened.', $marker)) + ->and(array_column($saved['errors'], 'message'))->toContain(sprintf('Chest %s: It has no item chosen, so it fails when opened.', $marker)) ->and((require $root . '/assets/Maps/test-map/test-map.data.php')['events'][$marker]['data']['loot'])->toBe(''); $loot = array_find($session->readInspector('test-map', $marker)['rows'], static fn(array $row): bool => trim($row['label']) === 'Loot'); $session->applyInspector('test-map', $session->readMap('test-map')['revision'], $loot['key'], 'item.tonic'); expect(array_column($session->readInspector('test-map', $marker)['rows'], 'label'))->not->toContain('Reward Error') - ->and(array_filter($session->saveMap('test-map')['warnings'], - static fn(string $warning): bool => str_starts_with($warning, sprintf('Chest %s:', $marker))))->toBe([]); + ->and(array_filter(array_column($session->saveMap('test-map')['errors'], 'message'), + static fn(string $error): bool => str_starts_with($error, sprintf('Chest %s:', $marker))))->toBe([]); }); diff --git a/tests/Unit/MapGraphicsTest.php b/tests/Unit/MapGraphicsTest.php index dd0a676..9ce9043 100644 --- a/tests/Unit/MapGraphicsTest.php +++ b/tests/Unit/MapGraphicsTest.php @@ -320,20 +320,19 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array file_put_contents($root . '/assets/Graphics/Tilesets/Home_B.png', 'not a png'); writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "0 300 0 0\n0 0 1536 5"); - $warnings = graphicsIssueLines($root, Severity::WARNING); - - expect($warnings)->toBe([ + expect(graphicsIssueLines($root, Severity::WARNING))->toBe([ 'Tileset home sheet A2 is unusable: Graphics/Tilesets/Home_A2.png is 30x24, which does not fit an A2 sheet of 16 x 12 tiles in one even tile size shared by every sheet (768x576 at 48 pixels).', 'Tileset home sheet B is unusable: Invalid PNG header: Graphics/Tilesets/Home_B.png', 'Tile layer test-map/graphics/02.decor.tiles.php uses sheets A5, C, which tileset home does not provide.', - // Tiles from unusable sheets draw nothing, so their glyphs show. + ])->and(graphicsIssueLines($root, Severity::ERROR))->toBe([ + // Tiles from unusable sheets draw nothing, so their glyphs show: the player sees them. '5 cells of . on the terrain layer have no tile, so the glyph shows in the graphical field: (0, 0), (2, 0), (3, 0) and 2 more.', '1 cell of / on the buildings layer has no tile, so the glyph shows in the graphical field: (1, 0).', '2 cells of x on the buildings layer have no tile, so the glyph shows in the graphical field: (1, 1), (2, 1).', - ])->and(graphicsIssueLines($root, Severity::ERROR))->toBe([]); + ]); }); -it('warns for glyphs no tile covers, NPCs without field sprites and copies of an NPC\'s glyph under it', function () { +it('reports glyphs no tile covers, NPCs without field sprites and copies of an NPC\'s glyph under it as errors', function () { $root = mapGraphicsProject(); $directory = $root . '/assets/Maps/test-map'; writeTileLayer($directory, '01.floor.tiles.php', "2816 0 2816 2816\n2816 0 0 2816"); @@ -349,12 +348,12 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array // The board draws no glyph, and the lamp stands on a covered cell. The // sign's copy is named once, for the NPC, not again among the x cells; // the cat stands on another glyph, an uncovered cell like any other. - expect(graphicsIssueLines($root))->toBe([ + expect(graphicsIssueLines($root, Severity::ERROR))->toBe([ 'NPC Guard (guard) has no field sprite, so its glyph @ shows in the graphical field.', 'NPC Sign (sign) stands on a copy of its glyph x on the buildings layer, which shows under its sprite.', '1 cell of / on the buildings layer has no tile, so the glyph shows in the graphical field: (1, 0).', '1 cell of x on the buildings layer has no tile, so the glyph shows in the graphical field: (2, 1).', - ])->and(graphicsIssueLines($root, Severity::ERROR))->toBe([]); + ])->and(graphicsIssueLines($root, Severity::WARNING))->toBe([]); }); it('warns for tiles a piece draws whose glyph is no longer there, and only those', function () { @@ -370,7 +369,8 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array // it is never judged. writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "0 0 0 6\n0 5 5 5"); $stale = array_values(array_filter(graphicsIssueLines($root), static fn(string $line): bool => str_contains($line, 'no longer there'))); - expect($stale)->toBe(['2 tiles on the decor tile layer are drawn by a tileset piece whose glyph is no longer there: (3, 0), (3, 1).']); + expect($stale)->toBe(['2 tiles on the decor tile layer are drawn by a tileset piece whose glyph is no longer there: (3, 0), (3, 1).']) + ->and(graphicsIssueLines($root, Severity::WARNING))->toContain($stale[0]); writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "0 7 6 0\n0 5 5 0"); expect(array_filter(graphicsIssueLines($root), static fn(string $line): bool => str_contains($line, 'no longer there')))->toBe([]); @@ -395,7 +395,9 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "0 255 0 0\n0 0 255 255"); $placeholder = static fn(): array => array_values(array_filter(graphicsIssueLines($root), static fn(string $line): bool => str_contains($line, 'missing-art placeholder'))); - expect($placeholder())->toBe(['3 cells on the decor tile layer show the missing-art placeholder: (1, 0), (2, 1), (3, 1).']); + expect($placeholder())->toBe(['3 cells on the decor tile layer show the missing-art placeholder: (1, 0), (2, 1), (3, 1).']) + // The author chose the placeholder for art still to make: a warning. + ->and(graphicsIssueLines($root, Severity::WARNING))->toContain($placeholder()[0]); // A tileset that names none has no placeholder to report. writeTestTileset($root); @@ -414,33 +416,37 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array ]); }); -it('includes graphics problems in the pre-save map warnings, unsaved resizes included', function () { +it('includes graphics problems and shown glyphs in the pre-save map checks, unsaved resizes included', function () { $root = mapGraphicsProject(); $map = loadLayeredMap($root); $map->setMapDataField(['tileset'], 'missing'); + $messages = static fn(ProjectMap $map): array => array_map(static fn(Issue $issue): string => $issue->message, + MapValidator::validate($map, ['test-map' => $map], mapValidatorInventory())); - expect(MapValidator::validate($map, ['test-map' => $map], mapValidatorInventory()))->toContain('Tileset missing was not found at Data/Tilesets/missing.php.'); + expect($messages($map))->toContain('Tileset missing was not found at Data/Tilesets/missing.php.'); $map->setMapDataField(['tileset'], 'home'); $map->resize(6, 3); expect(MapGraphicsValidator::validate($map))->toBe([]); - // Glyphs without tiles are art still to do, not a save problem. + // A glyph the player would see is an error the save reports; the save itself still runs. writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "0 0 0 0\n0 0 0 0"); $map = loadLayeredMap($root); - $coverage = array_map(static fn(Issue $issue): string => $issue->message, MapGraphicsValidator::validateCoverage($map)); + $coverage = MapGraphicsValidator::validateCoverage($map); expect($coverage)->not->toBe([]) - ->and(array_intersect(MapValidator::validate($map, ['test-map' => $map], mapValidatorInventory()), $coverage))->toBe([]); + ->and(array_values(array_intersect($messages($map), array_map(static fn(Issue $issue): string => $issue->message, $coverage)))) + ->toBe(array_map(static fn(Issue $issue): string => $issue->message, $coverage)) + ->and(array_all($coverage, static fn(Issue $issue): bool => $issue->severity === Severity::ERROR))->toBeTrue(); }); -it('warns about a map without a kind only in a project that has tilesets', function () { +it('reports a map without a kind as an error only in a project that has tilesets', function () { $root = layeredMapProject(); expect(MapGraphicsValidator::validate(loadLayeredMap($root)))->toBe([]); writeTestTileset($root, 'interior'); $issues = MapGraphicsValidator::validate(loadLayeredMap($root)); expect($issues)->toHaveCount(1) - ->and($issues[0]->severity)->toBe(Severity::WARNING) + ->and($issues[0]->severity)->toBe(Severity::ERROR) ->and($issues[0]->message)->toBe('It has no kind, so it has no tiles or pieces.') ->and($issues[0]->hint)->toStartWith('Set its Kind in the Inspector, one of the tilesets in assets/Data/Tilesets.'); diff --git a/tests/Unit/MapValidatorTest.php b/tests/Unit/MapValidatorTest.php index 85cf200..088c75b 100644 --- a/tests/Unit/MapValidatorTest.php +++ b/tests/Unit/MapValidatorTest.php @@ -3,7 +3,9 @@ declare(strict_types=1); use Ichiloto\Editor\ProjectMap; +use Ichiloto\Editor\Validation\Issue; use Ichiloto\Editor\Validation\MapValidator; +use Ichiloto\Editor\Validation\Severity; /** * Builds an in-memory map with the given data and event rows. @@ -33,14 +35,17 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' expect(MapValidator::validate($map, ['town' => $map], mapValidatorInventory()))->toBe([]); }); -it('warns about event markers without definitions', function () { +it('reports an event marker without a definition as an error the map still saves with', function () { $map = validatorMap('town', ['name' => 'Town', 'events' => []], ['E ', ' ']); - $warnings = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); + $issues = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); - expect($warnings)->toHaveCount(1) - ->and($warnings[0])->toContain('marker E') - ->and($warnings[0])->toContain('no definition'); + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::ERROR) + ->and($issues[0]->map)->toBe('town') + ->and($issues[0]->message)->toContain('marker E') + ->and($issues[0]->message)->toContain('no definition') + ->and($issues[0]->hint)->not->toBe(''); }); it('accepts a marker painted in any shape, as the runtime triggers its exact cells', function () { @@ -54,7 +59,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' expect(MapValidator::validate($map, ['town' => $map], mapValidatorInventory()))->toBe([]); }); -it('warns about dangling transfer destinations', function () { +it('reports a transfer to a map that does not exist as an error', function () { $map = validatorMap('town', [ 'name' => 'Town', 'events' => [ @@ -62,14 +67,15 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ], ], ['T ', ' ']); - $warnings = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); + $issues = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); - expect($warnings)->toHaveCount(1) - ->and($warnings[0])->toContain('vanished-map') - ->and($warnings[0])->toContain('does not exist'); + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::ERROR) + ->and($issues[0]->message)->toContain('vanished-map') + ->and($issues[0]->message)->toContain('does not exist'); }); -it('warns about spawn points outside the destination map', function () { +it('reports a spawn point outside the destination map as an error', function () { $destination = validatorMap('cave', ['name' => 'Cave', 'events' => []]); $map = validatorMap('town', [ 'name' => 'Town', @@ -84,11 +90,12 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ], ], ['T ', ' ']); - $warnings = MapValidator::validate($map, ['town' => $map, 'cave' => $destination], mapValidatorInventory()); + $issues = MapValidator::validate($map, ['town' => $map, 'cave' => $destination], mapValidatorInventory()); - expect($warnings)->toHaveCount(1) - ->and($warnings[0])->toContain('spawn point (40, 1)') - ->and($warnings[0])->toContain('cave'); + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::ERROR) + ->and($issues[0]->message)->toContain('spawn point (40, 1)') + ->and($issues[0]->message)->toContain('cave'); }); it('validates spawn points against this map when no destination is set', function () { @@ -100,11 +107,11 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ], ], ['SB ', ' ']); - $warnings = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); + $issues = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); - expect($warnings)->toHaveCount(1) - ->and($warnings[0])->toContain('B:') - ->and($warnings[0])->toContain('this map'); + expect($issues)->toHaveCount(1) + ->and($issues[0]->message)->toContain('B:') + ->and($issues[0]->message)->toContain('this map'); }); it('collects multiple findings in one pass', function () { @@ -115,9 +122,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ], ], ['TX ', ' ']); - $warnings = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); - - expect($warnings)->toHaveCount(2); + expect(MapValidator::validate($map, ['town' => $map], mapValidatorInventory()))->toHaveCount(2); }); it('warns before a save that a tiles2d crop table is no longer read', function () { @@ -127,6 +132,8 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' 'tiles2d' => ['asset' => 'Graphics/Tilesets/shared.png', 'symbols' => []], ]); - expect(MapValidator::validate($map, ['town' => $map], mapValidatorInventory())) - ->toBe(['tiles2d is no longer read; the map shows its terminal glyphs until it has a tileset.']); + $issues = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); + + expect(array_map(static fn(Issue $issue): array => [$issue->severity, $issue->message], $issues)) + ->toBe([[Severity::WARNING, 'tiles2d is no longer read; the map shows its terminal glyphs until it has a tileset.']]); }); diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index 541fb15..2061d08 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -981,7 +981,7 @@ function reachabilityIssueLines(string $root): array ])->and(issuesMentioning($issues, 'Edge trigger 1 leads to "nowhere", which is not a map in this project.'))->toHaveCount(1); }); -it('warns for floor the player can reach that no tile covers, and is satisfied once it is painted', function () { +it('reports floor the player can reach that no tile covers as an error, and is satisfied once it is painted', function () { $root = makeTemporaryProject(); writeConsistentQuests($root); writeConsistentSkits($root); @@ -993,10 +993,10 @@ function reachabilityIssueLines(string $root): array file_put_contents($data, str_replace("'triggers' => [],", "'triggers' => [], 'tileset' => 'home',", (string) file_get_contents($data))); // The walls and the first two floor cells are painted; the third floor cell is not. writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "5 5 5 5 5\n5 2816 2816 0 5\n5 5 5 5 5"); - $untiled = static fn(): array => array_values(array_map(static fn(Issue $issue): string => $issue->message, + $untiled = static fn(): array => array_values(array_map(static fn(Issue $issue): string => "{$issue->severity->value} {$issue->map}: {$issue->message}", issuesMentioning(validateProject($root), 'shows the background'))); - expect($untiled())->toBe(['1 cell the player can reach has no tile, so the field shows the background there: (3, 1).']); + expect($untiled())->toBe(['error test-map: 1 cell the player can reach has no tile, so the field shows the background there: (3, 1).']); writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "5 5 5 5 5\n5 2816 2816 2816 5\n5 5 5 5 5"); expect($untiled())->toBe([]); }); diff --git a/tests/Unit/Session/ChangeSetTest.php b/tests/Unit/Session/ChangeSetTest.php new file mode 100644 index 0000000..ca9c38f --- /dev/null +++ b/tests/Unit/Session/ChangeSetTest.php @@ -0,0 +1,211 @@ + $holes + */ +function changeSetProject(array $holes = []): string +{ + $root = mapGraphicsProject(); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource(" \n ", 'EVENTS')); + $terrain = ['....', '....']; + $floor = [[2816, 2816, 2816, 2816], [2816, 2816, 2816, 2816]]; + $decor = [[0, 5, 0, 0], [0, 5, 5, 0]]; + foreach ([[3, 1], ...$holes] as [$x, $y]) { + $terrain[$y][$x] = ' '; + $floor[$y][$x] = 0; + $decor[$y][$x] = 0; + } + file_put_contents($directory . '/layers/01.terrain.map.php', MapGridSource::buildSource(implode("\n", $terrain), 'AUTHORED')); + $rows = static fn(array $grid): string => implode("\n", array_map(static fn(array $row): string => implode(' ', $row), $grid)); + writeTileLayer($directory, '01.floor.tiles.php', $rows($floor)); + writeTileLayer($directory, '02.decor.tiles.php', $rows($decor)); + + return $root; +} + +/** The id of a test map layer by its name. */ +function changeSetLayer(EditorSession $session, string $name): string +{ + return array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['name'] === $name)['id']; +} + +/** Paints one glyph on the test map, at its current revision. */ +function changeSetPaint(EditorSession $session, string $layer, int $x, int $y, string $symbol): void +{ + $session->paint('test-map', $session->readMap('test-map')['revision'], changeSetLayer($session, $layer), [[$x, $y]], $symbol); +} + +/** @return list The blocking and reported messages of a commit. */ +function changeSetMessages(array $outcome, string $severity = 'error'): array +{ + return array_values(array_map(static fn(array $issue): string => $issue['message'], + array_filter($outcome['issues'], static fn(array $issue): bool => $issue['severity'] === $severity))); +} + +it('starts from a project whose test map has no errors', function () { + $session = EditorSession::open(changeSetProject()); + + expect($session->validateProject(['test-map'])['errors'])->toBe(0); +}); + +it('commits a change set that leaves its maps valid, through each map\'s own save', function () { + $root = changeSetProject(); + $session = EditorSession::open($root); + $session->beginChanges(); + changeSetPaint($session, 'buildings', 1, 0, ' '); + + $outcome = $session->commitChanges(); + + expect($outcome['status'])->toBe('saved') + ->and($outcome['maps'])->toBe(['test-map']) + ->and($outcome['saved'])->toBe(['test-map']) + ->and($outcome['blocking'])->toBe(0) + ->and(MapGridSource::readFile($root . '/assets/Maps/test-map/layers/04.buildings.map.php'))->toBe(" \n xx ") + ->and($session->listUnsavedChanges())->toBe([]); +}); + +it('refuses a change set that leaves a raw glyph showing, writes nothing and undoes it', function () { + $root = changeSetProject(); + $before = sourceHashTree($root . '/assets'); + $session = EditorSession::open($root); + $session->beginChanges(); + changeSetPaint($session, 'buildings', 3, 1, 'x'); + + $outcome = $session->commitChanges(); + + expect($outcome['status'])->toBe('refused') + ->and($outcome['blocking'])->toBe(1) + ->and($outcome['reason'])->toBe('1 error the player would meet.') + ->and(changeSetMessages($outcome))->toBe(['1 cell of x on the buildings layer has no tile, so the glyph shows in the graphical field: (3, 1).']) + ->and($outcome['issues'][0]['map'])->toBe('test-map') + ->and($outcome['issues'][0]['hint'])->not->toBe('') + ->and(sourceHashTree($root . '/assets'))->toBe($before) + ->and($session->listUnsavedChanges())->toBe([]) + // The change set is closed: another may begin. + ->and($session->beginChanges()['status'])->toBe('open'); +}); + +it('refuses a change set on a map that already shows a raw glyph until that is fixed too', function () { + $root = changeSetProject(holes: [[2, 1]]); + $session = EditorSession::open($root); + $session->beginChanges(); + changeSetPaint($session, 'buildings', 1, 0, ' '); + + $refused = $session->commitChanges(); + expect($refused['status'])->toBe('refused') + ->and(changeSetMessages($refused))->toBe(['1 cell of x on the buildings layer has no tile, so the glyph shows in the graphical field: (2, 1).']); + + // Fixing the defect in the same change set lets it through. + $session->beginChanges(); + changeSetPaint($session, 'buildings', 1, 0, ' '); + changeSetPaint($session, 'buildings', 2, 1, ' '); + expect($session->commitChanges()['status'])->toBe('saved'); +}); + +it('reports what committing would find without writing anything when only checking', function () { + $root = changeSetProject(); + $before = sourceHashTree($root . '/assets'); + $session = EditorSession::open($root); + $session->beginChanges(); + changeSetPaint($session, 'buildings', 1, 0, ' '); + + $checked = $session->commitChanges(checkOnly: true); + + expect($checked['status'])->toBe('checked') + ->and($checked['blocking'])->toBe(0) + ->and($checked['saved'])->toBe([]) + ->and(sourceHashTree($root . '/assets'))->toBe($before) + ->and($session->listUnsavedChanges())->toBe([]); +}); + +it('holds a change set to itself: no saves inside it, no undo past its start, and edits pending before it refuse it', function () { + $session = EditorSession::open(changeSetProject()); + changeSetPaint($session, 'buildings', 1, 0, ' '); + expect(fn() => $session->beginChanges())->toThrow(SessionRefusal::class, 'Save or undo pending edits'); + + $session->undo(); + $session->beginChanges(); + expect(fn() => $session->saveMap('test-map'))->toThrow(SessionRefusal::class, 'Saving a map is not part of a change set') + ->and(fn() => $session->saveAll())->toThrow(SessionRefusal::class, 'is not part of a change set') + ->and(fn() => $session->undo())->toThrow(SessionRefusal::class, 'before the change set began') + ->and(fn() => $session->beginChanges())->toThrow(SessionRefusal::class, 'already open'); + + changeSetPaint($session, 'buildings', 1, 0, ' '); + expect($session->cancelChanges()['status'])->toBe('cancelled') + ->and($session->listUnsavedChanges())->toBe([]); +}); + +it('undoes what a refused change set wrote at once, such as inserted rows', function () { + $root = changeSetProject(); + $before = sourceHashTree($root . '/assets'); + $buildings = changeSetLayer(EditorSession::open($root), 'buildings'); + + $outcome = ChangeBatch::apply($root, [ + ['method' => 'map.insertLines', 'params' => ['map' => 'test-map', 'axis' => 'y', 'at' => 2, 'count' => 1], 'answer' => 'write'], + ['method' => 'map.paint', 'params' => ['map' => 'test-map', 'layer' => $buildings, 'cells' => [[3, 1]], 'symbol' => 'x']], + ]); + + expect($outcome['status'])->toBe('refused') + ->and(changeSetMessages($outcome))->toContain('1 cell of x on the buildings layer has no tile, so the glyph shows in the graphical field: (3, 1).') + ->and(sourceHashTree($root . '/assets'))->toBe($before); +}); + +it('applies a batch of session requests as one change set, filling each map\'s revision as it stands', function () { + $root = changeSetProject(); + $buildings = changeSetLayer(EditorSession::open($root), 'buildings'); + + $outcome = ChangeBatch::apply($root, [ + ['method' => 'map.paint', 'params' => ['map' => 'test-map', 'layer' => $buildings, 'cells' => [[1, 0]], 'symbol' => ' ']], + ['method' => 'map.paint', 'params' => ['map' => 'test-map', 'layer' => $buildings, 'cells' => [[2, 1]], 'symbol' => ' ']], + ]); + + expect($outcome['status'])->toBe('saved') + ->and($outcome['saved'])->toBe(['test-map']) + ->and(EditorSession::open($root)->readMap('test-map')['revision'])->toBeInt(); + expect(MapGridSource::readFile($root . '/assets/Maps/test-map/layers/04.buildings.map.php'))->toBe(" \n x "); +}); + +it('names the step a batch was refused at, and writes nothing of it', function () { + $root = changeSetProject(); + $before = sourceHashTree($root . '/assets'); + $buildings = changeSetLayer(EditorSession::open($root), 'buildings'); + + $outcome = ChangeBatch::apply($root, [ + ['method' => 'map.paint', 'params' => ['map' => 'test-map', 'layer' => $buildings, 'cells' => [[1, 0]], 'symbol' => ' ']], + ['method' => 'map.paint', 'params' => ['map' => 'test-map', 'layer' => 'no-layer', 'cells' => [[0, 0]], 'symbol' => '#']], + ['method' => 'map.save', 'params' => ['map' => 'test-map']], + ]); + + expect($outcome)->toMatchArray(['status' => 'failed', 'step' => 2, 'method' => 'map.paint']) + ->and($outcome['message'])->toContain('test-map has no layer no-layer.') + ->and(sourceHashTree($root . '/assets'))->toBe($before) + ->and(ChangeBatch::apply($root, [['method' => 'map.save', 'params' => ['map' => 'test-map']]])['message']) + ->toContain('Saving a map is not part of a change set') + ->and(ChangeBatch::apply($root, [['method' => 'changes.commit', 'params' => []]])['message']) + ->toContain('made by the batch itself'); +}); + +it('reads a batch file as a list of changes or as {"changes": [...]}', function () { + $directory = rememberTemporaryProject(sys_get_temp_dir() . '/ichiloto-batch-' . bin2hex(random_bytes(4))); + mkdir($directory); + file_put_contents($directory . '/list.json', '[{"method": "maps.list", "params": {}}]'); + file_put_contents($directory . '/object.json', '{"changes": [{"method": "maps.list", "params": {}}]}'); + file_put_contents($directory . '/broken.json', '{"changes": 3}'); + + expect(ChangeBatch::readFile($directory . '/list.json'))->toBe([['method' => 'maps.list', 'params' => []]]) + ->and(ChangeBatch::readFile($directory . '/object.json'))->toBe([['method' => 'maps.list', 'params' => []]]) + ->and(fn() => ChangeBatch::readFile($directory . '/broken.json'))->toThrow(Ichiloto\Editor\Session\InvalidRequest::class, 'list of changes'); +}); diff --git a/tests/Unit/Session/MapAtlasAuthoringTest.php b/tests/Unit/Session/MapAtlasAuthoringTest.php index b9258b4..d151f65 100644 --- a/tests/Unit/Session/MapAtlasAuthoringTest.php +++ b/tests/Unit/Session/MapAtlasAuthoringTest.php @@ -4,7 +4,6 @@ use Ichiloto\Editor\Field\MapCategoryFields; use Ichiloto\Editor\Session\SessionRefusal; -use Ichiloto\Editor\Validation\MapValidator; use Ichiloto\Engine\Field\MapCategoryCatalog; use Ichiloto\Engine\Field\MapLandmarkCategory; @@ -67,7 +66,7 @@ function findAtlasRow($session, string $field, string $mapId = 'test-map'): arra it('reports an atlas whose parent no longer exists', function () { [, , $session] = createAtlasProject("'atlas' => ['parent' => ['map' => 'world', 'landmark' => 'old-gate']],"); $map = getSessionMap($session); - $problems = MapValidator::validate($map, ['test-map' => $map, 'world' => getSessionMap($session, 'world')], mapValidatorInventory()); + $problems = mapValidatorMessages($map, ['test-map' => $map, 'world' => getSessionMap($session, 'world')]); expect($problems)->toContain('Atlas: Atlas parent landmark does not exist on its referenced map.'); expect(findAtlasRow($session, 'parent')['optionLabels'])->toContain('world#old-gate · no longer declared'); }); diff --git a/tests/Unit/Session/MapLandmarkAuthoringTest.php b/tests/Unit/Session/MapLandmarkAuthoringTest.php index 7eb3f59..72e3993 100644 --- a/tests/Unit/Session/MapLandmarkAuthoringTest.php +++ b/tests/Unit/Session/MapLandmarkAuthoringTest.php @@ -4,7 +4,6 @@ use Ichiloto\Editor\Session\EditorSession; use Ichiloto\Editor\Session\SessionRefusal; -use Ichiloto\Editor\Validation\MapValidator; use Ichiloto\Engine\Field\MapLandmarkCatalog; it('describes an event from its own minimap row, as one undo step each way, and saves literal source', function () { @@ -148,10 +147,10 @@ it('reports declarations the Engine refuses, and still lets an author repair them', function () { [, , $session] = createLandmarkProject("'landmarks' => [['id' => 'gate', 'kind' => 'building', 'anchor' => ['event' => 'T']]],"); $map = getSessionMap($session); - expect(MapValidator::validate($map, ['test-map' => $map], mapValidatorInventory()))->toContain('Minimap landmarks: A transfer landmark must use passage, entrance or exit so ordinary routes remain visible.'); + expect(mapValidatorMessages($map, ['test-map' => $map]))->toContain('Minimap landmarks: A transfer landmark must use passage, entrance or exit so ordinary routes remain visible.'); expect(findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 0])['options'])->toBe(['passage', 'entrance', 'exit']); applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 0], 'entrance'); - expect(MapValidator::validate($map, ['test-map' => $map], mapValidatorInventory()))->not->toContain('Minimap landmarks: A transfer landmark must use passage, entrance or exit so ordinary routes remain visible.'); + expect(mapValidatorMessages($map, ['test-map' => $map]))->not->toContain('Minimap landmarks: A transfer landmark must use passage, entrance or exit so ordinary routes remain visible.'); }); it('describes a selected world object from its own inspector, through the same rows', function () { diff --git a/tests/Unit/UnsavedMapStageTest.php b/tests/Unit/UnsavedMapStageTest.php new file mode 100644 index 0000000..b4ed0d4 --- /dev/null +++ b/tests/Unit/UnsavedMapStageTest.php @@ -0,0 +1,67 @@ + CollisionType::SOLID, ' ' => CollisionType::NONE];\n"); + file_put_contents($root . '/assets/Data/system.php', ' ['player' => [ + 'destinationMap' => 'test-map', 'spawnPoint' => ['x' => 1, 'y' => 1], 'spawnSprite' => ['South'], + ]]], true) . ';'); + file_put_contents($root . '/assets/Maps/test-map/test-map.map.php', " array_values(array_filter(array_column($session->validateProject(['test-map'])['issues'], 'message'), + static fn(string $message): bool => str_contains($message, 'can be reached'))); + expect($unreachable())->toBe([]); + + // A wall across the corridor, not yet saved, cuts the chest off. + $map = $session->readMap('test-map'); + $session->paint('test-map', $map['revision'], $map['baseLayer'], [[3, 1]], '#'); + + expect($unreachable())->toBe(['No cell of event E (ChestEventTrigger) at (5, 1) can be reached, so it never fires.']) + ->and(sourceHashTree($root . '/assets'))->toBe($before) + ->and(countUnsavedStages())->toBe($stages); +}); + +it('stages only a workspace with unsaved maps, each written by its own writer beside copies of the rest', function () { + $root = unsavedStageProject(); + $workspace = ProjectWorkspace::fromProject($root); + expect(UnsavedMapStage::prepare($workspace))->toBeNull(); + + $map = $workspace->maps[array_search('test-map', $workspace->mapIds, true)]; + $map->setMapDataField(['name'], 'Renamed Unsaved'); + $stage = UnsavedMapStage::prepare($workspace); + + expect($stage)->not->toBeNull() + ->and((require $stage->assetRoot . '/Maps/test-map/test-map.data.php')['name'])->toBe('Renamed Unsaved') + ->and((require $root . '/assets/Maps/test-map/test-map.data.php')['name'])->toBe('Test Map') + // Other asset folders are read in place. + ->and(realpath($stage->assetRoot . '/Data/system.php'))->toBe(realpath($root . '/assets/Data/system.php')) + ->and($map->isDirty())->toBeTrue(); + + $stage->dispose(); + expect(is_dir(dirname($stage->assetRoot)))->toBeFalse() + ->and(is_file($root . '/assets/Data/system.php'))->toBeTrue(); +}); From e29c58cced7191723732d0bef5439f0ddef7f836 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:14:40 +0200 Subject: [PATCH 308/344] fix(session): checking a change set reports the save relocations committing would refuse --- src/Session/EditorSession.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 7a194be..6d7de1a 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -5218,15 +5218,16 @@ public function commitChanges(bool $relocateSaves = false, bool $checkOnly = fal $relocations = [...$relocations, ...$plan->describeMoves()]; } } - if ($blocking !== [] || $checkOnly || ($relocations !== [] && ! $relocateSaves)) { + $reason = match (true) { + $blocking !== [] => sprintf('%d error%s the player would meet.', count($blocking), count($blocking) === 1 ? '' : 's'), + $relocations !== [] && ! $relocateSaves => 'Saves may stand where a changed map is now solid; allow them to move to open floor, or change the map.', + default => null, + }; + if ($reason !== null || $checkOnly) { $unrestored = $this->rollBackChanges($changes); - return ['status' => $blocking === [] && $checkOnly ? 'checked' : 'refused', ...$outcome, 'relocated' => $relocations, - 'reason' => match (true) { - $blocking !== [] => sprintf('%d error%s the player would meet.', count($blocking), count($blocking) === 1 ? '' : 's'), - $checkOnly => null, - default => 'Saves stand where a changed map is now solid; allow them to move to open floor, or change the map.', - }, ...($unrestored > 0 ? ['unrestored' => $unrestored] : [])]; + return ['status' => $reason === null ? 'checked' : 'refused', ...$outcome, 'relocated' => $relocations, 'reason' => $reason, + ...($unrestored > 0 ? ['unrestored' => $unrestored] : [])]; } $this->changeSet = null; From 09dfc2ab2605cb4bf270d9c33b9f316f0637caf7 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:16:49 +0200 Subject: [PATCH 309/344] feat(session): paint a rectangle of glyphs from its rows in one edit (map.paintRegion) --- src/Session/EditorSession.php | 50 +++++++++++++++++++ src/Session/SessionHost.php | 11 ++++ tests/Unit/Session/PaintRegionSessionTest.php | 34 +++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 tests/Unit/Session/PaintRegionSessionTest.php diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 6d7de1a..cc8eac5 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1389,6 +1389,56 @@ public function paint(string $mapId, int $revision, string $layerId, array $cell $writes = array_map(static fn(array $cell): array => ['x' => (int) $cell[0], 'y' => (int) $cell[1], 'symbol' => $symbol, 'color' => $layerId === MapLayers::EVENT ? '' : $color], $cells); + return $this->paintWrites($map, $layerId, $writes, $choices, $label); + } + + /** + * Paints a rectangle of glyphs from its rows, one terminal cell per + * character with its top-left at x, y, and the tiles that follow each + * glyph, as one undo step: a room's rows rewritten in one edit. A space + * paints a blank cell. Questions and colours are answered as by + * {@see paint()}, `$choices` keyed by glyph. + * + * @param list $rows The glyph rows, top to bottom. + * @param array $choices + * @return array{status: 'applied', changed: int, revision: int}|array{status: 'question', glyph: string, roles: list} + * @throws SessionRefusal When the map is unknown, stale or refuses the edit, or a row leaves the map. + */ + public function paintRegion(string $mapId, int $revision, string $layerId, int $x, int $y, array $rows, ?string $color = null, + array $choices = [], string $label = 'Paint region'): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + if (! array_any($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $layerId)) { + throw new SessionRefusal(sprintf('%s has no layer %s.', $mapId, $layerId)); + } + $writes = []; + foreach (array_values($rows) as $dy => $row) { + foreach (mb_str_split($row) as $dx => $symbol) { + [$cellX, $cellY] = [$x + $dx, $y + $dy]; + if ($cellX < 0 || $cellY < 0 || $cellX >= $map->getWidth() || $cellY >= $map->getHeight()) { + throw new SessionRefusal(sprintf('Row %d reaches %d, %d, outside %s (%d x %d); nothing was painted.', + $dy + 1, $cellX, $cellY, $mapId, $map->getWidth(), $map->getHeight())); + } + $writes[] = ['x' => $cellX, 'y' => $cellY, 'symbol' => $symbol, 'color' => $layerId === MapLayers::EVENT ? '' : $color]; + } + } + + return $this->paintWrites($map, $layerId, $writes, $choices, $label); + } + + /** + * Paints glyph writes on a layer with the tiles that follow them, as one undo step. + * + * @param list $writes + * @param array $choices + * @return array{status: 'applied', changed: int, revision: int}|array{status: 'question', glyph: string, roles: list} + * @throws SessionRefusal When the map refuses the edit. + */ + private function paintWrites(ProjectMap $map, string $layerId, array $writes, array $choices, string $label): array + { try { $plan = CanvasEditor::plan($map, $layerId, $writes, $choices, true); if ($plan !== null && $plan['unresolved'] !== []) { diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index e45b85d..a547eae 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -280,6 +280,17 @@ private function dispatch(string $method, array $params): array self::readChoices($params), is_string($params['label'] ?? null) ? $params['label'] : 'Paint', ), + 'map.paintRegion' => $session->paintRegion( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + self::requireInt($params, 'x'), + self::requireInt($params, 'y'), + self::requireLines($params, 'rows'), + array_key_exists('color', $params) && (is_string($params['color']) || $params['color'] === null) ? $params['color'] : null, + self::readChoices($params), + is_string($params['label'] ?? null) ? $params['label'] : 'Paint region', + ), 'map.migrateOccupancy' => $session->migratePhysicalOccupancy( self::requireString($params, 'map'), self::requireInt($params, 'revision'), diff --git a/tests/Unit/Session/PaintRegionSessionTest.php b/tests/Unit/Session/PaintRegionSessionTest.php new file mode 100644 index 0000000..b78bad6 --- /dev/null +++ b/tests/Unit/Session/PaintRegionSessionTest.php @@ -0,0 +1,34 @@ +readMap('test-map'); + $buildings = array_find($map['layers'], static fn(array $layer): bool => $layer['name'] === 'buildings')['id']; + + $painted = $session->paintRegion('test-map', $map['revision'], $buildings, 1, 0, ['#+', ' #']); + + expect($painted['status'])->toBe('applied') + ->and($painted['changed'])->toBe(4); + $session->saveMap('test-map'); + expect(Ichiloto\Engine\Field\MapGridSource::readFile($root . '/assets/Maps/test-map/layers/04.buildings.map.php'))->toBe(" #+ \n # "); + + $session->undo(); + $session->saveMap('test-map'); + expect(Ichiloto\Engine\Field\MapGridSource::readFile($root . '/assets/Maps/test-map/layers/04.buildings.map.php'))->toBe(" / \n xx "); +}); + +it('refuses a region that leaves the map, painting none of it', function () { + $session = EditorSession::open(layeredMapProject()); + $map = $session->readMap('test-map'); + $buildings = array_find($map['layers'], static fn(array $layer): bool => $layer['name'] === 'buildings')['id']; + + expect(fn() => $session->paintRegion('test-map', $map['revision'], $buildings, 2, 1, ['###'])) + ->toThrow(SessionRefusal::class, 'Row 1 reaches 4, 1, outside test-map (4 x 2); nothing was painted.') + ->and($session->listUnsavedChanges())->toBe([]); +}); From b2057fb2846e6a32b5536df97759cc86b9995877 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:20:35 +0200 Subject: [PATCH 310/344] feat(world-objects): move an object with its covered cells and glyphs as one step (worldObjects.move) Moving furniture by its anchor alone left its glyphs and covered cells behind. worldObjects.move steps the anchor, the covered cells and the glyphs on the covered layer (with the tiles those glyphs draw) together, blanking the cells it leaves, and refuses a step that leaves the map, lands on another glyph or on another object's cells. --- src/Field/WorldObjectAuthoring.php | 38 +++++++++ src/Session/EditorSession.php | 83 +++++++++++++++++++ src/Session/SessionHost.php | 2 + .../Unit/Session/WorldObjectAuthoringTest.php | 32 +++++++ 4 files changed, 155 insertions(+) diff --git a/src/Field/WorldObjectAuthoring.php b/src/Field/WorldObjectAuthoring.php index 5def193..e6a6f98 100644 --- a/src/Field/WorldObjectAuthoring.php +++ b/src/Field/WorldObjectAuthoring.php @@ -470,6 +470,44 @@ public function placeObject(string $id, int $x, int $y, bool $coverage = false): return $this->writeEntries($entries, $coverage ? 'Toggle world-object coverage' : 'Place world-object anchor'); } + /** + * Moves an object's anchor and the cells it covers by a step, keeping its + * shape. The glyphs on those cells are the map's to move with it + * ({@see EditorSession::moveWorldObject()}). + * + * @throws InvalidArgumentException When the object is unknown, or the step leaves the map or reaches cells another object covers. + */ + public function moveObject(string $id, int $dx, int $dy): ?Command + { + $entries = self::readEntries($this->map); + $index = $this->getIndex($id); + $moved = static fn(array $cell): array => [$cell[0] + $dx, $cell[1] + $dy]; + $entries[$index]['anchor'] = ['x' => $entries[$index]['anchor']['x'] + $dx, 'y' => $entries[$index]['anchor']['y'] + $dy]; + if (isset($entries[$index]['covers'])) { + $layer = $entries[$index]['covers']['layer']; + $cells = array_map($moved, $entries[$index]['covers']['cells']); + foreach ($entries as $other => $entry) { + if ($other === $index || ($entry['covers']['layer'] ?? null) !== $layer) { + continue; + } + $taken = array_values(array_filter($cells, static fn(array $cell): bool => in_array($cell, $entry['covers']['cells'], true))); + if ($taken !== []) { + throw new InvalidArgumentException(sprintf('%d, %d on the %s layer is covered by world object %s; move it first.', + $taken[0][0], $taken[0][1], $layer, $entry['id'])); + } + } + $entries[$index]['covers']['cells'] = $cells; + } + foreach ([$entries[$index]['anchor'], ...array_map(static fn(array $cell): array => ['x' => $cell[0], 'y' => $cell[1]], + $entries[$index]['covers']['cells'] ?? [])] as $cell) { + if ($cell['x'] < 0 || $cell['y'] < 0 || $cell['x'] >= $this->map->getWidth() || $cell['y'] >= $this->map->getHeight()) { + throw new InvalidArgumentException(sprintf('Moving %s by %d, %d reaches %d, %d, outside the map.', $id, $dx, $dy, $cell['x'], $cell['y'])); + } + } + + return $this->writeEntries($entries, 'Move world object'); + } + private function writeRecords(ProjectRecordDatabase $records, string $label): ?Command { $entries = array_map(static fn($record): array => (array) $record->toArray(), $records->getRecords()); diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index cc8eac5..a7a035e 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -4207,6 +4207,89 @@ public function placeWorldObject(string $mapId, int $revision, string $id, int $ return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->placeObject($id, $x, $y, $coverage)); } + /** + * Moves a world object by a step with what shows it in the terminal, as + * one undo step: its anchor, the cells it covers, and the glyphs on those + * cells of its covered layer, with the tiles those glyphs draw. The cells + * it leaves become blank. Furniture moved this way leaves no glyph + * behind; an event that describes it moves with `event.move`. + * + * @return array{changed: bool, revision: int} + * @throws SessionRefusal When the map is unknown, stale or read-only, the object is unknown, or a destination cell + * leaves the map, holds another glyph or is covered by another object. + */ + public function moveWorldObject(string $mapId, int $revision, string $id, int $dx, int $dy): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + if ($dx === 0 && $dy === 0) { + return ['changed' => false, 'revision' => $this->getMapRevision($map)]; + } + try { + $entry = array_find(WorldObjectAuthoring::readEntries($map), static fn(array $entry): bool => ($entry['id'] ?? null) === $id) + ?? throw new InvalidArgumentException(sprintf('%s has no world object %s.', $mapId, $id)); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + + $commands = []; + $layerName = $entry['covers']['layer'] ?? null; + if (is_string($layerName)) { + $layerId = array_find($map->getLayers(), static fn(array $layer): bool => $layer['name'] === $layerName)['id'] + ?? throw new SessionRefusal(sprintf('World object %s covers layer %s, which %s does not have.', $id, $layerName, $mapId)); + $from = array_map(static fn(array $cell): array => [(int) $cell[0], (int) $cell[1]], $entry['covers']['cells']); + $to = array_map(static fn(array $cell): array => [$cell[0] + $dx, $cell[1] + $dy], $from); + $writes = []; + foreach ($from as $cell) { + if (! in_array($cell, $to, true)) { + $writes[] = ['x' => $cell[0], 'y' => $cell[1], 'symbol' => ' ', 'color' => '']; + } + } + foreach ($to as $index => [$x, $y]) { + if ($x < 0 || $y < 0 || $x >= $map->getWidth() || $y >= $map->getHeight()) { + throw new SessionRefusal(sprintf('Moving %s by %d, %d reaches %d, %d, outside %s.', $id, $dx, $dy, $x, $y, $mapId)); + } + if (! in_array([$x, $y], $from, true) && ($held = $map->getLayerSymbol($layerId, $x, $y)) !== ' ') { + throw new SessionRefusal(sprintf('%d, %d on the %s layer holds %s; clear it before moving %s there.', $x, $y, $layerName, $held, $id)); + } + [$fromX, $fromY] = $from[$index]; + $writes[] = ['x' => $x, 'y' => $y, 'symbol' => $map->getLayerSymbol($layerId, $fromX, $fromY), + 'color' => $map->getLayerColor($layerId, $fromX, $fromY) ?? '']; + } + try { + $plan = CanvasEditor::plan($map, $layerId, $writes, [], true); + if ($plan !== null && $plan['unresolved'] !== []) { + throw new SessionRefusal(sprintf('Glyph %s of %s could be several pieces; paint it where it goes, then move the object.', + (string) array_key_first($plan['unresolved']), $id)); + } + $glyphs = CanvasEditor::apply($map, $layerId, $plan['writes'] ?? $writes, 'Move world object', $plan['tiles'] ?? [])['command']; + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($glyphs !== null) { + $commands[] = $glyphs; + } + } + try { + $object = $this->createWorldObjectAuthoring($map)->moveObject($id, $dx, $dy); + } catch (InvalidArgumentException|\RuntimeException $error) { + foreach (array_reverse($commands) as $command) { + $command->undo(); + } + throw new SessionRefusal($error->getMessage(), previous: $error); + } + if ($object !== null) { + $commands[] = $object; + } + if ($commands !== []) { + $this->history->record(new CommandGroup('Move world object', $commands)); + } + + return ['changed' => $commands !== [], 'revision' => $this->getMapRevision($map)]; + } + public function moveWorldObjectVariant(string $mapId, int $revision, string $id, string $variantId, int $offset): array { return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->moveVariant($id, $variantId, $offset)); diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index a547eae..6292944 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -176,6 +176,8 @@ private function dispatch(string $method, array $params): array 'worldObjects.place' => $session->placeWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireString($params, 'id'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), is_bool($params['coverage'] ?? false) ? ($params['coverage'] ?? false) : throw new InvalidRequest('"coverage" must be a boolean.')), + 'worldObjects.move' => $session->moveWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'id'), self::requireInt($params, 'dx'), self::requireInt($params, 'dy')), 'worldObjects.moveVariant' => $session->moveWorldObjectVariant(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireString($params, 'id'), self::requireString($params, 'variant'), self::requireInt($params, 'offset')), 'canvas.exchange' => $session->exchangeAuthoringCanvas(self::requireString($params, 'map'), self::requireLines($params, 'events'), diff --git a/tests/Unit/Session/WorldObjectAuthoringTest.php b/tests/Unit/Session/WorldObjectAuthoringTest.php index e5b1f7b..42c3505 100644 --- a/tests/Unit/Session/WorldObjectAuthoringTest.php +++ b/tests/Unit/Session/WorldObjectAuthoringTest.php @@ -392,3 +392,35 @@ function readWorldObjectRow(EditorSession $session, string $field): ?array applyWorldObjectRow($session, 'variant3Terminalmode', 'its covered glyphs recoloured'); expect($session->readMap('test-map')['worldObjects'][0]['variants'][3]['terminal'])->toBe(['color' => 'white']); }); + +it('moves a world object with its covered glyphs as one undo step, leaving no glyph behind', function () { + [$root, $file] = createWorldObjectProject(); + file_put_contents($file, str_replace("'sprites2d' => null],", "'sprites2d' => null, + 'covers' => ['layer' => 'buildings', 'cells' => [[1, 1], [2, 1]], 'tileLayers' => []]],", (string) file_get_contents($file))); + $session = EditorSession::open($root); + $buildings = static fn(): string => implode("\n", array_map(static fn(int $y): string => implode('', array_map( + static fn(int $x): string => getSessionMap($session)->getLayerSymbol(array_find(getSessionMap($session)->getLayers(), + static fn(array $layer): bool => $layer['name'] === 'buildings')['id'], $x, $y), range(0, 3))), range(0, 1))); + $object = static fn(): array => array_find($session->readMap('test-map')['worldObjects'], static fn(array $entry): bool => $entry['id'] === 'fixture-prop'); + + $moved = $session->moveWorldObject('test-map', $session->readMap('test-map')['revision'], 'fixture-prop', 1, 0); + + expect($moved['changed'])->toBeTrue() + ->and($buildings())->toBe(" / \n xx") + ->and($object()['anchor'])->toBe(['x' => 2, 'y' => 1]) + ->and($object()['covers']['cells'])->toBe([[2, 1], [3, 1]]); + + $session->undo(); + expect($buildings())->toBe(" / \n xx ") + ->and($object()['anchor'])->toBe(['x' => 1, 'y' => 1]) + ->and($object()['covers']['cells'])->toBe([[1, 1], [2, 1]]); + + $revision = $session->readMap('test-map')['revision']; + expect(fn() => $session->moveWorldObject('test-map', $revision, 'fixture-prop', 0, -1)) + ->toThrow(SessionRefusal::class, '1, 0 on the buildings layer holds /; clear it before moving fixture-prop there.') + ->and(fn() => $session->moveWorldObject('test-map', $revision, 'fixture-prop', 2, 0)) + ->toThrow(SessionRefusal::class, 'outside test-map') + ->and(fn() => $session->moveWorldObject('test-map', $revision, 'no-such-prop', 1, 0)) + ->toThrow(SessionRefusal::class, 'test-map has no world object no-such-prop.') + ->and($buildings())->toBe(" / \n xx "); +}); From 96b91a360c1ec80ecfc865b8d5bc8249bf1e89de Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:25:07 +0200 Subject: [PATCH 311/344] fix(validation): judge each map's current data, unsaved edits included Validators read ProjectMap::$data, the data as loaded, so validating an open session missed unsaved events, NPCs, encounters and music: a chest made in a change set read as an undefined marker. They now read the map's editable data, which equals the loaded data in a fresh project. The quest reference finder had the same stale read. --- src/Database/QuestReferences.php | 2 +- src/Validation/EffectValidator.php | 8 ++-- src/Validation/ProjectValidator.php | 38 +++++++++---------- src/Validation/SaveCompatibilityValidator.php | 2 +- tests/Unit/Session/ChangeSetTest.php | 24 ++++++++++++ 5 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/Database/QuestReferences.php b/src/Database/QuestReferences.php index c89535e..c89375d 100644 --- a/src/Database/QuestReferences.php +++ b/src/Database/QuestReferences.php @@ -145,7 +145,7 @@ private function inMaps(string $questId): array $found = []; foreach ($this->workspace->maps as $map) { - foreach ((array) ($map->data['events'] ?? []) as $marker => $definition) { + foreach ((array) ($map->getEditableData()['events'] ?? []) as $marker => $definition) { if (! is_array($definition)) { continue; } diff --git a/src/Validation/EffectValidator.php b/src/Validation/EffectValidator.php index fc9acd7..b4036a8 100644 --- a/src/Validation/EffectValidator.php +++ b/src/Validation/EffectValidator.php @@ -155,7 +155,7 @@ public static function findUses(ProjectWorkspace $workspace, array &$issues = [] foreach ($workspace->maps as $map) { try { - foreach (FieldEffectManager::readDeclarations($map->data['fieldEffects'] ?? null) as $declaration) { + foreach (FieldEffectManager::readDeclarations($map->getEditableData()['fieldEffects'] ?? null) as $declaration) { $use($declaration['effect'], false, sprintf('%s field effect %s', $map->mapId, $declaration['id'])); } } catch (Throwable $failure) { @@ -223,7 +223,7 @@ private static function findStageUses(ProjectWorkspace $workspace, array &$issue }; foreach ($workspace->maps as $map) { - foreach ((array) ($map->data['events'] ?? []) as $marker => $definition) { + foreach ((array) ($map->getEditableData()['events'] ?? []) as $marker => $definition) { if (is_array($definition) && str_ends_with(strval($definition['class'] ?? ''), 'SleepEventTrigger')) { $name(((array) ($definition['data'] ?? []))['presentation'] ?? null, sprintf('map %s event %s', $map->mapId, strval($marker))); } @@ -305,10 +305,10 @@ private static function visitCommands(ProjectWorkspace $workspace, iterable $cin }; foreach ($workspace->maps as $map) { - foreach ((array) ($map->data['events'] ?? []) as $marker => $definition) { + foreach ((array) ($map->getEditableData()['events'] ?? []) as $marker => $definition) { $collect($definition, sprintf('map %s event %s', $map->mapId, strval($marker))); } - foreach ((array) ($map->data['npcs'] ?? []) as $index => $npc) { + foreach ((array) ($map->getEditableData()['npcs'] ?? []) as $index => $npc) { $id = is_array($npc) && is_string($npc['id'] ?? null) ? $npc['id'] : strval($index); $collect($npc, sprintf('map %s NPC %s', $map->mapId, $id)); } diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 774e16e..b2bc9e1 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -1733,7 +1733,7 @@ protected function checkRetiredMapData(ProjectMap $map): array protected function checkEventMarkers(ProjectMap $map): array { $issues = []; - $defined = array_keys((array) ($map->data['events'] ?? [])); + $defined = array_keys((array) ($map->getEditableData()['events'] ?? [])); $placed = $map->getPlacedEventMarkers(); foreach (array_diff($placed, $defined) as $marker) { @@ -1767,7 +1767,7 @@ protected function checkDoors(ProjectMap $map, array $mapIds): array { $issues = []; - foreach ((array) ($map->data['events'] ?? []) as $marker => $event) { + foreach ((array) ($map->getEditableData()['events'] ?? []) as $marker => $event) { if (! is_array($event)) { continue; } @@ -1797,7 +1797,7 @@ protected function checkDoors(ProjectMap $map, array $mapIds): array } // Edge triggers lead off the map the same way doors do. - foreach (array_values((array) ($map->data['triggers'] ?? [])) as $index => $trigger) { + foreach (array_values((array) ($map->getEditableData()['triggers'] ?? [])) as $index => $trigger) { $destination = is_array($trigger) ? trim(strval($trigger['destinationMap'] ?? '')) : ''; if ($destination === '') { @@ -1831,7 +1831,7 @@ protected function checkDoors(ProjectMap $map, array $mapIds): array */ protected function checkEncounters(ProjectMap $map, array $troops): array { - $authored = $map->data['encounters'] ?? null; + $authored = $map->getEditableData()['encounters'] ?? null; if ($authored === null) { return []; @@ -2010,7 +2010,7 @@ protected function checkEncounterRate(ProjectMap $map, MapEncounters $encounters */ protected function checkEncounterTiles(ProjectMap $map, MapEncounters $encounters): array { - $tiles = $map->data['encounters']['tiles'] ?? null; + $tiles = $map->getEditableData()['encounters']['tiles'] ?? null; if ($tiles === null) { return []; @@ -2071,14 +2071,14 @@ protected function checkEncounterTroopDuplicates(ProjectMap $map): array */ protected function checkNpcs(ProjectMap $map, ProjectWorkspace $workspace): array { - if (! array_key_exists('npcs', $map->data) || $map->data['npcs'] === null) { + if (! array_key_exists('npcs', $map->getEditableData()) || $map->getEditableData()['npcs'] === null) { return []; } - if (! is_array($map->data['npcs'])) { + if (! is_array($map->getEditableData()['npcs'])) { return [Issue::error( $map->mapId, - sprintf('The npcs block is %s, not a list of NPC entries.', get_debug_type($map->data['npcs'])), + sprintf('The npcs block is %s, not a list of NPC entries.', get_debug_type($map->getEditableData()['npcs'])), 'The game reads npcs as a list; every NPC on this map is dropped until it is one.' )]; } @@ -2088,7 +2088,7 @@ protected function checkNpcs(ProjectMap $map, ProjectWorkspace $workspace): arra $height = $map->getHeight(); $anchors = []; - foreach ($map->data['npcs'] as $index => $entry) { + foreach ($map->getEditableData()['npcs'] as $index => $entry) { $ordinal = is_int($index) ? $index + 1 : strval($index); if (! is_array($entry)) { @@ -2621,7 +2621,7 @@ protected function checkNpcUnknownFields(array $entry, string $where): array protected function checkNpcCollisions(ProjectMap $map, ProjectWorkspace $workspace, array $anchors): array { $issues = []; - $definitions = (array) ($map->data['events'] ?? []); + $definitions = (array) ($map->getEditableData()['events'] ?? []); $spawns = $this->spawnTilesOn($map, $workspace); $occupiedBy = ['fixed' => [], 'any' => []]; @@ -2735,7 +2735,7 @@ protected function spawnTilesOn(ProjectMap $map, ProjectWorkspace $workspace): a } foreach ($workspace->maps as $source) { - foreach ((array) ($source->data['events'] ?? []) as $marker => $definition) { + foreach ((array) ($source->getEditableData()['events'] ?? []) as $marker => $definition) { if (! is_array($definition)) { continue; } @@ -2776,7 +2776,7 @@ protected function checkNpcIdentities(ProjectMap $map): array $seen = []; $issues = []; - foreach ((array) ($map->data['npcs'] ?? []) as $index => $npc) { + foreach ((array) ($map->getEditableData()['npcs'] ?? []) as $index => $npc) { if (! is_array($npc)) { continue; } @@ -2894,7 +2894,7 @@ protected function checkMapSource(ProjectMap $map): array $names = []; - foreach ((array) ($map->data['npcs'] ?? []) as $npc) { + foreach ((array) ($map->getEditableData()['npcs'] ?? []) as $npc) { if (! is_array($npc) || trim(strval($npc['id'] ?? '')) !== '') { continue; } @@ -3826,11 +3826,11 @@ protected function checkMapReferences(ProjectWorkspace $workspace, array $known) protected function checkMapContentReferences(ProjectMap $map, array $known, array $npcIdsByMap, array $eventScripts): array { $issues = [ - ...$this->checkReference(strval($map->data['bgm'] ?? ''), 'bgm', 'track', $map->mapId, $known), + ...$this->checkReference(strval($map->getEditableData()['bgm'] ?? ''), 'bgm', 'track', $map->mapId, $known), ...$this->checkBgmVariants($map, $known), ]; - foreach ((array) ($map->data['events'] ?? []) as $marker => $definition) { + foreach ((array) ($map->getEditableData()['events'] ?? []) as $marker => $definition) { if (! is_array($definition)) { continue; } @@ -3892,7 +3892,7 @@ protected function checkMapContentReferences(ProjectMap $map, array $known, arra } } - foreach ((array) ($map->data['npcs'] ?? []) as $npc) { + foreach ((array) ($map->getEditableData()['npcs'] ?? []) as $npc) { if (! is_array($npc)) { continue; } @@ -3931,11 +3931,11 @@ protected function checkMapContentReferences(ProjectMap $map, array $known, arra */ protected function checkBgmVariants(ProjectMap $map, array $known): array { - if (! array_key_exists('bgmVariants', $map->data)) { + if (! array_key_exists('bgmVariants', $map->getEditableData())) { return []; } - $variants = $map->data['bgmVariants']; + $variants = $map->getEditableData()['bgmVariants']; $where = $map->mapId; if (! is_array($variants) || ! array_is_list($variants)) { @@ -4261,7 +4261,7 @@ protected function npcIdsByMap(ProjectWorkspace $workspace): array foreach ($workspace->maps as $map) { $idsByMap[$map->mapId] = []; - foreach ((array) ($map->data['npcs'] ?? []) as $npc) { + foreach ((array) ($map->getEditableData()['npcs'] ?? []) as $npc) { if (! is_array($npc)) { continue; } diff --git a/src/Validation/SaveCompatibilityValidator.php b/src/Validation/SaveCompatibilityValidator.php index db9ef39..342c334 100644 --- a/src/Validation/SaveCompatibilityValidator.php +++ b/src/Validation/SaveCompatibilityValidator.php @@ -416,7 +416,7 @@ private function oneShotEventIds(ProjectWorkspace $workspace): array continue; } - foreach (array_keys(is_array($map->data['events'] ?? null) ? $map->data['events'] : []) as $marker) { + foreach (array_keys(is_array($map->getEditableData()['events'] ?? null) ? $map->getEditableData()['events'] : []) as $marker) { if (is_string($marker)) { $ids[] = $map->mapId . ':' . $marker; } diff --git a/tests/Unit/Session/ChangeSetTest.php b/tests/Unit/Session/ChangeSetTest.php index ca9c38f..9e99d21 100644 --- a/tests/Unit/Session/ChangeSetTest.php +++ b/tests/Unit/Session/ChangeSetTest.php @@ -209,3 +209,27 @@ function changeSetMessages(array $outcome, string $severity = 'error'): array ->and(ChangeBatch::readFile($directory . '/object.json'))->toBe([['method' => 'maps.list', 'params' => []]]) ->and(fn() => ChangeBatch::readFile($directory . '/broken.json'))->toThrow(Ichiloto\Editor\Session\InvalidRequest::class, 'list of changes'); }); + +it('judges events made inside the change set: a chest with no reward is refused, one with a reward is written', function () { + $root = changeSetProject(); + writeItemRecords($root, new Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.tonic', name: 'Tonic', description: '', icon: 't', price: 5)); + $session = EditorSession::open($root); + $session->beginChanges(); + $created = $session->createEvent('test-map', $session->readMap('test-map')['revision'], [[0, 0]], 'Chest', 'C'); + $loot = static fn(string $field): array => ['target' => 'event', 'marker' => 'C', 'path' => ['data', $field]]; + $session->applyInspector('test-map', $created['revision'], $loot('lootType'), 'item'); + + $refused = $session->commitChanges(); + expect($refused['status'])->toBe('refused') + ->and(changeSetMessages($refused))->toContain('It has no item chosen, so it fails when opened.') + ->and(array_column($refused['issues'], 'map'))->each->toBe('test-map'); + + $session->beginChanges(); + $created = $session->createEvent('test-map', $session->readMap('test-map')['revision'], [[0, 0]], 'Chest', 'C'); + $revision = $session->applyInspector('test-map', $created['revision'], $loot('lootType'), 'item')['revision']; + $session->applyInspector('test-map', $revision, $loot('loot'), 'item.tonic'); + + $saved = $session->commitChanges(); + expect($saved['status'])->toBe('saved') + ->and((require $root . '/assets/Maps/test-map/test-map.data.php')['events']['C']['data']['loot'])->toBe('item.tonic'); +}); From d8e2cf73f0a82daeb2ae486ce96f2cea6cfaee2d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:26:32 +0200 Subject: [PATCH 312/344] docs(guides): change maps from a script through the editor session --- docs/guides/README.md | 7 + docs/guides/change-maps-from-a-script.md | 209 +++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 docs/guides/change-maps-from-a-script.md diff --git a/docs/guides/README.md b/docs/guides/README.md index 5764092..3fa2e8e 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -20,6 +20,13 @@ Read these in order the first time: 7. [Author a Summon Cutscene](author-a-summon-cutscene.md) — a frame-driven battle presentation: tracks, keyframes, cues, and the actor assignment. +## For Agents And Scripts + +- [Change Maps From A Script](change-maps-from-a-script.md): every map change + an agent or script makes goes through the editor session, as a batch with + `ichiloto map:apply` or over `ichiloto edit:host`, and is written only when + the project it leaves is valid. + ## What The Editor Can Do Today - Create, paint, duplicate, and delete maps, with full undo. diff --git a/docs/guides/change-maps-from-a-script.md b/docs/guides/change-maps-from-a-script.md new file mode 100644 index 0000000..1a53aa2 --- /dev/null +++ b/docs/guides/change-maps-from-a-script.md @@ -0,0 +1,209 @@ +# Change Maps From A Script + +This guide is for agents and scripts that change a project's maps without a +window. Every map change goes through the Editor, the same source-preserving +session the GUI editor uses, so it is written only when the project it leaves +is valid. Never edit map files (`*.data.php`, `*.event.php`, `layers/`, +`graphics/`) by hand: that path skips every check below, and it is how raw +glyphs, events left behind by moved furniture and chests with no reward +reached the game. + +## The Two Commands + +```bash +ichiloto map:apply changes.json --directory path/to/game +``` + +Applies a batch of changes as one change set. It writes them only when every +map they change has no errors and nothing new is broken anywhere else. +Otherwise it writes nothing, undoes what it did, and prints each problem with +its map, cells and fix. It exits `0` once written, `1` when refused. + +| Option | Does | +| --- | --- | +| `--check` | Reports what writing would find, and writes nothing. | +| `--relocate-saves` | Lets saves that may stand on cells the change makes solid move to open floor, as an author confirms in the editors. Without it such a change is refused. | +| `--json` | Prints the outcome as JSON: `status`, `maps`, `saved`, `issues`, `blocking`, `relocated`, `reason`. | + +```bash +ichiloto validate --directory path/to/game --map happyville/bsa-annex --map happyville/home +``` + +Reports only what is wrong on the named maps and exits `1` on any of their +errors. Use it to gate a commit on the maps it touched. Without `--map` it +checks the whole project. + +## What Counts As An Error + +An error is a defect the player would meet. The editors still save an +author's map with errors as a draft and show them; validation and +`map:apply` fail until they are fixed. + +- A gameplay glyph showing in the graphical field: a cell no tile covers, an + NPC without a field sprite, an NPC standing on a copy of its own glyph, a + map without a kind in a project with tilesets. +- Floor the player can reach with no tile, so the field shows the background. +- An event the player can never reach, or an arrival on a cell they cannot + stand on. +- A chest whose reward the Engine cannot give, or that has none chosen. +- An event marker with no definition, a door or edge to a map that does not + exist, a spawn point outside its map. +- Every other error `ichiloto validate` reports: unknown references, invalid + scripts and the rest. + +Warnings never refuse a change: tiles a piece draws whose glyph is gone, the +missing-art placeholder, see-through ground, a map nothing reaches yet. + +A change to a map that already has an error is refused until that error is +fixed too. Put the fix in the same batch. + +## The Batch + +A batch is a JSON list of editor session requests, or `{"changes": [...]}`: + +```json +{"changes": [ + {"method": "event.move", "params": {"map": "happyville/bsa-annex", "marker": "F", "dx": 8, "dy": 0}} +]} +``` + +Each change is a request of the session protocol: the GUI editor's own +requests, listed in `src/Session/SessionHost.php`. Two things are filled in +for you: + +- **Revisions.** A change naming a `map` without a `revision` edits that map + as it stands at that step. Give a `revision` only when the batch must refuse + a map that changed. +- **Questions.** Some requests ask before they act (inserting rows reviews + the coordinates they shift). Give the change an `"answer"` beside `params`; + the batch confirms what the question showed. A question without an answer + refuses the batch. + +Edits are made in order and held in memory. Inserting or removing rows, and +creating or duplicating a map, write their files when they run; a refused +batch undoes them. Inserting or removing rows needs no pending edits, so put +it first, or in its own batch. Saving, moving or deleting a map is refused +inside a batch: the batch saves, and moving or deleting a map clears the undo +history it relies on. + +## Finding Ids And Keys + +Read before you write. Over the protocol (see Driving The Session Directly) +or with the GUI editor open, these reads give every id a change needs: + +| Read | Gives | +| --- | --- | +| `maps.list` | Map ids and revisions. | +| `map.read {map}` | Layers (`id`, `name`), `baseLayer`, events, NPCs and world objects, unsaved changes included. | +| `inspector.read {map, event?}` | The map's rows, and an event's rows when `event` names its marker. Each row's `key` goes back unchanged to `inspector.apply`, `inspector.add` or `inspector.remove`. | +| `worldObjects.read {map, id}` | A world object's rows, for `worldObjects.apply`. | +| `npc.read {map, index, frame?}` | An NPC's rows, for `npc.apply`. | +| `pieces.list {map}` | The pieces the map's tileset can stamp. | +| `event.types` | Event type labels for `event.create`. | +| `references.list {map, category}` | The values a reference row accepts, such as `inventory` ids. | + +Layers are named by `id` in edits. An event row key looks like +`{"target": "event", "marker": "C", "path": ["data", "loot"]}`. + +## Which Request Makes Which Change + +| Change | Request | +| --- | --- | +| Paint glyphs | `map.paint {map, layer, cells, symbol, color?}`; a whole block of rows at once with `map.paintRegion {map, layer, x, y, rows}` | +| Draw the tiles a layer's glyphs need | `layer.drawTiles {map, layer}` | +| Paint or stamp tiles | `tiles.paint`, `tiles.stamp` | +| Stamp a tileset piece | `pieces.place {map, piece, from, to, color?}` | +| Layers | `layer.create`, `layer.rename`, `layer.remove`, `layer.reorder`, `layer.decoration`; tile layers `tileLayer.*` | +| Collision | `map.paintOccupancy`, `occupancy.stampPiece` | +| Resize | `map.insertLines`, `map.removeLines` (with an `answer`), or the map's size rows | +| Events | `event.create {map, cells, type, marker?}`, `event.move {map, marker, dx, dy}`, `event.bounds`, `event.destination`, `event.delete`; fields with `inspector.apply` | +| World objects | `worldObjects.create`, `worldObjects.move {map, id, dx, dy}`, `worldObjects.place`, `worldObjects.apply`, `worldObjects.add`, `worldObjects.remove`, `worldObjects.delete` | +| NPCs | `npc.create`, `npc.move`, `npc.apply`, `npc.add`, `npc.remove`, `npc.duplicate`, `npc.delete` | +| Map fields (name, kind, music, encounters, atlas, landmarks) | `inspector.apply` with the map row's key | +| New map | `map.create {name, kind, width, height}` | + +`worldObjects.move` moves an object with what shows it in the terminal: its +anchor, the cells it covers and their glyphs, with the tiles those glyphs +draw. Moving only the anchor (`worldObjects.place`) leaves its glyphs behind. + +## Worked Example: Move A World Object And Its Event + +Lockers move two cells right, and the inspection event that describes them +moves with them: + +```json +{"changes": [ + {"method": "worldObjects.move", "params": {"map": "town/annex", "id": "annex-lockers", "dx": 2, "dy": 0}}, + {"method": "event.move", "params": {"map": "town/annex", "marker": "F", "dx": 2, "dy": 0}} +]} +``` + +Run it with `--check` first. When the lockers would land on another glyph or +another object's cells, the move is refused and names the cell. When the event +would sit where the player cannot reach it, the batch is refused with that +error. + +## Worked Example: Stamp A Piece + +Read `pieces.list` for the piece's id, then stamp it with its top-left at +`from`. With `to` on the same cell it stamps one copy; a larger area stamps as +many whole copies as fit, and a connected piece (a wall) is drawn along the +area's outline: + +```json +{"changes": [ + {"method": "pieces.place", "params": {"map": "town/inn", "piece": "bookshelf", "from": [4, 2], "to": [4, 2]}} +]} +``` + +The piece writes its glyphs and its tiles together, so nothing shows as a raw +glyph. Stamping on cells that make the floor solid may need +`--relocate-saves`. + +## Worked Example: Place A Chest With A Reward + +Create the event, then set what it gives. The reward is an inventory id from +`references.list {map, category: "inventory"}`: + +```json +{"changes": [ + {"method": "event.create", "params": {"map": "town/cellar", "cells": [[10, 5]], "type": "Chest", "marker": "C"}}, + {"method": "inspector.apply", "params": {"map": "town/cellar", + "key": {"target": "event", "marker": "C", "path": ["data", "lootType"]}, "value": "item"}}, + {"method": "inspector.apply", "params": {"map": "town/cellar", + "key": {"target": "event", "marker": "C", "path": ["data", "loot"]}, "value": "item.tonic"}} +]} +``` + +Without the last change the batch is refused: the chest has no item chosen. + +## Driving The Session Directly + +For a long piece of work, an agent can hold one session open instead of +writing batches: start `ichiloto edit:host --directory path/to/game`, send one +JSON request per line on its standard input and read one response per line +from its standard output. The first request is +`{"id": 1, "method": "hello", "params": {"protocol": 1, "project": ""}}`. +Then: + +1. `changes.begin` opens a change set. It needs no pending edits. +2. Make the edits, passing each map's current `revision` (every edit returns + the new one), and read back what you need. +3. `project.validate {maps?}` reports the issues the project would have now, + unsaved edits included. +4. `changes.commit {relocateSaves?, check?}` writes the change set when it is + valid, or refuses it, writes nothing and undoes it. `changes.cancel` + undoes it. Closing the host with a change set open cancels it. + +A response is `{"id", "result"}` or `{"id", "error": {"kind", "message"}}`. +`map.save` and `project.saveAll` are the author's draft saves: they write a +map with errors. Agents commit change sets instead. + +## Before You Run + +- Do not apply a batch to a project an author has open in an editor with + unsaved changes to the same maps. The editor's own save refuses a file that + changed since it read it, so nothing is overwritten silently, but the + author's edits then have to be redone. +- `map:apply` validates the whole project twice, before and after. Expect it + to take as long as `ichiloto validate` does, twice. From f7549c20956cfd811a3bad8ce0aed38c7801099f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:27:34 +0200 Subject: [PATCH 313/344] feat(session): create, copy and move maps at an author-chosen folder id Map ids are folder paths under assets/Maps, and a map may live inside another map's folder or a folder that is no map. map.create takes an optional id, map.duplicate an optional to, and map.move an optional to that is reviewed with the same question, references report and confirmation as the derived path. Omitted params keep the old behaviour. MapFolder checks every chosen id before anything is written: slug segments only, nothing already at the folder, no file in the way, and no nested map named after a map's own layers or graphics folder. ProjectMap::moveTo uses the same check. Deleting a nested map now also removes its emptied parent folders up to the maps root, as a move does. --- src/Maps/MapFolder.php | 91 +++++++++ src/ProjectMap.php | 14 +- src/ProjectWorkspace.php | 74 ++++++-- src/Session/EditorSession.php | 69 ++++--- src/Session/SessionHost.php | 5 +- tests/Unit/Session/MapIdentitySessionTest.php | 175 ++++++++++++++++++ 6 files changed, 388 insertions(+), 40 deletions(-) create mode 100644 src/Maps/MapFolder.php create mode 100644 tests/Unit/Session/MapIdentitySessionTest.php diff --git a/src/Maps/MapFolder.php b/src/Maps/MapFolder.php new file mode 100644 index 0000000..7f2aa3b --- /dev/null +++ b/src/Maps/MapFolder.php @@ -0,0 +1,91 @@ +assertGridSourcesCanonical(); $mapsRoot = $this->getMapsRoot(); - $directory = $mapsRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $newRelativeId); - - if (is_dir($directory)) { - throw new RuntimeException("Map path {$newRelativeId} already exists."); + try { + $directory = MapFolder::requireFreeFolder($mapsRoot, $newRelativeId); + } catch (InvalidArgumentException $error) { + throw new RuntimeException($error->getMessage(), previous: $error); } $previousDirectory = $this->directory; diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index af0cbdc..a7f07ab 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -15,6 +15,7 @@ use Ichiloto\Editor\Database\RecordSchemaCatalog; use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Events\ProjectScriptCommands; +use Ichiloto\Editor\Maps\MapFolder; use Ichiloto\Editor\Storage\FileSetOperations; use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Editor\Storage\FilesystemFileSetOperations; @@ -564,25 +565,46 @@ public function getMapByIndex(int $index): ?ProjectMap */ public function createMap(?string $baseName = null, ?FileSetOperations $files = null, ?string $kind = null, int $width = 48, int $height = 18): string + { + $baseName = $this->getNextAvailableBaseName($baseName ?? 'new-map'); + + return $this->createMapAt($baseName, null, $files, $kind, $width, $height); + } + + /** + * Creates a new blank map at the id an author chose: a folder path under the maps root, nested inside another + * map's folder or any other folder ({@see MapFolder}). + * + * @param string $mapId The new map's id. + * @param string|null $displayName Its name; null names it after the id's last segment. + * @return string The created map id. + * @throws MapSourceRefusal When the size is no size, the id is not a map folder path or its folder is taken, or + * the files cannot be written. + */ + public function createMapAt(string $mapId, ?string $displayName = null, ?FileSetOperations $files = null, ?string $kind = null, + int $width = 48, int $height = 18): string { if ($width < 1 || $height < 1) { throw new MapSourceRefusal(sprintf('A map is at least 1 x 1 cells, not %d x %d.', $width, $height)); } - $mapsRoot = $this->getMapsRoot(); - $baseName = $this->getNextAvailableBaseName($baseName ?? 'new-map'); - $directory = $mapsRoot . DIRECTORY_SEPARATOR . $baseName; - ProjectMap::createBlank($directory, $baseName, self::humanizeBaseName($baseName), $width, $height, $files, $kind); + $directory = self::requireFreeMapFolder($this->getMapsRoot(), $mapId); + $baseName = basename($directory); + ProjectMap::createBlank($directory, $baseName, $displayName ?? self::humanizeBaseName($baseName), $width, $height, $files, $kind); - return $baseName; + return $mapId; } /** - * Duplicates the selected map with a smart sibling name. + * Duplicates the selected map with a smart sibling name, or at the id an author chose ({@see MapFolder}). The + * copy is named after its id's last segment. * * @param int $selectedMapIndex The selected map index. + * @param string|null $mapId The copy's id; null takes the next free `-copy` sibling. * @return string|null The duplicated map id. + * @throws MapSourceRefusal When the map is read-only, the chosen id is not a map folder path or its folder is + * taken, or the copy cannot be written. */ - public function duplicateMap(int $selectedMapIndex, ?FileSetOperations $files = null): ?string + public function duplicateMap(int $selectedMapIndex, ?FileSetOperations $files = null, ?string $mapId = null): ?string { $selectedMap = $this->getMapByIndex($selectedMapIndex); @@ -594,13 +616,17 @@ public function duplicateMap(int $selectedMapIndex, ?FileSetOperations $files = throw new MapSourceRefusal("{$selectedMap->mapId} is read-only: {$selectedMap->getGridSourceIssue()}"); } - $parentDirectory = dirname($selectedMap->directory); - $originalBaseName = basename($selectedMap->directory); - $baseName = $this->getNextAvailableSiblingBaseName($parentDirectory, $originalBaseName . '-copy'); - $directory = $parentDirectory . DIRECTORY_SEPARATOR . $baseName; + $mapsRoot = $this->getMapsRoot(); + if ($mapId === null) { + $parentDirectory = dirname($selectedMap->directory); + $directory = $parentDirectory . DIRECTORY_SEPARATOR + . $this->getNextAvailableSiblingBaseName($parentDirectory, basename($selectedMap->directory) . '-copy'); + } else { + $directory = self::requireFreeMapFolder($mapsRoot, $mapId); + } + $baseName = basename($directory); $selectedMap->duplicateTo($directory, $baseName, self::humanizeBaseName($baseName), $files); - $mapsRoot = $this->getMapsRoot(); $relativeDirectory = substr($directory, strlen($mapsRoot) + 1); return str_replace(DIRECTORY_SEPARATOR, '/', $relativeDirectory); @@ -637,15 +663,37 @@ public function deleteMap(int $selectedMapIndex, ?FileSetOperations $files = nul } $transaction->commit(); - foreach ([$selectedMap->directory . '/layers', $selectedMap->directory . '/' . MapGraphics::DIRECTORY, $selectedMap->directory] as $directory) { + foreach ([$selectedMap->directory . '/layers', $selectedMap->directory . '/' . MapGraphics::DIRECTORY] as $directory) { if (is_dir($directory) && array_diff(scandir($directory) ?: [], ['.', '..']) === []) { @rmdir($directory); } } + // A nested map's folders up to the maps root go with it while they are empty, as a move leaves them. + $mapsRoot = $this->getMapsRoot(); + for ($directory = $selectedMap->directory; str_starts_with($directory, $mapsRoot . DIRECTORY_SEPARATOR); $directory = dirname($directory)) { + if (is_dir($directory) && (array_diff(scandir($directory) ?: [], ['.', '..']) !== [] || ! @rmdir($directory))) { + break; + } + } return $selectedMap->mapId; } + /** + * The folder a map with a chosen id is written to, refused as the map transaction refuses a source it cannot + * take. + * + * @throws MapSourceRefusal When the id is not a map folder path or its folder is taken. + */ + private static function requireFreeMapFolder(string $mapsRoot, string $mapId): string + { + try { + return MapFolder::requireFreeFolder($mapsRoot, $mapId); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage(), previous: $error); + } + } + /** * Discovers maps from the folder-per-map layout. * diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 6d7de1a..55aa34c 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -76,6 +76,7 @@ use Ichiloto\Editor\Playtest\PlaytestStart; use Ichiloto\Editor\Maps\LayerEditor; use Ichiloto\Editor\Maps\LineRemoval; +use Ichiloto\Editor\Maps\MapFolder; use Ichiloto\Editor\Maps\MapLinePlan; use Ichiloto\Editor\Maps\MapLinePlanner; use Ichiloto\Editor\Maps\MapLayers; @@ -374,25 +375,31 @@ public function listMapKinds(): array } /** - * Creates a blank map at the maps root, written to disk at once as the - * terminal editor creates one, named after `$name` (`new-map` when - * empty) with a numbered suffix when that is taken. Every open map keeps - * its unsaved changes and the undo history stays. + * Creates a blank map, written to disk at once as the terminal editor + * creates one. Without `$mapId` it goes at the maps root, named after + * `$name` (`new-map` when empty) with a numbered suffix when that is + * taken. With one it goes at exactly that folder path, which may nest + * inside another map's folder ({@see MapFolder}), named `$name` or after + * the id's last segment. Every open map keeps its unsaved changes and the + * undo history stays. * + * @param string|null $mapId The new map's id; null derives one from the name. * @return array{map: string, maps: list>} - * @throws SessionRefusal When the kind is unknown, the size is not a size, or the files cannot be written. + * @throws SessionRefusal When the kind is unknown, the size is not a size, the id is not a free map folder path, or the files cannot be written. */ - public function createMap(?string $name, ?string $kind, int $width, int $height): array + public function createMap(?string $name, ?string $kind, int $width, int $height, ?string $mapId = null): array { if ($kind !== null && ! in_array($kind, array_column($this->listMapKinds(), 'value'), true)) { throw new SessionRefusal(sprintf('There is no map kind %s.', $kind)); } $baseName = $name === null || trim($name) === '' ? null : ProjectMap::slugify($name, ''); - if ($baseName === '') { + if ($mapId === null && $baseName === '') { throw new SessionRefusal(sprintf('"%s" has no letters or digits to name a map with.', $name)); } try { - $mapId = $this->workspace->createMap($baseName, kind: $kind, width: $width, height: $height); + $mapId = $mapId === null + ? $this->workspace->createMap($baseName, kind: $kind, width: $width, height: $height) + : $this->workspace->createMapAt($mapId, $name === null || trim($name) === '' ? null : trim($name), kind: $kind, width: $width, height: $height); } catch (MapSourceRefusal|RuntimeException $error) { throw new SessionRefusal(sprintf('The map was not created: %s', $error->getMessage()), previous: $error); } @@ -403,17 +410,20 @@ public function createMap(?string $name, ?string $kind, int $width, int $height) /** * Copies the current authored map through the workspace's transactional - * duplicate operation. Unsaved source edits are included in the copy; - * the original and every other loaded document and undo step remain. + * duplicate operation, beside it under the next free `-copy` name or at + * the folder path `$to` names ({@see MapFolder}). Unsaved source edits + * are included in the copy; the original and every other loaded document + * and undo step remain. * + * @param string|null $to The copy's id; null takes the next free sibling. * @return array{map: string, maps: list>} - * @throws SessionRefusal When the revision is stale or source cannot be copied safely. + * @throws SessionRefusal When the revision is stale, the id is not a free map folder path, or source cannot be copied safely. */ - public function duplicateMap(string $mapId, int $revision): array + public function duplicateMap(string $mapId, int $revision, ?string $to = null): array { $this->requireCurrentMap($mapId, $revision); try { - $created = $this->workspace->duplicateMap((int) array_search($mapId, $this->workspace->mapIds, true)) + $created = $this->workspace->duplicateMap((int) array_search($mapId, $this->workspace->mapIds, true), mapId: $to) ?? throw new RuntimeException('The map is no longer in the workspace.'); } catch (MapSourceRefusal|RuntimeException $error) { throw new SessionRefusal(sprintf('%s was not duplicated: %s', $mapId, $error->getMessage()), previous: $error); @@ -424,31 +434,48 @@ public function duplicateMap(string $mapId, int $revision): array } /** - * Explicitly moves a map to its metadata-derived path after the author - * reviews that exact destination. As in the TUI, references are not - * migrated and a successful move clears undo history naming retired maps. - * Ordinary saves never call this operation. + * Explicitly moves a map after the author reviews that exact + * destination: its metadata-derived path, or the folder path `$to` names + * ({@see MapFolder}), which may nest inside another map's folder. The + * first call asks, naming the destination and its references; the + * confirming call repeats `$to` and gives the reviewed `$destination`. + * As in the TUI, references are not migrated and a successful move + * clears undo history naming retired maps. Ordinary saves never call + * this operation. * + * @param string|null $destination The destination the author reviewed, repeated to confirm. + * @param string|null $to The id to move to; null proposes the metadata-derived path. * @return array - * @throws SessionRefusal When the revision or reviewed path changed, or source cannot move safely. + * @throws SessionRefusal When the revision or reviewed path changed, the id is not a free map folder path, or source cannot move safely. */ - public function moveMap(string $mapId, int $revision, ?string $destination = null, bool $confirmed = false): array + public function moveMap(string $mapId, int $revision, ?string $destination = null, bool $confirmed = false, ?string $to = null): array { $this->refuseDuringChangeSet('Moving a map clears the undo history'); $map = $this->requireCurrentMap($mapId, $revision); if ($map->getGridSourceIssue() !== null) { throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); } - $proposed = $map->getProposedMapId(); + $proposed = $to ?? $map->getProposedMapId(); if ($destination !== null && $destination !== $proposed) { - throw new SessionRefusal('The derived destination changed; review the proposed move again.'); + throw new SessionRefusal($to === null + ? 'The derived destination changed; review the proposed move again.' + : 'The reviewed destination is not the id to move to; review the move again.'); } if ($proposed === $mapId) { return ['status' => 'unchanged', 'map' => $mapId, 'destination' => $proposed, 'maps' => $this->describeMaps()]; } + if ($to !== null) { + // Refused before the question, so an author never reviews a move that cannot happen. + try { + MapFolder::requireFreeFolder($this->workspace->getMapsRoot(), $to); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal(sprintf('%s was not moved: %s', $mapId, $error->getMessage()), previous: $error); + } + } if (! $confirmed) { return [ 'status' => 'question', 'map' => $mapId, 'revision' => $revision, 'destination' => $proposed, + ...($to === null ? [] : ['to' => $to]), 'references' => new MapReferences($this->workspace)->describe($mapId), 'warning' => 'References are not migrated: doors, quests, saves and one-shot events naming the old id keep naming it. Moving clears the undo history. The original map folder is removed only after the new map validates.', ]; diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index e45b85d..45bd80e 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -141,11 +141,14 @@ private function dispatch(string $method, array $params): array is_string($params['kind'] ?? null) ? $params['kind'] : null, self::requireInt($params, 'width'), self::requireInt($params, 'height'), + self::readOptionalString($params, 'id'), ), - 'map.duplicate' => $session->duplicateMap(self::requireString($params, 'map'), self::requireInt($params, 'revision')), + 'map.duplicate' => $session->duplicateMap(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::readOptionalString($params, 'to')), 'map.move' => $session->moveMap( self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::readOptionalString($params, 'destination'), ($params['confirm'] ?? false) === true, + self::readOptionalString($params, 'to'), ), 'map.delete' => $session->deleteMap(self::requireString($params, 'map'), ($params['confirm'] ?? false) === true), 'map.insertLines' => $session->insertMapLines( diff --git a/tests/Unit/Session/MapIdentitySessionTest.php b/tests/Unit/Session/MapIdentitySessionTest.php new file mode 100644 index 0000000..0768e69 --- /dev/null +++ b/tests/Unit/Session/MapIdentitySessionTest.php @@ -0,0 +1,175 @@ + + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result'); + + return $request; +} + +it('creates a map at a chosen id inside another map\'s folder and inside folders that are no map', function () { + $root = mapGraphicsProject(); + $parent = sourceHashTree($root . '/assets/Maps/test-map'); + $request = mapIdentityHost($root); + + $nested = $request('map.create', ['name' => 'Wine Cellar', 'width' => 3, 'height' => 2, 'id' => 'test-map/cellar'])['result']; + $deep = $request('map.create', ['width' => 2, 'height' => 2, 'id' => 'harbour/licensing-office/front-desk'])['result']; + $derived = $request('map.create', ['name' => 'Wine Cellar', 'width' => 2, 'height' => 2])['result']; + + expect($nested['map'])->toBe('test-map/cellar') + ->and($deep['map'])->toBe('harbour/licensing-office/front-desk') + // Without an id the map still goes at the root under its name's slug. + ->and($derived['map'])->toBe('wine-cellar') + ->and(is_file($root . '/assets/Maps/test-map/cellar/cellar.data.php'))->toBeTrue() + ->and(is_file($root . '/assets/Maps/harbour/licensing-office/front-desk/front-desk.data.php'))->toBeTrue() + ->and(array_column($request('maps.list')['result'], 'name', 'id'))->toMatchArray([ + 'test-map/cellar' => 'Wine Cellar', 'harbour/licensing-office/front-desk' => 'Front Desk', 'test-map' => 'Test Map']) + // The parent map's own files are untouched; only the nested folder is new. + ->and(array_filter(sourceHashTree($root . '/assets/Maps/test-map'), + static fn(string $path): bool => ! str_starts_with($path, 'cellar/'), ARRAY_FILTER_USE_KEY))->toBe($parent); + + // A fresh scan finds every map where it was written, the parent still whole. + $loaded = ProjectWorkspace::fromProject($root); + expect($loaded->mapIds)->toContain('test-map', 'test-map/cellar', 'harbour/licensing-office/front-desk', 'wine-cellar') + ->and(array_find($loaded->maps, static fn($map): bool => $map->mapId === 'test-map')->getGridSourceIssue())->toBeNull() + ->and(array_find($loaded->maps, static fn($map): bool => $map->mapId === 'test-map/cellar')->getDisplayName())->toBe('Wine Cellar'); +}); + +it('refuses a chosen id that is no free map folder path before writing anything', function (string $id, string $reason) { + $root = mapGraphicsProject(); + file_put_contents($root . '/assets/Maps/notes.txt', 'Author notes'); + $session = EditorSession::open($root); + $before = sourceHashTree($root); + + expect(fn() => $session->createMap('Room', null, 2, 2, $id))->toThrow(SessionRefusal::class, $reason) + ->and(sourceHashTree($root))->toBe($before) + ->and(array_column($session->describeMaps(), 'id'))->toBe(['test-map']); +})->with([ + 'climbing out' => ['../outside', 'is not a folder path under assets/Maps'], + 'absolute' => ['/tmp/room', 'is not a folder path under assets/Maps'], + 'empty segment' => ['town//room', 'is not a folder path under assets/Maps'], + 'capitals' => ['Town/Room', 'is not a folder path under assets/Maps'], + 'dot folder' => ['town/.room', 'is not a folder path under assets/Maps'], + 'backslash' => ['town\\room', 'is not a folder path under assets/Maps'], + 'blank' => [' ', 'A map id cannot be empty.'], + 'an existing map' => ['test-map', 'Map path test-map already exists.'], + 'a map\'s layers' => ['test-map/layers', 'in the layers folder of the map at test-map'], + 'a map\'s graphics' => ['test-map/graphics', 'in the graphics folder of the map at test-map'], + 'a file' => ['notes.txt/room', 'is not a folder path'], +]); + +it('refuses a chosen id that runs through a file or lands on one', function () { + $root = mapGraphicsProject(); + file_put_contents($root . '/assets/Maps/annex', 'Not a folder'); + $session = EditorSession::open($root); + $before = sourceHashTree($root); + + expect(fn() => $session->createMap('Room', null, 2, 2, 'annex'))->toThrow(SessionRefusal::class, 'is a file') + ->and(fn() => $session->createMap('Room', null, 2, 2, 'annex/room'))->toThrow(SessionRefusal::class, 'passes through a file, annex') + ->and(sourceHashTree($root))->toBe($before); +}); + +it('copies a map to a chosen id and refuses a taken one', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + $session->createMap('Hall', null, 2, 2); + $revision = $session->readMap('test-map')['revision']; + $before = sourceHashTree($root); + + expect(fn() => $session->duplicateMap('test-map', $revision, 'hall'))->toThrow(SessionRefusal::class, 'Map path hall already exists.') + ->and(fn() => $session->duplicateMap('test-map', $revision, 'hall/layers'))->toThrow(SessionRefusal::class, 'layers folder') + ->and(sourceHashTree($root))->toBe($before); + + $request = mapIdentityHost($root); + $copy = $request('map.duplicate', ['map' => 'test-map', 'revision' => $request('map.read', ['map' => 'test-map'])['result']['revision'], + 'to' => 'hall/back-room'])['result']; + expect($copy['map'])->toBe('hall/back-room') + ->and(file_get_contents($root . '/assets/Maps/hall/back-room/back-room.data.php'))->toContain('// Authored metadata remains authored.', "'Back Room'") + ->and(readTileRows($root . '/assets/Maps/hall/back-room/graphics/01.floor.tiles.php'))->toBe(readTileRows($root . '/assets/Maps/test-map/graphics/01.floor.tiles.php')) + ->and($request('map.duplicate', ['map' => 'test-map', 'revision' => 0, 'to' => 3])['error']['kind'])->toBe('request'); +}); + +it('moves a map to a chosen id after the same review, naming references it does not migrate', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + $session->createMap('Hall', null, 3, 3); + $data = $root . '/assets/Maps/hall/hall.data.php'; + file_put_contents($data, str_replace("'events' => []", "'events' => ['D' => ['class' => 'Door', 'data' => ['destinationMap' => 'test-map']]]", (string) file_get_contents($data))); + $request = mapIdentityHost($root); + $revision = $request('map.read', ['map' => 'test-map'])['result']['revision']; + $before = sourceHashTree($root); + + $question = $request('map.move', ['map' => 'test-map', 'revision' => $revision, 'to' => 'hall/workshop'])['result']; + expect($question)->toMatchArray(['status' => 'question', 'destination' => 'hall/workshop', 'to' => 'hall/workshop', 'references' => ['event D on hall']]) + ->and($question['warning'])->toContain('References are not migrated') + ->and(sourceHashTree($root))->toBe($before) + // The confirmation names the destination it reviewed, as a derived move's does. + ->and($request('map.move', ['map' => 'test-map', 'revision' => $revision, 'to' => 'hall/workshop', 'confirm' => true])['error']['message']) + ->toContain('Review the proposed destination') + ->and($request('map.move', ['map' => 'test-map', 'revision' => $revision, 'to' => 'hall/workshop', 'destination' => 'hall/other', 'confirm' => true])['error']['message']) + ->toContain('reviewed destination is not the id to move to') + // A destination that cannot be written is refused before any question. + ->and($request('map.move', ['map' => 'test-map', 'revision' => $revision, 'to' => 'hall'])['error']['message'])->toContain('Map path hall already exists.') + ->and($request('map.move', ['map' => 'test-map', 'revision' => $revision, 'to' => '../hall'])['error']['message'])->toContain('not a folder path') + ->and($request('map.move', ['map' => 'test-map', 'revision' => $revision, 'to' => 'test-map'])['result']['status'])->toBe('unchanged') + ->and(sourceHashTree($root))->toBe($before); + + $moved = $request('map.move', ['map' => 'test-map', 'revision' => $revision, 'to' => 'hall/workshop', + 'destination' => $question['destination'], 'confirm' => true])['result']; + expect($moved)->toMatchArray(['status' => 'moved', 'previousMap' => 'test-map', 'map' => 'hall/workshop']) + ->and(file_get_contents($root . '/assets/Maps/hall/workshop/workshop.data.php'))->toContain('// Authored metadata remains authored.') + ->and(is_dir($root . '/assets/Maps/test-map'))->toBeFalse() + ->and(is_file($data))->toBeTrue(); + $loaded = ProjectWorkspace::fromProject($root); + expect($loaded->mapIds)->toBe(['hall', 'hall/workshop']) + ->and(array_find($loaded->maps, static fn($map): bool => $map->mapId === 'hall')->getEventDefinitions()['D']['data']['destinationMap'])->toBe('test-map'); +}); + +it('moves a nested map out of its folders and deletes one, leaving no empty folders behind', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + $session->createMap('Front Desk', null, 2, 2, 'harbour/office/front-desk'); + $session->createMap('Back Room', null, 2, 2, 'harbour/office/back-room'); + $question = $session->moveMap('harbour/office/front-desk', $session->readMap('harbour/office/front-desk')['revision'], to: 'front-desk'); + $session->moveMap('harbour/office/front-desk', $question['revision'], $question['destination'], true, 'front-desk'); + + expect(is_dir($root . '/assets/Maps/harbour/office/front-desk'))->toBeFalse() + ->and(is_dir($root . '/assets/Maps/harbour/office/back-room'))->toBeTrue(); + $session->deleteMap('harbour/office/back-room'); + expect(is_dir($root . '/assets/Maps/harbour'))->toBeFalse() + ->and(is_dir($root . '/assets/Maps'))->toBeTrue() + ->and(array_column($session->describeMaps(), 'id'))->toBe(['front-desk', 'test-map']); +}); + +it('takes a nested map made inside a cancelled change set away with its folders', function () { + $root = mapGraphicsProject(); + $session = EditorSession::open($root); + $before = sourceHashTree($root); + $session->beginChanges(); + $session->createMap('Cellar', null, 2, 2, 'test-map/cellar'); + $session->createMap('Gate', null, 2, 2, 'harbour/gate'); + expect(is_dir($root . '/assets/Maps/harbour/gate'))->toBeTrue(); + + $session->cancelChanges(); + expect(sourceHashTree($root))->toBe($before) + ->and(is_dir($root . '/assets/Maps/harbour'))->toBeFalse() + ->and(is_dir($root . '/assets/Maps/test-map/cellar'))->toBeFalse() + ->and(array_column($session->describeMaps(), 'id'))->toBe(['test-map']); +}); From b8afac48a6e2d08b53d27f6a00fcb0df537941ac Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:29:17 +0200 Subject: [PATCH 314/344] fix(world-objects): a moved object's glyphs keep the piece they play, and the batch says how to choose one worldObjects.move reads each covered glyph's piece role from its tiles and keeps it; a glyph the object's own art presents moves without tiles. A batch refused by a piece question names the role keys to give in choices. --- docs/guides/change-maps-from-a-script.md | 3 ++ src/Session/ChangeBatch.php | 10 ++++++- src/Session/EditorSession.php | 29 +++++++++++++++---- src/Session/SessionHost.php | 2 +- tests/Unit/Session/ChangeSetTest.php | 18 ++++++++++++ .../Unit/Session/WorldObjectAuthoringTest.php | 22 ++++++++++++++ 6 files changed, 76 insertions(+), 8 deletions(-) diff --git a/docs/guides/change-maps-from-a-script.md b/docs/guides/change-maps-from-a-script.md index 1a53aa2..2aa1b7a 100644 --- a/docs/guides/change-maps-from-a-script.md +++ b/docs/guides/change-maps-from-a-script.md @@ -78,6 +78,9 @@ for you: the coordinates they shift). Give the change an `"answer"` beside `params`; the batch confirms what the question showed. A question without an answer refuses the batch. +- **Pieces.** When several pieces draw a glyph you paint, the batch is + refused with their role keys. Answer as the editors do, in the change's + params: `"choices": {"x": "crate:0:0"}`, or `null` for no tiles. Edits are made in order and held in memory. Inserting or removing rows, and creating or duplicating a map, write their files when they run; a refused diff --git a/src/Session/ChangeBatch.php b/src/Session/ChangeBatch.php index 7d6f833..106d9c6 100644 --- a/src/Session/ChangeBatch.php +++ b/src/Session/ChangeBatch.php @@ -20,7 +20,9 @@ * Two conveniences keep a batch writable by hand: a change naming a `map` * without a `revision` edits the map as it stands at that step, and a change * whose method asks a question (inserting rows, for one) is answered with - * the change's own `answer`, confirming what the question showed. + * the change's own `answer`, confirming what the question showed. A glyph + * that several pieces could draw is answered as the editors answer it, by + * `choices` in the change's params; the refusal names the keys. */ final class ChangeBatch { @@ -114,6 +116,12 @@ private function applyChange(mixed $change): ?array try { $result = $this->request($method, $params); + if (($result['status'] ?? null) === 'question' && is_string($result['glyph'] ?? null)) { + // A glyph several pieces draw is answered in the change's own params, as an editor answers it. + return ['method' => $method, 'message' => sprintf('Glyph %s could be several pieces; give the change "choices": {"%s": key}, one of %s.', + $result['glyph'], $result['glyph'], implode(', ', array_map(static fn(array $role): string => "{$role['key']} ({$role['label']})", + (array) ($result['roles'] ?? []))))]; + } if (($result['status'] ?? null) === 'question') { $answer = is_array($change) ? ($change['answer'] ?? null) : null; if (! is_string($answer)) { diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index a7a035e..f445461 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -4212,13 +4212,18 @@ public function placeWorldObject(string $mapId, int $revision, string $id, int $ * one undo step: its anchor, the cells it covers, and the glyphs on those * cells of its covered layer, with the tiles those glyphs draw. The cells * it leaves become blank. Furniture moved this way leaves no glyph - * behind; an event that describes it moves with `event.move`. + * behind; an event that describes it moves with `event.move`. Each glyph + * keeps the piece it plays where it stands, read from its tiles, and a + * glyph no piece draws there (the object's own art presents it) moves + * without tiles. A role still in doubt is asked about as painting asks, + * and answered in `$choices` by glyph. * - * @return array{changed: bool, revision: int} + * @param array $choices + * @return array{changed: bool, revision: int}|array{status: 'question', glyph: string, roles: list} * @throws SessionRefusal When the map is unknown, stale or read-only, the object is unknown, or a destination cell * leaves the map, holds another glyph or is covered by another object. */ - public function moveWorldObject(string $mapId, int $revision, string $id, int $dx, int $dy): array + public function moveWorldObject(string $mapId, int $revision, string $id, int $dx, int $dy, array $choices = []): array { $map = $this->requireCurrentMap($mapId, $revision); if ($map->getGridSourceIssue() !== null) { @@ -4242,6 +4247,7 @@ public function moveWorldObject(string $mapId, int $revision, string $id, int $d $from = array_map(static fn(array $cell): array => [(int) $cell[0], (int) $cell[1]], $entry['covers']['cells']); $to = array_map(static fn(array $cell): array => [$cell[0] + $dx, $cell[1] + $dy], $from); $writes = []; + $assigned = []; foreach ($from as $cell) { if (! in_array($cell, $to, true)) { $writes[] = ['x' => $cell[0], 'y' => $cell[1], 'symbol' => ' ', 'color' => '']; @@ -4257,12 +4263,23 @@ public function moveWorldObject(string $mapId, int $revision, string $id, int $d [$fromX, $fromY] = $from[$index]; $writes[] = ['x' => $x, 'y' => $y, 'symbol' => $map->getLayerSymbol($layerId, $fromX, $fromY), 'color' => $map->getLayerColor($layerId, $fromX, $fromY) ?? '']; + $role = CanvasEditor::findPlayedRole($map, $layerId, $fromX, $fromY); + if ($role !== null) { + $assigned["{$x},{$y}"] = $role->key; + } else { + // A glyph no piece's tiles draw here (the object's own art presents it) moves without tiles. + $choices = [$map->getLayerSymbol($layerId, $fromX, $fromY) => null, ...$choices]; + } } try { - $plan = CanvasEditor::plan($map, $layerId, $writes, [], true); + $plan = CanvasEditor::plan($map, $layerId, $writes, $choices, true, assigned: $assigned); if ($plan !== null && $plan['unresolved'] !== []) { - throw new SessionRefusal(sprintf('Glyph %s of %s could be several pieces; paint it where it goes, then move the object.', - (string) array_key_first($plan['unresolved']), $id)); + $glyph = (string) array_key_first($plan['unresolved']); + + return ['status' => 'question', 'glyph' => $glyph, 'roles' => array_map( + static fn(PieceRole $role): array => ['key' => $role->key, 'label' => $role->label], + $plan['unresolved'][$glyph], + )]; } $glyphs = CanvasEditor::apply($map, $layerId, $plan['writes'] ?? $writes, 'Move world object', $plan['tiles'] ?? [])['command']; } catch (MapSourceRefusal $refusal) { diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 6292944..e4dbfda 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -177,7 +177,7 @@ private function dispatch(string $method, array $params): array self::requireString($params, 'id'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), is_bool($params['coverage'] ?? false) ? ($params['coverage'] ?? false) : throw new InvalidRequest('"coverage" must be a boolean.')), 'worldObjects.move' => $session->moveWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), - self::requireString($params, 'id'), self::requireInt($params, 'dx'), self::requireInt($params, 'dy')), + self::requireString($params, 'id'), self::requireInt($params, 'dx'), self::requireInt($params, 'dy'), self::readChoices($params)), 'worldObjects.moveVariant' => $session->moveWorldObjectVariant(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireString($params, 'id'), self::requireString($params, 'variant'), self::requireInt($params, 'offset')), 'canvas.exchange' => $session->exchangeAuthoringCanvas(self::requireString($params, 'map'), self::requireLines($params, 'events'), diff --git a/tests/Unit/Session/ChangeSetTest.php b/tests/Unit/Session/ChangeSetTest.php index 9e99d21..84c9587 100644 --- a/tests/Unit/Session/ChangeSetTest.php +++ b/tests/Unit/Session/ChangeSetTest.php @@ -233,3 +233,21 @@ function changeSetMessages(array $outcome, string $severity = 'error'): array expect($saved['status'])->toBe('saved') ->and((require $root . '/assets/Maps/test-map/test-map.data.php')['events']['C']['data']['loot'])->toBe('item.tonic'); }); + +it('asks a batch to choose the piece a glyph plays when several draw it, and paints it once chosen', function () { + $root = changeSetProject(); + writeTestTileset($root, pieces: [ + 'window' => ['name' => 'Window', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['decor' => ['5']]], + 'crate' => ['name' => 'Crate', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['decor' => ['6']]], + ]); + $buildings = changeSetLayer(EditorSession::open($root), 'buildings'); + $paint = ['method' => 'map.paint', 'params' => ['map' => 'test-map', 'layer' => $buildings, 'cells' => [[3, 1]], 'symbol' => 'x']]; + + $asked = ChangeBatch::apply($root, [$paint], checkOnly: true); + expect($asked['status'])->toBe('failed') + ->and($asked['message'])->toContain('Glyph x could be several pieces; give the change "choices": {"x": key}'); + + $paint['params']['choices'] = ['x' => 'crate:0:0']; + expect(ChangeBatch::apply($root, [$paint])['status'])->toBe('saved') + ->and(MapGridSource::readFile($root . '/assets/Maps/test-map/graphics/02.decor.tiles.php'))->toBe("0 5 0 0\n0 5 5 6"); +}); diff --git a/tests/Unit/Session/WorldObjectAuthoringTest.php b/tests/Unit/Session/WorldObjectAuthoringTest.php index 42c3505..3143bd9 100644 --- a/tests/Unit/Session/WorldObjectAuthoringTest.php +++ b/tests/Unit/Session/WorldObjectAuthoringTest.php @@ -424,3 +424,25 @@ function readWorldObjectRow(EditorSession $session, string $field): ?array ->toThrow(SessionRefusal::class, 'test-map has no world object no-such-prop.') ->and($buildings())->toBe(" / \n xx "); }); + +it('moves each covered glyph as the piece it plays, and one the object presents without tiles', function (string $decor, string $moved) { + [$root, $file] = createWorldObjectProject(); + writeTestTileset($root, pieces: [ + 'window' => ['name' => 'Window', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['decor' => ['5']]], + 'crate' => ['name' => 'Crate', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['decor' => ['6']]], + ]); + writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', $decor); + file_put_contents($file, str_replace("'sprites2d' => null],", "'sprites2d' => null, + 'covers' => ['layer' => 'buildings', 'cells' => [[1, 1], [2, 1]], 'tileLayers' => []]],", (string) file_get_contents($file))); + $session = EditorSession::open($root); + + expect($session->moveWorldObject('test-map', $session->readMap('test-map')['revision'], 'fixture-prop', 1, 0)['changed'])->toBeTrue(); + $session->saveMap('test-map'); + + expect(Ichiloto\Engine\Field\MapGridSource::readFile($root . '/assets/Maps/test-map/graphics/02.decor.tiles.php'))->toBe($moved); +})->with([ + // Its tiles show each x is a window: the windows move with them. + 'pieces' => ["0 0 0 0\n0 5 5 0", "0 0 0 0\n0 0 5 5"], + // No tile tells which piece: the object presents them, and no tiles follow. + 'object art' => ["0 0 0 0\n0 0 0 0", "0 0 0 0\n0 0 0 0"], +]); From 9652aee3e9d5f8608927dd01fa178a98f585fd3a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:30:29 +0200 Subject: [PATCH 315/344] feat(inspector): choose and rename a landmark's stable id Each landmark gets an Id row. A new landmark still starts as its anchor's id or landmark-N, and the author renames it through the row, checked against the Engine's landmark id pattern and the map's other landmarks. Other maps name a landmark as their atlas entry, so a rename rewrites atlas.parent.landmark on every map naming it in the same undo step. When one of those maps cannot take the edit in its source, nothing is renamed. The name's translation key is kept. Saves remember discoveries by id, so a save that learned the landmark under its old id learns it again. inspector.apply now reports the revisions of other maps an edit changed. --- src/Field/MapAtlas.php | 16 ++- src/Field/MapLandmarks.php | 28 ++++- src/Inspector/MapInspector.php | 41 +++++++ src/Inspector/MapLandmarkFields.php | 30 ++++- src/Session/EditorSession.php | 17 ++- .../Unit/Session/MapLandmarkIdentityTest.php | 115 ++++++++++++++++++ 6 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/Session/MapLandmarkIdentityTest.php diff --git a/src/Field/MapAtlas.php b/src/Field/MapAtlas.php index 5964e3c..cb85f4d 100644 --- a/src/Field/MapAtlas.php +++ b/src/Field/MapAtlas.php @@ -196,13 +196,27 @@ public static function resolveGlobal(ProjectMap $map, array $block, Closure $fin * @return list */ public static function describeParentReferences(iterable $maps, string $mapId, ?string $landmarkId = null): array + { + return array_map(static function (ProjectMap $map): string { + $landmark = $map->getMapDataField([self::KEY, 'parent', 'landmark']); + return sprintf('the atlas parent of %s (entry %s)', $map->mapId, is_string($landmark) ? $landmark : '?'); + }, self::findChildMaps($maps, $mapId, $landmarkId)); + } + + /** + * The maps that name a map as their atlas parent, or a landmark on it as their entry. + * + * @param iterable $maps + * @return list + */ + public static function findChildMaps(iterable $maps, string $mapId, ?string $landmarkId = null): array { $found = []; foreach ($maps as $map) { $parent = $map->getMapDataField([self::KEY, 'parent']); if (!is_array($parent) || ($parent['map'] ?? null) !== $mapId) { continue; } if ($landmarkId !== null && ($parent['landmark'] ?? null) !== $landmarkId) { continue; } - $found[] = sprintf('the atlas parent of %s (entry %s)', $map->mapId, is_string($parent['landmark'] ?? null) ? $parent['landmark'] : '?'); + $found[] = $map; } return $found; } diff --git a/src/Field/MapLandmarks.php b/src/Field/MapLandmarks.php index 5bf0313..a4c70b8 100644 --- a/src/Field/MapLandmarks.php +++ b/src/Field/MapLandmarks.php @@ -209,8 +209,7 @@ public function isRouteAnchor(array $anchor): bool * Describes a place: a declaration for an anchor no landmark describes yet. A route starts as the passage its * automatic marker already is, so describing it changes nothing on the minimap until an author edits it; any * other anchor starts as the kind chosen, or a landmark. Its id is the anchor's own stable id where that is a - * valid landmark id, otherwise the next free `landmark-N`, and never changes afterwards: saves remember - * discoveries by it. + * valid landmark id, otherwise the next free `landmark-N`, until the author chooses another ({@see setId()}). * * @return array{entries: list>, id: string} */ @@ -251,6 +250,31 @@ public function setAnchor(int $index, array $anchor): array return $entries; } + /** + * Gives a landmark the stable id an author chose: one the Engine accepts, used by no other landmark on the map. + * Its translation key stays, since translations are filed under it. Saves remember discoveries by id, so a save + * that learned the landmark under its old id learns it again; other maps whose atlas entry it is follow the + * rename ({@see MapAtlas::findChildMaps()}). + * + * @return list> + * @throws InvalidArgumentException When the id is not one the Engine accepts, or another landmark here has it. + */ + public function setId(int $index, string $id): array + { + $id = trim($id); + if (preg_match(MapLandmarkCatalog::ID_PATTERN, $id) !== 1) { + throw new InvalidArgumentException(sprintf('"%s" is not a landmark id: a lowercase letter, then up to 63 lowercase letters, digits, dots, underscores or hyphens.', $id)); + } + foreach ($this->entries as $other => $entry) { + if ($other !== $index && ($entry['id'] ?? null) === $id) { + throw new InvalidArgumentException(sprintf('Another landmark on %s is already %s.', $this->map->mapId, $id)); + } + } + $entries = $this->entries; + $entries[$index]['id'] = $id; + return $entries; + } + /** @return list> */ public function setKind(int $index, MapLandmarkKind $kind): array { diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index ae2bb0f..80db399 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -25,7 +25,9 @@ use Ichiloto\Editor\Field\MapAtlas; use Ichiloto\Editor\Field\MapLandmarks; use Ichiloto\Editor\History\Command; +use Ichiloto\Editor\History\CommandGroup; use Ichiloto\Editor\History\GenericCommand; +use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\Validation\ChestRewardValidator; use Ichiloto\Editor\Validation\Issue; @@ -355,6 +357,9 @@ public function apply(ProjectMap $map, array $field, string $rawValue): ?Command */ private function applyLandmarkValue(ProjectMap $map, array $field, mixed $value): ?Command { + if (($field['target'] ?? null) === MapLandmarkFields::TARGET && ($field['field'] ?? null) === MapLandmarkFields::ID_FIELD) { + return $this->renameLandmark($map, $field, (string) $value); + } try { $entries = $this->getLandmarkFields()->applyValue($map, $field, $value); } catch (InvalidArgumentException $error) { @@ -364,6 +369,42 @@ private function applyLandmarkValue(ProjectMap $map, array $field, mixed $value) return $entries === null ? null : $this->writeLandmarks($map, $entries, trim((string) ($field['label'] ?? 'Landmark'))); } + /** + * Renames a landmark and, in the same undo step, the atlas entry of every map that names it, so no local map + * loses its place above. When any of those maps cannot take the edit, nothing is renamed. + * + * @param array $field The landmark's id row. + * @throws InspectorRefusal When the id is refused, the map would not load the result, or a map naming it cannot follow. + */ + private function renameLandmark(ProjectMap $map, array $field, string $id): ?Command + { + try { + $rename = $this->getLandmarkFields()->renameEntry($map, $field, $id); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + if ($rename === null) { return null; } + + $problem = $this->getLandmarkFields()->findCurrentProblem($map, $rename['entries']); + if ($problem !== null) { throw new InspectorRefusal($problem); } + + // The maps naming it first: one that cannot follow refuses the rename before the landmark itself changes. + $commands = []; + try { + foreach ($rename['children'] as $child) { + $commands[] = $this->writeMapData($child, [MapAtlas::KEY, 'parent', 'landmark'], $rename['to'], sprintf('%s atlas entry', $child->mapId)); + } + $commands[] = $this->writeMapData($map, [MapLandmarks::KEY], $rename['entries'], 'Landmark id'); + } catch (MapSourceRefusal $refusal) { + foreach (array_reverse(array_filter($commands)) as $command) { $command->undo(); } + throw new InspectorRefusal(sprintf('Landmark %s was not renamed: %s', $rename['from'], $refusal->getMessage()), previous: $refusal); + } + + $commands = array_values(array_filter($commands)); + + return $commands === [] ? null : new CommandGroup(sprintf('Landmark rename %s to %s', $rename['from'] ?? '(no id)', $rename['to']), $commands); + } + /** * Applies one terrain row's edit: the atlas block it leaves is checked by the Engine against the map's source * layers as they stand, then written as one undo step; an empty block removes the key. diff --git a/src/Inspector/MapLandmarkFields.php b/src/Inspector/MapLandmarkFields.php index e14d6ed..2107e36 100644 --- a/src/Inspector/MapLandmarkFields.php +++ b/src/Inspector/MapLandmarkFields.php @@ -13,6 +13,7 @@ use Ichiloto\Engine\Field\MapLandmarkCatalog; use Ichiloto\Engine\Field\MapLandmarkKind; use InvalidArgumentException; +use LogicException; /** * The inspector rows that author a map's minimap landmarks, the same rows in the Terminal and in a graphical @@ -31,6 +32,8 @@ public const string OBJECT_TARGET = 'object-landmark'; /** The event row's value when no declaration describes the event. */ public const string UNDESCRIBED = 'undescribed'; + /** The field of a landmark's stable id row. */ + public const string ID_FIELD = 'id'; public function __construct(private ReferenceCatalog $references) {} @@ -158,10 +161,33 @@ public function applyValue(ProjectMap $map, array $field, mixed $value): ?array : $landmarks->setApproachDistance($index, null), 'approachDistance' => $landmarks->setApproachDistance($index, self::requireDistance($value)), 'revealConditions' => $landmarks->setRevealConditions($index, is_array($value) ? $value : []), + // A rename reaches other maps too, so it is never one map's declarations alone. + self::ID_FIELD => throw new LogicException('A landmark is renamed through renameEntry(), with the maps that name it.'), default => null, }; } + /** + * What renaming a landmark leaves: the map's declarations, and the other maps whose atlas entry it is, which + * follow the rename in the same step. Null when the id is unchanged. + * + * @param array $field The landmark's id row. + * @return array{entries: list>, from: ?string, to: string, children: list}|null + * @throws InvalidArgumentException When the landmark is gone or the id is refused. + */ + public function renameEntry(ProjectMap $map, array $field, string $id): ?array + { + $landmarks = MapLandmarks::readMap($map); + $index = (int) ($field['index'] ?? -1); + $entry = $landmarks->entries[$index] ?? throw new InvalidArgumentException('That landmark is no longer here; read the inspector again.'); + $from = is_string($entry['id'] ?? null) ? $entry['id'] : null; + $entries = $landmarks->setId($index, $id); + $to = (string) $entries[$index]['id']; + if ($to === $from) { return null; } + return ['entries' => $entries, 'from' => $from, 'to' => $to, + 'children' => $from === null ? [] : MapAtlas::findChildMaps($this->references->listMaps(), $map->mapId, $from)]; + } + /** * Describes the next place no landmark describes yet, in map order: events, then world objects, then legacy * transfers. Its anchor row then chooses any other. @@ -295,8 +321,10 @@ private function describeEntryFields(MapLandmarks $landmarks, array $anchors, in $named = isset($entry['name']); $conditions = is_array($entry['revealConditions'] ?? null) ? $entry['revealConditions'] : []; $fields = [ - // The id is the identity saves remember discoveries by: shown, never edited. $row(['label' => ' Landmark ' . $id, 'value' => $anchor === null ? '(no anchor)' : $landmarks->describeAnchor($anchor), 'editable' => false]), + // The identity saves remember discoveries by and other maps name as their atlas entry, which follow a rename. + $row(['label' => ' Id', 'value' => is_string($entry['id'] ?? null) ? $entry['id'] : '', + 'control' => new InputControl(InputControlType::TEXT, is_string($entry['id'] ?? null) ? $entry['id'] : ''), 'field' => self::ID_FIELD]), $row(['label' => ' Anchor', 'value' => $anchorValue, 'editable' => true, 'choices' => $anchorChoices, 'field' => 'anchor']), $row(['label' => ' Kind', 'value' => (string) ($entry['kind'] ?? ''), 'editable' => true, 'field' => 'kind', 'choices' => array_map(static fn(MapLandmarkKind $kind): array => ['label' => self::describeKind($kind), diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 55aa34c..99b5abb 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1784,8 +1784,12 @@ public function readInspector(string $mapId, ?string $marker = null): array * first: nothing changes until the edit is made again with `$answer` * `clear` (clear its tile layers and change) or `cancel`. * + * When the edit changes other maps too (renaming a landmark renames the + * atlas entry of every map naming it), `revisions` gives each of those + * maps' new revision. + * * @param array $key The row's key, as `readInspector` gave it. - * @return array{status: 'applied', revision: int, changed: bool}|array{status: 'question', question: string, answers: list} + * @return array{status: 'applied', revision: int, changed: bool, revisions?: array}|array{status: 'question', question: string, answers: list} * @throws SessionRefusal When the map is unknown or stale, the row is gone or read-only, or the edit is refused. */ public function applyInspector(string $mapId, int $revision, array $key, string $value, ?string $answer = null): array @@ -1817,11 +1821,20 @@ public function applyInspector(string $mapId, int $revision, array $key, string } } + $before = array_map($this->getMapRevision(...), $this->getMapsById()); $command = $this->runEdit(static fn(): ?Command => ($field['target'] ?? null) === 'map-kind' ? $inspector->changeMapKind($map, $value, $cleared > 0 && $answer === 'clear') : $inspector->apply($map, $field, $value)); + // A row may change other maps in the same step: a landmark rename carries the atlas entries naming it. + $others = []; + foreach ($this->getMapsById() as $otherId => $other) { + if ($other !== $map && $this->getMapRevision($other) !== ($before[$otherId] ?? null)) { + $others[(string) $otherId] = $this->getMapRevision($other); + } + } - return ['status' => 'applied', 'revision' => $this->getMapRevision($map), 'changed' => $command !== null]; + return ['status' => 'applied', 'revision' => $this->getMapRevision($map), 'changed' => $command !== null, + ...($others === [] ? [] : ['revisions' => $others])]; } /** diff --git a/tests/Unit/Session/MapLandmarkIdentityTest.php b/tests/Unit/Session/MapLandmarkIdentityTest.php new file mode 100644 index 0000000..68d27cb --- /dev/null +++ b/tests/Unit/Session/MapLandmarkIdentityTest.php @@ -0,0 +1,115 @@ + ['parent' => ['map' => 'world', 'landmark' => {$entry}]],\n", + (string) file_get_contents($file))); + + return [$root, $file, EditorSession::open($root)]; +} + +it('renames a landmark and the atlas entry naming it as one undo step, saving both as authored', function () { + [$root, $file, $session] = createLandmarkIdentityProject(); + $row = findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'id', 'index' => 0], mapId: 'world'); + expect($row)->toMatchArray(['kind' => 'text', 'value' => 'town-gate', 'raw' => 'town-gate', 'label' => 'Id']); + + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + $revision = $request('map.read', ['map' => 'world'])['result']['revision']; + $childRevision = $request('map.read', ['map' => 'test-map'])['result']['revision']; + + $applied = $request('inspector.apply', ['map' => 'world', 'revision' => $revision, 'key' => $row['key'], 'value' => 'harbour-gate'])['result']; + expect($applied)->toMatchArray(['status' => 'applied', 'changed' => true]) + ->and(array_keys($applied['revisions']))->toBe(['test-map']) + ->and($applied['revisions']['test-map'])->not->toBe($childRevision) + ->and($request('map.read', ['map' => 'test-map'])['result']['revision'])->toBe($applied['revisions']['test-map']); + + $undone = $request('history.undo')['result']; + expect($undone['label'])->toBe('Landmark rename town-gate to harbour-gate') + ->and($undone['maps'])->toEqualCanonicalizing(['world', 'test-map']); + $request('history.redo'); + $saved = $request('project.saveAll')['result']; + expect($saved['failures'] ?? [])->toBe([]); + + $world = (string) file_get_contents($root . '/assets/Maps/world/world.data.php'); + $child = (string) file_get_contents($file); + expect($world)->toContain("'harbour-gate'")->not->toContain("'town-gate'") + ->and($child)->toContain('// Map-owned presentation stays authored.', "'harbour-gate'")->not->toContain("'town-gate'"); + $loaded = ProjectWorkspace::fromProject($root); + $testMap = array_find($loaded->maps, static fn($map): bool => $map->mapId === 'test-map'); + expect($testMap->getMapDataField(['atlas', 'parent']))->toBe(['map' => 'world', 'landmark' => 'harbour-gate']) + ->and(Ichiloto\Editor\Field\MapAtlas::resolveGlobal($testMap, Ichiloto\Editor\Field\MapAtlas::readBlock($testMap), + static fn(string $id) => array_find($loaded->maps, static fn($map): bool => $map->mapId === $id))) + ->toBe(['map' => 'world', 'landmark' => 'harbour-gate']); +}); + +it('names a landmark that had no id, and keeps its translation key through a rename', function () { + [, , $session] = createLandmarkProject(); + $heading = findLandmarkRow($session, ['target' => 'map-landmarks']); + $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], $heading['key']); + applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'name', 'index' => 0], 'Harbour Gate'); + $key = readLandmarkEntries($session)[0]['name']['message']; + + $applied = applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'id', 'index' => 0], ' harbour-gate '); + expect($applied)->toMatchArray(['changed' => true])->not->toHaveKey('revisions') + ->and(readLandmarkEntries($session)[0])->toMatchArray(['id' => 'harbour-gate', 'name' => ['message' => $key, 'default' => 'Harbour Gate']]) + ->and(findLandmarkRow($session, ['target' => 'map-landmarks', 'index' => 0])['label'])->toBe('Landmark harbour-gate') + ->and(applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'id', 'index' => 0], 'harbour-gate')['changed'])->toBeFalse(); +}); + +it('refuses an id the Engine would not load or another landmark has, changing nothing', function (string $id, string $reason) { + [, , $session] = createLandmarkIdentityProject(); + $heading = findLandmarkRow($session, ['target' => 'map-landmarks'], mapId: 'world'); + $session->addInspectorListEntry('world', $session->readMap('world')['revision'], $heading['key']); + $revision = $session->readMap('world')['revision']; + $entries = readLandmarkEntries($session, 'world'); + + expect(fn() => applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'id', 'index' => 0], $id, mapId: 'world')) + ->toThrow(SessionRefusal::class, $reason) + ->and(readLandmarkEntries($session, 'world'))->toBe($entries) + ->and($session->readMap('world')['revision'])->toBe($revision) + ->and(getSessionMap($session)->getMapDataField(['atlas', 'parent', 'landmark']))->toBe('town-gate'); +})->with([ + 'spaces' => ['Harbour Gate', 'is not a landmark id'], + 'leading digit' => ['1-gate', 'is not a landmark id'], + 'blank' => ['', 'is not a landmark id'], + 'too long' => [str_repeat('a', 65), 'is not a landmark id'], + 'taken' => ['landmark-1', 'Another landmark on world is already landmark-1.'], +]); + +it('renames nothing when a map naming the landmark cannot take the new entry in its source', function () { + [$root, $file, $session] = createLandmarkIdentityProject("implode('-', ['town', 'gate'])"); + $before = sourceHashTree($root); + $revision = $session->readMap('world')['revision']; + + expect(fn() => applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'id', 'index' => 0], 'harbour-gate', mapId: 'world')) + ->toThrow(SessionRefusal::class, 'Landmark town-gate was not renamed') + ->and(readLandmarkEntries($session, 'world')[0]['id'])->toBe('town-gate') + ->and($session->readMap('world')['revision'])->toBe($revision) + ->and($session->readMap('world')['dirty'])->toBeFalse() + ->and($session->readMap('test-map')['dirty'])->toBeFalse() + ->and($session->undo()['label'])->toBeNull() + ->and(sourceHashTree($root))->toBe($before); +}); From 30be966a130f2c3a7823cbc01b64f2433a4907c4 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:35:47 +0200 Subject: [PATCH 316/344] feat(npc): let the author choose and change an NPC's stable id npc.create and npc.assignId take an optional id; without one the id is still derived from the name. The NPC Id row is now editable: it goes through NpcAuthoring::setId, which checks the id (lowercase letters and digits joined by single hyphens, unique on the map) and refuses, listing each reference, while a route, script, cinematic or dialogue event names the current id. References are not migrated. Behaviour narrowed: renaming an NPC now re-derives its id only when the id is the one its old name derived. Before, any unreferenced id followed the name, which would overwrite a chosen id such as djin-contract-trial. Two TUI tests that used non-derived ids to show the old following now use derived ones. The NPC session fixtures move to tests/Support/NpcFixtures.php so other test files can share them. The manual describes chosen NPC ids, landmark renames and chosen map folder paths. --- docs/manual.md | 47 +++++-- src/Database/RecordSchemaCatalog.php | 9 +- src/Editor.php | 4 +- src/Field/NpcAuthoring.php | 125 +++++++++++++++--- src/Field/NpcInspector.php | 4 +- src/Session/EditorSession.php | 28 ++-- src/Session/SessionHost.php | 4 +- tests/Pest.php | 2 + tests/Support/NpcFixtures.php | 34 +++++ tests/Unit/NpcAuthoringRulesTest.php | 2 +- tests/Unit/NpcAuthoringTest.php | 6 +- tests/Unit/Session/NpcIdentitySessionTest.php | 118 +++++++++++++++++ tests/Unit/Session/NpcSessionTest.php | 37 +----- 13 files changed, 331 insertions(+), 89 deletions(-) create mode 100644 tests/Support/NpcFixtures.php create mode 100644 tests/Unit/Session/NpcIdentitySessionTest.php diff --git a/docs/manual.md b/docs/manual.md index 187595e..518eb97 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -710,16 +710,22 @@ with a hint rather than painted under an NPC. `Enter` on an empty tile asks for the NPC's name first, and derives its stable `id` from that name - `Gate Guard` becomes `gate-guard`, numbered if the map already has one - because the id is what routes, dialogue events, cinematics -and script diagnostics name. Renaming the NPC re-derives its id from the new -name **while nothing refers to it**, so an NPC created as `New NPC` and named -later gets the id its name suggests. Once an event, script, route or cinematic -starting on the map names the id, it stays: a rename keeps it and the status -says what names it. Moving the NPC or changing its sprite never touches it. -Duplicating assigns a fresh id from the name. An NPC authored without an id loads and edits normally, shows a -`! No stable id` row (`Enter` there assigns one from its name, the one time an -id is ever written after creation, since nothing can yet name it), and -validates with a warning that scripted movement cannot target it. Changing an -existing id is an identity migration, which the editor does not offer. +and script diagnostics name. Renaming the NPC re-derives an id its old name +derived from the new name **while nothing refers to it**, so an NPC created as +`New NPC` and named later gets the id its name suggests. Once an event, script, +route or cinematic starting on the map names the id, it stays: a rename keeps +it and the status says what names it. Moving the NPC or changing its sprite +never touches it. Duplicating assigns a fresh id from the name. + +The `Id` row sets the id directly, for an NPC whose id should differ from its +name (one character met as several NPCs, such as `djin-contract-trial` for +`Djin`). The id is lowercase letters and digits joined by single hyphens and +unique on the map. Changing it is refused, with each reference listed, while +anything names the current id: references are not migrated, so change them +first. A chosen id stays when the NPC is renamed. An NPC authored without an +id loads and edits normally, shows a `! No stable id` row (`Enter` there +assigns one from its name; the `Id` row takes a chosen one), and validates +with a warning that scripted movement cannot target it. #### The NPC Inspector @@ -1012,9 +1018,14 @@ event or object moves the landmark. transfers that no other landmark uses, and `Kind` from the kinds that anchor may be. Moving a landmark between a transfer and anything else makes it a passage or a landmark accordingly. -- The id, shown on the landmark's heading, is fixed: saves remember what the - party has learned by it. An object's own id is used when it is a valid - landmark id; otherwise the next free `landmark-N`. +- `Id` is the landmark's stable id, shown on its heading. A new landmark + takes an object's own id when it is a valid landmark id, otherwise the next + free `landmark-N`; the row renames it to any id the Engine accepts that no + other landmark on the map has. Other maps name a landmark as their atlas + entry, and a rename changes their `atlas.parent.landmark` in the same undo + step; when one of those maps cannot take the edit, nothing is renamed. The + name's translation key stays. Saves remember what the party has learned by + id, so a save that learned the landmark under its old id learns it again. - `Category` says what the place offers, whatever its kind: the Engine's `inn`, `shop`, `home` and `organization`, or a category of the project's own (see Place Categories). An entrance into a shop is a route with the @@ -2717,6 +2728,16 @@ command palette. It shows the current and proposed ids, requires an explicit are **not** migrated: anything naming the old id keeps naming it, and the prompt says so before you confirm. +A map may live inside another map's folder (a building's rooms inside it) or +inside a folder that is no map. The editor session creates, copies and moves +a map at a folder path the author chooses: `map.create` takes `id`, and +`map.duplicate` and `map.move` take `to`. A chosen path is checked before +anything is written: each part between slashes is lowercase letters and digits +joined by single hyphens, nothing may already stand there, and no map nested +directly in a map's folder may be called `layers` or `graphics`. A move to a +chosen path is reviewed exactly as a derived one is. Deleting a nested map +also removes the folders above it that it leaves empty. + ### Dirty means "differs from the last save" An asset is dirty exactly when its content differs from what the last diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index cd3d4a6..d1a2eaf 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -1952,9 +1952,10 @@ private static function equipmentFields(EquipmentSlotType $defaultSlot, array $t * The schema for a map's NPCs, edited in the map's Inspector. * * Every field the engine's `NpcManager::configure()` reads, in the groups - * an author thinks in. The id is shown but not editable: it is what - * routes and diagnostics name, and nothing that names it would follow a - * rename. Dialogue is edited as conditional variants -- the runtime's + * an author thinks in. The id is what routes and diagnostics name; it is + * edited through {@see \Ichiloto\Editor\Field\NpcAuthoring::setId()}, + * which refuses while anything names it, since nothing that names it + * follows a rename. Dialogue is edited as conditional variants -- the runtime's * own model, of which plain pages are the one-variant case with no * conditions -- so the settings pane and the game agree about what a * given entry means. Scripts are the shared event-command list. @@ -1970,7 +1971,7 @@ public static function mapNpcs(bool $graphical = false): RecordSchema relativePath: '', fields: [ // Identity - new RecordField('id', 'Id', isReadOnly: true), + new RecordField('id', 'Id'), new RecordField('name', 'Name'), // Placement new RecordField('x', 'X', InputControlType::INTEGER), diff --git a/src/Editor.php b/src/Editor.php index f4f7509..fd7597f 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -2241,8 +2241,8 @@ private function selectNpc(?int $index): void * Gives an NPC authored without a stable id one, derived from its name * and unique on its map, so movement routes can target it. * - * Only an id-less NPC is eligible: an existing id is immutable, since - * routes and diagnostics that name it would not follow a change. + * Only an id-less NPC is eligible: an existing id changes through its + * Id row, which refuses while routes or scripts name it. * * @return void */ diff --git a/src/Field/NpcAuthoring.php b/src/Field/NpcAuthoring.php index 18b0336..7a51604 100644 --- a/src/Field/NpcAuthoring.php +++ b/src/Field/NpcAuthoring.php @@ -8,6 +8,7 @@ use Ichiloto\Editor\Database\RecordChange; use Ichiloto\Editor\Database\RecordItem; use Ichiloto\Editor\Database\RecordRefusal; +use Ichiloto\Editor\Database\Slug; use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\MapSourceRefusal; @@ -22,9 +23,11 @@ * through its record pane ({@see NpcInspector}). * * The rules live here: an NPC stands inside the map on a tile no other NPC - * anchors to, a new NPC's stable id derives from its name, an NPC something - * names is never deleted, and a renamed NPC takes the id its new name - * derives only while nothing names its old one. Each operation applies its + * anchors to, a new NPC's stable id is the one its author chose or else + * derives from its name, an id is unique on its map and changes only while + * nothing names it, an NPC something names is never deleted, and a renamed + * NPC whose id its name derived takes the id its new name derives only + * while nothing names its old one; a chosen id stays. Each operation applies its * change to the map and returns an {@see NpcChange} whose command redoes and * undoes exactly the map's NPC collection; selection, prompts and status * belong to the interface. A change that cannot be made is an @@ -48,18 +51,20 @@ public function __construct(ProjectWorkspace $workspace) } /** - * Creates a fixed NPC at a tile, its stable id derived from its name. + * Creates a fixed NPC at a tile under the stable id its author chose, or + * one derived from its name. * * @param string $name The display name; blank takes {@see DEFAULT_NAME}. - * @throws NpcRefusal When the tile is outside the map or another NPC anchors there. + * @param string|null $id The id; null derives one from the name. + * @throws NpcRefusal When the tile is outside the map or another NPC anchors there, or the id is not one or is taken. * @throws MapSourceRefusal When the map's source cannot take the change. */ - public function create(ProjectMap $map, int $x, int $y, string $name): NpcChange + public function create(ProjectMap $map, int $x, int $y, string $name, ?string $id = null): NpcChange { $before = $map->getNpcs(); $this->assertPlaceable($map, $before, null, $x, $y); $name = trim($name) === '' ? self::DEFAULT_NAME : trim($name); - $npc = ProjectNpc::createAt($before->uniqueIdFor($name), $name, $x, $y); + $npc = ProjectNpc::createAt($id === null ? $before->uniqueIdFor($name) : self::requireChosenId($before, null, $id), $name, $x, $y); $after = $before->withAdded($npc); $map->setNpcs($after); @@ -143,23 +148,25 @@ public function delete(ProjectMap $map, int $index): NpcChange } /** - * Gives an NPC authored without a stable id one, derived from its name - * and unique on its map, so movement routes can target it. An existing - * id never changes here: what names it would not follow. + * Gives an NPC authored without a stable id the one its author chose, or + * one derived from its name and unique on its map, so movement routes can + * target it. An existing id changes through {@see setId()}, which checks + * what names it. * - * @throws NpcRefusal When there is no such NPC or it already has an id. + * @param string|null $id The id; null derives one from the name. + * @throws NpcRefusal When there is no such NPC, it already has an id, or the chosen id is not one or is taken. * @throws MapSourceRefusal When the map's source cannot take the change. */ - public function assignId(ProjectMap $map, int $index): NpcChange + public function assignId(ProjectMap $map, int $index, ?string $id = null): NpcChange { $before = $map->getNpcs(); $npc = $this->requireNpc($map, $before, $index); if ($npc->getId() !== null) { - throw new NpcRefusal(sprintf('%s already has the stable id "%s"; ids do not change.', $npc->getName(), $npc->getId())); + throw new NpcRefusal(sprintf('%s already has the stable id "%s"; change it through its Id row.', $npc->getName(), $npc->getId())); } - $id = $before->uniqueIdFor($npc->getName()); + $id = $id === null ? $before->uniqueIdFor($npc->getName()) : self::requireChosenId($before, $index, $id); $identified = $npc->asCopyWithId($id); $after = $before->withReplaced($index, $identified); $map->setNpcs($after); @@ -167,11 +174,49 @@ public function assignId(ProjectMap $map, int $index): NpcChange return new NpcChange($this->createCommand($map, sprintf('Assign NPC id %s', $id), $before, $after), $index, $identified); } + /** + * Gives an NPC the stable id its author chose, refusing while anything + * names its current one: a route or script that silently stops resolving + * is worse than a refusal, and the refusal's details are what the author + * goes to change. An NPC without an id simply takes it. + * + * @throws NpcRefusal When there is no such NPC, the id is not one or is taken, or something names the current id. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function setId(ProjectMap $map, int $index, string $id): NpcChange + { + $before = $map->getNpcs(); + $npc = $this->requireNpc($map, $before, $index); + $id = self::requireChosenId($before, $index, $id); + $current = $npc->getId(); + + if ($current === $id) { + return new NpcChange(null, $index, $npc); + } + + $references = $current === null ? [] : $this->references->describe($map, $current); + + if ($references !== []) { + throw new NpcRefusal( + sprintf('%s keeps the id "%s": %s name%s it. Change those first.', $npc->getName(), $current, implode(', ', $references), + count($references) === 1 ? 's' : ''), + array_map(static fn(string $reference): string => '- ' . $reference, $references), + ); + } + + $identified = $current === null ? $npc->asCopyWithId($id) : $npc->withId($id); + $after = $before->withReplaced($index, $identified); + $map->setNpcs($after); + + return new NpcChange($this->createCommand($map, sprintf('NPC id %s', $id), $before, $after), $index, $identified); + } + /** * Applies one row's edit through the NPC's record pane. A new name - * carries the id with it while nothing names the old id; otherwise the - * id stays and the change says what names it. The rename and its id - * are one undo step. The X and Y rows move the NPC, under the same tile + * carries an id its old name derived with it while nothing names the old + * id; otherwise the id stays and the change says what names it. The + * rename and its id are one undo step. The Id row changes the id as + * {@see setId()} does. The X and Y rows move the NPC, under the same tile * rules as {@see move()}. * * @param array $framePath The frame the row belongs to; [] for the NPC itself. @@ -185,6 +230,15 @@ public function applyField(NpcInspector $inspector, int $index, array $framePath $before = $map->getNpcs(); $this->requireFrame($inspector, $before, $index, $framePath); $fieldId = (string) ($field['field'] ?? ''); + + if ($framePath === [] && $fieldId === 'id') { + try { + return $this->setId($map, $index, $rawValue); + } finally { + $inspector->refresh(); + } + } + $followedId = null; $idReferences = []; @@ -207,7 +261,7 @@ public function applyField(NpcInspector $inspector, int $index, array $framePath } if ($framePath === [] && $fieldId === 'name') { - [$after, $followedId, $idReferences] = $this->followNameWithId($map, $after, $index); + [$after, $followedId, $idReferences] = $this->followNameWithId($map, $before, $after, $index); } } catch (Throwable $failure) { // The rename and its id are one change: when the id cannot @@ -310,18 +364,20 @@ private function changeRows(NpcInspector $inspector, int $index, array $framePat } /** - * Gives a renamed NPC the id its new name derives, unless something - * names its current id. + * Gives a renamed NPC the id its new name derives, when its id is the + * one its old name derived and nothing names it. An id an author chose + * is not the name's to change. * * @return array{0: NpcCollection, 1: ?string, 2: list} The NPCs afterwards, the id taken, and what names the id kept. * @throws MapSourceRefusal When the map's source cannot take the change. */ - private function followNameWithId(ProjectMap $map, NpcCollection $npcs, int $index): array + private function followNameWithId(ProjectMap $map, NpcCollection $before, NpcCollection $npcs, int $index): array { $npc = $npcs->get($index); $id = $npc?->getId(); + $was = $before->get($index); - if ($npc === null || $id === null) { + if ($npc === null || $id === null || $was === null || $before->withRemoved($index)->uniqueIdFor($was->getName()) !== $id) { return [$npcs, null, []]; } @@ -343,6 +399,31 @@ private function followNameWithId(ProjectMap $map, NpcCollection $npcs, int $ind return [$renamed, $derived, []]; } + /** + * An id an author chose for an NPC: the form ids the editor derives take + * (lowercase letters and digits joined by single hyphens), so routes and + * scripts name it without quoting, and no other NPC on the map has it. + * + * @param int|null $ownIndex The NPC taking it, when it is already on the map. + * @throws NpcRefusal When it is not such an id, or another NPC has it. + */ + private static function requireChosenId(NpcCollection $npcs, ?int $ownIndex, string $id): string + { + $id = trim($id); + + if ($id === '' || Slug::of($id) !== $id) { + throw new NpcRefusal(sprintf('"%s" is not an NPC id: lowercase letters and digits joined by single hyphens, such as "gate-guard".', $id)); + } + + foreach ($npcs->all() as $index => $npc) { + if ($index !== $ownIndex && $npc->getId() === $id) { + throw new NpcRefusal(sprintf('%s already has the id "%s" on this map.', $npc->getName(), $id)); + } + } + + return $id; + } + /** * Refuses a tile outside the map or one another NPC anchors to. * diff --git a/src/Field/NpcInspector.php b/src/Field/NpcInspector.php index 8d2cc2b..c1feafe 100644 --- a/src/Field/NpcInspector.php +++ b/src/Field/NpcInspector.php @@ -86,8 +86,8 @@ public function getFields(int $index, array $framePath = []): ?array $notes = []; if ($npc !== null && $npc->getId() === null) { - // Legacy entry: nothing can name an id it never had, so giving - // it one is the one identity write that is safe after creation. + // Legacy entry: nothing can name an id it never had, so one is + // given from the name at once; the Id row takes a chosen one. $notes[] = [ 'label' => ' ! No stable id', 'value' => 'move_route cannot target it; Enter assigns one from the name', diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 99b5abb..252d8e6 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -4286,11 +4286,18 @@ private function changeWorldObject(string $mapId, int $revision, callable $edit) return ['changed' => $command !== null, 'revision' => $this->getMapRevision($map)]; } - public function createNpc(string $mapId, int $revision, int $x, int $y, string $name): array + /** + * Creates a fixed NPC at a tile under the id given, or one derived from its name. + * + * @param string|null $id The stable id; null derives one from the name. + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, the tile is outside the map or taken, or the id is not one or is taken. + */ + public function createNpc(string $mapId, int $revision, int $x, int $y, string $name, ?string $id = null): array { $map = $this->requireCurrentMap($mapId, $revision); - return $this->applyNpcChange($map, fn(NpcAuthoring $authoring): NpcChange => $authoring->create($map, $x, $y, $name)); + return $this->applyNpcChange($map, fn(NpcAuthoring $authoring): NpcChange => $authoring->create($map, $x, $y, $name, $id)); } /** @@ -4333,16 +4340,19 @@ public function deleteNpc(string $mapId, int $revision, int $index): array } /** - * Gives an NPC authored without a stable id one, from its name. + * Gives an NPC authored without a stable id the one given, or one from + * its name. An existing id changes through the NPC's Id row + * ({@see applyNpc()}), which refuses while anything names it. * + * @param string|null $id The stable id; null derives one from the name. * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} - * @throws SessionRefusal When the map is unknown, stale or read-only, the NPC is unknown, or it already has an id. + * @throws SessionRefusal When the map is unknown, stale or read-only, the NPC is unknown, it already has an id, or the id is not one or is taken. */ - public function assignNpcId(string $mapId, int $revision, int $index): array + public function assignNpcId(string $mapId, int $revision, int $index, ?string $id = null): array { $map = $this->requireCurrentMap($mapId, $revision); - return $this->applyNpcChange($map, fn(NpcAuthoring $authoring): NpcChange => $authoring->assignId($map, $index)); + return $this->applyNpcChange($map, fn(NpcAuthoring $authoring): NpcChange => $authoring->assignId($map, $index, $id)); } /** @@ -4442,8 +4452,10 @@ private static function insertNpcFieldSheetRow(array $rows, ProjectNpc $npc, str /** * Applies one NPC row's edit as one undo step. The row is found again by - * its key among the NPC's current rows. A rename carries the id along - * (`followedId`) unless something names it (`idReferences`). + * its key among the NPC's current rows. A rename carries an id its old + * name derived along (`followedId`) unless something names it + * (`idReferences`). The Id row sets the id itself, refused while + * anything names the current one. * * @param array $key The row's key, as `readNpc` gave it. * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index 45bd80e..af671c0 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -556,6 +556,7 @@ private function dispatch(string $method, array $params): array self::requireInt($params, 'x'), self::requireInt($params, 'y'), is_string($params['name'] ?? null) ? $params['name'] : '', + self::readOptionalString($params, 'id'), ), 'npc.move' => $session->moveNpc( self::requireString($params, 'map'), @@ -566,7 +567,8 @@ private function dispatch(string $method, array $params): array ), 'npc.duplicate' => $session->duplicateNpc(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireInt($params, 'index')), 'npc.delete' => $session->deleteNpc(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireInt($params, 'index')), - 'npc.assignId' => $session->assignNpcId(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireInt($params, 'index')), + 'npc.assignId' => $session->assignNpcId(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireInt($params, 'index'), + self::readOptionalString($params, 'id')), 'npc.read' => $session->readNpc( self::requireString($params, 'map'), self::requireInt($params, 'index'), diff --git a/tests/Pest.php b/tests/Pest.php index 3c891a1..bec4d18 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -394,6 +394,8 @@ function plainTextOfFrame(string $frame, int $width, int $height): string require_once __DIR__ . '/Support/DialogueFixtures.php'; +require_once __DIR__ . '/Support/NpcFixtures.php'; + /** * Authors a project's skill catalogue as record files, one per skill and * numbered in the given order, replacing any records already there: the diff --git a/tests/Support/NpcFixtures.php b/tests/Support/NpcFixtures.php new file mode 100644 index 0000000..812fb96 --- /dev/null +++ b/tests/Support/NpcFixtures.php @@ -0,0 +1,34 @@ + $npcs The npcs block. + */ +function npcSessionProject(array $npcs): string +{ + $root = makeTemporaryProject('ichiloto-npc-session-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['npcs'] = $npcs; + file_put_contents($path, "readMap('test-map')['npcs'], 'id'); +} + +/** The row with a field id in an NPC read. */ +function npcSessionRow(array $read, string $fieldId): array +{ + return array_find($read['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $fieldId) + ?? throw new RuntimeException("No NPC row {$fieldId}."); +} diff --git a/tests/Unit/NpcAuthoringRulesTest.php b/tests/Unit/NpcAuthoringRulesTest.php index 00f93a0..61f5f83 100644 --- a/tests/Unit/NpcAuthoringRulesTest.php +++ b/tests/Unit/NpcAuthoringRulesTest.php @@ -142,7 +142,7 @@ function npcRulesRow(NpcInspector $inspector, int $index, string $fieldId, array expect($assigned->npc?->getId())->toBe('old-man') ->and($assigned->command?->label)->toBe('Assign NPC id old-man') ->and(fn() => $authoring->assignId($map, 0)) - ->toThrow(NpcRefusal::class, 'Old Man already has the stable id "old-man"; ids do not change.'); + ->toThrow(NpcRefusal::class, 'Old Man already has the stable id "old-man"; change it through its Id row.'); }); it('edits rows through the record pane, carrying the id with a rename unless something names it', function () { diff --git a/tests/Unit/NpcAuthoringTest.php b/tests/Unit/NpcAuthoringTest.php index 4ffc928..20e63e2 100644 --- a/tests/Unit/NpcAuthoringTest.php +++ b/tests/Unit/NpcAuthoringTest.php @@ -547,7 +547,7 @@ function npcIssueLines(string $root, ?Severity $severity = null): array // -- Inspector: every field through the shared pane ---------------------- it('edits name, sprite, coordinates and movement, hiding wander bounds while fixed', function () { - [$root, $path] = npcProject([['id' => 'a', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1]]); + [$root, $path] = npcProject([['id' => 'ann', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1]]); $editor = npcEditor($root); $map = npcMap($editor); callEditorMethod($editor, 'selectNpc', 0); @@ -562,7 +562,7 @@ function npcIssueLines(string $root, ?Severity $severity = null): array setNpcField($editor, 'movement', 'wander'); $npc = $map->getNpcs()->get(0); - // Nothing names Ann, so her id follows her new name. + // Nothing names Ann and her id is the one her name derived, so it follows her new name. expect($npc?->getId())->toBe('annabel') ->and($npc?->getName())->toBe('Annabel') ->and($npc?->getSprite())->toBe('a') @@ -981,7 +981,7 @@ public function __construct() }); it('places the saved checkpoint in the middle of history and follows it with undo and redo', function () { - [$root] = npcProject([['id' => 'a', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1]]); + [$root] = npcProject([['id' => 'ann', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1]]); $editor = npcEditor($root); $map = npcMap($editor); callEditorMethod($editor, 'selectNpc', 0); diff --git a/tests/Unit/Session/NpcIdentitySessionTest.php b/tests/Unit/Session/NpcIdentitySessionTest.php new file mode 100644 index 0000000..3a538ca --- /dev/null +++ b/tests/Unit/Session/NpcIdentitySessionTest.php @@ -0,0 +1,118 @@ + 'Liora', 'x' => 2, 'y' => 1]]); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + $revision = $request('map.read', ['map' => 'test-map'])['result']['revision']; + + $created = $request('npc.create', ['map' => 'test-map', 'revision' => $revision, 'x' => 5, 'y' => 2, 'name' => 'Djin', 'id' => 'djin-contract-trial'])['result']; + $assigned = $request('npc.assignId', ['map' => 'test-map', 'revision' => $created['revision'], 'index' => 0, 'id' => 'liora-home'])['result']; + $derived = $request('npc.create', ['map' => 'test-map', 'revision' => $assigned['revision'], 'x' => 7, 'y' => 2, 'name' => 'Djin'])['result']; + expect($created)->toMatchArray(['changed' => true, 'index' => 1, 'id' => 'djin-contract-trial']) + ->and($assigned)->toMatchArray(['changed' => true, 'index' => 0, 'id' => 'liora-home']) + // Without an id the name still gives one. + ->and($derived['id'])->toBe('djin') + ->and($request('npc.create', ['map' => 'test-map', 'revision' => $derived['revision'], 'x' => 8, 'y' => 2, 'name' => 'X', 'id' => 4])['error']['kind']) + ->toBe('request'); + + expect($request('map.save', ['map' => 'test-map'])['result']['saved'])->toBe('test-map'); + $source = (string) file_get_contents($root . '/assets/Maps/test-map/test-map.data.php'); + expect($source)->toContain('// The cast stays authored.', "'djin-contract-trial'", "'liora-home'"); + $npcs = ProjectWorkspace::fromProject($root)->getMapByIndex(0)?->getNpcs(); + expect(array_map(static fn($npc): array => [$npc->getId(), $npc->getName()], $npcs?->all() ?? [])) + ->toBe([['liora-home', 'Liora'], ['djin-contract-trial', 'Djin'], ['djin', 'Djin']]); +}); + +it('refuses a chosen id that is no id or that another NPC on the map has, changing nothing', function () { + $session = EditorSession::open(npcIdentityProject([ + ['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1], + ['name' => 'Old Man', 'x' => 6, 'y' => 3], + ])); + $revision = $session->readMap('test-map')['revision']; + $row = npcSessionRow($session->readNpc('test-map', 0), 'id'); + + expect(fn() => $session->createNpc('test-map', $revision, 4, 2, 'Guard', 'Gate Guard'))->toThrow(SessionRefusal::class, '"Gate Guard" is not an NPC id') + ->and(fn() => $session->createNpc('test-map', $revision, 4, 2, 'Guard', 'ann'))->toThrow(SessionRefusal::class, 'Ann already has the id "ann" on this map.') + ->and(fn() => $session->createNpc('test-map', $revision, 4, 2, 'Guard', ' '))->toThrow(SessionRefusal::class, 'is not an NPC id') + ->and(fn() => $session->assignNpcId('test-map', $revision, 1, 'ann'))->toThrow(SessionRefusal::class, 'Ann already has the id "ann"') + ->and(fn() => $session->assignNpcId('test-map', $revision, 1, 'old--man'))->toThrow(SessionRefusal::class, 'is not an NPC id') + ->and(fn() => $session->assignNpcId('test-map', $revision, 0, 'anna'))->toThrow(SessionRefusal::class, 'change it through its Id row') + ->and(fn() => $session->applyNpc('test-map', $revision, 0, $row['key'], 'café'))->toThrow(SessionRefusal::class, 'is not an NPC id') + ->and($session->applyNpc('test-map', $revision, 0, $row['key'], 'ann'))->toMatchArray(['changed' => false, 'revision' => $revision]) + ->and(npcSessionIds($session))->toBe(['ann', null]) + ->and($session->hasUnsavedChanges())->toBeFalse(); +}); + +it('changes an id nothing names through the Id row as one undo step', function () { + $session = EditorSession::open(npcIdentityProject([['id' => 'nivira', 'name' => 'Nivira', 'x' => 2, 'y' => 1]])); + $read = $session->readNpc('test-map', 0); + + $applied = $session->applyNpc('test-map', $read['revision'], 0, npcSessionRow($read, 'id')['key'], ' nivira-morning-visit '); + expect($applied)->toMatchArray(['changed' => true, 'id' => 'nivira-morning-visit', 'followedId' => null]) + ->and(npcSessionRow($session->readNpc('test-map', 0), 'id')['raw'])->toBe('nivira-morning-visit') + ->and($session->undo()['label'])->toBe('NPC id nivira-morning-visit') + ->and(npcSessionIds($session))->toBe(['nivira']) + ->and($session->redo()['maps'])->toBe(['test-map']) + ->and(npcSessionIds($session))->toBe(['nivira-morning-visit']); +}); + +it('refuses to change an id a route, a script or a dialogue event names, naming each', function () { + $session = EditorSession::open(npcIdentityProject( + [ + ['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1], + ['id' => 'bob', 'name' => 'Bob', 'x' => 6, 'y' => 3, 'script' => [ + ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'ann', 'steps' => [['direction' => 'north']]], + ]], + ], + ['T' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\DialogueEventTrigger', 'data' => ['npcId' => 'ann']]], + )); + $read = $session->readNpc('test-map', 0); + + expect(fn() => $session->applyNpc('test-map', $read['revision'], 0, npcSessionRow($read, 'id')['key'], 'ann-at-home')) + ->toThrow(SessionRefusal::class, "Ann keeps the id \"ann\": test-map event T, NPC Bob script name it. Change those first.\n- test-map event T\n- NPC Bob script") + ->and(npcSessionIds($session))->toBe(['ann', 'bob']) + ->and($session->hasUnsavedChanges())->toBeFalse(); +}); + +it('keeps a chosen id when the NPC is renamed, and still carries a derived one', function () { + $session = EditorSession::open(npcIdentityProject([ + ['id' => 'djin-contract-trial', 'name' => 'Djin', 'x' => 2, 'y' => 1], + ['id' => 'mara', 'name' => 'Mara', 'x' => 6, 'y' => 3], + ])); + $read = $session->readNpc('test-map', 0); + $kept = $session->applyNpc('test-map', $read['revision'], 0, npcSessionRow($read, 'name')['key'], 'Djinn'); + $other = $session->readNpc('test-map', 1); + $followed = $session->applyNpc('test-map', $kept['revision'], 1, npcSessionRow($other, 'name')['key'], 'Old Mara'); + + expect($kept)->toMatchArray(['changed' => true, 'id' => 'djin-contract-trial', 'followedId' => null, 'idReferences' => []]) + ->and($followed)->toMatchArray(['id' => 'old-mara', 'followedId' => 'old-mara']) + ->and(npcSessionIds($session))->toBe(['djin-contract-trial', 'old-mara']); +}); diff --git a/tests/Unit/Session/NpcSessionTest.php b/tests/Unit/Session/NpcSessionTest.php index 9f1fb10..131c4cf 100644 --- a/tests/Unit/Session/NpcSessionTest.php +++ b/tests/Unit/Session/NpcSessionTest.php @@ -12,35 +12,6 @@ * one NPC's rows read and edited by the key the session gave. */ -/** - * A throwaway project whose 12x5 fixture map carries the given NPCs. - * - * @param array $npcs The npcs block. - */ -function npcSessionProject(array $npcs): string -{ - $root = makeTemporaryProject('ichiloto-npc-session-'); - $path = $root . '/assets/Maps/test-map/test-map.data.php'; - $data = require $path; - $data['npcs'] = $npcs; - file_put_contents($path, "readMap('test-map')['npcs'], 'id'); -} - -/** The row with a field id in an NPC read. */ -function npcSessionRow(array $read, string $fieldId): array -{ - return array_find($read['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $fieldId) - ?? throw new RuntimeException("No NPC row {$fieldId}."); -} - it('creates, moves, duplicates and deletes NPCs, each one undo step against a current revision', function () { $session = EditorSession::open(npcSessionProject([['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1]])); $revision = $session->readMap('test-map')['revision']; @@ -84,7 +55,7 @@ function npcSessionRow(array $read, string $fieldId): array ->and(fn() => $session->duplicateNpc('test-map', $revision, 9))->toThrow(SessionRefusal::class, 'test-map has no NPC 9.') ->and(fn() => $session->deleteNpc('test-map', $revision, 0)) ->toThrow(SessionRefusal::class, "Ann is named by NPC Bob script - resolve those before deleting.\n- NPC Bob script") - ->and(fn() => $session->assignNpcId('test-map', $revision, 0))->toThrow(SessionRefusal::class, 'ids do not change') + ->and(fn() => $session->assignNpcId('test-map', $revision, 0))->toThrow(SessionRefusal::class, 'change it through its Id row') ->and($session->moveNpc('test-map', $revision, 0, 2, 1))->toMatchArray(['changed' => false, 'revision' => $revision]) ->and($session->hasUnsavedChanges())->toBeFalse() ->and($session->undo()['label'])->toBeNull(); @@ -99,14 +70,14 @@ function npcSessionRow(array $read, string $fieldId): array ->and($name)->toMatchArray(['kind' => 'text', 'key' => ['target' => 'npc', 'field' => 'name', 'frame' => []]]) ->and(npcSessionRow($read, 'x')['kind'])->toBe('integer') ->and(npcSessionRow($read, 'movement'))->toMatchArray(['kind' => 'options', 'options' => ['fixed', 'wander']]) - ->and(npcSessionRow($read, 'id')['kind'])->toBe('info') + ->and(npcSessionRow($read, 'id'))->toMatchArray(['kind' => 'text', 'raw' => 'ann']) ->and(array_column($read['rows'], 'label'))->toContain('Identity') ->and(npcSessionRow($read, 'commandListScript')['frame'])->toBe(['script']); $applied = $session->applyNpc('test-map', $read['revision'], 0, $name['key'], 'Annabel'); expect($applied)->toMatchArray(['changed' => true, 'id' => 'annabel', 'followedId' => 'annabel', 'idReferences' => []]) - ->and(fn() => $session->applyNpc('test-map', $applied['revision'], 0, npcSessionRow($read, 'id')['key'], 'x')) - ->toThrow(SessionRefusal::class, 'Id cannot be edited here.') + ->and(fn() => $session->applyNpc('test-map', $applied['revision'], 0, npcSessionRow($read, 'id')['key'], 'Not An Id')) + ->toThrow(SessionRefusal::class, '"Not An Id" is not an NPC id') ->and(fn() => $session->applyNpc('test-map', $applied['revision'], 0, ['field' => 'nope'], 'x')) ->toThrow(SessionRefusal::class, 'no longer on the NPC') ->and(fn() => $session->applyNpc('test-map', $applied['revision'], 0, npcSessionRow($read, 'x')['key'], '40')) From 2d6499668885459b31f0387d6177db5e0abb75fe Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:44:18 +0200 Subject: [PATCH 317/344] refactor(validation): judge maps by the Engine's completeness rule, keeping no Editor copy MapCompletenessCheck feeds each map as it stands in the workspace (data, event layer, layers, graphics, inventory) to the Engine's MapCompletenessValidator and words its findings for authors: one issue per finding, findings that differ only by cell joined, the rule as the code, every one an error. Project validation, the pre-save checks and change sets all use it. Removed, now the Engine's: the Editor's event marker checks, event facing and engagement checks, its own chest call in project validation, and the shown-glyph, NPC-without-sprite, NPC-on-its-own-glyph, no-tiles-yet and map-without-a-kind coverage findings. Behaviour changes named plainly: - An event defined but never placed is now an error (event.area): the Engine's map loader refuses it. It was a warning. - An NPC standing on a copy of its glyph is reported as the shown glyph. - A map with a kind but no tiles names each glyph's cells instead of one summary line. - Messages are the Engine's wording. validateCoverage keeps the Editor's own art-in-progress warnings: stale piece tiles, the missing-art placeholder and see-through ground. Tests that need a loadable project convert the sample map's legacy tag, which the Engine reads as glyphs; the engagement fixture now places the event it defines. --- src/ProjectMap.php | 11 ++ src/Validation/ChestRewardValidator.php | 3 +- src/Validation/MapCompletenessCheck.php | 112 +++++++++++++++++ src/Validation/MapGraphicsValidator.php | 132 ++++++-------------- src/Validation/MapValidator.php | 53 ++------ src/Validation/ProjectValidator.php | 91 +------------- tests/Support/ValidationFixtures.php | 12 ++ tests/Unit/ChestRewardValidationTest.php | 12 +- tests/Unit/EventEngagementAuthoringTest.php | 5 +- tests/Unit/EventFacingAuthoringTest.php | 2 +- tests/Unit/MapGraphicsTest.php | 67 ++++++---- tests/Unit/MapValidatorTest.php | 22 ++-- tests/Unit/ProjectValidatorTest.php | 38 ++++-- tests/Unit/Session/ChangeSetTest.php | 6 +- 14 files changed, 283 insertions(+), 283 deletions(-) create mode 100644 src/Validation/MapCompletenessCheck.php diff --git a/src/ProjectMap.php b/src/ProjectMap.php index b547eb2..d4236ac 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -1499,6 +1499,17 @@ public function setEventSymbol(int $x, int $y, string $symbol): void * * @return string[] */ + /** + * The event layer's cells as its source holds them, row by row, unsaved + * edits included: what the Engine reads as the map's event layer. + * + * @return list> + */ + public function getEventLayerCells(): array + { + return array_values(array_map(array_values(...), $this->layers->getEventGrid()->getSymbols())); + } + public function getPlacedEventMarkers(): array { $markers = []; diff --git a/src/Validation/ChestRewardValidator.php b/src/Validation/ChestRewardValidator.php index e69a4f8..c54db43 100644 --- a/src/Validation/ChestRewardValidator.php +++ b/src/Validation/ChestRewardValidator.php @@ -62,8 +62,9 @@ public function validate(string $mapId, string $marker, array $data): array /** * Resolves an inventory reference for the Engine's check: the stable id, * null when nothing answers to it, and a failure when more than one does. + * The map completeness check gives the Engine the same resolver. */ - private function resolveInventoryReference(string $reference): ?string + public function resolveInventoryReference(string $reference): ?string { return $this->inventory->isAmbiguous($reference) ? $this->inventory->requireDefinitionId($reference, 'validating chest reward') diff --git a/src/Validation/MapCompletenessCheck.php b/src/Validation/MapCompletenessCheck.php new file mode 100644 index 0000000..10c1f86 --- /dev/null +++ b/src/Validation/MapCompletenessCheck.php @@ -0,0 +1,112 @@ + The errors found, each naming the map. + */ + public static function validate(ProjectMap $map, ?InventoryCatalog $inventory): array + { + $resolve = $inventory instanceof InventoryCatalog + ? new ChestRewardValidator($inventory)->resolveInventoryReference(...) + : static fn(string $reference): ?string => null; + try { + $found = MapCompletenessValidator::validate( + $map->mapId, + $map->getEditableData(), + $map->getEventLayerCells(), + $map->getLayerSet(), + MapGraphicsValidator::readGraphics($map), + $map->getAssetRoot(), + $resolve, + requiresGraphics: glob($map->getAssetRoot() . '/' . Tileset::DIRECTORY . '/*.php') !== [], + ); + } catch (Throwable $failure) { + // The map's own reading checks say why it cannot be read; nothing is judged as complete. + return [new Issue(Severity::ERROR, $map->mapId, 'Its completeness could not be checked: ' . $failure->getMessage(), + 'Repair what the other findings on this map name, then validate again.', 'map.unreadable', $map->mapId)]; + } + if ($map->getMapDataField(['tileset']) !== null) { + // A kind that cannot be read is reported with its reason by MapGraphicsValidator::validate(). + $found = array_values(array_filter($found, static fn(MapValidationIssue $issue): bool => $issue->rule !== 'graphics.missing')); + } + if ($inventory === null) { + $found = array_values(array_filter($found, static fn(MapValidationIssue $issue): bool => $issue->rule !== ChestRewardValidator::CODE)); + } + + return self::describeIssues($map, $found); + } + + /** + * Words the Engine's findings for an author: one issue per finding, the + * findings that differ only by cell joined into one that names its cells. + * + * @param list $found + * @return list + */ + private static function describeIssues(ProjectMap $map, array $found): array + { + $groups = []; + foreach ($found as $issue) { + $key = implode("\0", [$issue->rule, $issue->message, $issue->marker ?? '', json_encode($issue->path), $issue->hint]); + $groups[$key] ??= ['issue' => $issue, 'cells' => []]; + array_push($groups[$key]['cells'], ...$issue->cells); + } + + $issues = []; + foreach ($groups as ['issue' => $issue, 'cells' => $cells]) { + $named = array_map(static fn(array $cell): string => "({$cell[0]}, {$cell[1]})", array_slice($cells, 0, self::CELLS_NAMED)); + $rest = count($cells) - count($named); + $message = $cells === [] ? $issue->message : sprintf('%s. At %s%s%s.', rtrim($issue->message, '.'), + count($cells) === 1 ? '' : sprintf('%d cells: ', count($cells)), implode(', ', $named), $rest > 0 ? " and {$rest} more" : ''); + $where = $issue->marker === null ? $map->mapId : sprintf('%s event %s', $map->mapId, $issue->marker); + $issues[] = new Issue(Severity::ERROR, $where, $message, self::describeHint($map, $issue), $issue->rule, $map->mapId); + } + + return $issues; + } + + /** The fix, in the author's terms where the Editor knows a better one than the Engine's. */ + private static function describeHint(ProjectMap $map, MapValidationIssue $issue): string + { + return match (true) { + $issue->rule === 'graphics.missing' + => sprintf('Set its Kind in the Inspector, one of the tilesets in assets/%s.', Tileset::DIRECTORY), + $issue->rule === 'graphics.glyph' + => 'Draw its tiles (T in the terminal editor, layer.drawTiles through the session), give the object it belongs to its art, or remove the glyph if it is obsolete.', + default => $issue->hint, + }; + } +} diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php index 11405bf..ea4abbc 100644 --- a/src/Validation/MapGraphicsValidator.php +++ b/src/Validation/MapGraphicsValidator.php @@ -7,7 +7,6 @@ use Ichiloto\Editor\Canvas\GlyphTilePlanner; use Ichiloto\Editor\Canvas\PieceRole; use Ichiloto\Editor\Canvas\TileGlyphBinding; -use Ichiloto\Editor\Field\ProjectNpc; use Ichiloto\Editor\Field\WorldObjectAuthoring; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Maps\TileLayerSource; @@ -27,9 +26,7 @@ /** * Checks a map's graphics as the Engine reads them: its kind (the tileset it - * names) and the tile layers in `graphics/`, unsaved resizes included. In a - * project with tilesets every map needs a kind: one without shows its raw - * terminal glyphs in the graphical field, an error. + * names) and the tile layers in `graphics/`, unsaved resizes included. * * Unusable graphics never stop a map loading; the game shows its terminal * glyphs instead. Every file is checked, so one report names every problem @@ -59,12 +56,10 @@ public static function validate(ProjectMap $map): array $sources = $map->getTileLayerSources(); $tilesetId = $map->getMapDataField(['tileset']); + // A map without a kind in a project that has tilesets shows its raw glyphs: the Engine's + // completeness rule reports that ({@see MapCompletenessCheck}). if ($tilesetId === null && $sources === [] && ! is_dir($map->directory . '/' . MapGraphics::DIRECTORY)) { - return glob($map->getAssetRoot() . '/' . Tileset::DIRECTORY . '/*.php') === [] ? [] : [Issue::error( - $map->mapId, - 'It has no kind, so it has no tiles or pieces.', - sprintf('Set its Kind in the Inspector, one of the tilesets in assets/%s. %s', Tileset::DIRECTORY, self::GLYPH_FALLBACK), - )]; + return []; } $issues = []; @@ -84,17 +79,16 @@ public static function validate(ProjectMap $map): array } /** - * Reports what a map with a kind still shows as terminal glyphs in the - * graphical field, by the Engine's glyph fallback rule, and what its art - * still lacks. + * Warns for what a map's art still lacks, judged by the Editor's own + * authoring knowledge of its tileset: tiles a tileset piece draws whose + * glyph is no longer there, cells showing the missing-art placeholder the + * author chose, and ground that can show the background through + * see-through edges. The game plays the same, so these never fail + * validation. * - * A raw gameplay glyph the player sees in the graphical field is an - * error: a cell no tile covers, an NPC without a field sprite, and a copy - * of an NPC's glyph showing under its sprite. The map still saves as a - * draft; validation fails until it is presented. Art still to finish is a - * warning: tiles a tileset piece draws whose glyph is no longer there, - * cells showing the tileset's missing-art placeholder the author chose, - * and ground that can show the background through see-through edges. + * What the player would see raw (a gameplay glyph with no graphical + * presentation, an NPC or world object without field art) is the + * Engine's rule, reported as errors by {@see MapCompletenessCheck}. * Graphics the Engine refuses are {@see validate()}'s to report; only the * readable tile layers count here. * @@ -106,65 +100,10 @@ public static function validateCoverage(ProjectMap $map): array if ($coverage === null) { return []; } - [$tileset, $layers, $shownCells] = $coverage; - $shown = []; - foreach ($shownCells as $cell) { - $shown[$cell['y']][$cell['x']] = $cell; - } + [$tileset, $layers] = $coverage; - $issues = [...self::checkStalePieceTiles($map, $tileset, $layers), ...self::checkMissingArtTiles($map, $tileset, $layers), + return [...self::checkStalePieceTiles($map, $tileset, $layers), ...self::checkMissingArtTiles($map, $tileset, $layers), ...self::checkSeeThroughGround($map, $tileset, $layers)]; - foreach ($map->getNpcs()->all() as $npc) { - $name = self::formatNpcName($npc); - if (! array_key_exists('sprites2d', $npc->toArray())) { - // An NPC without a terminal sprite draws nothing; its map glyph, if any, is the cell's. - if ($npc->getVisibleSprite() !== '') { - $issues[] = Issue::error($map->mapId, - sprintf('NPC %s has no field sprite, so its glyph %s shows in the graphical field.', $name, $npc->getVisibleSprite()), - 'Give it an RPG Maker character sheet (sprites2d).'); - } - - continue; - } - - // A map glyph copying the NPC's own glyph is drawn twice in the - // terminal and shows under the sprite in the graphical field. - $cell = $shown[$npc->getY()][$npc->getX()] ?? null; - if ($cell !== null && $cell['glyph'] === $npc->getVisibleSprite()) { - unset($shown[$npc->getY()][$npc->getX()]); - $issues[] = Issue::error($map->mapId, - sprintf('NPC %s stands on a copy of its glyph %s on the %s layer, which shows under its sprite.', - $name, $cell['glyph'], $cell['layer']), - 'Remove the copy from the map if the NPC is always there; otherwise give the cell its tile.'); - } - } - - $cells = array_merge(...array_values(array_map(array_values(...), $shown)) ?: [[]]); - if ($cells === []) { - return $issues; - } - - if ($layers === []) { - return [...$issues, Issue::error($map->mapId, - sprintf('It has no tiles yet, so all %d of its glyph cells show in the graphical field.', count($cells)), - 'Stamp tileset pieces with P, draw the tiles for glyphs already on a layer with T, or paint tiles in the GUI editor.')]; - } - - $hint = 'Draw them with T on that layer when a tileset piece draws the glyph, or paint them in the GUI editor.'; - - $groups = []; - foreach ($cells as $cell) { - $groups[$cell['layer'] . "\0" . $cell['glyph']][] = $cell; - } - foreach ($groups as $group) { - $named = array_map(static fn(array $cell): string => "({$cell['x']}, {$cell['y']})", array_slice($group, 0, self::CELLS_NAMED)); - $rest = count($group) - count($named); - $issues[] = Issue::error($map->mapId, sprintf('%d %s of %s on the %s layer %s no tile, so the glyph shows in the graphical field: %s%s.', - count($group), count($group) === 1 ? 'cell' : 'cells', $group[0]['glyph'], $group[0]['layer'], - count($group) === 1 ? 'has' : 'have', implode(', ', $named), $rest > 0 ? " and {$rest} more" : ''), $hint); - } - - return $issues; } /** @@ -187,6 +126,27 @@ public static function listShownGlyphCells(ProjectMap $map): ?array * @return array{0: Tileset, 1: list, 2: list}|null */ private static function readCoverage(ProjectMap $map): ?array + { + $graphics = self::readGraphics($map); + if ($graphics === null) { + return null; + } + + // A world object that draws covers its glyph cells in the field, as the Engine's presentation applies it. + $covered = WorldObjectAuthoring::readRestingCoverage($map)['glyphs'] ?? []; + $shown = array_values(array_filter($graphics->getShownGlyphCells($map->getLayerSet(), $map->getAssetRoot()), + static fn(array $cell): bool => ! isset($covered[$cell['layer']][$cell['y']][$cell['x']]))); + + return [$graphics->tileset, $graphics->layers, $shown]; + } + + /** + * The map's graphics as the Engine reads them, unsaved edits included: + * its tileset, its readable tile layers and which gameplay layer each + * belongs to. Null when the map has no kind, or its kind or layers cannot + * be read; {@see validate()} reports why. + */ + public static function readGraphics(ProjectMap $map): ?MapGraphics { $ignored = []; $tileset = is_string($tilesetId = $map->getMapDataField(['tileset'])) ? self::loadTileset($map, $tilesetId, $ignored) : null; @@ -196,7 +156,6 @@ private static function readCoverage(ProjectMap $map): ?array $layers = self::readLayers($map, $map->getTileLayerSources(), $ignored); try { - $layerSet = $map->getLayerSet(); $owners = MapGraphics::resolveLayerOwners($map->getMapDataField([MapGraphics::SETTINGS_KEY]), array_map(static fn(MapTileLayer $layer): string => $layer->name, $layers), array_column(array_filter($map->getLayers(), static fn(array $layer): bool => @@ -206,12 +165,7 @@ private static function readCoverage(ProjectMap $map): ?array return null; } - // A world object that draws covers its glyph cells in the field, as the Engine's presentation applies it. - $covered = WorldObjectAuthoring::readRestingCoverage($map)['glyphs'] ?? []; - $shown = array_values(array_filter(new MapGraphics($tileset, $layers, owners: $owners)->getShownGlyphCells($layerSet, $map->getAssetRoot()), - static fn(array $cell): bool => ! isset($covered[$cell['layer']][$cell['y']][$cell['x']]))); - - return [$tileset, $layers, $shown]; + return new MapGraphics($tileset, $layers, owners: $owners); } /** @@ -409,18 +363,6 @@ private static function checkSeeThroughGround(ProjectMap $map, Tileset $tileset, 'Paint ground beneath them on a lower tile layer, such as the map\'s base or the floor they stand on; the game draws nothing under a tile.')]; } - private static function formatNpcName(ProjectNpc $npc): string - { - $name = trim($npc->getName()); - $id = $npc->getId(); - - return match (true) { - $name === '' => $id ?? 'without a name', - $id === null => $name, - default => "{$name} ({$id})", - }; - } - /** * Checks the map data's tile layer settings as the Engine reads them. * diff --git a/src/Validation/MapValidator.php b/src/Validation/MapValidator.php index 0a143c3..8d57fc0 100644 --- a/src/Validation/MapValidator.php +++ b/src/Validation/MapValidator.php @@ -44,8 +44,8 @@ private function __construct() public static function validate(ProjectMap $map, array $mapsById, ?InventoryCatalog $inventory): array { return Issue::attributeToMap([ - ...self::findMarkersWithoutDefinitions($map), - ...self::findChestRewardProblems($map, $inventory), + ...MapCompletenessCheck::validate($map, $inventory), + ...self::findUncheckedChests($map, $inventory), ...self::findDanglingDestinations($map, $mapsById), ...self::findOutOfRangeSpawnPoints($map, $mapsById), ...self::findRetiredData($map), @@ -144,53 +144,16 @@ public static function readInventoryCatalog(ProjectWorkspace $workspace): ?Inven } /** - * Finds chests whose reward the Engine cannot grant, by the Engine's chest - * rule the inspectors and the project report also read. + * Says so when the map has chests and the inventory could not be read, + * so what they give was not checked. * * @return list */ - private static function findChestRewardProblems(ProjectMap $map, ?InventoryCatalog $inventory): array + private static function findUncheckedChests(ProjectMap $map, ?InventoryCatalog $inventory): array { - $chests = array_filter(self::getEventDefinitions($map), ChestLoot::isChest(...)); - - if ($chests === []) { - return []; - } - - if (! $inventory instanceof InventoryCatalog) { - return [Issue::warning($map->mapId, 'The inventory could not be read, so what the chests give was not checked.')]; - } - - $validator = new ChestRewardValidator($inventory); - $issues = []; - - foreach ($chests as $marker => $definition) { - foreach ($validator->validate($map->mapId, strval($marker), (array) ($definition['data'] ?? [])) as $issue) { - $issues[] = new Issue($issue->severity, $issue->where, sprintf('Chest %s: %s', $marker, $issue->message), $issue->hint, $issue->code); - } - } - - return $issues; - } - - /** - * Finds event markers painted on the grid with no event definition. - * - * @param ProjectMap $map The map being saved. - * @return list - */ - private static function findMarkersWithoutDefinitions(ProjectMap $map): array - { - $issues = []; - - foreach ($map->getPlacedEventMarkers() as $marker) { - if ($map->getEventDefinition($marker) === null) { - $issues[] = Issue::error($map->mapId, sprintf('Event marker %s has no definition.', $marker), - 'The game refuses to load the map. Define the event, or clear the marker.'); - } - } - - return $issues; + return $inventory === null && array_filter(self::getEventDefinitions($map), ChestLoot::isChest(...)) !== [] + ? [Issue::warning($map->mapId, 'The inventory could not be read, so what the chests give was not checked.')] + : []; } /** diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 91fafb7..def3f5a 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -2,8 +2,6 @@ namespace Ichiloto\Editor\Validation; -use Ichiloto\Engine\Core\Enumerations\MovementHeading; -use Ichiloto\Engine\Events\Enumerations\EventEngagement; use Ichiloto\Editor\Database\BattleEntryActorResolver; use Ichiloto\Editor\Database\BattleEntryRuleContract; use Ichiloto\Editor\Database\InventoryCatalog; @@ -14,7 +12,6 @@ use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Database\SummonAssignmentDiagnostics; -use Ichiloto\Editor\Events\ChestLoot; use Ichiloto\Editor\Events\CommandMapContext; use Ichiloto\Editor\Events\ProjectScriptCommands; use Ichiloto\Editor\Field\MapEncounters; @@ -1662,7 +1659,7 @@ protected function checkMaps(ProjectWorkspace $workspace): array ...MapGraphicsValidator::validate($map), ...MapGraphicsValidator::validateCoverage($map), ...$this->checkRetiredMapData($map), - ...$this->checkEventMarkers($map), + ...MapCompletenessCheck::validate($map, $this->inventoryCatalog), ...$this->checkDoors($map, $workspace->mapIds), ...$this->checkEncounters($map, $troops), ...$this->checkNpcs($map, $workspace), @@ -1724,37 +1721,6 @@ protected function checkRetiredMapData(ProjectMap $map): array )]; } - /** - * Checks that placed markers and event definitions agree. - * - * @param ProjectMap $map The map. - * @return Issue[] The issues found. - */ - protected function checkEventMarkers(ProjectMap $map): array - { - $issues = []; - $defined = array_keys((array) ($map->getEditableData()['events'] ?? [])); - $placed = $map->getPlacedEventMarkers(); - - foreach (array_diff($placed, $defined) as $marker) { - $issues[] = Issue::error( - $map->mapId, - sprintf('The event layer places marker "%s", which the map does not define.', $marker), - 'The engine throws "Unmapped event markers" when the map loads. Define it, or clear the marker.' - ); - } - - foreach (array_diff($defined, $placed) as $marker) { - $issues[] = Issue::warning( - $map->mapId, - sprintf('Marker "%s" is defined but never placed on the event layer.', $marker), - 'Nothing can trigger it. Place it, or remove the definition.' - ); - } - - - return $issues; - } /** * Checks that doors lead somewhere. @@ -3843,8 +3809,6 @@ protected function checkMapContentReferences(ProjectMap $map, array $known, arra $issues = [ ...$issues, ...$this->checkEventCue($definition['cue'] ?? null, $where, $known), - ...$this->checkEventFacing($definition, $where), - ...$this->checkEventEngagement($definition, $where), ...$this->checkConditions((array) ($definition['conditions'] ?? []), $where, $known), ...$this->checkDialogueVariants((array) ($data['dialogue'] ?? []), $where, $known), ...$this->checkCommands( @@ -3886,10 +3850,6 @@ protected function checkMapContentReferences(ProjectMap $map, array $known, arra ]; } } - - if (ChestLoot::isChest($definition)) { - $issues = [...$issues, ...$this->checkChestLoot($map->mapId, strval($marker), $data)]; - } } foreach ((array) ($map->getEditableData()['npcs'] ?? []) as $npc) { @@ -4035,21 +3995,6 @@ protected function checkBgmVariants(ProjectMap $map, array $known): array return $issues; } - /** - * Checks that a chest can give what it holds, by the Engine's chest rule - * the inspectors and the map save warnings also read. - * - * @param string $mapId The map the chest is on. - * @param string $marker The chest event's marker. - * @param array $data The chest's data. - * @return Issue[] The issues found. - */ - protected function checkChestLoot(string $mapId, string $marker, array $data): array - { - return $this->inventoryCatalog instanceof InventoryCatalog - ? new ChestRewardValidator($this->inventoryCatalog)->validate($mapId, $marker, $data) - : []; - } /** * Checks ScriptEventTrigger's runtime-recognized fields and script picker. @@ -4159,41 +4104,7 @@ protected function checkScriptTrigger( * * @return Issue[] The issues found. */ - /** - * An event's optional facing: the player headings it engages from, as the Engine reads them. - * - * @param array $definition The event definition. - * @return Issue[] The issues found. - */ - protected function checkEventFacing(array $definition, string $where): array - { - if (! array_key_exists('facing', $definition)) { - return []; - } - $facing = $definition['facing']; - $valid = is_array($facing) && array_is_list($facing) && array_all($facing, static fn(mixed $name): bool => is_string($name) - && ($heading = MovementHeading::tryFrom($name)) !== null && $heading !== MovementHeading::NONE); - return $valid ? [] : [Issue::error($where, 'Its facing must list player headings: North, East, South or West.', - 'Choose the headings the player faces to start it, or none for any.')]; - } - - /** - * An event's optional engagement: standing on its cells, or next to them facing one. - * - * @param array $definition The event definition. - * @return Issue[] The issues found. - */ - protected function checkEventEngagement(array $definition, string $where): array - { - if (! array_key_exists('engagement', $definition)) { - return []; - } - $engagement = $definition['engagement']; - - return is_string($engagement) && EventEngagement::tryFrom($engagement) !== null ? [] : [Issue::error($where, - 'Its engagement must be inside or facing.', 'Choose to stand on the event, or to face it from next to it.')]; - } protected function checkEventCue(mixed $cue, string $where, array $known): array { diff --git a/tests/Support/ValidationFixtures.php b/tests/Support/ValidationFixtures.php index 35b14a3..29a22c3 100644 --- a/tests/Support/ValidationFixtures.php +++ b/tests/Support/ValidationFixtures.php @@ -22,6 +22,18 @@ function validateProject(string $root): array return new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)); } +/** + * Rewrites the sample map's legacy `` row in the current style, so the + * Engine can load it: the Engine knows no `blue` style, reads the tag as + * glyphs, and refuses the map's event layer as narrower than its tiles. + * Tests of legacy-tag preservation keep the fixture as it is. + */ +function writeLoadableSampleMap(string $root): void +{ + $path = $root . '/assets/Maps/test-map/test-map.map.php'; + file_put_contents($path, str_replace(['', ''], ['', ''], (string) file_get_contents($path))); +} + /** * Returns the issues whose message contains the given text. * diff --git a/tests/Unit/ChestRewardValidationTest.php b/tests/Unit/ChestRewardValidationTest.php index 3d6916b..17a431f 100644 --- a/tests/Unit/ChestRewardValidationTest.php +++ b/tests/Unit/ChestRewardValidationTest.php @@ -163,8 +163,12 @@ function listChestRewardIssues(string $root): array ]); $where = array_map(static fn(Issue $issue): string => $issue->where, listChestRewardIssues($root)); + $malformed = array_values(array_filter(validateProject($root), static fn(Issue $issue): bool => $issue->code === 'event.definition')); - expect($where)->toBe(['test-map event A', 'test-map event B', 'test-map event E']); + expect($where)->toBe(['test-map event A', 'test-map event B']) + // A chest with no data at all is a malformed event, by the Engine's event rule. + ->and(array_map(static fn(Issue $issue): string => $issue->where, $malformed))->toBe(['test-map event E']) + ->and($malformed[0]->severity)->toBe(Severity::ERROR); }); it('diagnoses a new chest on its inspector row and as a save error, and still saves it as authored', function () { @@ -201,13 +205,13 @@ function listChestRewardIssues(string $root): array $saved = $session->saveMap('test-map'); expect($saved['saved'])->toBe('test-map') - ->and(array_filter(array_column($saved['errors'], 'message'), static fn(string $error): bool => str_starts_with($error, sprintf('Chest %s: ', $marker))))->toHaveCount(1) + ->and(array_filter($saved['errors'], static fn(array $error): bool => $error['where'] === "test-map event {$marker}" + && $error['code'] === ChestRewardValidator::CODE))->toHaveCount(1) ->and((require $root . '/assets/Maps/test-map/test-map.data.php')['events'][$marker]['data']['loot'])->toBe(''); $loot = array_find($session->readInspector('test-map', $marker)['rows'], static fn(array $row): bool => trim($row['label']) === 'Loot'); $session->applyInspector('test-map', $session->readMap('test-map')['revision'], $loot['key'], 'item.tonic'); expect(array_column($session->readInspector('test-map', $marker)['rows'], 'label'))->not->toContain('Reward Error') - ->and(array_filter(array_column($session->saveMap('test-map')['errors'], 'message'), - static fn(string $error): bool => str_starts_with($error, sprintf('Chest %s:', $marker))))->toBe([]); + ->and(array_filter($session->saveMap('test-map')['errors'], static fn(array $error): bool => $error['where'] === "test-map event {$marker}"))->toBe([]); }); diff --git a/tests/Unit/EventEngagementAuthoringTest.php b/tests/Unit/EventEngagementAuthoringTest.php index 0ccdecb..3180187 100644 --- a/tests/Unit/EventEngagementAuthoringTest.php +++ b/tests/Unit/EventEngagementAuthoringTest.php @@ -40,6 +40,9 @@ function createEngagementProject(?string $engagementSource = null): string ], ]; PHP); + // The event layer places R, the event this fixture defines. + $events = $root . '/assets/Maps/test-map/test-map.event.php'; + file_put_contents($events, str_replace(' E ', ' R ', (string) file_get_contents($events))); return $root; } @@ -114,7 +117,7 @@ function getEngagementRow(MapInspector $inspector, ProjectMap $map): array $messages = array_map(static fn($issue): string => $issue->message, new ProjectValidator()->validate($workspace)); expect($row['editable'])->toBeFalse() - ->and(implode("\n", $messages))->toContain('Its engagement must be inside or facing') + ->and(implode("\n", $messages))->toContain('Event engagement must be inside or facing.') ->and(array_filter($messages, static fn(string $message): bool => str_contains($message, 'unsupported root field')))->toBe([]); }); diff --git a/tests/Unit/EventFacingAuthoringTest.php b/tests/Unit/EventFacingAuthoringTest.php index 121849e..a4eea10 100644 --- a/tests/Unit/EventFacingAuthoringTest.php +++ b/tests/Unit/EventFacingAuthoringTest.php @@ -122,7 +122,7 @@ function listFacingRows(MapInspector $inspector, ProjectMap $map): array expect($rows)->toHaveCount(1) ->and($rows[0]['editable'])->toBeFalse() ->and(implode("\n", array_map(static fn($issue): string => $issue->message, new ProjectValidator()->validate(ProjectWorkspace::fromProject($root))))) - ->toContain('Its facing must list player headings'); + ->toContain('Event facing accepts North, East, South and West only.'); }); it('accepts a facing on any trigger kind without calling it an unsupported field', function (string $class) { diff --git a/tests/Unit/MapGraphicsTest.php b/tests/Unit/MapGraphicsTest.php index 9ce9043..21c9478 100644 --- a/tests/Unit/MapGraphicsTest.php +++ b/tests/Unit/MapGraphicsTest.php @@ -7,6 +7,7 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Storage\FileSetTransactionFailure; use Ichiloto\Editor\Validation\Issue; +use Ichiloto\Editor\Validation\MapCompletenessCheck; use Ichiloto\Editor\Validation\MapGraphicsValidator; use Ichiloto\Editor\Validation\MapValidator; use Ichiloto\Editor\Validation\ProjectValidator; @@ -26,7 +27,7 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array { $map = loadLayeredMap($root); $issues = array_filter( - [...MapGraphicsValidator::validate($map), ...MapGraphicsValidator::validateCoverage($map)], + [...MapGraphicsValidator::validate($map), ...MapGraphicsValidator::validateCoverage($map), ...MapCompletenessCheck::validate($map, null)], static fn(Issue $issue): bool => $issue->where === 'test-map' && ($severity === null || $issue->severity === $severity), ); @@ -262,7 +263,11 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array expect((string) file_get_contents($map->dataPath))->toContain('// Authored metadata remains authored.') ->and(loadLayeredMap($root)->getMapDataField(['tileset']))->toBe('home') // Named but not yet painted: every glyph still shows in the graphical field. - ->and(graphicsIssueLines($root))->toBe(['It has no tiles yet, so all 8 of its glyph cells show in the graphical field.']); + ->and(graphicsIssueLines($root))->toBe([ + 'Gameplay glyph \'.\' on terrain has no graphical presentation. At 5 cells: (0, 0), (2, 0), (3, 0) and 2 more.', + 'Gameplay glyph \'/\' on buildings has no graphical presentation. At (1, 0).', + 'Gameplay glyph \'x\' on buildings has no graphical presentation. At 2 cells: (1, 1), (2, 1).', + ]); }); // -- Validation --------------------------------------------------------------- @@ -326,9 +331,9 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array 'Tile layer test-map/graphics/02.decor.tiles.php uses sheets A5, C, which tileset home does not provide.', ])->and(graphicsIssueLines($root, Severity::ERROR))->toBe([ // Tiles from unusable sheets draw nothing, so their glyphs show: the player sees them. - '5 cells of . on the terrain layer have no tile, so the glyph shows in the graphical field: (0, 0), (2, 0), (3, 0) and 2 more.', - '1 cell of / on the buildings layer has no tile, so the glyph shows in the graphical field: (1, 0).', - '2 cells of x on the buildings layer have no tile, so the glyph shows in the graphical field: (1, 1), (2, 1).', + 'Gameplay glyph \'.\' on terrain has no graphical presentation. At 5 cells: (0, 0), (2, 0), (3, 0) and 2 more.', + 'Gameplay glyph \'/\' on buildings has no graphical presentation. At (1, 0).', + 'Gameplay glyph \'x\' on buildings has no graphical presentation. At 2 cells: (1, 1), (2, 1).', ]); }); @@ -349,10 +354,14 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array // sign's copy is named once, for the NPC, not again among the x cells; // the cat stands on another glyph, an uncovered cell like any other. expect(graphicsIssueLines($root, Severity::ERROR))->toBe([ - 'NPC Guard (guard) has no field sprite, so its glyph @ shows in the graphical field.', - 'NPC Sign (sign) stands on a copy of its glyph x on the buildings layer, which shows under its sprite.', - '1 cell of / on the buildings layer has no tile, so the glyph shows in the graphical field: (1, 0).', - '1 cell of x on the buildings layer has no tile, so the glyph shows in the graphical field: (2, 1).', + 'Gameplay glyph \'/\' on buildings has no graphical presentation. At (1, 0).', + // The sign's copy of its glyph shows under it like any uncovered glyph. + 'Gameplay glyph \'x\' on buildings has no graphical presentation. At 2 cells: (1, 1), (2, 1).', + "NPC 'Guard' has no field artwork. At (0, 0).", + // The sheets these NPCs name are not in the project. + "NPC 'Sign' has unavailable field artwork. At (1, 1).", + "NPC 'Lamp' has unavailable field artwork. At (3, 1).", + "NPC 'Cat' has unavailable field artwork. At (2, 1).", ])->and(graphicsIssueLines($root, Severity::WARNING))->toBe([]); }); @@ -411,8 +420,8 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array // The floor belongs to terrain, so the building glyphs above it still show. expect(graphicsIssueLines($root))->toBe([ - '1 cell of / on the buildings layer has no tile, so the glyph shows in the graphical field: (1, 0).', - '2 cells of x on the buildings layer have no tile, so the glyph shows in the graphical field: (1, 1), (2, 1).', + 'Gameplay glyph \'/\' on buildings has no graphical presentation. At (1, 0).', + 'Gameplay glyph \'x\' on buildings has no graphical presentation. At 2 cells: (1, 1), (2, 1).', ]); }); @@ -429,33 +438,37 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array $map->resize(6, 3); expect(MapGraphicsValidator::validate($map))->toBe([]); - // A glyph the player would see is an error the save reports; the save itself still runs. + // A glyph the player would see is an error the save reports, by the Engine's rule; the save itself still runs. writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "0 0 0 0\n0 0 0 0"); $map = loadLayeredMap($root); - $coverage = MapGraphicsValidator::validateCoverage($map); - expect($coverage)->not->toBe([]) - ->and(array_values(array_intersect($messages($map), array_map(static fn(Issue $issue): string => $issue->message, $coverage)))) - ->toBe(array_map(static fn(Issue $issue): string => $issue->message, $coverage)) - ->and(array_all($coverage, static fn(Issue $issue): bool => $issue->severity === Severity::ERROR))->toBeTrue(); + $shown = array_values(array_filter(MapValidator::validate($map, ['test-map' => $map], mapValidatorInventory()), + static fn(Issue $issue): bool => $issue->code === 'graphics.glyph')); + expect($shown)->not->toBe([]) + ->and(array_all($shown, static fn(Issue $issue): bool => $issue->severity === Severity::ERROR && $issue->map === 'test-map'))->toBeTrue(); }); -it('reports a map without a kind as an error only in a project that has tilesets', function () { +it('reports a map without a kind as the Engine\'s error only in a project that has tilesets', function () { $root = layeredMapProject(); expect(MapGraphicsValidator::validate(loadLayeredMap($root)))->toBe([]); + $missing = static fn(): array => array_values(array_filter(MapCompletenessCheck::validate(loadLayeredMap($root), null), + static fn(Issue $issue): bool => $issue->code === 'graphics.missing')); + expect($missing())->toBe([]); + + // In a project with tilesets the field needs the map's graphics: the Engine's rule. writeTestTileset($root, 'interior'); - $issues = MapGraphicsValidator::validate(loadLayeredMap($root)); - expect($issues)->toHaveCount(1) - ->and($issues[0]->severity)->toBe(Severity::ERROR) - ->and($issues[0]->message)->toBe('It has no kind, so it has no tiles or pieces.') - ->and($issues[0]->hint)->toStartWith('Set its Kind in the Inspector, one of the tilesets in assets/Data/Tilesets.'); + expect($missing())->toHaveCount(1) + ->and($missing()[0]->severity)->toBe(Severity::ERROR) + ->and($missing()[0]->hint)->toStartWith('Set its Kind in the Inspector, one of the tilesets in assets/Data/Tilesets.') + ->and(MapGraphicsValidator::validate(loadLayeredMap($root)))->toBe([]); editTestMapData($root, static fn(string $source): string => str_replace("'events' => [],", "'events' => [], 'tileset' => 'interior',", $source)); $map = loadLayeredMap($root); expect(MapGraphicsValidator::validate($map))->toBe([]) - // Coverage is reported apart: the map has a kind but no tiles yet. - ->and(array_map(static fn(Issue $issue): string => $issue->message, MapGraphicsValidator::validateCoverage($map))) - ->toBe(['It has no tiles yet, so all 8 of its glyph cells show in the graphical field.']); + ->and($missing())->toBe([]) + // With a kind and no tiles yet, its glyphs show instead. + ->and(array_values(array_unique(array_filter(array_map(static fn(Issue $issue): ?string => $issue->code, MapCompletenessCheck::validate($map, null)), + static fn(?string $code): bool => str_starts_with((string) $code, 'graphics.')))))->toBe(['graphics.glyph']); }); it('reports coverage through project validation, as ichiloto validate runs it', function () { @@ -466,7 +479,7 @@ function graphicsIssueLines(string $root, ?Severity $severity = null): array new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), static fn(Issue $issue): bool => $issue->where === 'test-map', )); - expect($messages)->toContain('1 cell of x on the buildings layer has no tile, so the glyph shows in the graphical field: (1, 1).'); + expect($messages)->toContain("Gameplay glyph 'x' on buildings has no graphical presentation. At (1, 1)."); }); it('reports graphics through project validation, as ichiloto validate runs it', function () { diff --git a/tests/Unit/MapValidatorTest.php b/tests/Unit/MapValidatorTest.php index 088c75b..30b7798 100644 --- a/tests/Unit/MapValidatorTest.php +++ b/tests/Unit/MapValidatorTest.php @@ -6,6 +6,9 @@ use Ichiloto\Editor\Validation\Issue; use Ichiloto\Editor\Validation\MapValidator; use Ichiloto\Editor\Validation\Severity; +use Ichiloto\Engine\Events\Triggers\DialogueEventTrigger; +use Ichiloto\Engine\Events\Triggers\SleepEventTrigger; +use Ichiloto\Engine\Events\Triggers\TransferPlayerTrigger; /** * Builds an in-memory map with the given data and event rows. @@ -28,7 +31,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' $map = validatorMap('town', [ 'name' => 'Town', 'events' => [ - 'E' => ['class' => 'SomeTrigger', 'data' => []], + 'E' => ['class' => DialogueEventTrigger::class, 'data' => []], ], ], ['E ', ' ']); @@ -43,8 +46,9 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' expect($issues)->toHaveCount(1) ->and($issues[0]->severity)->toBe(Severity::ERROR) ->and($issues[0]->map)->toBe('town') - ->and($issues[0]->message)->toContain('marker E') - ->and($issues[0]->message)->toContain('no definition') + // The Engine's rule: its map loader refuses the marker. + ->and($issues[0]->code)->toBe('event.unmapped') + ->and($issues[0]->message)->toContain('E') ->and($issues[0]->hint)->not->toBe(''); }); @@ -52,7 +56,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' $map = validatorMap('town', [ 'name' => 'Town', 'events' => [ - 'E' => ['class' => 'SomeTrigger', 'data' => []], + 'E' => ['class' => DialogueEventTrigger::class, 'data' => []], ], ], [' E E', 'EE ']); @@ -63,7 +67,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' $map = validatorMap('town', [ 'name' => 'Town', 'events' => [ - 'T' => ['class' => 'Transfer', 'data' => ['destinationMap' => 'vanished-map']], + 'T' => ['class' => TransferPlayerTrigger::class, 'data' => ['destinationMap' => 'vanished-map']], ], ], ['T ', ' ']); @@ -81,7 +85,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' 'name' => 'Town', 'events' => [ 'T' => [ - 'class' => 'Transfer', + 'class' => TransferPlayerTrigger::class, 'data' => [ 'destinationMap' => 'cave', 'spawnPoint' => ['x' => 40, 'y' => 1], @@ -102,8 +106,8 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' $map = validatorMap('town', [ 'name' => 'Town', 'events' => [ - 'S' => ['class' => 'Spawn', 'data' => ['spawnPoint' => ['x' => 1, 'y' => 1]]], - 'B' => ['class' => 'Spawn', 'data' => ['spawnPoint' => ['x' => 9, 'y' => 9]]], + 'S' => ['class' => SleepEventTrigger::class, 'data' => ['spawnPoint' => ['x' => 1, 'y' => 1]]], + 'B' => ['class' => SleepEventTrigger::class, 'data' => ['spawnPoint' => ['x' => 9, 'y' => 9]]], ], ], ['SB ', ' ']); @@ -118,7 +122,7 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' $map = validatorMap('town', [ 'name' => 'Town', 'events' => [ - 'T' => ['class' => 'Transfer', 'data' => ['destinationMap' => 'gone']], + 'T' => ['class' => TransferPlayerTrigger::class, 'data' => ['destinationMap' => 'gone']], ], ], ['TX ', ' ']); diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index 2061d08..1bd8d3c 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -132,6 +132,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('passes a project with nothing wrong with it', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -141,6 +142,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('accepts conditional event cues from the shared runtime contract', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -158,6 +160,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('validates conditional event cues with the shared condition vocabulary', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -251,6 +254,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('validates generic summon availability, policies, identities, and linked actions', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -278,6 +282,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('rejects invalid summon starting assignments and duplicate exclusive holders', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -305,6 +310,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('reports malformed availability and actor assignment payloads', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -325,6 +331,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('reports malformed declared summon policy and condition fields without coercing them', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -346,6 +353,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('matches actor summon assignments to definition ids case-insensitively like runtime', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -362,6 +370,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('rejects non-list and duplicate actor summon assignments', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -440,12 +449,14 @@ function writeValidatorActor(string $root, string $fileId, string $name, string // Drop a stray marker onto the layer, the way a mis-click would. file_put_contents($path, preg_replace('/\n( +)\n/', "\nZ\n", $source, 1)); - $issues = issuesMentioning(validateProject($root), 'places marker "Z"'); + $issues = array_values(array_filter(validateProject($root), static fn(Issue $issue): bool => $issue->code === 'event.unmapped')); + // The Engine's own rule, the one its map loader refuses the map by, found before running it. expect($issues)->toHaveCount(1) ->and($issues[0]->severity)->toBe(Severity::ERROR) - // This is the crash the engine throws on load, found before running it. - ->and($issues[0]->hint)->toContain('Unmapped event markers'); + ->and($issues[0]->map)->toBe('test-map') + ->and($issues[0]->where)->toBe('test-map event Z') + ->and($issues[0]->message)->toContain('Z'); }); it('accepts a marker painted in separate places, as the runtime triggers its exact cells', function () { @@ -466,7 +477,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string ->and(issuesMentioning($issues, 'could not be read'))->toBe([]); }); -it('catches an event defined but never placed', function () { +it('reports an event defined but never placed as the error the Engine refuses the map for', function () { $root = makeTemporaryProject(); editTestMapData($root, static fn(string $source): string => str_replace( @@ -475,11 +486,12 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $source )); - $issues = issuesMentioning(validateProject($root), 'Marker "Q" is defined but never placed'); + $issues = array_values(array_filter(validateProject($root), static fn(Issue $issue): bool => $issue->code === 'event.area')); - // Harmless, but it is dead content: nothing can ever trigger it. + // The Engine's map loader refuses an event with neither cells nor a subject. expect($issues)->toHaveCount(1) - ->and($issues[0]->severity)->toBe(Severity::WARNING); + ->and($issues[0]->severity)->toBe(Severity::ERROR) + ->and($issues[0]->where)->toBe('test-map event Q'); }); it('catches a door that leads nowhere', function () { @@ -545,6 +557,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('does not mistake a repeated value for a repeated key', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); // Two different keys that happen to share a value. @@ -607,6 +620,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('sorts what will crash the game above what is merely dead', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); editTestMapData($root, static fn(string $source): string => str_replace( @@ -622,6 +636,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('accepts a complete sequential compatibility manifest with catalog-backed targets', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -798,6 +813,7 @@ function writeSpreadSkillCatalog(string $root, Ichiloto\Engine\Entities\Skills\S it('checks ability and spell alias targets against their own kind across every skill file', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -836,6 +852,7 @@ function writeSpreadSkillCatalog(string $root, Ichiloto\Engine\Entities\Skills\S it('reports a skill name the catalogue defines twice', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -880,6 +897,7 @@ function writeTransitionCatalog(string $root, string $asset, string $battle = 's it('accepts transitions the Engine can play', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -939,6 +957,7 @@ function reachabilityIssueLines(string $root): array it('reports an event the player can never reach, not where content stands', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -960,6 +979,7 @@ function reachabilityIssueLines(string $root): array it('notes a map nothing reaches yet as a warning, and checks edge trigger destinations', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -983,6 +1003,7 @@ function reachabilityIssueLines(string $root): array it('reports floor the player can reach that no tile covers as an error, and is satisfied once it is painted', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -1028,6 +1049,7 @@ function effectIssueLines(string $root): array it('checks every effect a consumer uses, as that consumer plays it, in both presentations', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -1069,6 +1091,7 @@ function effectIssueLines(string $root): array it('reports a record list entry that names nothing, or a definition the project lacks', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); @@ -1089,6 +1112,7 @@ function effectIssueLines(string $root): array it('checks a summon alias against the summons the project defines', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); writeConsistentEnemies($root); diff --git a/tests/Unit/Session/ChangeSetTest.php b/tests/Unit/Session/ChangeSetTest.php index 242d3d5..31472b1 100644 --- a/tests/Unit/Session/ChangeSetTest.php +++ b/tests/Unit/Session/ChangeSetTest.php @@ -89,7 +89,7 @@ function changeSetMessages(array $outcome, string $severity = 'error'): array expect($outcome['status'])->toBe('refused') ->and($outcome['blocking'])->toBe(1) ->and($outcome['reason'])->toBe('1 error the player would meet.') - ->and(changeSetMessages($outcome))->toBe(['1 cell of x on the buildings layer has no tile, so the glyph shows in the graphical field: (3, 1).']) + ->and(changeSetMessages($outcome))->toBe(["Gameplay glyph 'x' on buildings has no graphical presentation. At (3, 1)."]) ->and($outcome['issues'][0]['map'])->toBe('test-map') ->and($outcome['issues'][0]['hint'])->not->toBe('') ->and(sourceHashTree($root . '/assets'))->toBe($before) @@ -106,7 +106,7 @@ function changeSetMessages(array $outcome, string $severity = 'error'): array $refused = $session->commitChanges(); expect($refused['status'])->toBe('refused') - ->and(changeSetMessages($refused))->toBe(['1 cell of x on the buildings layer has no tile, so the glyph shows in the graphical field: (2, 1).']); + ->and(changeSetMessages($refused))->toBe(["Gameplay glyph 'x' on buildings has no graphical presentation. At (2, 1)."]); // Fixing the defect in the same change set lets it through. $session->beginChanges(); @@ -159,7 +159,7 @@ function changeSetMessages(array $outcome, string $severity = 'error'): array ]); expect($outcome['status'])->toBe('refused') - ->and(changeSetMessages($outcome))->toContain('1 cell of x on the buildings layer has no tile, so the glyph shows in the graphical field: (3, 1).') + ->and(changeSetMessages($outcome))->toContain("Gameplay glyph 'x' on buildings has no graphical presentation. At (3, 1).") ->and(sourceHashTree($root . '/assets'))->toBe($before); }); From d9d526e56c6f2d201e8721ade17bebc3b59262f8 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:48:02 +0200 Subject: [PATCH 318/344] feat(events): bind an event to the world object it describes (event.subject) An event bound to a world object has the Engine's subject shape, {kind: world_object, id}: its painted marker cells and authored area go, and its area is the object's covered cells, so moving or replacing the object moves the event. Unbinding paints the marker on chosen cells. Both are one undo step. Moving or reshaping a bound event is refused, naming its subject; deleting a subject an event names was already refused. --- src/Events/EventAuthoring.php | 89 +++++++++++++++++ src/Session/EditorSession.php | 22 +++++ src/Session/SessionHost.php | 8 ++ .../Unit/Session/EventSubjectSessionTest.php | 99 +++++++++++++++++++ 4 files changed, 218 insertions(+) create mode 100644 tests/Unit/Session/EventSubjectSessionTest.php diff --git a/src/Events/EventAuthoring.php b/src/Events/EventAuthoring.php index 8f4210f..db12348 100644 --- a/src/Events/EventAuthoring.php +++ b/src/Events/EventAuthoring.php @@ -9,6 +9,7 @@ use Ichiloto\Editor\MapSourceRefusal; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Field\MapLandmarks; +use Ichiloto\Editor\Field\WorldObjectAuthoring; use Ichiloto\Editor\ProjectMap; use Ichiloto\Engine\Field\MapLandmarkKind; @@ -24,6 +25,9 @@ */ final class EventAuthoring { + /** The kind of subject an event binds to, as the Engine's event subjects name it. */ + private const string SUBJECT_KIND = 'world_object'; + /** * Places a new event of a type on cells: its marker painted there and * its definition written, as one undo step. @@ -157,6 +161,7 @@ public static function deleteEvent(ProjectMap $map, string $marker): Command public static function moveEvent(ProjectMap $map, string $marker, int $deltaX, int $deltaY, string $label = 'Event move'): ?Command { self::assertEditable($map); + self::refuseBoundEvent($map, $marker); if ($deltaX === 0 && $deltaY === 0) { return null; @@ -171,6 +176,89 @@ public static function moveEvent(ProjectMap $map, string $marker, int $deltaX, i }); } + /** + * Binds an event to the world object it describes, by the Engine's event + * subject: the object's covered cells (its anchor when it covers none) + * become the event's area, so moving or replacing the object takes the + * event with it. The event's painted cells and any authored area go, in + * one undo step; its marker stays its identity. Null when it is already + * bound to that object. + * + * @throws EventRefusal When the map has no such event or world object. + * @throws MapSourceRefusal When the map is read-only or its data cannot take the subject. + */ + public static function bindEventSubject(ProjectMap $map, string $marker, string $objectId): ?Command + { + self::assertEditable($map); + self::assertEventExists($map, $marker); + if (array_find(WorldObjectAuthoring::readEntries($map), static fn(array $entry): bool => ($entry['id'] ?? null) === $objectId) === null) { + throw new EventRefusal(sprintf('%s has no world object %s.', $map->mapId, $objectId)); + } + if ($map->getEventDefinition($marker) === null) { + throw new EventRefusal(sprintf('Event %s has no definition to bind; give it a type first.', $marker)); + } + + return self::recordEdit($map, 'Bind event subject', static function () use ($map, $marker, $objectId): void { + foreach ($map->getEventArea($marker)?->cells ?? [] as [$x, $y]) { + $map->setEventSymbol($x, $y, ' '); + } + if (array_key_exists('area', $map->getEventDefinition($marker) ?? [])) { + $map->removeEventField($marker, ['area']); + } + $map->setEventField($marker, ['subject'], ['kind' => self::SUBJECT_KIND, 'id' => $objectId]); + }); + } + + /** + * Unbinds an event from its subject and paints its marker on cells of + * its own, as one undo step. + * + * @param list $cells The cells it triggers on, as [x, y]. + * @throws EventRefusal When the event has no subject, or a cell leaves the map or holds another event. + * @throws MapSourceRefusal When the map is read-only or its data cannot drop the subject. + */ + public static function unbindEventSubject(ProjectMap $map, string $marker, array $cells): Command + { + self::assertEditable($map); + if (! array_key_exists('subject', $map->getEventDefinition($marker) ?? [])) { + throw new EventRefusal(sprintf('Event %s has no subject to unbind.', $marker)); + } + if ($cells === []) { + throw new EventRefusal(sprintf('Choose the cells event %s triggers on once it has no subject.', $marker)); + } + foreach ($cells as [$x, $y]) { + if ($x < 0 || $y < 0 || $x >= $map->getWidth() || $y >= $map->getHeight()) { + throw new EventRefusal(sprintf('%d, %d is outside %s.', $x, $y, $map->mapId)); + } + $held = $map->getLayerSymbol(MapLayers::EVENT, $x, $y); + if (trim($held) !== '' && $held !== $marker) { + throw new EventRefusal(sprintf('%d, %d already holds event %s.', $x, $y, $held)); + } + } + + return self::recordEdit($map, 'Unbind event subject', static function () use ($map, $marker, $cells): void { + $map->removeEventField($marker, ['subject']); + foreach ($cells as [$x, $y]) { + $map->setEventSymbol($x, $y, $marker); + } + }) ?? throw new EventRefusal(sprintf('Event %s could not be unbound.', $marker)); + } + + /** + * Refuses to move or reshape an event bound to a subject: its area is the + * subject's, so it moves when the object does. + * + * @throws EventRefusal When the event has a subject. + */ + private static function refuseBoundEvent(ProjectMap $map, string $marker): void + { + $subject = $map->getEventDefinition($marker)['subject'] ?? null; + if ($subject !== null) { + throw new EventRefusal(sprintf('Event %s moves with its subject %s; move the world object, or unbind the event first.', + $marker, is_array($subject) && is_string($subject['id'] ?? null) ? $subject['id'] : '(malformed)')); + } + } + /** * Repaints an event as exactly a rectangle. Null when it already was. * @@ -180,6 +268,7 @@ public static function setEventBounds(ProjectMap $map, string $marker, int $x, i string $label = 'Event bounds edit'): ?Command { self::assertEditable($map); + self::refuseBoundEvent($map, $marker); if ($map->getEventArea($marker) === null) { throw new EventRefusal(sprintf('Marker %s is not placed.', $marker)); diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index f445461..559ea83 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1916,6 +1916,28 @@ public function moveEvent(string $mapId, int $revision, string $marker, int $del return ['revision' => $this->getMapRevision($map), 'changed' => $command !== null]; } + /** + * Binds an event to the world object it describes, or unbinds it onto + * cells of its own, as one undo step ({@see EventAuthoring::bindEventSubject()}). + * A bound event's area is its subject's, so it moves with the object and + * cannot be moved or reshaped itself. + * + * @param string|null $objectId The world object's id; null unbinds the event. + * @param list $cells Where an unbound event triggers, as [x, y]; unused when binding. + * @return array{revision: int, changed: bool} + * @throws SessionRefusal When the map is unknown, stale or read-only, the event or object is unknown, or the + * cells cannot take the event. + */ + public function setEventSubject(string $mapId, int $revision, string $marker, ?string $objectId, array $cells = []): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $command = $this->runEdit(static fn(): ?Command => $objectId === null + ? EventAuthoring::unbindEventSubject($map, $marker, $cells) + : EventAuthoring::bindEventSubject($map, $marker, $objectId)); + + return ['revision' => $this->getMapRevision($map), 'changed' => $command !== null]; + } + /** * Repaints an event as exactly a rectangle. * diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index e4dbfda..609c359 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -433,6 +433,14 @@ private function dispatch(string $method, array $params): array self::requireInt($params, 'dx'), self::requireInt($params, 'dy'), ), + 'event.subject' => $session->setEventSubject( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'marker'), + array_key_exists('subject', $params) && (is_string($params['subject']) || $params['subject'] === null) + ? $params['subject'] : throw new InvalidRequest('"subject" must be a world object id, or null to unbind.'), + array_key_exists('cells', $params) ? self::requireCells($params) : [], + ), 'event.bounds' => $session->setEventBounds( self::requireString($params, 'map'), self::requireInt($params, 'revision'), diff --git a/tests/Unit/Session/EventSubjectSessionTest.php b/tests/Unit/Session/EventSubjectSessionTest.php new file mode 100644 index 0000000..c74eb19 --- /dev/null +++ b/tests/Unit/Session/EventSubjectSessionTest.php @@ -0,0 +1,99 @@ + [],", "'events' => [ + 'E' => ['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\DialogueEventTrigger', 'data' => ['dialogue' => [['name' => '', 'text' => 'Lockers.']]]], + ], 'worldObjects' => [ + ['id' => 'lockers', 'anchor' => ['x' => 1, 'y' => 1], 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null, + 'covers' => ['layer' => 'buildings', 'cells' => [[1, 1], [2, 1]], 'tileLayers' => []]], + ],", (string) file_get_contents($file))); + + return $root; +} + +/** Where the Engine resolves an event's area on the map as it stands in the session. @return list */ +function readResolvedEventCells(EditorSession $session, string $marker): array +{ + $map = getSessionMap($session); + $resolution = MapEventResolver::inspect($map->getEditableData(), $map->getEventLayerCells(), 'test-map'); + $event = array_find($resolution->events, static fn(array $event): bool => ($event['marker'] ?? null) === $marker); + + return $event === null ? [] : \Ichiloto\Engine\Core\CellArea::fromArray($event['area'], 'area')->cells; +} + +it('binds an event to the world object it describes, so the event moves with the object', function () { + $root = eventSubjectProject(); + $session = EditorSession::open($root); + + $bound = $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', 'lockers'); + + expect($bound['changed'])->toBeTrue() + ->and(getSessionMap($session)->getEventArea('E'))->toBeNull() + ->and(getSessionMap($session)->getEventDefinition('E')['subject'])->toBe(['kind' => 'world_object', 'id' => 'lockers']) + ->and(readResolvedEventCells($session, 'E'))->toBe([[1, 1], [2, 1]]); + + $session->moveWorldObject('test-map', $session->readMap('test-map')['revision'], 'lockers', 1, 0); + expect(readResolvedEventCells($session, 'E'))->toBe([[2, 1], [3, 1]]); + + $session->saveMap('test-map'); + $saved = EditorSession::open($root); + expect(getSessionMap($saved)->getEventDefinition('E')['subject'])->toBe(['kind' => 'world_object', 'id' => 'lockers']) + ->and(array_filter($saved->validateProject(['test-map'])['issues'], static fn(array $issue): bool => str_starts_with((string) $issue['code'], 'event.'))) + ->toBe([]); +}); + +it('refuses to move a bound event apart from its subject, or to delete a subject an event names', function () { + $session = EditorSession::open(eventSubjectProject()); + $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', 'lockers'); + $revision = $session->readMap('test-map')['revision']; + + expect(fn() => $session->moveEvent('test-map', $revision, 'E', 1, 0)) + ->toThrow(SessionRefusal::class, 'Event E moves with its subject lockers; move the world object, or unbind the event first.') + ->and(fn() => $session->setEventBounds('test-map', $revision, 'E', 0, 0, 1, 1))->toThrow(SessionRefusal::class, 'moves with its subject') + ->and(fn() => $session->deleteWorldObject('test-map', $revision, 'lockers'))->toThrow(SessionRefusal::class, 'test-map event E') + ->and(fn() => $session->setEventSubject('test-map', $revision, 'E', 'no-such-object')) + ->toThrow(SessionRefusal::class, 'test-map has no world object no-such-object.'); +}); + +it('unbinds an event onto cells of its own, and undo puts the subject back', function () { + $session = EditorSession::open(eventSubjectProject()); + $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', 'lockers'); + + $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', null, [[0, 0]]); + expect(getSessionMap($session)->getEventDefinition('E'))->not->toHaveKey('subject') + ->and(readResolvedEventCells($session, 'E'))->toBe([[0, 0]]) + ->and(fn() => $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', null, [[1, 0]])) + ->toThrow(SessionRefusal::class, 'Event E has no subject to unbind.'); + + $session->undo(); + expect(getSessionMap($session)->getEventDefinition('E')['subject']['id'])->toBe('lockers') + ->and(getSessionMap($session)->getEventArea('E'))->toBeNull(); +}); + +it('binds an event through a batch and writes it once the map is valid', function () { + $root = eventSubjectProject(); + + $outcome = ChangeBatch::apply($root, [ + ['method' => 'event.subject', 'params' => ['map' => 'test-map', 'marker' => 'E', 'subject' => 'lockers']], + ['method' => 'worldObjects.move', 'params' => ['map' => 'test-map', 'id' => 'lockers', 'dx' => 1, 'dy' => 0]], + ]); + + expect($outcome['status'])->toBe('saved') + ->and((require $root . '/assets/Maps/test-map/test-map.data.php')['events']['E']['subject'])->toBe(['kind' => 'world_object', 'id' => 'lockers']) + ->and(array_filter($outcome['issues'], static fn(array $issue): bool => str_starts_with((string) $issue['code'], 'event.')))->toBe([]); +}); From 035324e197d4ed92fc07ca0893c61d89056a66f5 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:50:05 +0200 Subject: [PATCH 319/344] docs(guides): the Engine's map rule, subject binding and chosen ids in the script guide --- docs/guides/change-maps-from-a-script.md | 60 +++++++++++++++--------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/docs/guides/change-maps-from-a-script.md b/docs/guides/change-maps-from-a-script.md index 2aa1b7a..d6c84c3 100644 --- a/docs/guides/change-maps-from-a-script.md +++ b/docs/guides/change-maps-from-a-script.md @@ -39,17 +39,27 @@ An error is a defect the player would meet. The editors still save an author's map with errors as a draft and show them; validation and `map:apply` fail until they are fixed. -- A gameplay glyph showing in the graphical field: a cell no tile covers, an - NPC without a field sprite, an NPC standing on a copy of its own glyph, a - map without a kind in a project with tilesets. -- Floor the player can reach with no tile, so the field shows the background. -- An event the player can never reach, or an arrival on a cell they cannot - stand on. -- A chest whose reward the Engine cannot give, or that has none chosen. -- An event marker with no definition, a door or edge to a map that does not - exist, a spawn point outside its map. -- Every other error `ichiloto validate` reports: unknown references, invalid - scripts and the rest. +The map rules are the Engine's own (`MapCompletenessValidator`), the checks +the game itself relies on; the Editor keeps no copy. Each finding carries the +rule as its `code`: + +- `graphics.glyph`: a gameplay glyph with no graphical presentation, shown + raw in the field. `graphics.missing`: a map with no kind in a project with + tilesets. +- `npc.art`, `object.art`, `object.reference`: an NPC or world object without + usable field art, or a world object resource that does not resolve. +- `event.unmapped`, `event.area`, `event.subject`, `event.definition`, + `event.grid`: a marker with no definition, an event with no cells and no + subject, a subject that is missing or competes with painted cells, a + malformed event, an event layer that does not match the map. +- `chest.reward`: a chest whose reward the Engine cannot give, or that has + none chosen. + +The Editor's project checks add the rest `ichiloto validate` reports, also as +errors: floor the player can reach with no tile, an event the player can +never reach, an arrival on a cell they cannot stand on, a door or edge to a +map that does not exist, a spawn point outside its map, unknown references, +invalid scripts. Warnings never refuse a change: tiles a piece draws whose glyph is gone, the missing-art placeholder, see-through ground, a map nothing reaches yet. @@ -120,31 +130,39 @@ Layers are named by `id` in edits. An event row key looks like | Collision | `map.paintOccupancy`, `occupancy.stampPiece` | | Resize | `map.insertLines`, `map.removeLines` (with an `answer`), or the map's size rows | | Events | `event.create {map, cells, type, marker?}`, `event.move {map, marker, dx, dy}`, `event.bounds`, `event.destination`, `event.delete`; fields with `inspector.apply` | +| Bind an event to the object it describes | `event.subject {map, marker, subject}`; `subject: null` with `cells` unbinds it | | World objects | `worldObjects.create`, `worldObjects.move {map, id, dx, dy}`, `worldObjects.place`, `worldObjects.apply`, `worldObjects.add`, `worldObjects.remove`, `worldObjects.delete` | | NPCs | `npc.create`, `npc.move`, `npc.apply`, `npc.add`, `npc.remove`, `npc.duplicate`, `npc.delete` | | Map fields (name, kind, music, encounters, atlas, landmarks) | `inspector.apply` with the map row's key | -| New map | `map.create {name, kind, width, height}` | +| New map | `map.create {name, kind, width, height, id?}`; `id` is its folder, such as `town/inn/cellar` | +| Copy or move a map | `map.duplicate {map, to?}`; `map.move {map, to}` asks first, then `{map, to, destination, confirm: true}` | +| Landmark id | `inspector.apply` with `{target: map-landmarks, index, field: id}`; other maps' atlas parents follow the rename | +| NPC id | `npc.create {..., id?}`, `npc.assignId {..., id?}`, or the NPC's Id row with `npc.apply` | `worldObjects.move` moves an object with what shows it in the terminal: its anchor, the cells it covers and their glyphs, with the tiles those glyphs draw. Moving only the anchor (`worldObjects.place`) leaves its glyphs behind. -## Worked Example: Move A World Object And Its Event +## Worked Example: Bind An Event To Its Object, Then Move Them -Lockers move two cells right, and the inspection event that describes them -moves with them: +An inspection event that describes lockers is bound to them once. From then +on its area is the lockers' covered cells, so the event can never be left +behind when they move: ```json {"changes": [ - {"method": "worldObjects.move", "params": {"map": "town/annex", "id": "annex-lockers", "dx": 2, "dy": 0}}, - {"method": "event.move", "params": {"map": "town/annex", "marker": "F", "dx": 2, "dy": 0}} + {"method": "event.subject", "params": {"map": "town/annex", "marker": "F", "subject": "annex-lockers"}}, + {"method": "inspector.apply", "params": {"map": "town/annex", + "key": {"target": "event", "marker": "F", "path": ["engagement"]}, "value": "facing"}}, + {"method": "worldObjects.move", "params": {"map": "town/annex", "id": "annex-lockers", "dx": 2, "dy": 0}} ]} ``` -Run it with `--check` first. When the lockers would land on another glyph or -another object's cells, the move is refused and names the cell. When the event -would sit where the player cannot reach it, the batch is refused with that -error. +Binding clears the event's painted cells. A bound event cannot be moved or +reshaped by itself, and its object cannot be deleted while the event names +it. Run the batch with `--check` first: a move onto another glyph or another +object's cells is refused and names the cell, and moving furniture onto open +floor usually needs `--relocate-saves`. ## Worked Example: Stamp A Piece From c838a0064b8f7814dc2fefe56604c6fb39e9637f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:55:53 +0200 Subject: [PATCH 320/344] refactor(conditions): the condition and write codecs say which authored lists their line carries whole Music variants asked the question themselves, without map_event markers. ConditionCodec and WorldWriteCodec now own it, so every surface that edits a list through the line can refuse one it would lose part of. --- src/Database/ConditionCodec.php | 51 +++++++++++++++++++++++++++++ src/Database/WorldWriteCodec.php | 49 ++++++++++++++++++++++++++++ src/Field/MapBgmVariants.php | 55 +++----------------------------- 3 files changed, 104 insertions(+), 51 deletions(-) diff --git a/src/Database/ConditionCodec.php b/src/Database/ConditionCodec.php index da90e8d..db51726 100644 --- a/src/Database/ConditionCodec.php +++ b/src/Database/ConditionCodec.php @@ -24,6 +24,57 @@ */ final class ConditionCodec { + /** The keys the line carries per type, beyond type, name and negate. */ + private const array CARRIED_KEYS = [ + 'quest' => ['status'], + 'switch' => ['value'], + 'event' => [], + 'map_event' => ['marker'], + 'variable' => ['op', 'value'], + 'item' => ['quantity'], + 'key_item' => ['quantity'], + ]; + + /** + * Why an authored condition list cannot go through the condition line, + * or null when the line carries it faithfully. + * + * Committing the line writes only what it carries, so a list that is not + * a list, a condition type the line cannot express, a quest status it + * would rewrite, or an authored key outside its vocabulary would be lost + * on commit. Such a list is refused by name instead. + * + * @param mixed $conditions The list as authored. + */ + public static function findCarryIssue(mixed $conditions): ?string + { + if (! is_array($conditions) || ! array_is_list($conditions)) { + return sprintf('the conditions are %s, not a list', is_array($conditions) ? 'a keyed array' : get_debug_type($conditions)); + } + + foreach ($conditions as $index => $condition) { + if (! is_array($condition)) { + return sprintf('condition %d is %s, not an array', $index + 1, get_debug_type($condition)); + } + + if (self::decode(self::encode($condition)) === null) { + return sprintf('condition %d holds a shape the condition editor cannot carry', $index + 1); + } + + $type = strval($condition['type'] ?? ''); + if ($type === 'quest' && ! in_array($condition['status'] ?? 'completed', ['completed', 'active'], true)) { + return sprintf('condition %d has a quest status the condition editor would rewrite', $index + 1); + } + + $extra = array_diff(array_keys($condition), ['type', 'name', 'negate', ...(self::CARRIED_KEYS[$type] ?? [])]); + if ($extra !== []) { + return sprintf('condition %d carries "%s", which the condition editor would drop', $index + 1, implode('", "', array_map(strval(...), $extra))); + } + } + + return null; + } + /** * Returns the condition types the engine's evaluator understands. * diff --git a/src/Database/WorldWriteCodec.php b/src/Database/WorldWriteCodec.php index 9a5f7e4..d793979 100644 --- a/src/Database/WorldWriteCodec.php +++ b/src/Database/WorldWriteCodec.php @@ -29,6 +29,55 @@ final class WorldWriteCodec /** The write types the engine's writer applies. */ public const array TYPES = ['switch', 'event', 'variable', 'quest']; + /** The keys the line carries per type, beyond type and name. */ + private const array CARRIED_KEYS = [ + 'switch' => ['value'], + 'event' => [], + 'variable' => ['op', 'value'], + 'quest' => ['confirm'], + ]; + + /** + * Why an authored write list cannot go through the write line, or null + * when the line carries it faithfully: a list that is not a list, a write + * type or variable operation the line cannot express, a switch or quest + * flag that is not a boolean, or an authored key outside its vocabulary + * would be lost on commit, so such a list is refused by name instead. + * + * @param mixed $sets The list as authored. + */ + public static function findCarryIssue(mixed $sets): ?string + { + if (! is_array($sets) || ! array_is_list($sets)) { + return sprintf('the writes are %s, not a list', is_array($sets) ? 'a keyed array' : get_debug_type($sets)); + } + + foreach ($sets as $index => $set) { + if (! is_array($set)) { + return sprintf('write %d is %s, not an array', $index + 1, get_debug_type($set)); + } + + $type = strval($set['type'] ?? ''); + $flag = ['switch' => 'value', 'quest' => 'confirm'][$type] ?? null; + $lossy = match (true) { + self::decode(self::encode($set)) === null => 'holds a shape the write editor cannot carry', + $type === 'variable' && ! in_array($set['op'] ?? 'set', ['set', 'add'], true) => 'has a variable operation the write editor would rewrite', + $flag !== null && array_key_exists($flag, $set) && ! is_bool($set[$flag]) => sprintf('has a "%s" that is not true or false', $flag), + default => null, + }; + if ($lossy !== null) { + return sprintf('write %d %s', $index + 1, $lossy); + } + + $extra = array_diff(array_keys($set), ['type', 'name', ...(self::CARRIED_KEYS[$type] ?? [])]); + if ($extra !== []) { + return sprintf('write %d carries "%s", which the write editor would drop', $index + 1, implode('", "', array_map(strval(...), $extra))); + } + } + + return null; + } + /** * Encodes a list of writes into the one-line editable form. * diff --git a/src/Field/MapBgmVariants.php b/src/Field/MapBgmVariants.php index 73c8a26..be9f50b 100644 --- a/src/Field/MapBgmVariants.php +++ b/src/Field/MapBgmVariants.php @@ -155,62 +155,15 @@ public function conditionsAt(int $index): array return is_array($conditions) && array_is_list($conditions) ? $conditions : []; } - /** - * The keys the condition line carries per type, beyond type/name/negate. - */ - private const array CONDITION_KEYS = [ - 'switch' => ['value'], - 'event' => [], - 'variable' => ['op', 'value'], - 'item' => ['quantity'], - 'key_item' => ['quantity'], - 'quest' => ['status'], - ]; - /** * Why one variant's conditions cannot go through the condition editor, - * or null when the editor can carry them faithfully. - * - * Committing the editor writes what the condition line carries; a - * condition type the line cannot express, or an authored key outside - * the line's vocabulary, would be silently dropped on commit, so such - * a variant's conditions are refused by name instead. The track stays - * editable either way. + * or null when the editor can carry them faithfully + * ({@see ConditionCodec::findCarryIssue()}). The track stays editable + * either way. */ public function conditionsIssueAt(int $index): ?string { - $conditions = $this->rows[$index]['conditions'] ?? []; - - if (! is_array($conditions) || ! array_is_list($conditions)) { - return sprintf( - 'the conditions are %s, not a list', - is_array($conditions) ? 'a keyed array' : get_debug_type($conditions), - ); - } - - foreach ($conditions as $conditionIndex => $condition) { - if (! is_array($condition)) { - return sprintf('condition %d is %s, not an array', $conditionIndex + 1, get_debug_type($condition)); - } - - if (ConditionCodec::decode(ConditionCodec::encode($condition)) === null) { - return sprintf('condition %d holds a shape the condition editor cannot carry', $conditionIndex + 1); - } - - $type = strval($condition['type'] ?? ''); - $carried = ['type', 'name', 'negate', ...(self::CONDITION_KEYS[$type] ?? [])]; - $extra = array_diff(array_keys($condition), $carried); - - if ($extra !== []) { - return sprintf( - 'condition %d carries "%s", which the condition editor would drop', - $conditionIndex + 1, - implode('", "', array_map(strval(...), $extra)), - ); - } - } - - return null; + return ConditionCodec::findCarryIssue($this->rows[$index]['conditions'] ?? []); } /** From 961209019b0ba308540a816ccf1beebab38d9734 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:56:23 +0200 Subject: [PATCH 321/344] feat(events): every event's trigger fields, inline scripts and dialogue variants as record rows The Engine reads conditions, sets, whenBlocked and cue.conditions on every trigger class. They were Story Script and Cinematic defaults written eagerly and edited as free-form leaves. EventRecordPane now edits them on every event type as the shared condition and write rows, checked as the Engine reads them, written only when set. A list the line would not carry whole is shown read-only by name. A Story Script's inline data.script is edited with the shared event command list, in frames like an NPC's script: inspector.read takes a frame, row keys carry it, and references.list and mapPlacement.apply resolve frame owners. A Dialogue event offers its talk-to npcId and speaks the NPC dialogue-variant mechanism (DialogueVariants, shared with NPCs); plain pages stay pages until conditions, writes or a second variant make them variants. The terminal inspector enters and leaves those frames (Enter, Esc) and hosts the shared write editor for writes rows. Changed behaviour, named plainly: - New Story Script and Cinematic events no longer write empty conditions, sets and whenBlocked. Authored empty lists are left as they are. - Retyping an event keeps its root trigger fields; only its data restarts. - A Story Script's inline script and a Dialogue event's lines are no longer flattened leaf rows keyed by data path; they are record rows keyed by field and frame. Tests that pinned the old keys were moved to the new rows, and the event list add/remove test now uses a Shop's stock, which the inspector still edits as a data list. - The subject and area root keys are no longer offered as rows. --- src/Database/ProjectRecordDatabase.php | 29 + src/Database/RecordSchemaCatalog.php | 136 +++-- src/Dialogue/DialogueVariants.php | 59 ++ src/Editor.php | 212 +++++++- src/Events/EventRecordPane.php | 510 ++++++++++++++++++ src/Events/EventTypeCatalog.php | 20 +- src/Events/EventTypeDefinition.php | 16 +- src/Field/ProjectNpc.php | 27 +- src/Inspector/MapInspector.php | 148 ++++- src/Session/EditorSession.php | 67 ++- src/Session/MapPlacementSession.php | 6 +- src/Session/SessionHost.php | 1 + tests/Pest.php | 1 + tests/Support/EventRecordFixtures.php | 81 +++ tests/Unit/EventRecordTerminalTest.php | 88 +++ tests/Unit/MovementRouteAuthoringTest.php | 18 +- tests/Unit/ResumableStoryEventEditorTest.php | 3 +- .../Session/DialoguePortraitsSessionTest.php | 5 +- .../Session/EventAuthoringSessionTest.php | 19 +- .../Unit/Session/EventRecordAuthoringTest.php | 280 ++++++++++ 20 files changed, 1591 insertions(+), 135 deletions(-) create mode 100644 src/Dialogue/DialogueVariants.php create mode 100644 src/Events/EventRecordPane.php create mode 100644 tests/Support/EventRecordFixtures.php create mode 100644 tests/Unit/EventRecordTerminalTest.php create mode 100644 tests/Unit/Session/EventRecordAuthoringTest.php diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index dfe8d44..268d30c 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -4013,6 +4013,35 @@ public function getFrameSubList(array $framePath): ?RecordSubList return RecordSchemaCatalog::eventCommandList('commands', $this->graphical); } + /** + * The frame enclosing one, and the field id prefix of the row in it that + * opened the frame: the record's own list row, or the command (or + * sub-list entry) that holds the arm, for an interface to land back on. + * + * A record-level list is [key]; a list member's arm ends + * [i, listKey, j, armKey] (an option's `then`, a lane's `commands`); a + * branch, sequence or variant arm ends [i, arm]. + * + * @param array $framePath The frame, not the root. + * @return array{frame: list, landing: string} + */ + public function describeEnclosingFrame(array $framePath): array + { + $framePath = array_values($framePath); + $count = count($framePath); + $chunk = $count === 1 && is_string($framePath[0]) + ? 1 + : ((is_string($framePath[$count - 3] ?? null) && is_int($framePath[$count - 2] ?? null) && is_string($framePath[$count - 1] ?? null) && is_int($framePath[$count - 4] ?? null)) ? 4 : 2); + $enclosing = array_slice($framePath, 0, max(0, $count - $chunk)); + $enclosingList = $enclosing !== [] && is_string($enclosing[0]) ? ($this->schema->commandLists[$enclosing[0]] ?? null) : null; + $landing = $chunk === 1 + ? 'commandList' . ucfirst(strval($framePath[0] ?? '')) + : (($enclosing === [] ? ($this->schema->subList?->prefix ?? 'command') : ($enclosingList?->prefix ?? 'command')) + . intval($framePath[$count - $chunk] ?? 0)); + + return ['frame' => $enclosing, 'landing' => $landing]; + } + /** * Splits a frame path into the record key it starts from and the rest. * diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index cd3d4a6..75aa4b9 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -36,6 +36,8 @@ use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandReference; use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; +use Ichiloto\Engine\Events\Triggers\DialogueEventTrigger; +use Ichiloto\Engine\Events\Triggers\ScriptEventTrigger; use Ichiloto\Engine\Entities\Enemies\Enemy; use Ichiloto\Engine\Entities\Enemies\EnemyCatalog; use Ichiloto\Engine\Entities\Enumerations\ItemScopeNumber; @@ -2004,41 +2006,23 @@ public static function mapNpcs(bool $graphical = false): RecordSchema 'movement' => 'fixed', 'dialogue' => [['text' => 'Hello.']], ], - subList: new RecordSubList( - key: 'dialogue', - prefix: 'variant', - singular: 'dialogue variant', - fields: [ - new RecordField('conditions', 'When', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), - new RecordField('sets', 'Then Set', removeWhenEmpty: true, codec: RecordFieldCodec::WORLD_WRITES), - ], - blank: ['lines' => [['text' => 'Something to say.']]], - nestedLists: [ - '*' => new RecordSubList( - key: 'lines', - prefix: 'line', - singular: 'line', - fields: [ - // The runtime titles a page with its `name`, the - // NPC's own when the key is absent, and no title - // at all for ''. Both are choices, by meaning, - // never a name retyped that goes stale on rename. - RecordField::reference( - 'name', - 'Speaker', - 'actors', - allowsNone: true, - noneLabel: "(the NPC's name)", - blankLabel: '(No speaker)', - ), - RecordField::emotion(DialogueLineSpeaker::forNpcLine()), - new RecordField('text', 'Text'), - ], - blank: ['text' => 'Something to say.'], - ), - ], + subList: self::dialogueVariantList( + // The runtime titles a page with its `name`, the NPC's own + // when the key is absent, and no title at all for ''. Both + // are choices, by meaning, never a name retyped that goes + // stale on rename. + RecordField::reference( + 'name', + 'Speaker', + 'actors', + allowsNone: true, + noneLabel: "(the NPC's name)", + blankLabel: '(No speaker)', + ), + DialogueLineSpeaker::forNpcLine(), + ['text' => 'Something to say.'], // A variant's own script, edited as a frame like a branch arm. - commandArms: ['script' => 'Script'], + ['script' => 'Script'], ), // The NPC's inline script: the shared command vocabulary, in a // frame. The runtime runs it INSTEAD of dialogue when non-empty. @@ -2046,6 +2030,90 @@ public static function mapNpcs(bool $graphical = false): RecordSchema ); } + /** + * Returns one map event as a record pane ({@see \Ichiloto\Editor\Events\EventRecordPane}). + * + * Every trigger class reads the same root fields (EventTriggerFactory): + * the conditions that make it available, the message shown while they + * do not hold, the writes made when it completes, and the conditions + * that delay its cue. They are offered on every event and written only + * when set. A Dialogue event also says who is talked to and speaks + * conditional pages, the same variants an NPC speaks; a Story Script + * runs an inline script with the shared command vocabulary. The pane + * lifts those two out of `data` so they edit as an NPC's do. + * + * @param string|null $className The event's trigger class. + */ + public static function mapEvent(?string $className, bool $graphical = false): RecordSchema + { + $className = $className === null ? null : ltrim($className, '\\'); + $dialogue = $className === DialogueEventTrigger::class; + + return new RecordSchema( + key: 'map_event', + entryNoun: 'event', + storage: RecordStorage::MAP_OWNED, + relativePath: '', + fields: [ + new RecordField('conditions', 'Available When', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), + new RecordField('whenBlocked', 'When Blocked', removeWhenEmpty: true), + new RecordField('sets', 'Then Set', removeWhenEmpty: true, codec: RecordFieldCodec::WORLD_WRITES), + new RecordField('cue.conditions', 'Cue When', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), + // Who a talk-to quest objective counts as talked to; the + // runtime falls back to the first speaker's name. + // Empty, it is not written. + ...($dialogue ? [new RecordField('npcId', 'Talk-To Id', removeWhenEmpty: true)] : []), + ], + labelKey: 'class', + identityKey: null, + // A map Dialogue event's page names its speaker as a text command does. + subList: $dialogue ? self::dialogueVariantList( + new RecordField('name', 'Speaker'), + DialogueLineSpeaker::forText(), + ['name' => '', 'text' => 'Something to say.'], + ) : null, + commandLists: $className === ScriptEventTrigger::class + ? ['script' => self::eventCommandList('script', $graphical)] + : [], + ); + } + + /** + * Returns conditional dialogue as the Engine's ConditionalDialogue reads + * it: variants, the first whose conditions hold spoken, each with the + * writes it makes and its lines. One mechanism for every speaker that + * has them; only how a line names its speaker, and which command lists a + * variant may run, differ. + * + * @param RecordField $speaker The line field naming who speaks. + * @param DialogueLineSpeaker $speakerRule How a line's emotion finds its speaker. + * @param array $blankLine A new line. + * @param array $commandArms The command lists a variant runs, key => label. + */ + private static function dialogueVariantList(RecordField $speaker, DialogueLineSpeaker $speakerRule, array $blankLine, array $commandArms = []): RecordSubList + { + return new RecordSubList( + key: 'dialogue', + prefix: 'variant', + singular: 'dialogue variant', + fields: [ + new RecordField('conditions', 'When', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), + new RecordField('sets', 'Then Set', removeWhenEmpty: true, codec: RecordFieldCodec::WORLD_WRITES), + ], + blank: ['lines' => [$blankLine]], + nestedLists: [ + '*' => new RecordSubList( + key: 'lines', + prefix: 'line', + singular: 'line', + fields: [$speaker, RecordField::emotion($speakerRule), new RecordField('text', 'Text')], + blank: $blankLine, + ), + ], + commandArms: $commandArms, + ); + } + /** * Returns the event-command list under a payload key. * diff --git a/src/Dialogue/DialogueVariants.php b/src/Dialogue/DialogueVariants.php new file mode 100644 index 0000000..2a95b84 --- /dev/null +++ b/src/Dialogue/DialogueVariants.php @@ -0,0 +1,59 @@ + $dialogue The dialogue as authored. + * @return array> The variants. + */ + public static function fold(array $dialogue): array + { + $dialogue = array_values(array_filter($dialogue, is_array(...))); + + if ($dialogue === [] || ConditionalDialogue::isVariantList($dialogue)) { + return $dialogue; + } + + return [['lines' => $dialogue]]; + } + + /** + * The stored form of a variant list: plain pages when the list is one + * unconditioned, write-less, script-less variant. + * + * @param array $variants The variants. + * @return array The dialogue to store. + */ + public static function unfold(array $variants): array + { + $variants = array_values(array_filter($variants, is_array(...))); + + if (count($variants) === 1 && array_keys($variants[0]) === ['lines'] && is_array($variants[0]['lines'])) { + return array_values($variants[0]['lines']); + } + + return $variants; + } +} diff --git a/src/Editor.php b/src/Editor.php index f4f7509..606451b 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -63,6 +63,7 @@ use Ichiloto\Editor\Maps\SaveRelocationPlanner; use Ichiloto\Editor\Maps\MapLayers; use Ichiloto\Editor\Events\EventAuthoring; +use Ichiloto\Editor\Events\EventRecordPane; use Ichiloto\Editor\Events\EventRefusal; use Ichiloto\Editor\Events\EventTypeCatalog; use Ichiloto\Editor\History\Command; @@ -458,6 +459,22 @@ final class Editor * @var array|null */ private ?array $mapConditionField = null; + + /** + * The map inspector field whose writes the shared write editor is + * building, or null when it is not hosting one. + * + * @var array|null + */ + private ?array $mapWriteField = null; + + /** + * The frame of an event's inline commands the Inspector shows, by the + * event's marker; [] for the event's own rows. + * + * @var array{marker: string, frame: list}|null + */ + private ?array $eventInspectorFrame = null; /** * @var string[]|null */ @@ -1583,6 +1600,10 @@ private function isCapturingTypedText(): bool return true; } + if ($this->mapWriteField !== null && ($this->worldWriteEditor->isOpen() || $this->referencePicker->isOpen())) { + return true; + } + return $this->isNpcInspectorHosting() && ( $this->isDatabaseEditing || $this->conditionEditor->isOpen() @@ -2761,6 +2782,27 @@ private function handleInspectorPaneInput(string $input, string $normalizedInput return; } + if ($this->mapWriteField !== null && ($this->worldWriteEditor->isOpen() || $this->referencePicker->isOpen())) { + // An event's completion writes, in the shared write editor the NPC pane hosts. + $this->referencePicker->isOpen() + ? $this->handleReferencePickerInput($input) + : $this->handleWorldWriteEditorInput($input); + + if (! $this->worldWriteEditor->isOpen() && ! $this->referencePicker->isOpen()) { + $this->mapWriteField = null; + } + + $this->renderInspectorArea(); + + return; + } + + if (($input === "\033" || $input === "\x1b") && $this->getOpenEventInspectorFrame() !== []) { + $this->leaveEventInspectorFrame(); + + return; + } + if ($input === "\n" || $input === "\r") { $this->activateInspectorField(); return; @@ -8017,6 +8059,16 @@ private function activateInspectorField(): void return; } + if (($field['target'] ?? null) === EventRecordPane::TARGET && is_array($field['frame'] ?? null)) { + $this->enterEventInspectorFrame((string) ($field['marker'] ?? ''), array_values($field['frame'])); + return; + } + + if (($field['worldWrites'] ?? false) === true) { + $this->openMapWriteEditor($field); + return; + } + // A short enum also lists its options and is stepped through below; a longer list is picked from. if (is_array($field['choices'] ?? null) && $field['choices'] !== [] && ! isset($field['options'])) { $this->openInspectorChoiceDialog($field); @@ -8906,27 +8958,14 @@ private function leaveCommandFrame(): void $database = $this->activeRecordDatabase(); - // A record-level list is [key]; a list member's arm ends - // [i, listKey, j, armKey] -- an option's `then`, a lane's - // `commands`; a branch, sequence or variant arm ends [i, arm]. - $count = count($path); - $chunk = $count === 1 && is_string($path[0]) - ? 1 - : ((is_string($path[$count - 3] ?? null) && is_int($path[$count - 2] ?? null) && is_string($path[$count - 1] ?? null) && is_int($path[$count - 4] ?? null)) ? 4 : 2); - $enclosingCommand = $chunk === 1 ? null : intval($path[$count - $chunk]); - $this->databaseCommandFramePath = array_slice($path, 0, $count - $chunk); - $this->databaseSelectedSettingIndex = 0; + if (! $database instanceof ProjectRecordDatabase) { + $this->databaseCommandFramePath = []; - // Land back on the row the frame belonged to: the record's own list - // row, or the command (or sub-list entry) that holds the arm. - $enclosingList = $this->databaseCommandFramePath !== [] && is_string($this->databaseCommandFramePath[0]) - ? ($database?->schema->commandLists[$this->databaseCommandFramePath[0]] ?? null) - : null; - $landing = $chunk === 1 - ? 'commandList' . ucfirst(strval($path[0])) - : (($this->databaseCommandFramePath === [] - ? ($database?->schema->subList?->prefix ?? 'command') - : ($enclosingList?->prefix ?? 'command')) . $enclosingCommand); + return; + } + + ['frame' => $this->databaseCommandFramePath, 'landing' => $landing] = $database->describeEnclosingFrame($path); + $this->databaseSelectedSettingIndex = 0; foreach ($this->getDatabaseSettingsFields() as $index => $field) { if (str_starts_with((string) ($field['field'] ?? ''), $landing)) { @@ -8935,7 +8974,7 @@ private function leaveCommandFrame(): void } } - $this->setStatus(($database?->describeFramePath($this->databaseCommandFramePath) ?? 'Commands') . '.', StatusLevel::INFO); + $this->setStatus($database->describeFramePath($this->databaseCommandFramePath) . '.', StatusLevel::INFO); $this->renderDatabasePanes(['settings', 'cue']); } @@ -9891,6 +9930,20 @@ private function commitWorldWrites(): void $encoded = $this->worldWriteEditor->encoded(); $this->worldWriteEditor->close(); + if ($this->mapWriteField !== null) { + $field = $this->mapWriteField; + $this->mapWriteField = null; + + // The row applies the line as any interface sends it: its target owns the list it changes. + if ($this->applyInspectorFieldValue($field, $encoded)) { + $this->setStatus(sprintf('%s updated.', $label), StatusLevel::INFO); + } + + $this->renderSelectionDependentArea(); + + return; + } + $field = null; foreach ($this->getDatabaseSettingsFields() as $candidate) { @@ -11349,9 +11402,110 @@ private function getInspectorFields(): array return $fields; } + $frame = $this->getOpenEventInspectorFrame(); + $frameFields = $frame === [] ? null : $inspector->getEventFrameFields($selectedMap, $marker, $frame); + + if ($frameFields !== null) { + // A frame of the event's inline commands, alone under the trail that names it. + return [['label' => $inspector->describeEventFrame($selectedMap, $marker, $frame), 'value' => 'Esc goes back', 'editable' => false], ...$frameFields]; + } + + // A frame an edit or an undo took away leaves the event's own rows. + $this->eventInspectorFrame = null; + return [...$fields, ...$inspector->getEventFields($selectedMap, $marker)]; } + /** + * The frame of the focused event's inline commands the Inspector shows: + * [] for the event's own rows, and once the cursor has moved to another + * event. + * + * @return list + */ + private function getOpenEventInspectorFrame(): array + { + $open = $this->eventInspectorFrame; + + if ($open === null || $this->editingMode !== self::MODE_EVENT || $this->getFocusedEventMarker() !== $open['marker']) { + $this->eventInspectorFrame = null; + + return []; + } + + return $open['frame']; + } + + /** + * Opens a frame of an event's inline commands in the Inspector: a Story + * Script's script, or an arm of one of its commands. + * + * @param list $frame + */ + private function enterEventInspectorFrame(string $marker, array $frame): void + { + $map = $this->getSelectedMap(); + + if (! $map instanceof ProjectMap || $this->createMapInspector()->getEventFrameFields($map, $marker, $frame) === null) { + return; + } + + $this->eventInspectorFrame = ['marker' => $marker, 'frame' => $frame]; + $this->selectedInspectorFieldIndex = 1; + $this->setStatus($this->createMapInspector()->describeEventFrame($map, $marker, $frame) . '. Esc goes back.', StatusLevel::INFO); + $this->renderInspectorArea(); + } + + /** Leaves the open frame of an event's commands for the one enclosing it, back on the row that opened it. */ + private function leaveEventInspectorFrame(): void + { + $map = $this->getSelectedMap(); + $open = $this->eventInspectorFrame; + $definition = $map instanceof ProjectMap && $open !== null ? $map->getEventDefinition($open['marker']) : null; + + if ($open === null || $definition === null) { + $this->eventInspectorFrame = null; + + return; + } + + $pane = $this->createMapInspector()->createEventRecordPane($open['marker'], $definition); + ['frame' => $frame, 'landing' => $landing] = $pane->records()->describeEnclosingFrame($open['frame']); + $this->eventInspectorFrame = $frame === [] ? null : ['marker' => $open['marker'], 'frame' => $frame]; + $this->selectedInspectorFieldIndex = 0; + + foreach ($this->getInspectorFields() as $index => $field) { + if (($field['target'] ?? null) === EventRecordPane::TARGET && str_starts_with((string) ($field['field'] ?? ''), $landing)) { + $this->selectedInspectorFieldIndex = $index; + break; + } + } + + $this->setStatus($frame === [] ? sprintf('Event %s.', $open['marker']) : $pane->describeFrame($frame) . '.', StatusLevel::INFO); + $this->renderInspectorArea(); + } + + /** + * Opens the shared write editor on a map inspector row's writes (an + * event's completion writes, a dialogue variant's), hosted in the + * Inspector pane; the result applies back through the row. + * + * @param array $field The inspector field descriptor. + */ + private function openMapWriteEditor(array $field): void + { + $this->mapWriteField = $field; + $writeTypes = $field['writeTypes'] ?? null; + $this->worldWriteEditor->open( + (string) ($field['field'] ?? 'sets'), + trim((string) ($field['label'] ?? 'Writes')), + WorldWriteCodec::decodeAll((string) ($field['value'] ?? '')), + is_array($writeTypes) ? $writeTypes : null, + ); + $this->statusMessage = 'Building writes.'; + $this->renderInspectorArea(); + } + /** The map inspector over the project's references as the selected map sees them. */ private function createMapInspector(): MapInspector { @@ -12603,7 +12757,7 @@ private function openInspectorReferenceDialog(array $field): bool $category = (string) ($field['reference'] ?? ''); $target = (string) ($field['target'] ?? ''); - if ($category === '' || ! in_array($target, ['map', 'map-data', 'map-kind', 'map-encounters', 'map-bgm-variants'], true)) { + if ($category === '' || ! in_array($target, ['map', 'map-data', 'map-kind', 'map-encounters', 'map-bgm-variants', EventRecordPane::TARGET], true)) { return false; } @@ -12635,7 +12789,7 @@ private function openInspectorReferenceDialog(array $field): bool ]); } - if (($field['optional'] ?? false) === true) { + if (($field['optional'] ?? false) === true || ($field['allowsNone'] ?? false) === true) { // Optional data is cleared by choosing nothing, which removes it. array_unshift($entries, [ 'label' => 'None', @@ -13577,7 +13731,7 @@ private function recordPaneMetrics(): array return $this->cutsceneRecordPaneMetrics(); } - if ($this->isNpcInspectorHosting() || $this->mapConditionField !== null) { + if ($this->isNpcInspectorHosting() || $this->mapConditionField !== null || $this->mapWriteField !== null) { return [ 'width' => $this->getWindowContentWidth($layout['rightWidth']), 'rows' => max(1, $layout['contentHeight'] - 2), @@ -13802,6 +13956,16 @@ private function getInspectorLines(): array } } + if ($this->mapWriteField !== null) { + if ($this->referencePicker->isOpen()) { + return $this->buildReferencePickerRows(); + } + + if ($this->worldWriteEditor->isOpen()) { + return $this->buildWorldWriteEditorRows(); + } + } + if ($this->isNpcInspectorHosting() && $this->npcCreationInProgress !== null) { return $this->buildNpcNamePromptRows(); } diff --git a/src/Events/EventRecordPane.php b/src/Events/EventRecordPane.php new file mode 100644 index 0000000..ffed42f --- /dev/null +++ b/src/Events/EventRecordPane.php @@ -0,0 +1,510 @@ + The data keys this pane lifted. */ + private readonly array $lifted; + + /** + * @param string $marker The event's marker. + * @param array $definition The event's definition as the map holds it. + * @param ReferenceCatalog|null $references The project's references, for pickers and dialogue emotions. + */ + public function __construct( + public readonly string $marker, + private readonly array $definition, + ?ReferenceCatalog $references = null, + bool $graphical = false, + ) { + $className = is_string($definition['class'] ?? null) ? $definition['class'] : null; + $this->schema = RecordSchemaCatalog::mapEvent($className, $graphical); + [$payload, $this->lifted] = $this->project($definition); + $this->records = ProjectRecordDatabase::overOwnedList($this->schema, '', [$payload], static function (array $entries): void {}, $graphical); + + if ($references !== null) { + $this->records->useAuthoringReferences($references); + } + } + + /** + * The definition paths these rows edit, which no other row of the event + * describes: the generic trigger fields always, and a type's data lists + * when they could be lifted. + * + * @return list> + */ + public function listOwnedPaths(): array + { + $paths = []; + + foreach ($this->schema->fields as $field) { + if (! in_array($field->key, self::DATA_KEYS, true)) { + $paths[] = explode('.', $field->key); + } + } + + foreach ($this->lifted as $key) { + $paths[] = ['data', $key]; + } + + return $paths; + } + + /** The record pane, for what reads its rows by its own rules (a movement route's owner, an item's place). */ + public function records(): ProjectRecordDatabase + { + return $this->records; + } + + /** + * The rows of one frame: at the root the generic trigger fields, the + * talk-to id, the dialogue variants and the row that opens an inline + * script; inside a script frame, its commands. Null when the frame no + * longer resolves. + * + * @param list $frame The frame; [] for the event's own rows. + * @return list>|null + */ + public function getFields(array $frame = []): ?array + { + if ($frame !== [] && $this->records->getFrameCommands(0, $frame) === null) { + return null; + } + + $payload = (array) ($this->records->getRecordByIndex(0)?->toArray() ?? []); + $rows = []; + + $subList = $this->schema->subList; + if ($frame === [] && $subList !== null) { + // The heading a first variant is added at, as a list beside a record's own is headed. + $rows[] = ['label' => ucfirst($subList->key), 'value' => (string) count((array) ($payload[$subList->key] ?? [])), + 'field' => $subList->prefix . 'List', 'editable' => false, 'item' => true, 'itemNoun' => $subList->singular, + 'listHeading' => true, 'childNoun' => $subList->singular, 'target' => self::TARGET, 'marker' => $this->marker, 'eventFrame' => []]; + } + + foreach ($this->records->getFrameSettingsFields(0, $frame) as $row) { + $rows[] = $this->describeRow($row, $frame, $payload); + } + + if ($frame === [] && in_array('script', $this->lifted, true) && ($payload['script'] ?? []) !== [] + && trim((string) ($this->definition['data']['scriptId'] ?? '')) !== '') { + // The Engine runs a non-empty inline script instead of the script the id names. + $rows[] = ['label' => '! Inline script replaces Event Script', 'value' => 'the game runs the inline commands', 'editable' => false]; + } + + return $rows; + } + + /** The trail an author reads for a frame, such as "Script > Command 3 > Then". */ + public function describeFrame(array $frame): string + { + return $this->records->describeFramePath($frame); + } + + /** + * The definition the event has once one row is edited, or null when the + * edit changes nothing. Conditions and writes are checked as the Engine + * reads them. + * + * @param list $frame The frame the row is in. + * @param string $fieldId The row's field id. + * @return array|null + * @throws InspectorRefusal When the row is gone or read-only, or the value is refused. + */ + public function applyField(array $frame, string $fieldId, string $rawValue): ?array + { + $row = $this->requireRow($frame, $fieldId); + + if (! self::isEditable($row)) { + throw new InspectorRefusal(sprintf('%s cannot be edited here.', trim((string) ($row['label'] ?? 'That row')))); + } + + return $this->edit(fn(RecordAuthoring $authoring) => $authoring->applyField($this->records, 0, $frame, $fieldId, $rawValue, + trim((string) ($row['label'] ?? 'Event field')))); + } + + /** + * The definition the event has once an item is added at a row: after the + * item the row belongs to (a command, a dialogue variant, a line, an + * option), or at the end of the frame for a row that names none. + * + * @param list $frame The frame the row is in. + * @param string|null $fieldId The row's field id; null for the end of the frame. + * @return array{definition: array|null, summary: string} + * @throws InspectorRefusal When the frame is gone or the row's item cannot take another. + */ + public function addItem(array $frame, ?string $fieldId): array + { + $this->requireFrame($frame); + $ownListHeading = $frame === [] && $this->schema->subList !== null ? $this->schema->subList->prefix . 'List' : null; + // A frame's placeholder and the heading of the event's own list add at its end. + $fieldId = in_array($fieldId, [ProjectRecordDatabase::EMPTY_FRAME_FIELD, $ownListHeading], true) ? null : $fieldId; + $item = $fieldId === null ? null : $this->records->locateItem(0, $frame, $fieldId); + $noun = match (true) { + $item?->kind === RecordItem::LIST => $item->childNoun ?? $item->noun, + $item !== null => $item->noun, + default => $this->records->getFrameSubList($frame)?->singular ?? 'item', + }; + + if ($fieldId !== null && $item === null && $frame === []) { + throw new InspectorRefusal('Nothing here is a list to add to.'); + } + + return ['definition' => $this->edit(fn(RecordAuthoring $authoring) => $authoring->addItem($this->records, 0, $frame, $fieldId)), + 'summary' => sprintf('Added %s.', self::describeOne($noun))]; + } + + /** + * The definition the event has once the item a row belongs to is removed. + * + * @param list $frame The frame the row is in. + * @return array{definition: array|null, summary: string} + * @throws InspectorRefusal When the frame is gone or the row belongs to no item. + */ + public function removeItem(array $frame, string $fieldId): array + { + $this->requireFrame($frame); + $item = $this->records->locateItem(0, $frame, $fieldId); + + if ($item === null || $item->kind === RecordItem::LIST) { + throw new InspectorRefusal('Nothing here is a list entry to remove.'); + } + + return ['definition' => $this->edit(fn(RecordAuthoring $authoring) => $authoring->removeItem($this->records, 0, $frame, $fieldId)), + 'summary' => sprintf('Removed %s.', self::describeOne($item->noun))]; + } + + /** + * Makes one change through the record pane and returns the definition it + * leaves, refusing conditions and writes the Engine would not read. + * + * @param callable(RecordAuthoring): mixed $change + * @return array|null + * @throws InspectorRefusal + */ + private function edit(callable $change): ?array + { + try { + $change(new RecordAuthoring()); + } catch (RecordRefusal $refusal) { + throw new InspectorRefusal($refusal->getMessage(), previous: $refusal); + } + + $next = $this->unproject((array) ($this->records->getRecordByIndex(0)?->toArray() ?? [])); + + if ($next === $this->definition) { + return null; + } + + $problem = self::findInvalidState($this->definition, $next); + + if ($problem !== null) { + throw new InspectorRefusal($problem); + } + + return $next; + } + + /** + * The record the pane edits: the definition with the type's data lists + * lifted to its root. A list in a shape the pane would not carry whole + * is left where it is, for the event's other rows to show as authored. + * + * @param array $definition + * @return array{0: array, 1: list} + */ + private function project(array $definition): array + { + $data = $definition['data'] ?? null; + $edited = [ + ...array_map(static fn(RecordField $field): string => $field->key, $this->schema->fields), + ...($this->schema->subList === null ? [] : [$this->schema->subList->key]), + ...array_keys($this->schema->commandLists), + ]; + $lifted = []; + + foreach (self::DATA_KEYS as $key) { + if (! in_array($key, $edited, true) || ! is_array($data) || array_key_exists($key, $definition)) { + // Not this type's, no data to lift it from, or a root key of that name already there. + continue; + } + + if (! array_key_exists($key, $data)) { + $lifted[] = $key; + continue; + } + + $value = $data[$key]; + $whole = match ($key) { + 'npcId' => is_scalar($value), + default => is_array($value) && array_is_list($value) && array_all($value, static fn(mixed $entry): bool => is_array($entry)), + }; + + if (! $whole) { + continue; + } + + $definition[$key] = $key === 'dialogue' ? DialogueVariants::fold($value) : $value; + unset($definition['data'][$key]); + $lifted[] = $key; + } + + return [$definition, $lifted]; + } + + /** + * The definition a record payload stands for: lifted lists put back in + * `data`, exactly as authored where nobody changed them. + * + * @param array $payload + * @return array + */ + private function unproject(array $payload): array + { + [$initial] = $this->project($this->definition); + $next = $payload; + + foreach ($this->lifted as $key) { + $held = array_key_exists($key, $payload); + $value = $payload[$key] ?? null; + unset($next[$key]); + + if ($held === array_key_exists($key, $initial) && $value === ($initial[$key] ?? null)) { + if (array_key_exists($key, (array) ($this->definition['data'] ?? []))) { + $next['data'][$key] = $this->definition['data'][$key]; + } + + continue; + } + + if (! $held || ($key === 'script' && $value === [])) { + // An inline script with no commands leaves the event to its Event Script. + continue; + } + + $next['data'][$key] = $key === 'dialogue' && is_array($value) ? DialogueVariants::unfold($value) : $value; + } + + if (is_array($next['data'] ?? null) && is_array($this->definition['data'] ?? null)) { + // Authored keys keep their places; a new one follows them. + $next['data'] = [...array_intersect_key(array_replace($this->definition['data'], $next['data']), $next['data']), ...$next['data']]; + } + + return $next; + } + + /** + * A record row as the inspector lists it: aimed back at this pane, in its + * frame, with what it belongs to, and refused by name when it is a + * condition or write list the line would not carry whole. + * + * @param array $row + * @param list $frame + * @param array $payload + * @return array + */ + private function describeRow(array $row, array $frame, array $payload): array + { + $row['target'] = self::TARGET; + $row['marker'] = $this->marker; + $row['eventFrame'] = $frame; + $fieldId = (string) ($row['field'] ?? ''); + $item = $fieldId === '' ? null : $this->records->locateItem(0, $frame, $fieldId); + + if ($item !== null) { + $row['item'] = true; + $row['itemNoun'] = $item->noun; + if ($item->kind === RecordItem::LIST) { $row['listHeading'] = true; } + if ($item->childNoun !== null) { $row['childNoun'] = $item->childNoun; } + } + + $isConditions = ($row['conditions'] ?? false) === true; + $isWrites = ($row['worldWrites'] ?? false) === true; + + if ($isConditions || $isWrites) { + $authored = is_array($row['dataPath'] ?? null) ? self::readPath($payload, $row['dataPath']) : self::readPath($payload, explode('.', $fieldId)); + $issue = $authored === null ? null : ($isConditions ? ConditionCodec::findCarryIssue($authored) : WorldWriteCodec::findCarryIssue($authored)); + + if ($issue !== null) { + // Shown as authored and left alone: committing the line would lose what it cannot carry. + return [...array_diff_key($row, array_flip(['conditions', 'worldWrites'])), 'value' => '! ' . $issue, 'editable' => false]; + } + + if ($isConditions) { + // The map inspector's condition editor, in every interface that hosts one. + $row['mapConditions'] = true; + $row['encoded'] = (string) ($row['value'] ?? ''); + $row['conditionsTitle'] = trim((string) ($row['label'] ?? 'Conditions')); + $row['conditionsHint'] = 'Building conditions. An empty list always holds.'; + } + } + + if (is_array($row['mapPlacement']['sourcePaths'] ?? null)) { + // A lifted list's commands are at their place in the event's data. + $row['mapPlacement']['sourcePaths'] = array_map(fn(array $path): array => in_array($path[0] ?? null, $this->lifted, true) + ? ['data', ...$path] : $path, $row['mapPlacement']['sourcePaths']); + } + + return $row; + } + + /** One of a kind of item, as an author reads it: "a command", "an option". */ + private static function describeOne(string $noun): string + { + return (preg_match('/^[aeiou]/i', $noun) === 1 ? 'an ' : 'a ') . $noun; + } + + /** Whether a row is one an edit applies through: typed, picked, stepped, or built in an editor. */ + private static function isEditable(array $row): bool + { + if (($row['editable'] ?? true) === false) { + return false; + } + + return isset($row['control']) || isset($row['options']) || isset($row['choices']) || isset($row['reference']) + || ($row['conditions'] ?? false) === true || ($row['worldWrites'] ?? false) === true; + } + + /** + * @param list $frame + * @return array + * @throws InspectorRefusal + */ + private function requireRow(array $frame, string $fieldId): array + { + $rows = $this->getFields($frame) ?? throw new InspectorRefusal(sprintf('%s is no longer there; read the event again.', $this->describeFrame($frame))); + + return array_find($rows, static fn(array $row): bool => ($row['field'] ?? null) === $fieldId) + ?? throw new InspectorRefusal('That row is no longer on the event; read it again.'); + } + + /** + * @param list $frame + * @throws InspectorRefusal + */ + private function requireFrame(array $frame): void + { + if ($frame !== [] && $this->records->getFrameCommands(0, $frame) === null) { + throw new InspectorRefusal(sprintf('%s is no longer there; read the event again.', $this->describeFrame($frame))); + } + } + + /** @param list $path */ + private static function readPath(array $payload, array $path): mixed + { + $current = $payload; + + foreach ($path as $segment) { + if (! is_array($current) || ! array_key_exists($segment, $current)) { + return null; + } + + $current = $current[$segment]; + } + + return $current; + } + + /** + * Why the Engine would refuse a condition or write list an edit changed, + * or null when it reads every one. Lists the edit left alone are not its + * to answer for. + * + * @param array $before + * @param array $after + */ + private static function findInvalidState(array $before, array $after): ?string + { + $old = self::collectWorldStateLists($before); + + foreach (self::collectWorldStateLists($after) as $source => [$kind, $list]) { + if (($old[$source][1] ?? null) === $list) { + continue; + } + + try { + $kind === RecordFieldCodec::CONDITIONS + ? WorldConditionEvaluator::validateAll($list, $source) + : WorldStateWriter::validateAll($list, $source); + } catch (InvalidArgumentException $error) { + return $error->getMessage(); + } + } + + return null; + } + + /** + * Every condition and write list a definition holds, by where it is. + * + * @return array}> + */ + private static function collectWorldStateLists(array $definition): array + { + $lists = []; + $add = static function (string $source, RecordFieldCodec $kind, mixed $list) use (&$lists): void { + if (is_array($list)) { $lists[$source] = [$kind, $list]; } + }; + $add('conditions', RecordFieldCodec::CONDITIONS, $definition['conditions'] ?? null); + $add('sets', RecordFieldCodec::WORLD_WRITES, $definition['sets'] ?? null); + $add('cue.conditions', RecordFieldCodec::CONDITIONS, $definition['cue']['conditions'] ?? null); + $dialogue = $definition['data']['dialogue'] ?? null; + + if (is_array($dialogue)) { + foreach (DialogueVariants::fold($dialogue) as $index => $variant) { + $add(sprintf('dialogue[%d].conditions', $index), RecordFieldCodec::CONDITIONS, $variant['conditions'] ?? null); + $add(sprintf('dialogue[%d].sets', $index), RecordFieldCodec::WORLD_WRITES, $variant['sets'] ?? null); + } + } + + return $lists; + } +} diff --git a/src/Events/EventTypeCatalog.php b/src/Events/EventTypeCatalog.php index 9c86b9e..9d3f384 100644 --- a/src/Events/EventTypeCatalog.php +++ b/src/Events/EventTypeCatalog.php @@ -14,6 +14,12 @@ /** * Lists valid event types that can be assigned in the editor. + * + * A type owns its class and its data. The root fields every trigger reads + * whatever its class (`conditions`, `sets`, `whenBlocked`, `cue` and its + * `conditions`, `facing`, `engagement`) belong to no type: they are offered + * on every event and written only when an author sets them + * ({@see EventRecordPane}). */ final class EventTypeCatalog { @@ -41,12 +47,7 @@ className: ScriptEventTrigger::class, 'mode' => 'action', 'reusable' => false, ], - defaultDefinitionFields: [ - 'conditions' => [], - 'sets' => [], - 'whenBlocked' => '', - 'cue' => ['symbol' => '', 'color' => 'bright-yellow'], - ], + defaultDefinitionFields: ['cue' => ['symbol' => '', 'color' => 'bright-yellow']], ), new EventTypeDefinition( label: 'Cinematic', @@ -57,12 +58,7 @@ className: CinematicEventTrigger::class, 'mode' => 'auto', 'reusable' => false, ], - defaultDefinitionFields: [ - 'conditions' => [], - 'sets' => [], - 'whenBlocked' => '', - 'cue' => ['symbol' => '', 'color' => 'bright-yellow'], - ], + defaultDefinitionFields: ['cue' => ['symbol' => '', 'color' => 'bright-yellow']], ), new EventTypeDefinition( label: 'Dialogue', diff --git a/src/Events/EventTypeDefinition.php b/src/Events/EventTypeDefinition.php index 0975543..6c43358 100644 --- a/src/Events/EventTypeDefinition.php +++ b/src/Events/EventTypeDefinition.php @@ -31,9 +31,11 @@ public function __construct( /** * The definition an event of this type has. An event already of this - * type keeps everything it holds and gains any default it lacks; any - * other event starts again from the defaults, since another type's data - * means nothing to this one. + * type keeps everything it holds and gains any default it lacks. Any + * other event starts its data again from the defaults, since another + * type's data means nothing to this one, and keeps its root fields + * (conditions, writes, its cue, facing and engagement), which every + * trigger reads whatever its class. * * @param array|null $current The event's definition now, if it has one. * @return array @@ -46,8 +48,12 @@ public function buildDefinition(?array $current): array ...$this->defaultDefinitionFields, ]; - return is_array($current) && ($current['class'] ?? null) === $this->className + if (! is_array($current)) { + return $defaults; + } + + return ($current['class'] ?? null) === $this->className ? array_replace_recursive($defaults, $current) - : $defaults; + : [...$defaults, ...array_diff_key($current, ['class' => true, 'data' => true])]; } } diff --git a/src/Field/ProjectNpc.php b/src/Field/ProjectNpc.php index f9fcef6..914f88f 100644 --- a/src/Field/ProjectNpc.php +++ b/src/Field/ProjectNpc.php @@ -4,6 +4,8 @@ namespace Ichiloto\Editor\Field; +use Ichiloto\Editor\Dialogue\DialogueVariants; + /** * One map-local NPC, as the engine's `Field\Npc` will read it. * @@ -281,17 +283,7 @@ public function hasDialogueVariants(): bool */ public function getDialogueAsVariants(): array { - $dialogue = $this->getDialogue(); - - if ($dialogue === []) { - return []; - } - - if ($this->hasDialogueVariants()) { - return $dialogue; - } - - return [['lines' => $dialogue]]; + return DialogueVariants::fold($this->getDialogue()); } /** @@ -303,18 +295,7 @@ public function getDialogueAsVariants(): array */ public static function dialogueFromVariants(array $variants): array { - $variants = array_values(array_filter($variants, is_array(...))); - - if (count($variants) === 1) { - $only = $variants[0]; - $extras = array_diff(array_keys($only), ['lines']); - - if ($extras === [] && is_array($only['lines'] ?? null)) { - return array_values($only['lines']); - } - } - - return $variants; + return DialogueVariants::unfold($variants); } /** diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index ae2bb0f..33a3026 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -17,6 +17,7 @@ use Ichiloto\Editor\Database\RecordSchemaCatalog; use Ichiloto\Editor\Events\ChestLoot; use Ichiloto\Editor\Events\EventAuthoring; +use Ichiloto\Editor\Events\EventRecordPane; use Ichiloto\Editor\Events\EventRefusal; use Ichiloto\Editor\Events\EventTypeCatalog; use Ichiloto\Editor\Events\EventTypeDefinition; @@ -302,6 +303,9 @@ public function apply(ProjectMap $map, array $field, string $rawValue): ?Command if (($field['destination'] ?? false) === true) { throw new InspectorRefusal('A destination is set with its spawn point; choose both together.'); } + if (($field['target'] ?? null) === EventRecordPane::TARGET) { + return $this->applyEventRecordField($map, $field, $rawValue); + } $control = self::findControl($field); $type = $control?->type ?? InputControlType::TEXT; @@ -448,6 +452,8 @@ public static function describeRowKey(array $field): ?array 'index' => isset($field['index']) ? (int) $field['index'] : null, // A list heading has no path of its own; its list's names it. 'list' => is_array($field['list']['path'] ?? null) ? array_values(array_map('strval', $field['list']['path'])) : null, + // An event record row is named within the frame of commands it is in. + 'frame' => is_array($field['eventFrame'] ?? null) ? array_values($field['eventFrame']) : null, ], static fn(mixed $value): bool => $value !== null); } @@ -543,6 +549,10 @@ public function addListEntry(ProjectMap $map, array $field): ?InspectorListEdit sprintf('Added variant %d. Pick its track; it stays inactive until one is chosen.', $variants->count() + 1)); } + if (($field['target'] ?? null) === EventRecordPane::TARGET) { + return $this->editEventRecordItems($map, $field, false); + } + $list = self::findEventList($field) ?? throw new InspectorRefusal('Nothing here is a list to add to.'); $marker = (string) $field['marker']; $entries = $map->getEventField($marker, $list['path']); @@ -602,6 +612,10 @@ public function removeListEntry(ProjectMap $map, array $field): ?InspectorListEd sprintf('Removed variant %d.', $index + 1)); } + if (($field['target'] ?? null) === EventRecordPane::TARGET) { + return $this->editEventRecordItems($map, $field, true); + } + $list = self::findEventList($field) ?? throw new InspectorRefusal('Nothing here is a list entry to remove.'); $marker = (string) $field['marker']; $entries = $map->getEventField($marker, $list['path']); @@ -1462,14 +1476,28 @@ private function mapBgmVariantFields(ProjectMap $map, array $known): array /** * Builds editable inspector fields for event data. * + * The fields every trigger reads beside its class and data, and the lists + * a type keeps that edit as an NPC's do, are the event's record rows + * ({@see EventRecordPane}); the rest of its data and its cue are rows of + * their own. + * * @param string $marker The event marker. * @param array $definition The event definition. * @return array> */ public function buildEventDataFields(string $marker, array $definition): array { + $pane = $this->createEventRecordPane($marker, $definition); + $shown = $definition; + foreach ($pane->listOwnedPaths() as $path) { + // Only this display copy changes: the record rows describe these. + $shown = self::withoutPath($shown, $path); + } + $recordRows = $pane->getFields() ?? []; + $cueRecordRows = array_values(array_filter($recordRows, static fn(array $row): bool => ($row['field'] ?? null) === 'cue.conditions')); + $recordRows = array_values(array_filter($recordRows, static fn(array $row): bool => ($row['field'] ?? null) !== 'cue.conditions')); $fields = []; - $eventData = $definition['data'] ?? []; + $eventData = $shown['data'] ?? []; if (is_array($eventData)) { // The type's optional data is offered as it reads when absent, @@ -1478,7 +1506,7 @@ public function buildEventDataFields(string $marker, array $definition): array $hasPresentation = array_key_exists('presentation', $optional) || array_key_exists('presentation', $eventData); $presentation = $eventData['presentation'] ?? null; if ($hasPresentation) { unset($eventData['presentation'], $optional['presentation']); } - $pages = self::readDialoguePages($definition); + $pages = self::readDialoguePages($shown); $fields = array_map(static function (array $field) use ($optional): array { $path = array_values((array) ($field['path'] ?? [])); if (count($path) === 2 && array_key_exists((string) $path[1], $optional)) { @@ -1494,12 +1522,16 @@ public function buildEventDataFields(string $marker, array $definition): array if ($hasPresentation) { $fields = [...$fields, ...$this->buildInnPresentationFields($marker, $presentation)]; } } - $rootFields = array_diff_key($definition, ['class' => true, 'data' => true, 'facing' => true, 'engagement' => true]); + // The subject an event is bound to and its area are set by their own operations, never as rows. + $rootFields = array_diff_key($shown, ['class' => true, 'data' => true, 'facing' => true, 'engagement' => true, 'subject' => true, 'area' => true]); // Cue is a generic trigger capability, including for definitions // created before the field existed. Supplying an empty editor-only // default exposes the opt-in without changing the stored event until // the author actually edits it. - $rootFields['cue'] ??= ['symbol' => '', 'color' => 'bright-yellow']; + if (! isset($rootFields['cue']) || $rootFields['cue'] === []) { + // A cue holding only its conditions is still offered its symbol and colour. + $rootFields['cue'] = ['symbol' => '', 'color' => 'bright-yellow']; + } $cueKindFields = []; if (is_array($rootFields['cue'])) { $hasKind = array_key_exists('kind', $rootFields['cue']); @@ -1520,14 +1552,118 @@ public function buildEventDataFields(string $marker, array $definition): array return [ ...$fields, + ...$recordRows, ...$this->decorateEventInspectorFields( $marker, - [...$this->flattenInspectorFields($rootFields, []), ...$cueKindFields, self::buildEventEngagementField($definition), - ...self::buildEventFacingFields($definition)], + [...$this->flattenInspectorFields($rootFields, []), ...$cueKindFields], ), + ...$cueRecordRows, + ...$this->decorateEventInspectorFields($marker, [self::buildEventEngagementField($definition), ...self::buildEventFacingFields($definition)]), ]; } + /** + * The rows of one frame of an event's inline commands, or null when the + * event or the frame is gone. The frame [] is the event's record rows. + * + * @param list $frame + * @return list>|null + */ + public function getEventFrameFields(ProjectMap $map, string $marker, array $frame): ?array + { + $definition = $map->getEventDefinition($marker); + + return $definition === null ? null : $this->createEventRecordPane($marker, $definition)->getFields($frame); + } + + /** What an author reads for a frame of an event's inline commands. */ + public function describeEventFrame(ProjectMap $map, string $marker, array $frame): string + { + return $this->createEventRecordPane($marker, $map->getEventDefinition($marker) ?? [])->describeFrame($frame); + } + + /** The record rows of one event, over the project's references as this inspector reads them. */ + public function createEventRecordPane(string $marker, array $definition): EventRecordPane + { + return new EventRecordPane($marker, $definition, $this->references, $this->graphical); + } + + /** + * Applies one event record row's edit: the definition the pane leaves is + * written as one undo step. + * + * @param array $field + * @throws InspectorRefusal When the event is gone or the pane refuses the edit. + */ + private function applyEventRecordField(ProjectMap $map, array $field, string $rawValue): ?Command + { + $marker = (string) ($field['marker'] ?? ''); + $current = $map->getEventDefinition($marker) ?? throw new InspectorRefusal(sprintf('%s has no event %s.', $map->mapId, $marker)); + $next = $this->createEventRecordPane($marker, $current)->applyField(array_values((array) ($field['eventFrame'] ?? [])), (string) ($field['field'] ?? ''), $rawValue); + + return $next === null ? null : $this->writeEventDefinition($map, $marker, $current, $next, sprintf('%s edit', trim((string) ($field['label'] ?? 'Event field')))); + } + + /** + * Adds or removes the item an event record row belongs to (a command, a + * dialogue variant, a line, an option), as one undo step. + * + * @param array $field + * @throws InspectorRefusal When the event or frame is gone, or the row's item cannot change so. + */ + private function editEventRecordItems(ProjectMap $map, array $field, bool $remove): InspectorListEdit + { + $marker = (string) ($field['marker'] ?? ''); + $current = $map->getEventDefinition($marker) ?? throw new InspectorRefusal(sprintf('%s has no event %s.', $map->mapId, $marker)); + $pane = $this->createEventRecordPane($marker, $current); + $frame = array_values((array) ($field['eventFrame'] ?? [])); + $fieldId = is_string($field['field'] ?? null) && $field['field'] !== '' ? $field['field'] : null; + $edit = $remove ? $pane->removeItem($frame, (string) $fieldId) : $pane->addItem($frame, $fieldId); + + return new InspectorListEdit($edit['definition'] === null ? null + : $this->writeEventDefinition($map, $marker, $current, $edit['definition'], $remove ? 'Remove list entry' : 'Add list entry'), $edit['summary']); + } + + /** + * Writes an event's whole definition and returns the command that puts + * either one back. + * + * @param array $current + * @param array $next + */ + private function writeEventDefinition(ProjectMap $map, string $marker, array $current, array $next, string $label): Command + { + $map->setEventDefinition($marker, $next); + + return new GenericCommand( + $label, + static fn() => $map->setEventDefinition($marker, $next), + static fn() => $map->setEventDefinition($marker, $current), + ); + } + + /** + * An array without the value at a path; parents it leaves empty stay. + * + * @param array $array + * @param list $path + * @return array + */ + private static function withoutPath(array $array, array $path): array + { + $key = array_shift($path); + if ($key === null || ! array_key_exists($key, $array)) { + return $array; + } + if ($path === []) { + unset($array[$key]); + } elseif (is_array($array[$key])) { + $array[$key] = self::withoutPath($array[$key], $path); + } + + return $array; + } + /** * Which way the player faces to start the event: one row per heading, none chosen for any. A facing the Engine * would refuse stays as one row that names it, for the author to repair in the source, never flattened. diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 6d7de1a..aeebcfe 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1722,6 +1722,7 @@ private static function resolveOrder(?int $order, ?string $direction, Closure $f * `options`, read as the matching `optionLabels` when it has them), * `reference` (one of `references.list` for its `reference`), * `conditions` (the condition line, `type:name[:extras]` joined by `;`), + * `writes` (the world-state write line, in the same form), * `destination` (a map from `references.list` for `maps` and a spawn * point on it, set together through `setEventDestination`), or `info` * for a row that is only read here: headings and counts. A typed row's @@ -1729,21 +1730,37 @@ private static function resolveOrder(?int $order, ?string $direction, Closure $f * adds to and removes from carries `list` (its entry's index), and * `addInspectorListEntry`/`removeInspectorListEntry` apply to it. * - * @return array{map: string, revision: int, event: ?string, rows: list>} - * @throws SessionRefusal When the map or event is unknown. + * An event's record rows (what every trigger reads beside its class and + * data: its conditions, completion writes, blocked message and cue + * conditions, and a type's lists: a Dialogue event's pages, a Story + * Script's inline commands) are keyed with the `frame` they are in, [] + * at the event, and carry `item` when they belong to an entry adding and + * removing apply to. A row that opens a frame of commands carries that + * `frame`; reading the event again with it lists that frame's rows + * alone, edited through the same calls. + * + * @param list $frame A frame of the event's inline commands; [] for the map and event rows. + * @return array{map: string, revision: int, event: ?string, frame: list, frameLabel: ?string, rows: list>} + * @throws SessionRefusal When the map, event or frame is unknown. */ - public function readInspector(string $mapId, ?string $marker = null): array + public function readInspector(string $mapId, ?string $marker = null, array $frame = []): array { + $frame = self::requireFrame($frame, 'readInspector'); $map = $this->requireMap($mapId); if ($marker !== null && $map->getEventDefinition($marker) === null && $map->getEventArea($marker) === null) { throw new SessionRefusal(sprintf('%s has no event %s.', $mapId, $marker)); } + if ($frame !== [] && $marker === null) { + throw new SessionRefusal('A frame of commands belongs to an event; name the event.'); + } return [ 'map' => $mapId, 'revision' => $this->getMapRevision($map), 'event' => $marker, - 'rows' => $this->describePlacementRows(array_map(self::describeRow(...), $this->collectInspectorFields($map, $marker)), + 'frame' => $frame, + 'frameLabel' => $frame === [] || $marker === null ? null : $this->createMapInspector($map)->describeEventFrame($map, $marker, $frame), + 'rows' => $this->describePlacementRows(array_map(self::describeRow(...), $this->collectInspectorFields($map, $marker, $frame)), ['kind' => 'event', 'map' => $mapId, 'marker' => $marker]), ]; } @@ -1976,12 +1993,13 @@ private function runEdit(callable $edit): ?Command private function requireInspectorField(ProjectMap $map, array $key): array { $marker = is_string($key['marker'] ?? null) ? $key['marker'] : null; + $frame = self::requireFrame($key['frame'] ?? [], 'readInspector'); // A key comes back as the interface stored it, which may not keep // its members in the order they were given. ksort($key); - return array_find($this->collectInspectorFields($map, $marker), static function (array $candidate) use ($key): bool { + return array_find($this->collectInspectorFields($map, $marker, $frame), static function (array $candidate) use ($key): bool { $candidateKey = MapInspector::describeRowKey($candidate); if ($candidateKey !== null) { ksort($candidateKey); @@ -2993,7 +3011,8 @@ private function requireTimelineCutscene(string $category, int $index): Cutscene * cues) from the cutscene record named, as `{category, index}`. * * @param array{category?: mixed, index?: mixed, kind?: mixed, marker?: mixed, frame?: mixed, path?: mixed}|null $record - * Database {category,index,frame}, NPC {kind:'npc',index,frame}, or event {kind:'event',marker,path}. + * Database {category,index,frame}, NPC {kind:'npc',index,frame}, or event {kind:'event',marker,path} for a + * row of its data, {kind:'event',marker,frame} for one of its record rows. * @return list * @throws SessionRefusal When the category, map or record is unknown. */ @@ -3043,6 +3062,16 @@ private function getMovementOwnerCommands(?string $mapId, array $owner): array $marker = is_string($owner['marker'] ?? null) ? $owner['marker'] : ''; $event = $map->getEventDefinition($marker) ?? throw new SessionRefusal('That event route owner is no longer present.'); + if (array_key_exists('frame', $owner)) { + // A row of the event's record rows names its frame, as an NPC's does. + $records = $this->createMapInspector($map)->createEventRecordPane($marker, $event)->records(); + $frame = self::requireFrame($owner['frame'], 'references.list'); + if ($frame !== [] && $records->getFrameCommands(0, $frame) === null) { + throw new SessionRefusal('That route command frame is no longer present.'); + } + return MovementRouteFields::getOwnerCommands((array) ($records->getRecordByIndex(0)?->toArray() ?? []), + MovementRouteFields::getFrameOwnerPath($records->schema, $frame)); + } $path = self::requireFrame($owner['path'] ?? [], 'references.list'); return MovementRouteFields::getOwnerCommands($event, $path); } @@ -5372,11 +5401,23 @@ private function listDatabaseVersions(): array ]; } - /** @return array> */ - private function collectInspectorFields(ProjectMap $map, ?string $marker): array + /** + * The rows an inspector read lists: the map's and its event's, or one + * frame of the event's inline commands alone. + * + * @param list $frame + * @return array> + * @throws SessionRefusal When the frame is gone. + */ + private function collectInspectorFields(ProjectMap $map, ?string $marker, array $frame = []): array { $inspector = $this->createMapInspector($map); + if ($frame !== [] && $marker !== null) { + return $inspector->getEventFrameFields($map, $marker, $frame) + ?? throw new SessionRefusal('That frame of commands is no longer on the event; read the event again.'); + } + return [...$inspector->getMapFields($map), ...($marker === null ? [] : $inspector->getEventFields($map, $marker))]; } @@ -5434,7 +5475,8 @@ private static function describeRow(array $field): array 'depth' => $depth, // A section heading: a row that only names the rows after it. 'heading' => $kind === 'info' && (string) ($field['value'] ?? '') === '' && $key === null && $list === null ? true : null, - 'value' => (string) ($field['value'] ?? ''), + // Multi-line text is read whole, as it is edited. + 'value' => $control?->type === InputControlType::MULTILINE ? $control->rawValue : (string) ($field['value'] ?? ''), 'raw' => match ($kind) { 'conditions' => is_string($field['encoded'] ?? null) ? $field['encoded'] : (string) ($field['value'] ?? ''), 'writes', 'affinities' => (string) ($field['value'] ?? ''), @@ -5473,6 +5515,13 @@ private static function describeRow(array $field): array // A row about the rows before it: a warning (a line's emotion its speaker lacks) or an // error (a chest whose reward the Engine cannot give). 'tone' => in_array($field['tone'] ?? null, ['warning', 'error'], true) ? $field['tone'] : null, + // A record row: the frame of commands it opens, and the item adding and removing at it apply to. + 'frame' => is_array($field['frame'] ?? null) && $target !== null ? array_values($field['frame']) : null, + 'item' => ($field['item'] ?? false) === true ? true : null, + 'itemNoun' => is_string($field['itemNoun'] ?? null) ? $field['itemNoun'] : null, + 'listHeading' => ($field['listHeading'] ?? false) === true ? true : null, + 'childNoun' => is_string($field['childNoun'] ?? null) ? $field['childNoun'] : null, + 'multiline' => $control?->type === InputControlType::MULTILINE ? true : null, 'key' => $key, ], static fn(mixed $value): bool => $value !== null); } diff --git a/src/Session/MapPlacementSession.php b/src/Session/MapPlacementSession.php index ca296af..53316e9 100644 --- a/src/Session/MapPlacementSession.php +++ b/src/Session/MapPlacementSession.php @@ -5,6 +5,7 @@ namespace Ichiloto\Editor\Session; use Ichiloto\Editor\Database\ReferenceCatalog; +use Ichiloto\Editor\Events\EventRecordPane; use Ichiloto\Editor\Field\NpcAuthoring; use Ichiloto\Editor\Field\NpcInspector; use Ichiloto\Editor\History\CommandGroup; @@ -33,7 +34,7 @@ public function applyMapPlacement(array $context, array $key, array $expected, s $rows = match ($kind) { 'database' => $this->readDatabaseRecord(strval($context['category'] ?? ''), $index, $frame)['rows'], 'npc' => $this->readNpc($map->mapId, $index, $frame)['rows'], - 'event' => $this->readInspector($map->mapId, strval($key['marker'] ?? ''))['rows'], + 'event' => $this->readInspector($map->mapId, strval($key['marker'] ?? ''), $frame)['rows'], default => throw new SessionRefusal('Unknown map-placement owner.'), }; $row = array_find($rows, static fn(array $row): bool => MapPlacement::areValuesEqual($row['key'] ?? null, $key)); @@ -81,7 +82,8 @@ public function applyMapPlacement(array $context, array $key, array $expected, s } $changes = []; foreach ($values as $field => $value) { - $found = array_find($rows, static fn(array $candidate): bool => $kind === 'event' + // An event's data rows are named by their path, its record rows (and every other owner's) by their field. + $found = array_find($rows, static fn(array $candidate): bool => $kind === 'event' && ($candidate['key']['target'] ?? null) !== EventRecordPane::TARGET ? json_encode($candidate['key']['path'] ?? null, JSON_THROW_ON_ERROR) === $field : ($candidate['key']['field'] ?? null) === $field); if ($found === null || ($found['kind'] ?? 'info') === 'info') { diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index e45b85d..e29005c 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -382,6 +382,7 @@ private function dispatch(string $method, array $params): array 'inspector.read' => $session->readInspector( self::requireString($params, 'map'), is_string($params['event'] ?? null) ? $params['event'] : null, + is_array($params['frame'] ?? []) ? ($params['frame'] ?? []) : throw new InvalidRequest('"frame" must be a list of indexes and keys.'), ), 'inspector.apply' => $session->applyInspector( self::requireString($params, 'map'), diff --git a/tests/Pest.php b/tests/Pest.php index 3c891a1..fe5991e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -385,6 +385,7 @@ function plainTextOfFrame(string $frame, int $width, int $height): string require_once __DIR__ . '/Support/MapLineFixtures.php'; require_once __DIR__ . '/Support/TreeBrushFixtures.php'; require_once __DIR__ . '/Support/MapLandmarkFixtures.php'; +require_once __DIR__ . '/Support/EventRecordFixtures.php'; require_once __DIR__ . '/Support/BattleEntryFixtures.php'; diff --git a/tests/Support/EventRecordFixtures.php b/tests/Support/EventRecordFixtures.php new file mode 100644 index 0000000..569c7a2 --- /dev/null +++ b/tests/Support/EventRecordFixtures.php @@ -0,0 +1,81 @@ + 'Test Map', + 'region' => '', + 'description' => 'A tiny fixture map.', + 'triggers' => [], + 'events' => [ + 'E' => [ + 'class' => 'Ichiloto\Engine\Events\Triggers\ChestEventTrigger', + 'data' => [ + 'lootType' => 'item', + 'loot' => 'S-Potion', + ], + ], + // The way out. + 'T' => [ + 'class' => 'Ichiloto\Engine\Events\Triggers\TransferPlayerTrigger', + 'data' => [ + 'destinationMap' => 'test-map', + 'spawnPoint' => ['x' => 1, 'y' => 1], + 'spawnSprite' => ['@'], + ], + ], + 'D' => [ + 'class' => 'Ichiloto\Engine\Events\Triggers\DialogueEventTrigger', + 'sets' => [ + ['type' => 'event', 'name' => 'met_guide', 'note' => 'kept by the author'], + ], + 'data' => [ + 'dialogue' => [ + ['name' => 'Guide', 'text' => 'First line.'], + ['name' => '', 'text' => 'Second line.'], + ], + ], + ], + 'S' => [ + 'class' => 'Ichiloto\Engine\Events\Triggers\ScriptEventTrigger', + 'conditions' => [], + 'data' => [ + 'mode' => 'auto', + 'reusable' => true, + 'scriptId' => 'harbour-watch', + 'script' => [ + ['type' => 'text', 'name' => 'Observer', 'text' => 'Stop there.'], + // Timed by the author. + ['type' => 'wait', 'seconds' => abs(1)], + ], + ], + ], + ], +]; +PHP); + + return $root; +} + +/** The test map's data file in an event record project. */ +function eventRecordDataPath(string $root): string +{ + return $root . '/assets/Maps/test-map/test-map.data.php'; +} diff --git a/tests/Unit/EventRecordTerminalTest.php b/tests/Unit/EventRecordTerminalTest.php new file mode 100644 index 0000000..8d6960d --- /dev/null +++ b/tests/Unit/EventRecordTerminalTest.php @@ -0,0 +1,88 @@ + $row) { + if (($row['field'] ?? null) === $field) { + setEditorProperty($editor, 'selectedInspectorFieldIndex', $index); + + return $row; + } + } + + throw new RuntimeException("No inspector row {$field}."); +} + +it('opens a Story Script\'s inline commands as a frame, edits them there, and leaves with Esc', function () { + $editor = eventRecordTerminal(10); + $map = getEditorProperty($editor, 'workspace')->maps[0]; + + selectEventRecordField($editor, 'commandListScript'); + callEditorMethod($editor, 'dispatchInput', "\r"); + $rows = callEditorMethod($editor, 'getInspectorFields'); + + expect($rows[0]['label'])->toBe('Script') + ->and(array_column($rows, 'field'))->toContain('command0Text', 'command1Seconds') + ->and(array_column($rows, 'field'))->not->toContain('conditions'); + + callEditorMethod($editor, 'applyInspectorFieldValue', selectEventRecordField($editor, 'command0Text'), 'Wait for me.'); + selectEventRecordField($editor, 'command0Text'); + callEditorMethod($editor, 'dispatchInput', 'O'); + expect($map->getEventDefinition('S')['data']['script'][0]['text'])->toBe('Wait for me.') + ->and($map->getEventDefinition('S')['data']['script'])->toHaveCount(3); + + callEditorMethod($editor, 'dispatchInput', "\033"); + $field = callEditorMethod($editor, 'getInspectorFields')[getEditorProperty($editor, 'selectedInspectorFieldIndex')]; + expect($field['field'])->toBe('commandListScript') + ->and($field['value'])->toBe('3'); +}); + +it('builds an event\'s completion writes in the shared write editor hosted in the Inspector', function () { + $editor = eventRecordTerminal(1); + $map = getEditorProperty($editor, 'workspace')->maps[0]; + + selectEventRecordField($editor, 'sets'); + callEditorMethod($editor, 'dispatchInput', "\r"); + $writes = getEditorProperty($editor, 'worldWriteEditor'); + expect($writes->isOpen())->toBeTrue(); + + callEditorMethod($editor, 'dispatchInput', 'a'); + callEditorMethod($editor, 'dispatchInput', 't'); + $writes->setName('door_seen'); + callEditorMethod($editor, 'dispatchInput', "\r"); + + expect($map->getEventDefinition('T')['sets'])->toBe([['type' => 'event', 'name' => 'door_seen']]) + ->and(getEditorProperty($editor, 'mapWriteField'))->toBeNull(); + + // Esc leaves the writes alone and releases the hosting. + selectEventRecordField($editor, 'sets'); + callEditorMethod($editor, 'dispatchInput', "\r"); + callEditorMethod($editor, 'dispatchInput', 'a'); + callEditorMethod($editor, 'dispatchInput', "\033"); + expect($map->getEventDefinition('T')['sets'])->toBe([['type' => 'event', 'name' => 'door_seen']]) + ->and(getEditorProperty($editor, 'mapWriteField'))->toBeNull(); +}); diff --git a/tests/Unit/MovementRouteAuthoringTest.php b/tests/Unit/MovementRouteAuthoringTest.php index d2a9a6f..471d042 100644 --- a/tests/Unit/MovementRouteAuthoringTest.php +++ b/tests/Unit/MovementRouteAuthoringTest.php @@ -87,10 +87,10 @@ function makeRouteOwnerProject(): array }; expect($reply)->toHaveKey('result')->and(array_column($reply['result'], 'value'))->toBe($expected); } - $event = $request('inspector.read', ['map' => 'harbour', 'event' => 'S'])['result']; - $row = array_find($event['rows'], static fn(array $row): bool => ($row['reference'] ?? '') === 'cinematic_movement_routes'); - expect($row)->not->toBeNull(); - $reply = $list($event['map'], ['kind' => 'event', 'marker' => $row['key']['marker'], 'path' => $row['key']['path']]); + // A Story Script's inline commands are its record rows, in frames as an NPC's script is. + $event = $request('inspector.read', ['map' => 'harbour', 'event' => 'S', 'frame' => ['script', 2, 'commands']])['result']; + $row = routeAuthoringRow($event, 'Recorded Route'); + $reply = $list($event['map'], ['kind' => 'event', 'marker' => $row['key']['marker'], 'frame' => $row['key']['frame']]); expect($reply)->toHaveKey('result') ->and(array_column($reply['result'], 'value'))->toBe(['root-recorded', 'common-recorded', 'cycle-recorded']); @@ -118,6 +118,7 @@ function makeRouteOwnerProject(): array [$root] = makeRouteOwnerProject(); $session = EditorSession::open($root); foreach ([['kind' => 'npc', 'index' => 0, 'frame' => ['script', 2, 'commands']], + ['kind' => 'event', 'marker' => 'S', 'frame' => ['script', 2, 'commands']], ['kind' => 'event', 'marker' => 'S', 'path' => ['data', 'script', '2', 'commands', '0', 'retrace']]] as $owner) { expect(array_column($session->listReferences('harbour', 'cinematic_movement_routes', $owner), 'value')) ->toBe(['root-recorded', 'common-recorded', 'cycle-recorded']); @@ -172,10 +173,9 @@ function makeRouteOwnerProject(): array it('writes inline route references from the actual event script and refuses siblings without changing source or history', function () { [$root, $mapPath] = makeRouteOwnerProject(); $session = EditorSession::open($root); - $view = $session->readInspector('harbour', 'S'); - $path = ['data', 'script', '2', 'commands', '0', 'retrace']; - $row = array_find($view['rows'], static fn(array $row): bool => ($row['key']['path'] ?? []) === $path); - expect($row)->not->toBeNull(); + $frame = ['script', 2, 'commands']; + $view = $session->readInspector('harbour', 'S', $frame); + $row = routeAuthoringRow($view, 'Recorded Route'); $original = file_get_contents($mapPath); expect(fn() => $session->applyInspector('harbour', $view['revision'], $row['key'], 'other-event')) ->toThrow(SessionRefusal::class, 'Unknown cinematic_movement_routes'); @@ -189,7 +189,7 @@ function makeRouteOwnerProject(): array expect(file_get_contents($mapPath))->toBe($original); $session->redo(); $session->saveMap('harbour'); - expect(EditorSession::open($root)->listReferences('harbour', 'cinematic_movement_routes', ['kind' => 'event', 'marker' => 'S', 'path' => $path])) + expect(EditorSession::open($root)->listReferences('harbour', 'cinematic_movement_routes', ['kind' => 'event', 'marker' => 'S', 'frame' => $frame])) ->toHaveCount(3); }); diff --git a/tests/Unit/ResumableStoryEventEditorTest.php b/tests/Unit/ResumableStoryEventEditorTest.php index 2506713..c9e4e76 100644 --- a/tests/Unit/ResumableStoryEventEditorTest.php +++ b/tests/Unit/ResumableStoryEventEditorTest.php @@ -41,7 +41,8 @@ function writePhase7ArrayFile(string $path, array $payload): void 'mode' => 'action', 'reusable' => false, ]) - ->and($definition)->toHaveKeys(['conditions', 'sets', 'whenBlocked', 'cue']) + // Conditions, writes and the blocked message are every trigger's, written only once set. + ->and($definition)->not->toHaveKeys(['conditions', 'sets', 'whenBlocked']) ->and($definition['cue'])->toBe(['symbol' => '', 'color' => 'bright-yellow']); $fields = callEditorMethod($editor, 'createMapInspector')->buildEventDataFields('E', $definition); diff --git a/tests/Unit/Session/DialoguePortraitsSessionTest.php b/tests/Unit/Session/DialoguePortraitsSessionTest.php index 65980c9..9b9f2f2 100644 --- a/tests/Unit/Session/DialoguePortraitsSessionTest.php +++ b/tests/Unit/Session/DialoguePortraitsSessionTest.php @@ -194,9 +194,10 @@ function findDialogueRow(EditorSession $session, string $category, int $index, s PHP, (string) file_get_contents($data))); $session = EditorSession::open($root); $rows = $session->readInspector('test-map', 'D')['rows']; - $first = array_find($rows, static fn(array $row): bool => ($row['label'] ?? '') === 'Dialogue #1 Emotion'); + // Plain pages are one dialogue variant's lines, edited as an NPC's are. + $first = array_find($rows, static fn(array $row): bool => ($row['label'] ?? '') === 'Dialogue variant 1 Line 1 Emotion'); expect($first['kind'])->toBe('options')->and($first['options'])->toBe(['Neutral', 'Worried'])->and($first['value'])->toBe('Neutral'); - $warning = array_find($rows, static fn(array $row): bool => ($row['label'] ?? '') === 'Dialogue #2 Emotion Warning'); + $warning = array_find($rows, static fn(array $row): bool => ($row['label'] ?? '') === 'Dialogue variant 1 Line 2 Emotion Warning'); expect($warning['value'])->toContain('Kaelion has no "Sulky" emotion'); expect(getSessionMap($session)->getEventDefinition('D')['data']['dialogue'][0])->not->toHaveKey('emotion'); diff --git a/tests/Unit/Session/EventAuthoringSessionTest.php b/tests/Unit/Session/EventAuthoringSessionTest.php index 8726786..3904438 100644 --- a/tests/Unit/Session/EventAuthoringSessionTest.php +++ b/tests/Unit/Session/EventAuthoringSessionTest.php @@ -188,21 +188,24 @@ function eventSessionFreeCells(array $map, int $count): array it('adds and removes entries of an event\'s list, the map\'s encounters and its music variants', function () { $session = EditorSession::open(metadataProject()); $map = $session->readMap('test-map'); - $created = $session->createEvent('test-map', $map['revision'], eventSessionFreeCells($map, 1), 'Dialogue'); + // A shop's stock is a list of the event's own data. (A Dialogue event's pages are its record rows.) + $created = $session->createEvent('test-map', $map['revision'], eventSessionFreeCells($map, 1), 'Shop'); $marker = $created['marker']; - $isDialogue = static fn(array $row): bool => ($row['key']['list'] ?? null) === ['data', 'dialogue']; - $heading = eventSessionRow($session->readInspector('test-map', $marker), $isDialogue); + $isStock = static fn(array $row): bool => ($row['key']['list'] ?? null) === ['data', 'items']; + $heading = eventSessionRow($session->readInspector('test-map', $marker), $isStock); - $added = $session->addInspectorListEntry('test-map', $created['revision'], $heading['key']); - $rows = array_filter($session->readInspector('test-map', $marker)['rows'], $isDialogue); + $first = $session->addInspectorListEntry('test-map', $created['revision'], $heading['key']); + $item = eventSessionRow($session->readInspector('test-map', $marker), static fn(array $row): bool => $isStock($row) && $row['kind'] === 'integer'); + $added = $session->addInspectorListEntry('test-map', $first['revision'], $item['key']); + $rows = array_filter($session->readInspector('test-map', $marker)['rows'], $isStock); expect($heading['list'])->toBe(['index' => 0]) - ->and($added)->toMatchArray(['changed' => true, 'message' => 'Added dialogue 2.']) + ->and($added)->toMatchArray(['changed' => true, 'message' => 'Added item 2.']) ->and(array_column($rows, 'list'))->toContain(['index' => 1]); - $second = array_find($rows, static fn(array $row): bool => ($row['list'] ?? null) === ['index' => 1] && $row['kind'] === 'text'); + $second = array_find($rows, static fn(array $row): bool => ($row['list'] ?? null) === ['index' => 1] && $row['kind'] === 'integer'); $removed = $session->removeInspectorListEntry('test-map', $added['revision'], $second['key']); - expect($removed['message'])->toBe('Removed dialogue 2.') + expect($removed['message'])->toBe('Removed item 2.') ->and(fn() => $session->addInspectorListEntry('test-map', $removed['revision'], eventSessionRowLabelled($session->readInspector('test-map'), 'Name')['key'])) ->toThrow(SessionRefusal::class, 'Nothing here is a list to add to.'); diff --git a/tests/Unit/Session/EventRecordAuthoringTest.php b/tests/Unit/Session/EventRecordAuthoringTest.php new file mode 100644 index 0000000..e0568d6 --- /dev/null +++ b/tests/Unit/Session/EventRecordAuthoringTest.php @@ -0,0 +1,280 @@ + ($row['key']['field'] ?? null) === $field + || implode('.', $row['key']['path'] ?? []) === $field) + ?? throw new RuntimeException(sprintf('No row %s among: %s', $field, implode(', ', array_map( + static fn(array $row): string => (string) ($row['key']['field'] ?? implode('.', $row['key']['path'] ?? [])), $read['rows'])))); +} + +/** A JSON-line host over a project, and a request function. */ +function eventRecordHost(string $root): Closure +{ + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + + return $request; +} + +it('offers every trigger\'s conditions, writes, blocked message and cue conditions on a transfer, written only once set', function () { + $root = eventRecordProject(); + $request = eventRecordHost($root); + $before = file_get_contents(eventRecordDataPath($root)); + $read = $request('inspector.read', ['map' => 'test-map', 'event' => 'T'])['result']; + + expect(eventRecordRow($read, 'conditions'))->toMatchArray(['kind' => 'conditions', 'label' => 'Available When', 'raw' => '']) + ->and(eventRecordRow($read, 'sets'))->toMatchArray(['kind' => 'writes', 'label' => 'Then Set']) + ->and(eventRecordRow($read, 'whenBlocked'))->toMatchArray(['kind' => 'text', 'label' => 'When Blocked']) + ->and(eventRecordRow($read, 'cue.conditions'))->toMatchArray(['kind' => 'conditions', 'label' => 'Cue When']) + ->and(eventRecordRow($read, 'conditions')['key'])->toBe(['target' => 'event-record', 'field' => 'conditions', 'marker' => 'T', 'frame' => []]) + ->and(file_get_contents(eventRecordDataPath($root)))->toBe($before); + + $revision = $read['revision']; + foreach ([ + 'conditions' => '!event:door_open; quest:intro:active; map_event:test-map:E; variable:count:>=:3', + 'sets' => 'switch:lit:false; quest:intro:grant', + 'whenBlocked' => 'The door is shut.', + 'cue.conditions' => 'event:door_open', + ] as $field => $value) { + $applied = $request('inspector.apply', ['map' => 'test-map', 'revision' => $revision, 'key' => eventRecordRow($read, $field)['key'], 'value' => $value])['result']; + expect($applied['changed'])->toBeTrue(); + $revision = $applied['revision']; + } + $request('map.save', ['map' => 'test-map']); + $source = file_get_contents(eventRecordDataPath($root)); + $event = (require eventRecordDataPath($root))['events']['T']; + + expect($event['conditions'])->toBe([ + ['type' => 'event', 'name' => 'door_open', 'negate' => true], + ['type' => 'quest', 'name' => 'intro', 'status' => 'active'], + ['type' => 'map_event', 'name' => 'test-map', 'marker' => 'E'], + ['type' => 'variable', 'name' => 'count', 'op' => '>=', 'value' => 3], + ]) + ->and($event['sets'])->toBe([['type' => 'switch', 'name' => 'lit', 'value' => false], ['type' => 'quest', 'name' => 'intro', 'confirm' => false]]) + ->and($event['whenBlocked'])->toBe('The door is shut.') + ->and($event['cue'])->toBe(['conditions' => [['type' => 'event', 'name' => 'door_open']]]) + ->and($event['data'])->toBe(['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 1, 'y' => 1], 'spawnSprite' => ['@']]) + ->and($source)->toContain('// Synthetic map for event record authoring.', '// The way out.', '// Timed by the author.', "'seconds' => abs(1)"); + + // Cleared, a field leaves the event as it was before it was set. + $read = $request('inspector.read', ['map' => 'test-map', 'event' => 'T'])['result']; + $revision = $request('inspector.apply', ['map' => 'test-map', 'revision' => $read['revision'], 'key' => eventRecordRow($read, 'conditions')['key'], 'value' => ''])['result']['revision']; + $request('inspector.apply', ['map' => 'test-map', 'revision' => $revision, 'key' => eventRecordRow($read, 'cue.conditions')['key'], 'value' => '']); + $request('map.save', ['map' => 'test-map']); + expect((require eventRecordDataPath($root))['events']['T'])->not->toHaveKeys(['conditions', 'cue']) + ->and($request('history.undo')['result']['label'])->toBe('Cue When edit'); +}); + +it('refuses condition and write lines the Engine would not read, and lists the line cannot carry', function () { + $root = eventRecordProject(); + $session = EditorSession::open($root); + $before = file_get_contents(eventRecordDataPath($root)); + $read = $session->readInspector('test-map', 'D'); + + expect(fn() => $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'conditions')['key'], 'switch:open; nonsense')) + ->toThrow(SessionRefusal::class, 'Condition "nonsense" cannot be read') + ->and(fn() => $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'conditions')['key'], 'variable:count:~:3')) + ->toThrow(SessionRefusal::class, 'unsupported variable operator "~"') + ->and(fn() => $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'variant0Sets')['key'], 'teleport:home')) + ->toThrow(SessionRefusal::class, 'Write "teleport:home" cannot be read'); + + // An authored key outside the write line's vocabulary is kept, never dropped by an edit through the line. + $sets = eventRecordRow($read, 'sets'); + expect($sets['kind'])->toBe('info') + ->and($sets['value'])->toContain('write 1 carries "note"') + ->and(fn() => $session->applyInspector('test-map', $read['revision'], $sets['key'], 'event:met_guide'))->toThrow(SessionRefusal::class, 'cannot be edited here') + ->and($session->undo()['label'])->toBeNull(); + $session->saveMap('test-map'); + expect(file_get_contents(eventRecordDataPath($root)))->toBe($before); +}); + +it('leaves authored empty lists alone, writes no empty defaults on new events, and keeps trigger fields through a retype', function () { + $root = eventRecordProject(); + $session = EditorSession::open($root); + $read = $session->readInspector('test-map', 'S'); + $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'whenBlocked')['key'], 'Not yet.'); + $session->saveMap('test-map'); + $event = (require eventRecordDataPath($root))['events']['S']; + + expect($event['conditions'])->toBe([]) + ->and($event['whenBlocked'])->toBe('Not yet.') + ->and(file_get_contents(eventRecordDataPath($root)))->toContain("'seconds' => abs(1)"); + + $map = $session->readMap('test-map'); + foreach (['Story Script', 'Cinematic', 'Dialogue'] as $index => $type) { + $marker = $session->createEvent('test-map', $session->readMap('test-map')['revision'], [[1 + $index, 3]], $type)['marker']; + expect(getSessionMap($session)->getEventDefinition($marker))->not->toHaveKeys(['conditions', 'sets', 'whenBlocked']); + } + expect($map['revision'])->toBeInt(); + + // Conditions belong to the trigger, not its type: a retype keeps them and starts the data again. + $dialogue = $session->readInspector('test-map', 'D'); + $revision = $session->applyInspector('test-map', $dialogue['revision'], eventRecordRow($dialogue, 'conditions')['key'], 'event:door_open')['revision']; + $type = array_find($session->readInspector('test-map', 'D')['rows'], static fn(array $row): bool => ($row['key']['target'] ?? null) === 'event-type'); + $session->applyInspector('test-map', $revision, $type['key'], 'Transfer Player'); + $retyped = getSessionMap($session)->getEventDefinition('D'); + + expect($retyped['class'])->toBe('Ichiloto\Engine\Events\Triggers\TransferPlayerTrigger') + ->and($retyped['conditions'])->toBe([['type' => 'event', 'name' => 'door_open']]) + ->and($retyped['sets'])->toBe([['type' => 'event', 'name' => 'met_guide', 'note' => 'kept by the author']]) + ->and($retyped['data'])->toHaveKeys(['destinationMap', 'spawnPoint', 'spawnSprite']) + ->and($retyped['data'])->not->toHaveKey('dialogue'); +}); + +it('creates and edits a Story Script\'s inline commands with the shared command editor, frame by frame over the protocol', function () { + $root = eventRecordProject(); + $request = eventRecordHost($root); + $map = $request('map.read', ['map' => 'test-map'])['result']; + $marker = $request('event.create', ['map' => 'test-map', 'revision' => $map['revision'], 'cells' => [[2, 3]], 'type' => 'Story Script'])['result']['marker']; + $script = eventRecordRow($request('inspector.read', ['map' => 'test-map', 'event' => $marker])['result'], 'commandListScript'); + + expect($script)->toMatchArray(['kind' => 'info', 'label' => 'Script', 'value' => '0', 'frame' => ['script']]); + + $frame = $request('inspector.read', ['map' => 'test-map', 'event' => $marker, 'frame' => ['script']])['result']; + expect($frame['frameLabel'])->toBe('Script') + ->and(array_column($frame['rows'], 'label'))->toBe(['No commands yet']); + $added = $request('inspector.add', ['map' => 'test-map', 'revision' => $frame['revision'], 'key' => $frame['rows'][0]['key']])['result']; + expect($added['message'])->toBe('Added a command.'); + + $frame = $request('inspector.read', ['map' => 'test-map', 'event' => $marker, 'frame' => ['script']])['result']; + $type = eventRecordRow($frame, 'command0Type'); + expect($type['kind'])->toBe('options') + ->and($type['options'])->toContain('text', 'branch', 'set_variable', 'move_player') + ->and($type['key']['frame'])->toBe(['script']); + $revision = $request('inspector.apply', ['map' => 'test-map', 'revision' => $frame['revision'], 'key' => eventRecordRow($frame, 'command0Text')['key'], 'value' => 'Halt.'])['result']['revision']; + $revision = $request('inspector.add', ['map' => 'test-map', 'revision' => $revision, 'key' => eventRecordRow($frame, 'command0Text')['key']])['result']['revision']; + $frame = $request('inspector.read', ['map' => 'test-map', 'event' => $marker, 'frame' => ['script']])['result']; + $revision = $request('inspector.apply', ['map' => 'test-map', 'revision' => $revision, 'key' => eventRecordRow($frame, 'command1Type')['key'], 'value' => 'branch'])['result']['revision']; + + // A branch's arm is a frame of its own. + $frame = $request('inspector.read', ['map' => 'test-map', 'event' => $marker, 'frame' => ['script']])['result']; + $then = eventRecordRow($frame, 'command1Then'); + expect($then['frame'])->toBe(['script', 1, 'then']); + $arm = $request('inspector.read', ['map' => 'test-map', 'event' => $marker, 'frame' => $then['frame']])['result']; + $revision = $request('inspector.add', ['map' => 'test-map', 'revision' => $revision, 'key' => $arm['rows'][0]['key']])['result']['revision']; + $arm = $request('inspector.read', ['map' => 'test-map', 'event' => $marker, 'frame' => $then['frame']])['result']; + $request('inspector.apply', ['map' => 'test-map', 'revision' => $revision, 'key' => eventRecordRow($arm, 'command0Type')['key'], 'value' => 'set_switch']); + $request('map.save', ['map' => 'test-map']); + $saved = (require eventRecordDataPath($root))['events'][$marker]['data']; + + expect($saved['script'][0])->toBe(['type' => 'text', 'name' => '', 'text' => 'Halt.']) + ->and($saved['script'][1]['type'])->toBe('branch') + ->and($saved['script'][1]['then'][0]['type'])->toBe('set_switch') + ->and($saved['scriptId'])->toBe('') + ->and($request('inspector.read', ['map' => 'test-map', 'event' => $marker, 'frame' => ['script', 9, 'then']])['error']['message']) + ->toContain('no longer on the event'); +}); + +it('edits an authored inline script in place, keeping its comments and expressions, and refuses what the source cannot take', function () { + $root = eventRecordProject(); + $session = EditorSession::open($root); + $rootRows = $session->readInspector('test-map', 'S'); + + expect(array_column($rootRows['rows'], 'label'))->toContain('! Inline script replaces Event Script'); + + $frame = $session->readInspector('test-map', 'S', ['script']); + $before = file_get_contents(eventRecordDataPath($root)); + expect(fn() => $session->applyInspector('test-map', $frame['revision'], eventRecordRow($frame, 'command1Seconds')['key'], '3')) + ->toThrow(SessionRefusal::class) + ->and($session->undo()['label'])->toBeNull(); + + $session->applyInspector('test-map', $frame['revision'], eventRecordRow($frame, 'command0Text')['key'], 'Stop right there.'); + $session->saveMap('test-map'); + $after = file_get_contents(eventRecordDataPath($root)); + + expect($after)->toBe(str_replace("'text' => 'Stop there.'", "'text' => 'Stop right there.'", $before)) + ->and($before)->not->toBe($after); +}); + +it('turns a Dialogue event\'s pages into conditional variants without losing a line, and names who is talked to', function () { + $root = eventRecordProject(); + $session = EditorSession::open($root); + $read = $session->readInspector('test-map', 'D'); + $pages = [['name' => 'Guide', 'text' => 'First line.'], ['name' => '', 'text' => 'Second line.']]; + + expect(eventRecordRow($read, 'npcId'))->toMatchArray(['kind' => 'text', 'label' => 'Talk-To Id', 'value' => '', 'raw' => '']) + ->and(eventRecordRow($read, 'variantList'))->toMatchArray(['listHeading' => true, 'value' => '1']) + ->and(eventRecordRow($read, 'variant0Line1Text')['value'])->toBe('Second line.'); + + // Pages stay pages while nothing makes them a variant. + $revision = $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'npcId')['key'], 'guide-post')['revision']; + expect(getSessionMap($session)->getEventDefinition('D')['data'])->toBe(['dialogue' => $pages, 'npcId' => 'guide-post']); + + $revision = $session->applyInspector('test-map', $revision, eventRecordRow($read, 'variant0Conditions')['key'], 'event:met_guide')['revision']; + expect(getSessionMap($session)->getEventDefinition('D')['data']['dialogue'])->toBe([ + ['lines' => $pages, 'conditions' => [['type' => 'event', 'name' => 'met_guide']]], + ]); + + $read = $session->readInspector('test-map', 'D'); + $revision = $session->addInspectorListEntry('test-map', $revision, eventRecordRow($read, 'variant0Line1Text')['key'])['revision']; + $added = $session->addInspectorListEntry('test-map', $revision, eventRecordRow($read, 'variantList')['key']); + expect($added['message'])->toBe('Added a dialogue variant.'); + $read = $session->readInspector('test-map', 'D'); + // A new variant goes last, where the unconditioned fallback belongs: the first variant that holds is spoken. + $session->applyInspector('test-map', $added['revision'], eventRecordRow($read, 'variant1Line0Text')['key'], 'Before we met.'); + $read = $session->readInspector('test-map', 'D'); + + expect(fn() => $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'variant1Conditions')['key'], 'nonsense')) + ->toThrow(SessionRefusal::class, 'cannot be read'); + + $revision = $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'npcId')['key'], '')['revision']; + $session->saveMap('test-map'); + $saved = (require eventRecordDataPath($root))['events']['D']; + + expect($saved['data'])->not->toHaveKey('npcId') + ->and($saved['data']['dialogue'][0]['conditions'])->toBe([['type' => 'event', 'name' => 'met_guide']]) + ->and(array_column($saved['data']['dialogue'][0]['lines'], 'text'))->toBe(['First line.', 'Second line.', 'Something to say.']) + ->and($saved['data']['dialogue'][1])->toBe(['lines' => [['name' => '', 'text' => 'Before we met.']]]) + ->and($saved['sets'])->toBe([['type' => 'event', 'name' => 'met_guide', 'note' => 'kept by the author']]) + ->and($revision)->toBeInt(); +}); + +it('places a Story Script\'s inline route on the map through its frame rows, at the commands\' place in the event data', function () { + $root = eventRecordProject(); + $path = eventRecordDataPath($root); + file_put_contents($path, str_replace(" ['type' => 'text', 'name' => 'Observer', 'text' => 'Stop there.'],", + " ['type' => 'move_route', 'subject' => 'player', 'steps' => [['direction' => 'right', 'count' => 1, 'faceOnly' => false]]],", + (string) file_get_contents($path))); + $session = EditorSession::open($root); + $read = $session->readInspector('test-map', 'S', ['script']); + $row = array_find($read['rows'], static fn(array $row): bool => ($row['mapPlacement']['kind'] ?? null) === 'route') + ?? throw new RuntimeException('No route placement row.'); + + expect($row['key']['frame'])->toBe(['script']) + ->and($row['mapPlacement']['issue'] ?? null)->toBeNull(); + + $session->applyMapPlacement(['kind' => 'event', 'map' => 'test-map', 'revision' => $read['revision']], + $row['key'], $row['mapPlacement'], 'test-map', 0, 1, 3, [1, 1], $read['revision']); + $session->saveMap('test-map'); + $script = (require $path)['events']['S']['data']['script']; + + expect($script[0]['steps'])->toBe([['direction' => 'down', 'count' => 2, 'faceOnly' => false]]) + ->and(file_get_contents($path))->toContain("'seconds' => abs(1)", '// Timed by the author.'); +}); + +it('offers no row for the subject an event is bound to or its area, which their own operations set', function () { + $root = eventRecordProject(); + $path = eventRecordDataPath($root); + file_put_contents($path, str_replace(" 'D' => [\n 'class'", " 'D' => [\n 'subject' => ['kind' => 'world_object', 'id' => 'guide-post'],\n 'class'", (string) file_get_contents($path))); + $rows = EditorSession::open($root)->readInspector('test-map', 'D')['rows']; + + expect(array_filter($rows, static fn(array $row): bool => in_array($row['key']['path'][0] ?? $row['key']['field'] ?? null, ['subject', 'area'], true)))->toBe([]); +}); From b89975cc9035b74a13513064c9a92a985be3d520 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:56:33 +0200 Subject: [PATCH 322/344] feat(inspector): pin a map to a cell of its region map RegionMap reads a map's station as an x and y pair, both or neither, and places an unpinned map from the direction its doors lead. The map inspector now offers it as one 'x, y' row after the atlas rows: two whole numbers or empty to take the pin away, any other authored key kept. --- src/Inspector/MapInspector.php | 19 ++++ src/Inspector/MapStationField.php | 86 +++++++++++++++++++ .../Unit/Session/EventRecordAuthoringTest.php | 25 +++++- 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 src/Inspector/MapStationField.php diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index 33a3026..2b00a32 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -196,6 +196,8 @@ public function getMapFields(ProjectMap $map): array ], ...$this->mapRuntimeFields($map), ...MapAtlasFields::describeMapFields($map, $this->references), + // Where the region map pins it, beside where the atlas places it. + MapStationField::describeField($map), ...MapTerrainFields::describeMapFields($map), ...$this->getLandmarkFields()->describeMapFields($map), ]; @@ -346,6 +348,7 @@ public function apply(ProjectMap $map, array $field, string $rawValue): ?Command MapLandmarkFields::TARGET, MapLandmarkFields::EVENT_TARGET, MapLandmarkFields::OBJECT_TARGET => $this->applyLandmarkValue($map, $field, $value), MapAtlasFields::TARGET => $this->applyAtlasValue($map, $field, $value), MapTerrainFields::TARGET => $this->applyTerrainValue($map, $field, $value), + MapStationField::TARGET => $this->applyStationValue($map, (string) $value), default => null, }; } @@ -389,6 +392,22 @@ private function applyTerrainValue(ProjectMap $map, array $field, mixed $value): return $this->writeMapData($map, [MapAtlas::KEY], $block === [] ? null : $block, 'Atlas terrain'); } + /** + * Pins the map to a cell of its region's map, or takes the pin away, as one undo step. + * + * @throws InspectorRefusal When the value is not an x, y pair of whole numbers. + */ + private function applyStationValue(ProjectMap $map, string $value): ?Command + { + try { + $station = MapStationField::applyValue($map, $value); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + + return $this->writeMapData($map, [MapStationField::KEY], $station, 'Station'); + } + /** The landmark rows, over the project's place categories and maps as this inspector reads them. */ private function getLandmarkFields(): MapLandmarkFields { diff --git a/src/Inspector/MapStationField.php b/src/Inspector/MapStationField.php new file mode 100644 index 0000000..475abc4 --- /dev/null +++ b/src/Inspector/MapStationField.php @@ -0,0 +1,86 @@ + int, 'y' => int]`, both or neither. + * A map without one is placed from the direction its doors lead. The row is + * one `x, y` pair so the two are always written together, and an empty value + * takes the pin away. + */ +final class MapStationField +{ + public const string TARGET = 'map-station'; + + /** The map data key RegionMap reads. */ + public const string KEY = 'station'; + + private function __construct() {} + + /** @return array */ + public static function describeField(ProjectMap $map): array + { + $station = $map->getMapDataField([self::KEY]); + $pair = self::readPair($station); + $raw = $pair === null ? '' : sprintf('%d, %d', $pair[0], $pair[1]); + + return [ + 'label' => 'Region Map Station', + 'value' => match (true) { + $station === null => '(placed from its doors)', + $pair === null => '! not an x/y pair the region map reads · ' . get_debug_type($station), + default => $raw, + }, + 'control' => new InputControl(InputControlType::TEXT, $raw), + 'target' => self::TARGET, + 'field' => self::KEY, + ]; + } + + /** + * The station an edit leaves: null to take it away, or the pair with any + * other authored keys kept. + * + * @return array|null + * @throws InvalidArgumentException When the value is not two whole numbers. + */ + public static function applyValue(ProjectMap $map, string $value): ?array + { + $value = trim($value); + + if ($value === '') { + return null; + } + + $parts = array_map(trim(...), explode(',', $value)); + + if (count($parts) !== 2 || ! array_all($parts, static fn(string $part): bool => preg_match('/^-?\d+$/', $part) === 1)) { + throw new InvalidArgumentException(sprintf('A station is two whole numbers, x and y, separated by a comma; "%s" is not.', $value)); + } + + $current = $map->getMapDataField([self::KEY]); + + return [...(is_array($current) ? $current : []), 'x' => (int) $parts[0], 'y' => (int) $parts[1]]; + } + + /** + * The cell a station names, as RegionMap::stationOf reads it: both + * coordinates set, each read as a whole number, or null. + * + * @return array{0: int, 1: int}|null + */ + private static function readPair(mixed $station): ?array + { + if (! is_array($station) || ! isset($station['x'], $station['y'])) { + return null; + } + + return [intval($station['x']), intval($station['y'])]; + } +} diff --git a/tests/Unit/Session/EventRecordAuthoringTest.php b/tests/Unit/Session/EventRecordAuthoringTest.php index e0568d6..dce0a86 100644 --- a/tests/Unit/Session/EventRecordAuthoringTest.php +++ b/tests/Unit/Session/EventRecordAuthoringTest.php @@ -10,7 +10,7 @@ * An event's record rows through the session, as a GUI editor or an agent * authors them: the fields every trigger reads on every event type, a Story * Script's inline commands in the shared command editor, a Dialogue event's - * conditional pages and talk-to id. Every edit is checked against the written source: authored + * conditional pages and talk-to id, and the map's region station. Every edit is checked against the written source: authored * comments, expressions and untouched keys stay as they were. */ @@ -247,6 +247,29 @@ function eventRecordHost(string $root): Closure ->and($revision)->toBeInt(); }); +it('pins a map to a cell of its region map as one x, y pair, and takes the pin away', function () { + $root = eventRecordProject(); + $request = eventRecordHost($root); + $read = $request('inspector.read', ['map' => 'test-map'])['result']; + $station = eventRecordRow($read, 'station'); + + expect($station)->toMatchArray(['kind' => 'text', 'label' => 'Region Map Station', 'raw' => '', 'value' => '(placed from its doors)']); + foreach (['3', '3, 4, 5', 'a, b', '1.5, 2'] as $bad) { + expect($request('inspector.apply', ['map' => 'test-map', 'revision' => $read['revision'], 'key' => $station['key'], 'value' => $bad])['error']['message']) + ->toContain('two whole numbers'); + } + + $applied = $request('inspector.apply', ['map' => 'test-map', 'revision' => $read['revision'], 'key' => $station['key'], 'value' => ' -2, 7 '])['result']; + expect(eventRecordRow($request('inspector.read', ['map' => 'test-map'])['result'], 'station')['raw'])->toBe('-2, 7'); + $request('map.save', ['map' => 'test-map']); + expect((require eventRecordDataPath($root))['station'])->toBe(['x' => -2, 'y' => 7]) + ->and(file_get_contents(eventRecordDataPath($root)))->toContain('// Synthetic map for event record authoring.'); + + $request('inspector.apply', ['map' => 'test-map', 'revision' => $applied['revision'], 'key' => $station['key'], 'value' => '']); + $request('map.save', ['map' => 'test-map']); + expect(require eventRecordDataPath($root))->not->toHaveKey('station'); +}); + it('places a Story Script\'s inline route on the map through its frame rows, at the commands\' place in the event data', function () { $root = eventRecordProject(); $path = eventRecordDataPath($root); From 4277b47c4623bbfeee891b9bfc1311bef2505302 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 22:58:23 +0200 Subject: [PATCH 323/344] feat(events): pick the heading a transfer arrives facing from the Engine's headings PlayerSpriteSet reads a spawn sprite that names one heading as that heading. The Spawn Facing row on transfers and inns now offers North, East, South and West instead of a free-text list leaf, and names legacy sprite art it would replace. event.destination takes an optional heading, checked the same way; omitted, the facing is left unchanged. New Transfer and Sleep events start facing South instead of the placeholder glyph. --- src/Events/EventTypeCatalog.php | 11 ++- src/Inspector/MapInspector.php | 90 ++++++++++++++++++- src/Session/EditorSession.php | 10 ++- src/Session/SessionHost.php | 1 + .../Unit/Session/EventRecordAuthoringTest.php | 33 ++++++- 5 files changed, 131 insertions(+), 14 deletions(-) diff --git a/src/Events/EventTypeCatalog.php b/src/Events/EventTypeCatalog.php index 9d3f384..4e28201 100644 --- a/src/Events/EventTypeCatalog.php +++ b/src/Events/EventTypeCatalog.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor\Events; +use Ichiloto\Engine\Core\Enumerations\MovementHeading; use Ichiloto\Engine\Events\Triggers\ChestEventTrigger; use Ichiloto\Engine\Events\Triggers\CinematicEventTrigger; use Ichiloto\Engine\Events\Triggers\DialogueEventTrigger; @@ -84,9 +85,8 @@ className: TransferPlayerTrigger::class, 'x' => 0, 'y' => 0, ], - 'spawnSprite' => [ - '🧍', - ], + // A heading the player faces on arrival, as PlayerSpriteSet reads it. + 'spawnSprite' => [MovementHeading::SOUTH->value], ], defaultDefinitionFields: ['cue' => ['symbol' => '', 'color' => 'bright-yellow']], ), @@ -111,9 +111,8 @@ className: SleepEventTrigger::class, 'x' => 0, 'y' => 0, ], - 'spawnSprite' => [ - '🧍', - ], + // A heading the player faces on arrival, as PlayerSpriteSet reads it. + 'spawnSprite' => [MovementHeading::SOUTH->value], 'confirmDialogue' => [ 'name' => '', 'text' => 'Rest here?', diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index 2b00a32..e15596b 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -37,6 +37,7 @@ use Ichiloto\Engine\Events\Triggers\DialogueEventTrigger; use Ichiloto\Engine\Events\Triggers\EventCueKind; use Ichiloto\Engine\Events\Triggers\ShopEventTrigger; +use Ichiloto\Engine\Field\PlayerSpriteSet; use Ichiloto\Engine\Messaging\Dialogue\ConditionalDialogue; use Ichiloto\Engine\Messaging\Dialogue\Presentation\DialogueContext; use Ichiloto\Engine\Field\MapGraphics; @@ -494,12 +495,14 @@ public static function findChoiceValues(array $field): ?array /** * Sets a transfer event's destination: the map and the spawn point on - * it, as one undo step. + * it, and when given the heading the player faces there, as one undo + * step. Without a heading the spawn facing stays as it is. * * @param ProjectMap $destination The map it leads to. - * @throws InspectorRefusal When the event is not a transfer or the spawn point is off the destination map. + * @param string|null $heading North, East, South or West; null leaves the facing unchanged. + * @throws InspectorRefusal When the event is not a transfer, the spawn point is off the destination map, or the heading is not one. */ - public function setTransferDestination(ProjectMap $map, string $marker, ProjectMap $destination, int $x, int $y): ?Command + public function setTransferDestination(ProjectMap $map, string $marker, ProjectMap $destination, int $x, int $y, ?string $heading = null): ?Command { $current = $map->getEventDefinition($marker); @@ -512,9 +515,15 @@ public function setTransferDestination(ProjectMap $map, string $marker, ProjectM $x, $y, $destination->mapId, $destination->getWidth(), $destination->getHeight())); } + $facing = $heading === null ? null : (self::findSpawnHeading($heading) + ?? throw new InspectorRefusal(sprintf('%s is not a spawn facing; choose %s.', $heading, implode(', ', self::listSpawnHeadingNames())))); + $next = $current; $next['data']['destinationMap'] = $destination->mapId; $next['data']['spawnPoint'] = [...(is_array($current['data']['spawnPoint'] ?? null) ? $current['data']['spawnPoint'] : []), 'x' => $x, 'y' => $y]; + if ($facing !== null) { + $next['data']['spawnSprite'] = [$facing->value]; + } if ($next === $current) { return null; @@ -958,6 +967,9 @@ private function applyEventField(ProjectMap $map, array $field, mixed $value): ? if (is_string($field['facingHeading'] ?? null)) { return $this->applyEventFacing($map, $marker, $field['facingHeading'], $value === true); } + if (($field['spawnHeading'] ?? false) === true) { + return $this->applySpawnHeading($map, $marker, $value); + } $held = $map->hasEventField($marker, $path); $oldValue = $map->getEventField($marker, $path); // Optional data cleared is removed, as it was before it was set. @@ -1027,6 +1039,74 @@ private function applyEventFacing(ProjectMap $map, string $marker, string $name, return new GenericCommand('Facing edit', $apply, $restore); } + /** + * Sets the heading the player faces on arrival. The Engine reads a + * spawn sprite that names one heading as that heading, so it is written + * as the one name; sprite art authored there is replaced. + */ + private function applySpawnHeading(ProjectMap $map, string $marker, mixed $value): ?Command + { + $heading = is_string($value) ? self::findSpawnHeading($value) : null; + if ($heading === null) { + throw new InspectorRefusal(sprintf('Choose %s for the spawn facing.', implode(', ', self::listSpawnHeadingNames()))); + } + $path = ['data', 'spawnSprite']; + $old = $map->getEventField($marker, $path); + $next = [$heading->value]; + if ($old === $next) { + return null; + } + $held = $map->hasEventField($marker, $path); + $apply = static fn() => $map->setEventField($marker, $path, $next); + $restore = $held ? static fn() => $map->setEventField($marker, $path, $old) : static fn() => $map->removeEventField($marker, $path); + $apply(); + + return new GenericCommand('Spawn facing edit', $apply, $restore); + } + + /** The heading a spawn facing names, or null for none of North, East, South or West. */ + public static function findSpawnHeading(string $name): ?MovementHeading + { + return array_find(self::listFacingHeadings(), static fn(MovementHeading $heading): bool => $heading->value === $name); + } + + /** @return list The headings a spawn faces, in compass order. */ + public static function listSpawnHeadingNames(): array + { + return array_map(static fn(MovementHeading $heading): string => $heading->value, self::listFacingHeadings()); + } + + /** + * An event's data rows with the spawn facing after its spawn point: a + * heading picked from the Engine's headings. Sprite art authored in its + * place is named and kept until a heading is chosen. + * + * @param list> $fields + * @return list> + */ + private static function insertSpawnHeadingField(array $fields, string $marker, mixed $spawnSprite): array + { + $heading = is_array($spawnSprite) || is_string($spawnSprite) ? PlayerSpriteSet::headingFromName($spawnSprite) : null; + $art = is_array($spawnSprite) ? implode(' ', array_map(static fn(mixed $row): string => is_scalar($row) ? (string) $row : '?', $spawnSprite)) : (is_scalar($spawnSprite) ? (string) $spawnSprite : ''); + $row = [ + 'label' => 'Spawn Facing', + 'value' => $heading?->value ?? sprintf('(sprite art: %s)', $art), + 'selectedValue' => $heading?->value ?? '', + 'choices' => array_map(static fn(string $name): array => ['label' => $name, 'value' => $name, + 'description' => sprintf('The player arrives facing %s.', strtolower($name))], self::listSpawnHeadingNames()), + 'options' => self::listSpawnHeadingNames(), + 'path' => ['data', 'spawnSprite'], + 'spawnHeading' => true, + 'target' => 'event', + 'marker' => $marker, + ]; + $spawnRows = array_keys(array_filter($fields, static fn(array $field): bool => array_slice(array_values((array) ($field['path'] ?? [])), 0, 2) === ['data', 'spawnPoint'])); + $at = $spawnRows === [] ? count($fields) : max($spawnRows) + 1; + array_splice($fields, $at, 0, [$row]); + + return $fields; + } + /** Kind edits own the cue's optional parent so undo also restores its omission. */ private function applyEventCueKind(ProjectMap $map, string $marker, mixed $value): ?Command { @@ -1525,6 +1605,9 @@ public function buildEventDataFields(string $marker, array $definition): array $hasPresentation = array_key_exists('presentation', $optional) || array_key_exists('presentation', $eventData); $presentation = $eventData['presentation'] ?? null; if ($hasPresentation) { unset($eventData['presentation'], $optional['presentation']); } + $hasSpawnSprite = array_key_exists('spawnSprite', $eventData); + $spawnSprite = $eventData['spawnSprite'] ?? null; + unset($eventData['spawnSprite']); $pages = self::readDialoguePages($shown); $fields = array_map(static function (array $field) use ($optional): array { $path = array_values((array) ($field['path'] ?? [])); @@ -1538,6 +1621,7 @@ public function buildEventDataFields(string $marker, array $definition): array $this->flattenInspectorFields([...self::withPageEmotions($eventData, $pages), ...array_diff_key($optional, $eventData)], ['data']), )); $fields = $this->describePageEmotions($fields, $pages); + if ($hasSpawnSprite) { $fields = self::insertSpawnHeadingField($fields, $marker, $spawnSprite); } if ($hasPresentation) { $fields = [...$fields, ...$this->buildInnPresentationFields($marker, $presentation)]; } } diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index aeebcfe..d14d210 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -1899,16 +1899,18 @@ public function setEventBounds(string $mapId, int $revision, string $marker, int /** * Sets a transfer event's destination map and the spawn point on it as - * one undo step. + * one undo step, with the heading the player faces there when given; + * without one the spawn facing stays as it is. * + * @param string|null $heading North, East, South or West, as the Spawn Facing row offers them; null leaves it. * @return array{revision: int, changed: bool} - * @throws SessionRefusal When either map is unknown, the map is stale or read-only, the event has no destination, or the spawn point is off the destination. + * @throws SessionRefusal When either map is unknown, the map is stale or read-only, the event has no destination, the spawn point is off the destination, or the heading is not one. */ - public function setEventDestination(string $mapId, int $revision, string $marker, string $destinationMapId, int $x, int $y): array + public function setEventDestination(string $mapId, int $revision, string $marker, string $destinationMapId, int $x, int $y, ?string $heading = null): array { $map = $this->requireCurrentMap($mapId, $revision); $destination = $this->requireMap($destinationMapId); - $command = $this->runEdit(fn(): ?Command => $this->createMapInspector($map)->setTransferDestination($map, $marker, $destination, $x, $y)); + $command = $this->runEdit(fn(): ?Command => $this->createMapInspector($map)->setTransferDestination($map, $marker, $destination, $x, $y, $heading)); return ['revision' => $this->getMapRevision($map), 'changed' => $command !== null]; } diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php index e29005c..a0e06bb 100644 --- a/src/Session/SessionHost.php +++ b/src/Session/SessionHost.php @@ -437,6 +437,7 @@ private function dispatch(string $method, array $params): array self::requireString($params, 'destination'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), + self::readOptionalString($params, 'heading'), ), 'database.records' => $session->listDatabaseRecords(self::requireString($params, 'category')), 'troops.page' => $session->readTroopPage(self::requireInt($params, 'index'), diff --git a/tests/Unit/Session/EventRecordAuthoringTest.php b/tests/Unit/Session/EventRecordAuthoringTest.php index dce0a86..55f9371 100644 --- a/tests/Unit/Session/EventRecordAuthoringTest.php +++ b/tests/Unit/Session/EventRecordAuthoringTest.php @@ -10,7 +10,8 @@ * An event's record rows through the session, as a GUI editor or an agent * authors them: the fields every trigger reads on every event type, a Story * Script's inline commands in the shared command editor, a Dialogue event's - * conditional pages and talk-to id, and the map's region station. Every edit is checked against the written source: authored + * conditional pages and talk-to id, the map's region station and a spawn + * facing. Every edit is checked against the written source: authored * comments, expressions and untouched keys stay as they were. */ @@ -270,6 +271,36 @@ function eventRecordHost(string $root): Closure expect(require eventRecordDataPath($root))->not->toHaveKey('station'); }); +it('picks the heading a transfer arrives facing, with its destination or alone, from the Engine\'s headings', function () { + $root = eventRecordProject(); + $request = eventRecordHost($root); + $read = $request('inspector.read', ['map' => 'test-map', 'event' => 'T'])['result']; + $facing = eventRecordRow($read, 'data.spawnSprite'); + + expect($facing)->toMatchArray(['kind' => 'options', 'label' => 'Spawn Facing', 'value' => '(sprite art: @)', 'options' => ['North', 'East', 'South', 'West']]) + ->and($request('inspector.apply', ['map' => 'test-map', 'revision' => $read['revision'], 'key' => $facing['key'], 'value' => 'Up'])['error']['message']) + ->toContain('choose one of'); + + $revision = $request('inspector.apply', ['map' => 'test-map', 'revision' => $read['revision'], 'key' => $facing['key'], 'value' => 'North'])['result']['revision']; + expect(eventRecordRow($request('inspector.read', ['map' => 'test-map', 'event' => 'T'])['result'], 'data.spawnSprite')['value'])->toBe('North'); + + // With the destination: omitted, the facing stays; given, it is checked and set with the spawn point as one step. + $moved = $request('event.destination', ['map' => 'test-map', 'revision' => $revision, 'marker' => 'T', 'destination' => 'test-map', 'x' => 2, 'y' => 2])['result']; + expect($moved['changed'])->toBeTrue() + ->and(eventRecordRow($request('inspector.read', ['map' => 'test-map', 'event' => 'T'])['result'], 'data.spawnSprite')['value'])->toBe('North'); + expect($request('event.destination', ['map' => 'test-map', 'revision' => $moved['revision'], 'marker' => 'T', 'destination' => 'test-map', 'x' => 3, 'y' => 2, 'heading' => 'None'])['error']['message']) + ->toContain('not a spawn facing'); + $request('event.destination', ['map' => 'test-map', 'revision' => $moved['revision'], 'marker' => 'T', 'destination' => 'test-map', 'x' => 3, 'y' => 2, 'heading' => 'West']); + $request('map.save', ['map' => 'test-map']); + + expect((require eventRecordDataPath($root))['events']['T']['data'])->toBe(['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 3, 'y' => 2], 'spawnSprite' => ['West']]) + ->and($request('history.undo')['result']['label'])->toBe('Destination change') + ->and(eventRecordRow($request('inspector.read', ['map' => 'test-map', 'event' => 'T'])['result'], 'data.spawnSprite')['value'])->toBe('North'); + + $created = $request('event.create', ['map' => 'test-map', 'revision' => $request('map.read', ['map' => 'test-map'])['result']['revision'], 'cells' => [[2, 3]], 'type' => 'Transfer Player'])['result']; + expect(eventRecordRow($request('inspector.read', ['map' => 'test-map', 'event' => $created['marker']])['result'], 'data.spawnSprite')['value'])->toBe('South'); +}); + it('places a Story Script\'s inline route on the map through its frame rows, at the commands\' place in the event data', function () { $root = eventRecordProject(); $path = eventRecordDataPath($root); From 6100cbf9a6b568887060e20aa643d741a8c67f06 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 23:03:38 +0200 Subject: [PATCH 324/344] docs(guides): event trigger fields, inline scripts, dialogue variants, station and arrival heading in the script guide --- docs/guides/change-maps-from-a-script.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/guides/change-maps-from-a-script.md b/docs/guides/change-maps-from-a-script.md index d6c84c3..759c69a 100644 --- a/docs/guides/change-maps-from-a-script.md +++ b/docs/guides/change-maps-from-a-script.md @@ -131,6 +131,11 @@ Layers are named by `id` in edits. An event row key looks like | Resize | `map.insertLines`, `map.removeLines` (with an `answer`), or the map's size rows | | Events | `event.create {map, cells, type, marker?}`, `event.move {map, marker, dx, dy}`, `event.bounds`, `event.destination`, `event.delete`; fields with `inspector.apply` | | Bind an event to the object it describes | `event.subject {map, marker, subject}`; `subject: null` with `cells` unbinds it | +| An event's conditions, writes, blocked message, cue conditions | `inspector.apply` with `{target: event-record, marker, field, frame: []}`, `field` one of `conditions`, `sets`, `whenBlocked`, `cue.conditions`; the value is a condition or write line (`conditions.grammar`), empty removes it | +| A Story Script event's inline commands | `inspector.read {map, event, frame: ["script"]}`, then `inspector.apply`/`add`/`remove` with the frame's row keys; the first command goes on the `frameEmpty` row | +| Dialogue pages with conditions and writes, talk-to id | the event's `variantList`, `variantNConditions`, `variantNSets`, `variantNLineM...` rows; the Talk-To Id row sets `npcId` | +| Where a transfer arrives facing | the Spawn Facing row, or `event.destination {..., heading}` | +| A map's region-map cell | `inspector.apply` with `{target: map-station, field: station}`, value `x, y`, empty removes it | | World objects | `worldObjects.create`, `worldObjects.move {map, id, dx, dy}`, `worldObjects.place`, `worldObjects.apply`, `worldObjects.add`, `worldObjects.remove`, `worldObjects.delete` | | NPCs | `npc.create`, `npc.move`, `npc.apply`, `npc.add`, `npc.remove`, `npc.duplicate`, `npc.delete` | | Map fields (name, kind, music, encounters, atlas, landmarks) | `inspector.apply` with the map row's key | From 9398678cb708926d915269765fdf49c29508c6e2 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 23:08:39 +0200 Subject: [PATCH 325/344] fix(records): never rewrite a condition or write list its line cannot carry whole Record rows (NPCs, quests, cutscene availability and every other record with a conditions or writes field) re-encoded lists through the line even when it would drop part of them, such as a quest status it does not express. The record layer now shows such a list read-only with the reason and refuses an edit to it, by the codecs' own carry check. The event pane's copy of that check is removed. --- src/Database/ProjectRecordDatabase.php | 36 +++++++++++++- src/Events/EventRecordPane.php | 49 ++++--------------- .../Unit/Session/RecordConditionCarryTest.php | 31 ++++++++++++ 3 files changed, 74 insertions(+), 42 deletions(-) create mode 100644 tests/Unit/Session/RecordConditionCarryTest.php diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 268d30c..6ae1a2d 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -63,6 +63,13 @@ public function useAuthoringReferences(ReferenceCatalog $references): void private function coerceReferenceField(ProjectRecord $record, RecordField $field, string $rawValue, array $ownerPath = []): mixed { + $authored = (array) $record->toArray(); + foreach ($ownerPath === [] ? explode('.', $field->key) : $ownerPath as $segment) { + $authored = is_array($authored) ? ($authored[$segment] ?? null) : null; + } + if (($issue = self::findCarryIssue($field, $authored)) !== null) { + throw new \InvalidArgumentException(sprintf('%s cannot be edited as a line: %s. Nothing was changed.', $field->label, $issue)); + } $value = self::coerce($field, $rawValue); if ($this->authoringReferences !== null && in_array($field->reference, ['actor_ids', 'stage_timelines', 'cinematic_movement_routes', 'map_world_objects'], true) && $value !== null) { $choices = $field->reference === 'cinematic_movement_routes' @@ -77,6 +84,21 @@ private function coerceReferenceField(ProjectRecord $record, RecordField $field, return $value; } + /** + * Why a condition or write list cannot be edited through its line + * without losing part of it, by the codec that owns the line; null when + * it can, or the field holds another kind of value or none. + */ + private static function findCarryIssue(RecordField $field, mixed $authored): ?string + { + return match (true) { + $authored === null => null, + $field->codec === RecordFieldCodec::CONDITIONS => ConditionCodec::findCarryIssue($authored), + $field->codec === RecordFieldCodec::WORLD_WRITES => WorldWriteCodec::findCarryIssue($authored), + default => null, + }; + } + /** Proves a proposed payload is valid and source-editable without changing the actual record. */ private function assertProposedRecord(ProjectRecord $record, ProjectRecord $draft): void { @@ -580,7 +602,8 @@ public function getSettingsFields(int $index): array $fields = []; foreach ($this->schema->fieldsFor($record->toArray()) as $field) { - $fields[] = self::describeField($field, self::displayValue($field, $record->get($field->key)), $field->key, $isEditable); + $fields[] = self::describeField($field, self::displayValue($field, $record->get($field->key)), $field->key, $isEditable, + authored: $record->get($field->key)); } if ($this->schema->key === PlayerPresentationFields::CATEGORY) { $fields = PlayerPresentationFields::describeFields($fields, (array) $record->toArray(), $this->authoringReferences?->getAssetRoot()); @@ -647,6 +670,7 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, self::subFieldId($subList->prefix, $entryIndex, $field->key), $isEditable, sprintf('%s %d %s', ucfirst($subList->singular), $entryIndex + 1, $field->label), + self::readNested($entry, $field->key), ), 'entry' => [$entryIndex], 'entryLabel' => sprintf('%s %d', ucfirst($subList->singular), $entryIndex + 1), 'name' => $field->label, 'sourceKey' => $field->key, 'dataPath' => [...($basePath === [] ? explode('.', $subList->key) : $basePath), $entryIndex, ...explode('.', $field->key)]], $entry, $owner)]; } @@ -750,6 +774,7 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, $nestedIndex + 1, $field->label, ), + self::readNested($nestedEntry, $field->key), ), $nestedEntry, $owner)]; } @@ -3406,9 +3431,11 @@ private function writeNestedSubList(array $context, array $nestedEntries): void * @param string $fieldId The settings-field identifier. * @param bool $isEditable Whether the category and record accept edits. * @param string|null $label An override label (used for sub-list rows). + * @param mixed $authored The field's authored value, for a list its line may not carry whole. * @return array */ - private static function describeField(RecordField $field, string $value, string $fieldId, bool $isEditable, ?string $label = null): array + private static function describeField(RecordField $field, string $value, string $fieldId, bool $isEditable, ?string $label = null, + mixed $authored = null): array { $descriptor = [ 'label' => $label ?? $field->label, @@ -3416,6 +3443,11 @@ private static function describeField(RecordField $field, string $value, string 'field' => $fieldId, ]; + if (($issue = self::findCarryIssue($field, $authored)) !== null) { + // Shown as authored and left alone: committing the line would lose what it cannot carry. + return [...$descriptor, 'value' => '! ' . $issue, 'editable' => false]; + } + if (! $isEditable || $field->isReadOnly) { // Said outright, so the pane never opens an editor on a value the // record would refuse to take. diff --git a/src/Events/EventRecordPane.php b/src/Events/EventRecordPane.php index ffed42f..730c8c5 100644 --- a/src/Events/EventRecordPane.php +++ b/src/Events/EventRecordPane.php @@ -4,7 +4,6 @@ namespace Ichiloto\Editor\Events; -use Ichiloto\Editor\Database\ConditionCodec; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\RecordAuthoring; use Ichiloto\Editor\Database\RecordField; @@ -14,7 +13,6 @@ use Ichiloto\Editor\Database\RecordSchema; use Ichiloto\Editor\Database\RecordSchemaCatalog; use Ichiloto\Editor\Database\ReferenceCatalog; -use Ichiloto\Editor\Database\WorldWriteCodec; use Ichiloto\Editor\Dialogue\DialogueVariants; use Ichiloto\Editor\Inspector\InspectorRefusal; use Ichiloto\Engine\Core\WorldConditionEvaluator; @@ -131,7 +129,7 @@ public function getFields(array $frame = []): ?array } foreach ($this->records->getFrameSettingsFields(0, $frame) as $row) { - $rows[] = $this->describeRow($row, $frame, $payload); + $rows[] = $this->describeRow($row, $frame); } if ($frame === [] && in_array('script', $this->lifted, true) && ($payload['script'] ?? []) !== [] @@ -348,10 +346,9 @@ private function unproject(array $payload): array * * @param array $row * @param list $frame - * @param array $payload * @return array */ - private function describeRow(array $row, array $frame, array $payload): array + private function describeRow(array $row, array $frame): array { $row['target'] = self::TARGET; $row['marker'] = $this->marker; @@ -366,25 +363,13 @@ private function describeRow(array $row, array $frame, array $payload): array if ($item->childNoun !== null) { $row['childNoun'] = $item->childNoun; } } - $isConditions = ($row['conditions'] ?? false) === true; - $isWrites = ($row['worldWrites'] ?? false) === true; - - if ($isConditions || $isWrites) { - $authored = is_array($row['dataPath'] ?? null) ? self::readPath($payload, $row['dataPath']) : self::readPath($payload, explode('.', $fieldId)); - $issue = $authored === null ? null : ($isConditions ? ConditionCodec::findCarryIssue($authored) : WorldWriteCodec::findCarryIssue($authored)); - - if ($issue !== null) { - // Shown as authored and left alone: committing the line would lose what it cannot carry. - return [...array_diff_key($row, array_flip(['conditions', 'worldWrites'])), 'value' => '! ' . $issue, 'editable' => false]; - } - - if ($isConditions) { - // The map inspector's condition editor, in every interface that hosts one. - $row['mapConditions'] = true; - $row['encoded'] = (string) ($row['value'] ?? ''); - $row['conditionsTitle'] = trim((string) ($row['label'] ?? 'Conditions')); - $row['conditionsHint'] = 'Building conditions. An empty list always holds.'; - } + // A list its line cannot carry whole is already read-only, as the record layer describes it. + if (($row['conditions'] ?? false) === true) { + // The map inspector's condition editor, in every interface that hosts one. + $row['mapConditions'] = true; + $row['encoded'] = (string) ($row['value'] ?? ''); + $row['conditionsTitle'] = trim((string) ($row['label'] ?? 'Conditions')); + $row['conditionsHint'] = 'Building conditions. An empty list always holds.'; } if (is_array($row['mapPlacement']['sourcePaths'] ?? null)) { @@ -437,22 +422,6 @@ private function requireFrame(array $frame): void } } - /** @param list $path */ - private static function readPath(array $payload, array $path): mixed - { - $current = $payload; - - foreach ($path as $segment) { - if (! is_array($current) || ! array_key_exists($segment, $current)) { - return null; - } - - $current = $current[$segment]; - } - - return $current; - } - /** * Why the Engine would refuse a condition or write list an edit changed, * or null when it reads every one. Lists the edit left alone are not its diff --git a/tests/Unit/Session/RecordConditionCarryTest.php b/tests/Unit/Session/RecordConditionCarryTest.php new file mode 100644 index 0000000..292eb65 --- /dev/null +++ b/tests/Unit/Session/RecordConditionCarryTest.php @@ -0,0 +1,31 @@ + 'quest', 'name' => 'harvest', 'status' => 'failed']]; + $root = npcSessionProject([['id' => 'keeper', 'name' => 'Keeper', 'sprite' => '@', 'x' => 2, 'y' => 1, 'conditions' => $conditions]]); + $session = EditorSession::open($root); + $read = $session->readNpc('test-map', 0); + $row = npcSessionRow($read, 'conditions'); + + expect($row['kind'])->toBe('info') + ->and($row['value'])->toStartWith('! condition 1 has a quest status the condition editor would rewrite') + ->and(fn() => $session->applyNpc('test-map', $read['revision'], 0, $row['key'], 'switch door-open')) + ->toThrow(SessionRefusal::class, 'cannot be edited') + ->and(getSessionMap($session)->getNpcs()->all()[0]->toArray()['conditions'])->toBe($conditions) + ->and($session->listUnsavedChanges())->toBe([]); +}); + +it('keeps a condition list the line carries whole editable through the same row', function () { + $root = npcSessionProject([['id' => 'keeper', 'name' => 'Keeper', 'sprite' => '@', 'x' => 2, 'y' => 1, + 'conditions' => [['type' => 'switch', 'name' => 'door-open', 'value' => true]]]]); + $session = EditorSession::open($root); + $read = $session->readNpc('test-map', 0); + + expect(npcSessionRow($read, 'conditions')['kind'])->toBe('conditions'); +}); From 6851e1154389aee168b3742f3ea4e2b92e62e891 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 23:27:29 +0200 Subject: [PATCH 326/344] fix(cutscenes): choose a cinematic's map-local references on its start map A cinematic record checked world object choices against a catalog with no map, so every world object id was refused: a visual could not be bound to one and no other map-local reference could name one. Each cinematic record now chooses from a catalog scoped to its own asset, whose subject map is the map it starts on. Event marker choices read the same subject map, as NPC and world object choices already did. --- src/Cutscenes/CutsceneLibrary.php | 6 +++++ src/Database/ProjectRecordDatabase.php | 20 +++++++++++--- src/Database/ReferenceCatalog.php | 17 +++++++++--- .../Cutscenes/CinematicReferenceScopeTest.php | 27 +++++++++++++++++++ 4 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/Cutscenes/CinematicReferenceScopeTest.php diff --git a/src/Cutscenes/CutsceneLibrary.php b/src/Cutscenes/CutsceneLibrary.php index c991040..0d8af1f 100644 --- a/src/Cutscenes/CutsceneLibrary.php +++ b/src/Cutscenes/CutsceneLibrary.php @@ -5,6 +5,7 @@ namespace Ichiloto\Editor\Cutscenes; use Closure; +use Ichiloto\Editor\Database\ProjectRecord; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\GenericCommand; @@ -435,6 +436,11 @@ function (array $written) use ($type): void { graphical: $graphical, ); if ($this->authoringReferences !== null) { $records->useAuthoringReferences($this->authoringReferences); } + // Each record is one asset, keyed by its id: its map-local choices are on the map that asset starts on. + $records->useRecordReferences(function (ProjectRecord $record) use ($type): ?ReferenceCatalog { + $asset = $this->find($type, $record->recordId); + return $asset === null ? null : $this->authoringReferences?->createCutsceneContext($asset); + }); return $records; } diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index dfe8d44..fafec74 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -54,6 +54,8 @@ final class ProjectRecordDatabase /** @var (\Closure(string): list)|null What keeps a record from being deleted ({@see useDeletionReferences()}). */ private ?\Closure $deletionReferences = null; private ?ReferenceCatalog $authoringReferences = null; + /** @var (\Closure(ProjectRecord): ?ReferenceCatalog)|null */ + private ?\Closure $recordReferences = null; private bool $graphical = false; public function useAuthoringReferences(ReferenceCatalog $references): void @@ -61,13 +63,25 @@ public function useAuthoringReferences(ReferenceCatalog $references): void $this->authoringReferences = $references; } + /** + * Scopes the references one record chooses from to that record, where it owns some of its own: a cinematic's + * map-local subjects are on the map it starts on. + * + * @param \Closure(ProjectRecord): ?ReferenceCatalog $scope + */ + public function useRecordReferences(\Closure $scope): void + { + $this->recordReferences = $scope; + } + private function coerceReferenceField(ProjectRecord $record, RecordField $field, string $rawValue, array $ownerPath = []): mixed { $value = self::coerce($field, $rawValue); - if ($this->authoringReferences !== null && in_array($field->reference, ['actor_ids', 'stage_timelines', 'cinematic_movement_routes', 'map_world_objects'], true) && $value !== null) { + $references = ($this->recordReferences === null ? null : ($this->recordReferences)($record)) ?? $this->authoringReferences; + if ($references !== null && in_array($field->reference, ['actor_ids', 'stage_timelines', 'cinematic_movement_routes', 'map_world_objects'], true) && $value !== null) { $choices = $field->reference === 'cinematic_movement_routes' - ? $this->authoringReferences->getMovementRouteIds($record, $ownerPath) - : $this->authoringReferences->valuesFor($field->reference); + ? $references->getMovementRouteIds($record, $ownerPath) + : $references->valuesFor($field->reference); foreach (is_array($value) ? $value : [$value] as $selected) { if (! in_array($selected, $choices, true)) { throw new \InvalidArgumentException(sprintf('Unknown %s "%s"; choose a project resource.', $field->reference, strval($selected))); diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 33a24f7..b95cf35 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -293,7 +293,7 @@ public function valuesFor(string $category): array // named point on them (each image picks one on its own subject). 'stage_subjects' => array_keys($this->getStageSubjects()), 'stage_attachments' => array_keys($this->getStageAttachments()), - 'event_markers' => $this->currentMap?->getEventMarkers() ?? [], + 'event_markers' => $this->getSubjectMap()?->getEventMarkers() ?? [], // A map's kind is one of the project's tilesets, by file stem. 'tilesets' => array_keys($this->loadTilesetNames()), // The scenes a graphical battle can take place in, by the key a @@ -550,12 +550,15 @@ private function subjectIds(): array ...($this->getSubjectMap()?->getNpcs()->ids() ?? []), ...$this->valuesFor('map_world_objects'), ...$this->valuesFor('actors'), - ...($this->currentMap?->getEventMarkers() ?? []), + ...($this->getSubjectMap()?->getEventMarkers() ?? []), ])); } - /** Without an explicitly selected map context, a cinematic owns references on its declared start map. */ - private function getSubjectMap(): ?ProjectMap + /** + * The map whose NPCs, world objects and markers a subject reference names: the explicitly selected map, or + * without one a cinematic's declared start map. + */ + public function getSubjectMap(): ?ProjectMap { if ($this->currentMap !== null) { return $this->currentMap; @@ -587,6 +590,12 @@ private function checkpointIds(): array ), static fn(string $checkpoint): bool => $checkpoint !== '')); } + /** A cutscene's own references: its cast, and the map-local subjects on the map it declares it starts on. */ + public function createCutsceneContext(CutsceneAsset $cutscene): self + { + return new self($this->workspace, $this->currentMap, $cutscene, $this->movementCommands); + } + /** A projection keeps the actual invocation as reference owner, rather than its one displayed command. */ public function createMovementContext(array $commands): self { diff --git a/tests/Unit/Cutscenes/CinematicReferenceScopeTest.php b/tests/Unit/Cutscenes/CinematicReferenceScopeTest.php new file mode 100644 index 0000000..f223634 --- /dev/null +++ b/tests/Unit/Cutscenes/CinematicReferenceScopeTest.php @@ -0,0 +1,27 @@ + 'beacon', 'anchor' => ['x' => 6, 'y' => 3], 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null]]; + file_put_contents($mapPath, " array_find($session->readDatabaseRecord('cutscenes/cinematic', 0)['rows'], + static fn(array $row): bool => ($row['entry'] ?? []) === [0] && ($row['name'] ?? '') === $name) + ?? throw new RuntimeException('Missing cast row ' . $name); + + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row('Bind Visual To')['key'], 'world_object'); + expect(fn() => $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row('Bound World Object')['key'], 'elsewhere')) + ->toThrow(SessionRefusal::class, 'Unknown map_world_objects'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row('Bound World Object')['key'], 'beacon'); + $session->saveDatabase('cutscenes/cinematic'); + + expect((require $dataPath)['cast'][0]['subject'])->toBe(['kind' => 'world_object', 'id' => 'beacon']); +}); From 689f79ef106e6874aedaa9355f8aee9cc9c30370 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 23:27:39 +0200 Subject: [PATCH 327/344] feat(cutscenes): author subject-relative waypoints and staged positions Route waypoints, independent stage_actor commands and staged cast members can stand relative to a map subject through the Engine's at/offset contract. The Relative To choices come from CinematicCommandSchema::export(), named subjects take a stable id from a picker, and the Engine's route and stage validators judge every edit, with an id-less subject kept as a draft that refuses Save. Choosing a subject removes the point's own x/y; bound visuals are never offered a subject or offset, and binding one removes them. The Terminal editor offers the same rows as list choices, without world objects as for every subject. Map placement previews show a relative point at its subject's cell on the owner's map (an event's or NPC's map, a cinematic's start map) or say why it cannot, and refuse placing it by click. Map line insertion and removal never rewrite relative points; an offset the edited lines fall between, or may, is listed as a hand edit. Validation reports relative subjects missing from the scene's map or stage. --- docs/manual.md | 24 +- src/Database/CutsceneSchemas.php | 21 +- src/Database/MovementRouteFields.php | 20 +- src/Database/RecordSchemaCatalog.php | 2 +- src/Database/RelativePositionFields.php | 145 ++++++++ src/Database/StagedActorFields.php | 26 +- src/Maps/MapLineInventory.php | 50 ++- src/Maps/MapLinePlanner.php | 4 +- src/Maps/MapPlacement.php | 77 +++- src/Maps/MapSubjectCells.php | 98 +++++ src/Session/MapPlacementSession.php | 30 ++ src/Validation/CutsceneValidation.php | 29 ++ src/Validation/ProjectValidator.php | 12 + tests/Unit/RelativePositionAuthoringTest.php | 359 +++++++++++++++++++ 14 files changed, 876 insertions(+), 21 deletions(-) create mode 100644 src/Database/RelativePositionFields.php create mode 100644 src/Maps/MapSubjectCells.php create mode 100644 tests/Unit/RelativePositionAuthoringTest.php diff --git a/docs/manual.md b/docs/manual.md index 518eb97..43ffb40 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -291,7 +291,11 @@ What moves, when it is in the map's space and at or beyond the line: listed as a hand edit because a walk across the line ends elsewhere), `camera` and `field_animation` position targets, `stage_actor`, and `transfer` into the map. Scripts start on their map; after a `transfer` elsewhere, later commands - are in that map's space and stay; + are in that map's space and stay. A waypoint, staged actor or cast member + placed relative to a subject (`at` with an optional `offset`) is never + rewritten: it follows its subject. When the edited lines fall between the + subject and the point, or may (a player or staged actor subject), its + offset is listed as a hand edit; - reusable scripts in `assets/Events/` started only from this map, and cutscenes whose start map it is (their cast, script and finalizer). @@ -2000,6 +2004,24 @@ that leg; adding a point leaves both axes blank until authored, not zero. Incomplete new or changed routes refuse Save without discarding the draft or undo history. Unchanged legacy data survives unrelated edits. +A waypoint, an independent `stage_actor` and a staged cast member may instead +stand relative to a map subject, the Engine's `at`/`offset` position. `Relative +To` chooses the subject kind from the Engine's exported list: the player, an NPC, +a world object (graphical editor only, as for every subject picker), a staged +actor (cinematics only), a party actor or an event marker. A named subject's id +is picked from what exists (the map's NPCs, world objects and markers, the +cinematic's cast); the player and the leading party actor take none. `Offset X` +and `Offset Y` are whole cells, zero when left out. Choosing a subject removes +the point's X and Y; clearing it from a waypoint is refused rather than inventing +a coordinate. A visual bound to a real subject inherits its position and is never +offered a subject or offset; binding one removes them. A subject still waiting +for its id refuses Save. The map placement view shows each relative point at its +subject's cell on the owner's map (an event's or NPC's own map, a cinematic's +Start Map), or says why it cannot: the player, party actors and staged actors are +placed only while the scene runs, and a common event runs on whichever map calls +it. A relative point is changed through its subject and offset, never by a map +click. + `Remember As` declares a session-local identity. `Recorded Route` selects only declarations in that script invocation and its referenced common events, including nested command arms, not other NPCs, dialogue variants, map events or diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index 9f99f7c..9fb5795 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -335,7 +335,8 @@ public static function castList(bool $graphical = false): RecordSubList ], variantKey: 'kind', nestedLists: $graphical ? ['staged_actor' => StagedActorFields::getSuppressionList(...)] : [], - prepareEdit: $graphical ? StagedActorFields::prepareEdit(...) : null, + prepareEdit: static fn(array $entry, string $field): array => StagedActorFields::preparePositionEdit( + $graphical ? StagedActorFields::prepareEdit($entry, $field) : $entry, $field), ); } @@ -347,12 +348,19 @@ public static function castList(bool $graphical = false): RecordSubList */ public static function stagedActorFields(bool $graphical = false, array $entry = []): array { + $axes = [new RecordField('x', 'X', InputControlType::INTEGER), new RecordField('y', 'Y', InputControlType::INTEGER)]; + // A visual bound to a real subject inherits its position: it is never placed relative to another subject, + // and the graphical editor that binds it offers no coordinates either. + $position = match (true) { + ! isset($entry['subject']) => RelativePositionFields::getFields($entry, $axes, cinematic: true, graphical: $graphical), + $graphical => [], + default => $axes, + }; $fields = [ new RecordField('id', 'Id'), new RecordField('sprite', 'Sprite', InputControlType::MULTILINE, codec: RecordFieldCodec::LINES, removeWhenEmpty: true), new RecordField('asset', 'Asset', removeWhenEmpty: true), - new RecordField('x', 'X', InputControlType::INTEGER), - new RecordField('y', 'Y', InputControlType::INTEGER), + ...$position, new RecordField('facing', 'Facing', options: self::FACINGS, removeWhenEmpty: true, displayDefault: 'south'), RecordField::boolean('visible', 'Visible', removeWhenEmpty: false, displayDefault: 'true'), RecordField::boolean('collision', 'Collision'), @@ -368,7 +376,7 @@ public static function stagedActorFields(bool $graphical = false, array $entry = if (isset($entry['subject'])) { $fields = array_values(array_filter($fields, static fn(RecordField $field): bool - => ! in_array($field->key, ['x', 'y', 'facing', 'collision'], true))); + => ! in_array($field->key, ['facing', 'collision'], true))); } return [...$fields, ...StagedActorFields::getFields($entry)]; @@ -392,7 +400,7 @@ public static function cinematicCommandList(string $key, bool $graphical = false variants: self::cinematicCommandVariants($graphical), variantKey: 'type', nestedLists: [ - 'move_route' => MovementRouteFields::getPointList(...), + 'move_route' => static fn(array $entry): ?RecordSubList => MovementRouteFields::getPointList($entry, cinematic: true, graphical: $graphical), 'parallel' => self::laneList(), 'camera' => static fn(array $entry): ?RecordSubList => strtolower(strval($entry['operation'] ?? '')) === 'route' ? self::cameraPointList($graphical) : null, ...RecordSchemaCatalog::getRegisteredCommandLists($graphical), @@ -406,6 +414,7 @@ public static function cinematicCommandList(string $key, bool $graphical = false prepareEdit: static function (array $entry, string $field) use ($graphical): array { $entry = InnPresentationFields::prepareEdit($entry, $field); $entry = $graphical ? StagedActorFields::prepareEdit($entry, $field) : $entry; + $entry = StagedActorFields::preparePositionEdit($entry, $field); return MovementRouteFields::prepareEdit($entry, $field); }, ); @@ -500,7 +509,7 @@ public static function cinematicCommandVariants(bool $graphical = false): array ], 'stage_actor' => static fn(array $entry): array => $graphical ? StagedActorFields::getCommandFields($entry) - : self::stagedActorFields(), + : self::stagedActorFields(entry: $entry), 'show_actor' => [new RecordField('actorId', 'Actor', reference: 'cinematic_cast')], 'hide_actor' => [new RecordField('actorId', 'Actor', reference: 'cinematic_cast')], 'remove_actor' => [new RecordField('actorId', 'Actor', reference: 'cinematic_cast')], diff --git a/src/Database/MovementRouteFields.php b/src/Database/MovementRouteFields.php index 8fe1d20..acec156 100644 --- a/src/Database/MovementRouteFields.php +++ b/src/Database/MovementRouteFields.php @@ -80,21 +80,29 @@ public static function getFields(array $entry, bool $cinematic = false): array ]; } - public static function getPointList(array $entry): ?RecordSubList + /** A waypoint is a map cell (either axis may keep the current one) or a cell relative to a map subject. */ + public static function getPointList(array $entry, bool $cinematic = false, bool $graphical = false): ?RecordSubList { + $axes = [ + new RecordField('x', 'X', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '(Keep current X)'), + new RecordField('y', 'Y', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '(Keep current Y)'), + ]; return match (self::getMode($entry)) { 'steps' => RecordSchemaCatalog::routeStepList(), 'waypoints' => new RecordSubList( key: 'waypoints', prefix: 'waypoint', singular: 'waypoint', - fields: [ - new RecordField('x', 'X', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '(Keep current X)'), - new RecordField('y', 'Y', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '(Keep current Y)'), - ], + fields: $axes, blank: [], prepareEdit: static function (array $point, string $field): array { - self::assertPathOptions(['waypoints' => [$point]]); + $validate = static fn(array $point) => self::assertPathOptions(['waypoints' => [$point]]); + if (RelativePositionFields::isRelative($point) || $field === RelativePositionFields::KIND_FIELD) { + $point = RelativePositionFields::prepareEdit($point, $field, $validate); + if (RelativePositionFields::isRelative($point)) { return $point; } + } + $validate($point); return $point; }, + getFields: static fn(array $point): array => RelativePositionFields::getFields($point, $axes, $cinematic, $graphical), ), default => null, }; diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index d1a2eaf..eb863df 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -2071,7 +2071,7 @@ public static function eventCommandList(string $key, bool $graphical = false): R variants: self::eventCommandVariants($graphical), variantKey: 'type', nestedLists: [ - 'move_route' => MovementRouteFields::getPointList(...), + 'move_route' => static fn(array $entry): ?RecordSubList => MovementRouteFields::getPointList($entry, graphical: $graphical), ...self::getRegisteredCommandLists($graphical), ], prepareEdit: static fn(array $entry, string $field): array => MovementRouteFields::prepareEdit( diff --git a/src/Database/RelativePositionFields.php b/src/Database/RelativePositionFields.php new file mode 100644 index 0000000..b7efa9f --- /dev/null +++ b/src/Database/RelativePositionFields.php @@ -0,0 +1,145 @@ + ['map_npcs', 'NPC'], + 'world_object' => ['map_world_objects', 'World Object'], + 'staged_actor' => ['cinematic_cast', 'Staged Actor'], + 'party_actor' => ['actors', 'Party Actor'], + 'marker' => ['event_markers', 'Event Marker'], + ]; + + /** Whether a point declares any part of a relative position. */ + public static function isRelative(array $entry): bool + { + return array_intersect(CinematicCommandSchema::RELATIVE_POSITION_FIELDS, array_keys($entry)) !== []; + } + + /** + * The subject kinds an author may choose here. Staged actors exist only on a cinematic's stage, and world + * objects are authored in the graphical editor alone, as every other subject picker offers them. + * + * @return list + */ + public static function getSubjectKinds(bool $cinematic, bool $graphical): array + { + $kinds = CinematicCommandSchema::export()['relativePosition']['subjectKinds']; + return array_values(array_filter($kinds, static fn(string $kind): bool + => ($cinematic || $kind !== 'staged_actor') && ($graphical || $kind !== 'world_object'))); + } + + /** + * The fields of a point: its absolute fields and a subject choice while it is absolute, or the subject, its id + * and the offset once it is relative. Independent coordinates and an offset are never offered together. + * + * @param list $absoluteFields + * @return list + */ + public static function getFields(array $entry, array $absoluteFields, bool $cinematic, bool $graphical): array + { + $kinds = self::getSubjectKinds($cinematic, $graphical); + $kind = new RecordField(self::KIND_FIELD, 'Relative To', options: ['', ...$kinds], removeWhenEmpty: true, + displayDefault: '(map cell)'); + if (! self::isRelative($entry)) { + return [$kind, ...$absoluteFields]; + } + $selected = is_array($entry['at'] ?? null) ? strval($entry['at']['kind'] ?? '') : ''; + $fields = [$kind]; + if (isset(self::ID_REFERENCES[$selected])) { + [$reference, $label] = self::ID_REFERENCES[$selected]; + // The leading party actor needs no id; every other named subject does. + $fields[] = $selected === 'party_actor' + ? RecordField::reference(self::ID_FIELD, $label, $reference, allowsNone: true, noneLabel: '(leading party actor)') + : RecordField::reference(self::ID_FIELD, $label, $reference); + } + return [ + ...$fields, + new RecordField(self::OFFSET_X_FIELD, 'Offset X', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + new RecordField(self::OFFSET_Y_FIELD, 'Offset Y', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + ]; + } + + /** + * Keeps a point either absolute or relative after one field edit, then has the consumer's Engine validator judge + * it. Choosing a subject drops independent coordinates, and the id when the subject is the player; clearing it + * drops the offset with it. A subject still waiting for its id is a draft the owner's save boundary completes. + * + * @param Closure(array): void $validate The consumer's Engine validator. + * @return array + * @throws InvalidArgumentException When the edit leaves a point the Engine refuses. + */ + public static function prepareEdit(array $entry, string $field, Closure $validate): array + { + if (! in_array($field, [self::KIND_FIELD, self::ID_FIELD, self::OFFSET_X_FIELD, self::OFFSET_Y_FIELD], true)) { + return $entry; + } + if ($field === self::KIND_FIELD) { + $kind = is_array($entry['at'] ?? null) ? ($entry['at']['kind'] ?? null) : null; + if ($kind === null) { + unset($entry['at'], $entry['offset']); + } else { + unset($entry['x'], $entry['y']); + if (! isset(self::ID_REFERENCES[$kind])) { + unset($entry['at']['id']); + } + } + } + if (in_array($field, [self::OFFSET_X_FIELD, self::OFFSET_Y_FIELD], true) && ! is_array($entry['at'] ?? null)) { + throw new InvalidArgumentException('Choose what this position is relative to before giving it an offset.'); + } + if (($entry['offset'] ?? null) === []) { + // Omitted axes are zero, and the Engine refuses an empty offset. + unset($entry['offset']); + } + if (! self::isRelative($entry)) { + return $entry; + } + try { + $validate($entry); + } catch (InvalidArgumentException $error) { + if (array_key_exists('id', (array) $entry['at'])) { + throw $error; + } + } + return $entry; + } + + /** How a point reads where it is listed: its subject and offset, in cells. */ + public static function describePosition(array $entry): string + { + $at = is_array($entry['at'] ?? null) ? $entry['at'] : []; + $kind = strval($at['kind'] ?? ''); + $subject = match (true) { + $kind === 'player' => 'the player', + $kind === 'party_actor' && ! isset($at['id']) => 'the leading party actor', + isset(self::ID_REFERENCES[$kind]) => sprintf('%s %s', self::ID_REFERENCES[$kind][1], strval($at['id'] ?? '(no id)')), + default => '(no subject)', + }; + $offset = is_array($entry['offset'] ?? null) ? $entry['offset'] : []; + $x = intval($offset['x'] ?? 0); + $y = intval($offset['y'] ?? 0); + return $x === 0 && $y === 0 ? 'at ' . $subject : sprintf('%+d, %+d from %s', $x, $y, $subject); + } +} diff --git a/src/Database/StagedActorFields.php b/src/Database/StagedActorFields.php index 7aa7a16..54cc2c3 100644 --- a/src/Database/StagedActorFields.php +++ b/src/Database/StagedActorFields.php @@ -7,6 +7,7 @@ use Ichiloto\Editor\Field\CharacterSheetPreview; use Ichiloto\Editor\Field\FieldSpriteFields; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; +use Ichiloto\Engine\Cutscenes\Cinematics\CinematicStageManager; /** Graphical authoring controls; Engine remains the authority for accepted data. */ final class StagedActorFields @@ -88,6 +89,29 @@ public static function getSuppressionList(array $entry): ?RecordSubList ); } + /** + * Keeps an independent staged actor at either a map cell or a cell relative to a map subject, in both editors, + * as the Engine's stage binding validator accepts it. + */ + public static function preparePositionEdit(array $entry, string $field): array + { + if (($entry['type'] ?? null) === 'stage_actor' && is_array($entry['actor'] ?? null) && str_starts_with($field, 'actor.')) { + $actor = self::preparePositionEdit(['type' => 'stage_actor', ...$entry['actor']], substr($field, strlen('actor.'))); + if (! array_key_exists('type', $entry['actor'])) { + unset($actor['type']); + } + $entry['actor'] = $actor; + + return $entry; + } + if (($entry['type'] ?? null) !== 'stage_actor' && ($entry['kind'] ?? null) !== 'staged_actor') { + return $entry; + } + return RelativePositionFields::prepareEdit($entry, $field, static function (array $actor): void { + CinematicStageManager::validateBinding($actor); + }); + } + /** Changing a representation clears only the fields owned by the old representation. */ public static function prepareEdit(array $entry, string $field): array { @@ -108,7 +132,7 @@ public static function prepareEdit(array $entry, string $field): array if ($kind === null) { unset($entry['subject'], $entry['suppress']); } else { - unset($entry['x'], $entry['y'], $entry['facing'], $entry['collision']); + unset($entry['x'], $entry['y'], $entry['at'], $entry['offset'], $entry['facing'], $entry['collision']); if ($kind === 'player') { unset($entry['subject']['id']); } diff --git a/src/Maps/MapLineInventory.php b/src/Maps/MapLineInventory.php index 33b0e24..20443c6 100644 --- a/src/Maps/MapLineInventory.php +++ b/src/Maps/MapLineInventory.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor\Maps; +use Ichiloto\Editor\Database\RelativePositionFields; use Ichiloto\Editor\Events\CommandMapContext; use Ichiloto\Engine\Events\Triggers\SleepEventTrigger; @@ -52,10 +53,12 @@ final class MapLineInventory /** * @param MapLineEdit $edit The insertion or removal. * @param array $commonEvents Reusable event scripts by id. + * @param MapSubjectCells|null $subjects Where the edited map's subjects stood before the edit. */ public function __construct( private readonly MapLineEdit $edit, private readonly array $commonEvents, + private readonly ?MapSubjectCells $subjects = null, ) { } @@ -207,7 +210,7 @@ public function addCutscene(string $dataFile, string $scriptFile, string $id, ar : self::mergeContexts(array_map(static fn(string $map): array => [$map], $this->cinematicTriggerMaps[$id] ?? [])); foreach ((array) ($data['cast'] ?? []) as $index => $entry) { if (is_array($entry)) { - $this->shiftPointIn($dataFile, ['cast', $index], $entry, $start); + $this->shiftPositionIn($dataFile, ['cast', $index], $entry, $start); } } $this->transferContexts = []; @@ -325,7 +328,7 @@ private function shiftCommand(string $file, string $type, array $command, array case 'move_route': foreach ((array) ($command['waypoints'] ?? []) as $index => $waypoint) { if (is_array($waypoint)) { - $this->shiftPointIn($file, [...$at, 'waypoints', $index], $waypoint, $context); + $this->shiftPositionIn($file, [...$at, 'waypoints', $index], $waypoint, $context); } } // Steps are relative, so they cannot be rewritten, but a walk across the edited lines now ends elsewhere. @@ -347,8 +350,8 @@ private function shiftCommand(string $file, string $type, array $command, array break; case 'stage_actor': is_array($command['actor'] ?? null) - ? $this->shiftPointIn($file, [...$at, 'actor'], $command['actor'], $context) - : $this->shiftPointIn($file, $at, $command, $context); + ? $this->shiftPositionIn($file, [...$at, 'actor'], $command['actor'], $context) + : $this->shiftPositionIn($file, $at, $command, $context); break; } } @@ -372,6 +375,45 @@ private function shiftTargetIn(string $file, array $path, array $source, ?array } } + /** + * Moves a staged position: a map cell like any other point, or one relative to a map subject, which moves with + * its subject and is never rewritten. A relative offset counts cells, so when the edited lines fall between the + * subject and the point, or may, the point is reported for a hand edit. + * + * @param list $path + * @param array $point + * @param list|null $context + */ + private function shiftPositionIn(string $file, array $path, array $point, ?array $context): void + { + if (! RelativePositionFields::isRelative($point)) { + $this->shiftPointIn($file, $path, $point, $context); + return; + } + if ($context !== null && ! in_array($this->edit->mapId, $context, true)) { + return; + } + $axis = $this->edit->axis; + $offset = is_array($point['offset'] ?? null) && is_int($point['offset'][$axis] ?? null) ? $point['offset'][$axis] : 0; + if ($offset === 0) { + return; + } + $lines = $this->edit instanceof LineRemoval ? 'removed' : 'inserted'; + $position = RelativePositionFields::describePosition($point); + $cell = $context === [$this->edit->mapId] && is_array($point['at'] ?? null) + ? ($this->subjects?->resolveCell($point['at'])['cell'] ?? null) : null; + if ($cell === null) { + $this->reportHandEdit($file, [...$path, 'offset', $axis], sprintf('it stands %s, and the %s lines may fall between them; check its offset', $position, $lines)); + return; + } + $from = $cell[$axis === 'x' ? 0 : 1]; + $shiftedFrom = $this->edit->getShiftedCoordinate($from); + $shiftedTo = $this->edit->getShiftedCoordinate($from + $offset); + if ($shiftedFrom === null || $shiftedTo === null || $shiftedTo - $shiftedFrom !== $offset) { + $this->reportHandEdit($file, [...$path, 'offset', $axis], sprintf('it stands %s, and the %s lines fall between them; check its offset', $position, $lines)); + } + } + /** * Moves a point whose map is the script's current map: surely when that * is the edited map, never when it is surely another, and otherwise diff --git a/src/Maps/MapLinePlanner.php b/src/Maps/MapLinePlanner.php index f4d705b..b80270d 100644 --- a/src/Maps/MapLinePlanner.php +++ b/src/Maps/MapLinePlanner.php @@ -77,6 +77,8 @@ private static function buildPlan(string $root, MapLineEdit $edit): MapLinePlan $directory = $root . '/assets/Maps/' . $edit->mapId; $map = ProjectMap::fromDirectory($root . '/assets/Maps', $directory); [$width, $height] = [$map->getWidth(), $map->getHeight()]; + // Relative points are judged against where their subjects stand before the edit moves them. + $subjects = MapSubjectCells::readMap($map); $edit instanceof LineRemoval ? $map->removeLines($edit->axis, $edit->at, $edit->count) : $map->insertLines($edit->axis, $edit->at, $edit->count); @@ -121,7 +123,7 @@ private static function buildPlan(string $root, MapLineEdit $edit): MapLinePlan $commonEvents[basename($path, '.php')] = ['file' => $path, 'commands' => $payloads[$path]]; } } - $inventory = new MapLineInventory($edit, $commonEvents); + $inventory = new MapLineInventory($edit, $commonEvents, $subjects); foreach ($dataPaths as $path) { if (str_starts_with($path, $root . '/assets/Maps/') && is_array($payloads[$path])) { $inventory->addMapData($path, substr(dirname($path), strlen($root . '/assets/Maps/')), $payloads[$path]); diff --git a/src/Maps/MapPlacement.php b/src/Maps/MapPlacement.php index 352356c..b0526f2 100644 --- a/src/Maps/MapPlacement.php +++ b/src/Maps/MapPlacement.php @@ -6,11 +6,16 @@ use Ichiloto\Engine\Events\Interpreter\MovementRouteRunner; use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; +use Ichiloto\Editor\Database\RelativePositionFields; use Ichiloto\Editor\Cutscenes\Source\SourceNode; /** Map-space views of schema-owned command fields, never a runtime simulation. */ final class MapPlacement { + /** The source keys a subject-relative point is authored with. */ + private const array RELATIVE_KEYS = [RelativePositionFields::KIND_FIELD, RelativePositionFields::ID_FIELD, + RelativePositionFields::OFFSET_X_FIELD, RelativePositionFields::OFFSET_Y_FIELD]; + /** Decorate the existing event paths, including inline scripts and nested arms. */ public static function describeEventFields(array $definition, array $fields): array { @@ -91,9 +96,17 @@ public static function describeFields(array $entry, array $fields): array $coordinates = ['x' => null, 'y' => null]; foreach ((array) $entry['waypoints'] as $i => $waypoint) { if (!is_array($waypoint)) { break; } + if (RelativePositionFields::isRelative($waypoint)) { + // The route ends wherever its subject stands; resolveRelativePoints() reads that cell. + $coordinates = ['x' => null, 'y' => null]; + $steps[] = ['point' => [null, null], 'fields' => [], 'relative' => self::readRelative($waypoint), + 'label' => sprintf('Waypoint %d (%s)', $i + 1, RelativePositionFields::describePosition($waypoint))]; + continue; + } $axes = array_intersect_key($nested[$i] ?? [], $waypoint, array_flip(['x', 'y'])); $coordinates = array_replace($coordinates, array_intersect_key($waypoint, $coordinates)); $steps[] = ['point' => [$coordinates['x'], $coordinates['y']], 'fields' => $keys($axes), + 'axes' => array_intersect_key($waypoint, $coordinates), 'label' => 'Waypoint ' . ($i + 1) . ' (authored target only)']; } } elseif (array_key_exists('retrace', $entry)) { @@ -127,7 +140,8 @@ public static function describeFields(array $entry, array $fields): array $fields[$own['subject']]['mapPlacement'] = ['kind' => isset($entry['waypoints']) ? 'waypoints' : 'route', 'points' => $steps, 'issue' => $issue, 'subject' => $entry['subject'] ?? 'player', 'subjectId' => $entry['npcId'] ?? $entry['actorId'] ?? null, 'sourcePaths' => array_values(array_column(array_filter($fields, static fn(array $field): bool => - in_array($field['sourceKey'] ?? '', ['type', 'subject', 'npcId', 'actorId', 'direction', 'count', 'faceOnly', 'x', 'y', 'retrace', 'remember'], true)), 'dataPath'))]; + in_array($field['sourceKey'] ?? '', ['type', 'subject', 'npcId', 'actorId', 'direction', 'count', 'faceOnly', 'x', 'y', 'retrace', 'remember', + ...self::RELATIVE_KEYS], true)), 'dataPath'))]; } $point = static function (array $indexes, array $position, string $kind, ?string $map = null) use (&$fields, $keys, $own): void { if (!isset($indexes['x'], $indexes['y'])) { @@ -141,6 +155,21 @@ public static function describeFields(array $entry, array $fields): array [...$indexes, ...array_intersect_key($own, array_flip(['type', 'target.kind', 'operation']))])), 'issue' => $valid ? null : 'Map placement needs explicit non-negative integer x/y coordinates; relative or invalid axes are not inferred.']; }; + $isStagedActor = $type === 'stage_actor' || ($type === '' && ($entry['kind'] ?? null) === 'staged_actor'); + $wrapped = $type === 'stage_actor' && is_array($entry['actor'] ?? null); + $actor = $wrapped ? $entry['actor'] : $entry; + $prefix = $wrapped ? 'actor.' : ''; + // An independent staged actor relative to a subject is shown where that subject puts it. A bound visual + // inherits its real subject's position and declares none. + if ($isStagedActor && !isset($actor['subject']) && RelativePositionFields::isRelative($actor) + && isset($own[$prefix . RelativePositionFields::KIND_FIELD])) { + $relative = array_intersect_key($own, array_flip(array_map(static fn(string $key): string => $prefix . $key, self::RELATIVE_KEYS))); + $fields[$own[$prefix . RelativePositionFields::KIND_FIELD]]['mapPlacement'] = ['kind' => 'stage', 'map' => null, + 'points' => [['point' => [null, null], 'fields' => [], 'relative' => self::readRelative($actor), + 'label' => sprintf('Staged actor (%s)', RelativePositionFields::describePosition($actor))]], + 'sourcePaths' => array_values(array_map(static fn(int $i): array => $fields[$i]['dataPath'] ?? [], $relative)), + 'issue' => null]; + } if (in_array($type, ['transfer', 'move_player'], true)) { $point(array_intersect_key($own, array_flip(['x', 'y', 'map'])), $entry, $type === 'transfer' ? 'transfer' : 'position', is_string($entry['map'] ?? null) ? $entry['map'] : null); } @@ -191,6 +220,49 @@ public static function describeFields(array $entry, array $fields): array return $fields; } + /** @return array{at: array, offset: array{x: int, y: int}} */ + private static function readRelative(array $entry): array + { + $offset = is_array($entry['offset'] ?? null) ? $entry['offset'] : []; + + return ['at' => is_array($entry['at'] ?? null) ? $entry['at'] : [], + 'offset' => ['x' => is_int($offset['x'] ?? null) ? $offset['x'] : 0, 'y' => is_int($offset['y'] ?? null) ? $offset['y'] : 0]]; + } + + /** + * Shows each subject-relative point at its subject's cell plus the offset, on the map the owner's script runs + * on, or says why it has none there. A waypoint after it that keeps an axis keeps that resolved axis. + * + * @param MapSubjectCells|null $subjects The map the owner's script runs on; null when it names none. + * @param string $unavailable Why no map is known, when none is. + */ + public static function resolveRelativePoints(array $placement, ?MapSubjectCells $subjects, string $unavailable): array + { + if (!array_any($placement['points'] ?? [], static fn(array $point): bool => isset($point['relative']))) { + return $placement; + } + if ($subjects !== null) { + $placement['map'] = $subjects->mapId; + } + $coordinates = [null, null]; + foreach ($placement['points'] as &$point) { + if (isset($point['relative'])) { + $resolved = $subjects?->resolveCell($point['relative']['at']) ?? ['cell' => null, 'issue' => $unavailable]; + $cell = $resolved['cell']; + $coordinates = $cell === null ? [null, null] + : [$cell[0] + $point['relative']['offset']['x'], $cell[1] + $point['relative']['offset']['y']]; + $point['point'] = $coordinates; + $point['issue'] = $resolved['issue']; + } elseif ($placement['kind'] === 'waypoints' && isset($point['axes'])) { + $coordinates = [$point['axes']['x'] ?? $coordinates[0], $point['axes']['y'] ?? $coordinates[1]]; + $point['point'] = $coordinates; + } + } + unset($point); + + return $placement; + } + private static function isMapPoint(mixed $position): bool { return is_array($position) && is_int($position['x'] ?? null) && is_int($position['y'] ?? null) @@ -244,6 +316,9 @@ public static function getChanges(array $placement, int $point, int $x, int $y, throw new \InvalidArgumentException($placement['issue']); } $selected = $placement['points'][$point] ?? throw new \InvalidArgumentException('Choose an authored point first.'); + if (isset($selected['relative'])) { + throw new \InvalidArgumentException('This point follows its subject; change its subject or offset instead of placing it.'); + } $fields = $selected['fields']; if ($placement['kind'] === 'waypoints') { // A click changes only authored axes. Omitted axes retain the prior runtime coordinate. diff --git a/src/Maps/MapSubjectCells.php b/src/Maps/MapSubjectCells.php new file mode 100644 index 0000000..f841c1f --- /dev/null +++ b/src/Maps/MapSubjectCells.php @@ -0,0 +1,98 @@ +mapId, $map->getNpcs(), MapLandmarks::readAnchors($map), null); + } catch (InvalidArgumentException $error) { + return new self($map->mapId, $map->getNpcs(), null, $error->getMessage()); + } + } + + /** + * The cell a subject reference stands on, or why it has none here. + * + * @param array $reference An `at` reference: kind and optional id. + * @return array{cell: array{int, int}|null, issue: string|null} + */ + public function resolveCell(array $reference): array + { + $kind = strval($reference['kind'] ?? ''); + $id = is_string($reference['id'] ?? null) ? $reference['id'] : ''; + if (in_array($kind, ['npc', 'world_object', 'marker'], true) && $id === '') { + return self::describeIssue('Choose the subject this position is relative to.'); + } + + return match ($kind) { + 'npc' => $this->resolveNpcCell($id), + 'world_object' => $this->resolveAnchorCell($this->anchors?->objects, $id, 'world object'), + 'marker' => $this->resolveAnchorCell($this->anchors?->events, $id, 'event marker'), + 'player' => self::describeIssue('The player stands where the scene finds them; this point is known only while it runs.'), + 'party_actor' => self::describeIssue('A party actor stands where the scene finds it; this point is known only while it runs.'), + 'staged_actor' => self::describeIssue('A staged actor stands where the scene puts it; this point is known only while it runs.'), + default => self::describeIssue('Choose what this position is relative to.'), + }; + } + + /** @return array{cell: array{int, int}|null, issue: string|null} */ + private function resolveNpcCell(string $id): array + { + $index = $this->npcs->indexOfId($id); + $npc = $index === null ? null : $this->npcs->get($index); + + return $npc === null + ? self::describeIssue(sprintf('Map %s has no NPC %s.', $this->mapId, $id)) + : ['cell' => [$npc->getX(), $npc->getY()], 'issue' => null]; + } + + /** + * @param array|null $anchors + * @return array{cell: array{int, int}|null, issue: string|null} + */ + private function resolveAnchorCell(?array $anchors, string $id, string $noun): array + { + if ($anchors === null) { + return self::describeIssue(sprintf('Map %s cannot be read: %s', $this->mapId, strval($this->anchorIssue))); + } + $anchor = $anchors[$id] ?? null; + if ($anchor === null) { + return self::describeIssue(sprintf('Map %s has no %s %s.', $this->mapId, $noun, $id)); + } + $cell = $anchor->area->firstCell; + + return ['cell' => [(int) $cell->x, (int) $cell->y], 'issue' => null]; + } + + /** @return array{cell: null, issue: string} */ + private static function describeIssue(string $issue): array + { + return ['cell' => null, 'issue' => $issue]; + } +} diff --git a/src/Session/MapPlacementSession.php b/src/Session/MapPlacementSession.php index ca296af..83bcf07 100644 --- a/src/Session/MapPlacementSession.php +++ b/src/Session/MapPlacementSession.php @@ -9,6 +9,7 @@ use Ichiloto\Editor\Field\NpcInspector; use Ichiloto\Editor\History\CommandGroup; use Ichiloto\Editor\Maps\MapPlacement; +use Ichiloto\Editor\Maps\MapSubjectCells; use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; @@ -123,6 +124,30 @@ public function applyMapPlacement(array $context, array $key, array $expected, s return ['changed' => $commands !== [], 'revision' => $this->getMapRevision($map)]; } + /** + * The map a relative point's subject is read on, as its owner's references are chosen: an event's or NPC's own + * map, or a cinematic's declared start map. A reusable script names none. + * + * @return array{MapSubjectCells|null, string} The map's subjects, and why there are none. + */ + private function readPlacementSubjects(array $owner): array + { + if ($owner['kind'] !== 'database') { + return [MapSubjectCells::readMap($this->requireMap($owner['map'])), '']; + } + $type = CutsceneType::findByRecordCategory($owner['category']); + if ($type !== CutsceneType::CINEMATIC) { + return [null, 'This script runs on whichever map calls it; its relative points are shown from that map only.']; + } + $id = $this->workspace->cutscenes?->ids($type)[$owner['index']] ?? null; + $asset = $id === null ? null : $this->workspace->cutscenes?->find($type, $id); + $map = $asset === null ? null : new ReferenceCatalog($this->workspace, null, $asset)->getSubjectMap(); + + return $map === null + ? [null, 'Declare the cinematic\'s Start Map to show where its relative points land.'] + : [MapSubjectCells::readMap($map), '']; + } + /** Attach a review of the actual owner, never a second editable model. */ private function describePlacementRows(array $rows, array $owner): array { @@ -180,10 +205,15 @@ private function describePlacementRows(array $rows, array $owner): array foreach ($paths as $path) { $sources[$path] = is_file($path) ? hash_file('sha256', $path) : null; } + $subjects = null; foreach ($rows as &$row) { if (!isset($row['mapPlacement'])) { continue; } + if (array_any($row['mapPlacement']['points'] ?? [], static fn(array $point): bool => isset($point['relative']))) { + $subjects ??= $this->readPlacementSubjects($owner); + $row['mapPlacement'] = MapPlacement::resolveRelativePoints($row['mapPlacement'], ...$subjects); + } $placement = &$row['mapPlacement']; $placement['review'] = ['version' => $version, 'sources' => $sources]; $sourcePaths = array_map(static function (array $path) use ($prefix, $stripRoot): array { diff --git a/src/Validation/CutsceneValidation.php b/src/Validation/CutsceneValidation.php index 61276b7..9b9ef34 100644 --- a/src/Validation/CutsceneValidation.php +++ b/src/Validation/CutsceneValidation.php @@ -189,6 +189,11 @@ protected function checkCinematicReferences(ProjectWorkspace $workspace, Cutscen } $context = ['castIds' => $castIds, 'checkpoints' => $checkpoints, 'npcIds' => $npcIds, 'mapId' => $startMap !== '' ? $startMap : null, 'npcIdsByMap' => $npcIdsByMap, 'seenCheckpoints' => []]; + foreach ((array) ($data['cast'] ?? []) as $entry) { + if (is_array($entry) && is_array($entry['at'] ?? null)) { + $issues = [...$issues, ...$this->checkCinematicSubject(['subject' => $entry['at']], $where . ' cast', $context)]; + } + } $issues = [...$issues, ...$this->walkCinematicCommands($commands, $where, $known, $context)]; $issues = [...$issues, ...$this->walkCinematicCommands($finalizer, $where . ' finalizer', $known, $context)]; @@ -270,6 +275,11 @@ protected function walkCinematicCommands( } } + // A relative position names its subject as the command runs: before this actor is staged. + foreach (self::listRelativeSubjects($command) as $reference) { + $issues = [...$issues, ...$this->checkCinematicSubject(['subject' => $reference], $where, $context)]; + } + if ($type === 'stage_actor') { $id = trim(strval($command['id'] ?? $command['actorId'] ?? '')); @@ -334,6 +344,25 @@ protected function walkCinematicCommands( return $issues; } + /** + * The map subjects a command's relative positions name: each relative waypoint's, or an independent staged + * actor's. + * + * @param array $command + * @return list> + */ + protected static function listRelativeSubjects(array $command): array + { + $points = match ($command['type'] ?? null) { + 'move_route' => (array) ($command['waypoints'] ?? []), + 'stage_actor' => [is_array($command['actor'] ?? null) ? $command['actor'] : $command], + default => [], + }; + + return array_values(array_map(static fn(array $point): array => $point['at'], + array_filter($points, static fn(mixed $point): bool => is_array($point) && is_array($point['at'] ?? null)))); + } + /** * Validates and follows one Common Event in the same cinematic session. * diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index def3f5a..84cfb39 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -3605,6 +3605,18 @@ protected function checkMovementRoute(array $command, string $where, ?array $npc ); } + foreach ((array) ($command['waypoints'] ?? []) as $waypoint) { + $at = is_array($waypoint) && is_array($waypoint['at'] ?? null) ? $waypoint['at'] : null; + $atNpc = $at !== null && ($at['kind'] ?? null) === 'npc' ? trim(strval($at['id'] ?? '')) : ''; + if (! $cinematic && $atNpc !== '' && $npcIds !== null && ! in_array($atNpc, $npcIds, true)) { + $issues[] = Issue::error( + $where, + sprintf('A route waypoint is relative to NPC id "%s", which is not on map "%s".', $atNpc, $mapId ?? '(unknown)'), + 'Choose an NPC on the map the route runs on.' + ); + } + } + $wait = $command['wait'] ?? true; if (! is_bool($wait) || ! $wait) { diff --git a/tests/Unit/RelativePositionAuthoringTest.php b/tests/Unit/RelativePositionAuthoringTest.php new file mode 100644 index 0000000..5f23265 --- /dev/null +++ b/tests/Unit/RelativePositionAuthoringTest.php @@ -0,0 +1,359 @@ +readDatabaseRecord($category, $index, $frame)['rows'], + static fn(array $row): bool => ($row['entry'] ?? []) === $entry && ($row['name'] ?? '') === $name) + ?? throw new RuntimeException(sprintf('Missing row %s at %s', $name, json_encode($entry))); +} + +/** A synthetic cinematic on the harbour map whose script is the given commands, with a comment to preserve. */ +function writeRelativeScript(string $root, array $commands): string +{ + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, " 'move_route', 'subject' => 'player', 'secondsPerStep' => 0.2, + 'waypoints' => [['x' => 2], ['y' => 3]]]]); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $frame = ['commands']; + + $kind = relativeRow($session, $frame, [0, 1], 'Relative To'); + expect($kind['value'])->toBe('(map cell)') + ->and($kind['options'])->toBe(['', ...CinematicCommandSchema::export()['relativePosition']['subjectKinds']]); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $kind['key'], 'npc'); + $id = relativeRow($session, $frame, [0, 1], 'NPC'); + expect($id['reference'])->toBe('map_npcs') + ->and(array_column($session->listReferences(null, 'map_npcs', ['category' => 'cutscenes/cinematic', 'index' => 0]), 'value'))->toBe(['keeper']); + // A subject still waiting for its id is a draft the save completes, never written. + expect(fn() => $session->saveDatabase('cutscenes/cinematic'))->toThrow(InvalidArgumentException::class, 'requires an id') + ->and(file_get_contents($path))->toBe($original); + + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $id['key'], 'keeper'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 1], 'Offset X')['key'], '1'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 1], 'Offset Y')['key'], '-2'); + $session->saveDatabase('cutscenes/cinematic'); + + expect((require $path)[0]['waypoints'])->toBe([['x' => 2], ['at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => 1, 'y' => -2]]]) + ->and(file_get_contents($path))->toContain('// Staging authored by hand.'); + $reopened = EditorSession::open($root); + expect(relativeRow($reopened, $frame, [0, 1], 'NPC')['value'])->toBe('keeper') + ->and(relativeRow($reopened, $frame, [0, 1], 'Offset Y')['value'])->toBe('-2') + ->and(array_filter($reopened->readDatabaseRecord('cutscenes/cinematic', 0, $frame)['rows'], + static fn(array $row): bool => ($row['entry'] ?? []) === [0, 1] && in_array($row['name'] ?? '', ['X', 'Y'], true)))->toBe([]); + + // A zero offset is the default: clearing both axes leaves the subject alone, never an empty offset. + foreach (['Offset X', 'Offset Y'] as $axis) { + $reopened->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($reopened, $frame, [0, 1], $axis)['key'], '0'); + } + $reopened->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0]['waypoints'][1])->toBe(['at' => ['kind' => 'npc', 'id' => 'keeper']]); + + // Undo returns through every step to the authored absolute route. + foreach (range(1, 2) as $ignored) { $reopened->undo(); } + $reopened->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0]['waypoints'][1])->toBe(['at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => 1, 'y' => -2]]); +}); + +it('refuses relative waypoints the Engine would refuse and clears coordinates or ids a subject replaces', function () { + $root = cutsceneProject(); + $path = writeRelativeScript($root, [['type' => 'move_route', 'subject' => 'player', + 'waypoints' => [['at' => ['kind' => 'npc', 'id' => 'keeper']], ['x' => 3, 'y' => 1]]]]); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $frame = ['commands']; + + expect(fn() => $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 0], 'Offset X')['key'], '16385')) + ->toThrow(SessionRefusal::class, '-16384..16384') + // Clearing the subject would leave a waypoint with no coordinate; it is refused, not invented. + ->and(fn() => $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 0], 'Relative To')['key'], '')) + ->toThrow(SessionRefusal::class, 'x and/or y') + ->and($session->undo()['label'])->toBeNull(); + + // Choosing a subject for an absolute waypoint drops its independent coordinates; the player needs no id. + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 1], 'Relative To')['key'], 'player'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 1], 'Offset Y')['key'], '2'); + // Turning the first point toward the player drops the id the player does not take. + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 0], 'Relative To')['key'], 'player'); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0]['waypoints'])->toBe([['at' => ['kind' => 'player']], ['at' => ['kind' => 'player'], 'offset' => ['y' => 2]]]); + $session->undo(); + $session->undo(); + $session->undo(); + $session->saveDatabase('cutscenes/cinematic'); + expect(require $path)->toBe(eval('?>' . $original)); +}); + +it('offers only the subject kinds each owner can resolve, from the Engine contract', function () { + $exported = CinematicCommandSchema::export()['relativePosition']; + expect($exported['fields'])->toBe(['at', 'offset']) + ->and(RelativePositionFields::getSubjectKinds(cinematic: true, graphical: true))->toBe($exported['subjectKinds']) + ->and(RelativePositionFields::getSubjectKinds(cinematic: false, graphical: true))->not->toContain('staged_actor') + ->and(RelativePositionFields::getSubjectKinds(cinematic: true, graphical: false))->not->toContain('world_object'); + $fields = static fn(array $entry, bool $graphical): array => array_map(static fn($field): string => $field->key, + CutsceneSchemas::stagedActorFields($graphical, $entry)); + $independent = ['id' => 'a', 'at' => ['kind' => 'npc', 'id' => 'keeper']]; + $bound = ['id' => 'a', 'subject' => ['kind' => 'player']]; + expect($fields($independent, false))->toContain('at.kind', 'at.id', 'offset.x', 'offset.y')->not->toContain('x', 'y') + ->and($fields(['id' => 'a', 'x' => 1, 'y' => 1], false))->toContain('at.kind', 'x', 'y')->not->toContain('offset.x') + ->and($fields($bound, true))->not->toContain('at.kind', 'offset.x', 'x', 'y') + ->and($fields($bound, false))->not->toContain('at.kind', 'offset.x'); +}); + +it('previews relative points at their subject on the cinematic start map and refuses placing them by click', function () { + $root = cutsceneProject(); + $path = writeRelativeScript($root, [ + ['type' => 'move_route', 'subject' => 'player', + 'waypoints' => [['x' => 2], ['at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => 1, 'y' => 1]], ['y' => 6]]], + ['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => -1, 'y' => 1]], + ['type' => 'stage_actor', 'id' => 'porter', 'sprite' => ['P'], 'x' => 6, 'y' => 3], + ['type' => 'stage_actor', 'id' => 'echo', 'sprite' => ['E'], 'at' => ['kind' => 'player']], + ]); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $frame = ['commands']; + $rows = $session->readDatabaseRecord('cutscenes/cinematic', 0, $frame)['rows']; + $placements = array_values(array_filter($rows, static fn(array $row): bool => isset($row['mapPlacement']))); + // An absolute staged actor keeps its typed coordinates; only relative points are previewed. + [$route, $guide, $echo] = array_map(static fn(array $row): array => $row['mapPlacement'], $placements); + + expect($route['map'])->toBe('harbour') + ->and(array_column($route['points'], 'point'))->toBe([[2, null], [5, 2], [5, 6]]) + ->and($route['points'][1]['fields'])->toBe([]) + ->and($route['points'][1]['label'])->toContain('+1, +1 from NPC keeper') + ->and($guide)->toMatchArray(['kind' => 'stage', 'map' => 'harbour', 'issue' => null]) + ->and($guide['points'][0]['point'])->toBe([3, 2]) + ->and($echo['points'][0]['point'])->toBe([null, null]) + ->and($echo['points'][0]['issue'])->toContain('only while it runs'); + + $context = ['kind' => 'database', 'category' => 'cutscenes/cinematic', 'index' => 0]; + $revision = $session->readMap('harbour')['revision']; + expect(fn() => $session->applyMapPlacement($context, $placements[1]['key'], $guide, 'harbour', 0, 7, 3, null, $revision)) + ->toThrow(SessionRefusal::class, 'follows its subject') + ->and(fn() => $session->applyMapPlacement($context, $placements[0]['key'], $route, 'harbour', 1, 7, 3, [1, 1], $revision)) + ->toThrow(SessionRefusal::class, 'follows its subject'); + // Absolute waypoints around them still place as before. + $session->applyMapPlacement($context, $placements[0]['key'], $route, 'harbour', 0, 7, 3, [1, 1], $revision); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0]['waypoints'])->toBe([['x' => 7], ['at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => 1, 'y' => 1]], ['y' => 6]]) + ->and(array_slice(require $path, 1))->toBe(array_slice(eval('?>' . $original), 1)); +}); + +it('says why a reusable script cannot show its relative points', function () { + $root = makeTemporaryProject('ichiloto-relative-common-'); + file_put_contents($root . '/assets/Events/relative-walk.php', " 'move_route', 'subject' => 'player', + 'waypoints' => [['at' => ['kind' => 'marker', 'id' => 'E'], 'offset' => ['y' => 1]]]]], true) . ";\n"); + $session = EditorSession::open($root); + $index = array_search('relative-walk', $session->listDatabaseRecords('common_events')['records'], true); + $placement = array_find($session->readDatabaseRecord('common_events', $index)['rows'], static fn(array $row): bool => isset($row['mapPlacement']))['mapPlacement']; + expect($placement['points'][0]['point'])->toBe([null, null]) + ->and($placement['points'][0]['issue'])->toContain('whichever map calls it') + ->and($placement['map'] ?? null)->toBeNull(); +}); + +it('authors an independent staged actor and a cast member relative to a subject, refusing incomplete ones at save', function () { + $root = cutsceneProject(); + $path = writeRelativeScript($root, [['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'x' => 1, 'y' => 2, 'facing' => 'north']]); + $dataPath = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.data.php'; + $session = EditorSession::open($root); + $frame = ['commands']; + + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'Relative To')['key'], 'npc'); + expect(array_column(array_filter($session->readDatabaseRecord('cutscenes/cinematic', 0, $frame)['rows'], + static fn(array $row): bool => ($row['entry'] ?? []) === [0]), 'name'))->not->toContain('X', 'Y'); + $saved = sourceHashTree($root); + expect(fn() => $session->saveDatabase('cutscenes/cinematic'))->toThrow(RuntimeException::class, 'requires an id') + ->and(sourceHashTree($root))->toBe($saved); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'NPC')['key'], 'keeper'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'Offset X')['key'], '-1'); + + // The cast is staged the same way: its members are independent actors too. + $cast = static fn(string $name): array => array_find($session->readDatabaseRecord('cutscenes/cinematic', 0)['rows'], + static fn(array $row): bool => ($row['entry'] ?? []) === [1] && ($row['name'] ?? '') === $name); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $cast('Relative To')['key'], 'staged_actor'); + expect($cast('Staged Actor')['reference'])->toBe('cinematic_cast'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $cast('Staged Actor')['key'], 'boat-east'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $cast('Offset Y')['key'], '2'); + $session->saveDatabase('cutscenes/cinematic'); + + expect((require $path)[0])->toBe(['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'facing' => 'north', + 'at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => -1]]) + ->and(file_get_contents($path))->toContain('// Staging authored by hand.') + ->and((require $dataPath)['cast'][1])->toBe(['kind' => 'staged_actor', 'id' => 'boat-west', 'sprite' => ['~^~'], + 'at' => ['kind' => 'staged_actor', 'id' => 'boat-east'], 'offset' => ['y' => 2]]) + ->and(file_get_contents($dataPath))->toContain('// Harbour Lanterns: an original test cinematic.'); + + // Binding the visual to a real subject drops the position it would inherit, never keeping both. + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'Bind Visual To')['key'], 'player'); + $bound = $session->readDatabaseRecord('cutscenes/cinematic', 0, $frame)['rows']; + expect(array_column(array_filter($bound, static fn(array $row): bool => ($row['entry'] ?? []) === [0]), 'name')) + ->not->toContain('Relative To', 'Offset X', 'NPC'); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0])->not->toHaveKeys(['at', 'offset', 'x', 'y']); +}); + +it('authors relative staging in the Terminal editor with its list choices and no world objects', function () { + $root = cutsceneProject(); + $path = writeRelativeScript($root, [['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'x' => 1, 'y' => 2], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 3]]]]); + $library = ProjectWorkspace::fromProject($root)->cutscenes ?? throw new RuntimeException('The Terminal workspace has no cutscenes.'); + $frame = ['commands']; + $field = static fn(array $entry, string $name): array => array_find($library->records(CutsceneType::CINEMATIC)->getFrameSettingsFields(0, $frame), + static fn(array $row): bool => ($row['entry'] ?? []) === $entry && ($row['name'] ?? '') === $name) + ?? throw new RuntimeException('Missing Terminal row ' . $name); + // Every Terminal edit goes through the library's one change path. + $apply = static fn(array $entry, string $name, string $value) => $library->changeAsset(CutsceneType::CINEMATIC, 0, $name, + static fn($records, int $index) => $records->setFrameField($index, $frame, $field($entry, $name)['field'], $value)); + + expect($field([0], 'Relative To')['options'] ?? [])->not->toContain('world_object') + ->and($field([1, 0], 'Relative To')['options'] ?? [])->toContain('marker', 'staged_actor')->not->toContain('world_object'); + $apply([0], 'Relative To', 'party_actor'); + $apply([0], 'Offset Y', '1'); + $apply([1, 0], 'Relative To', 'staged_actor'); + $apply([1, 0], 'Staged Actor', 'guide'); + expect($field([0], 'Party Actor')['value'])->toBe('(leading party actor)'); + $library->save(CutsceneType::CINEMATIC, 'harbour-lanterns'); + + expect((require $path)[0])->toBe(['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'at' => ['kind' => 'party_actor'], 'offset' => ['y' => 1]]) + ->and((require $path)[1]['waypoints'])->toBe([['at' => ['kind' => 'staged_actor', 'id' => 'guide']]]); +}); + +it('leaves relative points to follow their subjects when map lines are inserted, reporting offsets the lines cross', function () { + $root = mapLineProject(); + $directory = $root . '/assets/Cutscenes/Cinematics/arrival'; + mkdir($directory, 0o777, true); + $cat = ['kind' => 'npc', 'id' => 'cat']; + file_put_contents($directory . '/arrival.data.php', " 'arrival', 'name' => 'Arrival', 'startMap' => 'home', + 'cast' => [['id' => 'visitor', 'sprite' => 'v', 'at' => $cat, 'offset' => ['y' => -2]]], + ], true) . ";\n"); + $script = [ + ['type' => 'stage_actor', 'id' => 'extra', 'sprite' => 'e', 'at' => $cat, 'offset' => ['x' => 1]], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['at' => $cat, 'offset' => ['y' => 1]], ['y' => 4]]], + ['type' => 'stage_actor', 'id' => 'echo', 'sprite' => 'o', 'at' => ['kind' => 'player'], 'offset' => ['y' => 1]], + ]; + file_put_contents($directory . '/arrival.script.php', "toContain( + 'assets/Cutscenes/Cinematics/arrival/arrival.data.php cast.0.offset.y', + 'assets/Cutscenes/Cinematics/arrival/arrival.script.php 2.offset.y', + )->and(describeMapLineHandEdits($plan))->not->toContain( + 'assets/Cutscenes/Cinematics/arrival/arrival.script.php 0.offset.x', + 'assets/Cutscenes/Cinematics/arrival/arrival.script.php 1.waypoints.0.offset.y', + ); + $plan->apply(); + + $saved = require $directory . '/arrival.script.php'; + expect((require $directory . '/arrival.data.php')['cast'][0])->toMatchArray(['at' => $cat, 'offset' => ['y' => -2]]) + ->and((require $directory . '/arrival.data.php')['cast'][0])->not->toHaveKeys(['x', 'y']) + ->and($saved[0])->toBe($script[0]) + ->and($saved[1]['waypoints'])->toBe([['at' => $cat, 'offset' => ['y' => 1]], ['y' => 5]]) + ->and($saved[2])->toBe($script[2]) + // The subject itself moved, so the points that follow it moved with it. + ->and((require $root . '/assets/Maps/home/home.data.php')['npcs'][0])->toMatchArray(['x' => 2, 'y' => 4]); +}); + +it('reports relative subjects that are not on the scene map or its stage', function () { + $root = cutsceneProject(); + writeRelativeScript($root, [ + ['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'at' => ['kind' => 'npc', 'id' => 'stranger']], + ['type' => 'stage_actor', 'id' => 'shadow', 'sprite' => ['S'], 'at' => ['kind' => 'staged_actor', 'id' => 'shadow']], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => 1]]]], + ]); + $messages = array_map(static fn($issue): string => $issue->message, validateProject($root)); + + expect($messages)->toContain('It addresses NPC "stranger", which is not on harbour.') + ->and($messages)->toContain('It addresses staged actor "shadow", which neither the cast nor an earlier stage_actor command defines.') + ->and(array_filter($messages, static fn(string $message): bool => str_contains($message, 'keeper')))->toBe([]); +}); + +/** A synthetic map with an event marker, a world object and an NPC whose scripts walk relative to them. */ +function writeRelativeMapProject(): array +{ + $root = makeTemporaryProject('ichiloto-relative-map-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['worldObjects'] = [['id' => 'lamp', 'anchor' => ['x' => 3, 'y' => 2], 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null]]; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, 'data' => ['script' => [ + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [ + ['at' => ['kind' => 'marker', 'id' => 'E'], 'offset' => ['y' => 1]], + ['at' => ['kind' => 'world_object', 'id' => 'lamp'], 'offset' => ['x' => -1]], + ]], + ]]]; + $data['npcs'] = [['id' => 'sage', 'name' => 'Sage', 'sprite' => 's', 'x' => 8, 'y' => 3, + 'script' => [['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 2]]]]]]; + file_put_contents($path, "readInspector('test-map', 'S'); + $placement = array_find($read['rows'], static fn(array $row): bool => isset($row['mapPlacement']))['mapPlacement']; + expect($placement['map'])->toBe('test-map') + ->and(array_column($placement['points'], 'point'))->toBe([[5, 2], [2, 2]]); + $offset = array_find($read['rows'], static fn(array $row): bool + => ($row['key']['path'] ?? []) === ['data', 'script', '0', 'waypoints', '1', 'offset', 'y']); + $session->applyInspector('test-map', $read['revision'], $offset['key'], '3'); + + $npc = $session->readNpc('test-map', 0, ['script']); + $kind = array_find($npc['rows'], static fn(array $row): bool => ($row['entry'] ?? []) === [0, 0] && ($row['name'] ?? '') === 'Relative To'); + $session->applyNpc('test-map', $npc['revision'], 0, $kind['key'], 'world_object'); + $npc = $session->readNpc('test-map', 0, ['script']); + $object = array_find($npc['rows'], static fn(array $row): bool => ($row['entry'] ?? []) === [0, 0] && ($row['name'] ?? '') === 'World Object'); + expect($object['reference'])->toBe('map_world_objects') + ->and(array_column($session->listReferences('test-map', 'map_world_objects'), 'value'))->toBe(['lamp']); + $session->applyNpc('test-map', $npc['revision'], 0, $object['key'], 'lamp'); + $session->saveMap('test-map'); + + $saved = require $path; + expect($saved['events']['S']['data']['script'][0]['waypoints'][1])->toBe(['at' => ['kind' => 'world_object', 'id' => 'lamp'], 'offset' => ['x' => -1, 'y' => 3]]) + ->and($saved['npcs'][0]['script'][0]['waypoints'])->toBe([['at' => ['kind' => 'world_object', 'id' => 'lamp']]]) + ->and(file_get_contents($path))->toContain('// Map staging by hand.'); + $preview = array_find(EditorSession::open($root)->readNpc('test-map', 0, ['script'])['rows'], static fn(array $row): bool => isset($row['mapPlacement'])); + expect($preview['mapPlacement']['points'][0]['point'])->toBe([3, 2]); +}); + +it('chooses world objects on the map a cinematic starts on, and refuses ids that are not there', function () { + $root = cutsceneProject(); + $mapPath = $root . '/assets/Maps/harbour/harbour.data.php'; + $map = require $mapPath; + $map['worldObjects'] = [['id' => 'beacon', 'anchor' => ['x' => 6, 'y' => 3], 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null]]; + file_put_contents($mapPath, " 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'x' => 1, 'y' => 1]]); + $session = EditorSession::open($root); + $frame = ['commands']; + + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'Relative To')['key'], 'world_object'); + $object = relativeRow($session, $frame, [0], 'World Object'); + expect(fn() => $session->applyDatabaseRecord('cutscenes/cinematic', 0, $object['key'], 'elsewhere')) + ->toThrow(SessionRefusal::class, 'Unknown map_world_objects'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $object['key'], 'beacon'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'Offset Y')['key'], '1'); + $session->saveDatabase('cutscenes/cinematic'); + + expect((require $path)[0])->toMatchArray(['at' => ['kind' => 'world_object', 'id' => 'beacon'], 'offset' => ['y' => 1]]) + ->and(array_find($session->readDatabaseRecord('cutscenes/cinematic', 0, $frame)['rows'], + static fn(array $row): bool => isset($row['mapPlacement']))['mapPlacement']['points'][0]['point'])->toBe([6, 4]); +}); From 41fcc8b71a2ad445a7248abfc504c8b5eacf7bf5 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 23:36:45 +0200 Subject: [PATCH 328/344] fix(inspector): map event routes offer the graphical editor's waypoint subjects, as cinematics do --- src/Inspector/MapInspector.php | 2 +- tests/Unit/MovementRouteAuthoringTest.php | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index 43b56e3..f6a6386 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -1876,7 +1876,7 @@ private function projectMovementRoute(array $route, ?array $ownerCommands = null private function buildMovementRouteFields(array $route, array $path, ?array $parentList): array { $fields = []; - $points = MovementRouteFields::getPointList($route); + $points = MovementRouteFields::getPointList($route, graphical: $this->graphical); foreach ($this->projectMovementRoute($route)->getSettingsFields(0) as $row) { if (! isset($row['dataPath']) || ($row['sourceKey'] ?? '') === 'type') { continue; } $relative = array_slice($row['dataPath'], 2); diff --git a/tests/Unit/MovementRouteAuthoringTest.php b/tests/Unit/MovementRouteAuthoringTest.php index 471d042..f2159d3 100644 --- a/tests/Unit/MovementRouteAuthoringTest.php +++ b/tests/Unit/MovementRouteAuthoringTest.php @@ -819,3 +819,20 @@ function makeRouteOwnerProject(): array $field->dispose(); } })->with([false, true]); + +it('offers world objects as a waypoint subject in a map event route the graphical editor reads, as in cinematics', function () { + $root = makeTemporaryProject('ichiloto-event-relative-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\ScriptEventTrigger::class, 'data' => ['script' => [ + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 2]]], + ]]]; + file_put_contents($path, "readInspector('test-map', 'S', ['script'])['rows']; + $kinds = array_find($rows, static fn(array $row): bool => trim((string) ($row['label'] ?? '')) === 'Relative To' + || ($row['name'] ?? null) === 'Relative To'); + + expect($kinds)->not->toBeNull() + ->and($kinds['options'] ?? [])->toContain('world_object'); +}); From 67c84c26dcf2c7a615a30e45a6d38b0a8f22d3a4 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 23:36:53 +0200 Subject: [PATCH 329/344] docs(guides): subject-relative waypoints and staged actors in the script guide --- docs/guides/change-maps-from-a-script.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/guides/change-maps-from-a-script.md b/docs/guides/change-maps-from-a-script.md index 759c69a..0ae3f3a 100644 --- a/docs/guides/change-maps-from-a-script.md +++ b/docs/guides/change-maps-from-a-script.md @@ -135,6 +135,7 @@ Layers are named by `id` in edits. An event row key looks like | A Story Script event's inline commands | `inspector.read {map, event, frame: ["script"]}`, then `inspector.apply`/`add`/`remove` with the frame's row keys; the first command goes on the `frameEmpty` row | | Dialogue pages with conditions and writes, talk-to id | the event's `variantList`, `variantNConditions`, `variantNSets`, `variantNLineM...` rows; the Talk-To Id row sets `npcId` | | Where a transfer arrives facing | the Spawn Facing row, or `event.destination {..., heading}` | +| A route waypoint or staged actor placed relative to a subject | the waypoint's or actor's Relative To row (player, npc, world_object, staged_actor, party_actor, marker), then its id row and Offset X / Offset Y rows: `database.apply` in a cinematic (`database.record {category: "cutscenes/cinematic", index, frame: ["commands"]}`), `npc.apply` in an NPC script, `inspector.apply` in an event script. A relative point follows its subject and is not shifted by row or column inserts | | A map's region-map cell | `inspector.apply` with `{target: map-station, field: station}`, value `x, y`, empty removes it | | World objects | `worldObjects.create`, `worldObjects.move {map, id, dx, dy}`, `worldObjects.place`, `worldObjects.apply`, `worldObjects.add`, `worldObjects.remove`, `worldObjects.delete` | | NPCs | `npc.create`, `npc.move`, `npc.apply`, `npc.add`, `npc.remove`, `npc.duplicate`, `npc.delete` | From 87e0cc90a2e2bff5c439fbfcc5c290d8be7a21b8 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 18 Oct 2026 23:42:12 +0200 Subject: [PATCH 330/344] refactor(validation): give unsaved maps to the Engine's reachability directly ProjectReachability::analyze() now takes current map sources (Engine 69d0881), so reachability judges unsaved maps in memory. Removed: UnsavedMapStage, the disposable asset root it copied maps into and linked the rest of assets beside, and ProjectMap::exportSources(), which only that stage used. Untiled floor is judged from each map as it stands in the workspace. The branch now needs Engine 69d0881. --- src/ProjectMap.php | 40 ------- src/Validation/ReachabilityValidator.php | 50 ++++----- src/Validation/UnsavedMapStage.php | 103 ------------------ ...geTest.php => UnsavedReachabilityTest.php} | 48 +++----- 4 files changed, 37 insertions(+), 204 deletions(-) delete mode 100644 src/Validation/UnsavedMapStage.php rename tests/Unit/{UnsavedMapStageTest.php => UnsavedReachabilityTest.php} (52%) diff --git a/src/ProjectMap.php b/src/ProjectMap.php index b67686b..1a4a788 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -2588,46 +2588,6 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) return $target['mapId']; } - /** - * Writes the sources saving would write into the map's folder under - * another maps root, replacing that folder's own files there, so a - * reader of map folders (the Engine's reachability analysis) sees the - * map as saving would leave it. This map's folder and its saved - * baseline are untouched; it stays as dirty as it was. - * - * @param string $mapsRoot The other maps root, holding a copy of the project's maps. - * @return string The folder written. - */ - public function exportSources(string $mapsRoot): string - { - $this->assertEditable(); - $this->assertGridSourcesCanonical(); - $this->validateLayerContracts(); - $target = $this->resolveSaveTarget(); - $directory = rtrim($mapsRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $target['mapId']); - $baseName = basename($directory); - $sources = [ - $directory . DIRECTORY_SEPARATOR . basename($target['dataPath']) => $this->dataDocument === null - ? (string) $this->unparsedDataSource : $this->proposedDataSource(), - ...$this->layers->getSources($directory, $baseName), - ]; - $transaction = new FileSetTransaction($directory, new FilesystemFileSetOperations()); - foreach ($sources as $path => $source) { - $transaction->write($path, $source); - } - // The folder's other member files are the copy's old ones: a layer this map no longer has. - foreach ([...glob($directory . DIRECTORY_SEPARATOR . $baseName . '.*.php') ?: [], - ...glob($directory . DIRECTORY_SEPARATOR . 'layers' . DIRECTORY_SEPARATOR . '*.php') ?: [], - ...glob($directory . DIRECTORY_SEPARATOR . MapGraphics::DIRECTORY . DIRECTORY_SEPARATOR . '*.php') ?: []] as $stale) { - if (! isset($sources[$stale])) { - $transaction->remove($stale); - } - } - $transaction->commit(); - - return $directory; - } - /** * Adopts what a save (or a proven no-op) established as the new baseline. */ diff --git a/src/Validation/ReachabilityValidator.php b/src/Validation/ReachabilityValidator.php index d5989bd..6c5f232 100644 --- a/src/Validation/ReachabilityValidator.php +++ b/src/Validation/ReachabilityValidator.php @@ -4,15 +4,13 @@ namespace Ichiloto\Editor\Validation; +use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; -use Ichiloto\Engine\Field\MapGraphics; -use Ichiloto\Engine\Field\MapLayerSource; use Ichiloto\Engine\Field\Reachability\MapReachabilityReport; use Ichiloto\Engine\Field\Reachability\ProjectReachability; use Ichiloto\Engine\Field\Reachability\ReachabilityProblem; use Ichiloto\Engine\Field\Reachability\ReachabilityProblemKind; use Ichiloto\Engine\Rendering\Tilesets\TileId; -use RuntimeException; use Throwable; /** @@ -23,31 +21,27 @@ * reported. Unknown destinations and unreadable maps are left to the checks * that own them (doors, references, map reading), so nothing is said twice. * - * The analysis reads map folders, so while maps have unsaved changes it - * reads them as saving would leave them ({@see UnsavedMapStage}), as every - * other check reads the workspace. + * Maps with unsaved changes are given to the analysis as they stand in the + * workspace, so it judges what saving would leave, as every other check + * reads the workspace. */ final class ReachabilityValidator { /** @return Issue[] */ public function validate(ProjectWorkspace $workspace): array { - try { - $stage = UnsavedMapStage::prepare($workspace); - } catch (RuntimeException $failure) { - return [Issue::error('maps', 'Reachability could not be checked: ' . $failure->getMessage(), - 'Save or undo the unsaved maps, then validate again.')]; - } - - try { - $assetRoot = $stage?->assetRoot ?? $workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; - $project = ProjectReachability::analyze($assetRoot); - $untiled = []; - foreach ($project->reports as $mapId => $report) { - $untiled[] = self::checkUntiledFloor($assetRoot, (string) $mapId, $report); + $unsaved = []; + foreach ($workspace->maps as $map) { + if ($map->isDirty()) { + $unsaved[$map->mapId] = ['data' => $map->getEditableData(), 'layers' => $map->getLayerSet(), 'eventLayer' => $map->getEventLayerCells()]; } - } finally { - $stage?->dispose(); + } + $project = ProjectReachability::analyze($workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets', $unsaved); + $maps = array_column(array_map(static fn(ProjectMap $map): array => ['id' => $map->mapId, 'map' => $map], $workspace->maps), 'map', 'id'); + $untiled = []; + foreach ($project->reports as $mapId => $report) { + $map = $maps[(string) $mapId] ?? null; + $untiled[] = $map === null ? null : self::checkUntiledFloor($map, $report); } return array_values(array_filter([...array_map( @@ -64,17 +58,13 @@ public function validate(ProjectWorkspace $workspace): array * there, so the player walks on the dark background, as on a map whose floor was never painted. A map without * a kind, or whose graphics cannot be read, is left to the checks that own it. */ - private static function checkUntiledFloor(string $assetRoot, string $mapId, MapReachabilityReport $report): ?Issue + private static function checkUntiledFloor(ProjectMap $map, MapReachabilityReport $report): ?Issue { + $mapId = $map->mapId; try { - $directory = $assetRoot . DIRECTORY_SEPARATOR . 'Maps' . DIRECTORY_SEPARATOR . $mapId; - $data = (static fn(string $file): mixed => require $file)($directory . DIRECTORY_SEPARATOR . basename($mapId) . '.data.php'); - if (! is_array($data) || ! is_string($data['tileset'] ?? null)) { - return null; - } - $layers = MapLayerSource::loadFromDirectory($directory, $mapId); - $graphics = MapGraphics::loadFromDirectory($directory, $mapId, $data['tileset'], $layers, $assetRoot, $data['tileLayers'] ?? null); - $usable = $graphics?->tileset->getUsableSheets($assetRoot)['sheets'] ?? []; + $layers = $map->getLayerSet(); + $graphics = MapGraphicsValidator::readGraphics($map); + $usable = $graphics?->tileset->getUsableSheets($map->getAssetRoot())['sheets'] ?? []; } catch (Throwable) { return null; } diff --git a/src/Validation/UnsavedMapStage.php b/src/Validation/UnsavedMapStage.php deleted file mode 100644 index a1d19d2..0000000 --- a/src/Validation/UnsavedMapStage.php +++ /dev/null @@ -1,103 +0,0 @@ -maps, static fn(ProjectMap $map): bool => $map->isDirty()); - if ($unsaved === []) { - return null; - } - - $assets = $workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; - $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ichiloto-unsaved-maps-' . bin2hex(random_bytes(6)); - $stage = new self($root . DIRECTORY_SEPARATOR . 'assets', $root); - - try { - if (! @mkdir($stage->assetRoot, 0o700, true)) { - throw new RuntimeException('Its folder could not be made.'); - } - foreach (array_diff(scandir($assets) ?: [], ['.', '..', 'Maps']) as $entry) { - if (! @symlink($assets . DIRECTORY_SEPARATOR . $entry, $stage->assetRoot . DIRECTORY_SEPARATOR . $entry)) { - throw new RuntimeException(sprintf('assets/%s could not be linked.', $entry)); - } - } - self::copyFolder($workspace->getMapsRoot(), $stage->assetRoot . DIRECTORY_SEPARATOR . 'Maps'); - foreach ($unsaved as $map) { - $map->exportSources($stage->assetRoot . DIRECTORY_SEPARATOR . 'Maps'); - } - } catch (\Throwable $failure) { - $stage->dispose(); - - throw new RuntimeException('The unsaved maps could not be staged: ' . $failure->getMessage(), previous: $failure); - } - - return $stage; - } - - /** Removes the stage: the copied maps, and the links without what they point at. */ - public function dispose(): void - { - self::removeFolder($this->root); - } - - private static function copyFolder(string $from, string $to): void - { - if (! is_dir($to) && ! @mkdir($to, 0o700, true)) { - throw new RuntimeException(sprintf('%s could not be made.', $to)); - } - foreach (array_diff(scandir($from) ?: [], ['.', '..']) as $entry) { - $source = $from . DIRECTORY_SEPARATOR . $entry; - $target = $to . DIRECTORY_SEPARATOR . $entry; - if (is_dir($source) && ! is_link($source)) { - self::copyFolder($source, $target); - } elseif (! @copy($source, $target)) { - throw new RuntimeException(sprintf('%s could not be copied.', $source)); - } - } - } - - private static function removeFolder(string $folder): void - { - if (! is_dir($folder) || is_link($folder)) { - return; - } - foreach (array_diff(scandir($folder) ?: [], ['.', '..']) as $entry) { - $path = $folder . DIRECTORY_SEPARATOR . $entry; - if (is_link($path) || ! is_dir($path)) { - @unlink($path); - } else { - self::removeFolder($path); - } - } - @rmdir($folder); - } -} diff --git a/tests/Unit/UnsavedMapStageTest.php b/tests/Unit/UnsavedReachabilityTest.php similarity index 52% rename from tests/Unit/UnsavedMapStageTest.php rename to tests/Unit/UnsavedReachabilityTest.php index b4ed0d4..bf1ee5f 100644 --- a/tests/Unit/UnsavedMapStageTest.php +++ b/tests/Unit/UnsavedReachabilityTest.php @@ -2,14 +2,12 @@ declare(strict_types=1); -use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Session\EditorSession; -use Ichiloto\Editor\Validation\UnsavedMapStage; /** A one-room project the game can walk: a corridor with the sample chest E at its end, entered at its start. */ -function unsavedStageProject(): string +function unsavedReachabilityProject(): string { - $root = makeTemporaryProject('ichiloto-unsaved-stage-'); + $root = makeTemporaryProject('ichiloto-unsaved-reach-'); file_put_contents($root . '/assets/Maps/collisions.php', " CollisionType::SOLID, ' ' => CollisionType::NONE];\n"); file_put_contents($root . '/assets/Data/system.php', ' ['player' => [ @@ -21,16 +19,9 @@ function unsavedStageProject(): string return $root; } -/** The temporary stages left in the temporary directory. */ -function countUnsavedStages(): int -{ - return count(glob(sys_get_temp_dir() . '/ichiloto-unsaved-maps-*') ?: []); -} - it('judges reachability by the unsaved map, as saving would leave it, writing nothing of the project', function () { - $root = unsavedStageProject(); + $root = unsavedReachabilityProject(); $before = sourceHashTree($root . '/assets'); - $stages = countUnsavedStages(); $session = EditorSession::open($root); $unreachable = static fn(): array => array_values(array_filter(array_column($session->validateProject(['test-map'])['issues'], 'message'), static fn(string $message): bool => str_contains($message, 'can be reached'))); @@ -41,27 +32,22 @@ function countUnsavedStages(): int $session->paint('test-map', $map['revision'], $map['baseLayer'], [[3, 1]], '#'); expect($unreachable())->toBe(['No cell of event E (ChestEventTrigger) at (5, 1) can be reached, so it never fires.']) - ->and(sourceHashTree($root . '/assets'))->toBe($before) - ->and(countUnsavedStages())->toBe($stages); + // Judged in memory: nothing of the project is written or copied. + ->and(sourceHashTree($root . '/assets'))->toBe($before); }); -it('stages only a workspace with unsaved maps, each written by its own writer beside copies of the rest', function () { - $root = unsavedStageProject(); - $workspace = ProjectWorkspace::fromProject($root); - expect(UnsavedMapStage::prepare($workspace))->toBeNull(); - - $map = $workspace->maps[array_search('test-map', $workspace->mapIds, true)]; - $map->setMapDataField(['name'], 'Renamed Unsaved'); - $stage = UnsavedMapStage::prepare($workspace); +it('judges floor left untiled by an unsaved edit before it is saved', function () { + $root = unsavedReachabilityProject(); + writeTestTileset($root); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace("'triggers' => [],", "'triggers' => [], 'tileset' => 'home',", (string) file_get_contents($data))); + writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', implode("\n", array_fill(0, 3, implode(' ', array_fill(0, 7, '2816'))))); + $session = EditorSession::open($root); + $untiled = static fn(): array => array_values(array_filter(array_column($session->validateProject(['test-map'])['issues'], 'message'), + static fn(string $message): bool => str_contains($message, 'shows the background'))); + expect($untiled())->toBe([]); - expect($stage)->not->toBeNull() - ->and((require $stage->assetRoot . '/Maps/test-map/test-map.data.php')['name'])->toBe('Renamed Unsaved') - ->and((require $root . '/assets/Maps/test-map/test-map.data.php')['name'])->toBe('Test Map') - // Other asset folders are read in place. - ->and(realpath($stage->assetRoot . '/Data/system.php'))->toBe(realpath($root . '/assets/Data/system.php')) - ->and($map->isDirty())->toBeTrue(); + $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[2, 1]], 0); - $stage->dispose(); - expect(is_dir(dirname($stage->assetRoot)))->toBeFalse() - ->and(is_file($root . '/assets/Data/system.php'))->toBeTrue(); + expect($untiled())->toBe(['1 cell the player can reach has no tile, so the field shows the background there: (2, 1).']); }); From d4a5c6a1ad6b34df01fe62c690c5babe489f87bb Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 19 Oct 2026 00:37:30 +0200 Subject: [PATCH 331/344] build(deps): lock the Engine published on 2026-10-18 Editor develop uses Engine APIs published as Engine 5c77c5a (map atlas and category catalogues, battle announcements, map completeness, subject-relative cinematic positions, chest grant capabilities); the lock still named Engine 739da66, so CI analysed and tested against the old Engine. --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 816c5ff..f0b62c4 100644 --- a/composer.lock +++ b/composer.lock @@ -260,12 +260,12 @@ "source": { "type": "git", "url": "https://github.com/ichiloto/engine.git", - "reference": "739da6638fe4808d6ed3209e9710bf4210493023" + "reference": "5c77c5a57573db0d86682b33180f81fafdbe27c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ichiloto/engine/zipball/739da6638fe4808d6ed3209e9710bf4210493023", - "reference": "739da6638fe4808d6ed3209e9710bf4210493023", + "url": "https://api.github.com/repos/ichiloto/engine/zipball/5c77c5a57573db0d86682b33180f81fafdbe27c7", + "reference": "5c77c5a57573db0d86682b33180f81fafdbe27c7", "shasum": "" }, "require": { @@ -306,7 +306,7 @@ "issues": "https://github.com/ichiloto/engine/issues", "source": "https://github.com/ichiloto/engine/tree/develop" }, - "time": "2026-10-13T03:29:37+00:00" + "time": "2026-10-18T21:42:40+00:00" }, { "name": "league/climate", From 7e1b76e88b20ab530f42063ab74f3f4f80e7c0f8 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 19 Oct 2026 00:40:21 +0200 Subject: [PATCH 332/344] fix(session): read field resources and event areas as the workspace holds them A field resource added in a change set was invisible to the operations after it: the resource pickers, world object resolution, map completeness validation and the effect check all loaded field.php from disk. They now read ReferenceCatalog::getFieldPresentationCatalog(), the file as the Field Resources page would leave it, which the workspace gives each map like its other current-state providers. Landmarks and cinematic preview origins read events through the Engine's MapEventResolver, so an event bound to a world object keeps its cells there; renaming a landmark anchored to a bound event no longer reports the event missing. Save All and change set commits write databases and cutscenes before maps, so a map is never on disk naming a record that is not. --- .../Preview/CinematicPreviewOrigin.php | 4 +- src/Database/ReferenceCatalog.php | 28 ++++++- src/Field/MapLandmarks.php | 15 ++-- src/Field/WorldObjectAuthoring.php | 4 +- src/Inspector/MapLandmarkFields.php | 8 +- src/Maps/TreeBrush.php | 2 +- src/ProjectMap.php | 39 ++++++++++ src/ProjectWorkspace.php | 2 + src/Session/EditorSession.php | 36 +++++---- src/Storage/WorkspaceSave.php | 77 ++++++++++--------- src/Validation/EffectValidator.php | 2 +- src/Validation/MapCompletenessCheck.php | 16 ++++ 12 files changed, 160 insertions(+), 73 deletions(-) diff --git a/src/Cutscenes/Preview/CinematicPreviewOrigin.php b/src/Cutscenes/Preview/CinematicPreviewOrigin.php index 0b526c8..1ef7948 100644 --- a/src/Cutscenes/Preview/CinematicPreviewOrigin.php +++ b/src/Cutscenes/Preview/CinematicPreviewOrigin.php @@ -33,8 +33,8 @@ public static function locate(ProjectWorkspace $workspace, CutsceneAsset $asset, continue; } - // Where the runtime places the marker: its first cell. - $first = $map->getEventArea((string) $marker)?->firstCell; + // Where the runtime places the marker: the first cell it triggers on, painted or its subject's. + $first = $map->getEventTriggerArea((string) $marker)?->firstCell; if ($first !== null) { return ['mapId' => $map->mapId, 'x' => (int) $first->x, 'y' => (int) $first->y, 'marker' => (string) $marker]; diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index b95cf35..d5a3bd7 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -333,8 +333,7 @@ public function valuesFor(string $category): array private function loadFieldResourceNames(): array { try { - $catalog = ProjectDirectoryContext::run($this->workspace->projectRoot, - fn(): FieldPresentationCatalog => FieldPresentationCatalog::load($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets')); + $catalog = $this->getFieldPresentationCatalog(); } catch (Throwable) { return []; } @@ -342,6 +341,31 @@ private function loadFieldResourceNames(): array return array_map(static fn($resource): string => $resource->name, $catalog->resources); } + /** + * The project's field presentation (assets/Data/Presentation/field.php) as the Engine reads it, with the + * Field Resources page's records as it holds them, unsaved edits included: what saving would leave. Every + * reader of field resources (pickers, world objects, validation) reads it, so a resource added in this + * session is usable at once. + * + * @throws \InvalidArgumentException When the catalogue is read-only or the Engine refuses it. + */ + public function getFieldPresentationCatalog(): FieldPresentationCatalog + { + $assets = $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; + $database = $this->workspace->getRecordDatabase(\Ichiloto\Editor\Field\FieldResourceFields::CATEGORY); + if ($database === null) { + return ProjectDirectoryContext::run($this->workspace->projectRoot, static fn(): FieldPresentationCatalog => FieldPresentationCatalog::load($assets)); + } + if (($reason = $database->getReadOnlyReason()) !== null) { + throw new \InvalidArgumentException($reason); + } + $file = PhpDataFile::load($database->backingFilePath(), $this->workspace->projectRoot); + $payload = $database->foldInto(is_array($file->payload) ? $file->payload : []); + + return ProjectDirectoryContext::run($this->workspace->projectRoot, + static fn(): FieldPresentationCatalog => FieldPresentationCatalog::fromArray($payload, $assets)); + } + /** * The project's text roles (assets/Data/Presentation/text.php), by stable id. A catalogue that cannot be read * offers none; project validation reports why. diff --git a/src/Field/MapLandmarks.php b/src/Field/MapLandmarks.php index a4c70b8..66290c3 100644 --- a/src/Field/MapLandmarks.php +++ b/src/Field/MapLandmarks.php @@ -9,6 +9,7 @@ use Ichiloto\Engine\Events\Triggers\TransferPlayerTrigger; use Ichiloto\Engine\Field\MapCategoryCatalog; use Ichiloto\Engine\Field\MapLandmarkAnchors; +use Ichiloto\Engine\Field\Validation\MapEventResolver; use Ichiloto\Engine\Field\MapLandmarkCatalog; use Ichiloto\Engine\Field\MapLandmarkKind; use InvalidArgumentException; @@ -40,21 +41,17 @@ public static function readMap(ProjectMap $map): self } /** - * The map as the Engine's map readers take it, unsaved edits included: its data with each placed event - * resolved to its marker and cells (an event without cells has nothing to mark), and its composed logical cell - * grid. Landmark anchors and atlas parents are both read from this one view. + * The map as the Engine's map readers take it, unsaved edits included: its data with each event resolved to + * its marker and cells by the Engine's own resolver (painted cells, or a bound subject's cells; an event the + * resolver refuses has nothing to mark, and validation says why), and its composed logical cell grid. + * Landmark anchors and atlas parents are both read from this one view. * * @return array{data: array, tiles: array>} * @throws InvalidArgumentException When world objects cannot be read. */ public static function readResolvedSource(ProjectMap $map): array { - $events = []; - foreach ($map->getEventDefinitions() as $marker => $definition) { - $area = $map->getEventArea((string) $marker); - if ($area === null || !is_array($definition)) { continue; } - $events[] = [...$definition, 'marker' => (string) $marker, 'area' => $area->toArray()]; - } + $events = MapEventResolver::inspect($map->getEditableData(), $map->getEventLayerCells(), $map->mapId)->events; $triggers = $map->getEditableData()['triggers'] ?? []; return ['data' => [...$map->getEditableData(), 'id' => $map->mapId, 'events' => $events, diff --git a/src/Field/WorldObjectAuthoring.php b/src/Field/WorldObjectAuthoring.php index 4bfb2c6..7a317c7 100644 --- a/src/Field/WorldObjectAuthoring.php +++ b/src/Field/WorldObjectAuthoring.php @@ -122,7 +122,7 @@ public static function validateEntries(ProjectMap $map, array $entries, bool $co private static function loadCatalog(ProjectMap $map, array $entries): ?FieldPresentationCatalog { return array_any($entries, static fn($entry): bool => is_array($entry) && array_key_exists('resource', $entry)) - ? FieldPresentationCatalog::load($map->getAssetRoot()) : null; + ? $map->getFieldCatalog() : null; } public function getSourceIssue(): ?string @@ -352,7 +352,7 @@ private static function makeLabel(string $id, string $name): array private static function readSignResources(ProjectMap $map): array { try { - $catalog = FieldPresentationCatalog::load($map->getAssetRoot()); + $catalog = $map->getFieldCatalog(); } catch (\Throwable) { return []; } diff --git a/src/Inspector/MapLandmarkFields.php b/src/Inspector/MapLandmarkFields.php index 2107e36..ad262fd 100644 --- a/src/Inspector/MapLandmarkFields.php +++ b/src/Inspector/MapLandmarkFields.php @@ -70,13 +70,17 @@ public function describeMapFields(ProjectMap $map): array /** * An event's minimap row: what the event is on the minimap, chosen among the kinds it may be. A transfer that * no landmark describes still shows as an unnamed route; choosing a kind describes it, and choosing the first - * option removes the description. An event without cells has nothing to mark. + * option removes the description. An event the map's resolver gives no cells (neither painted nor its + * subject's) has nothing to mark. * * @return list> */ public function describeEventFields(ProjectMap $map, string $marker): array { - return $map->getEventArea($marker) === null ? [] : $this->describeAnchorFields($map, ['event' => $marker]); + $resolved = array_any(MapLandmarks::readResolvedSource($map)['data']['events'], + static fn(array $event): bool => ($event['marker'] ?? null) === $marker); + + return $resolved ? $this->describeAnchorFields($map, ['event' => $marker]) : []; } /** diff --git a/src/Maps/TreeBrush.php b/src/Maps/TreeBrush.php index 5dafc91..f0de6b1 100644 --- a/src/Maps/TreeBrush.php +++ b/src/Maps/TreeBrush.php @@ -119,7 +119,7 @@ private static function getCellsTall(WorldObjectResource $type): int private static function loadCatalog(ProjectMap $map): FieldPresentationCatalog { try { - return FieldPresentationCatalog::load($map->getAssetRoot()); + return $map->getFieldCatalog(); } catch (Throwable $error) { throw new MapSourceRefusal('The field resources cannot be read, so no tree can be drawn: ' . $error->getMessage(), previous: $error); } diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 1a4a788..142f081 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -70,6 +70,9 @@ final class ProjectMap */ private ?string $dataSourceIssue = null; + /** @var (Closure(): \Ichiloto\Engine\Animations\Field\FieldPresentationCatalog)|null Its workspace's field presentation. */ + private ?Closure $fieldCatalog = null; + /** * The data file's raw bytes when its source cannot be parsed for * preservation, so a grid-only save still knows the data member is @@ -403,6 +406,42 @@ public function getEditableData(): array return $this->editableData; } + /** + * The cells an event triggers on as the Engine resolves them, unsaved + * edits included: its painted marker cells, or its bound subject's cells. + * Null when the resolver gives it none; validation says why. + */ + public function getEventTriggerArea(string $marker): ?CellArea + { + $events = \Ichiloto\Engine\Field\Validation\MapEventResolver::inspect($this->editableData, $this->getEventLayerCells(), $this->mapId)->events; + $event = array_find($events, static fn(array $event): bool => ($event['marker'] ?? null) === $marker); + + return is_array($event['area'] ?? null) ? CellArea::fromArray($event['area'], "Event {$marker} area") : null; + } + + /** + * Reads the field presentation this map's world objects name resources + * from through its workspace, unsaved edits included + * ({@see \Ichiloto\Editor\Database\ReferenceCatalog::getFieldPresentationCatalog()}). + * + * @param Closure(): \Ichiloto\Engine\Animations\Field\FieldPresentationCatalog $catalog + */ + public function useFieldCatalog(Closure $catalog): void + { + $this->fieldCatalog = $catalog; + } + + /** + * The field presentation this map's world objects name resources from: its + * workspace's, as that holds it, or the project's file for a map read on + * its own. + */ + public function getFieldCatalog(): \Ichiloto\Engine\Animations\Field\FieldPresentationCatalog + { + return $this->fieldCatalog !== null ? ($this->fieldCatalog)() + : \Ichiloto\Engine\Animations\Field\FieldPresentationCatalog::load($this->getAssetRoot()); + } + public function getLayers(): array { return array_map(static function (array $layer): array { diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index a7f07ab..728feff 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -70,6 +70,8 @@ public function __construct( ); $references = new ReferenceCatalog($this); foreach ($this->recordDatabases as $database) { $database->useAuthoringReferences($references); } + // A map's world objects name field resources as the Field Resources page holds them, unsaved edits included. + foreach ($this->maps as $map) { $map->useFieldCatalog($references->getFieldPresentationCatalog(...)); } $this->cutscenes?->useAuthoringReferences($references); } diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index 535a386..db8616f 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -5486,28 +5486,32 @@ public function commitChanges(bool $relocateSaves = false, bool $checkOnly = fal } $this->changeSet = null; - foreach ($changed as $mapId) { - if (! ($this->getMapsById()[$mapId] ?? null)?->isDirty()) { + // What the maps name (a field resource, a cinematic, a common event) is written before the maps, by the + // same Save All the editors use; a map whose save moves saves is then saved on its own, its moves allowed. + $unsaved = array_values(array_filter($changed, fn(string $mapId): bool => ($this->getMapsById()[$mapId] ?? null)?->isDirty() === true)); + $rest = $this->saveAll(); + if ($rest['failures'] !== []) { + throw new SessionRefusal('The change set was not fully saved: ' . implode('; ', $rest['failures'])); + } + foreach ($unsaved as $mapId) { + $map = $this->getMapsById()[$mapId] ?? null; + if ($map === null) { continue; } - try { - $result = $this->saveMap($mapId); - if (($result['status'] ?? null) === 'question') { - $result = $this->saveMap($mapId, 'write', $result['confirm']); - $outcome['relocated'] = [...$outcome['relocated'], ...($result['relocated'] ?? [])]; + if ($map->isDirty()) { + try { + $result = $this->saveMap($mapId); + if (($result['status'] ?? null) === 'question') { + $result = $this->saveMap($mapId, 'write', $result['confirm']); + $outcome['relocated'] = [...$outcome['relocated'], ...($result['relocated'] ?? [])]; + } + } catch (SessionRefusal $refusal) { + throw new SessionRefusal(sprintf('%s was not saved: %s Already saved: %s.', $mapId, $refusal->getMessage(), + implode(', ', $outcome['saved']) ?: 'every other change'), previous: $refusal); } - } catch (SessionRefusal $refusal) { - throw new SessionRefusal(sprintf('%s was not saved: %s%s', $mapId, $refusal->getMessage(), $outcome['saved'] === [] - ? ' Nothing was written.' : ' Already saved: ' . implode(', ', $outcome['saved']) . '.'), previous: $refusal); } $outcome['saved'][] = $mapId; } - if ($this->workspace->hasUnsavedChanges()) { - $rest = $this->saveAll(); - if ($rest['failures'] !== []) { - throw new SessionRefusal('The maps were saved, but not everything else: ' . implode('; ', $rest['failures'])); - } - } return ['status' => 'saved', ...$outcome]; } diff --git a/src/Storage/WorkspaceSave.php b/src/Storage/WorkspaceSave.php index cba73fe..f6bf040 100644 --- a/src/Storage/WorkspaceSave.php +++ b/src/Storage/WorkspaceSave.php @@ -45,44 +45,6 @@ public static function saveAll(ProjectWorkspace $workspace, BackupWriter $backup $inventory = MapValidator::readInventoryCatalog($workspace); - foreach ($workspace->maps as $map) { - if (! $map->isDirty()) { - continue; - } - - if ($map->willMoveOnSave()) { - $skippedRenames[] = $map->mapId; - continue; - } - - // Saves that would load inside what the map now makes solid are moved only once the author has seen where. - try { - $relocation = SaveRelocationPlanner::plan($map, $workspace->projectRoot); - } catch (Throwable $throwable) { - $failures[] = sprintf('%s: %s', $map->mapId, $throwable->getMessage()); - continue; - } - if ($relocation !== null) { - $skippedRelocations[] = $map->mapId; - continue; - } - - $issues = MapValidator::validate($map, $mapsById, $inventory); - foreach (MapValidator::describeIssues(MapValidator::selectSeverity($issues, Severity::WARNING)) as $warning) { - $warnings[] = sprintf('%s: %s', $map->mapId, $warning); - } - foreach (MapValidator::describeIssues(MapValidator::selectSeverity($issues, Severity::ERROR)) as $error) { - $errors[] = sprintf('%s: %s', $map->mapId, $error); - } - - try { - $map->save($backup); - $savedMaps++; - } catch (Throwable $throwable) { - $failures[] = sprintf('%s: %s', $map->mapId, $throwable->getMessage()); - } - } - // Categories sharing one file are saved together, so the file is // written once with every dirty part folded in rather than once per // category, each rewriting what the last just wrote. @@ -124,6 +86,45 @@ public static function saveAll(ProjectWorkspace $workspace, BackupWriter $backup $failures[] = sprintf('%s: %s', $asset, $reason); } + // Maps last: what they name (a field resource, a cinematic, an item) is on disk before they are. + foreach ($workspace->maps as $map) { + if (! $map->isDirty()) { + continue; + } + + if ($map->willMoveOnSave()) { + $skippedRenames[] = $map->mapId; + continue; + } + + // Saves that would load inside what the map now makes solid are moved only once the author has seen where. + try { + $relocation = SaveRelocationPlanner::plan($map, $workspace->projectRoot); + } catch (Throwable $throwable) { + $failures[] = sprintf('%s: %s', $map->mapId, $throwable->getMessage()); + continue; + } + if ($relocation !== null) { + $skippedRelocations[] = $map->mapId; + continue; + } + + $issues = MapValidator::validate($map, $mapsById, $inventory); + foreach (MapValidator::describeIssues(MapValidator::selectSeverity($issues, Severity::WARNING)) as $warning) { + $warnings[] = sprintf('%s: %s', $map->mapId, $warning); + } + foreach (MapValidator::describeIssues(MapValidator::selectSeverity($issues, Severity::ERROR)) as $error) { + $errors[] = sprintf('%s: %s', $map->mapId, $error); + } + + try { + $map->save($backup); + $savedMaps++; + } catch (Throwable $throwable) { + $failures[] = sprintf('%s: %s', $map->mapId, $throwable->getMessage()); + } + } + return new WorkspaceSaveResult( savedMaps: $savedMaps, savedDatabases: $savedDatabases, diff --git a/src/Validation/EffectValidator.php b/src/Validation/EffectValidator.php index b4036a8..f27e6d8 100644 --- a/src/Validation/EffectValidator.php +++ b/src/Validation/EffectValidator.php @@ -139,7 +139,7 @@ public static function findUses(ProjectWorkspace $workspace, array &$issues = [] } try { - $catalog = FieldPresentationCatalog::load($assetRoot); + $catalog = new ReferenceCatalog($workspace)->getFieldPresentationCatalog(); foreach ($catalog->cues as $color => $cue) { $use($cue['effect'], false, sprintf('%s: %s cue', self::FIELD_PRESENTATION, $color)); diff --git a/src/Validation/MapCompletenessCheck.php b/src/Validation/MapCompletenessCheck.php index 10c1f86..0fa2b3a 100644 --- a/src/Validation/MapCompletenessCheck.php +++ b/src/Validation/MapCompletenessCheck.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\Database\InventoryCatalog; use Ichiloto\Editor\ProjectMap; +use Ichiloto\Engine\Animations\Field\FieldPresentationCatalog; use Ichiloto\Engine\Field\Validation\MapCompletenessValidator; use Ichiloto\Engine\Field\Validation\MapValidationIssue; use Ichiloto\Engine\Rendering\Tilesets\Tileset; @@ -51,6 +52,8 @@ public static function validate(ProjectMap $map, ?InventoryCatalog $inventory): MapGraphicsValidator::readGraphics($map), $map->getAssetRoot(), $resolve, + // The resources as this session holds them: one added in the same change set resolves. + self::readFieldCatalog($map), requiresGraphics: glob($map->getAssetRoot() . '/' . Tileset::DIRECTORY . '/*.php') !== [], ); } catch (Throwable $failure) { @@ -69,6 +72,19 @@ public static function validate(ProjectMap $map, ?InventoryCatalog $inventory): return self::describeIssues($map, $found); } + /** + * The field presentation the map's world objects name resources from, as + * its workspace holds it; null when it names none, so a map of inline + * objects never depends on the catalogue. + */ + private static function readFieldCatalog(ProjectMap $map): ?FieldPresentationCatalog + { + $objects = $map->getEditableData()['worldObjects'] ?? []; + + return is_array($objects) && array_any($objects, static fn(mixed $entry): bool => is_array($entry) && array_key_exists('resource', $entry)) + ? $map->getFieldCatalog() : null; + } + /** * Words the Engine's findings for an author: one issue per finding, the * findings that differ only by cell joined into one that names its cells. From 9e02ad94e18489bb232c980b6e25a4b639196ec0 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 19 Oct 2026 00:44:48 +0200 Subject: [PATCH 333/344] fix(validation): read long array syntax as nested when finding duplicate keys The duplicate-key check only counted [ ] as array scopes, so a map file in array ( ) syntax, as var_export writes it, had every key read at the top level: two NPCs with the same fields were reported as duplicate keys and refused the map. Parentheses now open an array scope only after the array keyword. Adds the scene rebuild tests: a resource, its world object, a bound event, relative staging, an NPC and common event removed, committed as one change set or refused with nothing written; and a landmark renamed after its event is bound. --- src/Validation/ProjectValidator.php | 30 ++++- tests/Unit/ProjectValidatorTest.php | 14 +++ tests/Unit/Session/AtomicChangeSetTest.php | 131 +++++++++++++++++++++ 3 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/Session/AtomicChangeSetTest.php diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 84cfb39..edca66d 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -2915,10 +2915,22 @@ protected function duplicateKeysAt(ProjectMap $map, array $path): array $keyOfArray = []; $pendingKey = null; $occurrences = []; + // Whether each open bracket or parenthesis opened an array: `[...]`, or + // `array(...)` as var_export writes it, beside the parentheses of calls. + $opened = []; + $previous = null; foreach ($tokens as $index => $token) { + if (is_array($token) && in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + $after = $previous; + $previous = $token; + if (! is_array($token)) { - if ($token === '[') { + $opens = $token === '[' || ($token === '(' && is_array($after) && $after[0] === T_ARRAY); + if ($opens) { + $opened[] = true; $depth++; // The key this array was written against, so a nested block knows // its own name. @@ -2928,10 +2940,18 @@ protected function duplicateKeysAt(ProjectMap $map, array $path): array continue; } - if ($token === ']') { - unset($keyOfArray[$depth]); - $depth = max(0, $depth - 1); - $pendingKey = null; + if ($token === '(') { + $opened[] = false; + + continue; + } + + if ($token === ']' || $token === ')') { + if (array_pop($opened) === true) { + unset($keyOfArray[$depth]); + $depth = max(0, $depth - 1); + $pendingKey = null; + } continue; } diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index 1bd8d3c..20d4546 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -1149,3 +1149,17 @@ function effectIssueLines(string $root): array expect($missing)->toBe(['Alias target "cinder" is not defined in the current summons catalog.']); }); + +it('reads keys in long array syntax as nested, as var_export writes them, and still finds a real duplicate', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['npcs'] = [['id' => 'a', 'name' => 'A', 'x' => 1, 'y' => 1], ['id' => 'b', 'name' => 'B', 'x' => 2, 'y' => 1]]; + file_put_contents($path, " issuesMentioning(validateProject($root), 'is declared'); + + expect($duplicates())->toBe([]); + + file_put_contents($path, str_replace("'region' =>", "'name' => array ( 0 => 'Shadowed' ),\n 'region' =>", (string) file_get_contents($path))); + expect(array_map(static fn(Issue $issue): string => $issue->message, $duplicates()))->toBe(['"name" is declared 2 times (lines 4, 5).']); +}); diff --git a/tests/Unit/Session/AtomicChangeSetTest.php b/tests/Unit/Session/AtomicChangeSetTest.php new file mode 100644 index 0000000..82e56fc --- /dev/null +++ b/tests/Unit/Session/AtomicChangeSetTest.php @@ -0,0 +1,131 @@ + [], 'resources' => []];\n"); + writeTilesetTestPng($root . '/assets/Graphics/Props/Lamp.png', 12, 16); + $mapPath = $root . '/assets/Maps/harbour/harbour.data.php'; + $map = require $mapPath; + $map['events']['B'] = ['class' => Ichiloto\Engine\Events\Triggers\DialogueEventTrigger::class, 'data' => ['dialogue' => [['name' => '', 'text' => 'A lamp.']]]]; + $map['npcs'][] = ['id' => 'lamp-stand-in', 'name' => 'Lamp', 'sprite' => 'L', 'x' => 12, 'y' => 4]; + file_put_contents($mapPath, " 'text', 'message' => 'The lamp hums.']];\n"); + writeRelativeScriptForAtomicTest($root); + + return $root; +} + +/** The cinematic's script: one staged actor on a literal cell. */ +function writeRelativeScriptForAtomicTest(string $root): void +{ + file_put_contents($root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php', + " 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'x' => 1, 'y' => 2]], true) . ";\n"); +} + +/** The cinematic row at an entry, by name. */ +function atomicCinematicRow(EditorSession $session, string $name): array +{ + return array_find($session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands'])['rows'], + static fn(array $row): bool => ($row['entry'] ?? []) === [0] && ($row['name'] ?? '') === $name) + ?? throw new RuntimeException('Missing cinematic row ' . $name); +} + +/** Makes every edit of the rebuild inside the session's open change set. */ +function applyAtomicRebuild(EditorSession $session): void +{ + $session->createDatabaseRecord(FieldResourceFields::CATEGORY); + $index = count($session->listDatabaseRecords(FieldResourceFields::CATEGORY)['records']) - 1; + $resource = static fn(string $field): array => array_find($session->readDatabaseRecord(FieldResourceFields::CATEGORY, $index)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === $field) ?? throw new RuntimeException('Missing resource row ' . $field); + foreach (['id' => 'lamp', 'name' => 'Lamp', 'sprites2d.asset' => 'Graphics/Props/Lamp.png'] as $field => $value) { + $session->applyDatabaseRecord(FieldResourceFields::CATEGORY, $index, $resource($field)['key'], $value); + } + + // The resource is usable at once, though nothing has been written. + $session->createWorldObject('harbour', $session->readMap('harbour')['revision'], 'lamp-1', 10, 3, 'lamp', null); + $session->setEventSubject('harbour', $session->readMap('harbour')['revision'], 'B', 'lamp-1'); + $npc = array_search('lamp-stand-in', array_column($session->readMap('harbour')['npcs'], 'id'), true); + $session->deleteNpc('harbour', $session->readMap('harbour')['revision'], (int) $npc); + + $session->applyDatabaseRecord('cutscenes/cinematic', 0, atomicCinematicRow($session, 'Relative To')['key'], 'world_object'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, atomicCinematicRow($session, 'World Object')['key'], 'lamp-1'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, atomicCinematicRow($session, 'Offset Y')['key'], '1'); + + $duplicate = array_search('lamp-duplicate', $session->listDatabaseRecords('common_events')['records'], true); + expect($duplicate)->toBeInt(); + $session->deleteDatabaseRecord('common_events', $duplicate); +} + +it('commits a scene rebuild as one change set: a new resource, its object, a bound event, relative staging and removals', function () { + $root = atomicSceneProject(); + $session = EditorSession::open($root); + $session->beginChanges(); + applyAtomicRebuild($session); + + $outcome = $session->commitChanges(); + + $map = require $root . '/assets/Maps/harbour/harbour.data.php'; + expect($outcome['status'])->toBe('saved', json_encode($outcome['issues'])) + ->and(array_keys(FieldPresentationCatalog::load($root . '/assets')->resources))->toBe(['lamp']) + ->and(array_column($map['worldObjects'], 'resource', 'id'))->toBe(['lamp-1' => 'lamp']) + ->and($map['events']['B']['subject'])->toBe(['kind' => 'world_object', 'id' => 'lamp-1']) + ->and(array_column($map['npcs'], 'id'))->not->toContain('lamp-stand-in') + ->and((require $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php')[0]) + ->toMatchArray(['at' => ['kind' => 'world_object', 'id' => 'lamp-1'], 'offset' => ['y' => 1]]) + ->and(is_file($root . '/assets/Events/lamp-duplicate.php'))->toBeFalse() + ->and(is_file($root . '/assets/Events/dresser-note.php'))->toBeTrue() + ->and($session->listUnsavedChanges())->toBe([]); +}); + +it('refuses the same rebuild when it leaves a defect, writing no category file and no map', function () { + $root = atomicSceneProject(); + $before = sourceHashTree($root . '/assets'); + $session = EditorSession::open($root); + $session->beginChanges(); + applyAtomicRebuild($session); + // A marker painted with no event: the Engine refuses the map. + $map = $session->readMap('harbour'); + $session->paint('harbour', $map['revision'], 'event', [[2, 5]], 'Z'); + + $outcome = $session->commitChanges(); + + expect($outcome['status'])->toBe('refused') + ->and(array_column($outcome['issues'], 'code'))->toContain('event.unmapped') + ->and(sourceHashTree($root . '/assets'))->toBe($before) + ->and($session->listUnsavedChanges())->toBe([]); +}); + +it('keeps a landmark anchored to an event once the event is bound to a world object, renaming it in the same change set', function () { + [$root, $file, $session] = createLandmarkProject("'landmarks' => [['id' => 'trial', 'kind' => 'landmark', 'anchor' => ['event' => 'E']]],"); + $session->beginChanges(); + $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', 'old-tower'); + + applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'name', 'index' => 0], 'Lamp Trial'); + applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'id', 'index' => 0], 'lamp-trial'); + $outcome = $session->commitChanges(); + + $data = require $file; + expect($outcome['status'])->toBe('saved') + ->and(array_filter($outcome['issues'], static fn(array $issue): bool => str_contains($issue['message'], 'missing event')))->toBe([]) + ->and($data['events']['E']['subject'])->toBe(['kind' => 'world_object', 'id' => 'old-tower']) + ->and($data['landmarks'][0])->toMatchArray(['id' => 'lamp-trial', 'anchor' => ['event' => 'E']]) + ->and(json_encode($data['landmarks'][0]['name']))->toContain('Lamp Trial'); +}); From 1563a5196adab740e57a04ee2c9d0842421f629e Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 19 Oct 2026 00:45:01 +0200 Subject: [PATCH 334/344] docs(guides): the change set commit owns every save; later changes read earlier ones --- docs/guides/change-maps-from-a-script.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/guides/change-maps-from-a-script.md b/docs/guides/change-maps-from-a-script.md index 0ae3f3a..5aeff96 100644 --- a/docs/guides/change-maps-from-a-script.md +++ b/docs/guides/change-maps-from-a-script.md @@ -92,12 +92,20 @@ for you: refused with their role keys. Answer as the editors do, in the change's params: `"choices": {"x": "crate:0:0"}`, or `null` for no tiles. -Edits are made in order and held in memory. Inserting or removing rows, and -creating or duplicating a map, write their files when they run; a refused -batch undoes them. Inserting or removing rows needs no pending edits, so put -it first, or in its own batch. Saving, moving or deleting a map is refused -inside a batch: the batch saves, and moving or deleting a map clears the undo -history it relies on. +Edits are made in order and held in memory, and every later change reads +them: a field resource, database record or world object made earlier in the +batch is usable by the changes after it, before anything is written. +Inserting or removing rows, and creating or duplicating a map, write their +files when they run; a refused batch undoes them. Inserting or removing rows +needs no pending edits, so put it first, or in its own batch. + +The batch alone writes. When it is valid, `changes.commit` saves everything +it changed: databases (field resources, common events and the rest) and +cutscenes first, then maps, each through its own transaction. So +`database.save`, `map.save` and `project.saveAll` are refused inside a batch; +leave them out. Moving or deleting a map is refused too, since it clears the +undo history a refused batch relies on. Deleting a database record inside a +batch removes its file only when the batch is written. ## Finding Ids And Keys From c22e769c382dffdb20e3e8f8d269c7dc16648079 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 19 Oct 2026 00:54:34 +0200 Subject: [PATCH 335/344] fix(maps): follow reusable event script starts transitively before moving coordinates A map event that starts a reusable event script by id recorded only that script's start. Scripts it ran were walked from a snapshot taken before that walk, so their coordinates on the edited map stayed put during a row or column insertion or removal. The line inventory now settles every reusable event script's starts by following the scripts that run them until no new start appears, then moves coordinates. Starts are kept once each, so the settling ends, also for scripts that run each other in a cycle. Subject-relative positions stay unrewritten. --- src/Maps/MapLineInventory.php | 42 ++++++++++++++++--- .../Session/MapLineInsertionSessionTest.php | 32 ++++++++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/Maps/MapLineInventory.php b/src/Maps/MapLineInventory.php index 20443c6..c2415b0 100644 --- a/src/Maps/MapLineInventory.php +++ b/src/Maps/MapLineInventory.php @@ -41,7 +41,7 @@ final class MapLineInventory /** @var array, reason: string}>> What stands on a removed line, by file, keyed by path. */ private array $blocked = []; - /** @var array|null>> The contexts each reusable event script is started from. */ + /** @var array|null>> The contexts each reusable event script is started from, each once. */ private array $commonEventStarts = []; /** @var array> The maps whose events start each cutscene. */ @@ -158,7 +158,7 @@ public function addMapData(string $file, string $mapId, array $data): void if (is_array($eventData['script'] ?? null) && $eventData['script'] !== []) { $this->walkCommands($eventData['script'], ['events', $marker, 'data', 'script'], $here, $file); } elseif (trim(strval($eventData['scriptId'] ?? '')) !== '') { - $this->commonEventStarts[trim(strval($eventData['scriptId']))][] = $here; + $this->addCommonEventStart(trim(strval($eventData['scriptId'])), $here); } $cinematicId = trim(strval($eventData['cinematicId'] ?? '')); if ($cinematicId !== '') { @@ -221,16 +221,46 @@ public function addCutscene(string $dataFile, string $scriptFile, string $id, ar /** * Adds every reusable event script, each starting on every map that * runs it. Add every map and cutscene first, so those starts are known. + * A reusable event script runs others on the maps it is on, so every + * script's starts are followed through the scripts that run it before + * anything moves. Starts only grow and the maps are finite, so following + * them ends, also when scripts run each other in a cycle. */ public function addCommonEvents(): void { - $starts = $this->commonEventStarts; + do { + $known = $this->commonEventStarts; + foreach ($this->commonEvents as $id => $event) { + $this->walkCommands($event['commands'], [], $this->getCommonEventStart($id), null, [$id]); + } + } while ($this->commonEventStarts !== $known); + foreach ($this->commonEvents as $id => $event) { - $start = isset($starts[$id]) ? self::mergeContexts($starts[$id]) : []; - $this->walkCommands($event['commands'], [], $start, $event['file'], [$id]); + $this->walkCommands($event['commands'], [], $this->getCommonEventStart($id), $event['file'], [$id]); } } + /** + * Records a context a reusable event script is started from. + * + * @param list|null $context + */ + private function addCommonEventStart(string $id, ?array $context): void + { + $context = $context === null ? null : self::mergeContexts([$context]); + $this->commonEventStarts[$id][json_encode($context, JSON_THROW_ON_ERROR)] = $context; + } + + /** + * The maps a reusable event script may start on; none when nothing known runs it. + * + * @return list|null + */ + private function getCommonEventStart(string $id): ?array + { + return isset($this->commonEventStarts[$id]) ? self::mergeContexts(array_values($this->commonEventStarts[$id])) : []; + } + /** * Walks a command list from a context, moving coordinates in the edited * map's space when `$file` is given, and returns the context it ends in. @@ -280,7 +310,7 @@ private function walkCommands(mixed $commands, array $path, ?array $context, ?st if ($type === 'common_event') { $id = trim(strval($command['id'] ?? '')); - $this->commonEventStarts[$id][] = $context; + $this->addCommonEventStart($id, $context); if (isset($this->commonEvents[$id]) && ! in_array($id, $stack, true)) { $context = $this->walkCommands($this->commonEvents[$id]['commands'], [], $context, null, [...$stack, $id]); } diff --git a/tests/Unit/Session/MapLineInsertionSessionTest.php b/tests/Unit/Session/MapLineInsertionSessionTest.php index c4ec861..6bde318 100644 --- a/tests/Unit/Session/MapLineInsertionSessionTest.php +++ b/tests/Unit/Session/MapLineInsertionSessionTest.php @@ -261,3 +261,35 @@ function writeReviewedMapInsertion(EditorSession $session, array $question): arr ->and($request('history.undo')['result']['label'])->toBe('Insert row') ->and(sourceHashTree($root))->toBe($before); }); + +it('moves coordinates in event scripts reached only through another event script the map runs', function () { + $root = mapInsertionSessionProject(); + $directory = $root . '/assets/Maps/test-map'; + $path = $directory . '/test-map.data.php'; + file_put_contents($path, str_replace("'events' => ['D' =>", <<<'PHP' +'events' => ['E' => ['class' => 'Ichiloto\Engine\Events\Triggers\ScriptEventTrigger', 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'outer']], + 'D' => +PHP, (string) file_get_contents($path))); + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource(" E \n D ", 'EVENTS', "// Authored event notes.\n")); + file_put_contents($root . '/assets/Events/outer.php', " 'common_event', 'id' => 'inner']];\n"); + // The inner script runs the outer one again; following them must still end. + file_put_contents($root . '/assets/Events/inner.php', <<<'PHP' + 'move_player', 'x' => 3, 'y' => 1], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['at' => ['kind' => 'npc', 'id' => 'visitor'], 'offset' => ['x' => 1]]]], + ['type' => 'common_event', 'id' => 'outer'], +]; +PHP); + $session = EditorSession::open($root); + $question = $session->insertMapLines('test-map', $session->readMap('test-map')['revision'], 'y', 1, 2); + + expect($question['paths'])->toContain('assets/Events/inner.php') + ->and(writeReviewedMapInsertion($session, $question)['status'])->toBe('inserted') + ->and(require $root . '/assets/Events/inner.php')->toBe([ + ['type' => 'move_player', 'x' => 3, 'y' => 3], + // A position relative to a subject moves with its subject and is never rewritten. + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['at' => ['kind' => 'npc', 'id' => 'visitor'], 'offset' => ['x' => 1]]]], + ['type' => 'common_event', 'id' => 'outer'], + ]); +}); From 78c340005422e6adda918f6b7509623e5c6a2d81 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 19 Oct 2026 00:55:21 +0200 Subject: [PATCH 336/344] fix(npc): follow common_event chains when finding what names an NPC id NpcReferences only read the event scripts a map event names by scriptId, and never followed common_event commands. An NPC named only by an event script started from an NPC script, an event script, a dialogue variant or a cinematic, directly or through other event scripts, had no references, so a rename let its derived id follow the name and the Id row changed it while the script kept the old npcId. The finder now collects the scripts every map-local owner starts and follows them transitively, reading each once so cycles end. --- src/Field/NpcReferences.php | 104 +++++++++++++----- tests/Support/NpcFixtures.php | 36 ++++++ tests/Unit/NpcAuthoringTest.php | 19 ++++ tests/Unit/Session/NpcIdentitySessionTest.php | 28 +++++ 4 files changed, 162 insertions(+), 25 deletions(-) diff --git a/src/Field/NpcReferences.php b/src/Field/NpcReferences.php index 8029b59..46bcd99 100644 --- a/src/Field/NpcReferences.php +++ b/src/Field/NpcReferences.php @@ -21,8 +21,10 @@ * * NPC ids are map-local, so a reference is only a reference from that map's * own context: its map-authored events and their scripts, its NPCs' inline - * scripts and variant scripts, the reusable event scripts those trigger, and - * the cinematics that start on the map. + * scripts and variant scripts, the cinematics that start on the map, and the + * reusable event scripts any of those reach, by an event's `scriptId` or a + * `common_event` command, followed from script to script as the interpreter + * runs them. * * @package Ichiloto\Editor\Field */ @@ -57,16 +59,20 @@ public function describe(ProjectMap $map, string $npcId): array } $data = (array) ($definition['data'] ?? []); + $label = sprintf('%s event %s', $map->mapId, strval($marker)); + $script = (array) ($data['script'] ?? []); - if (trim(strval($data['npcId'] ?? '')) === $npcId || $this->commandsName((array) ($data['script'] ?? []), $npcId)) { - $found[] = sprintf('%s event %s', $map->mapId, strval($marker)); + if (trim(strval($data['npcId'] ?? '')) === $npcId || $this->commandsName($script, $npcId)) { + $found[] = $label; } $scriptId = trim(strval($data['scriptId'] ?? '')); if ($scriptId !== '') { - $usedScripts[$scriptId] = sprintf('%s event %s', $map->mapId, strval($marker)); + $usedScripts[$scriptId] ??= $label; } + + self::collectStartedScripts($script, $label, $usedScripts); } // The map's other NPCs: inline scripts and dialogue-variant scripts. @@ -77,28 +83,17 @@ public function describe(ProjectMap $map, string $npcId): array $found[] = $label . ' script'; } - foreach ($other->getDialogueAsVariants() as $index => $variant) { - if ($this->commandsName((array) ($variant['script'] ?? []), $npcId)) { - $found[] = sprintf('%s dialogue variant %d', $label, $index + 1); - } - } - } - - // Reusable scripts this map triggers. - $database = $this->workspace->getRecordDatabase('common_events'); + self::collectStartedScripts($other->getScript(), $label . ' script', $usedScripts); - if ($database instanceof ProjectRecordDatabase) { - foreach ($database->getRecords() as $record) { - $script = (array) $record->toArray(); - $scriptId = strval($script['__scriptId'] ?? ''); + foreach ($other->getDialogueAsVariants() as $index => $variant) { + $variantLabel = sprintf('%s dialogue variant %d', $label, $index + 1); + $script = (array) ($variant['script'] ?? []); - if (! isset($usedScripts[$scriptId])) { - continue; + if ($this->commandsName($script, $npcId)) { + $found[] = $variantLabel; } - if ($this->commandsName((array) ($script['commands'] ?? []), $npcId)) { - $found[] = sprintf('event script %s (via %s)', $scriptId, $usedScripts[$scriptId]); - } + self::collectStartedScripts($script, $variantLabel, $usedScripts); } } @@ -115,14 +110,73 @@ public function describe(ProjectMap $map, string $npcId): array continue; } - if ($this->commandsName([...(array) ($data['cast'] ?? []), ...(array) ($data['finalizer'] ?? []), ...$commands], $npcId)) { - $found[] = sprintf('cinematic %s', $asset->id); + $cinematic = [...(array) ($data['cast'] ?? []), ...(array) ($data['finalizer'] ?? []), ...$commands]; + $label = sprintf('cinematic %s', $asset->id); + + if ($this->commandsName($cinematic, $npcId)) { + $found[] = $label; + } + + self::collectStartedScripts($cinematic, $label, $usedScripts); + } + + // Reusable scripts this map reaches, and the ones those start in turn. + // Each is read once, so scripts that start each other end the walk. + $database = $this->workspace->getRecordDatabase('common_events'); + $scripts = []; + + if ($database instanceof ProjectRecordDatabase) { + foreach ($database->getRecords() as $record) { + $script = (array) $record->toArray(); + $scripts[strval($script['__scriptId'] ?? '')] ??= (array) ($script['commands'] ?? []); + } + } + + $read = []; + + while (($pending = array_diff_key($usedScripts, $read)) !== []) { + foreach ($pending as $scriptId => $via) { + $scriptId = strval($scriptId); + $read[$scriptId] = true; + $commands = $scripts[$scriptId] ?? []; + $label = sprintf('event script %s', $scriptId); + + if ($this->commandsName($commands, $npcId)) { + $found[] = sprintf('%s (via %s)', $label, $via); + } + + self::collectStartedScripts($commands, $label, $usedScripts); } } return array_values(array_unique($found)); } + /** + * Notes the reusable event scripts commands start, however deeply nested, + * each under the first owner found starting it. + * + * @param array $commands The commands. + * @param string $owner Where the commands live, in words. + * @param array $started Owners by script id, added to. + */ + private static function collectStartedScripts(array $commands, string $owner, array &$started): void + { + foreach ($commands as $node) { + if (! is_array($node)) { + continue; + } + + $scriptId = trim(strval($node['id'] ?? '')); + + if (($node['type'] ?? null) === 'common_event' && $scriptId !== '') { + $started[$scriptId] ??= $owner; + } + + self::collectStartedScripts($node, $owner, $started); + } + } + /** * Determines whether commands name the NPC, however deeply nested: a * `move_route` for subject `npc`, or any subject, target or cast entry diff --git a/tests/Support/NpcFixtures.php b/tests/Support/NpcFixtures.php index 812fb96..80f6ddb 100644 --- a/tests/Support/NpcFixtures.php +++ b/tests/Support/NpcFixtures.php @@ -32,3 +32,39 @@ function npcSessionRow(array $read, string $fieldId): array return array_find($read['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $fieldId) ?? throw new RuntimeException("No NPC row {$fieldId}."); } + +/** + * Writes reusable event scripts into a project, each as the bare command list + * `assets/Events/.php` returns. + * + * @param array>> $scripts Commands by script id. + */ +function writeNpcEventScripts(string $root, array $scripts): void +{ + foreach ($scripts as $id => $commands) { + file_put_contents($root . '/assets/Events/' . $id . '.php', ", 1: array, 2: array>>}> + * Bob's extra fields, the map's events and the event scripts, per case. + */ +function npcEventScriptChains(): array +{ + $startA = [['type' => 'common_event', 'id' => 'a']]; + $route = ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'mara', 'steps' => [['direction' => 'north']]]; + $event = static fn(array $data): array => ['T' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', 'data' => [...$data, 'mode' => 'action']]]; + $chain = ['a' => [['type' => 'common_event', 'id' => 'b']], 'b' => [$route]]; + + return [ + 'an NPC script through a to b' => [['script' => $startA], [], $chain], + 'an event inline script through a to b' => [[], $event(['script' => $startA]), $chain], + 'an event script id through a to b' => [[], $event(['scriptId' => 'a']), $chain], + 'a cycle between a and b' => [['script' => $startA], [], [...$chain, 'b' => [['type' => 'common_event', 'id' => 'a'], $route]]], + ]; +} diff --git a/tests/Unit/NpcAuthoringTest.php b/tests/Unit/NpcAuthoringTest.php index 20e63e2..7d104c8 100644 --- a/tests/Unit/NpcAuthoringTest.php +++ b/tests/Unit/NpcAuthoringTest.php @@ -722,6 +722,25 @@ public function __construct() ]]]], 'NPC Bob script'], ]); +it('keeps the id of an NPC only an event script reached through common_event chains names, on rename and on its Id row', function (array $bob, array $events, array $scripts) { + [$root] = npcProject([ + ['id' => 'mara', 'name' => 'Mara', 'sprite' => 'M', 'x' => 2, 'y' => 1], + ['id' => 'bob', 'name' => 'Bob', 'sprite' => 'B', 'x' => 6, 'y' => 3, ...$bob], + ], $events); + writeNpcEventScripts($root, $scripts); + $editor = npcEditor($root); + $map = npcMap($editor); + + callEditorMethod($editor, 'selectNpc', 0); + setNpcField($editor, 'name', 'Old Mara'); + expect($map->getNpcs()->get(0)?->getId())->toBe('mara') + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Its id stays mara')->toContain('event script b (via event script a)'); + + setNpcField($editor, 'id', 'mara-at-home'); + expect($map->getNpcs()->get(0)?->getId())->toBe('mara') + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('keeps the id "mara": event script b (via event script a)'); +})->with(npcEventScriptChains()); + it('reads Movement and Sprite as their runtime defaults when unset', function () { [$root] = npcProject([['name' => 'Legacy', 'x' => 2, 'y' => 1]]); $editor = npcEditor($root); diff --git a/tests/Unit/Session/NpcIdentitySessionTest.php b/tests/Unit/Session/NpcIdentitySessionTest.php index 3a538ca..bffe747 100644 --- a/tests/Unit/Session/NpcIdentitySessionTest.php +++ b/tests/Unit/Session/NpcIdentitySessionTest.php @@ -116,3 +116,31 @@ function npcIdentityProject(array $npcs, array $events = []): string ->and($followed)->toMatchArray(['id' => 'old-mara', 'followedId' => 'old-mara']) ->and(npcSessionIds($session))->toBe(['djin-contract-trial', 'old-mara']); }); + +it('keeps and refuses to change an id only an event script reached through common_event chains names', function (array $bob, array $events, array $scripts) { + $root = npcIdentityProject([ + ['id' => 'mara', 'name' => 'Mara', 'x' => 2, 'y' => 1], + ['id' => 'bob', 'name' => 'Bob', 'x' => 6, 'y' => 3, ...$bob], + ], $events); + writeNpcEventScripts($root, $scripts); + $session = EditorSession::open($root); + $read = $session->readNpc('test-map', 0); + + expect(fn() => $session->applyNpc('test-map', $read['revision'], 0, npcSessionRow($read, 'id')['key'], 'mara-at-home')) + ->toThrow(SessionRefusal::class, 'Mara keeps the id "mara": event script b (via event script a) names it.') + ->and(npcSessionIds($session))->toBe(['mara', 'bob']); + + $renamed = $session->applyNpc('test-map', $read['revision'], 0, npcSessionRow($read, 'name')['key'], 'Old Mara'); + expect($renamed)->toMatchArray(['changed' => true, 'id' => 'mara', 'followedId' => null, 'idReferences' => ['event script b (via event script a)']]) + ->and(npcSessionIds($session))->toBe(['mara', 'bob']); +})->with(npcEventScriptChains()); + +it('lets a derived id follow its name when no script this map reaches names it', function () { + $root = npcIdentityProject([['id' => 'mara', 'name' => 'Mara', 'x' => 2, 'y' => 1]]); + writeNpcEventScripts($root, npcEventScriptChains()['a cycle between a and b'][2]); + $session = EditorSession::open($root); + $read = $session->readNpc('test-map', 0); + + expect($session->applyNpc('test-map', $read['revision'], 0, npcSessionRow($read, 'name')['key'], 'Old Mara')) + ->toMatchArray(['id' => 'old-mara', 'followedId' => 'old-mara', 'idReferences' => []]); +}); From 4b873d7bd038a09f571db6135a1f9b529a8b0a85 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 19 Oct 2026 00:56:22 +0200 Subject: [PATCH 337/344] test(playtest): cinematic playtests start on layered and single-file maps A cinematic playtest locates its origin on the start map and hands the Engine a launch context; the game then reads that map from the project's own root. The test covers a map converted to layers (no single map file) and a single-file map, and reads each start map with the Engine's MapSourceReader from the launch's project root. The review finding about PlaytestOverlay's fixed data/map/event preflight no longer applies: 76918d5 removed the overlay, and PlaytestLaunch copies and checks no map files. --- tests/Unit/PlaytestTest.php | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/Unit/PlaytestTest.php b/tests/Unit/PlaytestTest.php index 1b29d67..48b6ab1 100644 --- a/tests/Unit/PlaytestTest.php +++ b/tests/Unit/PlaytestTest.php @@ -2,10 +2,16 @@ declare(strict_types=1); +use Ichiloto\Editor\Cutscenes\CutsceneAsset; +use Ichiloto\Editor\Cutscenes\CutsceneType; +use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewOrigin; use Ichiloto\Editor\Playtest\PlaytestLaunch; use Ichiloto\Editor\Playtest\PlaytestLauncher; +use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Core\GameLaunchContext; use Ichiloto\Engine\Core\GameStartupMode; +use Ichiloto\Engine\Field\MapGridSource; +use Ichiloto\Engine\Field\MapSourceReader; use Ichiloto\Engine\IO\LaunchDataRoot; /** @@ -136,6 +142,39 @@ function listPlaytestDataRoots(): array ->and(listPlaytestDataRoots())->toBe($before); }); +it('playtests a cinematic on a layered or a single-file start map the Engine reads from the project', function (bool $layered): void { + $root = cinematicStartMapProject($layered); + $mapDirectory = $root . '/assets/Maps/test-map'; + // A layered map keeps its grids in layers/ and has no single map file; a legacy map is that file alone. + expect(is_file($mapDirectory . '/test-map.map.php'))->toBe(! $layered) + ->and(is_dir($mapDirectory . '/layers'))->toBe($layered); + $before = checksumTree($root); + $workspace = ProjectWorkspace::fromProject($root); + $asset = CutsceneAsset::create(CutsceneType::CINEMATIC, 'dawn-crossing', $root . '/assets/Cutscenes/Cinematics', + ['id' => 'dawn-crossing', 'name' => 'Dawn Crossing', 'startMap' => 'test-map', 'commands' => []], $root); + // The origin the Cutscenes pane and the session both playtest from. + $origin = CinematicPreviewOrigin::locate($workspace, $asset, 'test-map'); + $launch = PlaytestLaunch::fromCinematic($root, (string) $origin['mapId'], $origin['x'], $origin['y'], $asset->id); + + try { + putenv(GameLaunchContext::ENVIRONMENT_VARIABLE . '=' . substr($launch->buildPlayOption(), strlen('--launch-context='))); + $context = GameLaunchContext::consumeEnvironment(); + // The game reads the start map from the project's own root, the way the field does. + $source = MapSourceReader::readFiles(MapSourceReader::resolvePaths($launch->projectRoot . '/assets/Maps', $context->start->mapId)); + + expect($origin['mapId'])->toBe('test-map') + ->and($context->cinematicId)->toBe('dawn-crossing') + ->and([$context->start->mapId, $context->start->x, $context->start->y])->toBe(['test-map', $origin['x'], $origin['y']]) + ->and($source['layers']->legacy)->toBe(! $layered) + ->and(count($source['tiles']))->toBe($workspace->getMapByIndex(0)->getHeight()) + ->and($source['tiles'][$origin['y']][$origin['x']] ?? null)->not->toBeNull() + ->and(checksumTree($root))->toBe($before); + } finally { + putenv(GameLaunchContext::ENVIRONMENT_VARIABLE); + $launch->destroy(); + } +})->with(['layered' => true, 'single file' => false]); + it('plays a project whose system data holds PHP objects, since nothing is rewritten for a playtest', function (): void { $root = makeTemporaryProject(); file_put_contents( @@ -255,6 +294,26 @@ function playtestScratch(): string return $scratch; } +/** + * A playtest project whose start map the Engine's map reader accepts, either + * converted to layers (no single map file) or as that single file alone, + * with its event marker defined in the map data. + */ +function cinematicStartMapProject(bool $layered): string +{ + $root = $layered ? layeredMapProject() : makeTemporaryProject(); + file_put_contents($root . '/assets/Data/system.php', " ['player' => []]];\n"); + $directory = $root . '/assets/Maps/test-map'; + if (! $layered) { + file_put_contents($directory . '/test-map.map.php', MapGridSource::buildSource("####\n# #", 'ICHILOTO_MAP')); + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource(" E\n ", 'ICHILOTO_EVENT_MAP')); + } + file_put_contents($directory . '/test-map.data.php', " 'Test Map', 'region' => '', 'events' => ['E' => " + . "['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\ChestEventTrigger', 'data' => ['lootType' => 'item', 'loot' => 'S-Potion']]]];\n"); + + return $root; +} + function playtestProject(): string { $root = makeTemporaryProject(); From 21209d8d4c97ecf91d1a973246c606849711c81d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 19 Oct 2026 01:04:10 +0200 Subject: [PATCH 338/344] fix(validation): follow a parallel lane's transfer into every lane The Engine ticks parallel lanes together on the one current map, and a transfer in any lane suspends the session and moves every lane. Validation walked lanes in file order with one shared context, so a lane listed before a sibling's transfer was checked only against the starting map, and a block with several transferring lanes ended on the last-listed destination. Each lane now starts on the incoming map or any map a sibling lane transfers to, a transfer inside a lane can still be overtaken by a sibling's, and the block ends on any map its transferring lanes can finish on. Event scripts, cinematics and map line edits share this model through CommandMapContext lane and transfer helpers. Validation keeps the possible maps as a list in ScriptMapState, so joined names no longer repeat. --- src/Events/CommandMapContext.php | 76 ++++++++++++- src/Maps/MapLineInventory.php | 48 ++++++-- src/Validation/CutsceneValidation.php | 38 ++++--- src/Validation/ProjectValidator.php | 97 ++++++++++------ src/Validation/ScriptMapState.php | 104 ++++++++++++++++++ .../Unit/Cutscenes/CutsceneValidationTest.php | 78 +++++++++++++ tests/Unit/MapLineInsertionTest.php | 25 +++++ 7 files changed, 402 insertions(+), 64 deletions(-) create mode 100644 src/Validation/ScriptMapState.php diff --git a/src/Events/CommandMapContext.php b/src/Events/CommandMapContext.php index 78408c0..8fd6d42 100644 --- a/src/Events/CommandMapContext.php +++ b/src/Events/CommandMapContext.php @@ -5,7 +5,8 @@ namespace Ichiloto\Editor\Events; /** - * How an event script's alternative arms share the map it runs on. + * How an event script's alternative arms and parallel lanes share the map + * it runs on. * * Validation follows the current map to check the NPCs a command names, and * a map edit follows it to know which coordinates are in that map's space. @@ -13,6 +14,11 @@ * starts on the map the command runs on, not where a sibling arm ended, and * a command that can run none of its arms leaves the map unchanged. * + * Parallel lanes run together on the game's one current map. The Engine + * ticks every lane in turn, and a transfer in any lane suspends the session + * and moves all of them, so a lane can be on the map the block started on or + * on any map a sibling lane transfers to, whatever the lanes' file order. + * * @package Ichiloto\Editor\Events */ final class CommandMapContext @@ -45,6 +51,74 @@ public static function getArms(array $command): array return $arms; } + /** + * The lanes of a parallel command, in order. A lane is a command list or + * a keyed lane holding `commands`. + * + * @param array $command The command. + * @return list, commands: array}> Each lane and its path from the command. + */ + public static function getLanes(array $command): array + { + if (($command['type'] ?? null) !== 'parallel') { + return []; + } + + $lanes = []; + foreach ((array) ($command['lanes'] ?? []) as $index => $lane) { + if (! is_array($lane)) { + continue; + } + $lanes[] = array_is_list($lane) + ? ['path' => ['lanes', $index], 'commands' => $lane] + : ['path' => ['lanes', $index, 'commands'], 'commands' => (array) ($lane['commands'] ?? [])]; + } + + return $lanes; + } + + /** + * Every map a command list can transfer to, through its blocks, arms, + * lanes and the reusable event scripts it runs. + * + * @param array $commands The command list. + * @param callable(string): ?array $findCommonEvent The commands of a reusable event script, or null when it is unknown. + * @param list $stack Reusable event scripts already being followed. + * @return list Each destination map id once; an empty id for a transfer that names no map. + */ + public static function listTransferDestinations(array $commands, callable $findCommonEvent, array $stack = []): array + { + $destinations = []; + foreach ($commands as $command) { + if (! is_array($command)) { + continue; + } + $type = strval($command['type'] ?? ''); + $nested = []; + if ($type === 'transfer') { + $destinations[] = trim(strval($command['map'] ?? '')); + } + if ($type === 'sequence') { + $nested[] = [(array) ($command['commands'] ?? []), $stack]; + } + if ($type === 'common_event') { + $id = trim(strval($command['id'] ?? '')); + $script = in_array($id, $stack, true) ? null : $findCommonEvent($id); + if ($script !== null) { + $nested[] = [$script, [...$stack, $id]]; + } + } + foreach ([...self::getLanes($command), ...self::getArms($command)] as $block) { + $nested[] = [$block['commands'], $stack]; + } + foreach ($nested as [$list, $listStack]) { + $destinations = [...$destinations, ...self::listTransferDestinations($list, $findCommonEvent, $listStack)]; + } + } + + return array_values(array_unique($destinations)); + } + /** * Whether the command can finish without running any of its arms: a * branch missing `then` or `else`, or a choice without `cancel`. diff --git a/src/Maps/MapLineInventory.php b/src/Maps/MapLineInventory.php index 20443c6..545d322 100644 --- a/src/Maps/MapLineInventory.php +++ b/src/Maps/MapLineInventory.php @@ -237,12 +237,17 @@ public function addCommonEvents(): void * A reusable event script it runs is followed inline, without moving * anything, for the map it leaves the caller on and the scripts it runs. * + * A parallel block's lanes run together on one current map, as + * {@see CommandMapContext} describes, so each lane can also be on any + * map a sibling lane transfers to. + * * @param list $path The list's path in its file. * @param list|null $context * @param list $stack Reusable event scripts being followed. + * @param list $concurrent Maps a concurrent parallel lane can transfer to at any point. * @return list|null */ - private function walkCommands(mixed $commands, array $path, ?array $context, ?string $file, array $stack = []): ?array + private function walkCommands(mixed $commands, array $path, ?array $context, ?string $file, array $stack = [], array $concurrent = []): ?array { if (! is_array($commands)) { return $context; @@ -259,19 +264,27 @@ private function walkCommands(mixed $commands, array $path, ?array $context, ?st } if ($type === 'sequence') { - $context = $this->walkCommands($command['commands'] ?? [], [...$at, 'commands'], $context, $file, $stack); + $context = $this->walkCommands($command['commands'] ?? [], [...$at, 'commands'], $context, $file, $stack, $concurrent); } if ($type === 'parallel') { + $lanes = CommandMapContext::getLanes($command); + $findCommonEvent = fn(string $id): ?array => is_array($this->commonEvents[$id]['commands'] ?? null) + ? $this->commonEvents[$id]['commands'] + : null; + $destinations = array_map( + static fn(array $lane): array => CommandMapContext::listTransferDestinations($lane['commands'], $findCommonEvent, $stack), + $lanes, + ); + // The block ends where a transferring lane can leave it, or where it started. $ends = []; - foreach ((array) ($command['lanes'] ?? []) as $laneIndex => $lane) { - if (! is_array($lane)) { - continue; + foreach ($lanes as $laneIndex => $lane) { + $siblings = array_values(array_unique(array_merge([], ...array_values(array_diff_key($destinations, [$laneIndex => true]))))); + $start = self::mergeContexts([$context, ...array_map(self::getTransferContext(...), $siblings)]); + $end = $this->walkCommands($lane['commands'], [...$at, ...$lane['path']], $start, $file, $stack, array_values(array_unique([...$concurrent, ...$siblings]))); + if ($destinations[$laneIndex] !== []) { + $ends[] = $end; } - [$laneCommands, $lanePath] = array_is_list($lane) - ? [$lane, [...$at, 'lanes', $laneIndex]] - : [$lane['commands'] ?? [], [...$at, 'lanes', $laneIndex, 'commands']]; - $ends[] = $this->walkCommands($laneCommands, $lanePath, $context, $file, $stack); } if ($ends !== []) { $context = self::mergeContexts($ends); @@ -282,7 +295,7 @@ private function walkCommands(mixed $commands, array $path, ?array $context, ?st $id = trim(strval($command['id'] ?? '')); $this->commonEventStarts[$id][] = $context; if (isset($this->commonEvents[$id]) && ! in_array($id, $stack, true)) { - $context = $this->walkCommands($this->commonEvents[$id]['commands'], [], $context, null, [...$stack, $id]); + $context = $this->walkCommands($this->commonEvents[$id]['commands'], [], $context, null, [...$stack, $id], $concurrent); } } @@ -290,7 +303,7 @@ private function walkCommands(mixed $commands, array $path, ?array $context, ?st if ($arms !== []) { $ends = []; foreach ($arms as $arm) { - $ends[] = $this->walkCommands($arm['commands'], [...$at, ...$arm['path']], $context, $file, $stack); + $ends[] = $this->walkCommands($arm['commands'], [...$at, ...$arm['path']], $context, $file, $stack, $concurrent); } if (CommandMapContext::canSkipArms($command)) { $ends[] = $context; @@ -303,7 +316,7 @@ private function walkCommands(mixed $commands, array $path, ?array $context, ?st if ($file !== null && $destination === $this->edit->mapId) { $this->shiftPoint($file, $at, $command); } - $context = $destination !== '' ? [$destination] : null; + $context = self::mergeContexts(array_map(self::getTransferContext(...), [$destination, ...$concurrent])); $this->transferContexts[] = $context; } } @@ -518,6 +531,17 @@ private static function formatKey(array $path): string return implode("\0", $path); } + /** + * The context a transfer arrives in: its map, or an unknown map when it + * names none. + * + * @return list|null + */ + private static function getTransferContext(string $destination): ?array + { + return $destination !== '' ? [$destination] : null; + } + /** * The maps any of several contexts may be on; unknown when any is. * diff --git a/src/Validation/CutsceneValidation.php b/src/Validation/CutsceneValidation.php index 9b9ef34..200fa2a 100644 --- a/src/Validation/CutsceneValidation.php +++ b/src/Validation/CutsceneValidation.php @@ -188,7 +188,7 @@ protected function checkCinematicReferences(ProjectWorkspace $workspace, Cutscen } } - $context = ['castIds' => $castIds, 'checkpoints' => $checkpoints, 'npcIds' => $npcIds, 'mapId' => $startMap !== '' ? $startMap : null, 'npcIdsByMap' => $npcIdsByMap, 'seenCheckpoints' => []]; + $context = ['castIds' => $castIds, 'checkpoints' => $checkpoints, 'map' => ScriptMapState::startOn($startMap !== '' ? $startMap : null, $npcIds), 'npcIdsByMap' => $npcIdsByMap, 'seenCheckpoints' => []]; foreach ((array) ($data['cast'] ?? []) as $entry) { if (is_array($entry) && is_array($entry['at'] ?? null)) { $issues = [...$issues, ...$this->checkCinematicSubject(['subject' => $entry['at']], $where . ' cast', $context)]; @@ -205,7 +205,7 @@ protected function checkCinematicReferences(ProjectWorkspace $workspace, Cutscen * * @param array $commands * @param array $known - * @param array{castIds: string[], checkpoints: string[], npcIds: string[]|null, mapId: string|null, npcIdsByMap: array, seenCheckpoints: string[]} $context + * @param array{castIds: string[], checkpoints: string[], map: ScriptMapState, npcIdsByMap: array, seenCheckpoints: string[]} $context * @return Issue[] */ protected function walkCinematicCommands( @@ -238,7 +238,7 @@ protected function walkCinematicCommands( } $issues = [...$issues, ...$this->checkSharedCommandSemantics( - $command, $where, $context['npcIds'], $context['mapId'], cinematic: true, + $command, $where, $context['map']->npcIds, $context['map']->description, cinematic: true, )]; $named = match ($type) { @@ -316,10 +316,20 @@ protected function walkCinematicCommands( } if ($type === 'parallel') { - foreach ((array) ($command['lanes'] ?? []) as $lane) { - $laneCommands = is_array($lane) && array_is_list($lane) ? $lane : (is_array($lane) ? (array) ($lane['commands'] ?? []) : []); - $issues = [...$issues, ...$this->walkCinematicCommands($laneCommands, $where, $known, $context, $commonEventStack)]; - } + // Lanes share the stage; only the map follows the Engine's lane timing. + [$laneIssues, $context['map']] = $this->walkParallelLanes( + $command, + $context['map'], + $context['npcIdsByMap'], + $commonEventStack, + function (array $laneCommands, ScriptMapState $laneStart) use (&$context, $where, $known, $commonEventStack): array { + $context['map'] = $laneStart; + $laneIssues = $this->walkCinematicCommands($laneCommands, $where, $known, $context, $commonEventStack); + + return [$laneIssues, $context['map']]; + }, + ); + $issues = [...$issues, ...$laneIssues]; } foreach (['then', 'else', 'cancel'] as $arm) { @@ -335,9 +345,7 @@ protected function walkCinematicCommands( } if ($type === 'transfer') { - $destination = trim(strval($command['map'] ?? '')); - $context['mapId'] = $destination !== '' ? $destination : $context['mapId']; - $context['npcIds'] = $destination !== '' && array_key_exists($destination, $context['npcIdsByMap']) ? $context['npcIdsByMap'][$destination] : null; + $context['map'] = $context['map']->transferTo(trim(strval($command['map'] ?? '')), $context['npcIdsByMap']); } } @@ -371,7 +379,7 @@ protected static function listRelativeSubjects(array $command): array * Event files, cast ids, current-map NPCs, and recursion safety. * * @param array $known - * @param array{castIds: string[], checkpoints: string[], npcIds: string[]|null, mapId: string|null, npcIdsByMap: array, seenCheckpoints: string[]} $context + * @param array{castIds: string[], checkpoints: string[], map: ScriptMapState, npcIdsByMap: array, seenCheckpoints: string[]} $context * @param string[] $commonEventStack * @return Issue[] */ @@ -420,7 +428,7 @@ protected function walkCinematicCommonEvent( * NPC on the map the cinematic is on. * * @param array $command - * @param array{castIds: string[], checkpoints: string[], npcIds: string[]|null, mapId: string|null, npcIdsByMap: array, seenCheckpoints: string[]} $context + * @param array{castIds: string[], checkpoints: string[], map: ScriptMapState, npcIdsByMap: array, seenCheckpoints: string[]} $context * @return Issue[] */ protected function checkCinematicSubject(array $command, string $where, array $context): array @@ -436,8 +444,8 @@ protected function checkCinematicSubject(array $command, string $where, array $c if ($kind === 'npc') { $npcId = trim(strval($fields['npcId'] ?? $fields['id'] ?? '')); - if ($npcId !== '' && $context['npcIds'] !== null && ! in_array($npcId, $context['npcIds'], true)) { - return [Issue::error($where, sprintf('It addresses NPC "%s", which is not on %s.', $npcId, $context['mapId'] ?? '(unknown map)'), 'Use a stable NPC id from the map the cinematic is on at that point.')]; + if ($npcId !== '' && $context['map']->npcIds !== null && ! in_array($npcId, $context['map']->npcIds, true)) { + return [Issue::error($where, sprintf('It addresses NPC "%s", which is not on %s.', $npcId, $context['map']->description ?? '(unknown map)'), 'Use a stable NPC id from the map the cinematic is on at that point.')]; } } @@ -445,7 +453,7 @@ protected function checkCinematicSubject(array $command, string $where, array $c } /** - * @param array{castIds: string[], checkpoints: string[], npcIds: string[]|null, mapId: string|null, npcIdsByMap: array, seenCheckpoints: string[]} $context + * @param array{castIds: string[], checkpoints: string[], map: ScriptMapState, npcIdsByMap: array, seenCheckpoints: string[]} $context * @return Issue[] */ protected function checkStagedActorReference(string $actorId, string $where, array $context): array diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index edca66d..42591d2 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -3373,7 +3373,7 @@ protected function checkCommands( array $commonEventStack = [], ): array { - $context = ['npcIds' => $npcIds, 'mapId' => $mapId, 'npcIdsByMap' => $npcIdsByMap]; + $context = ['map' => ScriptMapState::startOn($mapId, $npcIds), 'npcIdsByMap' => $npcIdsByMap]; return $this->walkEventCommands($commands, $where, $known, $context, $commonEventStack); } @@ -3383,14 +3383,13 @@ protected function checkCommands( * Independent entry points receive a fresh context through checkCommands(). * @param array $commands * @param array $known - * @param array{npcIds: string[]|null, mapId: string|null, npcIdsByMap: array} $context + * @param array{map: ScriptMapState, npcIdsByMap: array} $context * @param string[] $commonEventStack * @return Issue[] */ protected function walkEventCommands(array $commands, string $where, array $known, array &$context, array $commonEventStack = []): array { $issues = []; - $npcIdsByMap = $context['npcIdsByMap']; foreach ($commands as $command) { if (! is_array($command)) { @@ -3411,7 +3410,7 @@ protected function walkEventCommands(array $commands, string $where, array $know ); } - $issues = [...$issues, ...$this->checkSharedCommandSemantics($command, $where, $context['npcIds'], $context['mapId'])]; + $issues = [...$issues, ...$this->checkSharedCommandSemantics($command, $where, $context['map']->npcIds, $context['map']->description)]; $named = match ($type) { 'give_item' => ['inventory', 'item', 'item'], @@ -3488,20 +3487,19 @@ protected function walkEventCommands(array $commands, string $where, array $know } if ($type === 'parallel') { - foreach ((array) ($command['lanes'] ?? []) as $lane) { - if (! is_array($lane)) { - continue; - } + [$laneIssues, $context['map']] = $this->walkParallelLanes( + $command, + $context['map'], + $context['npcIdsByMap'], + $commonEventStack, + function (array $laneCommands, ScriptMapState $laneStart) use (&$context, $where, $known, $commonEventStack): array { + $context['map'] = $laneStart; + $laneIssues = $this->walkEventCommands($laneCommands, $where, $known, $context, $commonEventStack); - $laneCommands = array_is_list($lane) ? $lane : (array) ($lane['commands'] ?? []); - $issues = [...$issues, ...$this->walkEventCommands( - $laneCommands, - $where, - $known, - $context, - $commonEventStack, - )]; - } + return [$laneIssues, $context['map']]; + }, + ); + $issues = [...$issues, ...$laneIssues]; } // Alternative arms start on the same map, not at the end of a sibling arm. @@ -3523,19 +3521,12 @@ protected function walkEventCommands(array $commands, string $where, array $know if (CommandMapContext::canSkipArms($command)) { $branchContexts[] = $context; } - $this->mergeCommandMapContexts($context, $branchContexts); + // A command after a branch can address only NPCs present on every possible map. + $context['map'] = ScriptMapState::mergeStates(array_column($branchContexts, 'map')); } if ($type === 'transfer') { - $destination = trim(strval($command['map'] ?? '')); - - if ($destination !== '' && array_key_exists($destination, $npcIdsByMap)) { - $context['mapId'] = $destination; - $context['npcIds'] = $npcIdsByMap[$destination]; - } else { - $context['mapId'] = $destination !== '' ? $destination : $context['mapId']; - $context['npcIds'] = null; - } + $context['map'] = $context['map']->transferTo(trim(strval($command['map'] ?? '')), $context['npcIdsByMap']); } } @@ -3543,17 +3534,51 @@ protected function walkEventCommands(array $commands, string $where, array $know } /** - * A command after a branch can address only NPCs present on every possible map. - * Unknown map contents remain unknown rather than inventing an empty NPC list. - * @param array{mapId: string|null, npcIds: string[]|null, npcIdsByMap: array} $context - * @param array $branches + * Walks a parallel command's lanes as the Engine runs them: together, on + * the one current map, which a transfer in any lane changes for every lane. + * Each lane starts on the incoming map or any map a sibling lane transfers + * to, and its own transfers can be overtaken by a sibling's. The block ends + * where its transferring lanes can finish, or where it started when no + * lane transfers. + * + * @param array $command The parallel command. + * @param array $npcIdsByMap Each known map's NPCs. + * @param string[] $commonEventStack Reusable event scripts being followed. + * @param callable(array, ScriptMapState): array{Issue[], ScriptMapState} $walkLane Walks one lane from its start state, returning its issues and end state. + * @return array{Issue[], ScriptMapState} The lanes' issues and the state after the block. */ - protected function mergeCommandMapContexts(array &$context, array $branches): void + protected function walkParallelLanes( + array $command, + ScriptMapState $start, + array $npcIdsByMap, + array $commonEventStack, + callable $walkLane, + ): array { - $mapIds = array_unique(array_column($branches, 'mapId')); - $context['mapId'] = in_array(null, $mapIds, true) ? null : implode(' or ', $mapIds); - $npcLists = array_column($branches, 'npcIds'); - $context['npcIds'] = in_array(null, $npcLists, true) ? null : array_values(array_intersect(...$npcLists)); + $lanes = CommandMapContext::getLanes($command); + $findCommonEvent = fn(string $id): ?array => $this->commonEventScripts[$id] ?? null; + $destinations = array_map( + static fn(array $lane): array => CommandMapContext::listTransferDestinations($lane['commands'], $findCommonEvent, array_values($commonEventStack)), + $lanes, + ); + $issues = []; + $ends = []; + + foreach ($lanes as $index => $lane) { + $siblings = array_values(array_unique(array_merge([], ...array_values(array_diff_key($destinations, [$index => true]))))); + $laneStart = ScriptMapState::mergeStates([ + $start, + ...array_map(static fn(string $map): ScriptMapState => $start->arriveOn($map, $npcIdsByMap), $siblings), + ])->setConcurrentMaps([...$start->concurrentMaps, ...$siblings]); + [$laneIssues, $end] = $walkLane($lane['commands'], $laneStart); + $issues = [...$issues, ...$laneIssues]; + + if ($destinations[$index] !== []) { + $ends[] = $end; + } + } + + return [$issues, $ends !== [] ? ScriptMapState::mergeStates($ends)->setConcurrentMaps($start->concurrentMaps) : $start]; } /** diff --git a/src/Validation/ScriptMapState.php b/src/Validation/ScriptMapState.php new file mode 100644 index 0000000..a018931 --- /dev/null +++ b/src/Validation/ScriptMapState.php @@ -0,0 +1,104 @@ + $this->mapIds === null ? null : implode(' or ', $this->mapIds); + } + + /** + * @param list|null $mapIds The maps the script can be on, or null when unknown. + * @param list|null $npcIds The NPCs on every one of those maps, or null when unknown. + * @param list $concurrentMaps Maps a concurrent parallel lane can transfer to; an empty id names no map. + */ + public function __construct( + public readonly ?array $mapIds, + public readonly ?array $npcIds, + public readonly array $concurrentMaps = [], + ) + { + } + + /** + * The state of a script that starts on one map. + * + * @param string[]|null $npcIds The map's NPCs, or null when unknown. + */ + public static function startOn(?string $mapId, ?array $npcIds): self + { + return new self($mapId !== null ? [$mapId] : null, $npcIds !== null ? array_values($npcIds) : null); + } + + /** + * Joins states a script can be in at one point: any of their maps, and + * only the NPCs present on all of them. + * + * @param non-empty-list $states + */ + public static function mergeStates(array $states): self + { + $mapLists = array_map(static fn(self $state): ?array => $state->mapIds, $states); + $npcLists = array_map(static fn(self $state): ?array => $state->npcIds, $states); + + return new self( + in_array(null, $mapLists, true) ? null : array_values(array_unique(array_merge(...$mapLists))), + in_array(null, $npcLists, true) ? null : array_values(array_intersect(...$npcLists)), + array_values(array_unique(array_merge(...array_map(static fn(self $state): array => $state->concurrentMaps, $states)))), + ); + } + + /** + * The state after a transfer: on its destination, or on any map a + * concurrent lane can transfer to afterwards. + * + * @param array $npcIdsByMap Each known map's NPCs. + */ + public function transferTo(string $destination, array $npcIdsByMap): self + { + return self::mergeStates(array_map( + fn(string $map): self => $this->arriveOn($map, $npcIdsByMap), + [$destination, ...$this->concurrentMaps], + )); + } + + /** + * The state on arriving at one map. An empty map id names no map: the + * current maps stay, with unknown NPCs. + * + * @param array $npcIdsByMap Each known map's NPCs. + */ + public function arriveOn(string $map, array $npcIdsByMap): self + { + return new self( + $map !== '' ? [$map] : $this->mapIds, + $map !== '' && array_key_exists($map, $npcIdsByMap) ? array_values($npcIdsByMap[$map]) : null, + $this->concurrentMaps, + ); + } + + /** + * The same state with other maps a concurrent lane can transfer to. + * + * @param list $maps + */ + public function setConcurrentMaps(array $maps): self + { + return new self($this->mapIds, $this->npcIds, array_values(array_unique($maps))); + } +} diff --git a/tests/Unit/Cutscenes/CutsceneValidationTest.php b/tests/Unit/Cutscenes/CutsceneValidationTest.php index 63e4077..470877e 100644 --- a/tests/Unit/Cutscenes/CutsceneValidationTest.php +++ b/tests/Unit/Cutscenes/CutsceneValidationTest.php @@ -5,6 +5,7 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Validation\Issue; use Ichiloto\Editor\Validation\ProjectValidator; +use Ichiloto\Editor\Validation\ScriptMapState; /** * Project validation over cutscenes: paired folders, the Engine's own @@ -437,6 +438,83 @@ function insertHarbourCommands(string $root, array $commands): void ->and($issues[0]->message)->toContain('NPC id "destination-npc"'); }); +/** + * The Engine ticks parallel lanes together on the game's one current map, + * and a transfer in any lane moves every lane, so a lane can run on the map + * it started on or on any map a sibling lane transfers to. Lane order in the + * file does not decide which. + */ +function parallelLaneIssues(array $commands): array +{ + $validator = new ProjectValidator(); + $maps = [ + 'origin' => ['origin-npc', 'shared'], + 'destination' => ['destination-npc', 'shared'], + 'third' => ['third-npc', 'shared'], + ]; + + return issueMessages(new ReflectionMethod($validator, 'checkCommands')->invoke( + $validator, $commands, 'parallel context', ['maps' => array_keys($maps)], $maps['origin'], 'origin', $maps, + )); +} + +function parallelLaneRoute(string $npc): array +{ + return ['type' => 'move_route', 'subject' => 'npc', 'npcId' => $npc, 'steps' => [['direction' => 'right']]]; +} + +it('checks every legacy parallel lane against each map a sibling lane can move it to, in either lane order', function (bool $transferFirst) { + $transfer = [['type' => 'transfer', 'map' => 'destination', 'x' => 0, 'y' => 0]]; + $routes = [parallelLaneRoute('origin-npc'), parallelLaneRoute('shared')]; + $lanes = $transferFirst ? [$transfer, $routes] : [$routes, $transfer]; + + $messages = parallelLaneIssues([['type' => 'parallel', 'lanes' => $lanes]]); + + expect($messages)->toHaveCount(1) + ->and($messages[0])->toContain('NPC id "origin-npc", which is not on map "origin or destination"'); +})->with(['transfer lane first' => true, 'transfer lane last' => false]); + +it('checks every cinematic parallel lane against each map a sibling lane can move it to', function (bool $transferFirst) { + $validator = new ProjectValidator(); + $maps = ['origin' => ['origin-npc', 'shared'], 'destination' => ['destination-npc', 'shared']]; + $context = [ + 'castIds' => [], 'checkpoints' => [], 'map' => ScriptMapState::startOn('origin', $maps['origin']), + 'npcIdsByMap' => $maps, 'seenCheckpoints' => [], + ]; + $transfer = [['type' => 'transfer', 'map' => 'destination', 'x' => 0, 'y' => 0]]; + $routes = [parallelLaneRoute('origin-npc'), parallelLaneRoute('shared')]; + $walk = new ReflectionMethod($validator, 'walkCinematicCommands'); + $issues = $walk->invokeArgs($validator, [ + [['type' => 'parallel', 'lanes' => $transferFirst ? [$transfer, $routes] : [$routes, $transfer]], parallelLaneRoute('destination-npc')], + 'cinematic lanes', ['maps' => array_keys($maps)], &$context, + ]); + + expect(issueMessages($issues))->toBe(['cinematic lanes: It addresses NPC "origin-npc", which is not on origin or destination.']) + ->and($context['map']->mapIds)->toBe(['destination']); +})->with(['transfer lane first' => true, 'transfer lane last' => false]); + +it('leaves a legacy parallel block on any map its transferring lanes can finish on', function () { + $transfer = static fn(string $map): array => ['type' => 'transfer', 'map' => $map, 'x' => 0, 'y' => 0]; + + expect(parallelLaneIssues([ + ['type' => 'parallel', 'lanes' => [[parallelLaneRoute('shared')], ['commands' => [$transfer('destination')]]]], + parallelLaneRoute('destination-npc'), + ]))->toBe([]); + + $messages = parallelLaneIssues([ + ['type' => 'parallel', 'lanes' => [[$transfer('destination')], [$transfer('third')]]], + parallelLaneRoute('shared'), + parallelLaneRoute('third-npc'), + ]); + expect($messages)->toHaveCount(1) + ->and($messages[0])->toContain('NPC id "third-npc", which is not on map "destination or third"'); + + expect(parallelLaneIssues([ + ['type' => 'parallel', 'lanes' => [[parallelLaneRoute('shared')], [parallelLaneRoute('origin-npc')]]], + parallelLaneRoute('origin-npc'), + ]))->toBe([]); +}); + it('checks the map triggers that launch cinematics', function () { $root = cutsceneProject(); $mapData = <<<'PHP_SOURCE' diff --git a/tests/Unit/MapLineInsertionTest.php b/tests/Unit/MapLineInsertionTest.php index 5bc12ca..dc452d6 100644 --- a/tests/Unit/MapLineInsertionTest.php +++ b/tests/Unit/MapLineInsertionTest.php @@ -87,6 +87,31 @@ ->and(file_get_contents($root . '/assets/Events/home-only.php'))->toStartWith(" 'parallel', 'lanes' => [ + [['type' => 'wait', 'seconds' => 1.0], ['type' => 'transfer', 'map' => 'home', 'x' => 1, 'y' => 3]], + ['id' => 'walk', 'commands' => [['type' => 'move_player', 'x' => 2, 'y' => 3]]], + ]], + ['type' => 'move_player', 'x' => 1, 'y' => 3], + ]; + file_put_contents($path, "apply(); + $script = (require $path)['npcs'][0]['script']; + + // The second lane can run before or after the first lane's transfer. + expect(describeMapLineHandEdits($plan))->toContain('assets/Maps/town/town.data.php npcs.0.script.0.lanes.1.commands.0.y') + ->and($script[0]['lanes'][0][1])->toMatchArray(['map' => 'home', 'y' => 5]) + ->and($script[0]['lanes'][1]['commands'][0])->toMatchArray(['y' => 3]) + // Once every lane is done the player is on home. + ->and($script[1])->toBe(['type' => 'move_player', 'x' => 1, 'y' => 5]); +}); + it('reports what it cannot rewrite instead of guessing or flattening it', function () { $root = mapLineProject(); $shared = (string) file_get_contents($root . '/assets/Events/shared.php'); From 38003aa04053cd65cd2d507dac887418e2bdb17c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 19 Oct 2026 02:53:12 +0200 Subject: [PATCH 339/344] fix(conditions): read condition and write lines only as written, names escaped The strict decoders split on every colon and kept only the parts each type expects, so 'event:a & !event:b' was accepted as one event named 'a & !event' with ':b' silently lost, an unknown quest status became completed and an unreadable switch value became true. Conditions and writes now share one line grammar (WorldStateLine): entries split on unescaped ';', parts on unescaped ':', and a name or value escapes its backslash, ':' and ';', so any name the Engine accepts, such as the flag deckers:won, round-trips as event:deckers\:won. A part the type does not take, an unknown type, or a value the Engine would refuse (quest status, switch and quest flags, item quantity, variable operator) refuses the whole line, naming the entry and why, and nothing changes. Behaviour changes: lines that used to be quietly rewritten or truncated are now refused, and a colon inside a name must be escaped. The script guide documents the line. --- docs/guides/change-maps-from-a-script.md | 23 +++ src/Database/ConditionCodec.php | 152 ++++++++++++------ src/Database/WorldStateLine.php | 98 +++++++++++ src/Database/WorldWriteCodec.php | 102 ++++++++---- .../Unit/Session/EventRecordAuthoringTest.php | 2 +- tests/Unit/WorldStateLineTest.php | 96 +++++++++++ 6 files changed, 397 insertions(+), 76 deletions(-) create mode 100644 src/Database/WorldStateLine.php create mode 100644 tests/Unit/WorldStateLineTest.php diff --git a/docs/guides/change-maps-from-a-script.md b/docs/guides/change-maps-from-a-script.md index 5aeff96..d0c2ff0 100644 --- a/docs/guides/change-maps-from-a-script.md +++ b/docs/guides/change-maps-from-a-script.md @@ -126,6 +126,29 @@ or with the GUI editor open, these reads give every id a change needs: Layers are named by `id` in edits. An event row key looks like `{"target": "event", "marker": "C", "path": ["data", "loot"]}`. +## Condition And Write Lines + +A conditions or writes row takes one line. Entries are separated by `;`, +never `&` or a comma, and each entry is `type:name` plus the parts its type +takes: + +| Conditions | Writes | +| --- | --- | +| `[!]quest:id[:completed\|active]` | `quest:id[:confirm\|grant]` | +| `[!]switch:name[:true\|false]` | `switch:name[:true\|false]` | +| `[!]event:name` | `event:name` | +| `[!]map_event:map:marker` | `variable:name[:set\|add][:value]` | +| `[!]variable:name[:op[:value]]`, op one of `==` `!=` `>` `>=` `<` `<=` | | +| `[!]item:id[:quantity]`, `[!]key_item:id[:quantity]` | | + +A leading `!` negates a condition. A name or value that holds `:`, `;` or +`\` escapes it with a backslash: the event flag `deckers:won` is written +`event:deckers\:won` (in JSON, `"event:deckers\\:won"`). A line is read only +as written: a part its type does not take, an unknown type or a value the +Engine would refuse makes the row refuse the whole line, and nothing changes. +`event:a & !event:b` is refused, because the event condition takes nothing +after its name; write `event:a; !event:b`. + ## Which Request Makes Which Change | Change | Request | diff --git a/src/Database/ConditionCodec.php b/src/Database/ConditionCodec.php index db51726..72730a9 100644 --- a/src/Database/ConditionCodec.php +++ b/src/Database/ConditionCodec.php @@ -17,10 +17,13 @@ * representation of it, so every surface that edits conditions behaves * identically. * - * Wire form: entries separated by `;`, each `[!]type:name[:extras]`, where - * extras depend on the type (quest status, switch `false`, item quantity, - * variable `op:value`, or a map event's marker after its map id). A leading - * `!` negates. + * Wire form ({@see WorldStateLine}): entries separated by `;`, each + * `[!]type:name[:extras]`, where extras depend on the type (quest status, + * switch `false`, item quantity, variable `op:value`, or a map event's marker + * after its map id). A leading `!` negates. A name or value escapes its `\`, + * `:` and `;` with a backslash, so any name the Engine accepts round-trips. + * A line is read only as written: a part the type does not take, or a value + * the Engine would refuse, makes the entry unreadable, never rewritten. */ final class ConditionCodec { @@ -57,15 +60,15 @@ public static function findCarryIssue(mixed $conditions): ?string return sprintf('condition %d is %s, not an array', $index + 1, get_debug_type($condition)); } - if (self::decode(self::encode($condition)) === null) { - return sprintf('condition %d holds a shape the condition editor cannot carry', $index + 1); - } - $type = strval($condition['type'] ?? ''); if ($type === 'quest' && ! in_array($condition['status'] ?? 'completed', ['completed', 'active'], true)) { return sprintf('condition %d has a quest status the condition editor would rewrite', $index + 1); } + if (self::decode(self::encode($condition)) === null) { + return sprintf('condition %d holds a shape the condition editor cannot carry', $index + 1); + } + $extra = array_diff(array_keys($condition), ['type', 'name', 'negate', ...(self::CARRIED_KEYS[$type] ?? [])]); if ($extra !== []) { return sprintf('condition %d carries "%s", which the condition editor would drop', $index + 1, implode('", "', array_map(strval(...), $extra))); @@ -99,6 +102,9 @@ public static function encodeAll(array $conditions): string )); } + /** The operators a variable condition compares with, as the Engine's evaluator accepts them. */ + private const array VARIABLE_OPERATORS = ['==', '!=', '>', '>=', '<', '<=']; + /** * Encodes one condition as `[!]type:name[:extras]`. * @@ -108,8 +114,7 @@ public static function encodeAll(array $conditions): string public static function encode(array $condition): string { $type = strval($condition['type'] ?? ''); - $name = strval($condition['name'] ?? ''); - $parts = [$type, $name]; + $parts = [$type, strval($condition['name'] ?? '')]; switch ($type) { case 'quest': @@ -139,14 +144,13 @@ public static function encode(array $condition): string $negate = ($condition['negate'] ?? false) === true ? '!' : ''; - return $negate . implode(':', $parts); + return $negate . WorldStateLine::joinParts($parts); } /** - * Decodes the one-line form back into condition arrays. - * - * Unparseable segments are dropped rather than written back as garbage: - * a typo costs the author the condition, never the file. + * Decodes the one-line form back into condition arrays, leaving out an + * entry it cannot read: for showing a line this codec wrote. A line an + * author or a client submits is read by {@see decodeAllStrictly()}. * * @param string $value The encoded conditions. * @return array> @@ -155,7 +159,7 @@ public static function decodeAll(string $value): array { $conditions = []; - foreach (explode(';', $value) as $segment) { + foreach (WorldStateLine::splitEntries($value) as $segment) { $condition = self::decode($segment); if ($condition !== null) { @@ -167,87 +171,141 @@ public static function decodeAll(string $value): array } /** - * Decodes the one-line form, refusing it whole when any segment cannot - * be read: for an interface that submits the line as typed, where a - * dropped segment would be a condition lost without a word. + * Decodes the one-line form, refusing it whole when any entry cannot be + * read as written: for an interface that submits the line as typed, where + * a dropped or reinterpreted part would be a condition lost without a word. * * @param string $value The encoded conditions. * @return array> - * @throws InvalidArgumentException Naming the first unreadable segment. + * @throws InvalidArgumentException Naming the first unreadable entry and why. */ public static function decodeAllStrictly(string $value): array { $conditions = []; - foreach (explode(';', $value) as $segment) { + foreach (WorldStateLine::splitEntries($value) as $segment) { if (trim($segment) === '') { continue; } - $conditions[] = self::decode($segment) ?? throw new InvalidArgumentException(sprintf( - 'Condition "%s" cannot be read: write [!]type:name[:extras] with a type of %s.', - trim($segment), - implode(', ', self::types()), - )); + $condition = self::parse($segment); + if (is_string($condition)) { + throw new InvalidArgumentException(sprintf('Condition "%s" cannot be read: %s Nothing was changed.', trim($segment), $condition)); + } + $conditions[] = $condition; } return $conditions; } /** - * Decodes one `[!]type:name[:extras]` segment. + * Decodes one `[!]type:name[:extras]` entry. * - * @param string $segment The encoded segment. - * @return array|null The condition, or null when unparseable. + * @param string $segment The encoded entry. + * @return array|null The condition, or null when it cannot be read as written. */ public static function decode(string $segment): ?array { - $segment = trim($segment); + $condition = self::parse($segment); + + return is_array($condition) ? $condition : null; + } + /** + * Reads one entry exactly as written. + * + * @return array|string The condition, or why it cannot be read. + */ + private static function parse(string $segment): array|string + { + $segment = trim($segment); if ($segment === '') { - return null; + return 'it is empty.'; } $negate = str_starts_with($segment, '!'); - $segment = ltrim($segment, '!'); - $parts = array_map(trim(...), explode(':', $segment)); - $type = strtolower($parts[0] ?? ''); + if ($negate) { + $segment = ltrim(substr($segment, 1)); + } + try { + $parts = WorldStateLine::splitParts($segment); + } catch (InvalidArgumentException $error) { + return $error->getMessage(); + } + $type = strtolower($parts[0]); $name = $parts[1] ?? ''; - if ($name === '' || ! in_array($type, self::types(), true)) { - return null; + if (! in_array($type, self::types(), true)) { + return sprintf('write [!]type:name[:extras] with a type of %s.', implode(', ', self::types())); + } + if ($name === '') { + return sprintf('the %s condition needs a name.', $type); + } + + [$extras, $allowed] = [array_slice($parts, 2), match ($type) { + 'quest', 'switch', 'item', 'key_item' => 1, + 'map_event' => 1, + 'variable' => 2, + default => 0, + }]; + if (count($extras) > $allowed) { + return sprintf('the %s condition takes %s after its name, not "%s"; write a colon or semicolon inside a name as \\: or \\;.', + $type, match ($type) { + 'quest' => 'at most a status (completed or active)', + 'switch' => 'at most true or false', + 'item', 'key_item' => 'at most a quantity', + 'map_event' => 'only its event marker', + 'variable' => 'at most an operator and a value', + default => 'nothing', + }, implode(':', array_slice($extras, $allowed))); } $condition = ['type' => $type, 'name' => $name]; switch ($type) { case 'quest': - $status = strtolower($parts[2] ?? 'completed'); - $condition['status'] = in_array($status, ['completed', 'active'], true) ? $status : 'completed'; + $status = strtolower($extras[0] ?? 'completed'); + if (! in_array($status, ['completed', 'active'], true)) { + return sprintf('a quest status is completed or active, not "%s".', $extras[0]); + } + $condition['status'] = $status; break; case 'switch': - if (strtolower($parts[2] ?? 'true') === 'false') { + $state = strtolower($extras[0] ?? 'true'); + if (! in_array($state, ['true', 'false'], true)) { + return sprintf('a switch is true or false, not "%s".', $extras[0]); + } + if ($state === 'false') { $condition['value'] = false; } break; case 'item': case 'key_item': - if (intval($parts[2] ?? 1) > 1) { - $condition['quantity'] = intval($parts[2]); + if (isset($extras[0])) { + if (filter_var($extras[0], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) === false) { + return sprintf('a quantity is a whole number above zero, not "%s".', $extras[0]); + } + if ((int) $extras[0] > 1) { + $condition['quantity'] = (int) $extras[0]; + } } break; case 'variable': - $condition['op'] = $parts[2] ?? '=='; - $rawValue = $parts[3] ?? '0'; - $condition['value'] = is_numeric($rawValue) ? intval($rawValue) : $rawValue; + $operator = $extras[0] ?? '=='; + if (! in_array($operator, self::VARIABLE_OPERATORS, true)) { + return sprintf('a variable compares with %s, not "%s".', implode(' ', self::VARIABLE_OPERATORS), $operator); + } + $condition['op'] = $operator; + $rawValue = $extras[1] ?? '0'; + $condition['value'] = is_numeric($rawValue) ? $rawValue + 0 : $rawValue; break; case 'map_event': - // A map event is named by its map and its one-shot event marker; without a marker it is unreadable. - $marker = $parts[2] ?? ''; + // A map event is named by its map and its one-shot event marker. + $marker = $extras[0] ?? ''; if ($marker === '') { - return null; + return 'a map event condition needs its map and its event marker: map_event:map:marker.'; } $condition['marker'] = $marker; break; diff --git a/src/Database/WorldStateLine.php b/src/Database/WorldStateLine.php new file mode 100644 index 0000000..d81b7ef --- /dev/null +++ b/src/Database/WorldStateLine.php @@ -0,0 +1,98 @@ + + */ + public static function splitEntries(string $line): array + { + return self::splitAt($line, ';'); + } + + /** + * Splits one entry into its parts at every unescaped `:`, each trimmed and + * unescaped. + * + * @return list + * @throws InvalidArgumentException When a backslash escapes nothing it may. + */ + public static function splitParts(string $entry): array + { + return array_map(static fn(string $part): string => self::unescape(trim($part)), self::splitAt($entry, ':')); + } + + /** Writes one part with its `\`, `:` and `;` escaped, so it reads back whole. */ + public static function escape(string $part): string + { + return strtr($part, ['\\' => '\\\\', ':' => '\\:', ';' => '\\;']); + } + + /** + * Joins parts into one entry, each escaped. + * + * @param list $parts + */ + public static function joinParts(array $parts): string + { + return implode(':', array_map(self::escape(...), $parts)); + } + + /** @return list */ + private static function splitAt(string $text, string $separator): array + { + $pieces = []; + $current = ''; + $length = strlen($text); + for ($index = 0; $index < $length; $index++) { + $character = $text[$index]; + if ($character === '\\' && $index + 1 < $length) { + // An escaped character stays escaped for the part to unescape. + $current .= $character . $text[++$index]; + continue; + } + if ($character === $separator) { + $pieces[] = $current; + $current = ''; + continue; + } + $current .= $character; + } + $pieces[] = $current; + + return $pieces; + } + + /** @throws InvalidArgumentException When a backslash escapes nothing it may. */ + private static function unescape(string $part): string + { + return (string) preg_replace_callback('/\\\\(.?)/s', static function (array $match): string { + if (! in_array($match[1], ['\\', ':', ';'], true)) { + throw new InvalidArgumentException(sprintf('"\\%s" escapes nothing; write \\: for a colon, \\; for a semicolon and \\\\ for a backslash.', $match[1])); + } + + return $match[1]; + }, $part); + } +} diff --git a/src/Database/WorldWriteCodec.php b/src/Database/WorldWriteCodec.php index d793979..023cb6a 100644 --- a/src/Database/WorldWriteCodec.php +++ b/src/Database/WorldWriteCodec.php @@ -19,8 +19,11 @@ * variable:name[:set|add][:v] -> ['type' => 'variable', 'name', 'op', 'value'] * quest:id[:confirm|grant] -> ['type' => 'quest', 'name', 'confirm'] * - * Entries are separated by `;`. Unparseable segments are dropped rather than - * written back as garbage. + * Entries are separated by `;` ({@see WorldStateLine}); a name or value + * escapes its backslash, `:` and `;` with a backslash, so any name the Engine + * accepts round-trips. A line is read only as written: a part the type does + * not take, or a value it cannot be, makes the entry unreadable, never + * rewritten. * * @package Ichiloto\Editor\Database */ @@ -125,11 +128,13 @@ public static function encode(array $set): string break; } - return implode(':', $parts); + return WorldStateLine::joinParts($parts); } /** - * Decodes the one-line form back into write arrays. + * Decodes the one-line form back into write arrays, leaving out an entry + * it cannot read: for showing a line this codec wrote. A line an author or + * a client submits is read by {@see decodeAllStrictly()}. * * @param string $value The encoded writes. * @return array> The writes. @@ -138,7 +143,7 @@ public static function decodeAll(string $value): array { $sets = []; - foreach (explode(';', $value) as $segment) { + foreach (WorldStateLine::splitEntries($value) as $segment) { $set = self::decode($segment); if ($set !== null) { @@ -150,66 +155,107 @@ public static function decodeAll(string $value): array } /** - * Decodes the one-line form, refusing it whole when any segment cannot - * be read, so a typed write is never dropped without a word. + * Decodes the one-line form, refusing it whole when any entry cannot be + * read as written, so a typed write is never dropped or reinterpreted + * without a word. * * @param string $value The encoded writes. * @return array> The writes. - * @throws \InvalidArgumentException Naming the first unreadable segment. + * @throws \InvalidArgumentException Naming the first unreadable entry and why. */ public static function decodeAllStrictly(string $value): array { $sets = []; - foreach (explode(';', $value) as $segment) { + foreach (WorldStateLine::splitEntries($value) as $segment) { if (trim($segment) === '') { continue; } - $sets[] = self::decode($segment) ?? throw new \InvalidArgumentException(sprintf( - 'Write "%s" cannot be read: write type:name[:extras] with a type of %s.', - trim($segment), - implode(', ', self::TYPES), - )); + $set = self::parse($segment); + if (is_string($set)) { + throw new \InvalidArgumentException(sprintf('Write "%s" cannot be read: %s Nothing was changed.', trim($segment), $set)); + } + $sets[] = $set; } return $sets; } /** - * Decodes one segment. + * Decodes one entry. * - * @param string $segment The segment. - * @return array|null The write, or null when unparseable. + * @param string $segment The entry. + * @return array|null The write, or null when it cannot be read as written. */ public static function decode(string $segment): ?array { - $parts = array_map(trim(...), explode(':', trim($segment))); - $type = strtolower($parts[0] ?? ''); + $set = self::parse($segment); + + return is_array($set) ? $set : null; + } + + /** + * Reads one entry exactly as written. + * + * @return array|string The write, or why it cannot be read. + */ + private static function parse(string $segment): array|string + { + if (trim($segment) === '') { + return 'it is empty.'; + } + try { + $parts = WorldStateLine::splitParts(trim($segment)); + } catch (\InvalidArgumentException $error) { + return $error->getMessage(); + } + $type = strtolower($parts[0]); $name = $parts[1] ?? ''; - if ($name === '' || ! in_array($type, self::TYPES, true)) { - return null; + if (! in_array($type, self::TYPES, true)) { + return sprintf('write type:name[:extras] with a type of %s.', implode(', ', self::TYPES)); + } + if ($name === '') { + return sprintf('the %s write needs a name.', $type); + } + $extras = array_slice($parts, 2); + $allowed = ['switch' => 1, 'event' => 0, 'variable' => 2, 'quest' => 1][$type]; + if (count($extras) > $allowed) { + return sprintf('the %s write takes %s after its name, not "%s"; write a colon or semicolon inside a name as \\: or \\;.', + $type, ['switch' => 'at most true or false', 'event' => 'nothing', 'variable' => 'at most set or add and a value', + 'quest' => 'at most confirm or grant'][$type], implode(':', array_slice($extras, $allowed))); } $set = ['type' => $type, 'name' => $name]; switch ($type) { case 'switch': - if (isset($parts[2])) { - $set['value'] = strtolower($parts[2]) !== 'false'; + if (isset($extras[0])) { + $state = strtolower($extras[0]); + if (! in_array($state, ['true', 'false'], true)) { + return sprintf('a switch is set true or false, not "%s".', $extras[0]); + } + $set['value'] = $state === 'true'; } break; case 'variable': - $op = strtolower($parts[2] ?? 'set'); - $set['op'] = $op === 'add' ? 'add' : 'set'; - $rawValue = $parts[3] ?? ($op === 'add' ? '1' : '0'); + $op = strtolower($extras[0] ?? 'set'); + if (! in_array($op, ['set', 'add'], true)) { + return sprintf('a variable write is set or add, not "%s".', $extras[0]); + } + $set['op'] = $op; + $rawValue = $extras[1] ?? ($op === 'add' ? '1' : '0'); $set['value'] = is_numeric($rawValue) ? $rawValue + 0 : $rawValue; break; case 'quest': - if (isset($parts[2])) { - $set['confirm'] = strtolower($parts[2]) !== 'grant'; + if (isset($extras[0])) { + $mode = strtolower($extras[0]); + if (! in_array($mode, ['confirm', 'grant'], true)) { + return sprintf('a quest write is confirm or grant, not "%s".', $extras[0]); + } + $set['confirm'] = $mode === 'confirm'; } break; diff --git a/tests/Unit/Session/EventRecordAuthoringTest.php b/tests/Unit/Session/EventRecordAuthoringTest.php index 55f9371..75349ef 100644 --- a/tests/Unit/Session/EventRecordAuthoringTest.php +++ b/tests/Unit/Session/EventRecordAuthoringTest.php @@ -93,7 +93,7 @@ function eventRecordHost(string $root): Closure expect(fn() => $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'conditions')['key'], 'switch:open; nonsense')) ->toThrow(SessionRefusal::class, 'Condition "nonsense" cannot be read') ->and(fn() => $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'conditions')['key'], 'variable:count:~:3')) - ->toThrow(SessionRefusal::class, 'unsupported variable operator "~"') + ->toThrow(SessionRefusal::class, 'a variable compares with == != > >= < <=, not "~"') ->and(fn() => $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'variant0Sets')['key'], 'teleport:home')) ->toThrow(SessionRefusal::class, 'Write "teleport:home" cannot be read'); diff --git a/tests/Unit/WorldStateLineTest.php b/tests/Unit/WorldStateLineTest.php new file mode 100644 index 0000000..7ee218e --- /dev/null +++ b/tests/Unit/WorldStateLineTest.php @@ -0,0 +1,96 @@ + ConditionCodec::decodeAllStrictly('event:a & !event:b')) + ->toThrow(InvalidArgumentException::class, + 'Condition "event:a & !event:b" cannot be read: the event condition takes nothing after its name, not "b"'); +}); + +it('refuses the malformed line through the NPC session whole, leaving the NPC conditions and the file as they were', function () { + $conditions = [['type' => 'event', 'name' => 'harvest-done']]; + $root = npcSessionProject([['id' => 'keeper', 'name' => 'Keeper', 'sprite' => '@', 'x' => 2, 'y' => 1, 'conditions' => $conditions]]); + $before = sourceHashTree($root . '/assets'); + $session = EditorSession::open($root); + $read = $session->readNpc('test-map', 0); + + expect(fn() => $session->applyNpc('test-map', $read['revision'], 0, npcSessionRow($read, 'conditions')['key'], 'event:a & !event:b')) + ->toThrow(SessionRefusal::class, 'cannot be read') + ->and(getSessionMap($session)->getNpcs()->all()[0]->toArray()['conditions'])->toBe($conditions) + ->and($session->listUnsavedChanges())->toBe([]) + ->and(sourceHashTree($root . '/assets'))->toBe($before); +}); + +it('round-trips every condition type through the line, names with colons, semicolons and backslashes included', function () { + $conditions = [ + ['type' => 'quest', 'name' => 'harvest', 'status' => 'active'], + ['type' => 'quest', 'name' => 'harvest', 'status' => 'completed', 'negate' => true], + ['type' => 'switch', 'name' => 'gate:open', 'value' => false], + ['type' => 'event', 'name' => 'deckers:won'], + ['type' => 'event', 'name' => 'a & !event; b \\ c'], + ['type' => 'map_event', 'name' => 'town/square', 'marker' => 'B'], + ['type' => 'variable', 'name' => 'gold', 'op' => '>=', 'value' => 5], + ['type' => 'variable', 'name' => 'mood', 'op' => '!=', 'value' => 'calm'], + ['type' => 'item', 'name' => 'item.tonic', 'quantity' => 3], + ['type' => 'key_item', 'name' => 'item.key'], + ]; + $line = ConditionCodec::encodeAll($conditions); + + expect(ConditionCodec::decodeAllStrictly($line))->toBe($conditions) + ->and(ConditionCodec::decodeAll($line))->toBe($conditions) + ->and(ConditionCodec::encode(['type' => 'event', 'name' => 'deckers:won']))->toBe('event:deckers\\:won') + ->and(ConditionCodec::decodeAllStrictly('event:deckers\\:won'))->toBe([['type' => 'event', 'name' => 'deckers:won']]) + ->and(ConditionCodec::findCarryIssue($conditions))->toBeNull(); + // What the line yields is what the Engine accepts. + WorldConditionEvaluator::validateAll(ConditionCodec::decodeAllStrictly($line)); +}); + +it('refuses a condition value the Engine would refuse instead of rewriting it', function (string $line, string $reason) { + expect(fn() => ConditionCodec::decodeAllStrictly($line))->toThrow(InvalidArgumentException::class, $reason) + ->and(ConditionCodec::decodeAll($line))->toBe([]); +})->with([ + 'a quest status' => ['quest:harvest:failed', 'a quest status is completed or active, not "failed"'], + 'a switch value' => ['switch:gate:maybe', 'a switch is true or false, not "maybe"'], + 'a zero quantity' => ['item:item.tonic:0', 'a quantity is a whole number above zero, not "0"'], + 'a word quantity' => ['item:item.tonic:many', 'a quantity is a whole number above zero, not "many"'], + 'a variable operator' => ['variable:gold:~:3', 'a variable compares with == != > >= < <=, not "~"'], + 'a map event marker' => ['map_event:town/square', 'needs its map and its event marker'], + 'a fourth part' => ['quest:harvest:active:twice', 'takes at most a status'], + 'an unknown type' => ['flag:open', 'with a type of'], + 'a double negation' => ['!!switch:gate', 'with a type of'], + 'an unknown escape' => ['event:a\\b', '"\\b" escapes nothing'], + 'a trailing backslash' => ['event:a\\', 'escapes nothing'], +]); + +it('round-trips every world write type through the line and refuses parts and values it cannot be', function () { + $sets = [ + ['type' => 'switch', 'name' => 'gate:open', 'value' => true], + ['type' => 'event', 'name' => 'deckers:won'], + ['type' => 'variable', 'name' => 'gold', 'op' => 'add', 'value' => 5], + ['type' => 'quest', 'name' => 'harvest', 'confirm' => false], + ]; + $line = WorldWriteCodec::encodeAll($sets); + + expect(WorldWriteCodec::decodeAllStrictly($line))->toBe($sets) + ->and(WorldWriteCodec::findCarryIssue($sets))->toBeNull() + ->and(fn() => WorldWriteCodec::decodeAllStrictly('event:a & event:b'))->toThrow(InvalidArgumentException::class, 'the event write takes nothing after its name, not "b"') + ->and(fn() => WorldWriteCodec::decodeAllStrictly('switch:gate:maybe'))->toThrow(InvalidArgumentException::class, 'a switch is set true or false, not "maybe"') + ->and(fn() => WorldWriteCodec::decodeAllStrictly('variable:gold:mul:2'))->toThrow(InvalidArgumentException::class, 'a variable write is set or add, not "mul"') + ->and(fn() => WorldWriteCodec::decodeAllStrictly('quest:harvest:offer'))->toThrow(InvalidArgumentException::class, 'a quest write is confirm or grant, not "offer"'); +}); + +it('encodes a colon-bearing name for a graphical editor and reads it back whole', function () { + $session = EditorSession::open(makeTemporaryProject()); + + $encoded = $session->encodeWorldState('conditions', [['type' => 'event', 'name' => 'deckers:won'], ['type' => 'switch', 'name' => 'lamp', 'value' => true]]); + + expect($encoded['line'])->toBe('event:deckers\\:won; switch:lamp') + ->and(ConditionCodec::decodeAllStrictly($encoded['line']))->toBe([['type' => 'event', 'name' => 'deckers:won'], ['type' => 'switch', 'name' => 'lamp']]); +}); From b178b4bfa6220e84d1b22c0873066f4f96acac9a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 19 Oct 2026 17:39:45 +0200 Subject: [PATCH 340/344] fix(npc): let exclusive phases share a tile and read structured references Two NPCs may anchor on one tile only when their visibility conditions provably exclude each other: one holds a condition the other negates, read as the Engine's WorldConditionEvaluator runs it (event and !event, switch and switch:false, variable == and != one value). Creating, moving or editing the conditions of an NPC checks every NPC on its tile, so an edit that would show two bodies there together is refused with nothing changed. Validation applies the same rule, and Enter in the terminal editor steps through the phases on a tile. NPC reference walkers read kind, subject and ids only as text: a structured subject such as {kind: npc, id} is a reference read where the walk reaches it, and only a common_event command's id names a script. Duplicating and renaming no longer raise "Array to string conversion". --- docs/guides/change-maps-from-a-script.md | 11 ++ src/Database/WorldConditionExclusion.php | 128 +++++++++++++++ src/Editor.php | 8 +- src/Field/NpcAuthoring.php | 66 +++++--- src/Field/NpcCollection.php | 24 ++- src/Field/NpcReferences.php | 32 ++-- src/Validation/ProjectValidator.php | 13 +- tests/Unit/NpcAuthoringRulesTest.php | 8 +- tests/Unit/NpcAuthoringTest.php | 20 +++ tests/Unit/Session/NpcPhaseSessionTest.php | 177 +++++++++++++++++++++ tests/Unit/Session/NpcSessionTest.php | 4 +- tests/Unit/WorldConditionExclusionTest.php | 50 ++++++ 12 files changed, 499 insertions(+), 42 deletions(-) create mode 100644 src/Database/WorldConditionExclusion.php create mode 100644 tests/Unit/Session/NpcPhaseSessionTest.php create mode 100644 tests/Unit/WorldConditionExclusionTest.php diff --git a/docs/guides/change-maps-from-a-script.md b/docs/guides/change-maps-from-a-script.md index d0c2ff0..c24900d 100644 --- a/docs/guides/change-maps-from-a-script.md +++ b/docs/guides/change-maps-from-a-script.md @@ -176,6 +176,17 @@ after its name; write `event:a; !event:b`. | Landmark id | `inspector.apply` with `{target: map-landmarks, index, field: id}`; other maps' atlas parents follow the rename | | NPC id | `npc.create {..., id?}`, `npc.assignId {..., id?}`, or the NPC's Id row with `npc.apply` | +Two NPCs may anchor on one tile only as phases the game never shows +together: one has a visibility condition (its Visible When row) that the +other negates, such as `!event:evidence` and `event:evidence`, or +`switch:door` and `switch:door:false`. Variable `==` and `!=` against the +same value count too. Nothing else does, however disjoint it looks: quest +statuses, variable ranges and item counts can hold together. So give the +phases their conditions first, then move one onto the other's tile. A move, +create or conditions edit that would leave two NPCs visible on one tile at +once is refused with nothing changed, and validation reports such a tile. +In the terminal editor, Enter on that tile steps through its phases. + `worldObjects.move` moves an object with what shows it in the terminal: its anchor, the cells it covers and their glyphs, with the tiles those glyphs draw. Moving only the anchor (`worldObjects.place`) leaves its glyphs behind. diff --git a/src/Database/WorldConditionExclusion.php b/src/Database/WorldConditionExclusion.php new file mode 100644 index 0000000..06c2c09 --- /dev/null +++ b/src/Database/WorldConditionExclusion.php @@ -0,0 +1,128 @@ + $conditions One list. + * @param array $others The other list. + * @return bool True when one list holds a complement of a condition in the other. + */ + public static function proveExclusive(array $conditions, array $others): bool + { + $expected = []; + + foreach ($conditions as $condition) { + $test = self::readTest($condition); + + if ($test !== null) { + $expected[$test[0]][$test[1] ? 1 : 0] = true; + } + } + + foreach ($others as $condition) { + $test = self::readTest($condition); + + if ($test !== null && isset($expected[$test[0]][$test[1] ? 0 : 1])) { + return true; + } + } + + return false; + } + + /** + * Reads a condition as the test the Engine runs and the result it + * requires, or null when it proves nothing. + * + * @return array{0: string, 1: bool}|null The test's key and the result the condition holds on. + */ + private static function readTest(mixed $condition): ?array + { + if (! is_array($condition) || ! is_scalar($condition['type'] ?? null) || ! is_scalar($condition['name'] ?? null)) { + return null; + } + + $type = WorldConditionType::tryFrom(strval($condition['type'])); + $name = trim(strval($condition['name'])); + + if (! $type instanceof WorldConditionType || $name === '') { + return null; + } + + $holdsOn = ! (bool) ($condition['negate'] ?? false); + $parameters = match ($type) { + WorldConditionType::SWITCH, WorldConditionType::EVENT, WorldConditionType::KEY_ITEM => [], + WorldConditionType::MAP_EVENT => is_string($condition['marker'] ?? null) && trim($condition['marker']) !== '' + ? [trim($condition['marker'])] + : null, + WorldConditionType::ITEM => is_scalar($condition['quantity'] ?? 1) ? [max(1, intval($condition['quantity'] ?? 1))] : null, + WorldConditionType::QUEST => is_scalar($condition['status'] ?? 'completed') ? [strval($condition['status'] ?? 'completed')] : null, + WorldConditionType::VARIABLE => self::readVariableParameters($condition, $holdsOn), + }; + + if ($parameters === null) { + return null; + } + + if ($type === WorldConditionType::SWITCH) { + // A switch test compares the stored value with the expected one, + // so `switch:x:false` holds exactly when `switch:x` does not. + $holdsOn = $holdsOn === (bool) ($condition['value'] ?? true); + } + + return [json_encode([$type->value, $name, ...$parameters], JSON_THROW_ON_ERROR), $holdsOn]; + } + + /** + * Reads a variable comparison as an `==` test, folding `!=` into the + * result it requires, or null for an ordering it cannot complement. + * + * @param array $condition The condition. + * @param bool $holdsOn The result it requires, updated for `!=`. + * @return array|null The test's parameters. + */ + private static function readVariableParameters(array $condition, bool &$holdsOn): ?array + { + $operator = $condition['op'] ?? '=='; + $value = $condition['value'] ?? 0; + + if (! is_scalar($value) || ! in_array($operator, ['==', '!='], true)) { + return null; + } + + if ($operator === '!=') { + $holdsOn = ! $holdsOn; + } + + return ['==', $value]; + } +} diff --git a/src/Editor.php b/src/Editor.php index f82c328..0715886 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -2179,10 +2179,12 @@ private function handleNpcModeInput(string $input): bool return true; } - $under = $map->getNpcs()->indexAt($this->cursorX, $this->cursorY); + $under = $map->getNpcs()->indicesAt($this->cursorX, $this->cursorY); - if ($under !== null) { - $this->selectNpc($under); + if ($under !== []) { + // Phases of one presence share a tile: Enter steps through them. + $current = array_search($this->selectedNpcIndex, $under, true); + $this->selectNpc($under[$current === false ? 0 : ($current + 1) % count($under)]); $this->setStatus(sprintf('Selected %s.', $this->describeSelectedNpc())); $this->renderCanvasArea(); diff --git a/src/Field/NpcAuthoring.php b/src/Field/NpcAuthoring.php index 7a51604..305c4bb 100644 --- a/src/Field/NpcAuthoring.php +++ b/src/Field/NpcAuthoring.php @@ -9,6 +9,7 @@ use Ichiloto\Editor\Database\RecordItem; use Ichiloto\Editor\Database\RecordRefusal; use Ichiloto\Editor\Database\Slug; +use Ichiloto\Editor\Database\WorldConditionExclusion; use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\MapSourceRefusal; @@ -56,15 +57,16 @@ public function __construct(ProjectWorkspace $workspace) * * @param string $name The display name; blank takes {@see DEFAULT_NAME}. * @param string|null $id The id; null derives one from the name. - * @throws NpcRefusal When the tile is outside the map or another NPC anchors there, or the id is not one or is taken. + * @throws NpcRefusal When the tile is outside the map or another NPC anchors there while both can be visible, or the id is not one or is taken. * @throws MapSourceRefusal When the map's source cannot take the change. */ public function create(ProjectMap $map, int $x, int $y, string $name, ?string $id = null): NpcChange { $before = $map->getNpcs(); - $this->assertPlaceable($map, $before, null, $x, $y); + $this->assertInsideMap($map, $x, $y); $name = trim($name) === '' ? self::DEFAULT_NAME : trim($name); $npc = ProjectNpc::createAt($id === null ? $before->uniqueIdFor($name) : self::requireChosenId($before, null, $id), $name, $x, $y); + $this->assertPlaceable($map, $before, null, $npc); $after = $before->withAdded($npc); $map->setNpcs($after); @@ -75,7 +77,7 @@ public function create(ProjectMap $map, int $x, int $y, string $name, ?string $i * Moves an NPC's anchor to a tile. A move to where it already stands * changes nothing. * - * @throws NpcRefusal When there is no such NPC, the tile is outside the map, or another NPC anchors there. + * @throws NpcRefusal When there is no such NPC, the tile is outside the map, or another NPC anchors there while both can be visible. * @throws MapSourceRefusal When the map's source cannot take the change. */ public function move(ProjectMap $map, int $index, int $x, int $y): NpcChange @@ -87,8 +89,8 @@ public function move(ProjectMap $map, int $index, int $x, int $y): NpcChange return new NpcChange(null, $index, $npc); } - $this->assertPlaceable($map, $before, $index, $x, $y); $moved = $npc->movedTo($x, $y); + $this->assertPlaceable($map, $before, $index, $moved); $after = $before->withReplaced($index, $moved); $map->setNpcs($after); @@ -110,7 +112,7 @@ public function duplicate(ProjectMap $map, int $index): NpcChange $copy = $npc->asCopyWithId($before->uniqueIdFor($npc->getName())); $x = $npc->getX() + $npc->getSpriteWidth(); - if ($x < $map->getWidth() && $before->indexAt($x, $npc->getY()) === null) { + if ($x < $map->getWidth() && $before->indicesAt($x, $npc->getY()) === []) { $copy = $copy->movedTo($x, $npc->getY()); } @@ -217,11 +219,12 @@ public function setId(ProjectMap $map, int $index, string $id): NpcChange * id; otherwise the id stays and the change says what names it. The * rename and its id are one undo step. The Id row changes the id as * {@see setId()} does. The X and Y rows move the NPC, under the same tile - * rules as {@see move()}. + * rules as {@see move()}, and so does a visibility edit to an NPC sharing + * its tile with another. * * @param array $framePath The frame the row belongs to; [] for the NPC itself. * @param array $field The row, as {@see NpcInspector::getFields()} gave it. - * @throws NpcRefusal When there is no such NPC or frame, or a coordinate leaves the map or lands on another NPC. + * @throws NpcRefusal When there is no such NPC or frame, a coordinate leaves the map, or a move or visibility edit leaves two NPCs on one tile visible at once. * @throws MapSourceRefusal When the map's source cannot take the change. */ public function applyField(NpcInspector $inspector, int $index, array $framePath, array $field, string $rawValue): NpcChange @@ -254,10 +257,12 @@ public function applyField(NpcInspector $inspector, int $index, array $framePath $placed = $after->get($index); $was = $before->get($index); - if ($framePath === [] && in_array($fieldId, ['x', 'y'], true) && $placed !== null && $was !== null - && ($placed->getX() !== $was->getX() || $placed->getY() !== $was->getY())) { - // A coordinate row is a move: the tile rules a move keeps apply. - $this->assertPlaceable($map, $before, $index, $placed->getX(), $placed->getY()); + if ($placed !== null && $was !== null && ($placed->getX() !== $was->getX() || $placed->getY() !== $was->getY() + || $placed->getConditions() !== $was->getConditions())) { + // A coordinate row is a move, and a visibility edit can make + // phases sharing a tile visible together: the tile rules a + // move keeps apply to both. + $this->assertPlaceable($map, $before, $index, $placed); } if ($framePath === [] && $fieldId === 'name') { @@ -425,21 +430,44 @@ private static function requireChosenId(NpcCollection $npcs, ?int $ownIndex, str } /** - * Refuses a tile outside the map or one another NPC anchors to. + * Refuses a tile outside the map, or one another NPC anchors to while + * both can be visible. + * + * The game shows an NPC only while its visibility conditions hold, so + * two NPCs may share a tile as phases of one presence when their + * conditions provably exclude each other ({@see WorldConditionExclusion}). + * Any other pair would be two bodies on one tile. * * @param int|null $ownIndex The NPC being placed, when it is already on the map. + * @param ProjectNpc $npc The NPC as it would stand, conditions included. * @throws NpcRefusal */ - private function assertPlaceable(ProjectMap $map, NpcCollection $npcs, ?int $ownIndex, int $x, int $y): void + private function assertPlaceable(ProjectMap $map, NpcCollection $npcs, ?int $ownIndex, ProjectNpc $npc): void { - if ($x < 0 || $y < 0 || $x >= $map->getWidth() || $y >= $map->getHeight()) { - throw new NpcRefusal(sprintf('%d,%d is outside the map.', $x, $y)); - } + $x = $npc->getX(); + $y = $npc->getY(); + $this->assertInsideMap($map, $x, $y); - $occupant = $npcs->indexAt($x, $y); + foreach ($npcs->indicesAt($x, $y) as $occupant) { + $other = $npcs->get($occupant); - if ($occupant !== null && $occupant !== $ownIndex) { - throw new NpcRefusal(sprintf('%s already stands at %d,%d.', $npcs->get($occupant)?->getName() ?? 'An NPC', $x, $y)); + if ($occupant === $ownIndex || $other === null + || WorldConditionExclusion::proveExclusive($npc->getConditions(), $other->getConditions())) { + continue; + } + + throw new NpcRefusal(sprintf( + '%s already stands at %d,%d while both can be visible. Give one a condition the other negates, such as event:x and !event:x, to share the tile.', + $other->getName(), $x, $y, + )); + } + } + + /** @throws NpcRefusal */ + private function assertInsideMap(ProjectMap $map, int $x, int $y): void + { + if ($x < 0 || $y < 0 || $x >= $map->getWidth() || $y >= $map->getHeight()) { + throw new NpcRefusal(sprintf('%d,%d is outside the map.', $x, $y)); } } diff --git a/src/Field/NpcCollection.php b/src/Field/NpcCollection.php index 7755fe9..2d096c3 100644 --- a/src/Field/NpcCollection.php +++ b/src/Field/NpcCollection.php @@ -91,7 +91,29 @@ public function get(int $index): ?ProjectNpc } /** - * Returns the position of the NPC anchored at a tile, if any. + * Returns the positions of every NPC anchored at a tile, in order. More + * than one stands there only as phases whose visibility conditions + * exclude each other. + * + * @param int $x The column. + * @param int $y The row. + * @return list The positions. + */ + public function indicesAt(int $x, int $y): array + { + $indices = []; + + foreach ($this->npcs as $index => $npc) { + if ($npc->getX() === $x && $npc->getY() === $y) { + $indices[] = $index; + } + } + + return $indices; + } + + /** + * Returns the position of the first NPC anchored at a tile, if any. * * @param int $x The column. * @param int $y The row. diff --git a/src/Field/NpcReferences.php b/src/Field/NpcReferences.php index 46bcd99..c6ae2ce 100644 --- a/src/Field/NpcReferences.php +++ b/src/Field/NpcReferences.php @@ -62,11 +62,11 @@ public function describe(ProjectMap $map, string $npcId): array $label = sprintf('%s event %s', $map->mapId, strval($marker)); $script = (array) ($data['script'] ?? []); - if (trim(strval($data['npcId'] ?? '')) === $npcId || $this->commandsName($script, $npcId)) { + if (self::readText($data['npcId'] ?? null) === $npcId || $this->commandsName($script, $npcId)) { $found[] = $label; } - $scriptId = trim(strval($data['scriptId'] ?? '')); + $scriptId = self::readText($data['scriptId'] ?? null); if ($scriptId !== '') { $usedScripts[$scriptId] ??= $label; @@ -106,7 +106,7 @@ public function describe(ProjectMap $map, string $npcId): array continue; } - if (trim(strval($data['startMap'] ?? '')) !== $map->mapId) { + if (self::readText($data['startMap'] ?? null) !== $map->mapId) { continue; } @@ -167,9 +167,11 @@ private static function collectStartedScripts(array $commands, string $owner, ar continue; } - $scriptId = trim(strval($node['id'] ?? '')); + // Only a common_event command's id names a script; elsewhere an + // id may be any reference, structured ones included. + $scriptId = ($node['type'] ?? null) === 'common_event' ? self::readText($node['id'] ?? null) : ''; - if (($node['type'] ?? null) === 'common_event' && $scriptId !== '') { + if ($scriptId !== '') { $started[$scriptId] ??= $owner; } @@ -180,7 +182,9 @@ private static function collectStartedScripts(array $commands, string $owner, ar /** * Determines whether commands name the NPC, however deeply nested: a * `move_route` for subject `npc`, or any subject, target or cast entry - * of kind `npc`, by `npcId` or `id`. + * of kind `npc`, by `npcId` or `id`. A structured subject such as + * `['kind' => 'npc', 'id' => ...]` is itself a nested reference, read + * when the walk reaches it, as the Engine's subject resolver reads it. * * @param array $commands The commands. * @param string $npcId The id. @@ -193,9 +197,8 @@ private function commandsName(array $commands, string $npcId): bool continue; } - $namesNpc = strtolower(trim(strval($node['kind'] ?? $node['subject'] ?? ''))) === 'npc'; - - if ($namesNpc && trim(strval($node['npcId'] ?? $node['id'] ?? '')) === $npcId) { + if (strtolower(self::readText($node['kind'] ?? $node['subject'] ?? null)) === 'npc' + && self::readText($node['npcId'] ?? $node['id'] ?? null) === $npcId) { return true; } @@ -206,4 +209,15 @@ private function commandsName(array $commands, string $npcId): bool return false; } + + /** + * Returns an authored id, kind or map id as trimmed text, or an empty + * string when it is not text. A structured value, such as a subject + * written as `['kind' => 'npc', 'id' => ...]`, is a reference of its own + * that the walk reads where it stands, not text of the node holding it. + */ + private static function readText(mixed $value): string + { + return is_string($value) || is_int($value) ? trim((string) $value) : ''; + } } diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 42591d2..543d50a 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -12,6 +12,7 @@ use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Database\SummonAssignmentDiagnostics; +use Ichiloto\Editor\Database\WorldConditionExclusion; use Ichiloto\Editor\Events\CommandMapContext; use Ichiloto\Editor\Events\ProjectScriptCommands; use Ichiloto\Editor\Field\MapEncounters; @@ -2114,7 +2115,7 @@ protected function checkNpcs(ProjectMap $map, ProjectWorkspace $workspace): arra * @param string $where Where it lives. * @param int $width The map width. * @param int $height The map height. - * @param array $anchors Anchors seen so far, appended to. + * @param array}> $anchors Anchors seen so far, appended to. * @param string $name The NPC's name. * @param int|string $index The entry index. * @return Issue[] The issues found. @@ -2159,6 +2160,7 @@ protected function checkNpcPlacement(array $entry, string $where, int $width, in 'where' => $where, 'wanders' => strval($entry['movement'] ?? 'fixed') === 'wander', 'width' => ProjectNpc::glyphWidth($sprite), + 'conditions' => is_array($entry['conditions'] ?? null) ? $entry['conditions'] : [], ]; if ($x < 0 || $y < 0 || $x >= $width || $y >= $height) { @@ -2577,11 +2579,12 @@ protected function checkNpcUnknownFields(array $entry, string $where): array * celebration area). Only when fixed NPCs cover every tile of the area can * the event never fire; a wanderer only blocks it while there. Two NPCs on * one tile: `npcAt` finds the first, so the second can never be spoken to - * while both stand still. + * while both stand still, unless their visibility conditions exclude each + * other, as phases of one presence do. * * @param ProjectMap $map The map. * @param ProjectWorkspace $workspace The project. - * @param array $anchors The NPC anchors. + * @param array}> $anchors The NPC anchors. * @return Issue[] The issues found. */ protected function checkNpcCollisions(ProjectMap $map, ProjectWorkspace $workspace, array $anchors): array @@ -2604,7 +2607,9 @@ protected function checkNpcCollisions(ProjectMap $map, ProjectWorkspace $workspa foreach ($anchors as $index => $anchor) { foreach ($anchors as $otherIndex => $other) { - if ($otherIndex <= $index) { + // Phases whose visibility conditions exclude each other are never + // on the field together, so they may share or overlap a tile. + if ($otherIndex <= $index || WorldConditionExclusion::proveExclusive($anchor['conditions'], $other['conditions'])) { continue; } diff --git a/tests/Unit/NpcAuthoringRulesTest.php b/tests/Unit/NpcAuthoringRulesTest.php index 61f5f83..5c67177 100644 --- a/tests/Unit/NpcAuthoringRulesTest.php +++ b/tests/Unit/NpcAuthoringRulesTest.php @@ -55,7 +55,7 @@ function npcRulesRow(NpcInspector $inspector, int $index, string $fieldId, array ->and($blank->npc?->getName())->toBe(NpcAuthoring::DEFAULT_NAME) ->and($blank->npc?->getId())->toBe('new-npc') ->and(fn() => $authoring->create($map, 12, 0, 'Out'))->toThrow(NpcRefusal::class, '12,0 is outside the map.') - ->and(fn() => $authoring->create($map, 2, 1, 'Stacked'))->toThrow(NpcRefusal::class, 'Gate Guard already stands at 2,1.') + ->and(fn() => $authoring->create($map, 2, 1, 'Stacked'))->toThrow(NpcRefusal::class, 'Gate Guard already stands at 2,1 while both can be visible.') ->and($map->getNpcs()->count())->toBe(3); // The command puts back exactly the collection; nothing else is involved. @@ -76,7 +76,7 @@ function npcRulesRow(NpcInspector $inspector, int $index, string $fieldId, array expect($authoring->move($map, 0, 2, 1)->command)->toBeNull() ->and(fn() => $authoring->move($map, 0, -1, 1))->toThrow(NpcRefusal::class, '-1,1 is outside the map.') - ->and(fn() => $authoring->move($map, 0, 6, 3))->toThrow(NpcRefusal::class, 'Bob already stands at 6,3.') + ->and(fn() => $authoring->move($map, 0, 6, 3))->toThrow(NpcRefusal::class, 'Bob already stands at 6,3 while both can be visible.') ->and(fn() => $authoring->move($map, 7, 1, 1))->toThrow(NpcRefusal::class, 'test-map has no NPC 7.') ->and($map->isDirty())->toBeFalse(); @@ -181,7 +181,7 @@ function npcRulesRow(NpcInspector $inspector, int $index, string $fieldId, array $inspector = new NpcInspector($map); expect(fn() => $authoring->applyField($inspector, 0, [], npcRulesRow($inspector, 0, 'x'), '6')) - ->toThrow(NpcRefusal::class, 'Bob already stands at 6,1.') + ->toThrow(NpcRefusal::class, 'Bob already stands at 6,1 while both can be visible.') ->and(fn() => $authoring->applyField($inspector, 0, [], npcRulesRow($inspector, 0, 'y'), '9')) ->toThrow(NpcRefusal::class, '2,9 is outside the map.') ->and($map->getNpcs()->get(0)?->getX())->toBe(2) @@ -246,6 +246,6 @@ function npcRulesRow(NpcInspector $inspector, int $index, string $fieldId, array callEditorMethod($editor, 'applyNpcFieldValueRecorded', $x, '6'); expect($map->getNpcs()->get(0)?->getX())->toBe(2) - ->and(getEditorProperty($editor, 'statusMessage'))->toBe('Bob already stands at 6,1.') + ->and(getEditorProperty($editor, 'statusMessage'))->toBe('Bob already stands at 6,1 while both can be visible. Give one a condition the other negates, such as event:x and !event:x, to share the tile.') ->and(getEditorProperty($editor, 'history')->count())->toBe(0); }); diff --git a/tests/Unit/NpcAuthoringTest.php b/tests/Unit/NpcAuthoringTest.php index 7d104c8..ac462ff 100644 --- a/tests/Unit/NpcAuthoringTest.php +++ b/tests/Unit/NpcAuthoringTest.php @@ -1475,3 +1475,23 @@ public function __construct() ->toContain('NPC Keeper: It starts on event marker E (ChestEventTrigger) at (5, 1), and NPCs cover every tile of that event') ->and(implode("\n", npcIssueLines($covered, Severity::ERROR)))->not->toContain('event marker'); }); + +it('steps through the phases sharing a tile with Enter on the canvas', function () { + [$root] = npcProject([ + ['id' => 'ivara', 'name' => 'Ivara', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'evidence', 'negate' => true]]], + ['id' => 'ann', 'name' => 'Ann', 'x' => 6, 'y' => 1], + ['id' => 'ivara-returned', 'name' => 'Ivara Returned', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'evidence']]], + ]); + $editor = npcEditor($root); + setEditorProperty($editor, 'cursorX', 2); + setEditorProperty($editor, 'cursorY', 1); + $selected = []; + + foreach ([1, 2, 3] as $press) { + callEditorMethod($editor, 'dispatchInput', "\r"); + $selected[] = getEditorProperty($editor, 'selectedNpcIndex'); + } + + expect($selected)->toBe([2, 0, 2]) + ->and(npcMap($editor)->getNpcs()->count())->toBe(3); +}); diff --git a/tests/Unit/Session/NpcPhaseSessionTest.php b/tests/Unit/Session/NpcPhaseSessionTest.php new file mode 100644 index 0000000..c7c1630 --- /dev/null +++ b/tests/Unit/Session/NpcPhaseSessionTest.php @@ -0,0 +1,177 @@ +readNpc('test-map', $index); + + return $session->applyNpc('test-map', $read['revision'], $index, npcSessionRow($read, $fieldId)['key'], $value); +} + +/** The NPC entries as the map's data file holds them. */ +function npcPhaseSource(string $root): array +{ + return (require $root . '/assets/Maps/test-map/test-map.data.php')['npcs']; +} + +/** The validator's NPC tile issues for the map. */ +function npcPhaseTileIssues(EditorSession $session): array +{ + return array_values(array_filter( + array_column($session->validateProject(['test-map'])['issues'], 'message'), + static fn(?string $message): bool => str_contains((string) $message, 'same tile') || str_contains((string) $message, 'shares tile') + || str_contains((string) $message, 'covers the tile'), + )); +} + +it('lets a duplicate share its original\'s tile once their conditions exclude each other, with undo and a faithful save', function () { + $root = npcSessionProject([ + ['id' => 'ivara', 'name' => 'Ivara', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'evidence', 'negate' => true]]], + ]); + $session = EditorSession::open($root); + + $copy = $session->duplicateNpc('test-map', $session->readMap('test-map')['revision'], 0); + npcPhaseApply($session, $copy['index'], 'conditions', 'event:evidence'); + npcPhaseApply($session, $copy['index'], 'x', '2'); + + expect(array_map(static fn(array $npc): array => [$npc['id'], $npc['x'], $npc['y']], $session->readMap('test-map')['npcs'])) + ->toBe([['ivara', 2, 1], ['ivara-2', 2, 1]]) + ->and(npcPhaseTileIssues($session))->toBe([]); + + expect($session->undo()['label'])->not->toBeNull() + ->and($session->readMap('test-map')['npcs'][1])->toMatchArray(['x' => 3, 'y' => 1]); + $session->redo(); + $session->saveMap('test-map'); + + $reopened = EditorSession::open($root); + expect(npcPhaseSource($root))->toMatchArray([ + ['id' => 'ivara', 'name' => 'Ivara', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'evidence', 'negate' => true]]], + ['id' => 'ivara-2', 'name' => 'Ivara', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'evidence']]], + ]) + ->and(array_column($reopened->readMap('test-map')['npcs'], 'x'))->toBe([2, 2]) + ->and(npcPhaseTileIssues($reopened))->toBe([]); +}); + +it('refuses a second body on a tile while both NPCs can be visible', function (array $original, string $conditions) { + $session = EditorSession::open(npcSessionProject([ + ['id' => 'ivara', 'name' => 'Ivara', 'x' => 2, 'y' => 1, 'conditions' => $original], + ['id' => 'post', 'name' => 'Post', 'x' => 5, 'y' => 1], + ])); + npcPhaseApply($session, 1, 'conditions', $conditions); + $before = $session->readMap('test-map'); + + expect(fn() => npcPhaseApply($session, 1, 'x', '2')) + ->toThrow(SessionRefusal::class, 'Ivara already stands at 2,1 while both can be visible.') + ->and(fn() => $session->moveNpc('test-map', $session->readMap('test-map')['revision'], 1, 2, 1)) + ->toThrow(SessionRefusal::class, 'Ivara already stands at 2,1 while both can be visible.') + ->and(fn() => $session->createNpc('test-map', $session->readMap('test-map')['revision'], 2, 1, 'Stranger')) + ->toThrow(SessionRefusal::class, 'Ivara already stands at 2,1 while both can be visible.') + ->and($session->readMap('test-map')['npcs'])->toBe($before['npcs']); +})->with([ + 'neither guarded' => [[], ''], + 'one guarded' => [[['type' => 'event', 'name' => 'evidence', 'negate' => true]], ''], + 'the same flag' => [[['type' => 'event', 'name' => 'evidence']], 'event:evidence'], + 'different flags' => [[['type' => 'event', 'name' => 'evidence', 'negate' => true]], 'event:dispatched'], + 'quest statuses' => [[['type' => 'quest', 'name' => 'q', 'status' => 'active']], 'quest:q:completed'], + 'variable ranges' => [[['type' => 'variable', 'name' => 'v', 'op' => '<', 'value' => 3]], 'variable:v:>=:3'], +]); + +it('lets phases share a tile on any explicit complement the Engine evaluates', function (array $original, string $conditions) { + $session = EditorSession::open(npcSessionProject([ + ['id' => 'ivara', 'name' => 'Ivara', 'x' => 2, 'y' => 1, 'conditions' => $original], + ['id' => 'post', 'name' => 'Post', 'x' => 5, 'y' => 1], + ])); + npcPhaseApply($session, 1, 'conditions', $conditions); + + expect(npcPhaseApply($session, 1, 'x', '2')['changed'])->toBeTrue() + ->and(npcPhaseTileIssues($session))->toBe([]); +})->with([ + 'event' => [[['type' => 'event', 'name' => 'evidence']], '!event:evidence'], + 'switch value' => [[['type' => 'switch', 'name' => 'door']], 'switch:door:false'], + 'negated switch' => [[['type' => 'switch', 'name' => 'door', 'value' => false]], '!switch:door:false'], + 'one of several' => [[['type' => 'item', 'name' => 'key'], ['type' => 'event', 'name' => 'evidence']], 'quest:q:active; !event:evidence'], + 'map event' => [[['type' => 'map_event', 'name' => 'town', 'marker' => 'A']], '!map_event:town:A'], + 'variable equality' => [[['type' => 'variable', 'name' => 'v', 'op' => '==', 'value' => 3]], 'variable:v:!=:3'], +]); + +it('refuses a visibility edit that would show two phases on one tile together, changing nothing', function (int $edited, string $conditions) { + $root = npcSessionProject([ + ['id' => 'ivara', 'name' => 'Ivara', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'evidence', 'negate' => true]]], + ['id' => 'ivara-returned', 'name' => 'Ivara Returned', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'evidence']]], + ]); + $session = EditorSession::open($root); + $before = $session->readMap('test-map'); + $other = $edited === 0 ? 'Ivara Returned' : 'Ivara'; + + expect(fn() => npcPhaseApply($session, $edited, 'conditions', $conditions)) + ->toThrow(SessionRefusal::class, sprintf('%s already stands at 2,1 while both can be visible.', $other)) + ->and($session->readMap('test-map')['npcs'])->toBe($before['npcs']) + ->and($session->hasUnsavedChanges())->toBeFalse() + ->and($session->undo()['label'])->toBeNull() + ->and(npcPhaseTileIssues($session))->toBe([]); +})->with([ + 'the original loses its guard' => [0, ''], + 'the returned phase loses its guard' => [1, ''], + 'the returned phase takes another flag' => [1, 'event:dispatched'], +]); + +it('reports two NPCs anchored on one tile when nothing keeps them apart, and not phases', function () { + $session = EditorSession::open(npcSessionProject([ + ['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1], + ['id' => 'bob', 'name' => 'Bob', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'met']]], + ['id' => 'cal', 'name' => 'Cal', 'x' => 6, 'y' => 1, 'conditions' => [['type' => 'switch', 'name' => 'night']]], + ['id' => 'dee', 'name' => 'Dee', 'x' => 6, 'y' => 1, 'conditions' => [['type' => 'switch', 'name' => 'night', 'value' => false]]], + ])); + + expect(npcPhaseTileIssues($session))->toBe(['It shares tile (2, 1) with NPC Bob.']); +}); + +it('reads structured subjects and ids as references, without warnings, when duplicating and renaming', function () { + $root = npcSessionProject([ + ['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1], + ['id' => 'bob', 'name' => 'Bob', 'x' => 6, 'y' => 3, 'script' => [ + ['type' => 'stage', 'subject' => ['kind' => 'npc', 'id' => 'ann'], 'at' => ['kind' => 'world_object', 'id' => 'desk']], + ['type' => 'field_animation', 'id' => ['kind' => 'npc', 'id' => 'bob']], + ['type' => 'common_event', 'id' => ['kind' => 'script', 'id' => 'a']], + ]], + ]); + $session = EditorSession::open($root); + $warnings = []; + set_error_handler(static function (int $level, string $message) use (&$warnings): bool { + $warnings[] = $message; + + return true; + }); + + try { + $copy = $session->duplicateNpc('test-map', $session->readMap('test-map')['revision'], 0); + $renamed = npcPhaseApply($session, 0, 'name', 'Annabel'); + $copyRenamed = npcPhaseApply($session, $copy['index'], 'name', 'Anja'); + $refusal = null; + + try { + $session->deleteNpc('test-map', $copyRenamed['revision'], 0); + } catch (SessionRefusal $caught) { + $refusal = $caught->getMessage(); + } + } finally { + restore_error_handler(); + } + + expect($warnings)->toBe([]) + ->and($renamed)->toMatchArray(['id' => 'ann', 'followedId' => null, 'idReferences' => ['NPC Bob script']]) + ->and($copyRenamed)->toMatchArray(['id' => 'anja', 'followedId' => 'anja']) + ->and($refusal)->toContain('Annabel is named by NPC Bob script'); +}); diff --git a/tests/Unit/Session/NpcSessionTest.php b/tests/Unit/Session/NpcSessionTest.php index 131c4cf..7281a8b 100644 --- a/tests/Unit/Session/NpcSessionTest.php +++ b/tests/Unit/Session/NpcSessionTest.php @@ -50,8 +50,8 @@ $revision = $session->readMap('test-map')['revision']; expect(fn() => $session->createNpc('test-map', $revision, 20, 1, 'Out'))->toThrow(SessionRefusal::class, '20,1 is outside the map.') - ->and(fn() => $session->createNpc('test-map', $revision, 2, 1, 'On Ann'))->toThrow(SessionRefusal::class, 'Ann already stands at 2,1.') - ->and(fn() => $session->moveNpc('test-map', $revision, 0, 6, 3))->toThrow(SessionRefusal::class, 'Bob already stands at 6,3.') + ->and(fn() => $session->createNpc('test-map', $revision, 2, 1, 'On Ann'))->toThrow(SessionRefusal::class, 'Ann already stands at 2,1 while both can be visible.') + ->and(fn() => $session->moveNpc('test-map', $revision, 0, 6, 3))->toThrow(SessionRefusal::class, 'Bob already stands at 6,3 while both can be visible.') ->and(fn() => $session->duplicateNpc('test-map', $revision, 9))->toThrow(SessionRefusal::class, 'test-map has no NPC 9.') ->and(fn() => $session->deleteNpc('test-map', $revision, 0)) ->toThrow(SessionRefusal::class, "Ann is named by NPC Bob script - resolve those before deleting.\n- NPC Bob script") diff --git a/tests/Unit/WorldConditionExclusionTest.php b/tests/Unit/WorldConditionExclusionTest.php new file mode 100644 index 0000000..d020947 --- /dev/null +++ b/tests/Unit/WorldConditionExclusionTest.php @@ -0,0 +1,50 @@ +toBeTrue() + ->and(WorldConditionExclusion::proveExclusive($others, $conditions))->toBeTrue(); +})->with([ + 'negated event' => [[['type' => 'event', 'name' => 'x']], [['type' => 'event', 'name' => 'x', 'negate' => true]]], + 'trimmed names' => [[['type' => 'event', 'name' => ' x ']], [['type' => 'event', 'name' => 'x', 'negate' => true]]], + 'switch value' => [[['type' => 'switch', 'name' => 's']], [['type' => 'switch', 'name' => 's', 'value' => false]]], + 'negated switch' => [[['type' => 'switch', 'name' => 's', 'value' => true]], [['type' => 'switch', 'name' => 's', 'negate' => true]]], + 'key item' => [[['type' => 'key_item', 'name' => 'k']], [['type' => 'key_item', 'name' => 'k', 'negate' => true]]], + 'item quantity default' => [[['type' => 'item', 'name' => 'i']], [['type' => 'item', 'name' => 'i', 'quantity' => 1, 'negate' => true]]], + 'quest status default' => [[['type' => 'quest', 'name' => 'q']], [['type' => 'quest', 'name' => 'q', 'status' => 'completed', 'negate' => true]]], + 'map event' => [[['type' => 'map_event', 'name' => 'm', 'marker' => 'A']], [['type' => 'map_event', 'name' => 'm', 'marker' => ' A', 'negate' => true]]], + 'variable equality' => [[['type' => 'variable', 'name' => 'v', 'value' => 2]], [['type' => 'variable', 'name' => 'v', 'op' => '!=', 'value' => 2]]], + 'among others' => [ + [['type' => 'item', 'name' => 'i'], ['type' => 'event', 'name' => 'x']], + [['type' => 'quest', 'name' => 'q'], ['type' => 'event', 'name' => 'x', 'negate' => true]], + ], +]); + +it('proves nothing when both lists could hold together', function (array $conditions, array $others) { + expect(WorldConditionExclusion::proveExclusive($conditions, $others))->toBeFalse() + ->and(WorldConditionExclusion::proveExclusive($others, $conditions))->toBeFalse(); +})->with([ + 'no conditions' => [[], []], + 'one side unguarded' => [[['type' => 'event', 'name' => 'x']], []], + 'the same condition' => [[['type' => 'event', 'name' => 'x']], [['type' => 'event', 'name' => 'x']]], + 'different names' => [[['type' => 'event', 'name' => 'x']], [['type' => 'event', 'name' => 'y', 'negate' => true]]], + 'different types' => [[['type' => 'event', 'name' => 'x']], [['type' => 'switch', 'name' => 'x', 'negate' => true]]], + 'item quantities' => [[['type' => 'item', 'name' => 'i', 'quantity' => 2]], [['type' => 'item', 'name' => 'i', 'negate' => true]]], + 'quest statuses' => [[['type' => 'quest', 'name' => 'q', 'status' => 'active']], [['type' => 'quest', 'name' => 'q', 'status' => 'completed']]], + 'variable ranges' => [[['type' => 'variable', 'name' => 'v', 'op' => '<', 'value' => 3]], [['type' => 'variable', 'name' => 'v', 'op' => '>=', 'value' => 3]]], + 'variable values' => [[['type' => 'variable', 'name' => 'v', 'value' => 1]], [['type' => 'variable', 'name' => 'v', 'value' => 2]]], + 'markers' => [[['type' => 'map_event', 'name' => 'm', 'marker' => 'A']], [['type' => 'map_event', 'name' => 'm', 'marker' => 'B', 'negate' => true]]], + 'empty names the Engine skips' => [[['type' => 'event', 'name' => '']], [['type' => 'event', 'name' => '', 'negate' => true]]], + 'unknown types' => [[['type' => 'mood', 'name' => 'x']], [['type' => 'mood', 'name' => 'x', 'negate' => true]]], + 'missing markers' => [[['type' => 'map_event', 'name' => 'm']], [['type' => 'map_event', 'name' => 'm', 'negate' => true]]], + 'structured names' => [[['type' => 'event', 'name' => ['x']]], [['type' => 'event', 'name' => ['x'], 'negate' => true]]], +]); From 0e20a91bde40f48219d0d87521f12c93b0ea36e6 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 19 Oct 2026 17:59:44 +0200 Subject: [PATCH 341/344] fix(events): read the map event envelope and Script data from the Engine ScriptEventTrigger validation kept its own copies of the root fields every map event carries and of the Script trigger's data fields. The root copy drifted when the Engine bound events to world objects by `subject`, so a Story Script event on an object was refused while the game ran it. Validation now reads MapEventDefinition::ROOT_FIELDS and ScriptEventTrigger::DATA_FIELDS and MODES from the Engine, which owns them. The event inspector offers the modes of the event's own trigger class, and the Story Script default mode is the Engine's. Unknown root and data fields, unsupported modes and subjects naming no object are still errors. --- src/Database/RecordSchemaCatalog.php | 2 +- src/Events/EventTypeCatalog.php | 13 ++-- src/Inspector/MapInspector.php | 32 +++++++-- src/Validation/ProjectValidator.php | 22 +++--- .../Unit/Session/EventSubjectSessionTest.php | 69 +++++++++++++++++++ 5 files changed, 113 insertions(+), 25 deletions(-) diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 6faf5b7..05cd8fa 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -2034,7 +2034,7 @@ public static function mapNpcs(bool $graphical = false): RecordSchema /** * Returns one map event as a record pane ({@see \Ichiloto\Editor\Events\EventRecordPane}). * - * Every trigger class reads the same root fields (EventTriggerFactory): + * Every map event shares one envelope ({@see \Ichiloto\Engine\Events\MapEventDefinition}): * the conditions that make it available, the message shown while they * do not hold, the writes made when it completes, and the conditions * that delay its cue. They are offered on every event and written only diff --git a/src/Events/EventTypeCatalog.php b/src/Events/EventTypeCatalog.php index 4e28201..ccc2e0c 100644 --- a/src/Events/EventTypeCatalog.php +++ b/src/Events/EventTypeCatalog.php @@ -5,6 +5,7 @@ namespace Ichiloto\Editor\Events; use Ichiloto\Engine\Core\Enumerations\MovementHeading; +use Ichiloto\Engine\Events\MapEventDefinition; use Ichiloto\Engine\Events\Triggers\ChestEventTrigger; use Ichiloto\Engine\Events\Triggers\CinematicEventTrigger; use Ichiloto\Engine\Events\Triggers\DialogueEventTrigger; @@ -16,11 +17,11 @@ /** * Lists valid event types that can be assigned in the editor. * - * A type owns its class and its data. The root fields every trigger reads - * whatever its class (`conditions`, `sets`, `whenBlocked`, `cue` and its - * `conditions`, `facing`, `engagement`) belong to no type: they are offered - * on every event and written only when an author sets them - * ({@see EventRecordPane}). + * A type owns its class and its data. The envelope every map event shares + * whatever its class ({@see MapEventDefinition::ROOT_FIELDS}) belongs to no + * type: its conditions, writes, blocked message, cue, facing and engagement + * are offered on every event and written only when an author sets them + * ({@see EventRecordPane}); its subject and area have their own operations. */ final class EventTypeCatalog { @@ -45,7 +46,7 @@ className: ScriptEventTrigger::class, description: 'Runs a resumable event script by stable script id.', defaultData: [ 'scriptId' => '', - 'mode' => 'action', + 'mode' => ScriptEventTrigger::MODE_ACTION, 'reusable' => false, ], defaultDefinitionFields: ['cue' => ['symbol' => '', 'color' => 'bright-yellow']], diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php index f6a6386..d8dcb4b 100644 --- a/src/Inspector/MapInspector.php +++ b/src/Inspector/MapInspector.php @@ -33,11 +33,13 @@ use Ichiloto\Editor\Validation\ChestRewardValidator; use Ichiloto\Editor\Validation\Issue; use Ichiloto\Engine\Core\Enumerations\MovementHeading; +use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; use Ichiloto\Engine\Events\Enumerations\ChestType; use Ichiloto\Engine\Events\Enumerations\EventEngagement; use Ichiloto\Engine\Events\Enumerations\LootType; use Ichiloto\Engine\Events\Triggers\DialogueEventTrigger; use Ichiloto\Engine\Events\Triggers\EventCueKind; +use Ichiloto\Engine\Events\Triggers\ScriptEventTrigger; use Ichiloto\Engine\Events\Triggers\ShopEventTrigger; use Ichiloto\Engine\Field\PlayerSpriteSet; use Ichiloto\Engine\Messaging\Dialogue\ConditionalDialogue; @@ -1642,7 +1644,9 @@ public function buildEventDataFields(string $marker, array $definition): array if (is_array($eventData)) { // The type's optional data is offered as it reads when absent, // and stays out of the stored event until an author sets it. - $optional = EventTypeCatalog::findByClass(is_string($definition['class'] ?? null) ? $definition['class'] : null)?->optionalData ?? []; + $className = is_string($definition['class'] ?? null) ? ltrim($definition['class'], '\\') : null; + $optional = EventTypeCatalog::findByClass($className)?->optionalData ?? []; + $modes = self::listEventModes($className); $hasPresentation = array_key_exists('presentation', $optional) || array_key_exists('presentation', $eventData); $presentation = $eventData['presentation'] ?? null; if ($hasPresentation) { unset($eventData['presentation'], $optional['presentation']); } @@ -1650,11 +1654,15 @@ public function buildEventDataFields(string $marker, array $definition): array $spawnSprite = $eventData['spawnSprite'] ?? null; unset($eventData['spawnSprite']); $pages = self::readDialoguePages($shown); - $fields = array_map(static function (array $field) use ($optional): array { + $fields = array_map(static function (array $field) use ($optional, $modes): array { $path = array_values((array) ($field['path'] ?? [])); if (count($path) === 2 && array_key_exists((string) $path[1], $optional)) { $field['optional'] = true; } + if ($path === ['data', 'mode'] && $modes !== null) { + $field['options'] = $modes; + unset($field['control']); + } return $field; }, $this->decorateEventInspectorFields( @@ -2113,6 +2121,21 @@ private function describePageEmotions(array $fields, array $pages): array return $described; } + /** + * Returns the modes a trigger class runs in, as the Engine exports them, + * or null for a class that reads no mode. + * + * @return list|null + */ + private static function listEventModes(?string $className): ?array + { + return match ($className) { + ScriptEventTrigger::class => ScriptEventTrigger::MODES, + CinematicCommandSchema::CINEMATIC_TRIGGER_CLASS => CinematicCommandSchema::CINEMATIC_TRIGGER_MODES, + default => null, + }; + } + /** * Adds event context, pickers, and enum controls to flattened fields. * @@ -2132,11 +2155,6 @@ private function decorateEventInspectorFields(string $marker, array $fields): ar $path = array_values((array) ($field['path'] ?? [])); - if ($path === ['data', 'mode']) { - $field['options'] = ['action', 'auto']; - unset($field['control']); - } - $choices = match ($path) { ['cue', 'kind'] => self::EVENT_CUE_KIND_CHOICES, ['engagement'] => self::EVENT_ENGAGEMENT_CHOICES, diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 543d50a..026b5aa 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -37,7 +37,9 @@ use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; use Ichiloto\Engine\Events\Interpreter\MovementRouteRunner; +use Ichiloto\Engine\Events\MapEventDefinition; use Ichiloto\Engine\Events\Triggers\EventCueKind; +use Ichiloto\Engine\Events\Triggers\ScriptEventTrigger; use Ichiloto\Engine\IO\Console\TerminalText; use InvalidArgumentException; use Throwable; @@ -4078,13 +4080,13 @@ protected function checkScriptTrigger( array $npcIdsByMap, ): array { $issues = []; - $allowedRootFields = ['class', 'data', 'conditions', 'sets', 'whenBlocked', 'cue', 'facing', 'engagement']; - - foreach (array_diff(array_keys($definition), $allowedRootFields) as $field) { + // The Engine owns both vocabularies: the envelope every map event shares + // (whose combinations MapEventResolver checks) and this trigger's data. + foreach (array_diff(array_keys($definition), MapEventDefinition::ROOT_FIELDS) as $field) { $issues[] = Issue::error( $where, sprintf('ScriptEventTrigger uses unsupported root field "%s".', $field), - 'Use class, data, conditions, sets, whenBlocked, cue, facing, or engagement.' + sprintf('Use %s.', implode(', ', MapEventDefinition::ROOT_FIELDS)) ); } @@ -4094,13 +4096,11 @@ protected function checkScriptTrigger( return [...$issues, Issue::error($where, 'ScriptEventTrigger data is malformed.', 'Store its settings in a data array.')]; } - $allowedDataFields = ['scriptId', 'script', 'mode', 'reusable']; - - foreach (array_diff(array_keys($data), $allowedDataFields) as $field) { + foreach (array_diff(array_keys($data), ScriptEventTrigger::DATA_FIELDS) as $field) { $issues[] = Issue::error( $where, sprintf('ScriptEventTrigger uses unsupported data field "%s".', $field), - 'Use scriptId, script, mode, or reusable.' + sprintf('Use %s.', implode(', ', ScriptEventTrigger::DATA_FIELDS)) ); } @@ -4138,13 +4138,13 @@ protected function checkScriptTrigger( } } - $mode = strval($data['mode'] ?? 'action'); + $mode = strval($data['mode'] ?? ScriptEventTrigger::MODE_ACTION); - if (! in_array($mode, ['action', 'auto'], true)) { + if (! in_array($mode, ScriptEventTrigger::MODES, true)) { $issues[] = Issue::error( $where, sprintf('ScriptEventTrigger uses unsupported mode "%s".', $mode), - 'Choose action or auto.' + sprintf('Choose %s.', implode(' or ', ScriptEventTrigger::MODES)) ); } diff --git a/tests/Unit/Session/EventSubjectSessionTest.php b/tests/Unit/Session/EventSubjectSessionTest.php index c74eb19..7072358 100644 --- a/tests/Unit/Session/EventSubjectSessionTest.php +++ b/tests/Unit/Session/EventSubjectSessionTest.php @@ -97,3 +97,72 @@ function readResolvedEventCells(EditorSession $session, string $marker): array ->and((require $root . '/assets/Maps/test-map/test-map.data.php')['events']['E']['subject'])->toBe(['kind' => 'world_object', 'id' => 'lockers']) ->and(array_filter($outcome['issues'], static fn(array $issue): bool => str_starts_with((string) $issue['code'], 'event.')))->toBe([]); }); + +/** + * The subject project with event E as the given trigger, its data and any + * extra root fields. + * + * @param array $data + * @param array $root + */ +function eventSubjectProjectAs(string $className, array $data, array $root = []): string +{ + $root_ = eventSubjectProject(); + $file = $root_ . '/assets/Maps/test-map/test-map.data.php'; + $map = require $file; + $map['events']['E'] = ['class' => $className, 'data' => $data, ...$root]; + file_put_contents($file, " */ +function readEventSubjectErrors(EditorSession $session): array +{ + return array_values(array_map( + static fn(array $issue): string => $issue['where'] . ': ' . $issue['message'], + array_filter($session->validateProject(['test-map'])['issues'], + static fn(array $issue): bool => $issue['severity'] === 'error' && str_contains((string) $issue['where'] . ' ' . $issue['message'], 'E'), + ), + )); +} + +it('binds a Story Script event to its object and saves, reloads and validates it clean', function (string $className, array $data) { + $root = eventSubjectProjectAs($className, $data); + $session = EditorSession::open($root); + $errorsBefore = readEventSubjectErrors($session); + + $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', 'lockers'); + $session->saveMap('test-map'); + $saved = EditorSession::open($root); + + expect(getSessionMap($saved)->getEventDefinition('E'))->toMatchArray(['class' => $className, 'subject' => ['kind' => 'world_object', 'id' => 'lockers']]) + ->and(readResolvedEventCells($saved, 'E'))->toBe([[1, 1], [2, 1]]) + ->and(readEventSubjectErrors($saved))->toBe($errorsBefore); +})->with([ + 'Story Script' => ['Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', ['script' => [['type' => 'text', 'text' => 'Lockers.']], 'mode' => 'action', 'reusable' => true]], + 'Dialogue' => ['Ichiloto\\Engine\\Events\\Triggers\\DialogueEventTrigger', ['dialogue' => [['name' => '', 'text' => 'Lockers.']]]], +]); + +it('still refuses a Story Script event\'s unknown root and data fields and a subject naming no object', function () { + $root = eventSubjectProjectAs( + 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + ['script' => [['type' => 'text', 'text' => 'Lockers.']], 'mode' => 'sometimes', 'colour' => 'red'], + ['trigger' => 'step'], + ); + $session = EditorSession::open($root); + $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', 'lockers'); + $session->saveMap('test-map'); + // The object the subject names is gone from the source. + $file = $root . '/assets/Maps/test-map/test-map.data.php'; + $map = require $file; + $map['events']['E']['subject']['id'] = 'no-such-object'; + file_put_contents($file, "toContain('ScriptEventTrigger uses unsupported root field "trigger".') + ->and($errors)->toContain('ScriptEventTrigger uses unsupported data field "colour".') + ->and($errors)->toContain('ScriptEventTrigger uses unsupported mode "sometimes".') + ->and($errors)->toContain("subject 'no-such-object' was not found") + ->and($errors)->not->toContain('unsupported root field "subject"'); +}); From f805a4a963bc4ded6dfe3b99c36a61a9388df9d4 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 19 Oct 2026 20:10:51 +0200 Subject: [PATCH 342/344] fix(source): hold edited values in the key order their files read back The source-preserving writer keeps an authored key where it stands and places a new key where the edited value orders it. Maps already adopted that order for what they hold; cutscenes and actors did not, so an edit that rebuilt an entry or set a key again held its keys in another order than the file would read back, and the strict read-back refused the save. Replacing a branch arm's only route with a new one built row by row was refused this way. The alignment maps used is now PhpArraySourceDocument::alignValue, shared by maps, by every cutscene edit and by actor saves. The strict read-back is unchanged. A list entry that moves now plans its own nested removals, insertions and appends inside the bytes it carries; before, they were deferred against bytes the move had cut, and the rewrite failed with overlapping edits. --- src/Cutscenes/CutsceneAsset.php | 26 +++++++++ src/Cutscenes/Source/ArraySourceWriter.php | 52 ++++++++++++------ .../Source/PhpArraySourceDocument.php | 47 ++++++++++++++++ src/ProjectActor.php | 2 + src/ProjectMap.php | 26 +-------- tests/Unit/ActorClassFieldTest.php | 19 +++++++ .../Unit/Cutscenes/ArraySourceWriterTest.php | 16 ++++++ .../Cutscenes/FieldEffectReferenceTest.php | 3 +- .../Session/CutsceneRecordSessionTest.php | 54 +++++++++++++++++++ 9 files changed, 203 insertions(+), 42 deletions(-) diff --git a/src/Cutscenes/CutsceneAsset.php b/src/Cutscenes/CutsceneAsset.php index cb78b72..d2bfeb8 100644 --- a/src/Cutscenes/CutsceneAsset.php +++ b/src/Cutscenes/CutsceneAsset.php @@ -707,11 +707,37 @@ public function apply(array $payload): void return; } + // The files keep their authored key order, as a map's data does: the + // asset holds each side as its rewritten source reads back. + if ($this->type->hasDataFile()) { + $data = $this->alignWithProposedSource($this->dataDocument, $this->loadedData, $data, 'data'); + } + + $partner = $this->alignWithProposedSource($this->partnerDocument, $this->loadedPartner, $partner, $this->type->partnerNoun()); + + if ($data === $this->data && $partner === $this->partner) { + return; + } + $this->data = $data; $this->partner = $partner; $this->touchState(); } + /** + * Returns one side of the pair in the key order its rewritten source + * writes it. + * + * @param array $loaded What the file holds now. + * @param array $value What the side should hold. + * @return array + * @throws RuntimeException When the source cannot take the change; nothing is changed. + */ + private function alignWithProposedSource(?PhpArraySourceDocument $document, array $loaded, array $value, string $noun): array + { + return PhpArraySourceDocument::parse($this->proposedSource($document, $loaded, $value, $noun))->alignValue($value); + } + /** * Records the arrays the files hold and which file each key came from. * diff --git a/src/Cutscenes/Source/ArraySourceWriter.php b/src/Cutscenes/Source/ArraySourceWriter.php index 086b94b..bbc416c 100644 --- a/src/Cutscenes/Source/ArraySourceWriter.php +++ b/src/Cutscenes/Source/ArraySourceWriter.php @@ -642,39 +642,57 @@ private static function prefersInline(SourceNode $list): bool } /** - * Plans removing an entry and re-inserting its own bytes -- with any - * patches its new value needs applied inside them -- ahead of another - * position. + * Plans the patches a value that moves needs, against its current place, + * and returns those inside its bytes, last first, for the move to apply + * to the bytes it carries. Everything the diff defers to the end -- + * removals from arrays whose entries share their lines, insertions, + * appends -- is planned now, inside the moving bytes, rather than + * against bytes the move cuts. A variable defined for the value lives in + * the prelude and stays a global edit. * - * @param array $path The list's path. + * @param array $path The value's path. + * @return list */ - private function emitMove(SourceNode $list, int $from, mixed $oldValue, mixed $newValue, array $path, int $position): void + private function planPatchesWithin(SourceNode $value, mixed $oldValue, mixed $newValue, array $path, int $start, int $end): array { - $entry = $list->entries[$from]; - [$start, $end] = $this->document->blockSpan($entry); - - // Patches for the entry, planned against its current place ... + $deferred = [$this->appends, $this->inserts, $this->sharedLineRemovals]; + [$this->appends, $this->inserts, $this->sharedLineRemovals] = [[], [], []]; $before = count($this->edits); - $this->diff($entry->value, $oldValue, $newValue, [...$path, $from]); + $this->diff($value, $oldValue, $newValue, $path); + $this->flushAppends(); + [$this->appends, $this->inserts, $this->sharedLineRemovals] = $deferred; $inner = []; - $outer = []; foreach (array_splice($this->edits, $before) as $edit) { if ($edit[0] >= $start && $edit[1] <= $end) { $inner[] = $edit; } else { - // A variable defined for it lives in the prelude and stays global. - $outer[] = $edit; + $this->edits[] = $edit; } } - $this->edits = [...$this->edits, ...$outer]; + usort($inner, static fn(array $a, array $b): int => [$b[0], $b[1]] <=> [$a[0], $a[1]]); + + return $inner; + } - // ... applied to its own bytes, from the end forward. + /** + * Plans removing an entry and re-inserting its own bytes -- with any + * patches its new value needs applied inside them -- ahead of another + * position. + * + * @param array $path The list's path. + */ + private function emitMove(SourceNode $list, int $from, mixed $oldValue, mixed $newValue, array $path, int $position): void + { + $entry = $list->entries[$from]; + [$start, $end] = $this->document->blockSpan($entry); + + // Patches for the entry, planned against its current place, applied + // to its own bytes from the end forward. $block = substr($this->document->source, $start, $end - $start); - usort($inner, static fn(array $a, array $b): int => [$b[0], $b[1]] <=> [$a[0], $a[1]]); - foreach ($inner as [$editStart, $editEnd, $text]) { + foreach ($this->planPatchesWithin($entry->value, $oldValue, $newValue, [...$path, $from], $start, $end) as [$editStart, $editEnd, $text]) { $block = substr($block, 0, $editStart - $start) . $text . substr($block, $editEnd - $start); } diff --git a/src/Cutscenes/Source/PhpArraySourceDocument.php b/src/Cutscenes/Source/PhpArraySourceDocument.php index 7904d6f..6e606e9 100644 --- a/src/Cutscenes/Source/PhpArraySourceDocument.php +++ b/src/Cutscenes/Source/PhpArraySourceDocument.php @@ -245,6 +245,53 @@ public function resolveNode(SourceNode $node): SourceNode return $variable->value; } + /** + * Returns a value with its keyed entries in the order this source writes + * them, however deeply nested, as the file reads back once written. + * + * The source-preserving writer keeps an authored key where it stands and + * places a new key where the value orders it, so an edit that set a key + * again, or rebuilt an entry, may hold the same keys in another order + * than the file. That order is the file's: an editor holding the value + * adopts it, so its value is the one the file reads back as. Lists keep + * the order the value gives, and keys the source does not hold stay + * after the ones it does. + * + * @param array $value + * @return array + */ + public function alignValue(array $value): array + { + return $this->alignToNode($this->root(), $value); + } + + /** + * @param array $value + * @return array + */ + private function alignToNode(SourceNode $node, array $value): array + { + $node = $this->resolveNode($node); + + if ($node->kind !== SourceNode::ARRAY || $node->hasOpaqueKey) { + return $value; + } + + $ordered = []; + + foreach ($node->entries as $index => $entry) { + $key = $node->isList ? $index : $entry->key; + + if ($key === null || ! array_key_exists($key, $value)) { + continue; + } + + $ordered[$key] = is_array($value[$key]) ? $this->alignToNode($entry->value, $value[$key]) : $value[$key]; + } + + return $ordered + $value; + } + /** * Returns whether a reference names a value the file builds with its own * code -- or a variable it never assigns at the top level -- rather than diff --git a/src/ProjectActor.php b/src/ProjectActor.php index b2f9ed8..c4d860e 100644 --- a/src/ProjectActor.php +++ b/src/ProjectActor.php @@ -755,6 +755,8 @@ public function setField(string $field, mixed $value): void public function save(): void { $source = $this->getProposedSource(); + // The file keeps its authored key order: the actor holds what it reads back as. + $this->payload = PhpArraySourceDocument::parse($source)->alignValue($this->payload); $transaction = new FileSetTransaction(dirname($this->path)); $transaction->write($this->path, $source); try { diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 142f081..602d59b 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -6,7 +6,6 @@ use Ichiloto\Editor\Cutscenes\Source\ArraySourceWriter; use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; -use Ichiloto\Editor\Cutscenes\Source\SourceNode; use Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal; use Ichiloto\Editor\Cutscenes\Source\SourceUnreadable; use Ichiloto\Editor\Database\PhpDataFile; @@ -1741,31 +1740,10 @@ private function writeData(array $next): void } $document = $this->assertDataPreservable($next); - $this->editableData = $this->alignDataWithSource($document->root(), $next); + $this->editableData = $document->alignValue($next); $this->touchState(); } - /** Field edits retain authored key positions; ordered lists retain their requested order. */ - private function alignDataWithSource(SourceNode $node, array $data): array - { - if ($node->kind !== SourceNode::ARRAY || $node->hasOpaqueKey) { - return $data; - } - - $ordered = []; - foreach ($node->entries as $index => $entry) { - $key = $node->isList ? $index : $entry->key; - if ($key === null || ! array_key_exists($key, $data)) { - continue; - } - $ordered[$key] = is_array($data[$key]) - ? $this->alignDataWithSource($entry->value, $data[$key]) - : $data[$key]; - } - - return $ordered + $data; - } - /** * Proves the authored data source can be rewritten into this array. * @@ -2277,7 +2255,7 @@ private function applyGridState(MapLayers $layers, mixed $occupancy): void $this->assertWorldObjectState($layers, $next); if ($next !== $this->editableData) { $document = $this->assertDataPreservable($next); - $next = $this->alignDataWithSource($document->root(), $next); + $next = $document->alignValue($next); } $this->layers = $layers; $this->editableData = $next; diff --git a/tests/Unit/ActorClassFieldTest.php b/tests/Unit/ActorClassFieldTest.php index b7ade79..e5f250e 100644 --- a/tests/Unit/ActorClassFieldTest.php +++ b/tests/Unit/ActorClassFieldTest.php @@ -163,3 +163,22 @@ function actorClassEditor(string $root): Editor removeDirectoryRecursively($root); } }); + +it('saves a class cleared and chosen again where the file already holds it', function () { + $root = makeTemporaryProject(); + + try { + $path = $root . '/assets/Data/Actors/Kaelion.php'; + file_put_contents($path, str_replace(" 'level' => 1,", " 'class' => 'Vanguard',\n 'level' => 1,", (string) file_get_contents($path))); + $actor = ProjectActor::fromFile($path); + + $actor->setField('class', 'none'); + $actor->setField('class', 'Oracle'); + $actor->save(); + + expect((string) file_get_contents($path))->toContain(" 'class' => 'Oracle',\n 'level' => 1,") + ->and((require $path)['data']['class'])->toBe('Oracle'); + } finally { + removeDirectoryRecursively($root); + } +}); diff --git a/tests/Unit/Cutscenes/ArraySourceWriterTest.php b/tests/Unit/Cutscenes/ArraySourceWriterTest.php index d9188f3..eba635b 100644 --- a/tests/Unit/Cutscenes/ArraySourceWriterTest.php +++ b/tests/Unit/Cutscenes/ArraySourceWriterTest.php @@ -754,3 +754,19 @@ function rewriteSource(string $source, callable $change): string ->and($rewritten)->toContain("\$scene = ['when' => ['type' => 'event', 'name' => 'left'], 'line' => 'Goodbye'];") ->and($rewritten)->toContain(" 'gate' => \$gate,\n"); }); + +it('plans a moving list entry\'s own removals inside the bytes it carries', function () { + $source = " [\n ['id' => 'a', 'points' => [['x' => 1], ['x' => 2], ['x' => 3]]],\n ['id' => 'b'],\n ['id' => 'c'],\n ],\n];\n"; + $old = evaluateSource($source); + // b and c keep their order, so a is the entry that moves, losing points as it goes. + $new = ['steps' => [['id' => 'b'], ['id' => 'c'], ['id' => 'a', 'points' => [['x' => 2]]]]]; + + expect(evaluateSource(ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source))->toBe($new); +}); + +it('aligns a value with the key order its source writes, lists keeping their own', function () { + $document = PhpArraySourceDocument::parse(" 'here', 'waypoints' => [['y' => 1, 'x' => 2]]];\nreturn ['type' => 'move', 'route' => \$route, 'list' => [3, 1]];\n"); + + expect($document->alignValue(['list' => [1, 3], 'route' => ['waypoints' => [['x' => 2, 'y' => 1]], 'remember' => 'here'], 'extra' => true, 'type' => 'move'])) + ->toBe(['type' => 'move', 'route' => ['remember' => 'here', 'waypoints' => [['y' => 1, 'x' => 2]]], 'list' => [1, 3], 'extra' => true]); +}); diff --git a/tests/Unit/Cutscenes/FieldEffectReferenceTest.php b/tests/Unit/Cutscenes/FieldEffectReferenceTest.php index e75868e..ab849fc 100644 --- a/tests/Unit/Cutscenes/FieldEffectReferenceTest.php +++ b/tests/Unit/Cutscenes/FieldEffectReferenceTest.php @@ -95,11 +95,12 @@ function fieldEffectIssues(string $root, string $where): array setCutsceneField($editor, 'command4Effect', ''); setCutsceneField($editor, 'command4Animation', 'Slash'); + // The animation the file still holds keeps its authored place. expect(libraryOf($editor)->find(CutsceneType::CINEMATIC, 'harbour-lanterns')?->commands()[4] ?? [])->toBe([ 'type' => 'field_animation', + 'animation' => 'Slash', 'target' => ['kind' => 'staged_actor', 'id' => 'boat-east'], 'authoring' => ['note' => 'kept'], - 'animation' => 'Slash', ]); }); diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php index 4f45a27..8ccf64c 100644 --- a/tests/Unit/Session/CutsceneRecordSessionTest.php +++ b/tests/Unit/Session/CutsceneRecordSessionTest.php @@ -285,3 +285,57 @@ function expectTerminalCanvas(array $canvas, int $columns, int $rows): void expect(fn() => $session->exchangeCinematicScene([$ready]))->toThrow(SessionRefusal::class, 'No cinematic is being previewed') ->and(sourceHashTree($root))->toBe($before); }); + +it('replaces a branch arm\'s only route with a new one built row by row, and saves what it reads back', function () { + $root = cutsceneProject(); + $file = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($file, <<<'PHP' + 'branch', + 'conditions' => [ + ['type' => 'event', 'name' => 'a', 'negate' => true], + ], + 'then' => [[ + 'type' => 'move_route', 'subject' => 'player', 'remember' => 'caller-entry', + 'waypoints' => [['y' => 1], +['x' => 3], ['y' => 2] ], 'secondsPerStep' => 0.1, + ]], + 'else' => [], + ], +]; +PHP); + $session = EditorSession::open($root); + $category = 'cutscenes/cinematic'; + $frame = ['commands', 0, 'then']; + $setRow = function (string $name, string $value, array $entry) use ($session, $category, $frame): void { + $row = array_find($session->readDatabaseRecord($category, 0, $frame)['rows'], + static fn(array $row): bool => ($row['name'] ?? $row['label']) === $name && ($row['entry'] ?? null) === $entry); + $session->applyDatabaseRecord($category, 0, $row['key'], $value); + }; + + $session->removeDatabaseItem($category, 0, $session->readDatabaseRecord($category, 0, $frame)['rows'][0]['key']); + $session->addDatabaseItem($category, 0, ['frame' => $frame]); + foreach (['Type' => 'move_route', 'Subject' => 'player', 'Route Mode' => 'waypoints', 'Remember As' => 'caller-entry', 'Seconds Per Step' => '0.1'] as $name => $value) { + $setRow($name, $value, [0]); + } + foreach ([['Y' => '31'], ['X' => '47'], ['Y' => '20']] as $n => $point) { + $mode = array_find($session->readDatabaseRecord($category, 0, $frame)['rows'], + static fn(array $row): bool => ($row['name'] ?? '') === 'Route Mode' && ($row['entry'] ?? null) === [0]); + $session->addDatabaseItem($category, 0, $mode['key'], true); + foreach ($point as $name => $value) { + $setRow($name, $value, [0, $n]); + } + } + $saved = $session->saveAll(); + + expect($saved['failures'])->toBe([]) + // The route keeps the key order the file wrote it in. + ->and((require $file)[0]['then'])->toBe([[ + 'type' => 'move_route', 'subject' => 'player', 'remember' => 'caller-entry', + 'waypoints' => [['y' => 31], ['x' => 47], ['y' => 20]], 'secondsPerStep' => 0.1, + ]]) + ->and((string) file_get_contents($file))->toContain("['type' => 'event', 'name' => 'a', 'negate' => true],"); +}); From 20766e9b2def5d155a35908cba67c13f9ae8a705 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 19 Oct 2026 20:19:24 +0200 Subject: [PATCH 343/344] build(deps): lock the Engine published on 2026-10-19 Editor develop consumes MapEventDefinition::ROOT_FIELDS and ScriptEventTrigger::DATA_FIELDS from Engine 799203d, published today; the lock still named Engine 5c77c5a. --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index f0b62c4..e74068e 100644 --- a/composer.lock +++ b/composer.lock @@ -260,12 +260,12 @@ "source": { "type": "git", "url": "https://github.com/ichiloto/engine.git", - "reference": "5c77c5a57573db0d86682b33180f81fafdbe27c7" + "reference": "799203d609fc6f22651db5816946e8987afe646c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ichiloto/engine/zipball/5c77c5a57573db0d86682b33180f81fafdbe27c7", - "reference": "5c77c5a57573db0d86682b33180f81fafdbe27c7", + "url": "https://api.github.com/repos/ichiloto/engine/zipball/799203d609fc6f22651db5816946e8987afe646c", + "reference": "799203d609fc6f22651db5816946e8987afe646c", "shasum": "" }, "require": { @@ -306,7 +306,7 @@ "issues": "https://github.com/ichiloto/engine/issues", "source": "https://github.com/ichiloto/engine/tree/develop" }, - "time": "2026-10-18T21:42:40+00:00" + "time": "2026-10-19T15:55:46+00:00" }, { "name": "league/climate", From d54a76ec6f819287705f3a8276f5d1d007bacd84 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 19 Oct 2026 20:34:35 +0200 Subject: [PATCH 344/344] fix(session): write a change set whole or not at all changes.commit saved each document through its own transaction in turn, so a document that refused late, such as a cinematic whose staged script did not read back, left the documents saved before it written: a partial change set on disk. Every document now works out its save before anything is written: maps, cutscenes, record databases, actors, settings files and the save relocations a map needs each declare their files into one transaction, prove their staged bytes, and adopt their written state only once the whole set is installed. SaveSet installs them; a file that cannot be installed puts back the ones already installed. Each document's own save runs the same prepared path alone, and Save All keeps saving documents one by one. A document that refuses refuses the change set as an error does: nothing is written, the change set is undone, and the reason starts "Nothing was written:" and names the document. A saved result's `written` names each document and the files it wrote or removed. --- docs/guides/change-maps-from-a-script.md | 15 ++- src/Cutscenes/CutsceneAsset.php | 147 ++++++++++++--------- src/Cutscenes/CutsceneLibrary.php | 25 ++++ src/Database/PhpDataFile.php | 24 +++- src/Database/ProjectRecordDatabase.php | 100 ++++++++++---- src/Database/SharedFileTransaction.php | 140 ++++++++++++-------- src/Maps/SaveRelocationPlan.php | 17 +++ src/ProjectActor.php | 33 +++-- src/ProjectActorDatabase.php | 36 +++++ src/ProjectMap.php | 144 +++++++++++--------- src/ProjectSettingsFile.php | 60 ++++++--- src/Session/EditorSession.php | 96 +++++++++----- src/Storage/FileSetTransaction.php | 30 ++++- src/Storage/PreparedSave.php | 92 +++++++++++++ src/Storage/SaveSet.php | 113 ++++++++++++++++ src/Storage/SaveSetAdoptionFailure.php | 31 +++++ src/Storage/SaveSetRefusal.php | 34 +++++ src/Storage/SourceSetPlan.php | 42 ++++++ src/Storage/WorkspaceSave.php | 77 +++++++++++ tests/Unit/ProjectIntegrityMatrixTest.php | 2 +- tests/Unit/SaveSetTest.php | 141 ++++++++++++++++++++ tests/Unit/Session/AtomicChangeSetTest.php | 69 ++++++++++ 22 files changed, 1188 insertions(+), 280 deletions(-) create mode 100644 src/Storage/PreparedSave.php create mode 100644 src/Storage/SaveSet.php create mode 100644 src/Storage/SaveSetAdoptionFailure.php create mode 100644 src/Storage/SaveSetRefusal.php create mode 100644 tests/Unit/SaveSetTest.php diff --git a/docs/guides/change-maps-from-a-script.md b/docs/guides/change-maps-from-a-script.md index c24900d..7aefb20 100644 --- a/docs/guides/change-maps-from-a-script.md +++ b/docs/guides/change-maps-from-a-script.md @@ -100,10 +100,17 @@ files when they run; a refused batch undoes them. Inserting or removing rows needs no pending edits, so put it first, or in its own batch. The batch alone writes. When it is valid, `changes.commit` saves everything -it changed: databases (field resources, common events and the rest) and -cutscenes first, then maps, each through its own transaction. So -`database.save`, `map.save` and `project.saveAll` are refused inside a batch; -leave them out. Moving or deleting a map is refused too, since it clears the +it changed, databases (field resources, common events and the rest), +cutscenes, maps and the save relocations its maps need, as one transaction. +Every file is worked out and staged first, and each document proves its +staged file reads back as what it holds. Only then is any file installed, +and a file that cannot be installed puts back the ones already installed. +So the batch is written whole or not at all. A document that refuses here +refuses the batch as an error does: nothing is written, the batch is undone, +and `reason` starts with `Nothing was written:` and names the document. A +saved result's `written` names each document written and the files it wrote +or removed. `database.save`, `map.save` and `project.saveAll` are refused +inside a batch; leave them out. Moving or deleting a map is refused too, since it clears the undo history a refused batch relies on. Deleting a database record inside a batch removes its file only when the batch is written. diff --git a/src/Cutscenes/CutsceneAsset.php b/src/Cutscenes/CutsceneAsset.php index d2bfeb8..cae36fc 100644 --- a/src/Cutscenes/CutsceneAsset.php +++ b/src/Cutscenes/CutsceneAsset.php @@ -10,6 +10,8 @@ use Ichiloto\Editor\Cutscenes\Source\SourceUnreadable; use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Editor\Storage\FileSetTransactionFailure; +use Ichiloto\Editor\Storage\PreparedSave; +use Ichiloto\Editor\Storage\SaveSet; use Ichiloto\Editor\Database\InnPresentationFields; use Ichiloto\Editor\Database\PhpValueExporter; use Ichiloto\Editor\Database\ReferenceCatalog; @@ -762,11 +764,17 @@ public function assertSourcesUnchanged(): void private function createSourceTransaction(): FileSetTransaction { $transaction = new FileSetTransaction($this->folder); + $this->expectLoadedSources($transaction); + return $transaction; + } + + /** Requires each member to hold what it was loaded with. */ + private function expectLoadedSources(FileSetTransaction $transaction): void + { foreach ($this->paths() as $path) { // Null requires an unwritten/deleted member to remain absent. $transaction->expectSource($path, $this->baselineSources[$path] ?? null, allowMissing: true); } - return $transaction; } /** Checks both source edits without writing, so unsupported expressions never enter history. */ @@ -804,7 +812,8 @@ public function assertMovementRoutesValid(): void * file it had already touched. A deleted asset's pair is removed through * the same boundary. Nothing here advances until the whole operation * succeeds, so the pair on disk is always the complete old one or the - * complete new one. + * complete new one. {@see prepareSave()} works the save out; a change + * set installs it with every other document it changed. * * @param callable(string ...$paths): void|null $backup Called with the paths about to be overwritten, before they are. * @param ReferenceCatalog|null $references The workspace's live authoring references. @@ -813,15 +822,33 @@ public function assertMovementRoutesValid(): void */ public function save(?callable $backup = null, ?ReferenceCatalog $references = null): bool { - $transaction = $this->createSourceTransaction(); - $transaction->assertSourcesUnchanged(); + $prepared = $this->prepareSave($references); + + return $prepared !== null && SaveSet::commitOne($this->folder, $prepared, $backup) !== []; + } + + /** + * Works out this asset's save without writing it: both proposed sources, + * each touched only where it changed, and the proof that each staged + * file reads back as the edited side and hydrates through the engine. A + * deleted asset's save removes its pair. {@see SaveSet} installs it, + * alone or with the rest of a change set. Null when there is nothing to + * save. + * + * @param ReferenceCatalog|null $references The workspace's live authoring references. + * @throws RuntimeException When the asset is read-only or its sources cannot take the edits; nothing is written. + */ + public function prepareSave(?ReferenceCatalog $references = null): ?PreparedSave + { + $this->assertSourcesUnchanged(); + $label = sprintf('%s %s', $this->type->noun(), $this->id); if ($this->isDeleted) { - return $this->delete($backup); + return $this->prepareDeletion($label); } $missing = array_any($this->paths(), static fn(string $path): bool => ! is_file($path)); if (! $this->isDirty() && ! $this->isNew && ! $missing) { - return false; + return null; } if ($this->readOnlyReason !== null) { @@ -831,7 +858,6 @@ public function save(?callable $backup = null, ?ReferenceCatalog $references = n $this->assertInnPresentationsValid($references); $this->assertMovementRoutesValid(); - // 1. Both proposed sources, each touched only where it changed. $hasData = $this->type->hasDataFile(); $dataSource = $hasData ? $this->proposedSource($this->dataDocument, $this->loadedData, $this->data, 'data') : ''; $partnerSource = $this->proposedSource($this->partnerDocument, $this->loadedPartner, $this->partner, $this->type->partnerNoun()); @@ -841,46 +867,36 @@ public function save(?callable $backup = null, ?ReferenceCatalog $references = n if (! $writeData && ! $writePartner) { // Dirty by fingerprint but identical in source: a same-value // round trip. Clean without writing. - $this->adoptWritten($this->dataDocument?->source ?? $dataSource, $this->partnerDocument?->source ?? $partnerSource); - - return false; - } - - if ($writeData) { - $transaction->write($this->dataPath(), $dataSource); - } + $kept = [$this->dataDocument?->source ?? $dataSource, $this->partnerDocument?->source ?? $partnerSource]; - if ($writePartner) { - $transaction->write($this->partnerPath(), $partnerSource); + return PreparedSave::adoptOnly($label, fn() => $this->adoptWritten(...$kept)); } - // 2. Stage both, then read back the exact bytes that will be - // installed -- evaluated where the game would evaluate them. - $staged = $transaction->stage(); - - try { - $evaluatedData = $hasData - ? $this->evaluateSide($staged[$this->dataPath()] ?? $this->dataPath(), $this->data, basename($this->dataPath()), 'data') - : []; - $evaluatedPartner = $this->evaluateSide($staged[$this->partnerPath()] ?? $this->partnerPath(), $this->partner, basename($this->partnerPath()), $this->type->partnerNoun()); - - // 3. Hydrate and compile through the engine. - $this->hydrate($evaluatedData, $evaluatedPartner); - } catch (Throwable $throwable) { - // Nothing has been installed: drop the staged copies and leave - // both files exactly as they were. - $transaction->rollBack(); - - throw $throwable; - } - - // 4. Back up once, then install the pair or restore it. - $transaction->commit($backup); - - $this->adoptWritten($dataSource, $partnerSource); - $this->isNew = false; - - return true; + return new PreparedSave( + $label, + function (FileSetTransaction $transaction) use ($writeData, $writePartner, $dataSource, $partnerSource): void { + $this->expectLoadedSources($transaction); + if ($writeData) { + $transaction->write($this->dataPath(), $dataSource); + } + if ($writePartner) { + $transaction->write($this->partnerPath(), $partnerSource); + } + }, + // The exact bytes that will be installed, read back where the + // game would evaluate them, then hydrated through the engine. + function (array $staged) use ($hasData): void { + $evaluatedData = $hasData + ? $this->evaluateSide($staged[$this->dataPath()] ?? $this->dataPath(), $this->data, basename($this->dataPath()), 'data') + : []; + $evaluatedPartner = $this->evaluateSide($staged[$this->partnerPath()] ?? $this->partnerPath(), $this->partner, basename($this->partnerPath()), $this->type->partnerNoun()); + $this->hydrate($evaluatedData, $evaluatedPartner); + }, + function () use ($dataSource, $partnerSource): void { + $this->adoptWritten($dataSource, $partnerSource); + $this->isNew = false; + }, + ); } /** @@ -1011,29 +1027,38 @@ public function compiledSummon(EffectPresentation $presentation = EffectPresenta * @param callable(string ...$paths): void|null $backup * @throws FileSetTransactionFailure When the pair could not be removed. */ - private function delete(?callable $backup): bool + /** + * The save that removes a deleted asset's pair. Only the two files are + * the asset's; anything else in the folder is left for its author, and + * the folder goes only when empty. + */ + private function prepareDeletion(string $label): PreparedSave { - if (! is_dir($this->folder)) { - // Never written: nothing on disk to take away. + $forget = function (): void { $this->baselineSources = []; $this->captureBaseline(); + }; - return false; - } - - $transaction = $this->createSourceTransaction(); - - foreach ($this->paths() as $path) { - $transaction->remove($path); + if (! is_dir($this->folder)) { + // Never written: nothing on disk to take away. + return PreparedSave::adoptOnly($label, $forget); } - // Only the two files are the asset's; anything else in the folder is - // left for its author, and the folder goes only when empty. - $transaction->commit($backup); - $this->baselineSources = []; - $this->captureBaseline(); - - return true; + return new PreparedSave( + $label, + function (FileSetTransaction $transaction): void { + $this->expectLoadedSources($transaction); + foreach ($this->paths() as $path) { + $transaction->remove($path); + } + }, + adopt: function () use ($forget): void { + if (is_dir($this->folder) && (scandir($this->folder) ?: []) === ['.', '..']) { + @rmdir($this->folder); + } + $forget(); + }, + ); } /** diff --git a/src/Cutscenes/CutsceneLibrary.php b/src/Cutscenes/CutsceneLibrary.php index 0d8af1f..941e9a5 100644 --- a/src/Cutscenes/CutsceneLibrary.php +++ b/src/Cutscenes/CutsceneLibrary.php @@ -12,6 +12,7 @@ use Ichiloto\Editor\Database\RecordSchema; use Ichiloto\Editor\Database\RecordSchemaCatalog; use Ichiloto\Editor\Database\ReferenceCatalog; +use Ichiloto\Editor\Storage\PreparedSave; use RuntimeException; use Throwable; @@ -656,6 +657,30 @@ public function saveAll(?Closure $backup = null): array return ['saved' => $saved, 'failed' => $failed]; } + /** + * Works out every dirty asset's save without writing any, for a change + * set to install together with the rest of what it changed. + * + * @return list + * @throws RuntimeException When an asset is read-only or its sources cannot take its edits. + */ + public function prepareSaves(): array + { + $saves = []; + + foreach (CutsceneType::cases() as $type) { + foreach ($this->assets($type) as $asset) { + $prepared = $asset->isDirty() ? $asset->prepareSave($this->authoringReferences) : null; + + if ($prepared !== null) { + $saves[] = $prepared->withAdoption(fn() => $this->forgetDeleted($type)); + } + } + } + + return $saves; + } + /** * Saves one asset. * diff --git a/src/Database/PhpDataFile.php b/src/Database/PhpDataFile.php index b567482..a5c819d 100644 --- a/src/Database/PhpDataFile.php +++ b/src/Database/PhpDataFile.php @@ -9,6 +9,7 @@ use Ichiloto\Editor\Cutscenes\Source\SourceUnreadable; use Ichiloto\Editor\IO\AtomicFile; use Ichiloto\Editor\ProjectDirectoryContext; +use Ichiloto\Editor\Storage\FileSetTransaction; use RuntimeException; use Throwable; @@ -567,11 +568,32 @@ public function save(mixed $payload): void } AtomicFile::write($this->path, $contents); - // The file now reads as this payload, so the next save edits from here. + $this->adoptWritten($payload, $contents); + } + + /** + * Adopts what a save wrote: the file now reads as this payload, so the + * next save edits from here. + */ + public function adoptWritten(mixed $payload, string $contents): void + { $this->payload = $payload; $this->source = $contents; } + /** + * Requires the file to hold what it was read as when a transaction + * installs a save of it, or to be absent when it was. + */ + public function expectUnchanged(FileSetTransaction $transaction): void + { + if ($this->source !== null) { + $transaction->expectSource($this->path, $this->source); + } elseif (! $this->exists) { + $transaction->expectSource($this->path, null); + } + } + /** * Returns what saving a value would write, refusing what the file's own * source cannot take, without writing anything: an edit can be refused diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index 1c3dc5e..cc8196b 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -6,7 +6,9 @@ use Ichiloto\Editor\Field\PlayerPresentationFields; -use Ichiloto\Editor\IO\AtomicFile; +use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Editor\Storage\PreparedSave; +use Ichiloto\Editor\Storage\SaveSet; use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; use Ichiloto\Editor\Cutscenes\Source\SourceNode; use Ichiloto\Editor\Cutscenes\Source\SourceUnreadable; @@ -1740,19 +1742,36 @@ public function countNestedSubItems(int $index, int $parentIndex): int * @return void */ public function save(): void + { + $prepared = $this->prepareSave(); + + if ($prepared !== null) { + SaveSet::commitOne(dirname($this->path), $prepared); + } + } + + /** + * Works out the category's save without writing it, for {@see SaveSet} + * to install alone or with the rest of a change set: its own file, its + * share of a file other categories edit too, or a file per record and + * the record files it deleted. A category the map owns hands its records + * to the map now, which the map's own save writes. Null when nothing + * diverges from the last save; writing would only canonicalize authored + * formatting. + * + * @throws RuntimeException When the category is read-only or its files cannot take its edits. + */ + public function prepareSave(): ?PreparedSave { if (! $this->isEditable()) { throw new RuntimeException(sprintf('%s is read-only: %s.', $this->schema->entryNoun, $this->readOnlyReason)); } if ($this->settings !== null) { - $this->settings->save(); - return; + return $this->settings->prepareSave(); } if (! $this->isDirty()) { - // Nothing diverges from the last save; writing would only - // canonicalize authored formatting. - return; + return null; } foreach ($this->getRecords() as $record) { @@ -1767,25 +1786,26 @@ public function save(): void if ($this->schema->storage === RecordStorage::LIST_FILE) { // The transaction adopts the written file and cleans every // category it wrote for, this one included. - SharedFileTransaction::commit([$this]); - - return; + return SharedFileTransaction::prepare([$this]); } - match ($this->schema->storage) { - RecordStorage::DIRECTORY => $this->saveDirectory(), - RecordStorage::CONFIG_SUBTREE => null, - RecordStorage::MAP_OWNED => $this->writeBack !== null - ? ($this->writeBack)(array_map(static fn(ProjectRecord $record): array|object => $record->toArray(), $this->getRecords())) - : null, - RecordStorage::LIST_FILE => null, + $clean = function (): void { + foreach ($this->records as $record) { + $record->markClean(); + } + + $this->captureBaseline(); }; - foreach ($this->records as $record) { - $record->markClean(); + if ($this->schema->storage === RecordStorage::DIRECTORY) { + return $this->prepareDirectorySave($clean); } - $this->captureBaseline(); + if ($this->schema->storage === RecordStorage::MAP_OWNED && $this->writeBack !== null) { + ($this->writeBack)(array_map(static fn(ProjectRecord $record): array|object => $record->toArray(), $this->getRecords())); + } + + return PreparedSave::adoptOnly($this->schema->key, $clean); } /** @@ -2990,11 +3010,15 @@ public function assertUnchangedSource(): void } } - private function saveDirectory(): void + /** + * The save of a category kept a file per record: each dirty record's + * file, and the files of records it deleted. + * + * @param \Closure(): void $clean Cleans the category once its files are installed. + */ + private function prepareDirectorySave(\Closure $clean): PreparedSave { - if (! is_dir($this->path) && ! mkdir($this->path, 0777, true) && ! is_dir($this->path)) { - throw new RuntimeException("Unable to create {$this->path}."); - } + $writes = []; foreach ($this->records as $record) { $file = $record->file; @@ -3005,14 +3029,34 @@ private function saveDirectory(): void continue; } - $file->save($this->composeRecordFilePayload($record)); + $payload = $this->composeRecordFilePayload($record); + $writes[] = [$file, $payload, $file->composeContents($payload, checkDisk: true)]; } - foreach ($this->stagedDeletions as $path) { - @unlink($path); - } + // A record written where a deleted one lived replaces it. + $written = array_map(static fn(array $write): string => $write[0]->path, $writes); + $deletions = array_values(array_diff($this->stagedDeletions, $written)); - $this->stagedDeletions = []; + return new PreparedSave( + $this->schema->key, + static function (FileSetTransaction $transaction) use ($writes, $deletions): void { + foreach ($writes as [$file, , $contents]) { + $file->expectUnchanged($transaction); + $transaction->write($file->path, $contents); + } + foreach ($deletions as $path) { + $transaction->remove($path); + } + }, + adopt: function () use ($writes, $clean): void { + foreach ($writes as [$file, $payload, $contents]) { + $file->adoptWritten($payload, $contents); + } + + $this->stagedDeletions = []; + $clean(); + }, + ); } /** diff --git a/src/Database/SharedFileTransaction.php b/src/Database/SharedFileTransaction.php index 5efa001..4dc8228 100644 --- a/src/Database/SharedFileTransaction.php +++ b/src/Database/SharedFileTransaction.php @@ -4,7 +4,9 @@ namespace Ichiloto\Editor\Database; -use Ichiloto\Editor\IO\AtomicFile; +use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Editor\Storage\PreparedSave; +use Ichiloto\Editor\Storage\SaveSet; use RuntimeException; /** @@ -56,23 +58,22 @@ public static function groupByPath(array $databases): array /** * Writes one file for every dirty category that shares it. * - * The file is read once. If every dirty category can say what it wants - * as edits to the author's own source, those edits are composed against - * that one reading and written in one replacement. Otherwise every dirty - * category folds its records into that one reading's payload, in turn, - * and the result is written in one replacement -- unless a category's - * entries are constructor calls whose list has changed, which is - * refused with the reason rather than regenerated. Either way there is - * one payload, one write, and no baseline moves until it has succeeded. - * * @param array $databases The categories sharing one file. - * @return bool True when the file was written. + * @return bool True when the categories were saved. * @throws SourceIdentityConflict When an entry cannot be addressed with certainty. * @throws RuntimeException When the write is refused or fails. */ public static function commit(array $databases): bool { - return self::compose($databases, write: true); + $prepared = self::prepare($databases); + + if ($prepared === null) { + return false; + } + + SaveSet::commitOne(dirname(self::listDirty($databases)[0]->backingFilePath()), $prepared); + + return true; } /** @@ -86,25 +87,57 @@ public static function commit(array $databases): bool */ public static function preview(array $databases): void { - self::compose($databases, write: false); + self::compose($databases, forSave: false); + } + + /** + * Works out the one write for every dirty category sharing a file, for + * {@see SaveSet} to install alone or with the rest of a change set. No + * category's baseline moves until the file is installed. Null when no + * category sharing it is dirty. + * + * The file is read once. If every dirty category can say what it wants + * as edits to the author's own source, those edits are composed against + * that one reading. Otherwise every dirty category folds its records into + * that one reading's payload, in turn -- unless a category's entries are + * constructor calls whose list has changed, which is refused with the + * reason rather than regenerated. Either way there is one payload and + * one write. + * + * @param array $databases The categories sharing one file. + * @throws SourceIdentityConflict When an entry cannot be addressed with certainty. + * @throws RuntimeException When the write would be refused. + */ + public static function prepare(array $databases): ?PreparedSave + { + return self::compose($databases, forSave: true); } /** * @param array $databases - * @param bool $write Whether to write the result and adopt it, or only work it out. + * @return list */ - private static function compose(array $databases, bool $write): bool + private static function listDirty(array $databases): array { - $dirty = array_values(array_filter( + return array_values(array_filter( $databases, static fn(object $database): bool => $database instanceof ProjectRecordDatabase && $database->sharesBackingFile() && $database->isEditable() && $database->isDirty(), )); + } + + /** + * @param array $databases + * @param bool $forSave Whether the result is to be saved, checked as a save is, or only worked out. + */ + private static function compose(array $databases, bool $forSave): ?PreparedSave + { + $dirty = self::listDirty($databases); if ($dirty === []) { - return false; + return null; } foreach ($dirty as $database) { @@ -114,48 +147,48 @@ private static function compose(array $databases, bool $write): bool $first = $dirty[0]; $path = $first->backingFilePath(); $file = PhpDataFile::load($path, $first->projectRoot()); + $label = implode(', ', array_map(static fn(ProjectRecordDatabase $database): string => $database->schema->key, $dirty)); + $adopt = static fn() => self::adopt($dirty); - if ($write) { + if ($forSave) { self::checkSave($databases, $dirty, $file); } // One snapshot of the source, and every category's wants resolved // against it before a byte is written. - if (self::commitToSource($dirty, $file, $write)) { - if ($write) { - self::adopt($dirty); - } + $composed = self::composeSource($dirty, $file); - return true; - } - - foreach ($dirty as $database) { - $refusal = $database->regenerationRefusal($file); + if ($composed === null) { + foreach ($dirty as $database) { + $refusal = $database->regenerationRefusal($file); - if ($refusal !== null) { - throw new RuntimeException($refusal); + if ($refusal !== null) { + throw new RuntimeException($refusal); + } } - } - - // One authoritative payload: the file as it is now, with every dirty - // category's part folded in, in turn, and written once. No category - // describes the whole file from its own older reading of it. - $payload = is_array($file->payload) ? $file->payload : []; - foreach ($dirty as $database) { - $payload = $database->foldInto($payload); - } + // One authoritative payload: the file as it is now, with every dirty + // category's part folded in, in turn, and written once. No category + // describes the whole file from its own older reading of it. + $payload = is_array($file->payload) ? $file->payload : []; - if (! $write) { - $file->composeContents($payload); + foreach ($dirty as $database) { + $payload = $database->foldInto($payload); + } - return false; + $composed = $file->composeContents($payload, checkDisk: $forSave); } - $file->save($payload); - self::adopt($dirty); + if ($composed === '') { + // Nothing to write, but the categories are still adopted so a + // save that found no work leaves them clean. + return PreparedSave::adoptOnly($label, $adopt); + } - return true; + return new PreparedSave($label, static function (FileSetTransaction $transaction) use ($file, $path, $composed): void { + $file->expectUnchanged($transaction); + $transaction->write($path, $composed); + }, adopt: $adopt); } /** @@ -191,12 +224,13 @@ private static function checkSave(array $databases, array $dirty, PhpDataFile $f * * @param ProjectRecordDatabase[] $dirty The dirty categories. * @param PhpDataFile $file The file, read once. - * @return bool True when the file was written this way. + * @return string|null The file's new contents, an empty string when there is nothing to write, or null when + * a category cannot say what it wants in the source's terms. */ - private static function commitToSource(array $dirty, PhpDataFile $file, bool $write = true): bool + private static function composeSource(array $dirty, PhpDataFile $file): ?string { if (! is_file($file->path)) { - return false; + return null; } $document = PhpSourceDocument::parse((string) file_get_contents($file->path)); @@ -208,7 +242,7 @@ private static function commitToSource(array $dirty, PhpDataFile $file, bool $wr $plan = $database->sourcePlan($document, $file); if ($plan === null) { - return false; + return null; } $patches = [...$patches, ...$plan['patches']]; @@ -217,9 +251,7 @@ private static function commitToSource(array $dirty, PhpDataFile $file, bool $wr } if ($patches === [] && $removals === [] && $insertions === []) { - // Nothing to write, but the categories are still adopted so a - // save that found no work leaves them clean. - return true; + return ''; } // Values first, while every position still means what the snapshot @@ -264,11 +296,7 @@ private static function commitToSource(array $dirty, PhpDataFile $file, bool $wr $document = $document->withNewEntry($insertion['class'], $insertion['arguments']); } - if ($write) { - AtomicFile::write($file->path, $document->source); - } - - return true; + return $document->source; } /** diff --git a/src/Maps/SaveRelocationPlan.php b/src/Maps/SaveRelocationPlan.php index e7d2a82..6f57f10 100644 --- a/src/Maps/SaveRelocationPlan.php +++ b/src/Maps/SaveRelocationPlan.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\GenericCommand; +use Ichiloto\Editor\Storage\PreparedSave; use Ichiloto\Editor\Storage\SourceSetPlan; /** @@ -70,6 +71,22 @@ public function apply(): Command return new GenericCommand('Add save relocations', static fn() => $sources->apply(), static fn() => $sources->revert()); } + /** + * Works out writing the manifest step without writing it, for a change + * set to install with the map whose save needs it. The step that removes + * it again is recorded once it is installed. + * + * @param \Closure(Command): void $record Records the undo step. + */ + public function prepareApply(\Closure $record): PreparedSave + { + $sources = $this->sources; + + return $sources->prepareApply(sprintf('save relocations for %s', $this->mapId))->withAdoption(static fn() => $record( + new GenericCommand('Add save relocations', static fn() => $sources->apply(), static fn() => $sources->revert()), + )); + } + public function revert(): void { $this->sources->revert(); diff --git a/src/ProjectActor.php b/src/ProjectActor.php index c4d860e..8bbfd93 100644 --- a/src/ProjectActor.php +++ b/src/ProjectActor.php @@ -11,6 +11,8 @@ use Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal; use Ichiloto\Editor\Database\PhpDataFile; use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Editor\Storage\PreparedSave; +use Ichiloto\Editor\Storage\SaveSet; use Ichiloto\Engine\Entities\Enumerations\WeaponType; use InvalidArgumentException; @@ -753,22 +755,29 @@ public function setField(string $field, mixed $value): void * @return void */ public function save(): void + { + SaveSet::commitOne(dirname($this->path), $this->prepareSave()); + } + + /** + * Works out the actor's save without writing it: its file, and the proof + * that the staged file reads back as the edited actor. + */ + public function prepareSave(): PreparedSave { $source = $this->getProposedSource(); // The file keeps its authored key order: the actor holds what it reads back as. $this->payload = PhpArraySourceDocument::parse($source)->alignValue($this->payload); - $transaction = new FileSetTransaction(dirname($this->path)); - $transaction->write($this->path, $source); - try { - $staged = $transaction->stage(); - $this->validateStagedSource($staged[$this->path]); - $transaction->commit(); - } catch (\Throwable $failure) { - $transaction->rollBack(); - throw $failure; - } - $this->writtenSource = $source; - $this->captureBaseline(); + + return new PreparedSave( + sprintf('actor %s', $this->id), + fn(FileSetTransaction $transaction) => $transaction->write($this->path, $source), + fn(array $staged) => $this->validateStagedSource($staged[$this->path]), + function () use ($source): void { + $this->writtenSource = $source; + $this->captureBaseline(); + }, + ); } /** Preflights edits without writing or flattening authored PHP. diff --git a/src/ProjectActorDatabase.php b/src/ProjectActorDatabase.php index ae186d3..9eb593e 100644 --- a/src/ProjectActorDatabase.php +++ b/src/ProjectActorDatabase.php @@ -6,6 +6,8 @@ use Ichiloto\Engine\Entities\Actors\ActorDefinition; use Ichiloto\Engine\Util\Stores\ActorStore; +use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Editor\Storage\PreparedSave; use Throwable; use RuntimeException; @@ -271,6 +273,40 @@ public function save(): void } } + /** + * Works out the actor saves without writing any: the staged deletions, + * then every dirty actor's file, for a change set to install together. + * + * @return list + */ + public function prepareSaves(): array + { + $saves = []; + $deletions = $this->pendingDeletions; + + if ($deletions !== []) { + $saves[] = new PreparedSave( + 'actor deletions', + static function (FileSetTransaction $transaction) use ($deletions): void { + foreach ($deletions as $path) { + $transaction->remove($path); + } + }, + adopt: function (): void { + $this->pendingDeletions = []; + }, + ); + } + + foreach ($this->actors as $actor) { + if ($actor->isDirty()) { + $saves[] = $actor->prepareSave(); + } + } + + return $saves; + } + /** * Resolves the next available actor file id. * diff --git a/src/ProjectMap.php b/src/ProjectMap.php index 602d59b..7374dd5 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -16,6 +16,8 @@ use Ichiloto\Editor\Storage\FileSetTransactionFailure; use Ichiloto\Editor\Storage\FilesystemFileSetOperations; use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Editor\Storage\PreparedSave; +use Ichiloto\Editor\Storage\SaveSet; use Ichiloto\Engine\Core\CellArea; use Ichiloto\Engine\Field\MapGraphics; use Ichiloto\Engine\Field\MapGridSource; @@ -276,12 +278,18 @@ private function createSourceTransaction( bool $reserveFolder = false, ): FileSetTransaction { $transaction = new FileSetTransaction($directory, $files ?? new FilesystemFileSetOperations(), $reserveFolder); + $this->expectLoadedSources($transaction); + return $transaction; + } + + /** Requires the map's members to hold what it loaded. */ + private function expectLoadedSources(FileSetTransaction $transaction): void + { // Missing data can be recovered from the loaded source. An existing // file must still match it; the transaction captures metadata before // reading, so a failed move restores the original access time too. $transaction->expectSource($this->dataPath, $this->baselineDataSource, allowMissing: true); $this->layers->expectSources($transaction); - return $transaction; } /** @@ -2491,6 +2499,24 @@ public function willMoveOnSave(): bool * @return string The saved map id. */ public function save(?callable $backup = null, ?FileSetOperations $files = null): string + { + $target = $this->resolveSaveTarget(); + SaveSet::commitOne($target['directory'], $this->prepareSave(), $backup, $files); + + return $target['mapId']; + } + + /** + * Works out this map's save without writing it: the members that + * changed, and the proof that the staged data file evaluates to exactly + * the map being saved -- run where the game would run it, so relative + * requires resolve. {@see SaveSet} installs it, alone or with the rest of + * a change set. A map with nothing to write still proves its sources + * are the ones it loaded. + * + * @throws MapSourceRefusal When the map's sources cannot take its edits; nothing is written. + */ + public function prepareSave(): PreparedSave { $this->assertEditable(); if (($this->loadedData['worldObjects'] ?? null) !== ($this->editableData['worldObjects'] ?? null)) { @@ -2506,7 +2532,8 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) $target = $this->resolveSaveTarget(); $moving = $target['directory'] !== $this->directory; $this->assertGridSourcesCanonical(); - $transaction = $this->createSourceTransaction($target['directory'], $files); + $label = sprintf('map %s', $this->mapId); + $expectLoaded = fn(FileSetTransaction $transaction) => $this->expectLoadedSources($transaction); $tripletExists = is_file($this->dataPath) && count($this->layers->getSources()) > 0 @@ -2515,8 +2542,7 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) if (! $this->isDirty() && ! $moving && is_dir($this->directory) && $tripletExists) { // Nothing diverges from the last save: writing would only // canonicalize hand-authored formatting and churn mtimes. - $transaction->commit(); - return $target['mapId']; + return new PreparedSave($label, $expectLoaded); } $this->validateLayerContracts(); @@ -2538,71 +2564,65 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) if (! $moving && ! $writeData && ! $writeMap && ! $writeEvent) { // Dirty by fingerprint but identical in content: a same-value // round trip. Clean without writing. - $transaction->commit(); - $this->adoptWritten($dataSource, $mapPayload, $eventPayload); - - return $target['mapId']; - } - - if ($writeData || $moving) { - $transaction->write($target['dataPath'], $dataSource); - } - $this->layers->stageChanges($transaction, $moving ? $target['directory'] : null, $moving ? basename($target['directory']) : null, $moving); - if ($moving) { - $transaction->remove($this->dataPath); - } - - // Stage everything beside its destination, then prove the staged - // data file evaluates to exactly the map being saved -- run where - // the game would run it, so relative requires resolve. - $staged = $transaction->stage(); - - if ($writeData || $moving) { - try { - $evaluatedFingerprint = null; - $evaluated = $this->evaluateFile($staged[$target['dataPath']] ?? $target['dataPath'], $evaluatedFingerprint); - } catch (\Throwable $throwable) { - $transaction->rollBack(); - - throw new RuntimeException(sprintf( - '%s: the rewritten %s could not be evaluated (%s); nothing was written.', - $this->mapId, - basename($target['dataPath']), - $throwable->getMessage(), - ), previous: $throwable); - } - - if (! is_array($evaluated) || $evaluatedFingerprint !== PhpDataFile::valueFingerprint($this->editableData)) { - $transaction->rollBack(); - - throw new RuntimeException(sprintf( - '%s: the rewritten %s would not read back as the edited map; nothing was written.', - $this->mapId, - basename($target['dataPath']), - )); - } + return new PreparedSave($label, $expectLoaded, adopt: fn() => $this->adoptWritten($dataSource, $mapPayload, $eventPayload)); } - $transaction->commit($backup); + return new PreparedSave( + $label, + function (FileSetTransaction $transaction) use ($expectLoaded, $target, $moving, $writeData, $dataSource): void { + $expectLoaded($transaction); + if ($writeData || $moving) { + $transaction->write($target['dataPath'], $dataSource); + } + $this->layers->stageChanges($transaction, $moving ? $target['directory'] : null, $moving ? basename($target['directory']) : null, $moving); + if ($moving) { + $transaction->remove($this->dataPath); + } + }, + function (array $staged) use ($target, $moving, $writeData): void { + if (! $writeData && ! $moving) { + return; + } - if ($this->layers->legacy && is_dir($target['directory'] . '/layers') - && array_diff(scandir($target['directory'] . '/layers') ?: [], ['.', '..']) === []) { - @rmdir($target['directory'] . '/layers'); - } + try { + $evaluatedFingerprint = null; + $evaluated = $this->evaluateFile($staged[$target['dataPath']] ?? $target['dataPath'], $evaluatedFingerprint); + } catch (\Throwable $throwable) { + throw new RuntimeException(sprintf( + '%s: the rewritten %s could not be evaluated (%s); nothing was written.', + $this->mapId, + basename($target['dataPath']), + $throwable->getMessage(), + ), previous: $throwable); + } - if ($moving) { - // The transaction removed the triplet; the folder follows only - // when nothing else of the author's lives in it. - if (is_dir($this->directory) && array_diff(scandir($this->directory) ?: [], ['.', '..']) === []) { - @rmdir($this->directory); - } + if (! is_array($evaluated) || $evaluatedFingerprint !== PhpDataFile::valueFingerprint($this->editableData)) { + throw new RuntimeException(sprintf( + '%s: the rewritten %s would not read back as the edited map; nothing was written.', + $this->mapId, + basename($target['dataPath']), + )); + } + }, + function () use ($target, $moving, $dataSource, $mapPayload, $eventPayload): void { + if ($this->layers->legacy && is_dir($target['directory'] . '/layers') + && array_diff(scandir($target['directory'] . '/layers') ?: [], ['.', '..']) === []) { + @rmdir($target['directory'] . '/layers'); + } - self::deleteEmptyParentDirectories(dirname($this->directory), $this->getMapsRoot()); - } + if ($moving) { + // The transaction removed the triplet; the folder follows only + // when nothing else of the author's lives in it. + if (is_dir($this->directory) && array_diff(scandir($this->directory) ?: [], ['.', '..']) === []) { + @rmdir($this->directory); + } - $this->adoptWritten($dataSource, $mapPayload, $eventPayload); + self::deleteEmptyParentDirectories(dirname($this->directory), $this->getMapsRoot()); + } - return $target['mapId']; + $this->adoptWritten($dataSource, $mapPayload, $eventPayload); + }, + ); } /** diff --git a/src/ProjectSettingsFile.php b/src/ProjectSettingsFile.php index d602a89..435475f 100644 --- a/src/ProjectSettingsFile.php +++ b/src/ProjectSettingsFile.php @@ -13,7 +13,8 @@ use Ichiloto\Editor\Database\ProjectRecord; use Ichiloto\Editor\Storage\FileSetOperations; use Ichiloto\Editor\Storage\FileSetTransaction; -use Ichiloto\Editor\Storage\FilesystemFileSetOperations; +use Ichiloto\Editor\Storage\PreparedSave; +use Ichiloto\Editor\Storage\SaveSet; use RuntimeException; use Throwable; @@ -234,32 +235,47 @@ private function composeSource(array $overrides = []): string /** Saves all pending settings together, never a second whole-value rewrite. */ public function save(?FileSetOperations $files = null): void { - if (! $this->isDirty()) { return; } + $prepared = $this->prepareSave(); + if ($prepared !== null) { + SaveSet::commitOne($this->projectRoot, $prepared, files: $files); + } + } + + /** + * Works out the settings save without writing it: the file with every + * pending setting composed into it, and the proof that the staged file + * holds each changed setting. Null when nothing is pending. + * + * @throws SourcePreservationRefusal When a setting cannot be written into the file's own source. + */ + public function prepareSave(): ?PreparedSave + { + if (! $this->isDirty()) { return null; } foreach ($this->records as $path => $record) { if ($record->isDirty() && $record->get('value') !== $this->originalValues[$path]) { $this->assertAcceptableValue($path, $record->get('value')); } } $source = $this->getSource(); - $transaction = new FileSetTransaction($this->projectRoot, $files ?? new FilesystemFileSetOperations()); - $transaction->write($this->path, $source); - try { - $staged = $transaction->stage(); - if ((string) file_get_contents($this->path) !== $this->persistedSource) { - throw new SourcePreservationRefusal($this->relativePath . ' changed on disk. Reload before saving; pending edits were not written.'); - } - $evaluated = PhpDataFile::evaluateIsolated($staged[$this->path], $this->projectRoot); - foreach ($this->records as $path => $record) { - if ($record->get('value') === $this->originalValues[$path]) { continue; } - if ($this->readValue(is_array($evaluated) ? $evaluated : [], $path) !== $record->get('value')) { - throw new SourcePreservationRefusal('The staged ' . $this->relativePath . ' does not preserve ' . $path . '.'); + + return new PreparedSave( + $this->relativePath, + fn(FileSetTransaction $transaction) => $transaction->write($this->path, $source), + function (array $staged): void { + if ((string) file_get_contents($this->path) !== $this->persistedSource) { + throw new SourcePreservationRefusal($this->relativePath . ' changed on disk. Reload before saving; pending edits were not written.'); } - } - $transaction->commit(); - } catch (Throwable $error) { - $transaction->rollBack(); - throw $error; - } - $this->persistedSource = $source; - foreach ($this->records as $record) { $record->markClean(); } + $evaluated = PhpDataFile::evaluateIsolated($staged[$this->path], $this->projectRoot); + foreach ($this->records as $path => $record) { + if ($record->get('value') === $this->originalValues[$path]) { continue; } + if ($this->readValue(is_array($evaluated) ? $evaluated : [], $path) !== $record->get('value')) { + throw new SourcePreservationRefusal('The staged ' . $this->relativePath . ' does not preserve ' . $path . '.'); + } + } + }, + function () use ($source): void { + $this->persistedSource = $source; + foreach ($this->records as $record) { $record->markClean(); } + }, + ); } private function createFieldEdit(?PhpArraySourceDocument $document, string $path, string $literal, bool $allowArray = false): array diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php index db8616f..5c2092e 100644 --- a/src/Session/EditorSession.php +++ b/src/Session/EditorSession.php @@ -91,6 +91,9 @@ use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Storage\FileSetTransactionFailure; +use Ichiloto\Editor\Storage\SaveSet; +use Ichiloto\Editor\Storage\SaveSetAdoptionFailure; +use Ichiloto\Editor\Storage\SaveSetRefusal; use Ichiloto\Editor\Storage\WorkspaceSave; use Ichiloto\Editor\Validation\MapGraphicsValidator; use Ichiloto\Editor\Validation\Issue; @@ -5442,15 +5445,26 @@ public function beginChanges(): array * set is refused when a map it changed has any error (a defect the player * would meet), whether the change set made it or found it there, or when * it leaves a new error anywhere else (a door on another map now leading - * nowhere). Warnings never refuse it. Its maps are saved through their - * own transactions, then any other unsaved change it made. + * nowhere). Warnings never refuse it. + * + * Everything it left unsaved -- databases, cutscenes, maps and the save + * relocations its maps need -- is then written as one transaction: every + * document works out its file and proves the staged bytes read back as + * what it holds before any file is installed, and a file that cannot be + * installed puts back the ones already installed. A document that refuses + * refuses the change set exactly as an error does: nothing is written, + * and the change set is undone. Should a document fail to take up what it + * wrote once every file is installed, the change set is saved and + * `reason` says which, to reload before editing further. * * @param bool $relocateSaves Whether saves standing where a changed map is now solid may move to open floor; * without it such a change set is refused, naming where they would move. * @param bool $checkOnly Whether to only report what committing would find, then undo the change set. - * @return array{status: 'saved'|'refused'|'checked', maps: list, saved: list, issues: list>, - * blocking: int, relocated: list, reason?: ?string, unrestored?: int} - * @throws SessionRefusal When no change set is open, or a save fails part way (the message names what was written). + * @return array{status: 'saved'|'refused'|'checked', maps: list, saved: list, written: array>, + * issues: list>, blocking: int, relocated: list, backupFailures: list, + * reason?: ?string, unrestored?: int, unrestoredFiles?: list} + * `written` names each document written and the files it wrote or removed, relative to the project root. + * @throws SessionRefusal When no change set is open. */ public function commitChanges(bool $relocateSaves = false, bool $checkOnly = false): array { @@ -5462,8 +5476,8 @@ public function commitChanges(bool $relocateSaves = false, bool $checkOnly = fal && (in_array($issue->map, $changed, true) || ! isset($before[self::identifyIssue($issue)])))); $reported = array_values(array_filter($issues, static fn(Issue $issue): bool => in_array($issue->map, $changed, true) || in_array($issue, $blocking, true))); - $outcome = ['maps' => $changed, 'saved' => [], 'issues' => array_map(static fn(Issue $issue): array => $issue->describe(), $reported), - 'blocking' => count($blocking), 'relocated' => []]; + $outcome = ['maps' => $changed, 'saved' => [], 'written' => [], 'issues' => array_map(static fn(Issue $issue): array => $issue->describe(), $reported), + 'blocking' => count($blocking), 'relocated' => [], 'backupFailures' => []]; $relocations = []; foreach ($changed as $mapId) { @@ -5485,34 +5499,39 @@ public function commitChanges(bool $relocateSaves = false, bool $checkOnly = fal ...($unrestored > 0 ? ['unrestored' => $unrestored] : [])]; } - $this->changeSet = null; - // What the maps name (a field resource, a cinematic, a common event) is written before the maps, by the - // same Save All the editors use; a map whose save moves saves is then saved on its own, its moves allowed. $unsaved = array_values(array_filter($changed, fn(string $mapId): bool => ($this->getMapsById()[$mapId] ?? null)?->isDirty() === true)); - $rest = $this->saveAll(); - if ($rest['failures'] !== []) { - throw new SessionRefusal('The change set was not fully saved: ' . implode('; ', $rest['failures'])); - } - foreach ($unsaved as $mapId) { - $map = $this->getMapsById()[$mapId] ?? null; - if ($map === null) { - continue; + $backupFailures = []; + $backup = function (string ...$paths) use (&$backupFailures): void { + if ($this->backups->isEnabled() && $paths !== []) { + $backupFailures = $this->backups->backup(...$paths)['failed']; } - if ($map->isDirty()) { - try { - $result = $this->saveMap($mapId); - if (($result['status'] ?? null) === 'question') { - $result = $this->saveMap($mapId, 'write', $result['confirm']); - $outcome['relocated'] = [...$outcome['relocated'], ...($result['relocated'] ?? [])]; - } - } catch (SessionRefusal $refusal) { - throw new SessionRefusal(sprintf('%s was not saved: %s Already saved: %s.', $mapId, $refusal->getMessage(), - implode(', ', $outcome['saved']) ?: 'every other change'), previous: $refusal); - } - } - $outcome['saved'][] = $mapId; + }; + + try { + $saves = WorkspaceSave::prepareAll($this->workspace, $relocateSaves ? fn(Command $step) => $this->history->record($step) : null); + $written = SaveSet::commit($this->workspace->projectRoot, $saves, $backup); + } catch (SaveSetAdoptionFailure $partial) { + // The files are written: the change set is, though the editor's view of it is not current. + $written = $partial->written; + $outcome['reason'] = $partial->getMessage(); + } catch (Throwable $refusal) { + $unrestored = $this->rollBackChanges($changes); + $files = $refusal instanceof SaveSetRefusal ? $refusal->unrestored : []; + + return ['status' => 'refused', ...$outcome, 'relocated' => $relocations, 'backupFailures' => array_values($backupFailures), + 'reason' => ($files === [] ? 'Nothing was written: ' : 'The change set was not written, and some files could not be put back: ') + . $refusal->getMessage(), + ...($unrestored > 0 ? ['unrestored' => $unrestored] : []), + ...($files === [] ? [] : ['unrestoredFiles' => array_map($this->describeProjectPath(...), $files)])]; } + $this->changeSet = null; + $outcome['saved'] = $unsaved; + $outcome['relocated'] = $relocateSaves ? $relocations : []; + $outcome['backupFailures'] = array_values($backupFailures); + $outcome['written'] = array_map(fn(array $paths): array => array_map($this->describeProjectPath(...), $paths), + array_filter($written, static fn(array $paths): bool => $paths !== [])); + return ['status' => 'saved', ...$outcome]; } @@ -5570,6 +5589,21 @@ private function rollBackChanges(array $changes): int return $unrestored; } + /** A file of the project as the project names it: relative to its root. */ + private function describeProjectPath(string $path): string + { + // A file found through the root's real path names it as well as one found through the root as opened. + foreach (array_unique([$this->workspace->projectRoot, realpath($this->workspace->projectRoot) ?: $this->workspace->projectRoot]) as $root) { + $root = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + + if (str_starts_with($path, $root)) { + return substr($path, strlen($root)); + } + } + + return $path; + } + /** An issue's identity, to tell an issue found before a change set from one it made. */ private static function identifyIssue(Issue $issue): string { diff --git a/src/Storage/FileSetTransaction.php b/src/Storage/FileSetTransaction.php index 437978e..3b996d4 100644 --- a/src/Storage/FileSetTransaction.php +++ b/src/Storage/FileSetTransaction.php @@ -97,6 +97,7 @@ public function __construct( */ public function write(string $path, string $contents): void { + $this->assertUndeclared($path); $this->targets[] = ['path' => $path, 'intent' => self::INTENT_WRITE, 'contents' => $contents]; } @@ -105,13 +106,38 @@ public function write(string $path, string $contents): void */ public function remove(string $path): void { + $this->assertUndeclared($path); $this->targets[] = ['path' => $path, 'intent' => self::INTENT_REMOVE, 'contents' => null]; } - /** Requires the source used to build this proposal to remain unchanged. */ + /** + * Requires the source used to build this proposal to remain unchanged. + * Two documents of one set that read the same file must have read the + * same bytes. + */ public function expectSource(string $path, ?string $contents, bool $allowMissing = false): void { - $this->expectedSources[$path] = ['contents' => $contents, 'allowMissing' => $allowMissing]; + $expected = ['contents' => $contents, 'allowMissing' => $allowMissing]; + + if (isset($this->expectedSources[$path]) && $this->expectedSources[$path]['contents'] !== $contents) { + throw new FileSetTransactionFailure(sprintf('%s was read twice with different contents', basename($path))); + } + + $this->expectedSources[$path] = $expected; + } + + /** Whether any source is required to remain unchanged. */ + public function hasExpectedSources(): bool + { + return $this->expectedSources !== []; + } + + /** A path is one target: two proposals for one file would race each other. */ + private function assertUndeclared(string $path): void + { + if (in_array($path, $this->paths(), true)) { + throw new FileSetTransactionFailure(sprintf('%s is written twice in one transaction', basename($path))); + } } /** Checks loaded bytes without staging, creating folders or adopting new baselines. */ diff --git a/src/Storage/PreparedSave.php b/src/Storage/PreparedSave.php new file mode 100644 index 0000000..43b4531 --- /dev/null +++ b/src/Storage/PreparedSave.php @@ -0,0 +1,92 @@ +): void)|null $verify Proves the staged bytes, given each written + * path's staged copy; it throws to refuse the whole set. + * @param (Closure(): void)|null $adopt Adopts the written state once every file of the set is installed. + */ + public function __construct( + public readonly string $label, + private readonly Closure $declare, + private readonly ?Closure $verify = null, + private readonly ?Closure $adopt = null, + ) { + } + + /** + * A save with nothing to write that still moves the document's baseline: + * an edit that came back to what the file holds. + * + * @param Closure(): void $adopt + */ + public static function adoptOnly(string $label, Closure $adopt): self + { + return new self($label, static function (): void { + }, adopt: $adopt); + } + + /** + * The same save, followed by more once its state is adopted: an owner + * forgetting what the save took away. + * + * @param Closure(): void $after + */ + public function withAdoption(Closure $after): self + { + $adopt = $this->adopt; + + return new self($this->label, $this->declare, $this->verify, static function () use ($adopt, $after): void { + if ($adopt !== null) { + $adopt(); + } + $after(); + }); + } + + /** Declares this document's files in a transaction. */ + public function declare(FileSetTransaction $transaction): void + { + ($this->declare)($transaction); + } + + /** + * Proves the staged bytes before anything is installed. + * + * @param array $staged Staged copies by destination path. + */ + public function verify(array $staged): void + { + if ($this->verify !== null) { + ($this->verify)($staged); + } + } + + /** Adopts the written state. */ + public function adopt(): void + { + if ($this->adopt !== null) { + ($this->adopt)(); + } + } +} diff --git a/src/Storage/SaveSet.php b/src/Storage/SaveSet.php new file mode 100644 index 0000000..360c4a5 --- /dev/null +++ b/src/Storage/SaveSet.php @@ -0,0 +1,113 @@ + $saves The prepared saves. + * @param callable(string ...$paths): void|null $backup Receives the existing files about to be replaced or removed. + * @return array> The paths each document wrote or removed, by its label; a document + * whose save only adopted what its files already held writes none. + * @throws SaveSetRefusal When a document refuses or a file cannot be installed; nothing is left written. + * @throws SaveSetAdoptionFailure When every file was written but a document could not take up its files. + */ + public static function commit(string $folder, array $saves, ?callable $backup = null, ?FileSetOperations $files = null, bool $reserveFolder = false): array + { + $transaction = new FileSetTransaction($folder, $files ?? new FilesystemFileSetOperations(), $reserveFolder); + $written = []; + + foreach ($saves as $save) { + $before = count($transaction->paths()); + + try { + $save->declare($transaction); + } catch (Throwable $failure) { + throw new SaveSetRefusal($save->label, $failure); + } + + $written[$save->label] = [...$written[$save->label] ?? [], ...array_slice($transaction->paths(), $before)]; + } + + if (! $transaction->isEmpty() || $transaction->hasExpectedSources()) { + try { + $staged = $transaction->stage(); + } catch (Throwable $failure) { + throw new SaveSetRefusal(null, $failure); + } + + foreach ($saves as $save) { + try { + $save->verify($staged); + } catch (Throwable $failure) { + $transaction->rollBack(); + + throw new SaveSetRefusal($save->label, $failure); + } + } + + try { + $transaction->commit($backup); + } catch (Throwable $failure) { + throw new SaveSetRefusal(null, $failure); + } + } + + // Every file is on disk: each document takes up what it wrote, and + // one that cannot does not keep the others from it. + $failures = []; + + foreach ($saves as $save) { + try { + $save->adopt(); + } catch (Throwable $failure) { + $failures[$save->label] = $failure; + } + } + + if ($failures !== []) { + throw new SaveSetAdoptionFailure($written, $failures); + } + + return $written; + } + + /** + * Writes one document's prepared save alone, refusing as the document + * itself refuses. + * + * @param callable(string ...$paths): void|null $backup + * @return list Every path written or removed. + * @throws Throwable Whatever the document or the transaction refused with. + */ + public static function commitOne(string $folder, PreparedSave $save, ?callable $backup = null, ?FileSetOperations $files = null, bool $reserveFolder = false): array + { + try { + return array_merge(...array_values(self::commit($folder, [$save], $backup, $files, $reserveFolder))); + } catch (SaveSetRefusal $refusal) { + throw $refusal->getPrevious() ?? $refusal; + } + } +} diff --git a/src/Storage/SaveSetAdoptionFailure.php b/src/Storage/SaveSetAdoptionFailure.php new file mode 100644 index 0000000..24fc655 --- /dev/null +++ b/src/Storage/SaveSetAdoptionFailure.php @@ -0,0 +1,31 @@ +> $written The paths each document wrote or removed, by its label. + * @param array $failures Why each document could not take up its files, by its label. + */ + public function __construct(public readonly array $written, public readonly array $failures) + { + parent::__construct(sprintf( + 'Every file was written, but %s could not take up what was written (%s); reload the project before editing further.', + implode(', ', array_keys($failures)), + implode('; ', array_map(static fn(Throwable $failure): string => rtrim($failure->getMessage(), '.'), $failures)), + ), 0, array_values($failures)[0] ?? null); + } +} diff --git a/src/Storage/SaveSetRefusal.php b/src/Storage/SaveSetRefusal.php new file mode 100644 index 0000000..abbbfe7 --- /dev/null +++ b/src/Storage/SaveSetRefusal.php @@ -0,0 +1,34 @@ + Paths left in an unknown state when a rollback itself failed. */ + public readonly array $unrestored; + + /** + * @param string|null $label The document that refused, or null when installing the set failed. + */ + public function __construct(public readonly ?string $label, Throwable $previous) + { + $this->wasRolledBack = ! $previous instanceof FileSetTransactionFailure || $previous->wasRolledBack; + $this->unrestored = $previous instanceof FileSetTransactionFailure ? array_values($previous->unrestoredPaths) : []; + + parent::__construct($label === null ? $previous->getMessage() : sprintf('%s: %s', $label, $previous->getMessage()), 0, $previous); + } +} diff --git a/src/Storage/SourceSetPlan.php b/src/Storage/SourceSetPlan.php index 3fb2710..342ab28 100644 --- a/src/Storage/SourceSetPlan.php +++ b/src/Storage/SourceSetPlan.php @@ -77,6 +77,48 @@ public function apply(): array return $this->getChangedPaths(); } + /** + * Works out applying the plan without writing it, for {@see SaveSet} to + * install with the rest of a change set: every proposed file, every + * source it read required unchanged, and each staged file proven to read + * back as planned. Once installed, the plan counts as applied, so + * {@see revert()} undoes it. + */ + public function prepareApply(string $label): PreparedSave + { + if ($this->applied) { + throw new RuntimeException("The {$this->subject} is already applied."); + } + $this->assertSourcesUnchanged(); + + return new PreparedSave( + $label, + function (FileSetTransaction $transaction): void { + foreach ($this->watched as $path => $source) { + $transaction->expectSource($path, $source); + } + foreach ($this->proposals as $path => $source) { + if ($this->originals[$path] === null) { + // A file the plan creates must not exist until it does. + $transaction->expectSource($path, null); + } + $transaction->write($path, $source); + } + }, + function (array $staged): void { + foreach ($this->proposals as $path => $source) { + $value = ($this->readBack)($path, $staged[$path]); + if (serialize($value) !== serialize($this->afterValues[$path])) { + throw new RuntimeException("{$path} would not read back as the planned {$this->subject}; nothing was written."); + } + } + }, + function (): void { + $this->applied = true; + }, + ); + } + public function revert(): void { if ($this->applied) { diff --git a/src/Storage/WorkspaceSave.php b/src/Storage/WorkspaceSave.php index f6bf040..23d0672 100644 --- a/src/Storage/WorkspaceSave.php +++ b/src/Storage/WorkspaceSave.php @@ -4,13 +4,19 @@ namespace Ichiloto\Editor\Storage; +use Closure; use Ichiloto\Editor\Backup\BackupWriter; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\SharedFileTransaction; +use Ichiloto\Editor\History\Command; use Ichiloto\Editor\Maps\SaveRelocationPlanner; +use Ichiloto\Editor\ProjectActorDatabase; +use Ichiloto\Editor\ProjectSettingsFile; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Validation\MapValidator; use Ichiloto\Editor\Validation\Severity; +use LogicException; +use RuntimeException; use Throwable; /** @@ -19,9 +25,80 @@ * on. One document's failure never stops the others. A map whose save would * move its folder is skipped, because that move needs its own confirmation * on the map. Both editors' Save All runs this. + * + * A change set is the opposite case: everything it changed is written + * together or not at all. {@see prepareAll()} works out every document's + * save first, and {@see SaveSet} installs them as one transaction. */ final class WorkspaceSave { + /** + * Works out the save of every unsaved document of a workspace, writing + * nothing, for a change set to install as one {@see SaveSet}: its + * databases, then its cutscenes, then its maps, each map after the save + * relocations it needs. What the maps name is declared before them, as + * Save All writes it. + * + * @param (Closure(Command): void)|null $recordRelocation Records the undo step of a map's save relocations once + * they are installed; null refuses a map whose save would move existing saves. + * @return list + * @throws RuntimeException When a document cannot take its edits, a map would move its folder, or a map needs + * save relocations that were not allowed. Nothing is written. + */ + public static function prepareAll(ProjectWorkspace $workspace, ?Closure $recordRelocation = null): array + { + $saves = []; + + foreach (SharedFileTransaction::groupByPath($workspace->listSaveableDatabases()) as $group) { + $dirty = array_filter($group, static fn(object $database): bool => $database->isDirty()); + + if ($dirty === []) { + continue; + } + + $shared = reset($dirty); + + if ($shared instanceof ProjectRecordDatabase && $shared->sharesBackingFile()) { + $saves[] = SharedFileTransaction::prepare(array_values($dirty)); + continue; + } + + foreach ($dirty as $database) { + $saves = [...$saves, ...match (true) { + $database instanceof ProjectActorDatabase => $database->prepareSaves(), + $database instanceof ProjectRecordDatabase, $database instanceof ProjectSettingsFile => [$database->prepareSave()], + default => throw new LogicException(sprintf('%s cannot be saved with a change set.', get_debug_type($database))), + }]; + } + } + + $saves = [...$saves, ...($workspace->cutscenes?->prepareSaves() ?? [])]; + + foreach ($workspace->maps as $map) { + if (! $map->isDirty()) { + continue; + } + + if ($map->willMoveOnSave()) { + throw new RuntimeException(sprintf('%s would move its folder when saved; save it on its own.', $map->mapId)); + } + + $relocation = SaveRelocationPlanner::plan($map, $workspace->projectRoot); + + if ($relocation !== null) { + if ($recordRelocation === null) { + throw new RuntimeException(sprintf('Saving %s moves existing saves; allow the relocations first.', $map->mapId)); + } + + $saves[] = $relocation->prepareApply($recordRelocation); + } + + $saves[] = $map->prepareSave(); + } + + return array_values(array_filter($saves)); + } + public static function saveAll(ProjectWorkspace $workspace, BackupWriter $backups): WorkspaceSaveResult { $backupFailures = []; diff --git a/tests/Unit/ProjectIntegrityMatrixTest.php b/tests/Unit/ProjectIntegrityMatrixTest.php index a89e4a4..94f2ea6 100644 --- a/tests/Unit/ProjectIntegrityMatrixTest.php +++ b/tests/Unit/ProjectIntegrityMatrixTest.php @@ -332,7 +332,7 @@ function integrityEditor(string $root): Editor $database->save(); } catch (Throwable $refusal) { // A refused save is allowed; a half-written file is not. - expect($refusal->getMessage())->toContain('Unable to write temporary file'); + expect($refusal->getMessage())->toContain('Unable to write a temporary file'); } expect((string) file_get_contents($path))->toBe($before); diff --git a/tests/Unit/SaveSetTest.php b/tests/Unit/SaveSetTest.php new file mode 100644 index 0000000..cf6b507 --- /dev/null +++ b/tests/Unit/SaveSetTest.php @@ -0,0 +1,141 @@ +} */ +function saveSetFolder(): array +{ + $folder = sys_get_temp_dir() . '/ichiloto-save-set-' . bin2hex(random_bytes(4)); + mkdir($folder); + file_put_contents($folder . '/a.txt', "a\n"); + file_put_contents($folder . '/b.txt', "b\n"); + + return [$folder, ['a' => $folder . '/a.txt', 'b' => $folder . '/b.txt', 'c' => $folder . '/c.txt']]; +} + +/** + * A document writing one file, noting when it verifies and adopts. + * + * @param list $log + */ +function saveSetDocument(string $label, string $path, string $contents, array &$log, ?string $refusal = null): PreparedSave +{ + return new PreparedSave( + $label, + static fn(FileSetTransaction $transaction) => $transaction->write($path, $contents), + static function (array $staged) use ($label, $path, $contents, $refusal, &$log): void { + $log[] = 'verify ' . $label; + expect(file_get_contents($staged[$path]))->toBe($contents); + if ($refusal !== null) { + throw new RuntimeException($refusal); + } + }, + static function () use ($label, $path, $contents, &$log): void { + // Every document of the set is on disk before any adopts. + expect(file_get_contents($path))->toBe($contents); + $log[] = 'adopt ' . $label; + }, + ); +} + +it('installs every document of a set, verifying all before any file and adopting each once all are written', function () { + [$folder, $paths] = saveSetFolder(); + $log = []; + + $written = SaveSet::commit($folder, [ + saveSetDocument('first', $paths['a'], "A\n", $log), + saveSetDocument('second', $paths['c'], "C\n", $log), + PreparedSave::adoptOnly('unchanged', static function () use (&$log): void { + $log[] = 'adopt unchanged'; + }), + ]); + + expect($written)->toBe(['first' => [$paths['a']], 'second' => [$paths['c']], 'unchanged' => []]) + ->and($log)->toBe(['verify first', 'verify second', 'adopt first', 'adopt second', 'adopt unchanged']) + ->and(file_get_contents($paths['a']))->toBe("A\n") + ->and(file_get_contents($paths['c']))->toBe("C\n"); +}); + +it('writes nothing when the last document\'s staged bytes do not read back, and names it', function () { + [$folder, $paths] = saveSetFolder(); + $log = []; + $before = sourceHashTree($folder); + + expect(function () use ($folder, $paths, &$log): void { + SaveSet::commit($folder, [ + saveSetDocument('first', $paths['a'], "A\n", $log), + saveSetDocument('last', $paths['b'], "B\n", $log, 'b.txt would not read back as the edited document'), + ]); + })->toThrow(SaveSetRefusal::class, 'last: b.txt would not read back as the edited document') + ->and(sourceHashTree($folder))->toBe($before) + ->and($log)->toBe(['verify first', 'verify last']); +}); + +it('puts back every file already installed when a later one cannot be, and adopts nothing', function () { + [$folder, $paths] = saveSetFolder(); + $log = []; + $before = sourceHashTree($folder); + $files = new FailingFileSetOperations(failures: ['move' => [$paths['b']]]); + + try { + SaveSet::commit($folder, [ + saveSetDocument('first', $paths['a'], "A\n", $log), + saveSetDocument('second', $paths['c'], "C\n", $log), + saveSetDocument('third', $paths['b'], "B\n", $log), + ], files: $files); + $refusal = null; + } catch (SaveSetRefusal $caught) { + $refusal = $caught; + } + + expect($refusal)->toBeInstanceOf(SaveSetRefusal::class) + ->and($refusal?->wasRolledBack)->toBeTrue() + ->and($refusal?->unrestored)->toBe([]) + ->and($refusal?->getMessage())->toContain('Unable to replace b.txt') + ->and(sourceHashTree($folder))->toBe($before) + ->and(array_filter($log, static fn(string $entry): bool => str_starts_with($entry, 'adopt')))->toBe([]); +}); + +it('refuses two documents writing one file before anything is staged', function () { + [$folder, $paths] = saveSetFolder(); + $log = []; + $before = sourceHashTree($folder); + + expect(function () use ($folder, $paths, &$log): void { + SaveSet::commit($folder, [ + saveSetDocument('first', $paths['a'], "A\n", $log), + saveSetDocument('second', $paths['a'], "A2\n", $log), + ]); + })->toThrow(SaveSetRefusal::class, 'second: a.txt is written twice in one transaction') + ->and(sourceHashTree($folder))->toBe($before) + ->and($log)->toBe([]); +}); + +it('says every file was written when a document cannot take up its own, still adopting the rest', function () { + [$folder, $paths] = saveSetFolder(); + $log = []; + $stubborn = new PreparedSave('stubborn', static fn(FileSetTransaction $transaction) => $transaction->write($paths['c'], "C\n"), + adopt: static fn() => throw new RuntimeException('its index could not be reread')); + + try { + SaveSet::commit($folder, [$stubborn, saveSetDocument('other', $paths['a'], "A\n", $log)]); + $failure = null; + } catch (Ichiloto\Editor\Storage\SaveSetAdoptionFailure $caught) { + $failure = $caught; + } + + expect($failure?->written)->toBe(['stubborn' => [$paths['c']], 'other' => [$paths['a']]]) + ->and($failure?->getMessage())->toStartWith('Every file was written, but stubborn could not take up what was written (its index could not be reread)') + ->and($log)->toBe(['verify other', 'adopt other']) + ->and(file_get_contents($paths['c']))->toBe("C\n"); +}); diff --git a/tests/Unit/Session/AtomicChangeSetTest.php b/tests/Unit/Session/AtomicChangeSetTest.php index 82e56fc..f361bd6 100644 --- a/tests/Unit/Session/AtomicChangeSetTest.php +++ b/tests/Unit/Session/AtomicChangeSetTest.php @@ -129,3 +129,72 @@ function applyAtomicRebuild(EditorSession $session): void ->and($data['landmarks'][0])->toMatchArray(['id' => 'lamp-trial', 'anchor' => ['event' => 'E']]) ->and(json_encode($data['landmarks'][0]['name']))->toContain('Lamp Trial'); }); + +it('names every document a change set wrote, and the files each wrote', function () { + $root = atomicSceneProject(); + $session = EditorSession::open($root); + $session->beginChanges(); + applyAtomicRebuild($session); + + $outcome = $session->commitChanges(); + + expect($outcome['status'])->toBe('saved', json_encode($outcome['issues'])) + ->and($outcome['saved'])->toBe(['harbour']) + ->and($outcome['written'])->toBe([ + 'common_events' => ['assets/Events/lamp-duplicate.php'], + 'field_resources' => ['assets/Data/Presentation/field.php'], + 'cinematic harbour-lanterns' => ['assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'], + 'map harbour' => ['assets/Maps/harbour/harbour.data.php', 'assets/Maps/harbour/harbour.event.php'], + ]) + ->and($session->listUnsavedChanges())->toBe([]); +}); + +it('writes nothing when the last document of a change set would not read back, and says so', function () { + $root = atomicSceneProject(); + // A value the map's file makes anew each time it is read: the staged + // data can never read back as the map the editor holds. + $mapPath = $root . '/assets/Maps/harbour/harbour.data.php'; + file_put_contents($mapPath, str_replace("return array (", "return array (\n 'readAt' => hrtime(true),", (string) file_get_contents($mapPath))); + $before = sourceHashTree($root . '/assets'); + $session = EditorSession::open($root); + $session->beginChanges(); + applyAtomicRebuild($session); + + $outcome = $session->commitChanges(); + + expect($outcome['status'])->toBe('refused') + ->and($outcome['blocking'])->toBe(0) + ->and($outcome['reason'])->toStartWith('Nothing was written: map harbour: ') + ->and($outcome['reason'])->toContain('would not read back as the edited map') + ->and($outcome['saved'])->toBe([]) + ->and($outcome['written'])->toBe([]) + ->and(sourceHashTree($root . '/assets'))->toBe($before) + ->and($session->listUnsavedChanges())->toBe([]); +}); + +it('writes a change set\'s documents and the save relocations its map needs together, and undo removes the step', function () { + $root = atomicSceneProject(); + // The quay is where the game starts, so saves may stand on its floor. + file_put_contents($root . '/assets/Maps/collisions.php', " CollisionType::SOLID, '=' => CollisionType::SOLID, ' ' => CollisionType::NONE];\n"); + file_put_contents($root . '/assets/Data/system.php', " ['player' => " + . "['destinationMap' => 'harbour', 'spawnPoint' => ['x' => 5, 'y' => 3], 'spawnSprite' => ['South']]]];\n"); + $manifestPath = $root . '/assets/Data/save-compatibility.php'; + $manifest = (string) file_get_contents($manifestPath); + $session = EditorSession::open($root); + $session->beginChanges(); + applyAtomicRebuild($session); + $map = $session->readMap('harbour'); + $session->paint('harbour', $map['revision'], $map['baseLayer'], [[6, 4]], '#'); + + $outcome = $session->commitChanges(relocateSaves: true); + + expect($outcome)->toMatchArray(['status' => 'saved', 'saved' => ['harbour'], 'relocated' => ['x 6, y 4 to x 6, y 5']]) + ->and(array_keys($outcome['written']))->toBe(['common_events', 'field_resources', 'cinematic harbour-lanterns', 'save relocations for harbour', 'map harbour']) + ->and((require $manifestPath)['migrations'])->toBe([['from' => 0, 'to' => 1, + 'relocations' => [['map' => 'harbour', 'cells' => [[6, 4]], 'to' => [6, 5]]]]]) + ->and($session->listUnsavedChanges())->toBe([]); + + $session->undo(); + expect(file_get_contents($manifestPath))->toBe($manifest); +});